From 65aac3c9656d6a092eb7e766fb90232bd68221a0 Mon Sep 17 00:00:00 2001 From: Shlomo <78599753+ShlomoCode@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:14:08 +0300 Subject: [PATCH] feat(macos): add sandboxed launch at login for App Store version (#1793) --- app/lib/config/init.dart | 4 ++ app/lib/pages/tabs/settings_tab.dart | 6 +-- app/lib/util/native/autostart_helper.dart | 42 +++---------------- app/lib/util/native/macos_channel.dart | 40 ++++++++++++++++++ app/macos/Runner.xcodeproj/project.pbxproj | 37 ++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 11 ++++- app/macos/Runner/AppDelegate.swift | 28 +++++++++++++ 7 files changed, 126 insertions(+), 42 deletions(-) diff --git a/app/lib/config/init.dart b/app/lib/config/init.dart index 116f3c55..e301e94f 100644 --- a/app/lib/config/init.dart +++ b/app/lib/config/init.dart @@ -105,7 +105,11 @@ Future preInit(List args) async { if (args.contains(startHiddenFlag)) { // keep this app hidden startHidden = true; + } else if (defaultTargetPlatform == TargetPlatform.macOS) { + startHidden = await isLaunchedAsLoginItem() && await getLaunchAtLoginMinimized(); + } + if (startHidden) { unawaited(hideToTray()); } else { await WindowManager.instance.show(); diff --git a/app/lib/pages/tabs/settings_tab.dart b/app/lib/pages/tabs/settings_tab.dart index a3484300..176d8017 100644 --- a/app/lib/pages/tabs/settings_tab.dart +++ b/app/lib/pages/tabs/settings_tab.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:common/constants.dart'; import 'package:common/model/device.dart'; import 'package:flutter/foundation.dart'; @@ -30,8 +28,6 @@ import 'package:refena_flutter/refena_flutter.dart'; import 'package:routerino/routerino.dart'; import 'package:url_launcher/url_launcher.dart'; -final _isMacOSSandboxed = defaultTargetPlatform == TargetPlatform.macOS && Platform.environment['APP_SANDBOX_CONTAINER_ID'] != null; - class SettingsTab extends StatelessWidget { const SettingsTab(); @@ -106,7 +102,7 @@ class SettingsTab extends StatelessWidget { }, ), ], - if (checkPlatformIsDesktop() && !_isMacOSSandboxed) ...[ + if (checkPlatformIsDesktop()) ...[ _BooleanEntry( label: t.settingsTab.general.launchAtStartup, value: vm.autoStart, diff --git a/app/lib/util/native/autostart_helper.dart b/app/lib/util/native/autostart_helper.dart index 696e653d..fb1c1f50 100644 --- a/app/lib/util/native/autostart_helper.dart +++ b/app/lib/util/native/autostart_helper.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:localsend_app/util/native/macos_channel.dart'; import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:win32_registry/win32_registry.dart'; @@ -30,31 +31,8 @@ Terminal=false file.writeAsStringSync(contents); return true; case TargetPlatform.macOS: - final file = File(_getMacOSFilePath(packageInfo.packageName)); - file.writeAsStringSync(''' - - - - Label - ${packageInfo.packageName} - AssociatedBundleIdentifiers - ${packageInfo.packageName} - ProgramArguments - - ${Platform.executable} - ${startHidden ? '$startHiddenFlag' : ''} - - RunAtLoad - - ProcessType - Interactive - StandardErrorPath - /dev/null - StandardOutPath - /dev/null - - - '''); + await setLaunchAtLogin(true); + await setLaunchAtLoginMinimized(startHidden); return true; case TargetPlatform.windows: _getWindowsRegistryKey().createValue(RegistryValue( @@ -80,7 +58,7 @@ Future disableAutoStart() async { File(_getLinuxFilePath(packageInfo.packageName)).deleteSync(); break; case TargetPlatform.macOS: - File(_getMacOSFilePath(packageInfo.packageName)).deleteSync(); + await setLaunchAtLogin(false); break; case TargetPlatform.windows: _getWindowsRegistryKey().deleteValue(_windowsRegistryKeyValue); @@ -101,7 +79,7 @@ Future isAutoStartEnabled() async { case TargetPlatform.linux: return File(_getLinuxFilePath(packageInfo.packageName)).existsSync(); case TargetPlatform.macOS: - return File(_getMacOSFilePath(packageInfo.packageName)).existsSync(); + return await getLaunchAtLogin(); case TargetPlatform.windows: return _getWindowsRegistryKey().getValueAsString(_windowsRegistryKeyValue)?.contains(Platform.resolvedExecutable) ?? false; default: @@ -119,11 +97,7 @@ Future isAutoStartHidden() async { } return file.readAsStringSync().contains(startHiddenFlag); case TargetPlatform.macOS: - final file = File(_getMacOSFilePath(packageInfo.packageName)); - if (!file.existsSync()) { - return false; - } - return file.readAsStringSync().contains(startHiddenFlag); + return await getLaunchAtLoginMinimized(); case TargetPlatform.windows: return _getWindowsRegistryKey().getValueAsString(_windowsRegistryKeyValue)?.contains(startHiddenFlag) ?? false; default: @@ -144,7 +118,3 @@ RegistryKey _getWindowsRegistryKey() { String _getLinuxFilePath(String appName) { return '${Platform.environment['HOME']}/.config/autostart/$appName.desktop'; } - -String _getMacOSFilePath(String appName) { - return '${Platform.environment['HOME']}/Library/LaunchAgents/$appName.plist'; -} diff --git a/app/lib/util/native/macos_channel.dart b/app/lib/util/native/macos_channel.dart index 28baeb30..998fa473 100644 --- a/app/lib/util/native/macos_channel.dart +++ b/app/lib/util/native/macos_channel.dart @@ -27,6 +27,46 @@ Future updateDockProgress(double progress) async { await _methodChannel.invokeMethod('updateDockProgress', progress); } +Future setLaunchAtLogin(bool value) async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return; + } + + await _methodChannel.invokeMethod('setLaunchAtLogin', value); +} + +Future getLaunchAtLogin() async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return false; + } + + return await _methodChannel.invokeMethod('getLaunchAtLogin'); +} + +Future setLaunchAtLoginMinimized(bool value) async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return; + } + + await _methodChannel.invokeMethod('setLaunchAtLoginMinimized', value); +} + +Future getLaunchAtLoginMinimized() async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return false; + } + + return await _methodChannel.invokeMethod('getLaunchAtLoginMinimized'); +} + +Future isLaunchedAsLoginItem() async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return false; + } + + return await _methodChannel.invokeMethod('isLaunchedAsLoginItem'); +} + Future setDockIcon(TaskbarIcon icon) async { if (defaultTargetPlatform != TargetPlatform.macOS) { return; diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj index 23f6e84c..f69500a6 100644 --- a/app/macos/Runner.xcodeproj/project.pbxproj +++ b/app/macos/Runner.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 0543E87B2C8A93A9004AA8B7 /* ContentDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0543E87A2C8A93A9004AA8B7 /* ContentDropView.swift */; }; 05BAA8D02C8CE248003CFCF1 /* SecurityScopedResourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BAA8CF2C8CE248003CFCF1 /* SecurityScopedResourceManager.swift */; }; 05CD1E642C98F2220044F24D /* DockProgress in Frameworks */ = {isa = PBXBuildFile; productRef = 05CD1E632C98F2220044F24D /* DockProgress */; }; + 05F86B372C9B80BD000EB4BA /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 05F86B362C9B80BD000EB4BA /* LaunchAtLogin */; }; 0F663CE30FCD07225E44A808 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2F8CCFF0FAB783405F067709 /* Pods_Runner.framework */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; @@ -123,6 +124,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 05F86B372C9B80BD000EB4BA /* LaunchAtLogin in Frameworks */, 05CD1E642C98F2220044F24D /* DockProgress in Frameworks */, 0F663CE30FCD07225E44A808 /* Pods_Runner.framework in Frameworks */, ); @@ -261,6 +263,7 @@ 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, + 05F86B382C9B8118000EB4BA /* Copy “Launch at Login Helper” */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 2DA7D291A0AEEC0A0C7CE5C0 /* [CP] Embed Pods Frameworks */, @@ -275,6 +278,7 @@ name = Runner; packageProductDependencies = ( 05CD1E632C98F2220044F24D /* DockProgress */, + 05F86B362C9B80BD000EB4BA /* LaunchAtLogin */, ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* LocalSend.app */; @@ -321,6 +325,7 @@ mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( 05CD1E622C98F2220044F24D /* XCRemoteSwiftPackageReference "DockProgress" */, + 05F86B352C9B80BD000EB4BA /* XCRemoteSwiftPackageReference "LaunchAtLogin-Legacy" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; @@ -354,6 +359,25 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 05F86B382C9B8118000EB4BA /* Copy “Launch at Login Helper” */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy “Launch at Login Helper”"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/Resources/copy-helper-swiftpm.sh\"\n"; + }; 2DA7D291A0AEEC0A0C7CE5C0 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -935,6 +959,14 @@ minimumVersion = 4.3.1; }; }; + 05F86B352C9B80BD000EB4BA /* XCRemoteSwiftPackageReference "LaunchAtLogin-Legacy" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Legacy"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -943,6 +975,11 @@ package = 05CD1E622C98F2220044F24D /* XCRemoteSwiftPackageReference "DockProgress" */; productName = DockProgress; }; + 05F86B362C9B80BD000EB4BA /* LaunchAtLogin */ = { + isa = XCSwiftPackageProductDependency; + package = 05F86B352C9B80BD000EB4BA /* XCRemoteSwiftPackageReference "LaunchAtLogin-Legacy" */; + productName = LaunchAtLogin; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; diff --git a/app/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 88ce0843..b94c2df3 100644 --- a/app/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/app/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3e0046e1b0518dc180bd8a7c98fa476d9af079586f505a34aa5b924f90460bda", + "originHash" : "72ae1b64a4d55b124e5d523ea3b7e7432749772550fc8b66a0f17a2caa5d4dc1", "pins" : [ { "identity" : "dockprogress", @@ -9,6 +9,15 @@ "revision" : "d4f23b5a8f5ca0fac393eb7ba78c2fe3e32e52da", "version" : "4.3.1" } + }, + { + "identity" : "launchatlogin-legacy", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/LaunchAtLogin-Legacy", + "state" : { + "revision" : "9a894d799269cb591037f9f9cb0961510d4dca81", + "version" : "5.0.2" + } } ], "version" : 3 diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift index 1b6ee0cb..495377b1 100644 --- a/app/macos/Runner/AppDelegate.swift +++ b/app/macos/Runner/AppDelegate.swift @@ -2,6 +2,7 @@ import Cocoa import FlutterMacOS import Defaults import DockProgress +import LaunchAtLogin enum DockIcon: CaseIterable { case regular @@ -15,6 +16,7 @@ class AppDelegate: FlutterAppDelegate { private var channel: FlutterMethodChannel? private var pendingFilesObservation: Defaults.Observation? private var pendingStringsObservation: Defaults.Observation? + private var isLaunchedAsLoginItem: Bool? override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { // LocalSend handles the close event manually @@ -30,6 +32,12 @@ class AppDelegate: FlutterAppDelegate { let localsendBrandColor = NSColor(red: 0, green: 0.392, blue: 0.353, alpha: 0.8) // #00645a DockProgress.style = .squircle(color: localsendBrandColor) + + // source: https://stackoverflow.com/a/19890943 + // NOTE: this check must be in applicationDidFinishLaunching, otherwise the `NSAppleEventManager.shared().currentAppleEvent` will be nil + if let event = NSAppleEventManager.shared().currentAppleEvent { + isLaunchedAsLoginItem = (event.eventID == kAEOpenApplication) && (event.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem) + } } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { @@ -155,6 +163,26 @@ class AppDelegate: FlutterAppDelegate { let newIconIndex = call.arguments as! Int let newIcon = DockIcon.allCases[newIconIndex] setDockIcon(icon: newIcon) + case "getLaunchAtLogin": + result(LaunchAtLogin.isEnabled) + case "setLaunchAtLogin": + if let launchAtLogin = call.arguments as? Bool { + LaunchAtLogin.isEnabled = launchAtLogin + result(nil) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a boolean value", details: nil)) + } + case "getLaunchAtLoginMinimized": + result(UserDefaults.standard.bool(forKey: "launchAtLoginMinimized")) + case "setLaunchAtLoginMinimized": + if let launchAtLoginMinimized = call.arguments as? Bool { + UserDefaults.standard.set(launchAtLoginMinimized, forKey: "launchAtLoginMinimized") + result(nil) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Expected a boolean value", details: nil)) + } + case "isLaunchedAsLoginItem": + result(isLaunchedAsLoginItem) default: result(FlutterMethodNotImplemented) }