mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 02:00:45 +01:00
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:
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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user