macsync/@packages/shared/Sources/MacSyncShared/Storage/ConfigFile.swift

108 lines
5.3 KiB
Swift
Raw Normal View History

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 <key> <value>` (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)")
}
}