From 41de48789e70f44785ca721c639812d8e86402fe Mon Sep 17 00:00:00 2001 From: Davide Date: Tue, 29 Oct 2024 11:40:11 +0100 Subject: [PATCH] Show in Mac status bar plus Login Item (#773) Add a status menu via SwiftUI MenuBarExtra where to: - Show/hide app - Launch on login via "Login Item" target - Toggle profiles on/off Only weird that the login item is not added to the list of "Open at Login", but to "Allow in the Background", see https://github.com/pilotmoon/Scroll-Reverser/issues/165 Requires some refactoring to bring AppContext initialization to the AppDelegate. Fixes #617 Fixes #482 Fixes #696 Fixes #505 --- Passepartout.xcodeproj/project.pbxproj | 152 +++++++++++++++++- .../xcschemes/PassepartoutLoginItem.xcscheme | 78 +++++++++ Passepartout/App/App.plist | 10 +- Passepartout/App/AppDelegate.swift | 31 +++- .../App/Assets.xcassets/Menu/Contents.json | 6 + .../Menu/MenuActive.imageset/Contents.json | 20 +++ .../MenuActive.imageset/StatusActive@2x.png | Bin 0 -> 817 bytes .../Menu/MenuInactive.imageset/Contents.json | 20 +++ .../StatusInactive@2x.png | Bin 0 -> 743 bytes .../Menu/MenuPending.imageset/Contents.json | 20 +++ .../MenuPending.imageset/StatusPending@2x.png | Bin 0 -> 1276 bytes Passepartout/App/PassepartoutApp.swift | 22 +-- Passepartout/App/iOS/AppDelegate+iOS.swift | 6 +- .../App/macOS/AppDelegate+macOS.swift | 23 +-- Passepartout/Config.xcconfig | 1 + .../Sources/AppUI/L10n/SwiftGen+Strings.swift | 14 ++ .../Resources/en.lproj/Localizable.strings | 7 + .../AppUI/Views/AppMenu/AppMenu+Model.swift | 73 +++++++++ .../Sources/AppUI/Views/AppMenu/AppMenu.swift | 113 +++++++++++++ .../AppUI/Views/AppMenu/AppMenuImage.swift | 60 +++++++ .../AppUI/Views/AppMenu/AppWindow.swift | 64 ++++++++ .../Extensions/TunnelContextProviding.swift | 4 +- .../Views/Theme/Theme+MenuImageName.swift | 46 ++++++ .../Sources/AppUI/Views/Theme/Theme+UI.swift | 16 ++ .../Sources/AppUI/Views/Theme/Theme.swift | 2 + .../UI/TunnelContextProviding+Theme.swift | 2 +- .../AppUI/Views/UI/View+Environment.swift | 3 +- .../Domain/BundleConfiguration+Main.swift | 2 + Passepartout/LoginItem/AppDelegate.swift | 95 +++++++++++ Passepartout/LoginItem/LoginItem.entitlements | 10 ++ .../LoginItem/PassepartoutLoginItemApp.swift | 37 +++++ 31 files changed, 902 insertions(+), 35 deletions(-) create mode 100644 Passepartout.xcodeproj/xcshareddata/xcschemes/PassepartoutLoginItem.xcscheme create mode 100644 Passepartout/App/Assets.xcassets/Menu/Contents.json create mode 100644 Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/Contents.json create mode 100644 Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/StatusActive@2x.png create mode 100644 Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/Contents.json create mode 100644 Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/StatusInactive@2x.png create mode 100644 Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/Contents.json create mode 100644 Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/StatusPending@2x.png create mode 100644 Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu+Model.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenuImage.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/AppMenu/AppWindow.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Theme/Theme+MenuImageName.swift create mode 100644 Passepartout/LoginItem/AppDelegate.swift create mode 100644 Passepartout/LoginItem/LoginItem.entitlements create mode 100644 Passepartout/LoginItem/PassepartoutLoginItemApp.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index e52a2ec1..1ac0492a 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; }; + 0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */ = {isa = PBXBuildFile; fileRef = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 0E757F232CD0D2BD006E13E1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */; }; 0E7C3CCD2C9AF44600B72E69 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */; }; 0E7E3D692B9345FD002BBDB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0E7E3D5C2B9345FD002BBDB4 /* Assets.xcassets */; }; 0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3D5F2B9345FD002BBDB4 /* PassepartoutApp.swift */; }; @@ -29,6 +32,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 0E757F242CD0D812006E13E1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0E06D1872B87629100176E1D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0E757F0F2CD0CFFC006E13E1; + remoteInfo = PassepartoutLoginItem; + }; 0EC332D02B8A1808000B9C2F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 0E06D1872B87629100176E1D /* Project object */; @@ -46,6 +56,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 0E757F1F2CD0D1FB006E13E1 /* Embed Login Item */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LoginItems; + dstSubfolderSpec = 1; + files = ( + 0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */, + ); + name = "Embed Login Item"; + runOnlyForDeploymentPostprocessing = 0; + }; 0EC332D62B8A1808000B9C2F /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -72,6 +93,10 @@ /* Begin PBXFileReference section */ 0E06D18F2B87629100176E1D /* Passepartout.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Passepartout.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PassepartoutLoginItem.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassepartoutLoginItemApp.swift; sourceTree = ""; }; + 0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoginItem.entitlements; sourceTree = ""; }; + 0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 0E7D0EAD2CAEA47700A2F28D /* Passepartout.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.xctestplan; sourceTree = ""; }; 0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; @@ -99,6 +124,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 0E757F0D2CD0CFFC006E13E1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0EC332C52B8A1808000B9C2F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -141,6 +173,7 @@ 0E06D18F2B87629100176E1D /* Passepartout.app */, 0EC332C82B8A1808000B9C2F /* PassepartoutTunnel.appex */, 0EDE56F02CABE42E0082D21C /* PassepartoutIntents.appex */, + 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */, ); name = Products; sourceTree = ""; @@ -153,6 +186,16 @@ name = Frameworks; sourceTree = ""; }; + 0E757F112CD0CFFC006E13E1 /* LoginItem */ = { + isa = PBXGroup; + children = ( + 0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */, + 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */, + 0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */, + ); + path = LoginItem; + sourceTree = ""; + }; 0E7E3D592B9345FD002BBDB4 /* Passepartout */ = { isa = PBXGroup; children = ( @@ -160,6 +203,7 @@ 0E7D0EAD2CAEA47700A2F28D /* Passepartout.xctestplan */, 0E7E3D5A2B9345FD002BBDB4 /* App */, 0EDE56E82CABE40D0082D21C /* Intents */, + 0E757F112CD0CFFC006E13E1 /* LoginItem */, 0E7E3D612B9345FD002BBDB4 /* Shared */, 0E7E3D652B9345FD002BBDB4 /* Tunnel */, 0EBE80DD2BF55C9100E36A20 /* Library */, @@ -240,15 +284,17 @@ 0ED27CBF2B9331FF0089E26B /* Frameworks */, 0E06D18D2B87629100176E1D /* Resources */, 0EC332D62B8A1808000B9C2F /* Embed Foundation Extensions */, - 0E8D852E2C328C54005493DE /* SwiftLint */, 0EDE56FE2CABE42E0082D21C /* Embed ExtensionKit Extensions */, + 0E757F1F2CD0D1FB006E13E1 /* Embed Login Item */, + 0E8D852E2C328C54005493DE /* SwiftLint */, ); buildRules = ( ); dependencies = ( 0E6C0A032BF4047100450362 /* PBXTargetDependency */, - 0EC332D12B8A1808000B9C2F /* PBXTargetDependency */, 0EDE56F92CABE42E0082D21C /* PBXTargetDependency */, + 0E757F252CD0D812006E13E1 /* PBXTargetDependency */, + 0EC332D12B8A1808000B9C2F /* PBXTargetDependency */, ); name = Passepartout; packageProductDependencies = ( @@ -258,6 +304,23 @@ productReference = 0E06D18F2B87629100176E1D /* Passepartout.app */; productType = "com.apple.product-type.application"; }; + 0E757F0F2CD0CFFC006E13E1 /* PassepartoutLoginItem */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0E757F1E2CD0CFFD006E13E1 /* Build configuration list for PBXNativeTarget "PassepartoutLoginItem" */; + buildPhases = ( + 0E757F0C2CD0CFFC006E13E1 /* Sources */, + 0E757F0D2CD0CFFC006E13E1 /* Frameworks */, + 0E757F0E2CD0CFFC006E13E1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PassepartoutLoginItem; + productName = PassepartoutLoginItem; + productReference = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; + productType = "com.apple.product-type.application"; + }; 0EC332C72B8A1808000B9C2F /* PassepartoutTunnel */ = { isa = PBXNativeTarget; buildConfigurationList = 0EC332D32B8A1808000B9C2F /* Build configuration list for PBXNativeTarget "PassepartoutTunnel" */; @@ -309,6 +372,9 @@ 0E06D18E2B87629100176E1D = { CreatedOnToolsVersion = 15.2; }; + 0E757F0F2CD0CFFC006E13E1 = { + CreatedOnToolsVersion = 15.4; + }; 0EC332C72B8A1808000B9C2F = { CreatedOnToolsVersion = 15.2; }; @@ -332,6 +398,7 @@ targets = ( 0E06D18E2B87629100176E1D /* Passepartout */, 0EDE56EF2CABE42E0082D21C /* PassepartoutIntents */, + 0E757F0F2CD0CFFC006E13E1 /* PassepartoutLoginItem */, 0EC332C72B8A1808000B9C2F /* PassepartoutTunnel */, ); }; @@ -350,6 +417,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0E757F0E2CD0CFFC006E13E1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0EC332C62B8A1808000B9C2F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -401,6 +475,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 0E757F0C2CD0CFFC006E13E1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0E757F232CD0D2BD006E13E1 /* AppDelegate.swift in Sources */, + 0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0EC332C42B8A1808000B9C2F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -429,6 +512,14 @@ isa = PBXTargetDependency; productRef = 0E6C0A042BF4047600450362 /* TunnelLibrary */; }; + 0E757F252CD0D812006E13E1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilters = ( + macos, + ); + target = 0E757F0F2CD0CFFC006E13E1 /* PassepartoutLoginItem */; + targetProxy = 0E757F242CD0D812006E13E1 /* PBXContainerItemProxy */; + }; 0EC332D12B8A1808000B9C2F /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 0EC332C72B8A1808000B9C2F /* PassepartoutTunnel */; @@ -692,6 +783,54 @@ }; name = Release; }; + 0E757F1C2CD0CFFD006E13E1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Passepartout/LoginItem/LoginItem.entitlements; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 3645; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + INFOPLIST_KEY_CFBundleDisplayName = "$(TARGET_NAME)"; + INFOPLIST_KEY_LSBackgroundOnly = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "$(CFG_COPYRIGHT)"; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MARKETING_VERSION = 3.0.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CFG_LOGIN_ITEM_ID)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0E757F1D2CD0CFFD006E13E1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Passepartout/LoginItem/LoginItem.entitlements; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 3645; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + INFOPLIST_KEY_CFBundleDisplayName = "$(TARGET_NAME)"; + INFOPLIST_KEY_LSBackgroundOnly = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "$(CFG_COPYRIGHT)"; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MARKETING_VERSION = 3.0.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CFG_LOGIN_ITEM_ID)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 0EC332D42B8A1808000B9C2F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -820,6 +959,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 0E757F1E2CD0CFFD006E13E1 /* Build configuration list for PBXNativeTarget "PassepartoutLoginItem" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0E757F1C2CD0CFFD006E13E1 /* Debug */, + 0E757F1D2CD0CFFD006E13E1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 0EC332D32B8A1808000B9C2F /* Build configuration list for PBXNativeTarget "PassepartoutTunnel" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Passepartout.xcodeproj/xcshareddata/xcschemes/PassepartoutLoginItem.xcscheme b/Passepartout.xcodeproj/xcshareddata/xcschemes/PassepartoutLoginItem.xcscheme new file mode 100644 index 00000000..140c49c3 --- /dev/null +++ b/Passepartout.xcodeproj/xcshareddata/xcschemes/PassepartoutLoginItem.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Passepartout/App/App.plist b/Passepartout/App/App.plist index 85e304cd..eb6c4695 100644 --- a/Passepartout/App/App.plist +++ b/Passepartout/App/App.plist @@ -14,10 +14,12 @@ $(CFG_IAP_BUNDLE_PREFIX) keychainGroupId $(CFG_KEYCHAIN_GROUP_ID) - tunnelId - $(CFG_TUNNEL_ID) legacyV2CloudKitId $(CFG_LEGACY_V2_CLOUDKIT_ID) + loginItemId + $(CFG_LOGIN_ITEM_ID) + tunnelId + $(CFG_TUNNEL_ID) CFBundleDocumentTypes @@ -50,10 +52,6 @@ ITSAppUsesNonExemptEncryption - NSUserActivityTypes - - CustomIntentIntent - UIBackgroundModes remote-notification diff --git a/Passepartout/App/AppDelegate.swift b/Passepartout/App/AppDelegate.swift index bda3fc9a..e6cc2c1c 100644 --- a/Passepartout/App/AppDelegate.swift +++ b/Passepartout/App/AppDelegate.swift @@ -23,4 +23,33 @@ // along with Passepartout. If not, see . // -import Foundation +import AppUI +import CommonLibrary +import PassepartoutKit +import SwiftUI + +@MainActor +final class AppDelegate: NSObject { + + @AppStorage(AppPreference.confirmsQuit.key) + var confirmsQuit = true + + let context: AppContext = .shared +// let context: AppContext = .mock(withRegistry: .shared) + + func configure() { + PassepartoutConfiguration.shared.configureLogging( + to: BundleConfiguration.urlForAppLog, + parameters: Constants.shared.log, + logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key) + ) + AppUI.configure(with: context) + +#if os(macOS) + // keep this for login item because scenePhase is not triggered + Task { + try await context.tunnel.prepare(purge: true) + } +#endif + } +} diff --git a/Passepartout/App/Assets.xcassets/Menu/Contents.json b/Passepartout/App/Assets.xcassets/Menu/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Passepartout/App/Assets.xcassets/Menu/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/Contents.json b/Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/Contents.json new file mode 100644 index 00000000..97c88e5b --- /dev/null +++ b/Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x" + }, + { + "filename" : "StatusActive@2x.png", + "idiom" : "mac", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/StatusActive@2x.png b/Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/StatusActive@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f530ae120f5b2d274384e289f25769ada220d39b GIT binary patch literal 817 zcmeAS@N?(olHy`uVBq!ia0vp^N+8U^3=*07`vZ_tNcITwWnidMV_;}#VPN&RKR+#-xOZ~3+9-u*tN#5=*3>~bp9zYIffk$L91B0G22s2hJwJ!q-vX^-Jy0X9E zl4W3IIZzh(1t=ttoahIV$^~KvAQl5+{@p1LEr9f8PZ!4!i_>o}pX6&Y5Md3N;~vGr z&LSwN9#DAb=I{UepDIu6IC|vBjxTxd9?kW=9qw7BGWqONVNDV5NfUf_HeC|mc&&G4LA@}H;A zmM3p_z2}hcR(d+S_ zO-0&3@2Qr!MwFx^mZVxG7o`Fz1|tI_6J0|CT_cMSBV#L53oBz&Z36=<1A~HRYuBP^ z$jwj5OshoGU<_1h1kteZ{B9j7k6i literal 0 HcmV?d00001 diff --git a/Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/Contents.json b/Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/Contents.json new file mode 100644 index 00000000..90fad6d7 --- /dev/null +++ b/Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x" + }, + { + "filename" : "StatusInactive@2x.png", + "idiom" : "mac", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/StatusInactive@2x.png b/Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/StatusInactive@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4541d3243e326e864b7f62648f11a3e594bcd85c GIT binary patch literal 743 zcmeAS@N?(olHy`uVBq!ia0vp^N+8U^3=*07`vZ_tNcITwWnidMV_;}#VPNqfpXD169Rt&^)e=T zySp%Su*!M>Ih+L^k;M!QddeWoSh3W;3@FH6;_2(k{(?)Eky9{bL#P5!NFX`U4wiFjv*GO-(Eh+*JL2V8o(x6J<;ey!bHb{BOm|&Ki7V+gH`zP zDT(*b^r|MQt*`C6vq#HMCj(_(`uoiQ@8vL;at04S@7_b|A!uG2tG}5 zpJQ88ruTiaw&ZHL8G+h=Ie>mqEpd$~Nl7e8wMs5Z1yT$~21X{jh6cJumLUcPRt6?k zhL+kOhC$Aet+P-xN=rOc1u(UEXgIF?U S4WBAd4}+(xpUXO@geCxxP6Cwx literal 0 HcmV?d00001 diff --git a/Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/Contents.json b/Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/Contents.json new file mode 100644 index 00000000..f71a4753 --- /dev/null +++ b/Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x" + }, + { + "filename" : "StatusPending@2x.png", + "idiom" : "mac", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/StatusPending@2x.png b/Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/StatusPending@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..162ec98d26af1cc7c4fcd6ab4b8480119155ec02 GIT binary patch literal 1276 zcmeAS@N?(olHy`uVBq!ia0vp^N+8U^3=*07`vZ_tNcITwWnidMV_;}#VPN(BNswPK zgJkFJmu_93W;0qmxi?9E{crCjHeYusefH>IVD)b9oy9I9Jpzu?|GoL(J|{gdTdu%K zA|to7P@(wam-jzh&Hujr_A4MPSX?MDw4}DKUd^|)xy5J3N+#QxtETBjrKos%y)r9r zs8N=wj7d(AHc7P>jZ6AD>%kO5K5qT?*H8CNJgKv2>FWKHFPH({!`sf3ocm(M#;qmwz@zef#gI#kW?-ZI{>j55cBU& zd1wJNugTNJF~s8Z)+yn^OpXFA#~au+sNM>R!b~@T)nRw1J{dfCr_N2Ma zkx#ilU%I+AJ~#LMIh~8;M~|wjyy}>w;+e0IT-2pGHR9+}#v-eUoF@zdmPwvCmF6MD z;LSHt^n~iBr<}9TPheVbAnPuXf=&^P2varzT*x0?R zczgTQx?D{yXGhn?HXXisYJ%AbtF`9an0X2h-d*^0u86FWWk&sP-txMiN^qRfP}Y0vH)`gGxu)B+9_hBN;g zBbXZx98$VCDQj#k5E2aamb6a z53X&y_Wli9ts&>$?ezsKUS0h3v2)e(^xw||JN2A8A2ps1$nXwXr2XL6Gc%(N<=^?` zc1UbesCpPEE?NC}vbzbZ^QQTS=O6Eu`2XSg$6v?J@BZ}emDmZ>4~i4YUVd)>!BpQF z{q5Z&{R&{LsFt`!l%ynNoH Bool { + configure() + return true + } } #endif diff --git a/Passepartout/App/macOS/AppDelegate+macOS.swift b/Passepartout/App/macOS/AppDelegate+macOS.swift index a1f8fb68..3223ed74 100644 --- a/Passepartout/App/macOS/AppDelegate+macOS.swift +++ b/Passepartout/App/macOS/AppDelegate+macOS.swift @@ -27,17 +27,12 @@ import AppKit import AppUI -import CommonLibrary import PassepartoutKit -import SwiftUI - -final class AppDelegate: NSObject, NSApplicationDelegate { - - @AppStorage(AppPreference.confirmsQuit.key) - private var confirmsQuit = true +extension AppDelegate: NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { - NSApp.windows[0].styleMask.remove(.closable) + configureAppWindow() + configure() } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -52,8 +47,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -@MainActor private extension AppDelegate { + var isStartedFromLoginItem: Bool { + NSApp.isHidden + } + + func configureAppWindow() { + if isStartedFromLoginItem { + AppWindow.shared.isVisible = false + } + AppWindow.shared.removeCloseButton() + } + func quitConfirmationAlert() -> NSApplication.TerminateReply { let alert = NSAlert() alert.alertStyle = .warning diff --git a/Passepartout/Config.xcconfig b/Passepartout/Config.xcconfig index 9a6bc98a..c7d2632a 100644 --- a/Passepartout/Config.xcconfig +++ b/Passepartout/Config.xcconfig @@ -39,6 +39,7 @@ CFG_GROUP_ID[sdk=macosx*] = $(CFG_TEAM_ID).$(CFG_RAW_GROUP_ID) CFG_KEYCHAIN_GROUP_ID = $(CFG_TEAM_ID).$(CFG_RAW_GROUP_ID) CFG_IAP_BUNDLE_PREFIX = com.algoritmico.ios.Passepartout CFG_INTENTS_ID = $(CFG_APP_ID).Intents +CFG_LOGIN_ITEM_ID = $(CFG_APP_ID).LoginItem CFG_RAW_GROUP_ID = group.com.algoritmico.Passepartout CFG_TEAM_ID = DTDYD63ZX9 CFG_TUNNEL_ID = $(CFG_APP_ID).Tunnel diff --git a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift index 36b5571b..af312c9d 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift @@ -38,6 +38,16 @@ public enum Strings { } } } + public enum AppMenu { + public enum Items { + /// Launch on Login + public static let launchOnLogin = Strings.tr("Localizable", "app_menu.items.launch_on_login", fallback: "Launch on Login") + /// Quit %@ + public static func quit(_ p1: Any) -> String { + return Strings.tr("Localizable", "app_menu.items.quit", String(describing: p1), fallback: "Quit %@") + } + } + } public enum Entities { public enum ConnectionStatus { /// Connected @@ -227,6 +237,8 @@ public enum Strings { public static let gateway = Strings.tr("Localizable", "global.gateway", fallback: "Gateway") /// General public static let general = Strings.tr("Localizable", "global.general", fallback: "General") + /// Hide + public static let hide = Strings.tr("Localizable", "global.hide", fallback: "Hide") /// Hostname public static let hostname = Strings.tr("Localizable", "global.hostname", fallback: "Hostname") /// Interface @@ -297,6 +309,8 @@ public enum Strings { public static let servers = Strings.tr("Localizable", "global.servers", fallback: "Servers") /// Settings public static let settings = Strings.tr("Localizable", "global.settings", fallback: "Settings") + /// Show + public static let show = Strings.tr("Localizable", "global.show", fallback: "Show") /// Status public static let status = Strings.tr("Localizable", "global.status", fallback: "Status") /// Storage diff --git a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings index c0c3819b..63d57450 100644 --- a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings @@ -30,6 +30,7 @@ "global.folder" = "Folder"; "global.gateway" = "Gateway"; "global.general" = "General"; +"global.hide" = "Hide"; "global.hostname" = "Hostname"; "global.interface" = "Interface"; "global.keep_alive" = "Keep-alive"; @@ -64,6 +65,7 @@ "global.server" = "Server"; "global.servers" = "Servers"; "global.settings" = "Settings"; +"global.show" = "Show"; "global.status" = "Status"; "global.storage" = "Storage"; "global.subnet" = "Subnet"; @@ -233,6 +235,11 @@ "providers.vpn.preset" = "Preset"; "providers.vpn.no_servers" = "No servers"; +// MARK: - App menu + +"app_menu.items.launch_on_login" = "Launch on Login"; +"app_menu.items.quit" = "Quit %@"; + // MARK: - Components "ui.connection_status.on_demand_suffix" = " (on-demand)"; diff --git a/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu+Model.swift b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu+Model.swift new file mode 100644 index 00000000..b0145ab9 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu+Model.swift @@ -0,0 +1,73 @@ +// +// AppMenu+Model.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +#if os(macOS) + +import AppKit +import PassepartoutKit +import ServiceManagement + +extension AppMenu { + + @MainActor + final class Model: ObservableObject { + private let appService: SMAppService + + var isVisible: Bool { + get { + AppWindow.shared.isVisible + } + set { + AppWindow.shared.isVisible = newValue + objectWillChange.send() + } + } + + var launchesOnLogin: Bool { + get { + appService.status == .enabled + } + set { + do { + if newValue { + try appService.register() + } else { + try appService.unregister() + } + } catch { + pp_log(.app, .error, "Unable to (un)register login item: \(error)") + } + objectWillChange.send() + } + } + + init() { + let loginItemId = BundleConfiguration.mainString(for: .loginItemId) + appService = SMAppService.loginItem(identifier: loginItemId) + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu.swift b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu.swift new file mode 100644 index 00000000..3c6bdedc --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenu.swift @@ -0,0 +1,113 @@ +// +// AppMenu.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +#if os(macOS) + +import AppLibrary +import Combine +import PassepartoutKit +import SwiftUI + +public struct AppMenu: View { + + @EnvironmentObject + private var profileManager: ProfileManager + + @EnvironmentObject + private var profileProcessor: ProfileProcessor + + @EnvironmentObject + private var tunnel: Tunnel + + @StateObject + private var model = Model() + + public init() { + } + + public var body: some View { + versionItem + Divider() + dockToggle + loginToggle + Divider() + profilesList + Divider() + quitButton + } +} + +private extension AppMenu { + var versionItem: some View { + Text(BundleConfiguration.mainVersionString) + } + + var dockToggle: some View { + Button(model.isVisible ? Strings.Global.hide : Strings.Global.show) { + model.isVisible.toggle() + } + } + + var loginToggle: some View { + Toggle(Strings.AppMenu.Items.launchOnLogin, isOn: $model.launchesOnLogin) + } + + var profilesList: some View { + ForEach(profileManager.headers, id: \.self, content: profileToggle) + } + + func profileToggle(for header: ProfileHeader) -> some View { + Toggle(header.name, isOn: profileToggleBinding(for: header)) + } + + func profileToggleBinding(for header: ProfileHeader) -> Binding { + Binding { + header.id == tunnel.currentProfile?.id && tunnel.status != .inactive + } set: { isOn in + Task { + guard let profile = profileManager.profile(withId: header.id) else { + return + } + do { + if isOn { + try await tunnel.connect(with: profile, processor: profileProcessor) + } else { + try await tunnel.disconnect() + } + } catch { + pp_log(.app, .error, "Unable to toggle profile \(header.id) from menu: \(error)") + } + } + } + } + + var quitButton: some View { + Button(Strings.AppMenu.Items.quit(BundleConfiguration.mainDisplayName)) { + NSApp.terminate(self) + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenuImage.swift b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenuImage.swift new file mode 100644 index 00000000..21ff1596 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppMenuImage.swift @@ -0,0 +1,60 @@ +// +// AppMenuImage.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +#if os(macOS) + +import PassepartoutKit +import SwiftUI + +public struct AppMenuImage: View, TunnelContextProviding { + + @ObservedObject + var connectionObserver: ConnectionObserver + + public init(connectionObserver: ConnectionObserver) { + self.connectionObserver = connectionObserver + } + + public var body: some View { + ThemeMenuImage(tunnelConnectionStatus.imageName) + } +} + +private extension TunnelStatus { + var imageName: Theme.MenuImageName { + switch self { + case .active: + return .active + + case .inactive: + return .inactive + + case .activating, .deactivating: + return .pending + } + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppWindow.swift b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppWindow.swift new file mode 100644 index 00000000..6c529085 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/AppMenu/AppWindow.swift @@ -0,0 +1,64 @@ +// +// AppWindow.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +#if os(macOS) + +import AppKit + +@MainActor +public final class AppWindow { + public static let shared = AppWindow() + + public var isVisible: Bool { + get { + NSApp.activationPolicy() == .regular && window.isVisible + } + set { + NSApp.setActivationPolicy(newValue ? .regular : .prohibited) + if newValue { + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(self) + } + } + } + + private init() { + } + + public func removeCloseButton() { + window.styleMask.remove(.closable) + } +} + +private extension AppWindow { + var window: NSWindow { + guard let window = NSApp.windows.first else { + fatalError("No Mac window?") + } + return window + } +} + +#endif diff --git a/Passepartout/Library/Sources/AppUI/Views/Extensions/TunnelContextProviding.swift b/Passepartout/Library/Sources/AppUI/Views/Extensions/TunnelContextProviding.swift index 9c64282b..c63feb4b 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Extensions/TunnelContextProviding.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Extensions/TunnelContextProviding.swift @@ -27,15 +27,13 @@ import Foundation import PassepartoutKit protocol TunnelContextProviding { - var tunnel: Tunnel { get } - var connectionObserver: ConnectionObserver { get } } @MainActor extension TunnelContextProviding { var tunnelConnectionStatus: TunnelStatus { - var status = tunnel.status + var status = connectionObserver.tunnel.status if status == .active, let connectionStatus = connectionObserver.connectionStatus { if connectionStatus == .connected { status = .active diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+MenuImageName.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+MenuImageName.swift new file mode 100644 index 00000000..1218131f --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+MenuImageName.swift @@ -0,0 +1,46 @@ +// +// Theme+MenuImageName.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import SwiftUI + +extension Theme { + public enum MenuImageName { + case active + case inactive + case pending + } +} + +extension Theme.MenuImageName { + static var defaultImageName: (Self) -> String { + { + switch $0 { + case .active: return "MenuActive" + case .inactive: return "MenuInactive" + case .pending: return "MenuPending" + } + } + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift index e05082fa..bb580eb9 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme+UI.swift @@ -434,6 +434,22 @@ struct ThemeImageLabel: View { } } +struct ThemeMenuImage: View { + + @EnvironmentObject + private var theme: Theme + + private let name: Theme.MenuImageName + + init(_ name: Theme.MenuImageName) { + self.name = name + } + + var body: some View { + Image(theme.menuImageName(name)) + } +} + struct ThemeDisclosableMenu: View where Content: View, Label: View { @ViewBuilder diff --git a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift index 2d20ecf2..b43d6c6a 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Theme/Theme.swift @@ -80,6 +80,8 @@ public final class Theme: ObservableObject { var systemImageName: (ImageName) -> String = Theme.ImageName.defaultSystemName + var menuImageName: (MenuImageName) -> String = Theme.MenuImageName.defaultImageName + init(dummy: Void) { } diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/TunnelContextProviding+Theme.swift b/Passepartout/Library/Sources/AppUI/Views/UI/TunnelContextProviding+Theme.swift index 36125bcf..d6a5535c 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/TunnelContextProviding+Theme.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/TunnelContextProviding+Theme.swift @@ -30,7 +30,7 @@ import SwiftUI extension TunnelContextProviding where Self: ThemeProviding { var tunnelStatusColor: Color { if connectionObserver.lastErrorCode != nil { - switch tunnel.status { + switch connectionObserver.tunnel.status { case .inactive: return theme.inactiveColor diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/View+Environment.swift b/Passepartout/Library/Sources/AppUI/Views/UI/View+Environment.swift index 5e95cbec..dd3ce916 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/View+Environment.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/View+Environment.swift @@ -30,11 +30,12 @@ import SwiftUI extension View { public func withEnvironment(from context: AppContext, theme: Theme) -> some View { environmentObject(theme) + .environmentObject(context.connectionObserver) .environmentObject(context.iapManager) .environmentObject(context.profileManager) .environmentObject(context.profileProcessor) - .environmentObject(context.connectionObserver) .environmentObject(context.providerManager) + .environmentObject(context.tunnel) } public func withMockEnvironment() -> some View { diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift index ef4b40a3..fae8881a 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift @@ -42,6 +42,8 @@ extension BundleConfiguration { case keychainGroupId + case loginItemId + case tunnelId // legacy v2 diff --git a/Passepartout/LoginItem/AppDelegate.swift b/Passepartout/LoginItem/AppDelegate.swift new file mode 100644 index 00000000..355b9da0 --- /dev/null +++ b/Passepartout/LoginItem/AppDelegate.swift @@ -0,0 +1,95 @@ +// +// AppDelegate.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import AppKit +import Foundation + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + guard !isAppRunning else { + NSApp.terminate(self) + return + } + let cfg = NSWorkspace.OpenConfiguration() + cfg.hides = true + cfg.activates = false + cfg.addsToRecentItems = false + Task { + defer { + NSApp.terminate(self) + } + do { + try await NSWorkspace.shared.openApplication(at: appURL, configuration: cfg) + NSLog("Launched main app: \(appURL)") + } catch { + NSLog("Unable to launch main app: \(error)") + } + } + } +} + +private extension AppDelegate { + var loginItemId: String { + guard let id = Bundle.main.bundleIdentifier else { + fatalError("No bundle identifier in LoginItem?") + } + return id + } + + var appId: String { + var idComponents = loginItemId.components(separatedBy: ".") + idComponents.removeLast() + return idComponents.joined(separator: ".") + } + + var appURL: URL { + let path = Bundle.main.bundlePath as NSString + var components = path.pathComponents + + // Passepartout.app/Contents/Library/LoginItems/PassepartoutLoginItem.app + components.removeLast(4) + + let appPath = NSString.path(withComponents: components) + return URL(fileURLWithPath: appPath) + } + + var isAppRunning: Bool { + NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == appId + } + } +} + +// MARK: - Preconcurrency warnings + +extension NSWorkspace: @unchecked Sendable { +} + +extension NSRunningApplication: @unchecked Sendable { +} + +extension NSWorkspace.OpenConfiguration: @unchecked Sendable { +} diff --git a/Passepartout/LoginItem/LoginItem.entitlements b/Passepartout/LoginItem/LoginItem.entitlements new file mode 100644 index 00000000..f2ef3ae0 --- /dev/null +++ b/Passepartout/LoginItem/LoginItem.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Passepartout/LoginItem/PassepartoutLoginItemApp.swift b/Passepartout/LoginItem/PassepartoutLoginItemApp.swift new file mode 100644 index 00000000..f252d240 --- /dev/null +++ b/Passepartout/LoginItem/PassepartoutLoginItemApp.swift @@ -0,0 +1,37 @@ +// +// PassepartoutLoginItemApp.swift +// Passepartout +// +// Created by Davide De Rosa on 10/29/24. +// Copyright (c) 2024 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import SwiftUI + +@main +struct PassepartoutLoginItemApp: App { + + @NSApplicationDelegateAdaptor + private var appDelegate: AppDelegate + + var body: some Scene { + MenuBarExtra("") {} + } +}