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,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