import Foundation import Security public enum NewNoteAction: String, Codable, CaseIterable { case clipboard case editor } public struct SyncConfig: Codable, Equatable { public var serverURL: String public var apiToken: String? public var syncFolder: String? public var defaultUploadFolder: String? public var newNoteAction: NewNoteAction? public var autoSync: Bool public var syncInterval: Int // seconds public var deviceId: String public var iconName: String // SF Symbol name public static let iconOptions = [ "tray.circle.fill", "arrow.triangle.2.circlepath", "arrow.up.doc", "doc.badge.arrow.up", "arrow.up.and.down.and.arrow.left.and.right", "cloud", "externaldrive.connected.to.line.below", "note.text", "doc.text", "arrow.clockwise.circle", "rectangle.stack.badge.plus" ] public init( serverURL: String = "http://localhost:8080", apiToken: String? = nil, syncFolder: String? = nil, defaultUploadFolder: String? = nil, newNoteAction: NewNoteAction? = nil, autoSync: Bool = true, syncInterval: Int = 30, deviceId: String? = nil, iconName: String = "tray.circle.fill" ) { self.serverURL = serverURL self.apiToken = apiToken self.syncFolder = syncFolder self.defaultUploadFolder = defaultUploadFolder self.newNoteAction = newNoteAction self.autoSync = autoSync self.syncInterval = syncInterval self.deviceId = deviceId ?? SyncConfig.generateDeviceId() self.iconName = iconName } private static func generateDeviceId() -> String { if let existing = UserDefaults.standard.string(forKey: "cairnquire_device_id") { return existing } let id = "mac-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))" UserDefaults.standard.set(id, forKey: "cairnquire_device_id") return id } public var serverBaseURL: URL? { URL(string: serverURL) } public var effectiveNewNoteAction: NewNoteAction { newNoteAction ?? .clipboard } } public actor ConfigStore { public static let shared = ConfigStore() private let defaults = UserDefaults.standard private let keychainService = "com.cairnquire.sync" private let configKey = "cairnquire_config" public init() {} nonisolated public func load() -> SyncConfig { let defaults = UserDefaults.standard if let data = defaults.data(forKey: configKey), let config = try? JSONDecoder().decode(SyncConfig.self, from: data) { var config = config // Load token from keychain if let token = loadTokenFromKeychain() { config.apiToken = token } return config } return SyncConfig() } public func save(_ config: SyncConfig) { let defaults = UserDefaults.standard var configToSave = config let token = config.apiToken configToSave.apiToken = nil if let data = try? JSONEncoder().encode(configToSave) { defaults.set(data, forKey: configKey) } if let token = token { saveTokenToKeychain(token) } else { deleteTokenFromKeychain() } } private nonisolated func saveTokenToKeychain(_ token: String) { let data = token.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "api_token" ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound { var newItem = query newItem.merge(attributes) { _, new in new } SecItemAdd(newItem as CFDictionary, nil) } } private nonisolated func loadTokenFromKeychain() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "api_token", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else { return nil } return token } private nonisolated func deleteTokenFromKeychain() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: "api_token" ] SecItemDelete(query as CFDictionary) } }