Sources/OrgApp/ConfigSync.swift
136 lines · 6554 bytes
1#if os(macOS)
2import Foundation
3import OrgCore
4import OrgPresentation
5import OrgWorkspace
6
7/// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch
8/// and whenever it changes on disk, and a change made in the app is written into the file. A
9/// missing file is created with every setting at its current value.
10@MainActor
11@Observable
12public final class ConfigSync {
13 public static let shared = ConfigSync()
14
15 /// Problems in the file, from the last time it was read.
16 public private(set) var problems: [String] = []
17 /// The colors of `[theme]`, `[theme.light]`, `[theme.dark]` and `[theme.todo]` over the
18 /// default theme, with `theme-file`'s under them.
19 public private(set) var theme = ThemeSpec.default
20 /// The last file of the configuration folder that changed, with a serial number.
21 public private(set) var changedFile: (name: String, serial: Int)?
22
23 private let url: URL
24 private let defaults: UserDefaults
25 @ObservationIgnored private var text: String?
26 @ObservationIgnored private var configProblems: [String] = []
27 @ObservationIgnored private var applying = false
28 @ObservationIgnored private var watcher: FSEventsWatcher?
29 @ObservationIgnored private var observer: NSObjectProtocol?
30 @ObservationIgnored private var serial = 0
31
32 public init(url: URL = UserConfig.configFile, defaults: UserDefaults = .standard) {
33 self.url = url
34 self.defaults = defaults
35 }
36
37 public func start(watch: Bool = true) {
38 guard observer == nil else { return }
39 try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
40 let reference = url.deletingLastPathComponent().appendingPathComponent("default-theme.toml")
41 if (try? String(contentsOf: reference, encoding: .utf8)) != ThemeSpec.defaultThemeFile() {
42 try? ThemeSpec.defaultThemeFile().write(to: reference, atomically: true, encoding: .utf8)
43 }
44 if FileManager.default.fileExists(atPath: url.path) {
45 reload()
46 // A file from before settings took Emacs's names gets them in place; one from
47 // before a section existed gets the section.
48 if let text {
49 let migrated = UserConfig.usesLegacyNames(text) ? UserConfig.migratingLegacyNames(text) : text
50 let completed = UserConfig.addingMissingSections(migrated, from: defaults)
51 if completed != text {
52 try? completed.write(to: url, atomically: true, encoding: .utf8)
53 self.text = completed
54 }
55 }
56 } else {
57 let template = UserConfig.template(from: defaults)
58 try? template.write(to: url, atomically: true, encoding: .utf8)
59 text = template
60 }
61 observer = NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: defaults, queue: .main) { [weak self] _ in
62 MainActor.assumeIsolated { self?.writeBack() }
63 }
64 guard watch else { return }
65 let directory = url.deletingLastPathComponent().resolvingSymlinksInPath()
66 // Files still read from where earlier versions kept them reload when edited there too.
67 let legacy = UserConfig.legacyDirectory.resolvingSymlinksInPath()
68 let watchesLegacy = legacy.path != directory.path && FileManager.default.fileExists(atPath: legacy.path)
69 watcher = FSEventsWatcher(roots: [directory] + (watchesLegacy ? [legacy] : [])) { [weak self] events in
70 let changed = Set(events.compactMap { event -> URL? in
71 if case .changed(let url) = event { return url }
72 return nil
73 })
74 Task { @MainActor [weak self] in
75 guard let self else { return }
76 for file in changed.sorted(by: { $0.path < $1.path }) {
77 if !file.path.hasPrefix(directory.path + "/"),
78 UserConfig.file(file.lastPathComponent).resolvingSymlinksInPath().path != file.resolvingSymlinksInPath().path { continue }
79 let name = file.lastPathComponent
80 if name == self.url.lastPathComponent { self.reload() }
81 if self.isThemeFile(file) { self.readTheme() }
82 self.serial += 1
83 self.changedFile = (name, self.serial)
84 }
85 }
86 }
87 watcher?.start()
88 }
89
90 /// Reads the file and applies it when it changed since the last read or write.
91 public func reload() {
92 guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return }
93 text = new
94 applying = true
95 configProblems = UserConfig.apply(new, to: defaults)
96 applying = false
97 readTheme()
98 }
99
100 /// `theme-file`, relative to the configuration folder.
101 private var themeFile: URL? {
102 guard let name = defaults.string(forKey: "themeFile"), !name.isEmpty else { return nil }
103 return url.deletingLastPathComponent().appendingPathComponent(name)
104 }
105
106 /// Whether `file`, as the watcher reports it, is `theme-file`, which may be in a subfolder.
107 func isThemeFile(_ file: URL) -> Bool {
108 themeFile?.resolvingSymlinksInPath().path == file.resolvingSymlinksInPath().path
109 }
110
111 /// The theme from the file read last and its theme file.
112 func readTheme() {
113 guard let text, let read = UserConfig.theme(
114 config: text, themeFile: defaults.string(forKey: "themeFile"), in: url.deletingLastPathComponent()
115 ) else { return }
116 if read.theme != theme { theme = read.theme }
117 problems = configProblems + read.problems
118 }
119
120 /// Writes settings that differ from the file into it.
121 func writeBack() {
122 guard !applying, var current = text ?? (try? String(contentsOf: url, encoding: .utf8)) else { return }
123 let values = UserConfig.values(in: current)
124 let original = current
125 for setting in UserConfig.settings where defaults.object(forKey: setting.defaultsKey) != nil {
126 let value = UserConfig.value(setting, in: defaults)
127 if values[setting.path] != value {
128 current = UserConfig.setting(current, section: setting.section, key: setting.key, to: value)
129 }
130 }
131 guard current != original else { return }
132 text = current
133 try? current.write(to: url, atomically: true, encoding: .utf8)
134 }
135}
136#endif