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
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user