Add persistence layer
This commit is contained in:
parent
a5ee2edf5d
commit
0addde59d3
34 changed files with 2199 additions and 34 deletions
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
5
Sources/CSQLite/module.modulemap
Normal file
5
Sources/CSQLite/module.modulemap
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module CSQLite [system] {
|
||||
header "shim.h"
|
||||
link "sqlite3"
|
||||
export *
|
||||
}
|
||||
1
Sources/CSQLite/shim.h
Normal file
1
Sources/CSQLite/shim.h
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include <sqlite3.h>
|
||||
48
Sources/Luminate/ClientSessionBinder.swift
Normal file
48
Sources/Luminate/ClientSessionBinder.swift
Normal 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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
47
Sources/Luminate/LogBootstrap.swift
Normal file
47
Sources/Luminate/LogBootstrap.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
Sources/LuminateCore/Constants/AppInfo.swift
Normal file
32
Sources/LuminateCore/Constants/AppInfo.swift
Normal 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"
|
||||
}
|
||||
86
Sources/LuminateCore/Constants/AppPaths.swift
Normal file
86
Sources/LuminateCore/Constants/AppPaths.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
34
Sources/LuminateCore/Errors/PreferenceStoreError.swift
Normal file
34
Sources/LuminateCore/Errors/PreferenceStoreError.swift
Normal 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)
|
||||
}
|
||||
33
Sources/LuminateCore/Models/PreferenceKey.swift
Normal file
33
Sources/LuminateCore/Models/PreferenceKey.swift
Normal 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
|
||||
}
|
||||
}
|
||||
32
Sources/LuminateCore/Models/PreferenceKeys.swift
Normal file
32
Sources/LuminateCore/Models/PreferenceKeys.swift
Normal 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") }
|
||||
}
|
||||
110
Sources/LuminateCore/Models/PreferenceValue.swift
Normal file
110
Sources/LuminateCore/Models/PreferenceValue.swift
Normal 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 }
|
||||
}
|
||||
30
Sources/LuminateCore/Models/StoredPreference.swift
Normal file
30
Sources/LuminateCore/Models/StoredPreference.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Sources/LuminateCore/Protocols/PreferenceStoring.swift
Normal file
45
Sources/LuminateCore/Protocols/PreferenceStoring.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
Sources/LuminateStore/Database/SQLiteDatabase.swift
Normal file
139
Sources/LuminateStore/Database/SQLiteDatabase.swift
Normal 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);")
|
||||
}
|
||||
|
||||
}
|
||||
159
Sources/LuminateStore/Database/SQLiteStatement.swift
Normal file
159
Sources/LuminateStore/Database/SQLiteStatement.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Sources/LuminateStore/Migrations/SchemaMigrator.swift
Normal file
117
Sources/LuminateStore/Migrations/SchemaMigrator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
Sources/LuminateStore/Migrations/StoreMigration.swift
Normal file
32
Sources/LuminateStore/Migrations/StoreMigration.swift
Normal 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]
|
||||
}
|
||||
42
Sources/LuminateStore/Preferences/PreferenceBackend.swift
Normal file
42
Sources/LuminateStore/Preferences/PreferenceBackend.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Sources/LuminateStore/Preferences/SQLitePreferenceStore.swift
Normal file
140
Sources/LuminateStore/Preferences/SQLitePreferenceStore.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
81
Sources/LuminateUI/Preferences/Preference.swift
Normal file
81
Sources/LuminateUI/Preferences/Preference.swift
Normal 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
|
||||
}
|
||||
}
|
||||
47
Sources/LuminateUI/Preferences/PreferenceSlot.swift
Normal file
47
Sources/LuminateUI/Preferences/PreferenceSlot.swift
Normal 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
|
||||
}
|
||||
}
|
||||
123
Sources/LuminateUI/Preferences/Preferences.swift
Normal file
123
Sources/LuminateUI/Preferences/Preferences.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
89
Tests/LuminateCoreTests/AppPathsTests.swift
Normal file
89
Tests/LuminateCoreTests/AppPathsTests.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//
|
||||
// AppPathsTests.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 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")
|
||||
}
|
||||
}
|
||||
45
Tests/LuminateCoreTests/PreferenceValueTests.swift
Normal file
45
Tests/LuminateCoreTests/PreferenceValueTests.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// PreferenceValueTests.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 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
109
Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift
Normal file
109
Tests/LuminateStoreTests/SQLitePreferenceStoreTests.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// SQLitePreferenceStoreTests.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 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)
|
||||
}
|
||||
}
|
||||
157
Tests/LuminateStoreTests/SchemaMigratorTests.swift
Normal file
157
Tests/LuminateStoreTests/SchemaMigratorTests.swift
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
//
|
||||
// SchemaMigratorTests.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 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)
|
||||
}
|
||||
}
|
||||
60
Tests/LuminateTests/PreferencePersistenceTests.swift
Normal file
60
Tests/LuminateTests/PreferencePersistenceTests.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// PreferencePersistenceTests.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 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"))
|
||||
}
|
||||
}
|
||||
138
Tests/LuminateUITests/PreferenceTests.swift
Normal file
138
Tests/LuminateUITests/PreferenceTests.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
//
|
||||
// PreferenceTests.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
|
||||
@_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..<turns {
|
||||
if condition() { return }
|
||||
_ = preference_g_main_context_iteration(nil, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UPSTREAM: Portico should expose a main-loop pump for tests so suites do not need @_silgen_name.
|
||||
@_silgen_name("g_main_context_iteration")
|
||||
private nonisolated func preference_g_main_context_iteration(
|
||||
_ context: UnsafeMutableRawPointer?,
|
||||
_ mayBlock: Int32
|
||||
) -> Int32
|
||||
Loading…
Add table
Add a link
Reference in a new issue