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 00000000..f530ae12 Binary files /dev/null and b/Passepartout/App/Assets.xcassets/Menu/MenuActive.imageset/StatusActive@2x.png differ 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 00000000..4541d324 Binary files /dev/null and b/Passepartout/App/Assets.xcassets/Menu/MenuInactive.imageset/StatusInactive@2x.png differ 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 00000000..162ec98d Binary files /dev/null and b/Passepartout/App/Assets.xcassets/Menu/MenuPending.imageset/StatusPending@2x.png differ diff --git a/Passepartout/App/PassepartoutApp.swift b/Passepartout/App/PassepartoutApp.swift index f0c55771..42021dc1 100644 --- a/Passepartout/App/PassepartoutApp.swift +++ b/Passepartout/App/PassepartoutApp.swift @@ -42,8 +42,9 @@ struct PassepartoutApp: App { @Environment(\.scenePhase) private var scenePhase - private let context: AppContext = .shared -// private let context: AppContext = .mock(withRegistry: .shared) + private var context: AppContext { + appDelegate.context + } private let appName = BundleConfiguration.mainDisplayName @@ -62,12 +63,19 @@ struct PassepartoutApp: App { #else var body: some Scene { Window(appName, id: appName, content: contentView) - .defaultSize(width: 600.0, height: 400.0) + .defaultSize(width: 600, height: 400) Settings { SettingsView(profileManager: context.profileManager) .frame(minWidth: 300, minHeight: 200) } + MenuBarExtra { + AppMenu() + .withEnvironment(from: context, theme: theme) + } label: { + AppMenuImage(connectionObserver: context.connectionObserver) + .environmentObject(theme) + } } #endif } @@ -79,14 +87,6 @@ private extension PassepartoutApp { tunnel: context.tunnel, registry: context.registry ) - .onLoad { - PassepartoutConfiguration.shared.configureLogging( - to: BundleConfiguration.urlForAppLog, - parameters: Constants.shared.log, - logsPrivateData: UserDefaults.appGroup.bool(forKey: AppPreference.logsPrivateData.key) - ) - AppUI.configure(with: context) - } .onChange(of: scenePhase) { switch $0 { case .active: diff --git a/Passepartout/App/iOS/AppDelegate+iOS.swift b/Passepartout/App/iOS/AppDelegate+iOS.swift index 7330b231..6ec4342a 100644 --- a/Passepartout/App/iOS/AppDelegate+iOS.swift +++ b/Passepartout/App/iOS/AppDelegate+iOS.swift @@ -28,7 +28,11 @@ import AppUI import UIKit -final class AppDelegate: NSObject, UIApplicationDelegate { +extension AppDelegate: UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> 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("") {} + } +}