krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgApp/ConfigSync.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgApp/ConfigSync.swift history · blame · raw

136 lines · 6554 bytes

6 symbols in this file
  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