Add persistence layer

This commit is contained in:
Brendan Szymanski 2026-08-05 12:12:32 -04:00
parent a5ee2edf5d
commit 0addde59d3
34 changed files with 2199 additions and 34 deletions

View file

@ -0,0 +1,5 @@
module CSQLite [system] {
header "shim.h"
link "sqlite3"
export *
}

1
Sources/CSQLite/shim.h Normal file
View file

@ -0,0 +1 @@
#include <sqlite3.h>

View file

@ -0,0 +1,48 @@
//
// ClientSessionBinder.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateServices
import LuminateUI
import Portico
/// Keeps the live Jellyfin client in step with session preference changes.
@MainActor package final class ClientSessionBinder {
private var tokens: [SubscriptionToken] = []
/// Subscribes to the server URL and access token preferences.
///
/// - Parameters:
/// - client: The client to reconfigure.
/// - preferences: The coordinator owning the preference slots.
package init(client: JellyfinClient, preferences: Preferences = .shared) {
tokens.append(
preferences.binding(for: .serverURL).subscribe { url in
guard let url else { return }
Task { await client.setServerURL(url) }
}
)
tokens.append(
preferences.binding(for: .accessToken).subscribe { token in
Task { await client.setAccessToken(token) }
}
)
}
}

View file

@ -0,0 +1,47 @@
//
// LogBootstrap.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
import Logging
import LuminateCore
/// Installs Luminate's process-wide logging backend.
package enum LogBootstrap {
/// Environment variable naming the minimum level to emit.
package static let logLevelVariable = "LUMINATE_LOG_LEVEL"
/// Routes every logger in the process to standard error at the configured level.
///
/// `LoggingSystem.bootstrap` may be called at most once per process, so this must be called
/// exactly once during application initialization and never from a test.
///
/// The level comes from `LUMINATE_LOG_LEVEL` and defaults to `info` when unset or unparseable.
package static func bootstrap() {
let level =
ProcessInfo.processInfo.environment[logLevelVariable]
.flatMap(Logger.Level.init(rawValue:)) ?? .info
LoggingSystem.bootstrap { label in
var handler = StreamLogHandler.standardError(label: label)
handler.logLevel = level
return handler
}
}
}

View file

@ -20,26 +20,34 @@
//
import Foundation
import LuminateCore
import LuminateServices
import LuminateStore
import LuminateUI
import Portico
/// The Luminate application entry point.
///
/// Owns the single ``JellyfinClient`` for the process and publishes it to the whole view tree
/// through the `\.client` environment slot, so no screen constructs its own client.
/// Owns the process-wide Jellyfin client and publishes it to the whole view tree; preferences
/// install themselves through `Preferences.bootstrap(_:)`.
@main
struct Luminate: App {
var applicationId: String? { "dev.bscubed.Luminate" }
var applicationId: String? { AppInfo.identifier }
/// The process-wide Jellyfin service.
///
/// Starts pointed at `http://localhost`, which is the server URL the Jellyfin specification
/// declares. Onboarding replaces it with the user's chosen server through
/// ``JellyfinClient/setServerURL(_:)``; nothing here issues a request.
private let jellyfin = JellyfinClient(
configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!)
)
private let jellyfin: JellyfinClient
private let sessionBinder: ClientSessionBinder
init() {
LogBootstrap.bootstrap()
Preferences.bootstrap(PreferenceBackend.open())
jellyfin = JellyfinClient(
configuration: JellyfinClientConfiguration(
serverURL: Preferences.shared[.serverURL],
accessToken: Preferences.shared[.accessToken]
)
)
sessionBinder = ClientSessionBinder(client: jellyfin)
}
var body: some Scene {
ApplicationWindow { _ in
@ -51,12 +59,12 @@ struct Luminate: App {
/// The root of the window's view tree.
///
/// Reads the injected client so a launch proves the `\.client` slot resolves end to end; Portico
/// traps at mount if a key-path slot cannot be resolved.
/// Reads the injected client and a preference so a launch proves both resolve end to end.
private struct RootView: View {
@Environment(\.client) private var jellyfinClient
@Preference(.serverURL) private var serverURL: URL?
var body: some View {
Label("Hello, world!")
Label { serverURL?.absoluteString ?? "No server configured" }
}
}

View file

@ -0,0 +1,32 @@
//
// AppInfo.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
/// Identity constants for the application, shared by every target.
package enum AppInfo {
/// The user-visible application name, also the `Client` value Jellyfin records.
package static let name = "Luminate"
/// The reverse-DNS application identifier used for GTK single-instance uniqueness.
package static let identifier = "dev.bscubed.Luminate"
/// The on-disk directory name for Luminate's own data, under the platform data home.
package static let directoryName = "luminate"
/// The application version reported to Jellyfin and written into new databases.
package static let version = "0.1.0"
}

View file

@ -0,0 +1,86 @@
//
// AppPaths.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
/// Platform-aware locations for Luminate's persistent data.
package enum AppPaths {
/// Environment variable that overrides the data directory outright; used by tests and CI.
package static let dataDirectoryOverrideVariable = "LUMINATE_DATA_DIR"
/// The directory Luminate stores its own data in.
///
/// - Parameters:
/// - environment: Environment values used to resolve the location.
/// - home: The user's home directory, or `nil` when unavailable.
/// - temporaryDirectory: The fallback temporary directory.
/// - Returns: The resolved application data directory.
package static func dataDirectory(
environment: [String: String] = ProcessInfo.processInfo.environment,
home: URL? = FileManager.default.homeDirectoryForCurrentUser,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> URL {
if let override = environment[dataDirectoryOverrideVariable], override.hasPrefix("/") {
return URL(filePath: override)
}
#if canImport(Darwin)
let baseDirectory = home?.appending(
path: "Library/Application Support",
directoryHint: .isDirectory
)
#else
let xdgDataHome = environment["XDG_DATA_HOME"]
let baseDirectory: URL?
if let xdgDataHome, !xdgDataHome.isEmpty, xdgDataHome.hasPrefix("/") {
baseDirectory = URL(filePath: xdgDataHome)
} else if let home, !home.path.isEmpty {
baseDirectory = home.appending(path: ".local/share", directoryHint: .isDirectory)
} else {
baseDirectory = nil
}
#endif
if let baseDirectory {
return baseDirectory.appending(path: AppInfo.directoryName, directoryHint: .isDirectory)
}
return temporaryDirectory.appending(path: AppInfo.directoryName, directoryHint: .isDirectory)
}
/// The preferences database inside ``dataDirectory(environment:home:temporaryDirectory:)``.
///
/// - Parameters:
/// - environment: Environment values used to resolve the location.
/// - home: The user's home directory, or `nil` when unavailable.
/// - temporaryDirectory: The fallback temporary directory.
/// - Returns: The preferences database URL.
package static func preferencesDatabaseURL(
environment: [String: String] = ProcessInfo.processInfo.environment,
home: URL? = FileManager.default.homeDirectoryForCurrentUser,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> URL {
dataDirectory(
environment: environment,
home: home,
temporaryDirectory: temporaryDirectory
).appending(path: "preferences.sqlite", directoryHint: .notDirectory)
}
}

View file

@ -0,0 +1,34 @@
//
// PreferenceStoreError.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
/// A failure raised by Luminate's preference storage layer.
package enum PreferenceStoreError: Error, Hashable, Sendable {
/// SQLite refused to open the file at `path`; `message` is `sqlite3_errmsg`.
case cannotOpen(path: String, message: String)
/// A statement failed; `message` is `sqlite3_errmsg`.
case sqlite(message: String)
/// The file exists but is not a Luminate database.
case notALuminateDatabase(applicationID: Int32)
/// The database was written by a build whose schema this build cannot safely read.
case requiresNewerApplication(minimumReaderVersion: Int32, supportedVersion: Int32)
/// The declared migrations are not strictly increasing from a positive version.
case malformedMigrationList(version: Int32)
}

View file

@ -0,0 +1,33 @@
//
// PreferenceKey.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
/// A stable, typed identifier for one persisted preference row.
package struct PreferenceKey<Value: PreferenceValue>: Hashable, Sendable {
/// The row key in the `preference` table. Stable forever once shipped.
package let name: String
/// Creates a preference key with a stable storage name.
///
/// - Parameter name: The row key written to the preference table.
package init(_ name: String) {
self.name = name
}
}

View file

@ -0,0 +1,32 @@
//
// PreferenceKeys.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
extension PreferenceKey where Value == URL {
/// The Jellyfin server the user last connected to.
package static var serverURL: PreferenceKey<URL> { PreferenceKey("server.url") }
}
extension PreferenceKey where Value == String {
/// The access token issued by the server for the signed-in account.
package static var accessToken: PreferenceKey<String> { PreferenceKey("auth.accessToken") }
}

View file

@ -0,0 +1,110 @@
//
// PreferenceValue.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
/// Converts a Swift preference type to and from a SQLite scalar value.
package protocol PreferenceValue: Sendable, Equatable {
/// Decodes a value read back from storage, or `nil` when the stored shape does not match.
///
/// - Parameter preference: The raw value read from the preference table.
/// - Returns: A decoded value, or `nil` when the storage representation is incompatible.
init?(preference: StoredPreference)
/// The storage representation of this value.
var preferenceValue: StoredPreference { get }
}
extension Bool: PreferenceValue {
package init?(preference: StoredPreference) {
guard case .integer(let value) = preference, value == 0 || value == 1 else { return nil }
self = value == 1
}
package var preferenceValue: StoredPreference { .integer(self ? 1 : 0) }
}
extension Int: PreferenceValue {
package init?(preference: StoredPreference) {
guard case .integer(let value) = preference, let value = Int(exactly: value) else { return nil }
self = value
}
package var preferenceValue: StoredPreference { .integer(Int64(self)) }
}
extension Double: PreferenceValue {
package init?(preference: StoredPreference) {
switch preference {
case .real(let value): self = value
case .integer(let value): self = Double(value)
default: return nil
}
}
/// Non-finite values are encoded as zero because SQLite stores NaN as SQL NULL.
package var preferenceValue: StoredPreference { .real(isFinite ? self : 0) }
}
extension String: PreferenceValue {
package init?(preference: StoredPreference) {
guard case .text(let value) = preference else { return nil }
self = value
}
package var preferenceValue: StoredPreference { .text(self) }
}
extension URL: PreferenceValue {
package init?(preference: StoredPreference) {
guard case .text(let value) = preference, let url = URL(string: value) else { return nil }
self = url
}
package var preferenceValue: StoredPreference { .text(absoluteString) }
}
extension UUID: PreferenceValue {
package init?(preference: StoredPreference) {
guard case .text(let value) = preference, let uuid = UUID(uuidString: value) else { return nil }
self = uuid
}
package var preferenceValue: StoredPreference { .text(uuidString) }
}
extension Data: PreferenceValue {
package init?(preference: StoredPreference) {
guard case .blob(let value) = preference else { return nil }
self = value
}
package var preferenceValue: StoredPreference { .blob(self) }
}
extension PreferenceValue where Self: RawRepresentable, Self.RawValue: PreferenceValue {
package init?(preference: StoredPreference) {
guard let raw = RawValue(preference: preference) else { return nil }
self.init(rawValue: raw)
}
package var preferenceValue: StoredPreference { rawValue.preferenceValue }
}

View file

@ -0,0 +1,30 @@
//
// StoredPreference.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
/// A scalar value that can be stored directly in SQLite without a higher-level encoding.
package enum StoredPreference: Hashable, Sendable {
case integer(Int64)
case real(Double)
case text(String)
case blob(Data)
}

View file

@ -0,0 +1,84 @@
//
// EphemeralPreferenceStore.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
/// An ordered in-memory preference backend for tests and persistence fallback.
package actor EphemeralPreferenceStore: PreferenceStoring {
package nonisolated let initialSnapshot: [String: StoredPreference]
private var values: [String: StoredPreference]
private nonisolated let continuation: AsyncStream<PreferenceWrite>.Continuation
/// Creates an in-memory store seeded with optional raw values.
///
/// - Parameter initialSnapshot: Values available before the first write.
package init(initialSnapshot: [String: StoredPreference] = [:]) {
self.initialSnapshot = initialSnapshot
values = initialSnapshot
let (stream, continuation) = AsyncStream<PreferenceWrite>.makeStream(bufferingPolicy: .unbounded)
self.continuation = continuation
Task { [weak self] in
for await write in stream {
guard let self else { break }
await self.apply(write)
}
}
}
deinit {
continuation.finish()
}
/// Queues a value update or deletion without blocking the caller.
package nonisolated func enqueue(_ key: String, _ value: StoredPreference?) {
continuation.yield(.set(key: key, value: value))
}
/// Returns after queued writes have been applied to the in-memory values.
///
/// - Throws: Never; the in-memory backend has no write failure mode.
package nonisolated func flush() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
self.continuation.yield(
.barrier { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
})
}
}
/// The values currently held, for assertions.
package var storedValues: [String: StoredPreference] { values }
private func apply(_ write: PreferenceWrite) {
switch write {
case .set(let key, let value):
if let value {
values[key] = value
} else {
values.removeValue(forKey: key)
}
case .barrier(let resume):
resume(nil)
}
}
}

View file

@ -0,0 +1,45 @@
//
// PreferenceStoring.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
/// An ordered, asynchronous backend for scalar application preferences.
package protocol PreferenceStoring: Sendable {
/// Every persisted row, read once at open. The UI serves synchronous reads from this snapshot.
nonisolated var initialSnapshot: [String: StoredPreference] { get }
/// Queues a write without blocking or performing I/O on the caller's thread.
///
/// - Parameters:
/// - key: The stable preference row key.
/// - value: The new scalar value, or `nil` to delete the row.
nonisolated func enqueue(_ key: String, _ value: StoredPreference?)
/// Returns after every write queued before this call has been applied.
///
/// - Throws: The first backend failure recorded since the previous flush, then clears that
/// retained failure.
func flush() async throws
}
/// One queued mutation, or a barrier that reports the queue has drained to this point.
package enum PreferenceWrite: Sendable {
case set(key: String, value: StoredPreference?)
case barrier(@Sendable (PreferenceStoreError?) -> Void)
}

View file

@ -59,10 +59,10 @@ package actor JellyfinClient: JellyfinService {
/// Held as a closure so the production and test initialisers share one mutation path: the
/// production closure builds a real `Client` around the injected transport, the test closure
/// hands back the same double every time.
private let makeAPI: @Sendable (JellyfinClientConfiguration) -> any JellyfinAPI
private let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)?
/// The generated client the operations call, rebuilt on every configuration change.
private var api: any JellyfinAPI
private var api: (any JellyfinAPI)?
/// Creates a client that talks to a real server.
///
@ -73,9 +73,10 @@ package actor JellyfinClient: JellyfinService {
configuration: JellyfinClientConfiguration,
transport: any ClientTransport = URLSessionTransport()
) {
let makeAPI: @Sendable (JellyfinClientConfiguration) -> any JellyfinAPI = { configuration in
Client(
serverURL: configuration.serverURL,
let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)? = { configuration in
guard let serverURL = configuration.serverURL else { return nil }
return Client(
serverURL: serverURL,
transport: transport,
middlewares: [
AuthenticationMiddleware(
@ -107,8 +108,8 @@ package actor JellyfinClient: JellyfinService {
self.api = api
}
/// The server this client currently talks to.
package var serverURL: URL { configuration.serverURL }
/// The server this client currently talks to, or `nil` before configuration.
package var serverURL: URL? { configuration.serverURL }
/// The access token in force, or `nil` before sign-in.
package var accessToken: String? { configuration.accessToken }
@ -146,5 +147,12 @@ package actor JellyfinClient: JellyfinService {
/// The generated client the operation extensions call.
///
/// Actor-isolated, so every operation reads the client that matches the current configuration.
var current: any JellyfinAPI { api }
///
/// - Throws: ``JellyfinClientError/notConfigured`` when no server URL has been chosen yet.
var current: any JellyfinAPI {
get throws {
guard let api else { throw JellyfinClientError.notConfigured }
return api
}
}
}

View file

@ -18,8 +18,8 @@
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
import LuminateCore
/// Everything a ``JellyfinClient`` needs to reach one server as one client identity.
///
@ -27,8 +27,8 @@ import Foundation
/// builds. They are not cosmetic: the server lists ``deviceName`` in its active-devices UI and
/// keys the session by ``deviceID``.
package struct JellyfinClientConfiguration: Sendable, Hashable {
/// The base URL of the Jellyfin server, including scheme and any custom port.
package var serverURL: URL
/// The base URL of the Jellyfin server, or `nil` before a server is chosen.
package var serverURL: URL?
/// The application name reported to the server.
package var clientName: String
/// The human-readable device name shown in the server's active-devices list.
@ -43,18 +43,18 @@ package struct JellyfinClientConfiguration: Sendable, Hashable {
/// Creates a configuration, defaulting the client identity to Luminate's.
///
/// - Parameters:
/// - serverURL: The base URL of the Jellyfin server.
/// - serverURL: The base URL of the Jellyfin server, or `nil` before a server is chosen.
/// - clientName: The application name reported to the server.
/// - deviceName: The device name shown by the server; defaults to the machine's host name.
/// - deviceID: The device identifier; defaults to ``defaultDeviceID()``.
/// - version: The application version reported to the server.
/// - accessToken: An access token to start authenticated with, if one was persisted.
package init(
serverURL: URL,
clientName: String = "Luminate",
serverURL: URL?,
clientName: String = AppInfo.name,
deviceName: String = ProcessInfo.processInfo.hostName,
deviceID: String = JellyfinClientConfiguration.defaultDeviceID(),
version: String = "0.1.0",
version: String = AppInfo.version,
accessToken: String? = nil
) {
self.serverURL = serverURL

View file

@ -0,0 +1,139 @@
//
// SQLiteDatabase.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import CSQLite
import Foundation
import LuminateCore
/// A synchronous SQLite connection owned by the storage actor.
package final class SQLiteDatabase {
package let handle: OpaquePointer
/// Opens or creates a private SQLite database at `url` and configures local durability.
///
/// - Parameter url: The database file location.
/// - Throws: ``PreferenceStoreError/cannotOpen(path:message:)`` when SQLite cannot open it.
package init(url: URL) throws {
let directory = url.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
var opened: OpaquePointer?
let result = url.path.withCString { path in
sqlite3_open_v2(
path,
&opened,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX,
nil
)
}
guard result == SQLITE_OK, let opened else {
let message = opened.map { String(cString: sqlite3_errmsg($0)) } ?? "SQLite returned no handle"
if let opened { sqlite3_close_v2(opened) }
throw PreferenceStoreError.cannotOpen(path: url.path, message: message)
}
handle = opened
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
try exec("PRAGMA journal_mode = WAL;")
try exec("PRAGMA synchronous = NORMAL;")
try exec("PRAGMA foreign_keys = ON;")
// SQLite has no busy handler by default, so concurrent writers would fail immediately.
try exec("PRAGMA busy_timeout = 5000;")
}
deinit {
sqlite3_close_v2(handle)
}
/// The latest SQLite diagnostic for this connection.
package var errorMessage: String {
String(cString: sqlite3_errmsg(handle))
}
/// Executes SQL that does not return rows.
///
/// - Parameter sql: The SQL statements to execute.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when execution fails.
package func exec(_ sql: String) throws {
var errorPointer: UnsafeMutablePointer<CChar>?
let result = sqlite3_exec(handle, sql, nil, nil, &errorPointer)
defer {
if let errorPointer { sqlite3_free(errorPointer) }
}
guard result == SQLITE_OK else {
let message = errorPointer.map { String(cString: $0) } ?? errorMessage
throw PreferenceStoreError.sqlite(message: message)
}
}
/// Runs a closure inside one SQLite transaction and rolls back failures.
///
/// - Parameter body: Statements to execute while the transaction is open.
/// - Throws: The first error raised by the body or transaction control statements.
package func transaction(_ body: () throws -> Void) throws {
try exec("BEGIN;")
do {
try body()
try exec("COMMIT;")
} catch {
try? exec("ROLLBACK;")
throw error
}
}
/// Reads SQLite's schema version pragma.
package var userVersion: Int32 {
get throws {
let statement = try SQLiteStatement(database: self, sql: "PRAGMA user_version;")
guard try statement.step() else { throw PreferenceStoreError.sqlite(message: errorMessage) }
return statement.integer(at: 0)
}
}
/// Sets SQLite's schema version pragma.
///
/// - Parameter version: The non-negative schema version.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when SQLite rejects the pragma.
package func setUserVersion(_ version: Int32) throws {
try exec("PRAGMA user_version = \(version);")
}
/// Reads SQLite's application identifier pragma.
package var applicationID: Int32 {
get throws {
let statement = try SQLiteStatement(database: self, sql: "PRAGMA application_id;")
guard try statement.step() else { throw PreferenceStoreError.sqlite(message: errorMessage) }
return statement.integer(at: 0)
}
}
/// Sets SQLite's application identifier pragma.
///
/// - Parameter applicationID: The application identifier to persist in the file header.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when SQLite rejects the pragma.
package func setApplicationID(_ applicationID: Int32) throws {
try exec("PRAGMA application_id = \(applicationID);")
}
}

View file

@ -0,0 +1,159 @@
//
// SQLiteStatement.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import CSQLite
import Foundation
import LuminateCore
/// `SQLITE_TRANSIENT`. The C macro is a cast of `-1` and does not survive into Swift, so the bit
/// pattern is reconstructed here; it tells SQLite to copy the bound bytes.
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
/// A prepared SQLite statement with typed binding and column helpers.
final class SQLiteStatement {
private let database: SQLiteDatabase
private let handle: OpaquePointer
/// Prepares `sql` against `database`.
///
/// - Parameters:
/// - database: The open database that owns the statement.
/// - sql: The SQL statement to prepare.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when preparation fails.
init(database: SQLiteDatabase, sql: String) throws {
self.database = database
var statement: OpaquePointer?
let result = sqlite3_prepare_v2(database.handle, sql, -1, &statement, nil)
guard result == SQLITE_OK, let statement else {
throw PreferenceStoreError.sqlite(message: database.errorMessage)
}
handle = statement
}
deinit {
sqlite3_finalize(handle)
}
/// Binds a scalar preference value at a 1-based parameter index.
///
/// - Parameters:
/// - index: The SQLite parameter index.
/// - value: The scalar value to bind.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when binding fails.
func bind(_ index: Int32, _ value: StoredPreference) throws {
let result: Int32
switch value {
case .integer(let value):
result = sqlite3_bind_int64(handle, index, value)
case .real(let value):
result = sqlite3_bind_double(handle, index, value)
case .text(let value):
result = bindText(index, value)
case .blob(let value):
if value.isEmpty {
result = sqlite3_bind_zeroblob(handle, index, 0)
} else {
result = value.withUnsafeBytes { bytes in
sqlite3_bind_blob(handle, index, bytes.baseAddress, Int32(value.count), sqliteTransient)
}
}
}
try check(result)
}
/// Binds a string at a 1-based parameter index.
///
/// - Parameters:
/// - index: The SQLite parameter index.
/// - text: The string to bind.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` when binding fails.
func bind(_ index: Int32, text: String) throws {
try check(bindText(index, text))
}
/// Steps the statement once.
///
/// - Returns: `true` when a row is available, or `false` when the statement is complete.
/// - Throws: ``PreferenceStoreError/sqlite(message:)`` for any other SQLite result.
func step() throws -> Bool {
switch sqlite3_step(handle) {
case SQLITE_ROW:
return true
case SQLITE_DONE:
return false
default:
throw PreferenceStoreError.sqlite(message: database.errorMessage)
}
}
/// Reads an integer result column.
/// - Parameter index: The zero-based result column index.
/// - Returns: The SQLite integer value.
func integer(at index: Int32) -> Int32 {
sqlite3_column_int(handle, index)
}
/// Reads a nullable string column.
///
/// - Parameter index: The zero-based result column index.
/// - Returns: The column text, or `nil` for SQL NULL.
func text(at index: Int32) -> String? {
guard let pointer = sqlite3_column_text(handle, index) else { return nil }
let count = Int(sqlite3_column_bytes(handle, index))
return String(decoding: UnsafeBufferPointer(start: pointer, count: count), as: UTF8.self)
}
/// Reads the SQLite storage-class value from a result column.
///
/// - Parameter index: The zero-based result column index.
/// - Returns: The scalar value, or `nil` for SQL NULL or an unsupported storage class.
func preference(at index: Int32) -> StoredPreference? {
switch sqlite3_column_type(handle, index) {
case SQLITE_INTEGER:
return .integer(sqlite3_column_int64(handle, index))
case SQLITE_FLOAT:
return .real(sqlite3_column_double(handle, index))
case SQLITE_TEXT:
guard let value = text(at: index) else { return nil }
return .text(value)
case SQLITE_BLOB:
let length = Int(sqlite3_column_bytes(handle, index))
guard let pointer = sqlite3_column_blob(handle, index), length > 0 else {
return .blob(Data())
}
return .blob(Data(bytes: pointer, count: length))
default:
return nil
}
}
private func bindText(_ index: Int32, _ value: String) -> Int32 {
value.utf8CString.withUnsafeBufferPointer { buffer in
sqlite3_bind_text(handle, index, buffer.baseAddress, Int32(buffer.count - 1), sqliteTransient)
}
}
private func check(_ result: Int32) throws {
guard result == SQLITE_OK else {
throw PreferenceStoreError.sqlite(message: database.errorMessage)
}
}
}

View file

@ -0,0 +1,117 @@
//
// SchemaMigrator.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateCore
/// Applies the append-only schema migration history to a Luminate database.
package enum SchemaMigrator {
/// `PRAGMA application_id` marking a file as Luminate's: ASCII "LUMN" as a big-endian Int32.
package static let applicationID: Int32 = 0x4C55_4D4E
/// Every migration ever shipped, in ascending version order.
package static let migrations: [StoreMigration] = [
StoreMigration(
version: 1,
name: "create preference table",
minimumReaderVersion: 1,
statements: [
"""
CREATE TABLE schema_metadata (
id INTEGER PRIMARY KEY CHECK (id = 1),
minimum_reader_version INTEGER NOT NULL
) STRICT;
""",
"""
CREATE TABLE preference (
key TEXT PRIMARY KEY NOT NULL,
value ANY
) STRICT;
""",
]
)
]
/// The newest schema version shipped by this application.
package static var latestVersion: Int32 { migrations.last?.version ?? 0 }
/// Migrates `database` and validates its application and downgrade metadata.
///
/// - Parameter database: An open SQLite connection.
/// - Throws: A ``PreferenceStoreError`` when the file belongs to another application or requires
/// a newer reader.
package static func migrate(
_ database: SQLiteDatabase,
migrations: [StoreMigration] = SchemaMigrator.migrations
) throws {
var previous: Int32 = 0
for migration in migrations {
guard migration.version > previous else {
throw PreferenceStoreError.malformedMigrationList(version: migration.version)
}
previous = migration.version
}
let latest = migrations.last?.version ?? 0
let applicationID = try database.applicationID
guard applicationID == 0 || applicationID == Self.applicationID else {
throw PreferenceStoreError.notALuminateDatabase(applicationID: applicationID)
}
let current = try database.userVersion
if current > 0 {
let minimumReaderVersion = try readMinimumReaderVersion(from: database)
guard minimumReaderVersion <= latest else {
throw PreferenceStoreError.requiresNewerApplication(
minimumReaderVersion: minimumReaderVersion,
supportedVersion: latest
)
}
}
for migration in migrations where migration.version > current {
try database.transaction {
for statement in migration.statements {
try database.exec(statement)
}
try database.exec(
"INSERT INTO schema_metadata (id, minimum_reader_version) VALUES (1, "
+ "\(migration.minimumReaderVersion)) ON CONFLICT(id) DO UPDATE SET "
+ "minimum_reader_version = excluded.minimum_reader_version;"
)
try database.setUserVersion(migration.version)
}
}
if applicationID == 0 {
try database.setApplicationID(Self.applicationID)
}
}
private static func readMinimumReaderVersion(from database: SQLiteDatabase) throws -> Int32 {
let statement = try SQLiteStatement(
database: database,
sql: "SELECT minimum_reader_version FROM schema_metadata WHERE id = 1;"
)
guard try statement.step() else {
throw PreferenceStoreError.sqlite(message: database.errorMessage)
}
return statement.integer(at: 0)
}
}

View file

@ -0,0 +1,32 @@
//
// StoreMigration.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
/// One forward schema migration, applied inside a single transaction.
package struct StoreMigration: Sendable {
/// The `user_version` this migration establishes. Strictly increasing across the list.
package let version: Int32
/// Human-readable name, used in error text.
package let name: String
/// The oldest schema version whose reader can safely open the database after this migration.
package let minimumReaderVersion: Int32
/// The statements to run, in order.
package let statements: [String]
}

View file

@ -0,0 +1,42 @@
//
// PreferenceBackend.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
import Logging
import LuminateCore
private let logger = Logger(label: "\(AppInfo.identifier).store")
/// Opens Luminate's on-disk preference backend, degrading to memory when SQLite is unavailable.
package enum PreferenceBackend {
/// Opens the SQLite preference store, falling back to an in-memory store on failure.
///
/// - Parameter url: The database file location.
/// - Returns: A SQLite-backed store, or an `EphemeralPreferenceStore` when opening failed.
package static func open(url: URL = AppPaths.preferencesDatabaseURL()) -> any PreferenceStoring {
do {
return try SQLitePreferenceStore(url: url)
} catch {
logger.error("Preferences unavailable, running without persistence", error: error)
return EphemeralPreferenceStore()
}
}
}

View file

@ -0,0 +1,140 @@
//
// SQLitePreferenceStore.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
import Logging
import LuminateCore
private let logger = Logger(label: "\(AppInfo.identifier).store")
/// A SQLite-backed preference store with an ordered asynchronous write queue.
package actor SQLitePreferenceStore: PreferenceStoring {
private let database: SQLiteDatabase
package nonisolated let initialSnapshot: [String: StoredPreference]
private nonisolated let continuation: AsyncStream<PreferenceWrite>.Continuation
private var pendingFailure: PreferenceStoreError?
/// Opens, migrates, and snapshots the preference database at `url`.
///
/// - Parameter url: The database file location.
/// - Throws: ``PreferenceStoreError`` when the database cannot be opened or migrated.
package init(url: URL) throws {
let database = try SQLiteDatabase(url: url)
try SchemaMigrator.migrate(database)
initialSnapshot = try Self.loadAllPreferences(from: database)
self.database = database
let (stream, continuation) = AsyncStream<PreferenceWrite>.makeStream(bufferingPolicy: .unbounded)
self.continuation = continuation
Task { [weak self] in
for await write in stream {
guard let self else { break }
await self.apply(write)
}
}
}
deinit {
continuation.finish()
}
/// Queues a value update or deletion without blocking the caller.
package nonisolated func enqueue(_ key: String, _ value: StoredPreference?) {
continuation.yield(.set(key: key, value: value))
}
/// Returns after queued writes have been committed to SQLite.
///
/// - Throws: The first write failure recorded since the previous flush.
package nonisolated func flush() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
self.continuation.yield(
.barrier { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
})
}
}
private func apply(_ write: PreferenceWrite) {
switch write {
case .set(let key, let value):
do {
try Self.writePreference(to: database, key: key, value: value)
} catch {
let failure = (error as? PreferenceStoreError) ?? .sqlite(message: "\(error)")
logger.error("Preference write failed", error: failure, metadata: ["key": "\(key)"])
if pendingFailure == nil {
pendingFailure = failure
}
}
case .barrier(let resume):
let failure = pendingFailure
pendingFailure = nil
resume(failure)
}
}
/// Reads every non-NULL row from the preference table.
///
/// - Parameter database: The open SQLite connection containing the preference table.
/// - Returns: Raw preference values keyed by their stable row names.
/// - Throws: ``PreferenceStoreError`` when querying the table fails.
private static func loadAllPreferences(from database: SQLiteDatabase) throws -> [String: StoredPreference] {
let statement = try SQLiteStatement(database: database, sql: "SELECT key, value FROM preference;")
var values: [String: StoredPreference] = [:]
while try statement.step() {
guard let key = statement.text(at: 0), let value = statement.preference(at: 1) else { continue }
values[key] = value
}
return values
}
/// Inserts or updates one preference row, or deletes it when `value` is `nil`.
///
/// - Parameters:
/// - database: The open SQLite connection containing the preference table.
/// - key: The stable row key.
/// - value: The scalar value to store, or `nil` to delete the row.
/// - Throws: ``PreferenceStoreError`` when the statement fails.
private static func writePreference(
to database: SQLiteDatabase,
key: String,
value: StoredPreference?
) throws {
if let value {
let statement = try SQLiteStatement(
database: database,
sql: "INSERT INTO preference (key, value) VALUES (?1, ?2) "
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value;"
)
try statement.bind(1, text: key)
try statement.bind(2, value)
_ = try statement.step()
} else {
let statement = try SQLiteStatement(database: database, sql: "DELETE FROM preference WHERE key = ?1;")
try statement.bind(1, text: key)
_ = try statement.step()
}
}
}

View file

@ -0,0 +1,40 @@
//
// PreferencesEnvironmentKey.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateCore
import Portico
/// The environment slot that carries the preference coordinator down the view tree.
package enum PreferencesEnvironmentKey: EnvironmentKey {
/// The process-wide coordinator.
///
/// Portico caches this in a shared default box, so `@Preference` resolves with no injection.
/// A subtree may still override it with `.environment(\.preferences, other)`.
package static var defaultValue: Preferences { Preferences.shared }
}
extension EnvironmentValues {
/// The preference coordinator visible to this subtree.
package var preferences: Preferences {
get { self[PreferencesEnvironmentKey.self] }
set { self[PreferencesEnvironmentKey.self] = newValue }
}
}

View file

@ -0,0 +1,81 @@
//
// Preference.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateCore
import Portico
/// Shared reference storage for a wrapper's mount-time slot resolution.
@MainActor private final class ResolvedPreferences<Value: PreferenceValue> {
var coordinator: Preferences?
var slot: PreferenceSlot<Value>?
}
/// Reads and writes one persisted preference like Portico's `@State`.
///
/// A missing key reads as `nil` without a fallback. Reads inside reactive closure modifiers track the
/// key, writes update every reader, and persistence is queued asynchronously off the main actor.
@propertyWrapper @MainActor package struct Preference<Value: PreferenceValue>: DynamicProperty {
private let key: PreferenceKey<Value>
private let resolved = ResolvedPreferences<Value>()
/// Creates a preference declaration for `key`.
///
/// - Parameter key: The stable typed preference identifier.
package init(_ key: PreferenceKey<Value>) {
self.key = key
}
/// Resolves the declaration against the mounting environment.
///
/// - Parameter environment: The environment supplied by the mounted view tree.
package func _resolve(in environment: EnvironmentValues) {
let preferences = environment.preferences
if let previous = resolved.coordinator, previous !== preferences {
fatalError(
"@Preference: view instance mounted into two different environment scopes. "
+ "A view instance resolves its preferences once; build a fresh instance per mount."
)
}
resolved.coordinator = preferences
resolved.slot = preferences.slot(for: key)
}
/// The current persisted value, or `nil` when the key is unset.
package var wrappedValue: Value? {
get { slot.value }
nonmutating set { slot.value = newValue }
}
/// A live binding to the optional preference value.
package var projectedValue: Binding<Value?> {
Binding(slot, \PreferenceSlot<Value>.value)
}
/// The slot bound at mount.
///
/// Traps rather than inventing storage when read before `_resolve`.
private var slot: PreferenceSlot<Value> {
guard let slot = resolved.slot else {
fatalError("@Preference read before its view was mounted.")
}
return slot
}
}

View file

@ -0,0 +1,47 @@
//
// PreferenceSlot.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import LuminateCore
import Observation
/// The observable cell backing one preference key.
///
/// One instance per key lets Swift Observation track each preference independently.
@Observable @MainActor package final class PreferenceSlot<Value: PreferenceValue> {
/// Invoked synchronously after every value write.
@ObservationIgnored private let onChange: (Value?) -> Void
/// The current value, or `nil` when the key has never been set.
package var value: Value? {
didSet { onChange(value) }
}
/// Creates a slot with an optional initial value and persistence hook.
///
/// - Parameters:
/// - value: The value restored from storage, or `nil` when unset. Initialization does not
/// invoke `onChange`.
/// - onChange: Invoked synchronously after every subsequent value write.
package init(_ value: Value?, onChange: @escaping (Value?) -> Void) {
self.onChange = onChange
self.value = value
}
}

View file

@ -0,0 +1,123 @@
//
// Preferences.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// 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<Value>(for key: PreferenceKey<Value>) -> PreferenceSlot<Value> {
if let existing = slots[key.name] {
guard let typed = existing as? PreferenceSlot<Value> 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<Value>(key: PreferenceKey<Value>) -> 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<Value>(for key: PreferenceKey<Value>) -> Binding<Value?> {
Binding(slot(for: key), \PreferenceSlot<Value>.value)
}
/// Decodes the launch snapshot row for `key`, warning when its stored type is incompatible.
private func restoredValue<Value>(for key: PreferenceKey<Value>) -> 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()
}
}