mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 00:00:45 +01:00
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
165 lines
6.3 KiB
Swift
165 lines
6.3 KiB
Swift
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) }
|
|
}
|
|
}
|