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

+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
}
}
}