Files
cairnquire/apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
2026-06-04 08:50:34 -04:00

165 lines
5.2 KiB
Swift

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