diff --git a/Package.swift b/Package.swift index 300d8e0..10b7e23 100644 --- a/Package.swift +++ b/Package.swift @@ -4,15 +4,16 @@ import PackageDescription let strictConcurrencySettings: [SwiftSetting] = [ - .enableExperimentalFeature("StrictConcurrency=complete"), + .enableExperimentalFeature("StrictConcurrency=complete") ] // UI/executable-facing targets default to MainActor isolation on top of strict // concurrency; data/service layers (e.g. LuminateAPI) stay nonisolated by default // so they aren't implicitly pinned to the main actor. -let uiSwiftSettings: [SwiftSetting] = strictConcurrencySettings + [ - .defaultIsolation(MainActor.self), -] +let uiSwiftSettings: [SwiftSetting] = + strictConcurrencySettings + [ + .defaultIsolation(MainActor.self) + ] // Everything the service layer needs to speak to a Jellyfin server: the domain // layer, the generated client, the OpenAPI runtime/transport, and HTTPTypes for @@ -29,6 +30,13 @@ let servicesDeps: [Target.Dependency] = [ let uiDeps: [Target.Dependency] = [ "LuminateCore", .product(name: "Portico", package: "portico"), + .product(name: "Logging", package: "swift-log"), +] +// The persistence layer owns SQLite and exposes only Core value types above it. +let storeDeps: [Target.Dependency] = [ + "LuminateCore", + "CSQLite", + .product(name: "Logging", package: "swift-log"), ] let package = Package( @@ -39,11 +47,12 @@ let package = Package( .executable( name: "Luminate", targets: ["Luminate"] - ), + ) ], dependencies: [ .package(url: "https://git.bscubed.dev/gtk-swift/portico.git", branch: "main"), .package(url: "https://github.com/apple/swift-http-types", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.14.0"), .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.13.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.12.0"), .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.3.1"), @@ -66,6 +75,17 @@ let package = Package( .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator") ] ), + .systemLibrary( + name: "CSQLite", + path: "Sources/CSQLite", + pkgConfig: "sqlite3", + providers: [.apt(["libsqlite3-dev"]), .brew(["sqlite3"])] + ), + .target( + name: "LuminateStore", + dependencies: storeDeps, + swiftSettings: strictConcurrencySettings + ), .target( name: "LuminateServices", dependencies: servicesDeps, @@ -80,9 +100,11 @@ let package = Package( name: "Luminate", dependencies: [ "LuminateCore", + "LuminateStore", "LuminateServices", "LuminateUI", .product(name: "Portico", package: "portico"), + .product(name: "Logging", package: "swift-log"), ], swiftSettings: uiSwiftSettings ), @@ -96,11 +118,21 @@ let package = Package( dependencies: servicesDeps + ["LuminateServices"], swiftSettings: strictConcurrencySettings ), + .testTarget( + name: "LuminateStoreTests", + dependencies: storeDeps + ["LuminateStore"], + swiftSettings: strictConcurrencySettings + ), .testTarget( name: "LuminateUITests", dependencies: uiDeps + ["LuminateUI"], swiftSettings: uiSwiftSettings ), + .testTarget( + name: "LuminateTests", + dependencies: ["LuminateCore", "LuminateStore", "LuminateUI"], + swiftSettings: uiSwiftSettings + ), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/CSQLite/module.modulemap b/Sources/CSQLite/module.modulemap new file mode 100644 index 0000000..0a291b5 --- /dev/null +++ b/Sources/CSQLite/module.modulemap @@ -0,0 +1,5 @@ +module CSQLite [system] { + header "shim.h" + link "sqlite3" + export * +} diff --git a/Sources/CSQLite/shim.h b/Sources/CSQLite/shim.h new file mode 100644 index 0000000..f52e1f0 --- /dev/null +++ b/Sources/CSQLite/shim.h @@ -0,0 +1 @@ +#include diff --git a/Sources/Luminate/ClientSessionBinder.swift b/Sources/Luminate/ClientSessionBinder.swift new file mode 100644 index 0000000..ed2d79b --- /dev/null +++ b/Sources/Luminate/ClientSessionBinder.swift @@ -0,0 +1,48 @@ +// +// ClientSessionBinder.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 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) } + } + ) + } +} diff --git a/Sources/Luminate/LogBootstrap.swift b/Sources/Luminate/LogBootstrap.swift new file mode 100644 index 0000000..2949656 --- /dev/null +++ b/Sources/Luminate/LogBootstrap.swift @@ -0,0 +1,47 @@ +// +// LogBootstrap.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 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 + } + } +} diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index ef53709..23d6c30 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -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" } } } diff --git a/Sources/LuminateCore/Constants/AppInfo.swift b/Sources/LuminateCore/Constants/AppInfo.swift new file mode 100644 index 0000000..4918819 --- /dev/null +++ b/Sources/LuminateCore/Constants/AppInfo.swift @@ -0,0 +1,32 @@ +// +// AppInfo.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 +// + +/// 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" +} diff --git a/Sources/LuminateCore/Constants/AppPaths.swift b/Sources/LuminateCore/Constants/AppPaths.swift new file mode 100644 index 0000000..b240fd5 --- /dev/null +++ b/Sources/LuminateCore/Constants/AppPaths.swift @@ -0,0 +1,86 @@ +// +// AppPaths.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 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) + } +} diff --git a/Sources/LuminateCore/Errors/PreferenceStoreError.swift b/Sources/LuminateCore/Errors/PreferenceStoreError.swift new file mode 100644 index 0000000..f3050d1 --- /dev/null +++ b/Sources/LuminateCore/Errors/PreferenceStoreError.swift @@ -0,0 +1,34 @@ +// +// PreferenceStoreError.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 +// + +/// 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) +} diff --git a/Sources/LuminateCore/Models/PreferenceKey.swift b/Sources/LuminateCore/Models/PreferenceKey.swift new file mode 100644 index 0000000..d2ac955 --- /dev/null +++ b/Sources/LuminateCore/Models/PreferenceKey.swift @@ -0,0 +1,33 @@ +// +// PreferenceKey.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 +// + +/// A stable, typed identifier for one persisted preference row. +package struct PreferenceKey: 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 + } +} diff --git a/Sources/LuminateCore/Models/PreferenceKeys.swift b/Sources/LuminateCore/Models/PreferenceKeys.swift new file mode 100644 index 0000000..18d2c80 --- /dev/null +++ b/Sources/LuminateCore/Models/PreferenceKeys.swift @@ -0,0 +1,32 @@ +// +// PreferenceKeys.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 Foundation + +extension PreferenceKey where Value == URL { + /// The Jellyfin server the user last connected to. + package static var serverURL: PreferenceKey { 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 { PreferenceKey("auth.accessToken") } +} diff --git a/Sources/LuminateCore/Models/PreferenceValue.swift b/Sources/LuminateCore/Models/PreferenceValue.swift new file mode 100644 index 0000000..0f7598f --- /dev/null +++ b/Sources/LuminateCore/Models/PreferenceValue.swift @@ -0,0 +1,110 @@ +// +// PreferenceValue.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 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 } +} diff --git a/Sources/LuminateCore/Models/StoredPreference.swift b/Sources/LuminateCore/Models/StoredPreference.swift new file mode 100644 index 0000000..f86fea0 --- /dev/null +++ b/Sources/LuminateCore/Models/StoredPreference.swift @@ -0,0 +1,30 @@ +// +// StoredPreference.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 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) +} diff --git a/Sources/LuminateCore/Protocols/EphemeralPreferenceStore.swift b/Sources/LuminateCore/Protocols/EphemeralPreferenceStore.swift new file mode 100644 index 0000000..bf92e44 --- /dev/null +++ b/Sources/LuminateCore/Protocols/EphemeralPreferenceStore.swift @@ -0,0 +1,84 @@ +// +// EphemeralPreferenceStore.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 +// + +/// 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.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.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) 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) + } + } +} diff --git a/Sources/LuminateCore/Protocols/PreferenceStoring.swift b/Sources/LuminateCore/Protocols/PreferenceStoring.swift new file mode 100644 index 0000000..33a4db4 --- /dev/null +++ b/Sources/LuminateCore/Protocols/PreferenceStoring.swift @@ -0,0 +1,45 @@ +// +// PreferenceStoring.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 +// + +/// 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) +} diff --git a/Sources/LuminateServices/Session/JellyfinClient.swift b/Sources/LuminateServices/Session/JellyfinClient.swift index 607ed1d..63f8eb5 100644 --- a/Sources/LuminateServices/Session/JellyfinClient.swift +++ b/Sources/LuminateServices/Session/JellyfinClient.swift @@ -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 + } + } } diff --git a/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift b/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift index c90f975..def42e5 100644 --- a/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift +++ b/Sources/LuminateServices/Session/JellyfinClientConfiguration.swift @@ -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 diff --git a/Sources/LuminateStore/Database/SQLiteDatabase.swift b/Sources/LuminateStore/Database/SQLiteDatabase.swift new file mode 100644 index 0000000..3630ab4 --- /dev/null +++ b/Sources/LuminateStore/Database/SQLiteDatabase.swift @@ -0,0 +1,139 @@ +// +// SQLiteDatabase.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 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? + 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);") + } + +} diff --git a/Sources/LuminateStore/Database/SQLiteStatement.swift b/Sources/LuminateStore/Database/SQLiteStatement.swift new file mode 100644 index 0000000..cf4f36b --- /dev/null +++ b/Sources/LuminateStore/Database/SQLiteStatement.swift @@ -0,0 +1,159 @@ +// +// SQLiteStatement.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 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) + } + } +} diff --git a/Sources/LuminateStore/Migrations/SchemaMigrator.swift b/Sources/LuminateStore/Migrations/SchemaMigrator.swift new file mode 100644 index 0000000..ebfd121 --- /dev/null +++ b/Sources/LuminateStore/Migrations/SchemaMigrator.swift @@ -0,0 +1,117 @@ +// +// SchemaMigrator.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 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) + } +} diff --git a/Sources/LuminateStore/Migrations/StoreMigration.swift b/Sources/LuminateStore/Migrations/StoreMigration.swift new file mode 100644 index 0000000..90e568f --- /dev/null +++ b/Sources/LuminateStore/Migrations/StoreMigration.swift @@ -0,0 +1,32 @@ +// +// StoreMigration.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 +// + +/// 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] +} diff --git a/Sources/LuminateStore/Preferences/PreferenceBackend.swift b/Sources/LuminateStore/Preferences/PreferenceBackend.swift new file mode 100644 index 0000000..457bd02 --- /dev/null +++ b/Sources/LuminateStore/Preferences/PreferenceBackend.swift @@ -0,0 +1,42 @@ +// +// PreferenceBackend.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 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() + } + } +} diff --git a/Sources/LuminateStore/Preferences/SQLitePreferenceStore.swift b/Sources/LuminateStore/Preferences/SQLitePreferenceStore.swift new file mode 100644 index 0000000..9f7a114 --- /dev/null +++ b/Sources/LuminateStore/Preferences/SQLitePreferenceStore.swift @@ -0,0 +1,140 @@ +// +// SQLitePreferenceStore.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 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.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.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) 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() + } + } +} diff --git a/Sources/LuminateUI/Environment/PreferencesEnvironmentKey.swift b/Sources/LuminateUI/Environment/PreferencesEnvironmentKey.swift new file mode 100644 index 0000000..3625d80 --- /dev/null +++ b/Sources/LuminateUI/Environment/PreferencesEnvironmentKey.swift @@ -0,0 +1,40 @@ +// +// PreferencesEnvironmentKey.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 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 } + } +} diff --git a/Sources/LuminateUI/Preferences/Preference.swift b/Sources/LuminateUI/Preferences/Preference.swift new file mode 100644 index 0000000..d34dacd --- /dev/null +++ b/Sources/LuminateUI/Preferences/Preference.swift @@ -0,0 +1,81 @@ +// +// Preference.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 LuminateCore +import Portico + +/// Shared reference storage for a wrapper's mount-time slot resolution. +@MainActor private final class ResolvedPreferences { + var coordinator: Preferences? + var slot: PreferenceSlot? +} + +/// 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: DynamicProperty { + private let key: PreferenceKey + private let resolved = ResolvedPreferences() + + /// Creates a preference declaration for `key`. + /// + /// - Parameter key: The stable typed preference identifier. + package init(_ key: PreferenceKey) { + 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 { + Binding(slot, \PreferenceSlot.value) + } + + /// The slot bound at mount. + /// + /// Traps rather than inventing storage when read before `_resolve`. + private var slot: PreferenceSlot { + guard let slot = resolved.slot else { + fatalError("@Preference read before its view was mounted.") + } + return slot + } +} diff --git a/Sources/LuminateUI/Preferences/PreferenceSlot.swift b/Sources/LuminateUI/Preferences/PreferenceSlot.swift new file mode 100644 index 0000000..04efdf7 --- /dev/null +++ b/Sources/LuminateUI/Preferences/PreferenceSlot.swift @@ -0,0 +1,47 @@ +// +// PreferenceSlot.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 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 { + /// 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 + } +} diff --git a/Sources/LuminateUI/Preferences/Preferences.swift b/Sources/LuminateUI/Preferences/Preferences.swift new file mode 100644 index 0000000..ee8977d --- /dev/null +++ b/Sources/LuminateUI/Preferences/Preferences.swift @@ -0,0 +1,123 @@ +// +// 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() + } +} diff --git a/Tests/LuminateCoreTests/AppPathsTests.swift b/Tests/LuminateCoreTests/AppPathsTests.swift new file mode 100644 index 0000000..0d013bd --- /dev/null +++ b/Tests/LuminateCoreTests/AppPathsTests.swift @@ -0,0 +1,89 @@ +// +// AppPathsTests.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 Foundation +import Testing + +@testable import LuminateCore + +@Suite struct AppPathsTests { + @Test("An absolute override is used verbatim") + func overrideWins() { + let path = AppPaths.dataDirectory( + environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"], + home: URL(filePath: "/home/test"), + temporaryDirectory: URL(filePath: "/tmp") + ) + #expect(path == URL(filePath: "/override/luminate")) + } + + #if canImport(Darwin) + @Test("Injected home receives the Application Support application directory") + func applicationSupportHome() { + let path = AppPaths.dataDirectory( + environment: [:], + home: URL(filePath: "/Users/test"), + temporaryDirectory: URL(filePath: "/tmp") + ) + #expect(path.path == "/Users/test/Library/Application Support/luminate") + } + #else + @Test("Absolute XDG data home receives the lowercase application directory") + func xdgDataHome() { + let path = AppPaths.dataDirectory( + environment: ["XDG_DATA_HOME": "/data"], + home: URL(filePath: "/home/test"), + temporaryDirectory: URL(filePath: "/tmp") + ) + #expect(path.path == "/data/luminate") + } + + @Test("Relative XDG data home is ignored") + func relativeXDGDataHome() { + let path = AppPaths.dataDirectory( + environment: ["XDG_DATA_HOME": "relative"], + home: URL(filePath: "/home/test"), + temporaryDirectory: URL(filePath: "/tmp") + ) + #expect(path.path == "/home/test/.local/share/luminate") + } + + @Test("Root home is a valid Linux data location") + func rootHome() { + let path = AppPaths.dataDirectory( + environment: [:], + home: URL(filePath: "/"), + temporaryDirectory: URL(filePath: "/tmp") + ) + #expect(path.path == "/.local/share/luminate") + } + #endif + + @Test("Missing home falls back to the temporary directory") + func missingHome() { + let path = AppPaths.dataDirectory( + environment: [:], + home: nil, + temporaryDirectory: URL(filePath: "/tmp/luminate-test") + ) + #expect(path.path == "/tmp/luminate-test/luminate") + } +} diff --git a/Tests/LuminateCoreTests/PreferenceValueTests.swift b/Tests/LuminateCoreTests/PreferenceValueTests.swift new file mode 100644 index 0000000..b7dcb9b --- /dev/null +++ b/Tests/LuminateCoreTests/PreferenceValueTests.swift @@ -0,0 +1,45 @@ +// +// PreferenceValueTests.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 Testing + +@testable import LuminateCore + +/// Verifies typed conversions at the preference storage boundary. +@Suite struct PreferenceValueTests { + @Test("Boolean decoding accepts only zero and one") + func booleanDecoding() { + #expect(Bool(preference: .integer(0)) == false) + #expect(Bool(preference: .integer(1)) == true) + #expect(Bool(preference: .integer(2)) == nil) + } + + @Test("Double decoding accepts integer storage") + func doubleIntegerDecoding() { + #expect(Double(preference: .integer(3)) == 3.0) + } + + @Test("Non-finite doubles encode as zero") + func nonFiniteDoubleEncoding() { + #expect(Double.nan.preferenceValue == .real(0)) + #expect(Double.infinity.preferenceValue == .real(0)) + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift b/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift index 2364511..02f68c0 100644 --- a/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift +++ b/Tests/LuminateServicesTests/JellyfinClientErrorMappingTests.swift @@ -42,6 +42,18 @@ import Testing ) } + @Test("An unconfigured client rejects operations before network access") + func unconfiguredClientThrows() async { + let client = JellyfinClient( + configuration: JellyfinClientConfiguration(serverURL: nil) + ) + + #expect(await client.serverURL == nil) + await #expect(throws: JellyfinClientError.notConfigured) { + _ = try await client.items() + } + } + @Test("A 401 maps to unauthorized") func unauthorizedMaps() async { var api = MockJellyfinAPI() diff --git a/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift b/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift new file mode 100644 index 0000000..e3c6ce0 --- /dev/null +++ b/Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift @@ -0,0 +1,109 @@ +// +// SQLitePreferenceStoreTests.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 Foundation +import Testing + +@testable import LuminateCore +@testable import LuminateStore + +@Suite struct SQLitePreferenceStoreTests { + @Test("A fresh database has schema version one and no preferences") + func freshDatabase() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try SQLitePreferenceStore(url: url) + #expect(store.initialSnapshot.isEmpty) + let database = try SQLiteDatabase(url: url) + #expect(try database.userVersion == 1) + } + + @Test("Queued values survive flush and reopen") + func persistsValue() async throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try SQLitePreferenceStore(url: url) + store.enqueue("server.url", .text("http://jellyfin.test:8096")) + try await store.flush() + + let reopened = try SQLitePreferenceStore(url: url) + #expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096")) + } + + @Test("A nil queued value deletes the stored row") + func deletesValue() async throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try SQLitePreferenceStore(url: url) + store.enqueue("auth.accessToken", .text("secret")) + store.enqueue("auth.accessToken", nil) + try await store.flush() + + let reopened = try SQLitePreferenceStore(url: url) + #expect(reopened.initialSnapshot["auth.accessToken"] == nil) + } + + @Test("Every SQLite storage class round trips") + func storageClassesRoundTrip() async throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try SQLitePreferenceStore(url: url) + let values: [String: StoredPreference] = [ + "integer": .integer(42), + "real": .real(3.5), + "text": .text("value"), + "blob": .blob(Data([1, 2, 3])), + "emptyBlob": .blob(Data()), + "nulText": .text("a\0b"), + ] + for (key, value) in values { + store.enqueue(key, value) + } + try await store.flush() + + let reopened = try SQLitePreferenceStore(url: url) + #expect(reopened.initialSnapshot == values) + } + + @Test("Writes to one key are applied in submission order") + func preservesOrder() async throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = try SQLitePreferenceStore(url: url) + store.enqueue("server.url", .text("first")) + store.enqueue("server.url", .text("second")) + try await store.flush() + + let reopened = try SQLitePreferenceStore(url: url) + #expect(reopened.initialSnapshot["server.url"] == .text("second")) + } + + private func makeDatabaseURL() -> (URL, URL) { + let directory = FileManager.default.temporaryDirectory + .appending(path: "luminate-store-\(UUID().uuidString)", directoryHint: .isDirectory) + return (directory.appending(path: "preferences.sqlite"), directory) + } +} diff --git a/Tests/LuminateStoreTests/SchemaMigratorTests.swift b/Tests/LuminateStoreTests/SchemaMigratorTests.swift new file mode 100644 index 0000000..22bf999 --- /dev/null +++ b/Tests/LuminateStoreTests/SchemaMigratorTests.swift @@ -0,0 +1,157 @@ +// +// SchemaMigratorTests.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 Foundation +import Testing + +@testable import LuminateCore +@testable import LuminateStore + +@Suite struct SchemaMigratorTests { + @Test("Migrating a current database is a no-op") + func currentDatabase() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + try SchemaMigrator.migrate(database) + let snapshot = try SQLitePreferenceStore(url: url).initialSnapshot + try SchemaMigrator.migrate(database) + #expect(try database.userVersion == SchemaMigrator.latestVersion) + #expect(try SQLitePreferenceStore(url: url).initialSnapshot == snapshot) + } + + @Test("A newer minimum reader version rejects the database") + func newerReaderVersion() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + try SchemaMigrator.migrate(database) + try database.exec( + "UPDATE schema_metadata SET minimum_reader_version = \(SchemaMigrator.latestVersion + 1);" + ) + + do { + _ = try SQLitePreferenceStore(url: url) + Issue.record("Expected a newer-reader migration error") + } catch let error as PreferenceStoreError { + #expect( + error + == .requiresNewerApplication( + minimumReaderVersion: SchemaMigrator.latestVersion + 1, + supportedVersion: SchemaMigrator.latestVersion + ) + ) + } + } + + @Test("A non-Luminate application identifier is rejected") + func foreignApplication() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + try database.setApplicationID(1234) + + do { + try SchemaMigrator.migrate(database) + Issue.record("Expected a foreign-database error") + } catch let error as PreferenceStoreError { + #expect(error == .notALuminateDatabase(applicationID: 1234)) + } + } + + @Test("A higher compatible schema version remains readable") + func compatibleHigherVersion() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + try SchemaMigrator.migrate(database) + try database.setUserVersion(SchemaMigrator.latestVersion + 1) + let statement = try SQLiteStatement( + database: database, + sql: "INSERT INTO preference (key, value) VALUES (?1, ?2);" + ) + try statement.bind(1, text: "server.url") + try statement.bind(2, .text("http://compatible")) + _ = try statement.step() + + let store = try SQLitePreferenceStore(url: url) + #expect(store.initialSnapshot["server.url"] == .text("http://compatible")) + } + + @Test("A failed migration rolls back its statements and version") + func failedMigrationRollsBack() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + let failingMigration = StoreMigration( + version: 2, + name: "failing migration", + minimumReaderVersion: 1, + statements: [ + "CREATE TABLE version_two_marker (id INTEGER);", + "CREATE TABLE preference (x);", + ] + ) + + #expect(throws: PreferenceStoreError.self) { + try SchemaMigrator.migrate(database, migrations: SchemaMigrator.migrations + [failingMigration]) + } + #expect(try database.userVersion == 1) + #expect(try tableExists("version_two_marker", in: database) == false) + } + + @Test("Migration versions must be strictly increasing") + func rejectsMalformedMigrationOrder() throws { + let (url, directory) = makeDatabaseURL() + defer { try? FileManager.default.removeItem(at: directory) } + + let database = try SQLiteDatabase(url: url) + let migrations = [ + StoreMigration(version: 2, name: "second", minimumReaderVersion: 1, statements: []), + StoreMigration(version: 1, name: "first", minimumReaderVersion: 1, statements: []), + ] + + #expect(throws: PreferenceStoreError.malformedMigrationList(version: 1)) { + try SchemaMigrator.migrate(database, migrations: migrations) + } + } + + /// Checks whether SQLite exposes a table with the requested name. + private func tableExists(_ name: String, in database: SQLiteDatabase) throws -> Bool { + let statement = try SQLiteStatement( + database: database, + sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1;" + ) + try statement.bind(1, text: name) + return try statement.step() + } + + private func makeDatabaseURL() -> (URL, URL) { + let directory = FileManager.default.temporaryDirectory + .appending(path: "luminate-schema-\(UUID().uuidString)", directoryHint: .isDirectory) + return (directory.appending(path: "preferences.sqlite"), directory) + } +} diff --git a/Tests/LuminateTests/PreferencePersistenceTests.swift b/Tests/LuminateTests/PreferencePersistenceTests.swift new file mode 100644 index 0000000..ed98439 --- /dev/null +++ b/Tests/LuminateTests/PreferencePersistenceTests.swift @@ -0,0 +1,60 @@ +// +// PreferencePersistenceTests.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 Foundation +import Testing + +@testable import LuminateCore +@testable import LuminateStore +@testable import LuminateUI + +/// Verifies preference persistence across the UI and SQLite layers. +@MainActor @Suite struct PreferencePersistenceTests { + @Test("A preference written through a slot survives flush and reopen without a main-loop turn") + func survivesFlushAndReopen() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "luminate-e2e-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "preferences.sqlite") + + let preferences = Preferences(store: try SQLitePreferenceStore(url: url)) + preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096") + try await preferences.flush() + + let reopened = try SQLitePreferenceStore(url: url) + #expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096")) + } + + @Test("Bootstrap points the shared coordinator at the on-disk store") + func bootstrapInstallsSharedCoordinator() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: "luminate-bootstrap-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "preferences.sqlite") + + Preferences.bootstrap(PreferenceBackend.open(url: url)) + Preferences.shared[.serverURL] = URL(string: "http://jellyfin.test:8096") + try await Preferences.shared.flush() + + let reopened = try SQLitePreferenceStore(url: url) + #expect(reopened.initialSnapshot["server.url"] == .text("http://jellyfin.test:8096")) + } +} diff --git a/Tests/LuminateUITests/PreferenceTests.swift b/Tests/LuminateUITests/PreferenceTests.swift new file mode 100644 index 0000000..13664ab --- /dev/null +++ b/Tests/LuminateUITests/PreferenceTests.swift @@ -0,0 +1,138 @@ +// +// PreferenceTests.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 Foundation +@_spi(SGTKInternal) import Gtk +import LuminateCore +@_spi(Portico) import Portico +import Testing + +@testable import LuminateUI + +/// Test view that renders the current server URL preference. +private struct PreferenceLabel: View { + @Preference(.serverURL) private var serverURL: URL? + + var body: some View { + Label(str: "").label { serverURL?.absoluteString ?? "No server configured" } + } +} + +/// Exercises preference mounting, observation, and persistence wiring. +@Suite(.serialized) @MainActor struct PreferenceTests { + @Test("An unset preference reads nil at mount") + func unsetAtMount() { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let context = MountContext() + let label = + AnyView(PreferenceLabel().environment(\.preferences, preferences)) + .makeWidget(context) as! Gtk.Label + defer { context.registry.teardown() } + + #expect(label.getText() == "No server configured") + } + + @Test("An un-injected preference resolves through the shared coordinator") + func resolvesWithoutInjection() { + guard Gtk.initCheck() else { return } + Preferences.shared[.serverURL] = URL(string: "http://shared.test") + defer { Preferences.shared[.serverURL] = nil } + + let context = MountContext() + let label = AnyView(PreferenceLabel()).makeWidget(context) as! Gtk.Label + defer { context.registry.teardown() } + + #expect(label.getText() == "http://shared.test") + } + + @Test("Writing a preference updates the same mounted label") + func liveUpdate() { + guard Gtk.initCheck() else { return } + let preferences = Preferences(store: EphemeralPreferenceStore()) + let context = MountContext() + let label = + AnyView(PreferenceLabel().environment(\.preferences, preferences)) + .makeWidget(context) as! Gtk.Label + defer { context.registry.teardown() } + let pointer = label.pointer + + preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096") + pump { + label.getText() == "http://jellyfin.test:8096" + } + + #expect(label.pointer == pointer) + #expect(label.getText() == "http://jellyfin.test:8096") + } + + @Test("A preference write reaches the in-memory backend after flush") + func persistsToBackend() async throws { + let store = EphemeralPreferenceStore() + let preferences = Preferences(store: store) + preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096") + try await store.flush() + + #expect(await store.storedValues["server.url"] == .text("http://jellyfin.test:8096")) + } + + @Test("A seeded store presents its value at mount") + func restoresAtMount() { + guard Gtk.initCheck() else { return } + let store = EphemeralPreferenceStore( + initialSnapshot: ["server.url": .text("http://seeded.test")] + ) + let preferences = Preferences(store: store) + let context = MountContext() + let label = + AnyView(PreferenceLabel().environment(\.preferences, preferences)) + .makeWidget(context) as! Gtk.Label + defer { context.registry.teardown() } + + #expect(label.getText() == "http://seeded.test") + } + + @Test("Setting a preference to nil enqueues a deletion") + func deletesFromBackend() async throws { + let store = EphemeralPreferenceStore( + initialSnapshot: ["server.url": .text("http://seeded.test")] + ) + let preferences = Preferences(store: store) + preferences.slot(for: .serverURL).value = nil + try await store.flush() + + #expect(await store.storedValues["server.url"] == nil) + } + + private func pump(until condition: () -> Bool = { false }, turns: Int = 200) { + for _ in 0.. Int32