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
This commit is contained in:
Davide 2024-10-29 11:40:11 +01:00 committed by GitHub
parent d60ab97922
commit 41de48789e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 902 additions and 35 deletions

View File

@ -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 = "<group>"; };
0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoginItem.entitlements; sourceTree = "<group>"; };
0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
0E7C3CCC2C9AF44600B72E69 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
0E7D0EAD2CAEA47700A2F28D /* Passepartout.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.xctestplan; sourceTree = "<group>"; };
0E7E3D5B2B9345FD002BBDB4 /* App.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -153,6 +186,16 @@
name = Frameworks;
sourceTree = "<group>";
};
0E757F112CD0CFFC006E13E1 /* LoginItem */ = {
isa = PBXGroup;
children = (
0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */,
0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */,
0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */,
);
path = LoginItem;
sourceTree = "<group>";
};
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 = (

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E757F0F2CD0CFFC006E13E1"
BuildableName = "PassepartoutLoginItem.app"
BlueprintName = "PassepartoutLoginItem"
ReferencedContainer = "container:Passepartout.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E757F0F2CD0CFFC006E13E1"
BuildableName = "PassepartoutLoginItem.app"
BlueprintName = "PassepartoutLoginItem"
ReferencedContainer = "container:Passepartout.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0E757F0F2CD0CFFC006E13E1"
BuildableName = "PassepartoutLoginItem.app"
BlueprintName = "PassepartoutLoginItem"
ReferencedContainer = "container:Passepartout.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -14,10 +14,12 @@
<string>$(CFG_IAP_BUNDLE_PREFIX)</string>
<key>keychainGroupId</key>
<string>$(CFG_KEYCHAIN_GROUP_ID)</string>
<key>tunnelId</key>
<string>$(CFG_TUNNEL_ID)</string>
<key>legacyV2CloudKitId</key>
<string>$(CFG_LEGACY_V2_CLOUDKIT_ID)</string>
<key>loginItemId</key>
<string>$(CFG_LOGIN_ITEM_ID)</string>
<key>tunnelId</key>
<string>$(CFG_TUNNEL_ID)</string>
</dict>
<key>CFBundleDocumentTypes</key>
<array>
@ -50,10 +52,6 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSUserActivityTypes</key>
<array>
<string>CustomIntentIntent</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>

View File

@ -23,4 +23,33 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
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
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 B

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)";

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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<Bool> {
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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
#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

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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"
}
}
}
}

View File

@ -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<Content, Label>: View where Content: View, Label: View {
@ViewBuilder

View File

@ -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) {
}

View File

@ -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

View File

@ -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 {

View File

@ -42,6 +42,8 @@ extension BundleConfiguration {
case keychainGroupId
case loginItemId
case tunnelId
// legacy v2

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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 {
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import SwiftUI
@main
struct PassepartoutLoginItemApp: App {
@NSApplicationDelegateAdaptor
private var appDelegate: AppDelegate
var body: some Scene {
MenuBarExtra("") {}
}
}