mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 03:00:46 +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
@@ -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