feat: persist local configuration and device identity (#3)

Store receiver and identity together in Keychain, with versioned tracking
preferences, explicit authorization retry and stable Settings focus.
Adopt free Apple Development locally and credential-free ad hoc CI.
Run configuration and focus tests through both CI toolchains.

Final decisions: https://github.com/jayanezj/traccar-client-macos/issues/3#issuecomment-6396087703
This commit is contained in:
jayanezj committed 2026-10-25 10:43:43 +01:00
1 parent 8aaff37d7a
commit 026cd6aa40
23 files changed
+1442 -42

No files matched your search

@@ -5,6 +5,8 @@ import SwiftUI
struct TraccarClientApp: App {
@NSApplicationDelegateAdaptor(ApplicationDelegate.self) private var delegate
@State private var configuration = ConfigurationModel.live()
var body: some Scene {
MenuBarExtra {
TrackingMenu()
@@ -16,13 +18,54 @@ struct TraccarClientApp: App {
.menuBarExtraStyle(.menu)
Settings {
SettingsView()
SettingsView(model: configuration)
}
}
}
@MainActor
final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private let openWindows = NSHashTable<NSWindow>.weakObjects()
private var closingWindows: Set<ObjectIdentifier> = []
func applicationDidFinishLaunching(_ notification: Notification) {
let center = NotificationCenter.default
let notifications = [
NSWindow.didBecomeKeyNotification, NSWindow.didUpdateNotification, NSWindow.willCloseNotification,
]
for name in notifications {
center.addObserver(self, selector: #selector(windowChanged(_:)), name: name, object: nil)
}
}
@objc private func windowChanged(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
window.level == .normal, window.canBecomeKey else { return }
let identity = ObjectIdentifier(window)
if notification.name == NSWindow.willCloseNotification {
closingWindows.insert(identity)
openWindows.remove(window)
} else {
if notification.name == NSWindow.didBecomeKeyNotification { closingWindows.remove(identity) }
guard !closingWindows.contains(identity), window.isVisible || window.isMiniaturized else { return }
openWindows.add(window)
}
let policy: NSApplication.ActivationPolicy = openWindows.allObjects.isEmpty ? .accessory : .regular
if NSApp.activationPolicy() != policy {
NSApp.setActivationPolicy(policy)
if policy == .regular { NSApp.activate() }
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
guard WindowFocusController.pendingActivations == 0 else { return false }
if let window = sender.windows.first(where: { openWindows.contains($0) }) {
if window.isMiniaturized { window.deminiaturize(nil) }
window.makeKeyAndOrderFront(nil)
}
return false
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
@@ -0,0 +1,116 @@
import Foundation
import Security
struct ReceiverConnection: Codable, Equatable {
static let demoURL = "http://demo.traccar.org:5055"
var serverURL = demoURL
var deviceIdentifier = ""
var isDemo: Bool {
URLComponents(string: serverURL)?.host?.lowercased() == "demo.traccar.org"
}
func validated() throws -> Self {
var result = self
result.serverURL = try Self.canonicalURL(serverURL)
if !deviceIdentifier.isEmpty {
guard !deviceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!deviceIdentifier.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw ConfigurationError.invalidIdentifier
}
}
return result
}
static func canonicalURL(_ value: String) throws -> String {
guard !value.isEmpty,
!value.unicodeScalars.contains(where: CharacterSet.whitespacesAndNewlines.contains),
let url = URL(string: value, encodingInvalidCharacters: false),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil, components.fragment == nil else {
throw ConfigurationError.invalidURL
}
if let separator = value.range(of: "://") {
let authority = value[separator.upperBound...].prefix { $0 != "/" && $0 != "?" }
if authority.hasSuffix(":") { throw ConfigurationError.invalidURL }
}
if let port = components.port, !(1...65535).contains(port) {
throw ConfigurationError.invalidURL
}
components.scheme = scheme
components.host = host.lowercased()
if (scheme == "http" && components.port == 80) || (scheme == "https" && components.port == 443) {
components.port = nil
}
if components.path == "/" { components.path = "" }
guard let normalized = components.url?.absoluteString else { throw ConfigurationError.invalidURL }
return normalized
}
}
enum LocationAccuracy: String, Codable, CaseIterable {
case low, medium, high
var title: String { rawValue.capitalized }
}
struct TrackingPreferences: Codable, Equatable {
var accuracy: LocationAccuracy = .medium
var intervalSeconds: Double = 300
var distanceMetres: Double = 75
var angleDegrees: Double = 0
var heartbeatSeconds: Double = 0
var bufferingEnabled = true
var stationaryDetectionEnabled = true
var launchAtLogin = false
var restoreTracking = false
func validate() throws {
let numbers = [intervalSeconds, distanceMetres, angleDegrees, heartbeatSeconds]
guard numbers.allSatisfy({ $0.isFinite && $0 >= 0 }), angleDegrees <= 180 else {
throw ConfigurationError.invalidTracking
}
}
}
struct AppConfiguration: Equatable {
var connection = ReceiverConnection()
var tracking = TrackingPreferences()
}
enum ConfigurationError: Error, LocalizedError, Equatable {
case invalidURL
case invalidIdentifier
case invalidTracking
case unreadableStorage
case unsupportedStorageVersion
case missingIdentity
case notLoaded
case keychain(Int32)
var errorDescription: String? {
switch self {
case .invalidURL:
"Enter an absolute HTTP or HTTPS URL with a host and valid port. No credentials or fragments."
case .invalidIdentifier:
"Enter an identifier without control characters, or leave it empty to generate one."
case .invalidTracking:
"Tracking values must be finite and non-negative; the angle must not exceed 180 degrees."
case .unreadableStorage:
"Saved configuration could not be read. No settings or identifier have been replaced."
case .unsupportedStorageVersion:
"This configuration was saved by an unsupported version. No settings have been replaced."
case .missingIdentity:
"The saved device identity is missing from Keychain. No replacement has been generated."
case .notLoaded:
"Reload the saved configuration successfully before saving changes."
case .keychain(let status) where status == errSecUserCanceled:
"Keychain access was cancelled. Saved configuration is unchanged. You can retry when ready."
case .keychain(let status):
"Keychain could not complete the operation (status \(status)). Allow access and try again."
}
}
}
@@ -0,0 +1,121 @@
import Foundation
import Observation
import Security
@MainActor
@Observable
final class ConfigurationModel {
var draft = AppConfiguration()
private(set) var saved: AppConfiguration?
private(set) var hasLoaded = false
private(set) var needsKeychainAuthorization = false
@ObservationIgnored private var attemptedAutomaticLoad = false
private(set) var errorMessage: String?
private(set) var statusMessage: String?
@ObservationIgnored private var statusTask: Task<Void, Never>?
@ObservationIgnored private let statusDelay: @MainActor () async throws -> Void
@ObservationIgnored private let storage: any ConfigurationStorage
@ObservationIgnored private let generateIdentifier: () -> String
@ObservationIgnored private let beforeConnectionChange: (ReceiverConnection, ReceiverConnection) throws -> Void
init(
storage: any ConfigurationStorage,
generateIdentifier: @escaping () -> String = { String(Int.random(in: 10_000_000...99_999_999)) },
statusDelay: @escaping @MainActor () async throws -> Void = { try await Task.sleep(for: .seconds(10)) },
beforeConnectionChange: @escaping (ReceiverConnection, ReceiverConnection) throws -> Void = { _, _ in }
) {
self.statusDelay = statusDelay
self.storage = storage
self.generateIdentifier = generateIdentifier
self.beforeConnectionChange = beforeConnectionChange
}
static func live() -> ConfigurationModel {
ConfigurationModel(storage: PreferencesConfigurationStorage(
preferences: .standard, connectionStorage: KeychainConnectionStorage(
service: "\(Bundle.main.bundleIdentifier ?? "com.codigojose.traccar-client-macos").configuration"
)
))
}
func loadIfNeeded() {
guard !hasLoaded, !attemptedAutomaticLoad else { return }
reload(allowInteraction: false)
}
func reload(allowInteraction: Bool = true) {
attemptedAutomaticLoad = true
do {
let configuration = try storage.load(allowInteraction: allowInteraction)
draft = configuration
saved = configuration
hasLoaded = true
needsKeychainAuthorization = false
errorMessage = nil
showStatus(configuration.connection.deviceIdentifier.isEmpty ? nil : "Configuration loaded.")
} catch {
hasLoaded = false
needsKeychainAuthorization = Self.requiresAuthorization(error)
errorMessage = !allowInteraction && needsKeychainAuthorization ? nil : message(for: error)
showStatus(nil)
}
}
@discardableResult
func save() -> Bool {
do {
guard hasLoaded, let saved else { throw ConfigurationError.notLoaded }
var candidate = draft
candidate.connection = try candidate.connection.validated()
try candidate.tracking.validate()
if candidate.connection.deviceIdentifier.isEmpty {
candidate.connection.deviceIdentifier = generateIdentifier()
// Retain the generated value on failure so an explicit retry does not generate another identity.
draft.connection.deviceIdentifier = candidate.connection.deviceIdentifier
}
guard !candidate.connection.deviceIdentifier.isEmpty else { throw ConfigurationError.invalidIdentifier }
if saved.connection != candidate.connection {
// Queue/tracking coordination will supply this guard before changing the active destination.
try beforeConnectionChange(saved.connection, candidate.connection)
}
try storage.save(candidate)
self.saved = candidate
draft = candidate
errorMessage = nil
showStatus("Configuration saved locally. Tracking remains paused.")
return true
} catch {
errorMessage = message(for: error)
showStatus(nil)
return false
}
}
private static func requiresAuthorization(_ error: Error) -> Bool {
guard case ConfigurationError.keychain(let status) = error else { return false }
return [errSecInteractionNotAllowed, errSecInteractionRequired, errSecAuthFailed, errSecUserCanceled]
.contains(status)
}
private func showStatus(_ message: String?) {
statusTask?.cancel()
statusMessage = message
guard message != nil else { return }
let delay = statusDelay
statusTask = Task { [weak self] in
do {
try await delay()
try Task.checkCancellation()
self?.statusMessage = nil
} catch {
// Cancellation leaves the newer message or persistent error in control.
}
}
}
private func message(for error: Error) -> String {
if let error = error as? ConfigurationError { return error.localizedDescription }
return "Configuration could not be saved or loaded. Check storage and try again."
}
}
@@ -0,0 +1,164 @@
import Foundation
import Security
@MainActor
protocol ConnectionStorage {
func read(allowInteraction: Bool) throws -> ReceiverConnection?
func write(_ connection: ReceiverConnection) throws
}
@MainActor
protocol ConfigurationStorage {
func load(allowInteraction: Bool) throws -> AppConfiguration
func save(_ configuration: AppConfiguration) throws
}
extension ConnectionStorage {
func read() throws -> ReceiverConnection? { try read(allowInteraction: true) }
}
extension ConfigurationStorage {
func load() throws -> AppConfiguration { try load(allowInteraction: true) }
}
@MainActor
final class PreferencesConfigurationStorage: ConfigurationStorage {
private struct Record: Codable {
var version = 1
var tracking: TrackingPreferences
}
static let preferencesKey = "configuration.v1"
private let preferences: UserDefaults
private let connectionStorage: any ConnectionStorage
init(preferences: UserDefaults, connectionStorage: any ConnectionStorage) {
self.preferences = preferences
self.connectionStorage = connectionStorage
}
func load(allowInteraction: Bool) throws -> AppConfiguration {
let data = preferences.data(forKey: Self.preferencesKey)
let record: Record?
if let data {
do {
record = try JSONDecoder().decode(Record.self, from: data)
} catch {
throw ConfigurationError.unreadableStorage
}
guard record?.version == 1 else { throw ConfigurationError.unsupportedStorageVersion }
} else if preferences.object(forKey: Self.preferencesKey) != nil {
throw ConfigurationError.unreadableStorage
} else {
record = nil
}
let connection = try connectionStorage.read(allowInteraction: allowInteraction)
if record != nil && connection == nil { throw ConfigurationError.missingIdentity }
var configuration = AppConfiguration()
if let connection {
guard !connection.deviceIdentifier.isEmpty else { throw ConfigurationError.missingIdentity }
configuration.connection = try connection.validated()
}
if let record { configuration.tracking = record.tracking }
try configuration.tracking.validate()
return configuration
}
func save(_ configuration: AppConfiguration) throws {
let connection = try configuration.connection.validated()
guard !connection.deviceIdentifier.isEmpty else { throw ConfigurationError.invalidIdentifier }
try configuration.tracking.validate()
let data = try JSONEncoder().encode(Record(tracking: configuration.tracking))
// Keychain errors leave UserDefaults untouched. Connection and identity share one secure item.
try connectionStorage.write(connection)
preferences.set(data, forKey: Self.preferencesKey)
}
}
@MainActor
protocol KeychainOperations {
func copyMatching(
_ query: CFDictionary, allowInteraction: Bool, result: UnsafeMutablePointer<CFTypeRef?>
) -> OSStatus
func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus
func add(_ attributes: CFDictionary) -> OSStatus
}
@MainActor
struct SystemKeychainOperations: KeychainOperations {
func copyMatching(
_ query: CFDictionary, allowInteraction: Bool, result: UnsafeMutablePointer<CFTypeRef?>
) -> OSStatus {
if allowInteraction { return SecItemCopyMatching(query, result) }
// SecItem's LAContext/UI flags do not suppress dialogs for file-based keychains.
// Keep this synchronous and on MainActor with every other Keychain operation.
var previous: DarwinBoolean = false
var status = SecKeychainGetUserInteractionAllowed(&previous)
guard status == errSecSuccess else { return status }
status = SecKeychainSetUserInteractionAllowed(false)
guard status == errSecSuccess else { return status }
let readStatus = SecItemCopyMatching(query, result)
let restoreStatus = SecKeychainSetUserInteractionAllowed(previous.boolValue)
return restoreStatus == errSecSuccess ? readStatus : restoreStatus
}
func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus {
SecItemUpdate(query, attributes)
}
func add(_ attributes: CFDictionary) -> OSStatus {
SecItemAdd(attributes, nil)
}
}
@MainActor
final class KeychainConnectionStorage: ConnectionStorage {
private let service: String
private let operations: any KeychainOperations
private let account = "receiver-identity.v1"
init(
service: String = "com.codigojose.traccar-client-macos.configuration",
operations: any KeychainOperations = SystemKeychainOperations()
) {
self.service = service
self.operations = operations
}
private var query: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: false,
]
}
func read(allowInteraction: Bool) throws -> ReceiverConnection? {
var request = query
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = operations.copyMatching(
request as CFDictionary, allowInteraction: allowInteraction, result: &result
)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw ConfigurationError.keychain(status) }
guard let data = result as? Data,
let connection = try? JSONDecoder().decode(ReceiverConnection.self, from: data) else {
throw ConfigurationError.unreadableStorage
}
return connection
}
func write(_ connection: ReceiverConnection) throws {
let data = try JSONEncoder().encode(connection)
var status = operations.update(query as CFDictionary, attributes: [kSecValueData: data] as CFDictionary)
if status == errSecItemNotFound {
var attributes = query
attributes[kSecValueData as String] = data
status = operations.add(attributes as CFDictionary)
}
guard status == errSecSuccess else { throw ConfigurationError.keychain(status) }
}
}
+161 -22
View File
@@ -1,34 +1,173 @@
import AppKit
import SwiftUI
struct SettingsView: View {
@Bindable var model: ConfigurationModel
@State private var focus = WindowFocusController()
var body: some View {
TabView {
Form {
LabeledContent("Status", value: "Paused")
Text("Server and device configuration will be available in a later build.")
VStack(spacing: 12) {
if model.hasLoaded {
TabView {
connection
.tabItem { Label("Connection", systemImage: "network") }
tracking
.tabItem { Label("Tracking", systemImage: "location") }
general
.tabItem { Label("General", systemImage: "gearshape") }
diagnostics
.tabItem { Label("Diagnostics", systemImage: "waveform.path.ecg") }
}
} else {
keychainIntroduction
}
.tabItem { Label("Connection", systemImage: "network") }
Form {
Text("Continuous tracking, manual location and SOS are not implemented yet.")
Text("This build does not request your location or send positions.")
VStack(alignment: .leading) {
if let error = model.errorMessage {
Label(error, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red).textSelection(.enabled)
} else if let status = model.statusMessage {
Label(status, systemImage: "info.circle").foregroundStyle(.secondary)
}
}
.tabItem { Label("Tracking", systemImage: "location") }
Form {
Text("Login startup and tracking restoration are not enabled.")
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 64, alignment: .topLeading)
HStack {
if !model.hasLoaded {
Button(model.errorMessage == nil ? "Access Keychain" : "Retry loading") {
focus.perform { model.reload() }
}
.buttonStyle(.borderedProminent)
}
Spacer()
if model.hasLoaded {
Button("Save") { focus.perform { model.save() } }
.keyboardShortcut("s", modifiers: .command)
}
}
.tabItem { Label("General", systemImage: "gearshape") }
Form {
LabeledContent("Status", value: "Paused")
Text("No location or network activity is started by this application shell.")
}
.tabItem { Label("Diagnostics", systemImage: "waveform.path.ecg") }
}
.textFieldStyle(.roundedBorder)
.formStyle(.grouped)
.padding()
.frame(width: 560, height: 320)
.frame(width: 720, height: 700)
.background(SettingsWindowReader(focus: focus).allowsHitTesting(false))
.onAppear { model.loadIfNeeded() }
}
private var keychainIntroduction: some View {
VStack(alignment: .leading, spacing: 18) {
Label(model.needsKeychainAuthorization ? "Keychain authorization required" : "Configuration unavailable",
systemImage: "lock.shield")
.font(.title2)
Text("Your server URL and device identifier are protected in the macOS Keychain.")
Text("Settings load automatically when access is available. Continue only if macOS needs authorization.")
Label("macOS may ask for your login keychain password. This app never receives that password.",
systemImage: "info.circle")
Text("In the system dialog, Always Allow remembers access for the recognized app. You can also cancel.")
Text("Cancelling leaves your saved configuration intact. Access is retried only when you choose to retry.")
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
private var connection: some View {
Form {
Section("Traccar Server") {
VStack(alignment: .leading, spacing: 6) {
Text("Server URL").font(.headline)
TextField("http://demo.traccar.org:5055", text: $model.draft.connection.serverURL)
.textFieldStyle(.roundedBorder)
.labelsHidden()
.accessibilityLabel("Server URL")
.privacySensitive()
}
Text("HTTP or HTTPS, with an IP address or domain and an optional port and path.")
if model.draft.connection.isDemo {
Label("Traccar public demo server. Configure your own receiver before sending private locations.",
systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
Section("Device identity") {
VStack(alignment: .leading, spacing: 6) {
Text("Unique identifier").font(.headline)
TextField("Leave blank to generate an identifier", text: $model.draft.connection.deviceIdentifier)
.textFieldStyle(.roundedBorder)
.labelsHidden()
.accessibilityLabel("Unique identifier")
.privacySensitive()
}
Text("Use an existing identifier to continue its history. Leave blank to generate eight digits.")
Text("Generated on save. Add it to the server separately; saving does not register a device.")
}
Label("Saving does not start tracking, request location access or contact the server.",
systemImage: "info.circle")
.foregroundStyle(.secondary)
}
.disabled(!model.hasLoaded)
}
private var tracking: some View {
Form {
Picker("Accuracy", selection: $model.draft.tracking.accuracy) {
ForEach(LocationAccuracy.allCases, id: \.self) { accuracy in
Text(accuracy.title).tag(accuracy)
}
}
TextField("Interval (seconds)", value: $model.draft.tracking.intervalSeconds, format: .number)
TextField("Distance (metres)", value: $model.draft.tracking.distanceMetres, format: .number)
Text("A position will be selected when time OR distance is reached. Zero disables that threshold.")
TextField("Angle (degrees)", value: $model.draft.tracking.angleDegrees, format: .number)
TextField("Heartbeat (seconds)", value: $model.draft.tracking.heartbeatSeconds, format: .number)
Toggle("Buffer positions offline", isOn: $model.draft.tracking.bufferingEnabled)
Toggle("Detect stationary periods", isOn: $model.draft.tracking.stationaryDetectionEnabled)
Text("Preferences are saved for later tracking implementation. Tracking remains paused in this build.")
}
.disabled(!model.hasLoaded)
}
private var general: some View {
Form {
Toggle("Launch at login", isOn: $model.draft.tracking.launchAtLogin).disabled(true)
Toggle("Restore continuous tracking", isOn: $model.draft.tracking.restoreTracking).disabled(true)
Text("Login startup and tracking restoration will be available in a later build. Both default to off.")
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
}
}
private var diagnostics: some View {
Form {
LabeledContent("Status", value: "Paused")
LabeledContent("Configuration", value: model.hasLoaded ? "Loaded locally" : "Unavailable")
Text("Connection and identity: Keychain. Tracking preferences: UserDefaults.")
Text("No location permission requests or network activity are started by loading or saving settings.")
}
}
}
private struct SettingsWindowReader: NSViewRepresentable {
let focus: WindowFocusController
func makeNSView(context: Context) -> WindowReaderView {
WindowReaderView(focus: focus)
}
func updateNSView(_ nsView: WindowReaderView, context: Context) {}
final class WindowReaderView: NSView {
private let focus: WindowFocusController
init(focus: WindowFocusController) {
self.focus = focus
super.init(frame: .zero)
}
required init?(coder: NSCoder) { nil }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
focus.window = window
}
}
}
+2 -2
View File
@@ -20,13 +20,13 @@ struct TrackingMenu: View {
Divider()
Button("Settings…") {
NSApplication.shared.activate(ignoringOtherApps: true)
NSApplication.shared.activate()
openSettings()
}
.keyboardShortcut(",")
Button("About Traccar Client for macOS") {
NSApplication.shared.activate(ignoringOtherApps: true)
NSApplication.shared.activate()
NSApplication.shared.orderFrontStandardAboutPanel(options: [
.applicationName: "Traccar Client for macOS",
.credits: NSAttributedString(
@@ -0,0 +1,63 @@
import AppKit
@MainActor
protocol FocusRestorationWindow: AnyObject {
var isVisible: Bool { get }
var isMiniaturized: Bool { get }
func restoreKeyWindow()
}
extension NSWindow: FocusRestorationWindow {
func restoreKeyWindow() { makeKeyAndOrderFront(nil) }
}
@MainActor
final class WindowFocusController {
// A workspace activation sends a reopen event. Its window is selected only after activation finishes.
private(set) static var pendingActivations = 0
weak var window: (any FocusRestorationWindow)?
private var restoration: Task<Void, Never>?
private let activate: @MainActor () async -> Bool
init(activate: @escaping @MainActor () async -> Bool = activateCurrentApplication) {
self.activate = activate
}
@discardableResult
func perform(_ action: () -> Void) -> Task<Void, Never>? {
restoration?.cancel()
let initiatingWindow = window
action()
guard let initiatingWindow, initiatingWindow.isVisible, !initiatingWindow.isMiniaturized else { return nil }
let activate = activate
restoration = Task { [weak initiatingWindow] in
guard !Task.isCancelled, let initiatingWindow,
initiatingWindow.isVisible, !initiatingWindow.isMiniaturized else { return }
Self.pendingActivations += 1
defer { Self.pendingActivations -= 1 }
guard await activate(), !Task.isCancelled,
initiatingWindow.isVisible, !initiatingWindow.isMiniaturized else { return }
initiatingWindow.restoreKeyWindow()
}
return restoration
}
private static func activateCurrentApplication() async -> Bool {
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
configuration.createsNewApplicationInstance = false
configuration.allowsRunningApplicationSubstitution = false
configuration.addsToRecentItems = false
configuration.promptsUserIfNeeded = false
do {
// Launch Services supplies activation context that a plain NSApp.activate() cannot guarantee.
let application = try await NSWorkspace.shared.openApplication(
at: Bundle.main.bundleURL, configuration: configuration
)
return application.processIdentifier == ProcessInfo.processInfo.processIdentifier
} catch {
return false
}
}
}