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

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