import Foundation /// Loads a JSON config file from `~/.config/com.lilith.mac-sync/config.json` and /// merges the values into `UserDefaults.standard` at launch. /// /// ## Why /// /// UserDefaults is native but opaque — `defaults read com.lilith.mac-sync` is the /// only way to see the current configuration. A plain-text file at a conventional /// XDG-style path is self-documenting: users can `cat` it, edit it in any editor, /// version-control it with their dotfiles, or symlink it in from another host. /// /// ## Precedence (highest wins) /// /// 1. `~/.config/com.lilith.mac-sync/config.json` (this loader, called at launch) /// 2. `defaults write com.lilith.mac-sync ` (runtime overrides) /// 3. Compiled-in defaults (e.g. `http://10.0.0.11:3201`) /// /// The file wins because `load()` writes to UserDefaults at every launch — any /// manual `defaults write` done between launches gets overwritten on restart. /// Users who prefer runtime-only config can simply delete the file. /// /// ## Keys understood /// /// - `serverURL` — String. Base URL of mac-sync-server (default `http://10.0.0.11:3201`). /// - `webServerPort` — Int. Port for the local status webapp (default 8765). /// - `deviceName` — String. Human-readable device name used at registration. /// - `imessageOwnAddresses` — [String]. Phone numbers / emails the user owns (used to /// classify iMessage direction). Rarely needed; auto-detected from chat.db in most cases. /// - `imailOwnAddresses` — [String]. Email addresses the user owns (used by iMail to /// classify incoming/outgoing). /// - `imessageMaxMessagesPerBatch` — Int. Max messages per initial-sync batch request (default 300). /// - `imessageMaxConversationsPerBatch` — Int. Max conversations per initial-sync batch (default 5). /// - `quinnApiURL` — String. Base URL of the quinn.api instance this MacSync companion /// talks to (e.g. `http://10.0.0.116:3030` on LAN, `http://localhost:3030` when co-resident). /// Owned by the host operator; mac-sync does not discover this and has no opinion on what /// `lilith-platform.live` is running. Used by the contact-render poller to pull CNContactStore /// write deltas. /// - `quinnApiServiceToken` — String. Service-key header value for quinn.api `/m/*` endpoints. /// /// Unknown keys are copied through verbatim, so any future module can define its /// own settings without changes to this loader. public enum ConfigFile { public static let directory = "\(NSHomeDirectory())/.config/com.lilith.mac-sync" public static let path = "\(directory)/config.json" /// Merge config file values into UserDefaults. Safe to call multiple times. /// Logs a line per key merged. Does nothing if the file is missing or empty. public static func load() { let fm = FileManager.default guard fm.fileExists(atPath: path) else { NSLog("ConfigFile: no file at \(path) — using UserDefaults + compiled-in defaults") return } guard let data = fm.contents(atPath: path) else { NSLog("ConfigFile: cannot read \(path)") return } guard let raw = try? JSONSerialization.jsonObject(with: data), let dict = raw as? [String: Any] else { NSLog("ConfigFile: \(path) is not valid JSON object") return } for (key, value) in dict { UserDefaults.standard.set(value, forKey: key) } let keyList = dict.keys.sorted().joined(separator: ", ") NSLog("ConfigFile: merged \(dict.count) keys from \(path): \(keyList)") } /// Merge `updates` into the config file and into UserDefaults. /// Called by LocalWebServer when the user saves settings from the web UI. public static func merge(_ updates: [String: Any]) { let fm = FileManager.default var existing: [String: Any] = [:] if fm.fileExists(atPath: path), let data = fm.contents(atPath: path), let raw = try? JSONSerialization.jsonObject(with: data), let dict = raw as? [String: Any] { existing = dict } for (k, v) in updates { existing[k] = v } guard let data = try? JSONSerialization.data( withJSONObject: existing, options: [.prettyPrinted, .sortedKeys] ) else { return } try? fm.createDirectory(atPath: directory, withIntermediateDirectories: true) try? data.write(to: URL(fileURLWithPath: path)) NSLog("ConfigFile: merged \(updates.keys.sorted().joined(separator: ", "))") } /// Write a documented template file if none exists. Install scripts can call this /// to give the user a starting point. Never overwrites an existing file. public static func writeTemplateIfMissing(serverURL: String = "http://10.0.0.11:3201") { let fm = FileManager.default if fm.fileExists(atPath: path) { return } try? fm.createDirectory(atPath: directory, withIntermediateDirectories: true) let template: [String: Any] = [ "serverURL": serverURL, "webServerPort": 8765, ] guard let data = try? JSONSerialization.data(withJSONObject: template, options: [.prettyPrinted, .sortedKeys]) else { return } try? data.write(to: URL(fileURLWithPath: path)) NSLog("ConfigFile: wrote template to \(path)") } }