Compare commits

...
Sign in to create a new pull request.

3 commits

62 changed files with 4961 additions and 39 deletions

3
.gitignore vendored
View file

@ -41,3 +41,6 @@ Package.resolved
*.xcodeproj *.xcodeproj
*.xcworkspace *.xcworkspace
xcuserdata/ xcuserdata/
# Code coverage files
*.profraw

View file

@ -96,6 +96,16 @@ let package = Package(
dependencies: uiDeps, dependencies: uiDeps,
swiftSettings: uiSwiftSettings swiftSettings: uiSwiftSettings
), ),
.target(
name: "LuminateOnboarding",
dependencies: [
"LuminateCore",
"LuminateServices",
"LuminateUI",
.product(name: "Portico", package: "portico"),
],
swiftSettings: uiSwiftSettings
),
.executableTarget( .executableTarget(
name: "Luminate", name: "Luminate",
dependencies: [ dependencies: [
@ -103,11 +113,22 @@ let package = Package(
"LuminateStore", "LuminateStore",
"LuminateServices", "LuminateServices",
"LuminateUI", "LuminateUI",
"LuminateOnboarding",
.product(name: "Portico", package: "portico"), .product(name: "Portico", package: "portico"),
.product(name: "Logging", package: "swift-log"), .product(name: "Logging", package: "swift-log"),
], ],
swiftSettings: uiSwiftSettings swiftSettings: uiSwiftSettings
), ),
.testTarget(
name: "LuminateOnboardingTests",
dependencies: [
"LuminateCore",
"LuminateServices",
"LuminateUI",
"LuminateOnboarding",
],
swiftSettings: uiSwiftSettings
),
.testTarget( .testTarget(
name: "LuminateCoreTests", name: "LuminateCoreTests",
dependencies: ["LuminateCore"], dependencies: ["LuminateCore"],
@ -130,7 +151,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "LuminateTests", name: "LuminateTests",
dependencies: ["LuminateCore", "LuminateStore", "LuminateUI"], dependencies: ["Luminate", "LuminateCore", "LuminateServices", "LuminateStore", "LuminateUI"],
swiftSettings: uiSwiftSettings swiftSettings: uiSwiftSettings
), ),
], ],

View file

@ -0,0 +1,59 @@
//
// BlockingBridge.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 Synchronization
/// Bridges a synchronous context into Swift Concurrency by blocking the calling thread until an
/// `async` operation completes.
///
/// Exists for exactly one call site: ``LaunchSession/resolveAtLaunch(preferences:probe:)``, run
/// from ``Luminate/init()`` before ``PorticoRuntime`` installs the GLib main-actor executor and
/// starts the GTK main loop. Blocking a thread on Concurrency work is normally unsafe - it can
/// starve the cooperative thread pool a suspended task needs to resume on - but is safe here
/// specifically because the calling thread is the process's original thread, not a pool worker,
/// and the awaited operation (``JellyfinClient/currentUser()``, a plain, non-main-actor `actor`
/// call) never needs the main actor to make progress.
enum BlockingBridge {
/// Runs `operation` to completion on a detached task and blocks the calling thread until it
/// finishes.
///
/// - Parameter operation: The `async` work to run to completion before returning.
/// - Returns: `operation`'s result.
/// - Throws: Whatever `operation` throws.
nonisolated static func run<T: Sendable>(
_ operation: @escaping @Sendable () async throws -> T
) throws -> T {
let semaphore = DispatchSemaphore(value: 0)
let box = Mutex<Result<T, Error>?>(nil)
Task.detached(priority: .userInitiated) {
do {
let value = try await operation()
box.withLock { $0 = .success(value) }
} catch {
box.withLock { $0 = .failure(error) }
}
semaphore.signal()
}
semaphore.wait()
return try box.withLock { $0 }!.get()
}
}

View file

@ -0,0 +1,71 @@
//
// ErrorWindow.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 Portico
/// The error surface: shown only when the pre-launch token probe (run synchronously in
/// ``Luminate/init()``, before any window exists) could not reach the server.
///
/// Its own spinner reappears while an interactive retry (``LaunchSession/retry()``) re-runs
/// that same probe; both states share one window so a retry never crosses a scene-identity
/// boundary.
package struct ErrorWindow: View {
private let session: LaunchSession
package init(_ session: LaunchSession) {
self.session = session
}
package var body: some View {
ToolbarView {
Clamp {
VStack(spacing: 24) {
VStack(spacing: 12) {
Spinner()
Label("Checking your saved session...")
}
.visible { session.phase == .verifying }
StatusPage("Cannot reach your server", iconName: "dialog-warning-symbolic") {
VStack(spacing: 12) {
Button("Try Again") {
Task { await session.retry() }
}
.suggestedAction()
.pill()
Button("Sign In Again") {
session.signOut()
}
.pill()
}
}
.description { session.failureMessage }
.visible { session.phase != .verifying }
}
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
}
}

View file

@ -0,0 +1,192 @@
//
// LaunchSession.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
import LuminateUI
import Observation
private let logger = Logger(label: "\(AppInfo.identifier).session")
/// The launch-time gate that proves a stored access token is still valid before the main window
/// is shown, and drives the interactive retry surface when it cannot be proven.
///
/// The initial probe runs synchronously, via ``launch(preferences:verify:)``, inside
/// ``Luminate/init()`` - before any window exists - so a ``LaunchSession`` is always constructed
/// already in a terminal starting phase: ``Phase/resolved`` (nothing to check, the token was
/// proven valid, or a rejected token was cleared) or ``Phase/unreachable(_:)`` (the probe could
/// not complete). Only the retry surface re-enters ``Phase/verifying``, through
/// ``verifyStoredToken()``/``retry()``. `verify` is injected as a plain closure rather than a
/// ``LuminateCore/JellyfinService`` so tests can drive every branch without a full service
/// double, and the exact same closure backs both the one-shot launch probe and every later retry.
@Observable package final class LaunchSession {
/// Where the launch gate is: probing, stuck, or finished deciding.
package enum Phase: Equatable, Sendable {
/// The stored token is being checked against the server.
case verifying
/// The probe could not complete; the token is unproven but was not rejected outright.
/// The associated value is a user-facing description of what went wrong.
case unreachable(String)
/// The gate has finished: either there was nothing to check, the token was proven valid,
/// or a rejected token was cleared. The scene conditional reads only this case.
case resolved
}
/// The current state of the gate.
package private(set) var phase: Phase
private let preferences: Preferences
private nonisolated let verify: @Sendable () async throws -> Void
/// Creates a session gate already in `phase` - an explicit starting point for a test
/// exercising ``verifyStoredToken()``/``retry()`` directly. Real launches go through
/// ``launch(preferences:verify:)`` instead.
///
/// - Parameters:
/// - phase: The gate's starting state.
/// - preferences: The coordinator holding the stored access token.
/// - verify: Probes the stored token against the server, throwing on rejection or failure.
package init(
phase: Phase,
preferences: Preferences = .shared,
verify: @escaping @Sendable () async throws -> Void
) {
self.phase = phase
self.preferences = preferences
self.verify = verify
}
/// Creates a launch-ready session: proves a stored token against the server before any window
/// exists, and remains ready to retry with the exact same probe.
///
/// Skips `verify` entirely when no token is stored - resolves immediately, no network call.
/// Otherwise blocks the calling thread on `verify` via ``BlockingBridge`` and classifies its
/// outcome with ``startingPhase(for:preferences:)``. Intended to run inside ``Luminate/init()``,
/// before ``PorticoRuntime`` creates any window, so the very first window the process ever
/// shows is already the right one.
///
/// - Parameters:
/// - preferences: The coordinator holding the stored access token.
/// - verify: Probes the stored token against the server; called only when a token is
/// present, and reused by every later ``retry()``.
/// - Returns: A session already in its starting phase.
package static func launch(
preferences: Preferences = .shared,
verify: @escaping @Sendable () async throws -> Void
) -> LaunchSession {
guard preferences.isAuthenticated else {
return LaunchSession(phase: .resolved, preferences: preferences, verify: verify)
}
let phase = startingPhase(for: Result { try BlockingBridge.run(verify) }, preferences: preferences)
return LaunchSession(phase: phase, preferences: preferences, verify: verify)
}
/// Classifies a completed probe outcome into a phase, clearing `preferences`'s access token on
/// a definitive rejection.
///
/// Shared by ``verifyStoredToken()`` (an interactive retry, already running on the main loop)
/// and ``launch(preferences:verify:)`` (the pre-launch synchronous probe): both need the exact
/// same success/rejection/unreachable classification.
///
/// - Parameters:
/// - result: The outcome of calling `verify` once, already run to completion.
/// - preferences: The coordinator holding the stored access token.
/// - Returns: ``Phase/resolved`` on success or rejection, ``Phase/unreachable(_:)`` otherwise.
private static func startingPhase(for result: Result<Void, Error>, preferences: Preferences) -> Phase {
switch result {
case .success:
return .resolved
case .failure(let error as JellyfinClientError) where isRejection(error):
logger.info("Stored access token was rejected (\(error)); returning to sign-in")
preferences[.accessToken] = nil
return .resolved
case .failure(let error):
logger.warning("Could not verify the stored access token: \(error)")
return .unreachable(message(for: error))
}
}
/// The retry surface's description text, or `nil` while verification is still running or has
/// already resolved.
package var failureMessage: String? {
if case .unreachable(let message) = phase { return message }
return nil
}
/// Runs the stored probe, transitioning to ``Phase/resolved`` on success, clearing the token
/// and transitioning to ``Phase/resolved`` on a definitive rejection, or transitioning to
/// ``Phase/unreachable(_:)`` when the server could not be reached at all.
///
/// A no-op outside ``Phase/verifying``, so a stray call after the gate already resolved does
/// nothing.
package func verifyStoredToken() async {
guard case .verifying = phase else { return }
let work = verify
let outcome: Result<Void, Error>
do {
try await Task.detached(priority: .userInitiated) { try await work() }.value
outcome = .success(())
} catch {
outcome = .failure(error)
}
phase = Self.startingPhase(for: outcome, preferences: preferences)
}
/// Re-runs verification from the retry surface.
///
/// A no-op outside ``Phase/unreachable(_:)``.
package func retry() async {
guard case .unreachable = phase else { return }
phase = .verifying
await verifyStoredToken()
}
/// Clears the stored token and resolves the gate, sending the user back to onboarding without
/// waiting for another probe.
package func signOut() {
preferences[.accessToken] = nil
phase = .resolved
}
/// Whether `error` is a definitive rejection of the token, as opposed to the server simply
/// being unreachable.
private static func isRejection(_ error: JellyfinClientError) -> Bool {
switch error {
case .unauthorized, .forbidden, .badRequest, .notConfigured: return true
default: return false
}
}
/// The retry surface's description text for `error`.
private static func message(for error: any Error) -> String {
let fallback = "Could not reach the server. Check that it is running and that this device is online."
guard let error = error as? JellyfinClientError else { return fallback }
switch error {
case .serviceUnavailable:
return "The server is still starting up. Try again in a moment."
case .unexpectedStatus(_, let statusCode):
return "The server answered with an unexpected HTTP \(statusCode) response."
default:
return fallback
}
}
}

View file

@ -20,39 +20,75 @@
// //
import Foundation import Foundation
import Logging
import LuminateCore import LuminateCore
import LuminateOnboarding
import LuminateServices import LuminateServices
import LuminateStore import LuminateStore
import LuminateUI import LuminateUI
import Portico import Portico
private let logger = Logger(label: "\(AppInfo.identifier).launch")
/// The Luminate application entry point. /// The Luminate application entry point.
/// ///
/// Owns the process-wide Jellyfin client and publishes it to the whole view tree; preferences /// Owns the process-wide Jellyfin client and publishes it to the whole view tree; preferences
/// install themselves through `Preferences.bootstrap(_:)`. /// install themselves through `Preferences.bootstrap(_:)`. A stored access token is proved against
/// the server synchronously at launch, before any window exists, so the process's first window is
/// already the right one.
@main @main
struct Luminate: App { struct Luminate: App {
var applicationId: String? { AppInfo.identifier } var applicationId: String? { AppInfo.identifier }
private let jellyfin: JellyfinClient private let jellyfinClient: JellyfinClient
private let sessionBinder: ClientSessionBinder private let sessionBinder: ClientSessionBinder
private let launchSession: LaunchSession
init() { init() {
LogBootstrap.bootstrap() LogBootstrap.bootstrap()
Preferences.bootstrap(PreferenceBackend.open()) Preferences.bootstrap(PreferenceBackend.open())
jellyfin = JellyfinClient( let client = JellyfinClient(
configuration: JellyfinClientConfiguration( configuration: JellyfinClientConfiguration(
serverURL: Preferences.shared[.serverURL], serverURL: Preferences.shared[.serverURL],
accessToken: Preferences.shared[.accessToken] accessToken: Preferences.shared[.accessToken]
) )
) )
sessionBinder = ClientSessionBinder(client: jellyfin) jellyfinClient = client
sessionBinder = ClientSessionBinder(client: client)
// Proves a stored token against the server, if there is one, before PorticoRuntime creates
// any window: blocks this thread synchronously so the process's first window is already
// the right one and no launch-time window is ever shown just to be replaced. Reconnecting
// before every check (not just retries) means a check never reuses a transport that
// previously failed to resolve DNS - see JellyfinClient.reconnect().
launchSession = LaunchSession.launch {
await client.reconnect()
_ = try await client.currentUser()
}
} }
var body: some Scene { var body: some Scene {
ApplicationWindow { _ in // `launchSession.phase` is never `.verifying` on this first evaluation: the probe already
RootView() // ran, synchronously, inside `init()`. This branch is only ever taken for `.unreachable`.
.environment(\.client, jellyfin) if launchSession.phase != .resolved {
ApplicationWindow { _ in
logger.debug("Presenting the error window (phase=\(launchSession.phase))")
return ErrorWindow(launchSession)
}
.defaultSize(width: 800, height: 600)
} else if Preferences.shared.isAuthenticated {
ApplicationWindow { _ in
logger.debug("Presenting the main window")
return RootView()
.environment(\.client, jellyfinClient)
}
} else {
ApplicationWindow { _ in
logger.debug("Presenting the onboarding window")
return OnboardWindow(startingAt: Preferences.shared[.serverURL] == nil ? [] : [.setup])
.environment(\.client, jellyfinClient)
}
.defaultSize(width: 800, height: 600)
} }
} }
} }

View file

@ -18,6 +18,7 @@ filter:
- GetSystemInfo - GetSystemInfo
# Auth # Auth
- GetPublicUsers - GetPublicUsers
- GetCurrentUser
- AuthenticateUserByName - AuthenticateUserByName
- UpdateUserPassword - UpdateUserPassword
- GetQuickConnectEnabled - GetQuickConnectEnabled

View file

@ -58,4 +58,7 @@ package enum JellyfinClientError: Error, Hashable, Sendable {
/// ///
/// - Parameter operation: The Jellyfin operation identifier, such as `AuthenticateUserByName`. /// - Parameter operation: The Jellyfin operation identifier, such as `AuthenticateUserByName`.
case missingPayload(operation: String) case missingPayload(operation: String)
/// ``JellyfinQuickConnect/connect(pollInterval:maxPolls:)`` polled past its `maxPolls` limit
/// without the request being approved or expired by the server.
case quickConnectTimedOut
} }

View file

@ -0,0 +1,62 @@
//
// ServerAddressError.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 validation failure while converting onboarding input into a server URL.
package enum ServerAddressError: Error, Hashable, Sendable {
/// The address field contained no non-whitespace characters.
case emptyAddress
/// The address used a URL scheme other than HTTP or HTTPS.
case unsupportedScheme(String)
/// Foundation could not parse the supplied address as URL components.
case malformedAddress
/// The parsed address did not contain a host name or IP address.
case missingHost
/// The address included credentials, which belong on the sign-in screen.
case credentialsNotAllowed
/// The address included a query string or fragment, which is not part of a server base URL.
case queryOrFragmentNotAllowed
/// The supplied port text was not an integer from 1 through 65535.
case invalidPort(String)
/// Both the address and the separate port field supplied a port.
case conflictingPorts
/// The user-facing explanation for this validation failure.
package var message: String {
switch self {
case .emptyAddress:
"Enter a server address"
case .unsupportedScheme(let scheme):
"\(scheme) is not supported. Use http or https"
case .malformedAddress:
"That server address could not be understood"
case .missingHost:
"The server address must contain a host name or IP address"
case .credentialsNotAllowed:
"Remove the username and password from the server address; sign in on the next screen instead"
case .queryOrFragmentNotAllowed:
"The server address cannot contain a query string or fragment"
case .invalidPort(let text):
"\(text) is not a valid port. Enter a number from 1 to 65535"
case .conflictingPorts:
"Specify the port in the address or in the port field, not both"
}
}
}

View file

@ -0,0 +1,43 @@
//
// ServerConnectionError.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 failure while probing a normalized server address.
package enum ServerConnectionError: Error, Hashable, Sendable {
/// Every candidate failed at the transport layer.
case unreachable(attempted: [URL])
/// A candidate answered, but its response was not accepted as a Jellyfin response.
case rejected(url: URL, detail: String)
/// A reachable server identified itself as a different product.
case notJellyfin(url: URL, productName: String?)
/// The user-facing explanation for this connection failure.
package var message: String {
switch self {
case .unreachable(let attempted):
"Could not reach a server at \(attempted.map(\.absoluteString).joined(separator: " or "))."
case .rejected(let url, let detail):
"\(url.absoluteString) answered, but not as a Jellyfin server: \(detail)"
case .notJellyfin(let url, let productName):
"\(url.absoluteString) is running \(productName ?? "an unknown product"), not Jellyfin."
}
}
}

View file

@ -0,0 +1,129 @@
//
// JellyfinQuickConnect.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
//
/// Drives one Quick Connect pairing attempt as an async event stream.
///
/// Reach it through ``JellyfinService/quickConnect``. Iterating ``connect(pollInterval:maxPolls:)``
/// initiates the request, yields the code to show the user, then polls the server on the caller's
/// behalf until the request is approved from another signed-in client:
///
/// ```swift
/// for try await event in client.quickConnect.connect() {
/// switch event {
/// case let .polling(code: code):
/// showCode(code)
/// case let .authenticated(secret: secret):
/// let authentication = try await client.authenticateWithQuickConnect(secret: secret)
/// accessToken = authentication.accessToken
/// }
/// }
/// ```
///
/// The polling loop is a plain `Task` owned by the stream, cancelled from
/// `Continuation.onTermination` -- ending iteration (breaking out of the loop, or the enclosing
/// `.task` being cancelled on unmount) stops the polling task with it. Nothing keeps requesting
/// once nobody is listening.
package struct JellyfinQuickConnect: Sendable {
/// One step of a Quick Connect pairing attempt.
package enum Event: Equatable, Sendable {
/// The request was created; show the associated code to the user.
case polling(code: String)
/// The request was approved from another client; redeem the secret for a token via
/// ``JellyfinService/authenticateWithQuickConnect(secret:)``.
case authenticated(secret: String)
}
private let service: any JellyfinService
/// Wraps a service for Quick Connect.
///
/// - Parameter service: The service to initiate and poll through. Use
/// ``JellyfinService/quickConnect`` instead of calling this directly.
init(service: any JellyfinService) {
self.service = service
}
/// Starts a Quick Connect pairing attempt when iterated.
///
/// - Parameters:
/// - pollInterval: Time between polls. Defaults to five seconds, matching Jellyfin's web
/// client.
/// - maxPolls: The maximum number of polls before giving up. Defaults to 200, about sixteen
/// minutes at the default interval.
/// - Returns: A stream of one ``Event/polling(code:)`` followed by at most one
/// ``Event/authenticated(secret:)``. The stream throws ``JellyfinClientError/notFound`` if
/// the server expires the request first, or ``JellyfinClientError/quickConnectTimedOut`` if
/// `maxPolls` is exhausted without an answer either way.
package func connect(
pollInterval: Duration = .seconds(5),
maxPolls: Int = 200
) -> AsyncThrowingStream<Event, Error> {
precondition(pollInterval > .zero, "Poll interval must be positive")
precondition(maxPolls > 0, "Maximum poll count must be positive")
return AsyncThrowingStream { continuation in
let task = Task {
do {
try await run(pollInterval: pollInterval, maxPolls: maxPolls, into: continuation)
continuation.finish()
} catch is CancellationError {
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
/// Initiates the request, yields its code, then polls until it is approved or exhausted.
///
/// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the
/// secret or code, whatever ``JellyfinService/quickConnectState(secret:)`` throws (notably
/// ``JellyfinClientError/notFound`` once the server expires the request), or
/// ``JellyfinClientError/quickConnectTimedOut`` after `maxPolls` unanswered polls.
private func run(
pollInterval: Duration,
maxPolls: Int,
into continuation: AsyncThrowingStream<Event, Error>.Continuation
) async throws {
let state = try await service.initiateQuickConnect()
guard let secret = state.secret, let code = state.code else {
throw JellyfinClientError.missingPayload(operation: "InitiateQuickConnect")
}
continuation.yield(.polling(code: code))
for _ in 0..<maxPolls {
try Task.checkCancellation()
if try await service.quickConnectState(secret: secret).authenticated == true {
continuation.yield(.authenticated(secret: secret))
return
}
try await Task.sleep(for: pollInterval)
}
throw JellyfinClientError.quickConnectTimedOut
}
}

View file

@ -0,0 +1,32 @@
//
// OnboardDestination.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
//
/// The destinations that can be pushed onto `OnboardWindow`'s `onboardPath`.
///
/// Appending a case pushes its corresponding page as the top-most view in the navigation stack.
package enum OnboardDestination: Hashable, Sendable {
case setup
case login
case userLogin(user: JellyfinUser)
case manualLogin
case quickConnect
case forgotPassword
}

View file

@ -0,0 +1,139 @@
//
// ServerAddress.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
/// Controls whether server discovery may try plaintext HTTP after HTTPS fails.
package enum ServerSchemePolicy: Sendable, Hashable {
/// Try HTTPS first and permit HTTP fallback only for an unprefixed address.
case automatic
/// Use HTTPS only, preserving any port supplied by the user.
case httpsOnly
}
/// A validated Jellyfin server base URL and its scheme provenance.
package struct ServerAddress: Sendable, Hashable {
/// The normalized base URL, including a trailing slash.
package let baseURL: URL
/// Whether the user supplied a scheme in the original address.
package let schemeWasExplicit: Bool
private let allowsInsecureFallback: Bool
/// The candidates to probe, ordered from preferred to fallback URL.
package var probeCandidates: [URL] {
allowsInsecureFallback ? [baseURL, baseURL.withScheme("http")] : [baseURL]
}
/// Normalizes free-text onboarding fields into a validated server address.
///
/// - Parameters:
/// - address: A host name, IP address, or HTTP(S) URL, optionally with a path and port.
/// - port: An optional separate TCP port field.
/// - policy: The scheme policy selected by the user.
/// - Returns: A validated server address whose base URL is ready for probing.
/// - Throws: `ServerAddressError` when the input is empty, malformed, unsupported, or conflicting.
package static func normalize(
address rawAddress: String,
port rawPort: String,
policy: ServerSchemePolicy
) throws(ServerAddressError) -> ServerAddress {
let address = rawAddress.trimmingCharacters(in: .whitespacesAndNewlines)
let portText = rawPort.trimmingCharacters(in: .whitespacesAndNewlines)
guard !address.isEmpty else {
throw .emptyAddress
}
let schemeWasExplicit =
address.range(
of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#,
options: .regularExpression
) != nil
let parseable = schemeWasExplicit ? address : "https://" + address
guard var components = URLComponents(string: parseable) else {
throw .malformedAddress
}
guard let parsedScheme = components.scheme?.lowercased() else {
throw .malformedAddress
}
guard parsedScheme == "http" || parsedScheme == "https" else {
throw .unsupportedScheme(parsedScheme)
}
guard let host = components.host, !host.isEmpty else {
throw .missingHost
}
guard components.user == nil, components.password == nil else {
throw .credentialsNotAllowed
}
guard components.query == nil, components.fragment == nil else {
throw .queryOrFragmentNotAllowed
}
let addressPort = components.port
let enteredPort: Int?
if portText.isEmpty {
enteredPort = nil
} else {
guard let value = Int(portText), (1...65_535).contains(value) else {
throw .invalidPort(portText)
}
enteredPort = value
}
if addressPort != nil && enteredPort != nil {
throw .conflictingPorts
}
var chosenPort = enteredPort ?? addressPort
if enteredPort == nil,
(parsedScheme == "http" && chosenPort == 80) || (parsedScheme == "https" && chosenPort == 443)
{
chosenPort = nil
}
components.scheme = policy == .httpsOnly ? "https" : parsedScheme
components.port = chosenPort
var path = components.path
if path.isEmpty {
path = "/"
} else if !path.hasSuffix("/") {
path += "/"
}
components.path = path
guard let baseURL = components.url else {
throw .malformedAddress
}
return ServerAddress(
baseURL: baseURL,
schemeWasExplicit: schemeWasExplicit,
allowsInsecureFallback: !schemeWasExplicit && policy == .automatic
)
}
}
extension URL {
fileprivate func withScheme(_ scheme: String) -> URL {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
return self
}
components.scheme = scheme
return components.url ?? self
}
}

View file

@ -0,0 +1,50 @@
//
// ServerConnection.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 reachable Jellyfin server together with the URL that answered the probe.
package struct ServerConnection: Sendable, Hashable {
/// The validated base URL that successfully answered.
package let url: URL
/// The identity information returned by the server.
package let serverInfo: JellyfinServerInfo
/// Creates a connection from a successful probe result.
///
/// - Parameters:
/// - url: The validated base URL that answered.
/// - serverInfo: The identity information returned by the server.
package init(url: URL, serverInfo: JellyfinServerInfo) {
self.url = url
self.serverInfo = serverInfo
}
/// Whether the successful connection used HTTPS.
package var isEncrypted: Bool { url.scheme?.lowercased() == "https" }
/// A concise success message suitable for the onboarding toast.
package var summary: String {
let name = serverInfo.serverName ?? "the server"
if isEncrypted {
return "Connected to \(name) over HTTPS."
}
return "Connected to \(name) over HTTP - this connection is not encrypted."
}
}

View file

@ -0,0 +1,28 @@
//
// JellyfinService+QuickConnect.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
//
/// The Quick Connect entry point for a ``JellyfinService``.
extension JellyfinService {
/// Starts a Quick Connect pairing attempt. See ``JellyfinQuickConnect`` for usage.
package var quickConnect: JellyfinQuickConnect {
JellyfinQuickConnect(service: self)
}
}

View file

@ -74,6 +74,15 @@ package protocol JellyfinService: AnyObject, Sendable {
/// - Throws: A transport error if the server is unreachable. /// - Throws: A transport error if the server is unreachable.
func publicUsers() async throws -> [JellyfinUser] func publicUsers() async throws -> [JellyfinUser]
/// Fetches the account the current access token belongs to.
///
/// This is Luminate's token-validity check: a rejected token surfaces as
/// ``JellyfinClientError/unauthorized``.
///
/// - Returns: The signed-in account.
/// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected.
func currentUser() async throws -> JellyfinUser
/// Signs in with a username and password and stores the resulting token on the service. /// Signs in with a username and password and stores the resulting token on the service.
/// ///
/// Every later call made through this service is authenticated as the returned account. /// Every later call made through this service is authenticated as the returned account.

View file

@ -53,6 +53,11 @@ package final class UnconfiguredJellyfinService: JellyfinService {
throw JellyfinClientError.notConfigured throw JellyfinClientError.notConfigured
} }
/// - Throws: Always ``JellyfinClientError/notConfigured``.
package func currentUser() async throws -> JellyfinUser {
throw JellyfinClientError.notConfigured
}
/// - Throws: Always ``JellyfinClientError/notConfigured``. /// - Throws: Always ``JellyfinClientError/notConfigured``.
package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
throw JellyfinClientError.notConfigured throw JellyfinClientError.notConfigured

View file

@ -0,0 +1,70 @@
//
// OnboardPathEnvironmentKey.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).onboarding")
/// The environment slot that carries `OnboardWindow`'s navigation path down the onboarding tree.
///
/// The slot holds the `Binding` itself, not the path value: Portico's `@Environment` is a read-only
/// view of a slot, so a consumer can only push by writing through a binding that the injecting
/// window owns. Onboarding-specific by design -- a future main-window stack gets its own slot
/// rather than sharing this one.
package enum OnboardPathEnvironmentKey: EnvironmentKey {
/// The inert binding used when nothing injected a path.
///
/// Reads yield an empty stack and writes are logged and dropped, so a page mounted outside
/// `OnboardWindow` (a unit test, a preview harness) reports the mistake instead of silently
/// appearing to navigate.
package static var defaultValue: Binding<[OnboardDestination]> {
Binding(
get: { [] },
set: { _ in
logger.error("Dropped an onboarding navigation push: no OnboardWindow injected \\.onboardPath")
}
)
}
}
extension EnvironmentValues {
/// The onboarding navigation path visible to this subtree.
///
/// ```swift
/// // Injection, once, on OnboardWindow's NavigationView:
/// NavigationView(path: $onboardPath) { ... }.environment(\.onboardPath, $onboardPath)
///
/// // Consumption in any onboarding page:
/// @Environment(\.onboardPath) private var onboardPath
/// onboardPath.wrappedValue.append(.manualLogin)
/// ```
///
/// The accessors deliberately route through `self[OnboardPathEnvironmentKey.self]`: Portico
/// resolves a key path by reading it with a probe installed and capturing the box the subscript
/// hands over, so a computed property that bypassed the subscript would leave the slot
/// unresolvable.
package var onboardPath: Binding<[OnboardDestination]> {
get { self[OnboardPathEnvironmentKey.self] }
set { self[OnboardPathEnvironmentKey.self] = newValue }
}
}

View file

@ -0,0 +1,74 @@
//
// ForgotPasswordPage.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).onboarding")
/// The password-reset entry page.
///
/// The reset flow is not implemented yet: submitting the form shows an explanatory toast instead of
/// resetting a password. See ``signIn()``.
package struct ForgotPasswordPage: View {
@State private var isLoading = false
@State private var username: String = ""
private let toastManager = ToastManager()
package init() {}
package var body: some View {
ToastOverlay {
ToolbarView {
Clamp {
StatusPage(description: "Enter your username, if you remember it", title: "Reset Your Password") {
PreferencesGroup {
EntryRow("Username", text: $username)
ButtonRow("Continue") {
Task { await signIn() }
}
.suggestedAction()
.sensitive({ !isLoading })
}
.separateRows(true)
}
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle("Forgot Password")
}
.toastManager(toastManager)
}
private func signIn() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
logger.notice("Forgot password functionality is currently unimplemented and will be added at a later date")
toastManager.addToast(.init(title: "Password reset isn't available yet"))
}
}

View file

@ -0,0 +1,54 @@
//
// IntroPage.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 Portico
/// The first carousel page in `OnboardWindow`: a brief welcome screen before server setup.
package struct IntroPage: View {
@Environment(\.onboardPath) private var onboardPath
package init() {}
package var body: some View {
ToolbarView {
Clamp {
StatusPage(
description:
"Luminate is a modern Jellyfin client for the Linux desktop. Continue to the next page to connect to a server",
iconName: "person-symbolic",
title: "Welcome to Luminate!"
) {
Button("Continue") {
onboardPath.wrappedValue.append(.setup)
}
.suggestedAction()
.pill()
.halign(.center)
}
}
.hexpand(true)
.vexpand(true)
} top: {
HeaderBar()
}
.navigationTitle("Welcome")
}
}

View file

@ -0,0 +1,130 @@
//
// LoginPage.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 LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The sign-in landing page: lists the server's public users, or offers manual/Quick
/// Connect/forgot-password entry points when none are public or none are shown.
package struct LoginPage: View {
@State private var isLoading: Bool = false
@State private var showPublicUsers: Bool = false
@State private var users: [JellyfinUser] = []
@State private var isMobile: Bool = false
@Environment(\.client) private var client
@Environment(\.onboardPath) private var onboardPath
package init() {}
package var body: some View {
ToolbarView {
BreakpointBin {
Clamp {
EitherView($isLoading) {
Spinner()
} second: {
EitherView($showPublicUsers) {
publicUsersView
} second: {
ManualLoginUI(showSignInMethods: true)
}
}
.margin(12)
}
}
.breakpoint(.maxWidth(400, .sp), isActive: $isMobile)
.preferredWidth(200)
.preferredHeight(200)
} top: {
HeaderBar()
}
.navigationTitle("Sign In")
.task {
await getPublicUsers()
}
}
private var publicUsersView: some View {
VStack(spacing: 32) {
WrapBox {
ForEach($users, id: \.id) { user in
userView(user)
}
}
.childSpacing(16)
.lineSpacing(16)
.align(0.5)
PreferencesGroup {
ButtonRow("Manual Login", endIconName: "go-next-symbolic") {
onboardPath.wrappedValue.append(.manualLogin)
}
ButtonRow("Quick Connect", endIconName: "go-next-symbolic") {
onboardPath.wrappedValue.append(.quickConnect)
}
ButtonRow("Forgot Password", endIconName: "go-next-symbolic") {
onboardPath.wrappedValue.append(.forgotPassword)
}
}
.separateRows(true)
}
.vexpand(true)
.valign(.center)
}
private func userView(_ user: JellyfinUser) -> some View {
Button {
VStack(spacing: 8) {
Avatar(size: 0, text: user.name, showInitials: true)
.size({ isMobile ? 96 : 128 })
Label(str: user.name)
}
.margin(8)
}
.onClicked {
onboardPath.wrappedValue.append(.userLogin(user: user))
}
.flat()
}
private func getPublicUsers() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let users = try await Task.detached(priority: .userInitiated) {
try await client.publicUsers()
}.value
self.users = users
self.showPublicUsers = users.count > 0
} catch let error as JellyfinClientError {
logger.error("Server connection failure: \(error)")
} catch {
logger.error("Unexpected error occurred: \(error)")
}
}
}

View file

@ -0,0 +1,39 @@
//
// ManualLoginPage.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 Portico
/// The manual sign-in page for a private user: wraps ``ManualLoginUI`` in the page's own toolbar
/// chrome. The sign-in logic itself, including the access-token write, lives in ``ManualLoginUI``.
package struct ManualLoginPage: View {
package var body: some View {
ToolbarView {
Clamp {
ManualLoginUI()
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle("Sign In")
}
}

View file

@ -0,0 +1,113 @@
//
// ManualLoginUI.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 LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The manual sign-in form: username/password entry, and the Quick Connect/forgot-password entry
/// points when the caller asks for them.
///
/// Reused by ``ManualLoginPage`` (its own toolbar chrome, `showSignInMethods: false`) and by
/// ``LoginPage`` (embedded directly, `showSignInMethods: true`, so a server with no public users
/// still offers Quick Connect and Forgot Password). A successful sign-in stores the access token
/// in the `.accessToken` preference; the application's scene conditional observes that same slot
/// and replaces the onboarding window with the main window.
package struct ManualLoginUI: View {
@Environment(\.onboardPath) private var onboardPath
@Environment(\.client) private var client
@Preference(.accessToken) private var accessToken: String?
@State private var isLoading = false
@State private var username: String = ""
@State private var password: String = ""
private let showSignInMethods: Bool
package init(showSignInMethods: Bool = false) {
self.showSignInMethods = showSignInMethods
}
package var body: some View {
EitherView($isLoading) {
Spinner()
} second: {
StatusPage(
"Let's get to know each other", description: "Enter Jellyfin account information to continue"
) {
VStack(spacing: 24) {
PreferencesGroup {
EntryRow("Username", text: $username)
PasswordEntryRow("Password", text: $password)
.onEntryActivated { Task { await signIn() } }
}
PreferencesGroup {
ButtonRow("Sign In") {
Task { await signIn() }
}
.suggestedAction()
.sensitive { !username.isEmpty }
}
if showSignInMethods {
PreferencesGroup {
ButtonRow("Quick Connect", endIconName: "go-next-symbolic") {
onboardPath.wrappedValue.append(.quickConnect)
}
ButtonRow("Forgot Password", endIconName: "go-next-symbolic") {
onboardPath.wrappedValue.append(.forgotPassword)
}
}
.separateRows(true)
}
}
}
.vexpand(true)
.valign(.center)
}
}
private func signIn() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let authentication = try await client.authenticate(
username: username,
password: password
)
let authenticatedUser = authentication.user?.name ?? username
logger.info("Authenticated as \(authenticatedUser)!")
accessToken = authentication.accessToken
} catch let error as JellyfinClientError {
logger.error("Server connection failure: \(error)")
} catch {
logger.error("Unexpected error occurred: \(error)")
}
}
}

View file

@ -0,0 +1,59 @@
//
// OnboardWindow.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 root view for the unauthenticated onboarding window.
///
/// Hosts a `NavigationView` rooted at ``IntroPage``, with every later step (server setup, then the
/// `.login`-rooted stack: public-user picker, manual sign-in, Quick Connect, forgot password)
/// pushed as an ``OnboardDestination``. Owns the single `onboardPath` state and injects it through
/// `\.onboardPath` so every pushed page can push further without re-threading a binding down the
/// view tree.
package struct OnboardWindow: View {
@State private var onboardPath: [OnboardDestination]
/// Creates the onboarding window.
///
/// - Parameter path: The destinations to push above the server-setup carousel at mount. Pass
/// `[.login]` when a server URL is already stored so a returning user does not retype it.
package init(startingAt path: [OnboardDestination] = []) {
_onboardPath = State(wrappedValue: path)
}
package var body: some View {
NavigationView(path: $onboardPath) {
IntroPage()
.navigationDestination(for: OnboardDestination.self) { destination in
switch destination {
case .setup: SetupPage()
case .login: LoginPage()
case .userLogin(let user): UserLoginPage(user)
case .manualLogin: ManualLoginPage()
case .quickConnect: QuickConnectPage()
case .forgotPassword: ForgotPasswordPage()
}
}
}
.environment(\.onboardPath, $onboardPath)
}
}

View file

@ -0,0 +1,154 @@
//
// QuickConnectPage.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 LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The Quick Connect pairing page: shows a one-time code and waits for another signed-in client to
/// approve it.
///
/// A successful pairing stores the access token in the `.accessToken` preference. That write is the
/// only login-state mutation in the app: the application's scene conditional observes the same
/// slot and replaces the onboarding window with the main window.
package struct QuickConnectPage: View {
@Environment(\.client) private var client
@Environment(\.clipboard) private var clipboard
@State private var isLoading: Bool = false
@State private var quickConnectCode: [CodeCharacter] = []
@Preference(.accessToken) private var accessToken: String?
private let toastManager = ToastManager()
package init() {}
package var body: some View {
ToastOverlay {
ToolbarView {
Clamp {
StatusPage(
"Quick Connect", description: "Enter the code below in Jellyfin's Settings > Quick Connect"
) {
VStack(spacing: 12) {
EitherView($isLoading) {
Spinner()
} second: {
VStack(spacing: 48) {
HStack(spacing: 8) {
ForEach($quickConnectCode, id: \.id) { character in
Bin {
Label(str: character.value)
.numericStyle()
.title1()
.marginTop(8)
.marginBottom(8)
.marginStart(12)
.marginEnd(12)
}
.card()
}
}
.hexpand(true)
.halign(.center)
Button {
ButtonContent(iconName: "edit-copy-symbolic", label: "Copy to Clipboard")
} onClicked: {
copyCodeToClipboard()
}
.suggestedAction()
.halign(.center)
}
}
}
}
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle("Sign In")
}
.toastManager(toastManager)
.task {
await startQuickConnect()
}
}
/// Runs the Quick Connect flow to completion: shows the code, then waits for approval.
///
/// ``JellyfinService/quickConnect`` owns the initiate-then-poll exchange as an event stream;
/// this only reacts to what it yields. Cancelling this `.task` (on unmount) cancels the
/// stream's polling with it, so leaving the page stops the network chatter.
private func startQuickConnect() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
for try await event in client.quickConnect.connect() {
switch event {
case .polling(let code):
quickConnectCode = code.enumerated().map { CodeCharacter(id: $0.offset, value: String($0.element)) }
isLoading = false
case .authenticated(let secret):
let authentication = try await client.authenticateWithQuickConnect(secret: secret)
let authenticatedUser = authentication.user?.name ?? "unknown user"
logger.info("Authenticated via Quick Connect as \(authenticatedUser)!")
accessToken = authentication.accessToken
}
}
} catch JellyfinClientError.notFound, JellyfinClientError.quickConnectTimedOut {
logger.warning("Quick connect request expired before it was approved")
toastManager.addToast(.init(title: "Code expired, go back and try again"))
} catch let error as JellyfinClientError {
logger.error("Server connection failure: \(error)")
} catch {
logger.error("Unexpected error occurred: \(error)")
}
}
/// Joins the displayed digits and hands the result to the system clipboard.
///
/// The write runs off the main actor because it spawns a short-lived helper process; the
/// result is reported back on the main actor so failures reach the log.
private func copyCodeToClipboard() {
let code = quickConnectCode.map(\.value).joined()
guard !code.isEmpty else { return }
clipboard.setText(code)
toastManager.addToast(.init(title: "Code copied to clipboard"))
}
}
/// One character of a Quick Connect code, keyed by its fixed position rather than its value so
/// repeated characters (Quick Connect codes are not guaranteed unique per digit, e.g. "AA12BB")
/// don't collide during ``ForEach`` diffing.
private struct CodeCharacter: Identifiable {
let id: Int
let value: String
}

View file

@ -0,0 +1,155 @@
//
// SetupPage.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
import LuminateServices
import LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The server connection setup page: enter or paste a server address, test or connect to it.
///
/// A successful connect stores the resolved URL in the `.serverURL` preference and pushes
/// `.login` onto the shared onboarding navigation path.
package struct SetupPage: View {
@State private var serverAddress: String = ""
@State private var port: String = ""
@State private var forceHttps: Bool = false
@State private var isTesting: Bool = false
@State private var isConnecting: Bool = false
@Preference(.serverURL) private var serverURL: URL?
@Environment(\.onboardPath) private var onboardPath
private let probe = ServerConnectionProbe()
private let toasts = ToastManager()
package init() {}
package var body: some View {
ToolbarView {
ToastOverlay {
Clamp {
StatusPage(description: "Enter your Jellyfin server information", title: "Let's get started") {
VStack(spacing: 8) {
PreferencesGroup {
EntryRow("Server URL", text: $serverAddress)
ExpanderRow("Advanced Options") {
EntryRow("Port", text: $port)
SwitchRow(
"Force HTTPS", subtitle: "Enforces connection over HTTPS", active: $forceHttps)
}
} headerSuffix: {
Button {
EitherView($isTesting) {
Spinner()
} second: {
Label("Test Connection")
}
} onClicked: {
connect(persisting: false)
}
.sensitive { !isTesting }
}
Button {
EitherView($isConnecting) {
Spinner()
} second: {
Label("Connect")
}
} onClicked: {
connect(persisting: true)
}
.sensitive { !isConnecting }
.circular()
.suggestedAction()
}
}
}
}
.toastManager(toasts)
.hexpand(true)
.vexpand(true)
} top: {
HeaderBar()
}
.navigationTitle("Server Setup")
.onAppear {
if let preconfiguredServerPath = serverURL?.absoluteString {
serverAddress = preconfiguredServerPath
}
}
}
/// Normalizes the form, probes the server, and optionally persists the successful URL.
///
/// Normalization is pure and stays on the main actor; the network probe runs in a detached
/// task so the reachability request never occupies main-actor isolation, and its result is
/// applied to view state after the `await` resumes back on the main actor.
///
/// - Parameter persisting: Whether a successful probe should save the URL and open sign-in.
private func connect(persisting: Bool) {
guard !isConnecting && !isTesting else { return }
if persisting {
isConnecting = true
} else {
isTesting = true
}
let policy: ServerSchemePolicy = forceHttps ? .httpsOnly : .automatic
let probe = probe
Task { @MainActor in
defer {
if persisting {
isConnecting = false
} else {
isTesting = false
}
}
do {
let address = try ServerAddress.normalize(address: serverAddress, port: port, policy: policy)
let connection = try await Task.detached(priority: .userInitiated) {
try await probe.connect(to: address)
}.value
if persisting {
serverURL = connection.url
onboardPath.wrappedValue.append(.login)
} else {
toasts.addToast(.init(title: connection.summary))
}
} catch let error as ServerAddressError {
logger.debug("Rejected server address: \(error)")
toasts.addToast(.init(title: error.message))
} catch let error as ServerConnectionError {
logger.debug("Server connection failed: \(error)")
toasts.addToast(.init(title: error.message))
} catch is CancellationError {
logger.debug("Server connection cancelled")
} catch {
logger.error("Unexpected server connection failure: \(error)")
toasts.addToast(.init(title: String(describing: error)))
}
}
}
}

View file

@ -0,0 +1,103 @@
//
// UserLoginPage.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 LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The password sign-in page for one public user.
///
/// A successful sign-in stores the access token in the `.accessToken` preference. That write is the
/// only login-state mutation in the app: the application's scene conditional observes the same
/// slot and replaces the onboarding window with the main window.
package struct UserLoginPage: View {
@Environment(\.client) private var client
@State private var isLoading = false
@State private var password: String = ""
@Preference(.accessToken) private var accessToken: String?
private let user: JellyfinUser
package init(_ user: JellyfinUser) {
self.user = user
}
package var body: some View {
ToolbarView {
Clamp {
EitherView($isLoading) {
Spinner()
} second: {
VStack(spacing: 32) {
Avatar(size: 128, text: user.name, showInitials: true)
PreferencesGroup {
PasswordEntryRow("Password", text: $password)
ButtonRow("Sign In") {
Task { await signIn() }
}
.suggestedAction()
}
.separateRows(true)
}
}
.margin(12)
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle {
if let userName = user.name {
"Sign In as \(userName)"
} else {
"Sign In"
}
}
}
private func signIn() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let authentication = try await client.authenticate(
username: user.name ?? "",
password: password
)
let authenticatedUser = authentication.user?.name ?? user.name ?? "unknown user"
logger.info("Authenticated as \(authenticatedUser)!")
accessToken = authentication.accessToken
} catch let error as JellyfinClientError {
logger.error("Server connection failure: \(error)")
} catch {
logger.error("Unexpected error occurred: \(error)")
}
}
}

View file

@ -48,6 +48,37 @@ extension JellyfinClient {
} }
} }
/// Fetches the account the current access token belongs to.
///
/// This is Luminate's token-validity check: a stored token that the server no longer accepts
/// surfaces as ``JellyfinClientError/unauthorized``, ``JellyfinClientError/forbidden``, or
/// ``JellyfinClientError/badRequest`` (Jellyfin's "token is not owned by a user" response)
/// rather than returning a value.
///
/// - Returns: The signed-in account.
/// - Throws: ``JellyfinClientError/unauthorized`` if the token is missing or rejected.
package func currentUser() async throws -> JellyfinUser {
switch try await current.getCurrentUser(.init()) {
case .ok(let response):
return JellyfinUser(response.body.payload)
case .badRequest:
throw JellyfinClientError.badRequest
case .forbidden:
throw JellyfinClientError.forbidden
case .serviceUnavailable(let response):
throw JellyfinClientError.serviceUnavailable(
retryAfterSeconds: response.headers.retryAfter
)
case .unauthorized:
throw JellyfinClientError.unauthorized
case .undocumented(let statusCode, _):
throw JellyfinClientError.unexpectedStatus(
operation: Operations.GetCurrentUser.id,
statusCode: statusCode
)
}
}
/// Signs in with a username and password and stores the resulting token on this client. /// Signs in with a username and password and stores the resulting token on this client.
/// ///
/// Every later call through this client is authenticated as the returned account, so a caller /// Every later call through this client is authenticated as the returned account, so a caller

View file

@ -0,0 +1,49 @@
//
// HTTPSDowngradeGuard.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
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Prevents an HTTPS connection probe from silently following a redirect to HTTP.
final class HTTPSDowngradeGuard: NSObject, URLSessionTaskDelegate, Sendable {
/// Decides whether a redirect preserves transport security.
///
/// - Parameters:
/// - session: The URL session handling the redirect.
/// - task: The task receiving the redirect.
/// - response: The response that requested the redirect.
/// - request: The proposed redirected request.
/// - completionHandler: Receives the request to follow, or `nil` to reject it.
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
let from = response.url?.scheme?.lowercased()
let to = request.url?.scheme?.lowercased()
completionHandler(from == "https" && to == "http" ? nil : request)
}
}

View file

@ -0,0 +1,99 @@
//
// ServerConnectionProbe.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 LuminateAPI
import LuminateCore
import OpenAPIRuntime
import OpenAPIURLSession
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Probes normalized server URLs and accepts only reachable Jellyfin identities.
package struct ServerConnectionProbe: Sendable {
/// Asks one candidate URL to identify itself.
package typealias Request = @Sendable (URL) async throws -> JellyfinServerInfo
private let request: Request
/// Creates a probe using the supplied request seam or the live HTTP implementation.
///
/// - Parameter request: The request closure used for each candidate URL.
package init(request: @escaping Request = ServerConnectionProbe.liveRequest) {
self.request = request
}
/// Tries the candidates in order and returns the first accepted Jellyfin connection.
///
/// - Parameter address: The normalized address whose candidates should be probed.
/// - Returns: The candidate URL and server identity that answered successfully.
/// - Throws: ``ServerConnectionError`` for unreachable, rejected, or non-Jellyfin servers;
/// cancellation is propagated as `CancellationError`.
package func connect(to address: ServerAddress) async throws -> ServerConnection {
for url in address.probeCandidates {
if Task.isCancelled {
throw CancellationError()
}
do {
let info = try await request(url)
guard isJellyfin(info) else {
throw ServerConnectionError.notJellyfin(url: url, productName: info.productName)
}
return ServerConnection(url: url, serverInfo: info)
} catch let error as ServerConnectionError {
throw error
} catch {
let underlying = (error as? ClientError)?.underlyingError ?? error
if underlying is URLError {
continue
}
throw ServerConnectionError.rejected(url: url, detail: String(describing: underlying))
}
}
throw ServerConnectionError.unreachable(attempted: address.probeCandidates)
}
/// Performs the public Jellyfin system-info request against one candidate URL.
///
/// - Parameter url: The normalized base URL to probe.
/// - Returns: The identifying information returned by Jellyfin.
/// - Throws: Transport, OpenAPI runtime, or mapped Jellyfin client errors from the request.
package static let liveRequest: Request = { url in
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 15
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: configuration, delegate: HTTPSDowngradeGuard(), delegateQueue: nil)
let client = JellyfinClient(
configuration: JellyfinClientConfiguration(serverURL: url),
makeTransport: { URLSessionTransport(configuration: .init(session: session)) }
)
return try await client.publicServerInfo()
}
}
private func isJellyfin(_ info: JellyfinServerInfo) -> Bool {
guard let productName = info.productName else {
return true
}
return productName.range(of: "jellyfin", options: .caseInsensitive) != nil
}

View file

@ -56,6 +56,13 @@ package protocol JellyfinAPI: Sendable {
/// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases. /// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases.
func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output
/// Fetches the account the current access token belongs to.
///
/// - Parameter input: The generated `GetCurrentUser` input.
/// - Returns: The generated `GetCurrentUser` output, including every documented HTTP status.
/// - Throws: A transport or decoding error; documented HTTP statuses arrive as output cases.
func getCurrentUser(_ input: Operations.GetCurrentUser.Input) async throws -> Operations.GetCurrentUser.Output
/// Reads the server information available without authentication. /// Reads the server information available without authentication.
/// ///
/// - Parameter input: The generated `GetPublicSystemInfo` input. /// - Parameter input: The generated `GetPublicSystemInfo` input.

View file

@ -25,6 +25,10 @@ import LuminateCore
import OpenAPIRuntime import OpenAPIRuntime
import OpenAPIURLSession import OpenAPIURLSession
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Luminate's Jellyfin service: one configured server, one client identity, one access token. /// Luminate's Jellyfin service: one configured server, one client identity, one access token.
/// ///
/// Construct it once, hand it to the view tree through the `\.client` environment slot, and call /// Construct it once, hand it to the view tree through the `\.client` environment slot, and call
@ -68,16 +72,21 @@ package actor JellyfinClient: JellyfinService {
/// ///
/// - Parameters: /// - Parameters:
/// - configuration: The server URL, client identity, and any persisted access token. /// - configuration: The server URL, client identity, and any persisted access token.
/// - transport: The HTTP transport; defaults to `URLSession`. /// - makeTransport: Builds the HTTP transport; called once at init and again on every
/// ``setServerURL(_:)``, ``setAccessToken(_:)``, or ``reconnect()`` call. Defaults to
/// ``defaultTransport``, which builds a fresh `URLSession` (never `URLSession.shared`, so
/// a rebuild never reuses another rebuild's connection pool) bounded to a 10 second
/// request timeout.
package init( package init(
configuration: JellyfinClientConfiguration, configuration: JellyfinClientConfiguration,
transport: any ClientTransport = URLSessionTransport() makeTransport: @escaping @Sendable () -> any ClientTransport = JellyfinClient.defaultTransport
) { ) {
let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)? = { configuration in let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)? = { configuration in
guard let serverURL = configuration.serverURL else { return nil } guard let serverURL = configuration.serverURL else { return nil }
return Client( return Client(
serverURL: serverURL, serverURL: serverURL,
transport: transport, configuration: .init(dateTranscoder: .iso8601WithFractionalSeconds),
transport: makeTransport(),
middlewares: [ middlewares: [
AuthenticationMiddleware( AuthenticationMiddleware(
clientName: configuration.clientName, clientName: configuration.clientName,
@ -94,6 +103,20 @@ package actor JellyfinClient: JellyfinService {
self.api = makeAPI(configuration) self.api = makeAPI(configuration)
} }
/// Builds the production HTTP transport: a freshly constructed `URLSession` whose requests
/// time out after 10 seconds.
///
/// `URLSessionConfiguration.default` times out a request only after 60 seconds, too long for
/// a caller that blocks synchronously on the result - most notably ``LaunchSession``'s
/// pre-window token probe, run from `Luminate.init()` before any window exists. A 10 second
/// bound keeps that worst case predictable without needing a second, probe-specific
/// transport.
package static let defaultTransport: @Sendable () -> any ClientTransport = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 10
return URLSessionTransport(configuration: .init(session: URLSession(configuration: configuration)))
}
/// Creates a client backed by a supplied API implementation, for tests. /// Creates a client backed by a supplied API implementation, for tests.
/// ///
/// The supplied implementation is reused across configuration changes, so a test can observe /// The supplied implementation is reused across configuration changes, so a test can observe
@ -144,6 +167,20 @@ package actor JellyfinClient: JellyfinService {
api = makeAPI(configuration) api = makeAPI(configuration)
} }
/// Rebuilds the underlying API client against a freshly constructed transport, without
/// changing the server URL or access token.
///
/// `setServerURL(_:)` and `setAccessToken(_:)` already rebuild the transport as a side effect
/// of reconfiguring; this exists for the case where neither changed but the previous
/// connection is known to be bad - most notably, `URLSession`'s connection pool caches a DNS
/// resolution failure for the life of the session on Linux, so a client that failed to
/// resolve its host while offline keeps failing the same way even after the network recovers,
/// until a fresh session replaces it. Call this before retrying a request that previously
/// failed at the transport level.
package func reconnect() {
api = makeAPI(configuration)
}
/// The generated client the operation extensions call. /// The generated client the operation extensions call.
/// ///
/// Actor-isolated, so every operation reads the client that matches the current configuration. /// Actor-isolated, so every operation reads the client that matches the current configuration.

View file

@ -72,6 +72,18 @@ extension Operations.GetPublicUsers.Output.Ok.Body {
} }
} }
extension Operations.GetCurrentUser.Output.Ok.Body {
/// The decoded body, whichever JSON profile the server negotiated.
var payload: Components.Schemas.UserDto {
switch self {
case .json(let value),
.applicationJsonProfile_Quot_camelcase_quot_(let value),
.applicationJsonProfile_Quot_pascalcase_quot_(let value):
value
}
}
}
extension Operations.AuthenticateUserByName.Output.Ok.Body { extension Operations.AuthenticateUserByName.Output.Ok.Body {
/// The decoded body, whichever JSON profile the server negotiated. /// The decoded body, whichever JSON profile the server negotiated.
var payload: Components.Schemas.AuthenticationResult { var payload: Components.Schemas.AuthenticationResult {

View file

@ -0,0 +1,32 @@
//
// Preferences+Session.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
extension Preferences {
/// Whether a usable session token is stored.
///
/// Reading this tracks the `.accessToken` slot, so any Portico `DependencyTracker` that
/// evaluates it - including the one `SceneHost` wraps around `App.body` - re-runs when the
/// token is written or cleared. An empty string counts as signed out, matching
/// `JellyfinClient.isAuthenticated`.
package var isAuthenticated: Bool { self[.accessToken]?.isEmpty == false }
}

View file

@ -86,4 +86,20 @@ import Testing
) )
#expect(path.path == "/tmp/luminate-test/luminate") #expect(path.path == "/tmp/luminate-test/luminate")
} }
@Test("The preferences database sits inside the data directory")
func preferencesDatabaseLocation() {
let dataPath = AppPaths.dataDirectory(
environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"],
home: URL(filePath: "/home/test"),
temporaryDirectory: URL(filePath: "/tmp")
)
let dbPath = AppPaths.preferencesDatabaseURL(
environment: [AppPaths.dataDirectoryOverrideVariable: "/override/luminate"],
home: URL(filePath: "/home/test"),
temporaryDirectory: URL(filePath: "/tmp")
)
#expect(dbPath == dataPath.appending(path: "preferences.sqlite", directoryHint: .notDirectory))
#expect(dbPath.path == "/override/luminate/preferences.sqlite")
}
} }

View file

@ -0,0 +1,133 @@
//
// JellyfinQuickConnectTests.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
/// Exercises ``JellyfinQuickConnect``'s initiate-then-poll stream against a scripted
/// ``JellyfinServiceMock``.
@Suite struct JellyfinQuickConnectTests {
@Test("Yields the code, then the secret once a poll reports authenticated")
func happyPath() async throws {
let polls = PolledResults([
.success(JellyfinQuickConnectState(authenticated: false)),
.success(JellyfinQuickConnectState(authenticated: true, secret: "top-secret")),
])
let service = JellyfinServiceMock(
initiateQuickConnect: { JellyfinQuickConnectState(secret: "top-secret", code: "123456") },
quickConnectState: { _ in try await polls.next() }
)
var events: [JellyfinQuickConnect.Event] = []
for try await event in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {
events.append(event)
}
#expect(events == [.polling(code: "123456"), .authenticated(secret: "top-secret")])
#expect(await polls.callCount == 2)
}
@Test("A server omitting the secret or code fails without polling")
func missingPayloadThrows() async {
let service = JellyfinServiceMock(
initiateQuickConnect: { JellyfinQuickConnectState(secret: nil, code: "123456") }
)
await #expect(throws: JellyfinClientError.missingPayload(operation: "InitiateQuickConnect")) {
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {}
}
}
@Test("The server expiring the request propagates from a poll")
func serverExpiryPropagates() async {
let polls = PolledResults([
.success(JellyfinQuickConnectState(authenticated: false)),
.failure(JellyfinClientError.notFound),
])
let service = JellyfinServiceMock(
initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") },
quickConnectState: { _ in try await polls.next() }
)
await #expect(throws: JellyfinClientError.notFound) {
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {}
}
}
@Test("Exhausting maxPolls without an answer gives up")
func maxPollsExhaustedThrows() async {
let polls = PolledResults(Array(repeating: .success(JellyfinQuickConnectState(authenticated: false)), count: 3))
let service = JellyfinServiceMock(
initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") },
quickConnectState: { _ in try await polls.next() }
)
await #expect(throws: JellyfinClientError.quickConnectTimedOut) {
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 3) {}
}
#expect(await polls.callCount == 3)
}
@Test("Cancelling the consuming task stops polling instead of leaking it")
func cancellationStopsPolling() async throws {
let polls = PolledResults(
Array(repeating: .success(JellyfinQuickConnectState(authenticated: false)), count: 1000)
)
let service = JellyfinServiceMock(
initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") },
quickConnectState: { _ in try await polls.next() }
)
let task = Task {
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 1000) {}
}
try await Task.sleep(for: .milliseconds(30))
task.cancel()
try await Task.sleep(for: .milliseconds(20))
let countAtCancel = await polls.callCount
try await Task.sleep(for: .milliseconds(60))
let countLater = await polls.callCount
#expect(countLater == countAtCancel)
}
}
/// Hands back queued Quick Connect poll results in call order and counts how many were consumed.
///
/// A small actor rather than a plain array captured in a closure because ``JellyfinQuickConnect``
/// polls from a task the stream owns internally, so consumption genuinely races the test.
private actor PolledResults {
private var queue: [Result<JellyfinQuickConnectState, Error>]
private(set) var callCount = 0
init(_ queue: [Result<JellyfinQuickConnectState, Error>]) {
self.queue = queue
}
func next() throws -> JellyfinQuickConnectState {
callCount += 1
guard !queue.isEmpty else { throw JellyfinClientError.notFound }
return try queue.removeFirst().get()
}
}

View file

@ -0,0 +1,256 @@
//
// JellyfinServiceConvenienceTests.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 Synchronization
import Testing
@testable import LuminateCore
/// Pins the default arguments the reduced-arity overloads in `JellyfinService+Convenience.swift`
/// forward to their full-arity requirements. A protocol requirement cannot declare default
/// arguments, so a wrong default here (e.g. forwarding `userID: ""` instead of `nil`) would be
/// silent without these tests.
@Suite struct JellyfinServiceConvenienceTests {
@Test("libraries() forwards an empty options set")
func librariesDefaults() async throws {
let recorded = Mutex<JellyfinListOptions?>(nil)
let mock = JellyfinServiceMock(libraries: { options in
recorded.withLock { $0 = options }
return []
})
_ = try await mock.libraries()
#expect(recorded.withLock { $0 } == JellyfinListOptions())
}
@Test("items() forwards an empty media query")
func itemsDefaults() async throws {
let recorded = Mutex<JellyfinMediaQuery?>(nil)
let mock = JellyfinServiceMock(items: { query in
recorded.withLock { $0 = query }
return JellyfinMediaPage()
})
_ = try await mock.items()
#expect(recorded.withLock { $0 } == JellyfinMediaQuery())
}
@Test("item(id:) forwards a nil userID")
func itemDefaultsUserID() async throws {
let recorded = Mutex<(id: String, userID: String?)?>(nil)
let mock = JellyfinServiceMock(item: { id, userID in
recorded.withLock { $0 = (id, userID) }
return JellyfinMediaItem(id: id)
})
_ = try await mock.item(id: "item-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.id == "item-1")
#expect(captured.userID == nil)
}
@Test("resumeItems() forwards an empty options set")
func resumeItemsDefaults() async throws {
let recorded = Mutex<JellyfinListOptions?>(nil)
let mock = JellyfinServiceMock(resumeItems: { options in
recorded.withLock { $0 = options }
return JellyfinMediaPage()
})
_ = try await mock.resumeItems()
#expect(recorded.withLock { $0 } == JellyfinListOptions())
}
@Test("nextUp() forwards a nil series and empty options")
func nextUpDefaults() async throws {
let recorded = Mutex<(seriesID: String?, options: JellyfinListOptions)?>(nil)
let mock = JellyfinServiceMock(nextUp: { seriesID, options in
recorded.withLock { $0 = (seriesID, options) }
return JellyfinMediaPage()
})
_ = try await mock.nextUp()
let captured = try #require(recorded.withLock { $0 })
#expect(captured.seriesID == nil)
#expect(captured.options == JellyfinListOptions())
}
@Test("nextUp(options:) passes options through unchanged")
func nextUpOptionsPassthrough() async throws {
let recorded = Mutex<(seriesID: String?, options: JellyfinListOptions)?>(nil)
let mock = JellyfinServiceMock(nextUp: { seriesID, options in
recorded.withLock { $0 = (seriesID, options) }
return JellyfinMediaPage()
})
let nonDefault = JellyfinListOptions(limit: 5)
_ = try await mock.nextUp(options: nonDefault)
let captured = try #require(recorded.withLock { $0 })
#expect(captured.seriesID == nil)
#expect(captured.options == nonDefault)
}
@Test("latestMedia() forwards an empty options set")
func latestMediaDefaults() async throws {
let recorded = Mutex<JellyfinListOptions?>(nil)
let mock = JellyfinServiceMock(latestMedia: { options in
recorded.withLock { $0 = options }
return []
})
_ = try await mock.latestMedia()
#expect(recorded.withLock { $0 } == JellyfinListOptions())
}
@Test("seasons(seriesID:) forwards an empty options set")
func seasonsDefaults() async throws {
let recorded = Mutex<(seriesID: String, options: JellyfinListOptions)?>(nil)
let mock = JellyfinServiceMock(seasons: { seriesID, options in
recorded.withLock { $0 = (seriesID, options) }
return JellyfinMediaPage()
})
_ = try await mock.seasons(seriesID: "series-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.seriesID == "series-1")
#expect(captured.options == JellyfinListOptions())
}
@Test("episodes(seriesID:) forwards an empty options set")
func episodesDefaults() async throws {
let recorded = Mutex<(seriesID: String, options: JellyfinListOptions)?>(nil)
let mock = JellyfinServiceMock(episodes: { seriesID, options in
recorded.withLock { $0 = (seriesID, options) }
return JellyfinMediaPage()
})
_ = try await mock.episodes(seriesID: "series-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.seriesID == "series-1")
#expect(captured.options == JellyfinListOptions())
}
@Test("searchHints(term:) forwards an empty options set")
func searchHintsDefaults() async throws {
let recorded = Mutex<(term: String, options: JellyfinListOptions)?>(nil)
let mock = JellyfinServiceMock(searchHints: { term, options in
recorded.withLock { $0 = (term, options) }
return []
})
_ = try await mock.searchHints(term: "batman")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.term == "batman")
#expect(captured.options == JellyfinListOptions())
}
@Test("image(itemID:type:) forwards a nil index and default request")
func imageDefaults() async throws {
let recorded = Mutex<(index: Int32?, request: JellyfinImageRequest)?>(nil)
let mock = JellyfinServiceMock(image: { _, _, index, request in
recorded.withLock { $0 = (index, request) }
return Data()
})
_ = try await mock.image(itemID: "item-1", type: .primary)
let captured = try #require(recorded.withLock { $0 })
#expect(captured.index == nil)
#expect(captured.request == JellyfinImageRequest())
}
@Test("image(itemID:type:request:) passes the request through unchanged")
func imageRequestPassthrough() async throws {
let recorded = Mutex<(index: Int32?, request: JellyfinImageRequest)?>(nil)
let mock = JellyfinServiceMock(image: { _, _, index, request in
recorded.withLock { $0 = (index, request) }
return Data()
})
let nonDefault = JellyfinImageRequest(tag: "abc", fillWidth: 300, fillHeight: 450)
_ = try await mock.image(itemID: "item-1", type: .primary, request: nonDefault)
let captured = try #require(recorded.withLock { $0 })
#expect(captured.index == nil)
#expect(captured.request == nonDefault)
}
@Test("markPlayed(itemID:) forwards nil userID and datePlayed")
func markPlayedDefaults() async throws {
let recorded = Mutex<(itemID: String, userID: String?, datePlayed: Date?)?>(nil)
let mock = JellyfinServiceMock(markPlayed: { itemID, userID, datePlayed in
recorded.withLock { $0 = (itemID, userID, datePlayed) }
return JellyfinUserData(itemID: itemID)
})
_ = try await mock.markPlayed(itemID: "item-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.itemID == "item-1")
#expect(captured.userID == nil)
#expect(captured.datePlayed == nil)
}
@Test("markUnplayed(itemID:) forwards a nil userID")
func markUnplayedDefaults() async throws {
let recorded = Mutex<(itemID: String, userID: String?)?>(nil)
let mock = JellyfinServiceMock(markUnplayed: { itemID, userID in
recorded.withLock { $0 = (itemID, userID) }
return JellyfinUserData(itemID: itemID)
})
_ = try await mock.markUnplayed(itemID: "item-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.itemID == "item-1")
#expect(captured.userID == nil)
}
@Test("markFavorite(itemID:) forwards a nil userID")
func markFavoriteDefaults() async throws {
let recorded = Mutex<(itemID: String, userID: String?)?>(nil)
let mock = JellyfinServiceMock(markFavorite: { itemID, userID in
recorded.withLock { $0 = (itemID, userID) }
return JellyfinUserData(itemID: itemID)
})
_ = try await mock.markFavorite(itemID: "item-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.itemID == "item-1")
#expect(captured.userID == nil)
}
@Test("unmarkFavorite(itemID:) forwards a nil userID")
func unmarkFavoriteDefaults() async throws {
let recorded = Mutex<(itemID: String, userID: String?)?>(nil)
let mock = JellyfinServiceMock(unmarkFavorite: { itemID, userID in
recorded.withLock { $0 = (itemID, userID) }
return JellyfinUserData(itemID: itemID)
})
_ = try await mock.unmarkFavorite(itemID: "item-1")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.itemID == "item-1")
#expect(captured.userID == nil)
}
@Test("updateUserPassword(currentPassword:newPassword:) forwards nil userID, currentPIN, and resetPassword")
func updateUserPasswordDefaults() async throws {
let recorded = Mutex<
(
userID: String?, currentPassword: String?, currentPIN: String?, newPassword: String?,
resetPassword: Bool?
)?
>(nil)
let mock = JellyfinServiceMock(
updateUserPassword: { userID, currentPassword, currentPIN, newPassword, resetPassword in
recorded.withLock { $0 = (userID, currentPassword, currentPIN, newPassword, resetPassword) }
})
try await mock.updateUserPassword(currentPassword: "old-password", newPassword: "new-password")
let captured = try #require(recorded.withLock { $0 })
#expect(captured.userID == nil)
#expect(captured.currentPassword == "old-password")
#expect(captured.currentPIN == nil)
#expect(captured.newPassword == "new-password")
#expect(captured.resetPassword == nil)
}
}

View file

@ -0,0 +1,241 @@
//
// JellyfinServiceMock.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
/// A ``JellyfinService`` double that answers each requirement with a test-supplied handler.
///
/// Mirrors `MockJellyfinAPI` in `LuminateServicesTests`: every requirement is implemented exactly
/// once here, so a test configures only the handlers it needs through the initializer and every
/// other call fails the test with a clear message rather than a fabricated success. Handlers are
/// `let`, so the whole instance is provably `Sendable` with no isolation escape hatches -- a
/// handler that must vary its answer across calls closes over its own actor or queue instead of
/// mutating this type.
final class JellyfinServiceMock: JellyfinService, Sendable {
private let isAuthenticatedValue: Bool
private let publicServerInfoHandler: (@Sendable () async throws -> JellyfinServerInfo)?
private let serverInfoHandler: (@Sendable () async throws -> JellyfinServerInfo)?
private let publicUsersHandler: (@Sendable () async throws -> [JellyfinUser])?
private let currentUserHandler: (@Sendable () async throws -> JellyfinUser)?
private let authenticateHandler: (@Sendable (String, String) async throws -> JellyfinAuthentication)?
private let quickConnectEnabledHandler: (@Sendable () async throws -> Bool)?
private let initiateQuickConnectHandler: (@Sendable () async throws -> JellyfinQuickConnectState)?
private let quickConnectStateHandler: (@Sendable (String) async throws -> JellyfinQuickConnectState)?
private let authenticateWithQuickConnectHandler: (@Sendable (String) async throws -> JellyfinAuthentication)?
private let updateUserPasswordHandler: (@Sendable (String?, String?, String?, String?, Bool?) async throws -> Void)?
private let librariesHandler: (@Sendable (JellyfinListOptions) async throws -> [JellyfinLibrary])?
private let itemsHandler: (@Sendable (JellyfinMediaQuery) async throws -> JellyfinMediaPage)?
private let itemHandler: (@Sendable (String, String?) async throws -> JellyfinMediaItem)?
private let resumeItemsHandler: (@Sendable (JellyfinListOptions) async throws -> JellyfinMediaPage)?
private let nextUpHandler: (@Sendable (String?, JellyfinListOptions) async throws -> JellyfinMediaPage)?
private let latestMediaHandler: (@Sendable (JellyfinListOptions) async throws -> [JellyfinMediaItem])?
private let seasonsHandler: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)?
private let episodesHandler: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)?
private let searchHintsHandler: (@Sendable (String, JellyfinListOptions) async throws -> [JellyfinSearchHint])?
private let imageHandler:
(
@Sendable (String, JellyfinImageType, Int32?, JellyfinImageRequest) async throws -> Data
)?
private let markPlayedHandler: (@Sendable (String, String?, Date?) async throws -> JellyfinUserData)?
private let markUnplayedHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)?
private let markFavoriteHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)?
private let unmarkFavoriteHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)?
/// Creates a mock; every parameter defaults to `nil`, so a call the test never stubbed fails
/// with a clear message instead of returning a fabricated value.
init(
isAuthenticated: Bool = false,
publicServerInfo: (@Sendable () async throws -> JellyfinServerInfo)? = nil,
serverInfo: (@Sendable () async throws -> JellyfinServerInfo)? = nil,
publicUsers: (@Sendable () async throws -> [JellyfinUser])? = nil,
currentUser: (@Sendable () async throws -> JellyfinUser)? = nil,
authenticate: (@Sendable (String, String) async throws -> JellyfinAuthentication)? = nil,
quickConnectEnabled: (@Sendable () async throws -> Bool)? = nil,
initiateQuickConnect: (@Sendable () async throws -> JellyfinQuickConnectState)? = nil,
quickConnectState: (@Sendable (String) async throws -> JellyfinQuickConnectState)? = nil,
authenticateWithQuickConnect: (@Sendable (String) async throws -> JellyfinAuthentication)? = nil,
updateUserPassword: (@Sendable (String?, String?, String?, String?, Bool?) async throws -> Void)? = nil,
libraries: (@Sendable (JellyfinListOptions) async throws -> [JellyfinLibrary])? = nil,
items: (@Sendable (JellyfinMediaQuery) async throws -> JellyfinMediaPage)? = nil,
item: (@Sendable (String, String?) async throws -> JellyfinMediaItem)? = nil,
resumeItems: (@Sendable (JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
nextUp: (@Sendable (String?, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
latestMedia: (@Sendable (JellyfinListOptions) async throws -> [JellyfinMediaItem])? = nil,
seasons: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
episodes: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
searchHints: (@Sendable (String, JellyfinListOptions) async throws -> [JellyfinSearchHint])? = nil,
image: (@Sendable (String, JellyfinImageType, Int32?, JellyfinImageRequest) async throws -> Data)? = nil,
markPlayed: (@Sendable (String, String?, Date?) async throws -> JellyfinUserData)? = nil,
markUnplayed: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil,
markFavorite: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil,
unmarkFavorite: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil
) {
self.isAuthenticatedValue = isAuthenticated
self.publicServerInfoHandler = publicServerInfo
self.serverInfoHandler = serverInfo
self.publicUsersHandler = publicUsers
self.currentUserHandler = currentUser
self.authenticateHandler = authenticate
self.quickConnectEnabledHandler = quickConnectEnabled
self.initiateQuickConnectHandler = initiateQuickConnect
self.quickConnectStateHandler = quickConnectState
self.authenticateWithQuickConnectHandler = authenticateWithQuickConnect
self.updateUserPasswordHandler = updateUserPassword
self.librariesHandler = libraries
self.itemsHandler = items
self.itemHandler = item
self.resumeItemsHandler = resumeItems
self.nextUpHandler = nextUp
self.latestMediaHandler = latestMedia
self.seasonsHandler = seasons
self.episodesHandler = episodes
self.searchHintsHandler = searchHints
self.imageHandler = image
self.markPlayedHandler = markPlayed
self.markUnplayedHandler = markUnplayed
self.markFavoriteHandler = markFavorite
self.unmarkFavoriteHandler = unmarkFavorite
}
var isAuthenticated: Bool { isAuthenticatedValue }
/// Returns a stubbed handler, or fails the test naming the unstubbed operation.
private func unwrap<Handler>(_ handler: Handler?, _ operation: String) throws -> Handler {
try #require(handler, "JellyfinServiceMock received an unstubbed call to \(operation)")
}
func publicServerInfo() async throws -> JellyfinServerInfo {
try await unwrap(publicServerInfoHandler, "publicServerInfo")()
}
func serverInfo() async throws -> JellyfinServerInfo {
try await unwrap(serverInfoHandler, "serverInfo")()
}
func publicUsers() async throws -> [JellyfinUser] {
try await unwrap(publicUsersHandler, "publicUsers")()
}
func currentUser() async throws -> JellyfinUser {
try await unwrap(currentUserHandler, "currentUser")()
}
func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
try await unwrap(authenticateHandler, "authenticate")(username, password)
}
func quickConnectEnabled() async throws -> Bool {
try await unwrap(quickConnectEnabledHandler, "quickConnectEnabled")()
}
func initiateQuickConnect() async throws -> JellyfinQuickConnectState {
try await unwrap(initiateQuickConnectHandler, "initiateQuickConnect")()
}
func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState {
try await unwrap(quickConnectStateHandler, "quickConnectState")(secret)
}
func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication {
try await unwrap(authenticateWithQuickConnectHandler, "authenticateWithQuickConnect")(secret)
}
func updateUserPassword(
userID: String?,
currentPassword: String?,
currentPIN: String?,
newPassword: String?,
resetPassword: Bool?
) async throws {
try await unwrap(updateUserPasswordHandler, "updateUserPassword")(
userID,
currentPassword,
currentPIN,
newPassword,
resetPassword
)
}
func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] {
try await unwrap(librariesHandler, "libraries")(options)
}
func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage {
try await unwrap(itemsHandler, "items")(query)
}
func item(id: String, userID: String?) async throws -> JellyfinMediaItem {
try await unwrap(itemHandler, "item")(id, userID)
}
func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage {
try await unwrap(resumeItemsHandler, "resumeItems")(options)
}
func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage {
try await unwrap(nextUpHandler, "nextUp")(seriesID, options)
}
func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] {
try await unwrap(latestMediaHandler, "latestMedia")(options)
}
func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage {
try await unwrap(seasonsHandler, "seasons")(seriesID, options)
}
func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage {
try await unwrap(episodesHandler, "episodes")(seriesID, options)
}
func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] {
try await unwrap(searchHintsHandler, "searchHints")(term, options)
}
func image(
itemID: String,
type: JellyfinImageType,
index: Int32?,
request: JellyfinImageRequest
) async throws -> Data {
try await unwrap(imageHandler, "image")(itemID, type, index, request)
}
func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData {
try await unwrap(markPlayedHandler, "markPlayed")(itemID, userID, datePlayed)
}
func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData {
try await unwrap(markUnplayedHandler, "markUnplayed")(itemID, userID)
}
func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData {
try await unwrap(markFavoriteHandler, "markFavorite")(itemID, userID)
}
func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData {
try await unwrap(unmarkFavoriteHandler, "unmarkFavorite")(itemID, userID)
}
}

View file

@ -19,6 +19,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// //
import Foundation
import Testing import Testing
@testable import LuminateCore @testable import LuminateCore
@ -42,4 +43,46 @@ import Testing
#expect(Double.nan.preferenceValue == .real(0)) #expect(Double.nan.preferenceValue == .real(0))
#expect(Double.infinity.preferenceValue == .real(0)) #expect(Double.infinity.preferenceValue == .real(0))
} }
@Test("Boolean encoding maps to zero and one")
func booleanEncoding() {
#expect(true.preferenceValue == .integer(1))
#expect(false.preferenceValue == .integer(0))
}
@Test("Integer decoding and encoding round trip")
func integerRoundTrip() {
#expect(Int(preference: .integer(7)) == 7)
#expect(Int(preference: .text("7")) == nil)
#expect(7.preferenceValue == .integer(7))
}
@Test("UUID round trips through text storage")
func uuidRoundTrip() {
let uuid = UUID()
#expect(uuid.preferenceValue == .text(uuid.uuidString))
#expect(UUID(preference: .text(uuid.uuidString)) == uuid)
#expect(UUID(preference: .text("not-a-uuid")) == nil)
#expect(UUID(preference: .integer(1)) == nil)
}
@Test("Data round trips through blob storage, including empty data")
func dataRoundTrip() {
#expect(Data([1, 2, 3]).preferenceValue == .blob(Data([1, 2, 3])))
#expect(Data(preference: .blob(Data([1, 2, 3]))) == Data([1, 2, 3]))
#expect(Data().preferenceValue == .blob(Data()))
#expect(Data(preference: .blob(Data())) == Data())
#expect(Data(preference: .text("x")) == nil)
}
@Test("A RawRepresentable conformance encodes and decodes through its raw value")
func rawRepresentableConformance() {
#expect(Theme.dark.preferenceValue == .text("dark"))
#expect(Theme(preference: .text("light")) == .light)
#expect(Theme(preference: .text("plaid")) == nil)
}
}
private enum Theme: String, PreferenceValue {
case light, dark
} }

View file

@ -0,0 +1,176 @@
//
// ServerAddressTests.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
/// Covers normalization, validation, and candidate selection for onboarding server addresses.
@Suite struct ServerAddressTests {
@Test("Normalizes a bare host and permits HTTPS fallback")
func bareHost() throws {
let address = try ServerAddress.normalize(address: " jellyfin.example.com ", port: "", policy: .automatic)
#expect(address.baseURL.absoluteString == "https://jellyfin.example.com/")
#expect(
address.probeCandidates.map(\.absoluteString) == [
"https://jellyfin.example.com/",
"http://jellyfin.example.com/",
])
}
@Test("Preserves a custom port on a bare host")
func customPort() throws {
let address = try ServerAddress.normalize(address: "jellyfin.example.com", port: "8096", policy: .automatic)
#expect(address.baseURL.absoluteString == "https://jellyfin.example.com:8096/")
}
@Test("Honors an explicit HTTP scheme without fallback")
func explicitHTTP() throws {
let address = try ServerAddress.normalize(
address: "http://192.168.1.20:8096",
port: "",
policy: .automatic
)
#expect(address.baseURL.absoluteString == "http://192.168.1.20:8096/")
#expect(address.probeCandidates.map(\.absoluteString) == ["http://192.168.1.20:8096/"])
}
@Test("Force HTTPS preserves an explicit nonstandard port")
func forceHTTPSPreservesPort() throws {
let address = try ServerAddress.normalize(
address: "http://192.168.1.20:8096",
port: "",
policy: .httpsOnly
)
#expect(address.baseURL.absoluteString == "https://192.168.1.20:8096/")
#expect(address.probeCandidates.map(\.absoluteString) == ["https://192.168.1.20:8096/"])
}
@Test("Lowercases an explicit HTTPS scheme without changing its host")
func lowercasesScheme() throws {
let address = try ServerAddress.normalize(address: "HTTPS://Host.Example", port: "", policy: .automatic)
#expect(address.baseURL.absoluteString == "https://Host.Example/")
}
@Test("Preserves a reverse proxy path prefix")
func preservesPathPrefix() throws {
let address = try ServerAddress.normalize(
address: "https://example.com/jellyfin",
port: "",
policy: .automatic
)
#expect(address.baseURL.absoluteString == "https://example.com/jellyfin/")
}
@Test("Strips a redundant HTTPS default port")
func stripsDefaultPort() throws {
let address = try ServerAddress.normalize(address: "https://example.com:443", port: "", policy: .automatic)
#expect(address.baseURL.absoluteString == "https://example.com/")
}
@Test("Preserves IPv6 literal brackets")
func preservesIPv6() throws {
let address = try ServerAddress.normalize(
address: "http://[2001:db8::10]:8096",
port: "",
policy: .automatic
)
#expect(address.baseURL.absoluteString == "http://[2001:db8::10]:8096/")
}
@Test("Force HTTPS disables fallback for a bare host")
func forceHTTPSDisablesFallback() throws {
let address = try ServerAddress.normalize(address: "jellyfin.example.com", port: "", policy: .httpsOnly)
#expect(address.probeCandidates.map(\.absoluteString) == ["https://jellyfin.example.com/"])
}
@Test("Rejects an empty address")
func emptyAddress() {
#expect(throws: ServerAddressError.emptyAddress) {
_ = try ServerAddress.normalize(address: "", port: "", policy: .automatic)
}
}
@Test("Rejects an unsupported scheme")
func unsupportedScheme() {
#expect(throws: ServerAddressError.unsupportedScheme("ftp")) {
_ = try ServerAddress.normalize(address: "ftp://host", port: "", policy: .automatic)
}
}
@Test("Rejects conflicting port fields")
func conflictingPorts() {
#expect(throws: ServerAddressError.conflictingPorts) {
_ = try ServerAddress.normalize(address: "https://host:8920", port: "8096", policy: .automatic)
}
}
@Test("Rejects ports outside the TCP range")
func invalidPortRange() {
#expect(throws: ServerAddressError.invalidPort("70000")) {
_ = try ServerAddress.normalize(address: "host", port: "70000", policy: .automatic)
}
#expect(throws: ServerAddressError.invalidPort("0")) {
_ = try ServerAddress.normalize(address: "host", port: "0", policy: .automatic)
}
}
@Test("Rejects nonnumeric port text")
func invalidPortText() {
#expect(throws: ServerAddressError.invalidPort("80abc")) {
_ = try ServerAddress.normalize(address: "host", port: "80abc", policy: .automatic)
}
}
@Test("Rejects credentials in the server address")
func rejectsCredentials() {
#expect(throws: ServerAddressError.credentialsNotAllowed) {
_ = try ServerAddress.normalize(address: "https://user:pw@host", port: "", policy: .automatic)
}
}
@Test("Rejects query strings and fragments")
func rejectsQueryAndFragment() {
#expect(throws: ServerAddressError.queryOrFragmentNotAllowed) {
_ = try ServerAddress.normalize(address: "https://host/?token=x", port: "", policy: .automatic)
}
#expect(throws: ServerAddressError.queryOrFragmentNotAllowed) {
_ = try ServerAddress.normalize(address: "https://host/#login", port: "", policy: .automatic)
}
}
@Test("Rejects an address with no host")
func missingHost() {
#expect(throws: ServerAddressError.missingHost) {
_ = try ServerAddress.normalize(address: "https://", port: "", policy: .automatic)
}
}
@Test("Explains invalid address input")
func errorMessages() {
#expect(ServerAddressError.emptyAddress.message == "Enter a server address")
#expect(ServerAddressError.unsupportedScheme("ftp").message == "ftp is not supported. Use http or https")
#expect(
ServerAddressError.invalidPort("70000").message
== "70000 is not a valid port. Enter a number from 1 to 65535")
}
}

View file

@ -0,0 +1,58 @@
//
// ServerConnectionErrorTests.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
/// Covers the human-readable messages for each `ServerConnectionError` case.
@Suite struct ServerConnectionErrorTests {
@Test("Unreachable joins every attempted URL with 'or'")
func unreachableJoinsAttemptedURLs() {
let a = URL(string: "http://a.test")!
let b = URL(string: "http://b.test")!
#expect(
ServerConnectionError.unreachable(attempted: [a, b]).message
== "Could not reach a server at http://a.test or http://b.test.")
}
@Test("Rejected includes the URL and the detail text")
func rejectedIncludesURLAndDetail() {
#expect(
ServerConnectionError.rejected(url: URL(string: "http://host")!, detail: "not JSON").message
== "http://host answered, but not as a Jellyfin server: not JSON")
}
@Test("A missing product name renders as an unknown product")
func missingProductNameRendersUnknown() {
#expect(
ServerConnectionError.notJellyfin(url: URL(string: "http://host")!, productName: nil).message
== "http://host is running an unknown product, not Jellyfin.")
}
@Test("A named product renders its own name")
func namedProductRendersOwnName() {
#expect(
ServerConnectionError.notJellyfin(url: URL(string: "http://host")!, productName: "Emby").message
== "http://host is running Emby, not Jellyfin.")
}
}

View file

@ -0,0 +1,46 @@
//
// ServerConnectionSummaryTests.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
/// Verifies the onboarding success copy for encrypted and plaintext connections.
@Suite struct ServerConnectionSummaryTests {
@Test("Reports an HTTPS connection by server name")
func reportsHTTPS() {
let connection = ServerConnection(
url: URL(string: "https://jellyfin.test/")!,
serverInfo: JellyfinServerInfo(serverName: "Basement")
)
#expect(connection.summary == "Connected to Basement over HTTPS.")
}
@Test("Warns that an HTTP connection is unencrypted")
func warnsForHTTP() {
let connection = ServerConnection(
url: URL(string: "http://jellyfin.test/")!,
serverInfo: JellyfinServerInfo()
)
#expect(connection.summary == "Connected to the server over HTTP - this connection is not encrypted.")
}
}

View file

@ -54,4 +54,66 @@ import Testing
_ = try await service.libraries() _ = try await service.libraries()
} }
} }
@Test("Every remaining operation on the unconfigured service throws notConfigured")
func everyOperationThrows() async {
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.publicServerInfo()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.serverInfo()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.authenticate(username: "u", password: "p")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.quickConnectEnabled()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.initiateQuickConnect()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.quickConnectState(secret: "s")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.authenticateWithQuickConnect(secret: "s")
}
await #expect(throws: JellyfinClientError.notConfigured) {
try await service.updateUserPassword(
userID: nil, currentPassword: "a", currentPIN: nil, newPassword: "b", resetPassword: nil)
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.item(id: "i")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.resumeItems()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.nextUp()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.latestMedia()
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.seasons(seriesID: "s")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.episodes(seriesID: "s")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.searchHints(term: "t")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.markPlayed(itemID: "i")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.markUnplayed(itemID: "i")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.markFavorite(itemID: "i")
}
await #expect(throws: JellyfinClientError.notConfigured) {
_ = try await service.unmarkFavorite(itemID: "i")
}
}
} }

View file

@ -0,0 +1,80 @@
//
// OnboardPathEnvironmentKeyTests.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
//
@_spi(SGTKInternal) import Gtk
import LuminateCore
@_spi(Portico) import Portico
import Testing
@testable import LuminateOnboarding
/// Captures the binding a mounted page resolves, so a test can push through it after mount.
@MainActor private final class PathCapture {
var path: Portico.Binding<[OnboardDestination]>?
}
/// Stands in for an onboarding page that pushes through the injected path.
private struct PathProbe: View {
@Environment(\.onboardPath) private var onboardPath
let capture: PathCapture
var body: some View {
capture.path = onboardPath
return Label(str: "probe")
}
}
/// Covers `OnboardPathEnvironmentKey`/`EnvironmentValues.onboardPath`: the default-empty binding,
/// writes routing through the environment subscript, and a mounted page resolving an injected
/// binding after mount.
@Suite(.serialized) @MainActor struct OnboardPathEnvironmentKeyTests {
@Test("An unresolved slot reads an empty path")
func defaultIsEmpty() {
#expect(EnvironmentValues().onboardPath.wrappedValue.isEmpty)
}
@Test("The key path routes through the environment subscript")
func keyPathResolvesToABox() {
#expect(EnvironmentValues()._box(for: \.onboardPath) != nil)
}
@Test("A push through the injected slot reaches the owning state")
func injectedBindingWritesThrough() {
@State var path: [OnboardDestination] = []
var values = EnvironmentValues()
values.onboardPath = $path
values.onboardPath.wrappedValue.append(.login)
#expect(path == [.login])
}
@Test("A page mounted under the injection pushes onto the window's path")
func mountedPageResolvesInjectedBinding() {
guard Gtk.initCheck() else { return }
@State var path: [OnboardDestination] = []
let capture = PathCapture()
let context = MountContext()
_ = AnyView(PathProbe(capture: capture).environment(\.onboardPath, $path)).makeWidget(context)
defer { context.registry.teardown() }
capture.path?.wrappedValue.append(.manualLogin)
#expect(path == [.manualLogin])
}
}

View file

@ -0,0 +1,51 @@
//
// OnboardWindowTests.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 Adw
@_spi(SGTKInternal) import Gtk
import LuminateCore
@_spi(Portico) import Portico
import Testing
@testable import LuminateOnboarding
@testable import LuminateUI
/// Verifies the onboarding target exposes its root view.
@Suite(.serialized) @MainActor
struct OnboardWindowTests {
@Test("Creates the onboarding root view")
func createsRootView() {
_ = OnboardWindow()
}
@Test("Starting the stack at server setup mounts the pageless setup destination")
func mountsSetupDestination() {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
let nav =
AnyView(OnboardWindow(startingAt: [.setup]).environment(\.preferences, preferences))
.makeWidget(context) as! Adw.NavigationView
defer { context.registry.teardown() }
#expect(nav.getVisiblePage()?.getTitle() == "Server Setup")
}
}

View file

@ -0,0 +1,45 @@
//
// SetupPageTests.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
//
@_spi(SGTKInternal) import Gtk
import LuminateCore
@_spi(Portico) import Portico
import Testing
@testable import LuminateOnboarding
@testable import LuminateUI
/// Verifies that the setup page mounts with its live button modifiers.
@Suite(.serialized) @MainActor struct SetupPageTests {
@Test("Mounts the setup page")
func mounts() {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
@State var onboardPath: [OnboardDestination] = []
_ = AnyView(
SetupPage()
.environment(\.preferences, preferences)
.environment(\.onboardPath, $onboardPath)
).makeWidget(context)
context.registry.teardown()
}
}

View file

@ -0,0 +1,81 @@
//
// HTTPSDowngradeGuardTests.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 LuminateServices
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Covers the redirect policy that stops an HTTPS probe from being silently downgraded.
@Suite struct HTTPSDowngradeGuardTests {
/// Invokes the guard synchronously and captures whatever request it decides to follow.
///
/// - Parameters:
/// - responseURL: The URL of the response that requested the redirect.
/// - requestURL: The URL of the proposed redirected request.
/// - Returns: The request the guard passed to its completion handler, or `nil` if rejected.
private func redirect(from responseURL: URL, to requestURL: URL) -> URLRequest? {
let guardian = HTTPSDowngradeGuard()
let response = HTTPURLResponse(
url: responseURL, statusCode: 302, httpVersion: nil, headerFields: nil)!
let request = URLRequest(url: requestURL)
let task = URLSession.shared.dataTask(with: URLRequest(url: responseURL))
var captured: URLRequest?!
guardian.urlSession(
.shared, task: task, willPerformHTTPRedirection: response, newRequest: request
) { result in
captured = result
}
return captured
}
@Test("An HTTPS to HTTP redirect is rejected")
func httpsToHTTPRejected() {
let result = redirect(
from: URL(string: "https://host/a")!, to: URL(string: "http://host/b")!)
#expect(result == nil)
}
@Test("An HTTPS to HTTPS redirect passes through unchanged")
func httpsToHTTPSPassesThrough() {
let requestURL = URL(string: "https://host/b")!
let result = redirect(from: URL(string: "https://host/a")!, to: requestURL)
#expect(result?.url == requestURL)
}
@Test("An HTTP to HTTP redirect passes through")
func httpToHTTPPassesThrough() {
let requestURL = URL(string: "http://host/b")!
let result = redirect(from: URL(string: "http://host/a")!, to: requestURL)
#expect(result?.url == requestURL)
}
@Test("Uppercase scheme spellings still trigger the downgrade check")
func uppercaseSchemesNormalized() {
let result = redirect(
from: URL(string: "HTTPS://host/a")!, to: URL(string: "HTTP://host/b")!)
#expect(result == nil)
}
}

View file

@ -20,12 +20,31 @@
// //
import Foundation import Foundation
import HTTPTypes
import LuminateAPI import LuminateAPI
import LuminateCore import LuminateCore
import OpenAPIRuntime
import Testing import Testing
@testable import LuminateServices @testable import LuminateServices
private struct PublicUsersTransport: ClientTransport {
let responseBody: String
func send(
_ request: HTTPRequest,
body: HTTPBody?,
baseURL: URL,
operationID: String
) async throws -> (HTTPResponse, HTTPBody?) {
let response = HTTPResponse(
status: .ok,
headerFields: [.contentType: "application/json"]
)
return (response, HTTPBody(responseBody))
}
}
/// Covers sign-in: argument validation, payload mapping, and token adoption. /// Covers sign-in: argument validation, payload mapping, and token adoption.
@Suite struct JellyfinClientAuthTests { @Suite struct JellyfinClientAuthTests {
/// Builds a client wired to a double, pointed at a URL that is never dialled. /// Builds a client wired to a double, pointed at a URL that is never dialled.
@ -42,6 +61,92 @@ import Testing
api: api api: api
) )
} }
/// Builds a client that decodes a raw public-user response without opening a network connection.
///
/// - Parameter responseBody: The JSON body returned by the transport double.
/// - Returns: A client configured with the production OpenAPI transport and date transcoder.
private func makeDecodingClient(responseBody: String) -> JellyfinClient {
JellyfinClient(
configuration: JellyfinClientConfiguration(
serverURL: URL(string: "http://localhost")!,
deviceName: "test-device",
deviceID: "test-device-id"
),
makeTransport: { PublicUsersTransport(responseBody: responseBody) }
)
}
@Test("Public users decode Jellyfin dates with seven fractional digits")
func publicUsersDecodeFractionalDates() async throws {
let lastLoginDate = "2025-05-07T17:36:46.1130892Z"
let responseBody = """
[
{
"Name": "ashley",
"Id": "user-1",
"LastLoginDate": "\(lastLoginDate)",
"LastActivityDate": "2026-07-16T05:04:36.1878329Z"
}
]
"""
let client = makeDecodingClient(responseBody: responseBody)
let users = try await client.publicUsers()
let expectedDate = try ISO8601DateTranscoder.iso8601WithFractionalSeconds.decode(lastLoginDate)
#expect(users.count == 1)
#expect(users[0].id == "user-1")
#expect(users[0].name == "ashley")
#expect(users[0].lastLoginDate == expectedDate)
}
@Test("Public users reject malformed date strings")
func publicUsersRejectMalformedDates() async {
let responseBody = """
[
{
"Name": "ashley",
"Id": "user-1",
"LastLoginDate": "not-a-date"
}
]
"""
let client = makeDecodingClient(responseBody: responseBody)
do {
_ = try await client.publicUsers()
Issue.record("Expected malformed public-user date to fail decoding")
} catch let error as ClientError {
#expect(error.underlyingError is DecodingError)
} catch {
Issue.record("Expected OpenAPI ClientError, got \(error)")
}
}
@Test("Fetching the current user maps the signed-in account")
func currentUserMapsAccount() async throws {
var api = MockJellyfinAPI()
api.getCurrentUserOutput = .ok(
.init(body: .json(Components.Schemas.UserDto(name: "echo", id: "u1")))
)
let client = makeClient(api)
let user = try await client.currentUser()
#expect(user.id == "u1")
#expect(user.name == "echo")
}
@Test("A rejected token surfaces as unauthorized")
func currentUserRejectedTokenIsUnauthorized() async {
var api = MockJellyfinAPI()
api.getCurrentUserOutput = .unauthorized(.init())
let client = makeClient(api)
await #expect(throws: JellyfinClientError.unauthorized) {
_ = try await client.currentUser()
}
}
@Test("A successful sign-in maps the result and adopts the token") @Test("A successful sign-in maps the result and adopts the token")
func signInAdoptsToken() async throws { func signInAdoptsToken() async throws {
@ -101,6 +206,124 @@ import Testing
} }
} }
@Test("Quick Connect enabled reports the stubbed flag")
func quickConnectEnabledReportsFlag() async throws {
var api = MockJellyfinAPI()
api.getQuickConnectEnabledOutput = .ok(.init(body: .json(true)))
let client = makeClient(api)
let enabled = try await client.quickConnectEnabled()
#expect(enabled == true)
}
@Test("Initiating Quick Connect maps the pairing state")
func initiateQuickConnectMapsState() async throws {
var api = MockJellyfinAPI()
api.initiateQuickConnectOutput = .ok(
.init(
body: .json(
Components.Schemas.QuickConnectResult(
authenticated: false,
secret: "sec1",
code: "ABC123"
)
)
)
)
let client = makeClient(api)
let state = try await client.initiateQuickConnect()
#expect(state.code == "ABC123")
#expect(state.secret == "sec1")
#expect(state.authenticated == false)
}
@Test("Polling an approved Quick Connect request reports success")
func quickConnectStateReportsApproval() async throws {
var api = MockJellyfinAPI()
api.getQuickConnectStateOutput = .ok(
.init(
body: .json(
Components.Schemas.QuickConnectResult(authenticated: true, secret: "sec1")
)
)
)
let client = makeClient(api)
let state = try await client.quickConnectState(secret: "sec1")
#expect(state.authenticated == true)
}
@Test("Redeeming Quick Connect adopts the issued token")
func authenticateWithQuickConnectAdoptsToken() async throws {
var api = MockJellyfinAPI()
api.authenticateWithQuickConnectOutput = .ok(
.init(
body: .json(
Components.Schemas.AuthenticationResult(
user: .init(value1: Components.Schemas.UserDto(name: "echo", id: "u1", hasPassword: true)),
accessToken: "qc-tok",
serverId: "srv"
)
)
)
)
let client = makeClient(api)
let result = try await client.authenticateWithQuickConnect(secret: "sec1")
#expect(result.accessToken == "qc-tok")
#expect(await client.accessToken == "qc-tok")
#expect(await client.isAuthenticated)
}
@Test("Updating a password records every supplied field")
func updatePasswordRecordsFields() async throws {
var api = MockJellyfinAPI()
api.updateUserPasswordOutput = .noContent
let client = makeClient(api)
try await client.updateUserPassword(
userID: "u1",
currentPassword: "old",
currentPIN: "1234",
newPassword: "new",
resetPassword: false
)
let input = try #require(api.inputs.last("UpdateUserPassword", as: Operations.UpdateUserPassword.Input.self))
#expect(input.query.userId == "u1")
guard case .json(let payload) = input.body else {
Issue.record("Expected a JSON update-password body")
return
}
#expect(payload.value1.currentPassword == "old")
#expect(payload.value1.currentPw == "1234")
#expect(payload.value1.newPw == "new")
#expect(payload.value1.resetPassword == false)
}
@Test("The reduced-arity password overload sends nil PIN, userID, and reset flag")
func reducedArityPasswordOverloadSendsNils() async throws {
var api = MockJellyfinAPI()
api.updateUserPasswordOutput = .noContent
let client = makeClient(api)
try await client.updateUserPassword(currentPassword: "old", newPassword: "new")
let input = try #require(api.inputs.last("UpdateUserPassword", as: Operations.UpdateUserPassword.Input.self))
#expect(input.query.userId == nil)
guard case .json(let payload) = input.body else {
Issue.record("Expected a JSON update-password body")
return
}
#expect(payload.value1.currentPw == nil)
#expect(payload.value1.resetPassword == nil)
}
@Test("Clearing the token signs the client out") @Test("Clearing the token signs the client out")
func clearingTokenSignsOut() async { func clearingTokenSignsOut() async {
let client = makeClient(MockJellyfinAPI()) let client = makeClient(MockJellyfinAPI())

View file

@ -146,4 +146,171 @@ import Testing
_ = try await client.seasons(seriesID: "") _ = try await client.seasons(seriesID: "")
} }
} }
@Test("A fully populated query maps every field onto the generated request")
func fullyPopulatedQueryMapsEveryField() async throws {
var api = MockJellyfinAPI()
api.getItemsOutput = .ok(.init(body: .json(movieResult)))
let client = makeClient(api)
let query = JellyfinMediaQuery(
userID: "u1",
parentID: "p1",
includeItemKinds: [.movie],
searchTerm: "arrival",
sortBy: [.sortName],
sortOrder: [.ascending],
fields: [.overview],
startIndex: 20,
limit: 40,
recursive: true,
isFavorite: true,
isPlayed: false,
genres: ["Drama"],
years: [2020],
nameStartsWith: "A"
)
_ = try await client.items(query)
let input = try #require(api.inputs.last("GetItems", as: Operations.GetItems.Input.self))
#expect(input.query.userId == "u1")
#expect(input.query.parentId == "p1")
#expect(input.query.searchTerm == "arrival")
#expect(input.query.recursive == true)
#expect(input.query.isFavorite == true)
#expect(input.query.isPlayed == false)
#expect(input.query.genres == ["Drama"])
#expect(input.query.years == [2020])
#expect(input.query.nameStartsWith == "A")
#expect(input.query.startIndex == 20)
#expect(input.query.limit == 40)
#expect(input.query.includeItemTypes == [.movie])
#expect(input.query.sortBy == [.sortName])
#expect(input.query.sortOrder == [.ascending])
#expect(input.query.fields == [.overview])
}
@Test("Fetching one item maps the DTO and forwards the item id and user id")
func fetchesOneItem() async throws {
var api = MockJellyfinAPI()
api.getItemOutput = .ok(.init(body: .json(Components.Schemas.BaseItemDto(name: "Arrival", id: "i1"))))
let client = makeClient(api)
let item = try await client.item(id: "i1", userID: "u9")
#expect(item.id == "i1")
#expect(item.name == "Arrival")
let input = try #require(api.inputs.last("GetItem", as: Operations.GetItem.Input.self))
#expect(input.path.itemId == "i1")
#expect(input.query.userId == "u9")
}
@Test("An empty item identifier is rejected before any request is sent")
func emptyItemIdentifierRejected() async {
let client = makeClient(MockJellyfinAPI())
await #expect(throws: JellyfinClientError.invalidArgument(name: "id")) {
_ = try await client.item(id: "", userID: nil)
}
}
@Test("resumeItems forwards paging and defaults the item-kind filter to movies and episodes")
func resumeItemsDefaultsItemKinds() async throws {
var api = MockJellyfinAPI()
api.getResumeItemsOutput = .ok(.init(body: .json(movieResult)))
let client = makeClient(api)
_ = try await client.resumeItems(JellyfinListOptions(startIndex: 5, limit: 10))
let input = try #require(api.inputs.last("GetResumeItems", as: Operations.GetResumeItems.Input.self))
#expect(input.query.startIndex == 5)
#expect(input.query.limit == 10)
#expect(input.query.includeItemTypes == [.movie, .episode])
}
@Test("nextUp records an explicit series id, or omits it for every series")
func nextUpRecordsSeriesID() async throws {
var api = MockJellyfinAPI()
api.getNextUpOutput = .ok(.init(body: .json(movieResult)))
let client = makeClient(api)
_ = try await client.nextUp(seriesID: "s1", options: JellyfinListOptions())
let withSeries = try #require(api.inputs.last("GetNextUp", as: Operations.GetNextUp.Input.self))
#expect(withSeries.query.seriesId == "s1")
_ = try await client.nextUp(seriesID: nil, options: JellyfinListOptions())
let withoutSeries = try #require(api.inputs.last("GetNextUp", as: Operations.GetNextUp.Input.self))
#expect(withoutSeries.query.seriesId == nil)
}
@Test("latestMedia maps a plain item list and forwards its options")
func latestMediaMapsPlainList() async throws {
var api = MockJellyfinAPI()
api.getLatestMediaOutput = .ok(
.init(
body: .json([
Components.Schemas.BaseItemDto(name: "Arrival", id: "i1"),
Components.Schemas.BaseItemDto(name: "Dune", id: "i2"),
])
)
)
let client = makeClient(api)
let items = try await client.latestMedia(JellyfinListOptions(parentID: "lib1", limit: 2))
#expect(items.map(\.id) == ["i1", "i2"])
#expect(items.map(\.name) == ["Arrival", "Dune"])
let input = try #require(api.inputs.last("GetLatestMedia", as: Operations.GetLatestMedia.Input.self))
#expect(input.query.parentId == "lib1")
#expect(input.query.limit == 2)
}
@Test("episodes forwards season and season-id filters")
func episodesForwardsSeasonFilters() async throws {
var api = MockJellyfinAPI()
api.getEpisodesOutput = .ok(.init(body: .json(movieResult)))
let client = makeClient(api)
_ = try await client.episodes(
seriesID: "series1",
options: JellyfinListOptions(seasonID: "season-1", season: 2)
)
let input = try #require(api.inputs.last("GetEpisodes", as: Operations.GetEpisodes.Input.self))
#expect(input.path.seriesId == "series1")
#expect(input.query.season == 2)
#expect(input.query.seasonId == "season-1")
}
@Test("searchHints maps hints and forwards paging")
func searchHintsMapsAndForwardsPaging() async throws {
var api = MockJellyfinAPI()
api.getSearchHintsOutput = .ok(
.init(
body: .json(
Components.Schemas.SearchHintResult(
searchHints: [
Components.Schemas.SearchHint(id: "h1", name: "Arrival", matchedTerm: "arrival"),
Components.Schemas.SearchHint(id: "h2", name: "Arrival 2", matchedTerm: "arrival"),
]
)
)
)
)
let client = makeClient(api)
let hints = try await client.searchHints(
term: "arrival",
options: JellyfinListOptions(startIndex: 10, limit: 5)
)
#expect(hints.map(\.id) == ["h1", "h2"])
#expect(hints.map(\.name) == ["Arrival", "Arrival 2"])
#expect(hints.map(\.matchedTerm) == ["arrival", "arrival"])
let input = try #require(api.inputs.last("GetSearchHints", as: Operations.GetSearchHints.Input.self))
#expect(input.query.startIndex == 10)
#expect(input.query.limit == 5)
#expect(input.query.searchTerm == "arrival")
}
} }

View file

@ -0,0 +1,89 @@
//
// JellyfinClientReconnectTests.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 HTTPTypes
import LuminateCore
import OpenAPIRuntime
import Synchronization
import Testing
@testable import LuminateServices
/// A transport double that always answers with the same canned public-users payload.
private struct FixedPublicUsersTransport: ClientTransport {
let responseBody: String
func send(
_ request: HTTPRequest,
body: HTTPBody?,
baseURL: URL,
operationID: String
) async throws -> (HTTPResponse, HTTPBody?) {
let response = HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"])
return (response, HTTPBody(responseBody))
}
}
/// Covers ``JellyfinClient/reconnect()``: the fix for a transport that failed once (most notably,
/// `URLSession` caching a DNS resolution failure for its whole lifetime on Linux) and must be
/// rebuilt from scratch, rather than reused, before a retry can possibly succeed.
@Suite struct JellyfinClientReconnectTests {
private static func publicUsersBody(name: String, id: String) -> String {
"""
[
{
"Name": "\(name)",
"Id": "\(id)"
}
]
"""
}
@Test("reconnect() rebuilds the API client against a freshly constructed transport")
func reconnectRebuildsTransport() async throws {
let invocationCount = Mutex(0)
let responses = [
Self.publicUsersBody(name: "alpha", id: "user-1"),
Self.publicUsersBody(name: "beta", id: "user-2"),
]
let client = JellyfinClient(
configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!),
makeTransport: {
let index = invocationCount.withLock { count in
defer { count += 1 }
return count
}
return FixedPublicUsersTransport(responseBody: responses[index])
}
)
let beforeReconnect = try await client.publicUsers()
#expect(beforeReconnect.map(\.name) == ["alpha"])
#expect(invocationCount.withLock { $0 } == 1)
await client.reconnect()
let afterReconnect = try await client.publicUsers()
#expect(afterReconnect.map(\.name) == ["beta"])
#expect(invocationCount.withLock { $0 } == 2)
}
}

View file

@ -0,0 +1,79 @@
//
// JellyfinClientSystemTests.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 LuminateAPI
import LuminateCore
import Testing
@testable import LuminateServices
/// Covers the authenticated server-identification call and its DTO-to-domain mapping.
///
/// `publicServerInfo()` is exercised elsewhere; this suite is only `serverInfo()`.
@Suite struct JellyfinClientSystemTests {
/// Builds a client wired to a double.
///
/// - Parameter api: The stubbed API.
/// - Returns: A client that routes every call to `api`.
private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient {
JellyfinClient(
configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!),
api: api
)
}
@Test("Maps the signed-in server's system information")
func mapsServerInfo() async throws {
var api = MockJellyfinAPI()
api.getSystemInfoOutput = .ok(
.init(
body: .json(
Components.Schemas.SystemInfo(
serverName: "Echo's Server",
version: "10.11.10",
productName: "Jellyfin Server",
id: "srv-1"
)
)
)
)
let client = makeClient(api)
let info = try await client.serverInfo()
#expect(info.serverName == "Echo's Server")
#expect(info.version == "10.11.10")
#expect(info.id == "srv-1")
#expect(info.productName == "Jellyfin Server")
}
@Test("A forbidden response throws before returning any information")
func forbiddenThrows() async {
var api = MockJellyfinAPI()
api.getSystemInfoOutput = .forbidden(.init(body: .json(Components.Schemas.ProblemDetails())))
let client = makeClient(api)
await #expect(throws: JellyfinClientError.forbidden) {
_ = try await client.serverInfo()
}
}
}

View file

@ -0,0 +1,40 @@
//
// JellyfinClientTransportTests.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 OpenAPIURLSession
import Testing
@testable import LuminateServices
/// Covers ``JellyfinClient/defaultTransport``: the production HTTP transport used whenever a
/// caller does not inject a double, including the pre-window token probe in `LaunchSession`.
@Suite struct JellyfinClientTransportTests {
@Test("The default transport bounds each request to a 10 second timeout")
func defaultTransportUsesATenSecondRequestTimeout() {
let transport = JellyfinClient.defaultTransport()
guard let urlSessionTransport = transport as? URLSessionTransport else {
Issue.record("Expected a URLSessionTransport, got \(type(of: transport))")
return
}
#expect(urlSessionTransport.configuration.session.configuration.timeoutIntervalForRequest == 10)
}
}

View file

@ -0,0 +1,146 @@
//
// JellyfinClientUserStateTests.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 LuminateAPI
import LuminateCore
import Testing
@testable import LuminateServices
/// Covers the watched-state and favourite mutations, and their itemID validation.
@Suite struct JellyfinClientUserStateTests {
/// Builds a client wired to a double.
///
/// - Parameter api: The stubbed API.
/// - Returns: A client that routes every call to `api`.
private func makeClient(_ api: MockJellyfinAPI) -> JellyfinClient {
JellyfinClient(
configuration: JellyfinClientConfiguration(serverURL: URL(string: "http://localhost")!),
api: api
)
}
@Test("Marking an item played returns the server's recomputed state and forwards the user and date")
func markPlayedSucceeds() async throws {
var api = MockJellyfinAPI()
api.markPlayedItemOutput = .ok(
.init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: true, played: true)))
)
let client = makeClient(api)
let datePlayed = Date(timeIntervalSince1970: 1_700_000_000)
let userData = try await client.markPlayed(itemID: "i1", userID: "u1", datePlayed: datePlayed)
#expect(userData.isFavorite == true)
#expect(userData.played == true)
let input = try #require(api.inputs.last("MarkPlayedItem", as: Operations.MarkPlayedItem.Input.self))
#expect(input.query.userId == "u1")
#expect(input.query.datePlayed == datePlayed)
}
@Test("An empty item identifier rejects markPlayed before any request is sent")
func markPlayedEmptyItemIDRejected() async {
let api = MockJellyfinAPI()
let client = makeClient(api)
await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) {
_ = try await client.markPlayed(itemID: "", userID: nil, datePlayed: nil)
}
#expect(api.inputs.last("MarkPlayedItem", as: Operations.MarkPlayedItem.Input.self) == nil)
}
@Test("Marking an item unplayed returns the server's recomputed state")
func markUnplayedSucceeds() async throws {
var api = MockJellyfinAPI()
api.markUnplayedItemOutput = .ok(
.init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: false, played: false)))
)
let client = makeClient(api)
let userData = try await client.markUnplayed(itemID: "i1", userID: nil)
#expect(userData.isFavorite == false)
#expect(userData.played == false)
}
@Test("An empty item identifier rejects markUnplayed before any request is sent")
func markUnplayedEmptyItemIDRejected() async {
let api = MockJellyfinAPI()
let client = makeClient(api)
await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) {
_ = try await client.markUnplayed(itemID: "", userID: nil)
}
#expect(api.inputs.last("MarkUnplayedItem", as: Operations.MarkUnplayedItem.Input.self) == nil)
}
@Test("Marking an item a favourite returns the server's recomputed state")
func markFavoriteSucceeds() async throws {
var api = MockJellyfinAPI()
api.markFavoriteItemOutput = .ok(
.init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: true, played: false)))
)
let client = makeClient(api)
let userData = try await client.markFavorite(itemID: "i1", userID: nil)
#expect(userData.isFavorite == true)
#expect(userData.played == false)
}
@Test("An empty item identifier rejects markFavorite before any request is sent")
func markFavoriteEmptyItemIDRejected() async {
let api = MockJellyfinAPI()
let client = makeClient(api)
await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) {
_ = try await client.markFavorite(itemID: "", userID: nil)
}
#expect(api.inputs.last("MarkFavoriteItem", as: Operations.MarkFavoriteItem.Input.self) == nil)
}
@Test("Unmarking an item a favourite returns the server's recomputed state")
func unmarkFavoriteSucceeds() async throws {
var api = MockJellyfinAPI()
api.unmarkFavoriteItemOutput = .ok(
.init(body: .json(Components.Schemas.UserItemDataDto(isFavorite: false, played: true)))
)
let client = makeClient(api)
let userData = try await client.unmarkFavorite(itemID: "i1", userID: nil)
#expect(userData.isFavorite == false)
#expect(userData.played == true)
}
@Test("An empty item identifier rejects unmarkFavorite before any request is sent")
func unmarkFavoriteEmptyItemIDRejected() async {
let api = MockJellyfinAPI()
let client = makeClient(api)
await #expect(throws: JellyfinClientError.invalidArgument(name: "itemID")) {
_ = try await client.unmarkFavorite(itemID: "", userID: nil)
}
#expect(api.inputs.last("UnmarkFavoriteItem", as: Operations.UnmarkFavoriteItem.Input.self) == nil)
}
}

View file

@ -21,14 +21,37 @@
import LuminateAPI import LuminateAPI
import LuminateServices import LuminateServices
import Synchronization
import Testing import Testing
/// Records the last input each operation received, so a test can assert parameter mapping.
///
/// A reference type with a `Mutex` rather than a mutable struct field: `MockJellyfinAPI` is
/// copied into the actor-isolated `JellyfinClient`, so a value-typed log would record into a
/// copy the test cannot see.
final class OperationInputLog: Sendable {
private let entries = Mutex<[String: any Sendable]>([:])
/// Stores `input` as the most recent call to `operation`.
func record(_ input: any Sendable, for operation: String) {
entries.withLock { $0[operation] = input }
}
/// The most recent input recorded for `operation`, or `nil` when it was never called.
func last<Input: Sendable>(_ operation: String, as type: Input.Type) -> Input? {
entries.withLock { $0[operation] as? Input }
}
}
/// A ``JellyfinAPI`` double that answers with pre-baked generated outputs. /// A ``JellyfinAPI`` double that answers with pre-baked generated outputs.
/// ///
/// Only the operations a test actually stubs are usable; every other call fails the test with a /// Only the operations a test actually stubs are usable; every other call fails the test with a
/// clear message rather than returning a fabricated success. Outputs are plain `Sendable` values, /// clear message rather than returning a fabricated success. Outputs are plain `Sendable` values,
/// so the whole double is a `Sendable` struct with no isolation escape hatches. /// so the whole double is a `Sendable` struct with no isolation escape hatches.
struct MockJellyfinAPI: JellyfinAPI { struct MockJellyfinAPI: JellyfinAPI {
/// Records the input each operation method received, keyed by operation identifier.
let inputs = OperationInputLog()
/// The output returned by ``authenticateUserByName(_:)``. /// The output returned by ``authenticateUserByName(_:)``.
var authenticateUserByNameOutput: Operations.AuthenticateUserByName.Output? var authenticateUserByNameOutput: Operations.AuthenticateUserByName.Output?
@ -38,6 +61,9 @@ struct MockJellyfinAPI: JellyfinAPI {
/// The output returned by ``getPublicUsers(_:)``. /// The output returned by ``getPublicUsers(_:)``.
var getPublicUsersOutput: Operations.GetPublicUsers.Output? var getPublicUsersOutput: Operations.GetPublicUsers.Output?
/// The output returned by ``getCurrentUser(_:)``.
var getCurrentUserOutput: Operations.GetCurrentUser.Output?
/// The output returned by ``getPublicSystemInfo(_:)``. /// The output returned by ``getPublicSystemInfo(_:)``.
var getPublicSystemInfoOutput: Operations.GetPublicSystemInfo.Output? var getPublicSystemInfoOutput: Operations.GetPublicSystemInfo.Output?
@ -115,116 +141,145 @@ struct MockJellyfinAPI: JellyfinAPI {
func authenticateUserByName(_ input: Operations.AuthenticateUserByName.Input) async throws func authenticateUserByName(_ input: Operations.AuthenticateUserByName.Input) async throws
-> Operations.AuthenticateUserByName.Output -> Operations.AuthenticateUserByName.Output
{ {
try unwrap(authenticateUserByNameOutput, "AuthenticateUserByName") inputs.record(input, for: "AuthenticateUserByName")
return try unwrap(authenticateUserByNameOutput, "AuthenticateUserByName")
} }
func authenticateWithQuickConnect(_ input: Operations.AuthenticateWithQuickConnect.Input) async throws func authenticateWithQuickConnect(_ input: Operations.AuthenticateWithQuickConnect.Input) async throws
-> Operations.AuthenticateWithQuickConnect.Output -> Operations.AuthenticateWithQuickConnect.Output
{ {
try unwrap(authenticateWithQuickConnectOutput, "AuthenticateWithQuickConnect") inputs.record(input, for: "AuthenticateWithQuickConnect")
return try unwrap(authenticateWithQuickConnectOutput, "AuthenticateWithQuickConnect")
} }
func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output { func getPublicUsers(_ input: Operations.GetPublicUsers.Input) async throws -> Operations.GetPublicUsers.Output {
try unwrap(getPublicUsersOutput, "GetPublicUsers") inputs.record(input, for: "GetPublicUsers")
return try unwrap(getPublicUsersOutput, "GetPublicUsers")
}
func getCurrentUser(_ input: Operations.GetCurrentUser.Input) async throws -> Operations.GetCurrentUser.Output {
inputs.record(input, for: "GetCurrentUser")
return try unwrap(getCurrentUserOutput, "GetCurrentUser")
} }
func getPublicSystemInfo(_ input: Operations.GetPublicSystemInfo.Input) async throws func getPublicSystemInfo(_ input: Operations.GetPublicSystemInfo.Input) async throws
-> Operations.GetPublicSystemInfo.Output -> Operations.GetPublicSystemInfo.Output
{ {
try unwrap(getPublicSystemInfoOutput, "GetPublicSystemInfo") inputs.record(input, for: "GetPublicSystemInfo")
return try unwrap(getPublicSystemInfoOutput, "GetPublicSystemInfo")
} }
func getSystemInfo(_ input: Operations.GetSystemInfo.Input) async throws -> Operations.GetSystemInfo.Output { func getSystemInfo(_ input: Operations.GetSystemInfo.Input) async throws -> Operations.GetSystemInfo.Output {
try unwrap(getSystemInfoOutput, "GetSystemInfo") inputs.record(input, for: "GetSystemInfo")
return try unwrap(getSystemInfoOutput, "GetSystemInfo")
} }
func getQuickConnectEnabled(_ input: Operations.GetQuickConnectEnabled.Input) async throws func getQuickConnectEnabled(_ input: Operations.GetQuickConnectEnabled.Input) async throws
-> Operations.GetQuickConnectEnabled.Output -> Operations.GetQuickConnectEnabled.Output
{ {
try unwrap(getQuickConnectEnabledOutput, "GetQuickConnectEnabled") inputs.record(input, for: "GetQuickConnectEnabled")
return try unwrap(getQuickConnectEnabledOutput, "GetQuickConnectEnabled")
} }
func initiateQuickConnect(_ input: Operations.InitiateQuickConnect.Input) async throws func initiateQuickConnect(_ input: Operations.InitiateQuickConnect.Input) async throws
-> Operations.InitiateQuickConnect.Output -> Operations.InitiateQuickConnect.Output
{ {
try unwrap(initiateQuickConnectOutput, "InitiateQuickConnect") inputs.record(input, for: "InitiateQuickConnect")
return try unwrap(initiateQuickConnectOutput, "InitiateQuickConnect")
} }
func getQuickConnectState(_ input: Operations.GetQuickConnectState.Input) async throws func getQuickConnectState(_ input: Operations.GetQuickConnectState.Input) async throws
-> Operations.GetQuickConnectState.Output -> Operations.GetQuickConnectState.Output
{ {
try unwrap(getQuickConnectStateOutput, "GetQuickConnectState") inputs.record(input, for: "GetQuickConnectState")
return try unwrap(getQuickConnectStateOutput, "GetQuickConnectState")
} }
func updateUserPassword(_ input: Operations.UpdateUserPassword.Input) async throws func updateUserPassword(_ input: Operations.UpdateUserPassword.Input) async throws
-> Operations.UpdateUserPassword.Output -> Operations.UpdateUserPassword.Output
{ {
try unwrap(updateUserPasswordOutput, "UpdateUserPassword") inputs.record(input, for: "UpdateUserPassword")
return try unwrap(updateUserPasswordOutput, "UpdateUserPassword")
} }
func getUserViews(_ input: Operations.GetUserViews.Input) async throws -> Operations.GetUserViews.Output { func getUserViews(_ input: Operations.GetUserViews.Input) async throws -> Operations.GetUserViews.Output {
try unwrap(getUserViewsOutput, "GetUserViews") inputs.record(input, for: "GetUserViews")
return try unwrap(getUserViewsOutput, "GetUserViews")
} }
func getItems(_ input: Operations.GetItems.Input) async throws -> Operations.GetItems.Output { func getItems(_ input: Operations.GetItems.Input) async throws -> Operations.GetItems.Output {
try unwrap(getItemsOutput, "GetItems") inputs.record(input, for: "GetItems")
return try unwrap(getItemsOutput, "GetItems")
} }
func getItem(_ input: Operations.GetItem.Input) async throws -> Operations.GetItem.Output { func getItem(_ input: Operations.GetItem.Input) async throws -> Operations.GetItem.Output {
try unwrap(getItemOutput, "GetItem") inputs.record(input, for: "GetItem")
return try unwrap(getItemOutput, "GetItem")
} }
func getResumeItems(_ input: Operations.GetResumeItems.Input) async throws -> Operations.GetResumeItems.Output { func getResumeItems(_ input: Operations.GetResumeItems.Input) async throws -> Operations.GetResumeItems.Output {
try unwrap(getResumeItemsOutput, "GetResumeItems") inputs.record(input, for: "GetResumeItems")
return try unwrap(getResumeItemsOutput, "GetResumeItems")
} }
func getNextUp(_ input: Operations.GetNextUp.Input) async throws -> Operations.GetNextUp.Output { func getNextUp(_ input: Operations.GetNextUp.Input) async throws -> Operations.GetNextUp.Output {
try unwrap(getNextUpOutput, "GetNextUp") inputs.record(input, for: "GetNextUp")
return try unwrap(getNextUpOutput, "GetNextUp")
} }
func getLatestMedia(_ input: Operations.GetLatestMedia.Input) async throws -> Operations.GetLatestMedia.Output { func getLatestMedia(_ input: Operations.GetLatestMedia.Input) async throws -> Operations.GetLatestMedia.Output {
try unwrap(getLatestMediaOutput, "GetLatestMedia") inputs.record(input, for: "GetLatestMedia")
return try unwrap(getLatestMediaOutput, "GetLatestMedia")
} }
func getSeasons(_ input: Operations.GetSeasons.Input) async throws -> Operations.GetSeasons.Output { func getSeasons(_ input: Operations.GetSeasons.Input) async throws -> Operations.GetSeasons.Output {
try unwrap(getSeasonsOutput, "GetSeasons") inputs.record(input, for: "GetSeasons")
return try unwrap(getSeasonsOutput, "GetSeasons")
} }
func getEpisodes(_ input: Operations.GetEpisodes.Input) async throws -> Operations.GetEpisodes.Output { func getEpisodes(_ input: Operations.GetEpisodes.Input) async throws -> Operations.GetEpisodes.Output {
try unwrap(getEpisodesOutput, "GetEpisodes") inputs.record(input, for: "GetEpisodes")
return try unwrap(getEpisodesOutput, "GetEpisodes")
} }
func getSearchHints(_ input: Operations.GetSearchHints.Input) async throws -> Operations.GetSearchHints.Output { func getSearchHints(_ input: Operations.GetSearchHints.Input) async throws -> Operations.GetSearchHints.Output {
try unwrap(getSearchHintsOutput, "GetSearchHints") inputs.record(input, for: "GetSearchHints")
return try unwrap(getSearchHintsOutput, "GetSearchHints")
} }
func getItemImage(_ input: Operations.GetItemImage.Input) async throws -> Operations.GetItemImage.Output { func getItemImage(_ input: Operations.GetItemImage.Input) async throws -> Operations.GetItemImage.Output {
try unwrap(getItemImageOutput, "GetItemImage") inputs.record(input, for: "GetItemImage")
return try unwrap(getItemImageOutput, "GetItemImage")
} }
func getItemImageByIndex(_ input: Operations.GetItemImageByIndex.Input) async throws func getItemImageByIndex(_ input: Operations.GetItemImageByIndex.Input) async throws
-> Operations.GetItemImageByIndex.Output -> Operations.GetItemImageByIndex.Output
{ {
try unwrap(getItemImageByIndexOutput, "GetItemImageByIndex") inputs.record(input, for: "GetItemImageByIndex")
return try unwrap(getItemImageByIndexOutput, "GetItemImageByIndex")
} }
func markPlayedItem(_ input: Operations.MarkPlayedItem.Input) async throws -> Operations.MarkPlayedItem.Output { func markPlayedItem(_ input: Operations.MarkPlayedItem.Input) async throws -> Operations.MarkPlayedItem.Output {
try unwrap(markPlayedItemOutput, "MarkPlayedItem") inputs.record(input, for: "MarkPlayedItem")
return try unwrap(markPlayedItemOutput, "MarkPlayedItem")
} }
func markUnplayedItem(_ input: Operations.MarkUnplayedItem.Input) async throws -> Operations.MarkUnplayedItem.Output func markUnplayedItem(_ input: Operations.MarkUnplayedItem.Input) async throws -> Operations.MarkUnplayedItem.Output
{ {
try unwrap(markUnplayedItemOutput, "MarkUnplayedItem") inputs.record(input, for: "MarkUnplayedItem")
return try unwrap(markUnplayedItemOutput, "MarkUnplayedItem")
} }
func markFavoriteItem(_ input: Operations.MarkFavoriteItem.Input) async throws -> Operations.MarkFavoriteItem.Output func markFavoriteItem(_ input: Operations.MarkFavoriteItem.Input) async throws -> Operations.MarkFavoriteItem.Output
{ {
try unwrap(markFavoriteItemOutput, "MarkFavoriteItem") inputs.record(input, for: "MarkFavoriteItem")
return try unwrap(markFavoriteItemOutput, "MarkFavoriteItem")
} }
func unmarkFavoriteItem(_ input: Operations.UnmarkFavoriteItem.Input) async throws func unmarkFavoriteItem(_ input: Operations.UnmarkFavoriteItem.Input) async throws
-> Operations.UnmarkFavoriteItem.Output -> Operations.UnmarkFavoriteItem.Output
{ {
try unwrap(unmarkFavoriteItemOutput, "UnmarkFavoriteItem") inputs.record(input, for: "UnmarkFavoriteItem")
return try unwrap(unmarkFavoriteItemOutput, "UnmarkFavoriteItem")
} }
} }

View file

@ -0,0 +1,162 @@
//
// ServerConnectionProbeTests.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 LuminateCore
import OpenAPIRuntime
import Testing
@testable import LuminateServices
/// Records probe candidates without introducing shared mutable state into test closures.
private actor ProbeRecorder {
private(set) var urls: [URL] = []
func record(_ url: URL) {
urls.append(url)
}
}
/// Exercises HTTPS preference, transport fallback, and server identity classification.
@Suite struct ServerConnectionProbeTests {
private let httpsURL = URL(string: "https://jellyfin.test/")!
private let httpURL = URL(string: "http://jellyfin.test/")!
@Test("Uses the HTTPS candidate when it answers")
func usesHTTPS() async throws {
let address = try makeAddress()
let probe = ServerConnectionProbe { url in
#expect(url == self.httpsURL)
return JellyfinServerInfo(serverName: "Home", productName: "Jellyfin Server")
}
let connection = try await probe.connect(to: address)
#expect(connection.url == httpsURL)
#expect(connection.isEncrypted)
}
@Test("Falls back to HTTP after an HTTPS transport failure")
func fallsBackAfterTransportFailure() async throws {
let address = try makeAddress()
let recorder = ProbeRecorder()
let probe = ServerConnectionProbe { url in
await recorder.record(url)
if url.scheme == "https" {
throw URLError(.cannotConnectToHost)
}
return JellyfinServerInfo(productName: "Jellyfin Server")
}
let connection = try await probe.connect(to: address)
#expect(connection.url == httpURL)
#expect(!connection.isEncrypted)
#expect(await recorder.urls == [httpsURL, httpURL])
}
@Test("Does not fall back when HTTPS answers with an error status")
func doesNotFallbackAfterAnswer() async throws {
let address = try makeAddress()
let recorder = ProbeRecorder()
let probe = ServerConnectionProbe { url in
await recorder.record(url)
throw JellyfinClientError.unauthorized
}
do {
_ = try await probe.connect(to: address)
Issue.record("Expected the answered HTTPS candidate to be rejected")
} catch let error as ServerConnectionError {
guard case .rejected(let url, let detail) = error else {
Issue.record("Expected a rejected connection, got \(error)")
return
}
#expect(url == httpsURL)
#expect(!detail.isEmpty)
}
#expect(await recorder.urls == [httpsURL])
}
@Test("Unwraps an OpenAPI ClientError to reach the URLError")
func unwrapsClientError() async throws {
let address = try makeAddress()
let recorder = ProbeRecorder()
let probe = ServerConnectionProbe { url in
await recorder.record(url)
if url.scheme == "https" {
throw ClientError(
operationID: "GetPublicSystemInfo",
operationInput: "test",
causeDescription: "transport failed",
underlyingError: URLError(.timedOut)
)
}
return JellyfinServerInfo(productName: "Jellyfin Server")
}
let connection = try await probe.connect(to: address)
#expect(connection.url == httpURL)
#expect(await recorder.urls == [httpsURL, httpURL])
}
@Test("Rejects a reachable server that is not Jellyfin")
func rejectsNonJellyfin() async throws {
let address = try makeAddress()
do {
let probe = ServerConnectionProbe { _ in
JellyfinServerInfo(productName: "Emby Server")
}
_ = try await probe.connect(to: address)
Issue.record("Expected a non-Jellyfin identity to be rejected")
} catch let error as ServerConnectionError {
#expect(error == .notJellyfin(url: httpsURL, productName: "Emby Server"))
}
}
@Test("Accepts a server that omits its product name")
func acceptsMissingProductName() async throws {
let address = try makeAddress()
let probe = ServerConnectionProbe { _ in JellyfinServerInfo(serverName: "Proxy") }
let connection = try await probe.connect(to: address)
#expect(connection.serverInfo.serverName == "Proxy")
}
@Test("Reports every attempted URL when nothing answers")
func reportsUnreachableCandidates() async throws {
let address = try makeAddress()
let recorder = ProbeRecorder()
let probe = ServerConnectionProbe { url in
await recorder.record(url)
throw URLError(.cannotConnectToHost)
}
do {
_ = try await probe.connect(to: address)
Issue.record("Expected all candidates to be reported as unreachable")
} catch let error as ServerConnectionError {
#expect(error == .unreachable(attempted: [httpsURL, httpURL]))
}
#expect(await recorder.urls == [httpsURL, httpURL])
}
private func makeAddress() throws -> ServerAddress {
try ServerAddress.normalize(address: "jellyfin.test", port: "", policy: .automatic)
}
}

View file

@ -101,6 +101,36 @@ import Testing
#expect(reopened.initialSnapshot["server.url"] == .text("second")) #expect(reopened.initialSnapshot["server.url"] == .text("second"))
} }
@Test("A write failure surfaces once and does not repeat on the next flush")
func writeFailureIsRetainedThenCleared() async throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let store = try SQLitePreferenceStore(url: url)
let saboteur = try SQLiteDatabase(url: url)
try saboteur.exec("DROP TABLE preference;")
store.enqueue("server.url", .text("http://x"))
await #expect(throws: PreferenceStoreError.self) { try await store.flush() }
try await store.flush()
}
@Test("Opening a database at a directory path fails to open")
func opensDirectoryFails() throws {
let directory = FileManager.default.temporaryDirectory
.appending(path: "luminate-store-dir-\(UUID().uuidString)", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
do {
_ = try SQLiteDatabase(url: directory)
Issue.record("Expected SQLiteDatabase(url:) to throw for a directory path")
} catch PreferenceStoreError.cannotOpen(let path, _) {
#expect(path == directory.path)
} catch {
Issue.record("Expected PreferenceStoreError.cannotOpen, got \(error)")
}
}
private func makeDatabaseURL() -> (URL, URL) { private func makeDatabaseURL() -> (URL, URL) {
let directory = FileManager.default.temporaryDirectory let directory = FileManager.default.temporaryDirectory
.appending(path: "luminate-store-\(UUID().uuidString)", directoryHint: .isDirectory) .appending(path: "luminate-store-\(UUID().uuidString)", directoryHint: .isDirectory)

View file

@ -0,0 +1,96 @@
//
// SQLiteStatementTests.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 SQLiteStatementTests {
@Test("Preparing malformed SQL fails with a non-empty diagnostic")
func prepareFailureReportsDiagnostic() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
#expect(throws: PreferenceStoreError.self) {
_ = try SQLiteStatement(database: database, sql: "NOT SQL")
}
do {
_ = try SQLiteStatement(database: database, sql: "NOT SQL")
Issue.record("Expected malformed SQL to throw")
} catch let error as PreferenceStoreError {
if case .sqlite(let message) = error {
#expect(!message.isEmpty)
} else {
Issue.record("Expected .sqlite, got \(error)")
}
}
}
@Test("Binding past the parameter count fails")
func bindOutOfRangeFails() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
let statement = try SQLiteStatement(database: database, sql: "SELECT ?1;")
#expect(throws: PreferenceStoreError.self) {
try statement.bind(99, text: "x")
}
}
@Test("A constraint violation on step fails")
func stepConstraintViolationFails() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
try database.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT NOT NULL);")
let statement = try SQLiteStatement(database: database, sql: "INSERT INTO t (v) VALUES (NULL);")
#expect(throws: PreferenceStoreError.self) {
_ = try statement.step()
}
}
@Test("A NULL result column decodes as nil")
func nullColumnDecodesAsNil() throws {
let (url, directory) = makeDatabaseURL()
defer { try? FileManager.default.removeItem(at: directory) }
let database = try SQLiteDatabase(url: url)
let statement = try SQLiteStatement(database: database, sql: "SELECT NULL;")
_ = try statement.step()
#expect(statement.preference(at: 0) == nil)
}
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)
}
}

View file

@ -0,0 +1,50 @@
//
// BlockingBridgeTests.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 Luminate
@Suite struct BlockingBridgeTests {
@Test("Returns the operation's value")
func returnsValue() throws {
let result = try BlockingBridge.run { 42 }
#expect(result == 42)
}
@Test("Rethrows the operation's error")
func rethrowsError() {
struct Marker: Error, Equatable {}
#expect(throws: Marker.self) {
try BlockingBridge.run { throw Marker() }
}
}
@Test("Awaits genuinely asynchronous work before returning")
func awaitsAsyncWork() throws {
let result = try BlockingBridge.run { () async -> Int in
try? await Task.sleep(for: .milliseconds(50))
return 7
}
#expect(result == 7)
}
}

View file

@ -0,0 +1,113 @@
//
// ClientSessionBinderTests.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
import LuminateServices
import LuminateUI
import Testing
@testable import Luminate
/// Exercises the wiring between session preferences and the live Jellyfin client.
@Suite(.serialized) @MainActor struct ClientSessionBinderTests {
@Test("Writing the server URL preference reconfigures the client")
func serverURLPreferenceReconfiguresClient() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let client = JellyfinClient(configuration: JellyfinClientConfiguration(serverURL: nil))
let binder = ClientSessionBinder(client: client, preferences: preferences)
let expected = URL(string: "http://jellyfin.test:8096")!
preferences[.serverURL] = expected
await asyncPump { await client.serverURL == expected }
#expect(await client.serverURL == expected)
withExtendedLifetime(binder) {}
}
@Test("Writing and clearing the access token updates authentication state")
func accessTokenPreferenceUpdatesAuthenticationState() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let client = JellyfinClient(configuration: JellyfinClientConfiguration(serverURL: nil))
let binder = ClientSessionBinder(client: client, preferences: preferences)
preferences[.accessToken] = "tok"
await asyncPump { await client.accessToken == "tok" }
#expect(await client.accessToken == "tok")
#expect(await client.isAuthenticated)
preferences[.accessToken] = nil
await asyncPump { await client.accessToken == nil }
#expect(await client.accessToken == nil)
#expect(await client.isAuthenticated == false)
withExtendedLifetime(binder) {}
}
@Test("A nil server URL write is ignored")
func nilServerURLWriteIsIgnored() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let client = JellyfinClient(configuration: JellyfinClientConfiguration(serverURL: nil))
let binder = ClientSessionBinder(client: client, preferences: preferences)
let established = URL(string: "http://jellyfin.test:8096")!
preferences[.serverURL] = established
await asyncPump { await client.serverURL == established }
#expect(await client.serverURL == established)
preferences[.serverURL] = nil
await asyncPump(turns: 50) { false }
#expect(await client.serverURL == established)
withExtendedLifetime(binder) {}
}
/// Drives the GLib default main context, yielding the task each turn, until `condition`
/// reports true or `turns` is exhausted.
///
/// Two independent async hops must both complete before the client actor reflects a
/// preference write: Portico's idle-deferred Observation flush (which invokes the binder's
/// subscription closure) and the `Task { await client.set... }` that closure spawns. A single
/// main-context pump only satisfies the first hop, so this loops - repeating both the GLib
/// pump and `Task.yield()` - and lets `condition` `await` the actor's state directly, since a
/// synchronous condition cannot observe it. Mirrors
/// `Tests/LuminateUITests/PreferenceTests.swift`'s `asyncPump`, which exists for the same
/// swift-testing-does-not-run-@MainActor-bodies-on-the-initial-thread reason.
private func asyncPump(turns: Int = 500, until condition: () async -> Bool) async {
for _ in 0..<turns {
if await condition() { return }
_ = clientSessionBinder_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
}
// UPSTREAM: see Tests/LuminateUITests/PreferenceTests.swift - Portico should expose an async
// main-loop pump for tests so suites do not need @_silgen_name.
@_silgen_name("g_main_context_iteration")
private nonisolated func clientSessionBinder_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?,
_ mayBlock: Int32
) -> Int32

View file

@ -0,0 +1,182 @@
//
// LaunchSessionTests.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 LuminateCore
import LuminateUI
import Synchronization
import Testing
@testable import Luminate
/// Exercises the launch-time token verification gate in isolation from any real network call.
@Suite @MainActor struct LaunchSessionTests {
@Test("A launch with no stored token skips verification")
func noStoredTokenSkipsVerification() async {
let preferences = Preferences(store: EphemeralPreferenceStore())
let callCount = Mutex(0)
let session = LaunchSession(phase: .resolved, preferences: preferences) {
callCount.withLock { $0 += 1 }
}
#expect(session.phase == .resolved)
await session.verifyStoredToken()
#expect(callCount.withLock { $0 } == 0)
}
@Test("A valid token keeps the session and resolves")
func validTokenResolves() async {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession(phase: .verifying, preferences: preferences) {}
#expect(session.phase == .verifying)
await session.verifyStoredToken()
#expect(session.phase == .resolved)
#expect(preferences[.accessToken] == "tok")
}
@Test("A rejected token is cleared and returns the user to onboarding")
func rejectedTokenIsCleared() async {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession(phase: .verifying, preferences: preferences) {
throw JellyfinClientError.unauthorized
}
await session.verifyStoredToken()
#expect(session.phase == .resolved)
#expect(preferences[.accessToken] == nil)
}
@Test("An unreachable server keeps the token and offers a retry")
func unreachableServerOffersRetry() async {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession(phase: .verifying, preferences: preferences) {
throw URLError(.cannotConnectToHost)
}
await session.verifyStoredToken()
guard case .unreachable = session.phase else {
Issue.record("Expected .unreachable, got \(session.phase)")
return
}
#expect(session.failureMessage != nil)
#expect(preferences[.accessToken] == "tok")
}
@Test("Retrying after an outage re-runs verification")
func retryReRunsVerification() async {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let callCount = Mutex(0)
let session = LaunchSession(phase: .verifying, preferences: preferences) {
let attempt = callCount.withLock { count -> Int in
count += 1
return count
}
if attempt == 1 { throw URLError(.cannotConnectToHost) }
}
await session.verifyStoredToken()
guard case .unreachable = session.phase else {
Issue.record("Expected .unreachable after the first attempt, got \(session.phase)")
return
}
await session.retry()
#expect(session.phase == .resolved)
#expect(preferences[.accessToken] == "tok")
}
@Test("Signing out from the retry screen clears the token")
func signOutFromRetryScreenClearsToken() async {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession(phase: .verifying, preferences: preferences) {
throw URLError(.cannotConnectToHost)
}
await session.verifyStoredToken()
guard case .unreachable = session.phase else {
Issue.record("Expected .unreachable, got \(session.phase)")
return
}
session.signOut()
#expect(session.phase == .resolved)
#expect(preferences[.accessToken] == nil)
}
@Test("Launching with no stored token skips the probe entirely")
func launchWithNoStoredTokenSkipsProbe() {
let preferences = Preferences(store: EphemeralPreferenceStore())
let callCount = Mutex(0)
let session = LaunchSession.launch(preferences: preferences) {
callCount.withLock { $0 += 1 }
}
#expect(session.phase == .resolved)
#expect(callCount.withLock { $0 } == 0)
}
@Test("Launching with a valid token resolves synchronously")
func launchWithValidTokenResolves() {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession.launch(preferences: preferences) {}
#expect(session.phase == .resolved)
#expect(preferences[.accessToken] == "tok")
}
@Test("Launching with a rejected token clears it synchronously")
func launchWithRejectedTokenClearsIt() {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession.launch(preferences: preferences) {
throw JellyfinClientError.unauthorized
}
#expect(session.phase == .resolved)
#expect(preferences[.accessToken] == nil)
}
@Test("Launching against an unreachable server resolves to a retry phase")
func launchAgainstUnreachableServerResolvesToRetry() {
let preferences = Preferences(store: EphemeralPreferenceStore())
preferences[.accessToken] = "tok"
let session = LaunchSession.launch(preferences: preferences) {
throw URLError(.cannotConnectToHost)
}
guard case .unreachable = session.phase else {
Issue.record("Expected .unreachable, got \(session.phase)")
return
}
#expect(preferences[.accessToken] == "tok")
}
}

View file

@ -33,6 +33,7 @@ private final class StubJellyfinService: JellyfinService {
func publicServerInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() } func publicServerInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() }
func serverInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() } func serverInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() }
func publicUsers() async throws -> [JellyfinUser] { [] } func publicUsers() async throws -> [JellyfinUser] { [] }
func currentUser() async throws -> JellyfinUser { JellyfinUser() }
func authenticate(username: String, password: String) async throws -> JellyfinAuthentication { func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
JellyfinAuthentication(accessToken: "stub") JellyfinAuthentication(accessToken: "stub")
} }

View file

@ -36,6 +36,26 @@ private struct PreferenceLabel: View {
} }
} }
/// Records each value a tracked closure observes, so a test can assert re-evaluation.
private final class SessionRecorder {
var values: [Bool] = []
}
/// Captures the projected binding of a mounted `@Preference`, so a test can act on it after mount.
@MainActor private final class BindingCapture {
var binding: Portico.Binding<URL?>?
}
private struct BindingProbeView: View {
@Preference(.serverURL) private var serverURL: URL?
let capture: BindingCapture
var body: some View {
capture.binding = $serverURL
return Label(str: "").label { serverURL?.absoluteString ?? "" }
}
}
/// Exercises preference mounting, observation, and persistence wiring. /// Exercises preference mounting, observation, and persistence wiring.
@Suite(.serialized) @MainActor struct PreferenceTests { @Suite(.serialized) @MainActor struct PreferenceTests {
@Test("An unset preference reads nil at mount") @Test("An unset preference reads nil at mount")
@ -65,7 +85,7 @@ private struct PreferenceLabel: View {
} }
@Test("Writing a preference updates the same mounted label") @Test("Writing a preference updates the same mounted label")
func liveUpdate() { func liveUpdate() async {
guard Gtk.initCheck() else { return } guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore()) let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext() let context = MountContext()
@ -76,7 +96,7 @@ private struct PreferenceLabel: View {
let pointer = label.pointer let pointer = label.pointer
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096") preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
pump { await asyncPump {
label.getText() == "http://jellyfin.test:8096" label.getText() == "http://jellyfin.test:8096"
} }
@ -122,15 +142,95 @@ private struct PreferenceLabel: View {
#expect(await store.storedValues["server.url"] == nil) #expect(await store.storedValues["server.url"] == nil)
} }
private func pump(until condition: () -> Bool = { false }, turns: Int = 200) { @Test("A stored token reports an authenticated coordinator")
func authenticatedWithStoredToken() {
let store = EphemeralPreferenceStore(initialSnapshot: ["auth.accessToken": .text("tok")])
#expect(Preferences(store: store).isAuthenticated)
}
@Test("A missing or empty token reports an unauthenticated coordinator")
func unauthenticatedWithoutToken() {
let preferences = Preferences(store: EphemeralPreferenceStore())
#expect(preferences.isAuthenticated == false)
preferences.slot(for: .accessToken).value = ""
#expect(preferences.isAuthenticated == false)
}
@Test("Writing the access token re-evaluates a tracker that read the session state")
func authenticationDrivesTrackedReevaluation() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let recorder = SessionRecorder()
let tracker = DependencyTracker { recorder.values.append(preferences.isAuthenticated) }
tracker.run()
defer { tracker.teardown() }
preferences.slot(for: .accessToken).value = "tok"
await asyncPump { recorder.values.count > 1 }
#expect(recorder.values == [false, true])
}
@Test("A binding writes through to the preference slot")
func bindingWritesThrough() async throws {
let store = EphemeralPreferenceStore()
let preferences = Preferences(store: store)
let binding = preferences.binding(for: .serverURL)
binding.wrappedValue = URL(string: "http://jellyfin.test:8096")
#expect(preferences[.serverURL] == URL(string: "http://jellyfin.test:8096"))
try await store.flush()
#expect(await store.storedValues["server.url"] == .text("http://jellyfin.test:8096"))
}
@Test("A stored value of the wrong type is treated as unset")
func restoredValueTypeMismatchIsIgnored() {
guard Gtk.initCheck() else { return }
let store = EphemeralPreferenceStore(initialSnapshot: ["server.url": .integer(1)])
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() == "No server configured")
}
@Test("The projected binding writes back through wrappedValue")
func projectedValueWritesThrough() throws {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let capture = BindingCapture()
let context = MountContext()
_ = AnyView(BindingProbeView(capture: capture).environment(\.preferences, preferences))
.makeWidget(context)
defer { context.registry.teardown() }
let binding = try #require(capture.binding)
binding.wrappedValue = URL(string: "http://projected.test")
#expect(preferences[.serverURL] == URL(string: "http://projected.test"))
}
/// Drives the GLib default main context, yielding each turn so the Observation bridge's
/// main-actor hop can run. swift-testing does not run `@MainActor` bodies on the process
/// initial thread, so `_porticoObservationDidChange` defers through a `Task` that only
/// runs at a suspension point; a non-suspending pump would starve it forever.
private func asyncPump(until condition: () -> Bool = { false }, turns: Int = 500) async {
for _ in 0..<turns { for _ in 0..<turns {
if condition() { return } if condition() { return }
_ = preference_g_main_context_iteration(nil, 0) _ = preference_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
} }
} }
} }
// UPSTREAM: Portico should expose a main-loop pump for tests so suites do not need @_silgen_name. // UPSTREAM: Portico should expose an async main-loop pump for tests (e.g.
// `_porticoPumpMainContext(until:turns:)`) so suites do not need @_silgen_name and do not
// rediscover the sync-vs-async trap: a synchronous pump cannot observe Observation-driven
// updates because swift-testing never runs @MainActor bodies on the process initial thread.
@_silgen_name("g_main_context_iteration") @_silgen_name("g_main_context_iteration")
private nonisolated func preference_g_main_context_iteration( private nonisolated func preference_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ context: UnsafeMutableRawPointer?,