// // Preferences.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import Logging import LuminateCore import Portico private let logger = Logger(label: "\(AppInfo.identifier).preferences") /// The main-actor front end over a ``PreferenceStoring`` backend. /// /// Reads and writes stay in memory on the UI side; each change is mirrored into the backend's /// ordered write queue. @MainActor package final class Preferences { /// The process-wide coordinator every `Preference` resolves to unless a subtree injects another. package private(set) static var shared = Preferences(store: EphemeralPreferenceStore()) /// Whether `bootstrap(_:)` has already installed a backend. private static var didBootstrap = false /// Installs the process-wide coordinator over `store` and registers its quit-time flush. /// /// Must be called exactly once, before the first view mounts. /// /// - Parameter store: The backend that owns persisted values and asynchronous writes. package static func bootstrap(_ store: any PreferenceStoring) { precondition( !didBootstrap, "Preferences.bootstrap(_:) must be called exactly once, before the first view mounts." ) didBootstrap = true shared = Preferences(store: store) ApplicationLifecycle.onShutdown { [store] in do { try await store.flush() } catch { logger.error("Failed to flush preferences on quit", error: error) } } } private let store: any PreferenceStoring private var slots: [String: AnyObject] = [:] private let snapshot: [String: StoredPreference] /// Creates a coordinator over `store` and captures its launch snapshot. /// /// - Parameter store: The backend that owns persisted values and asynchronous writes. package init(store: any PreferenceStoring) { self.store = store snapshot = store.initialSnapshot } /// The observable cell for `key`, created and hooked up to persistence on first request. /// /// - Parameter key: The typed preference identifier. /// - Returns: The shared observable slot for this key. package func slot(for key: PreferenceKey) -> PreferenceSlot { if let existing = slots[key.name] { guard let typed = existing as? PreferenceSlot else { preconditionFailure( "Preference key \"\(key.name)\" is used with two different value types." ) } return typed } let slot = PreferenceSlot(restoredValue(for: key)) { [store] newValue in store.enqueue(key.name, newValue?.preferenceValue) } slots[key.name] = slot return slot } /// The current value for `key`, or `nil` when unset. package subscript(key: PreferenceKey) -> Value? { get { slot(for: key).value } set { slot(for: key).value = newValue } } /// A live two-way binding to `key`. /// /// - Parameter key: The typed preference identifier. /// - Returns: A binding that tracks reads and persists writes. package func binding(for key: PreferenceKey) -> Binding { Binding(slot(for: key), \PreferenceSlot.value) } /// Decodes the launch snapshot row for `key`, warning when its stored type is incompatible. private func restoredValue(for key: PreferenceKey) -> Value? { guard let stored = snapshot[key.name] else { return nil } guard let value = Value(preference: stored) else { logger.warning( "Stored preference does not match its declared type; treating it as unset", metadata: ["key": "\(key.name)"] ) return nil } return value } /// Waits for every queued write to reach the backend. /// /// - Throws: The first backend failure recorded since the previous flush. package func flush() async throws { try await store.flush() } }