153 lines
4.9 KiB
Swift
153 lines
4.9 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
public struct SyncConfig: Codable, Equatable {
|
|
public var serverURL: String
|
|
public var apiToken: String?
|
|
public var syncFolder: String?
|
|
public var defaultUploadFolder: String?
|
|
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,
|
|
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.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 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)
|
|
}
|
|
}
|