Compare commits

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

4 commits

62 changed files with 4963 additions and 39 deletions

3
.gitignore vendored
View file

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

View file

@ -96,6 +96,16 @@ let package = Package(
dependencies: uiDeps,
swiftSettings: uiSwiftSettings
),
.target(
name: "LuminateOnboarding",
dependencies: [
"LuminateCore",
"LuminateServices",
"LuminateUI",
.product(name: "Portico", package: "portico"),
],
swiftSettings: uiSwiftSettings
),
.executableTarget(
name: "Luminate",
dependencies: [
@ -103,11 +113,22 @@ let package = Package(
"LuminateStore",
"LuminateServices",
"LuminateUI",
"LuminateOnboarding",
.product(name: "Portico", package: "portico"),
.product(name: "Logging", package: "swift-log"),
],
swiftSettings: uiSwiftSettings
),
.testTarget(
name: "LuminateOnboardingTests",
dependencies: [
"LuminateCore",
"LuminateServices",
"LuminateUI",
"LuminateOnboarding",
],
swiftSettings: uiSwiftSettings
),
.testTarget(
name: "LuminateCoreTests",
dependencies: ["LuminateCore"],
@ -130,7 +151,7 @@ let package = Package(
),
.testTarget(
name: "LuminateTests",
dependencies: ["LuminateCore", "LuminateStore", "LuminateUI"],
dependencies: ["Luminate", "LuminateCore", "LuminateServices", "LuminateStore", "LuminateUI"],
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) {
if session.phase == .verifying {
VStack(spacing: 12) {
Spinner()
Label("Checking your saved session...")
}
} else {
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 }
}
}
}
.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 Logging
import LuminateCore
import LuminateOnboarding
import LuminateServices
import LuminateStore
import LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).launch")
/// The Luminate application entry point.
///
/// 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
struct Luminate: App {
var applicationId: String? { AppInfo.identifier }
private let jellyfin: JellyfinClient
private let jellyfinClient: JellyfinClient
private let sessionBinder: ClientSessionBinder
private let launchSession: LaunchSession
init() {
LogBootstrap.bootstrap()
Preferences.bootstrap(PreferenceBackend.open())
jellyfin = JellyfinClient(
let client = JellyfinClient(
configuration: JellyfinClientConfiguration(
serverURL: Preferences.shared[.serverURL],
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 {
ApplicationWindow { _ in
RootView()
.environment(\.client, jellyfin)
// `launchSession.phase` is never `.verifying` on this first evaluation: the probe already
// ran, synchronously, inside `init()`. This branch is only ever taken for `.unreachable`.
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
# Auth
- GetPublicUsers
- GetCurrentUser
- AuthenticateUserByName
- UpdateUserPassword
- GetQuickConnectEnabled

View file

@ -58,4 +58,7 @@ package enum JellyfinClientError: Error, Hashable, Sendable {
///
/// - Parameter operation: The Jellyfin operation identifier, such as `AuthenticateUserByName`.
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.
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.
///
/// 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
}
/// - Throws: Always ``JellyfinClientError/notConfigured``.
package func currentUser() async throws -> JellyfinUser {
throw JellyfinClientError.notConfigured
}
/// - Throws: Always ``JellyfinClientError/notConfigured``.
package func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
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,132 @@
//
// 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 {
if isLoading {
Spinner()
} else {
Bin {
if showPublicUsers {
publicUsersView
} else {
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 {
if isLoading {
Spinner()
} else {
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) {
if isLoading {
Spinner()
} else {
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 {
if isTesting {
Spinner()
} else {
Label("Test Connection")
}
} onClicked: {
connect(persisting: false)
}
.sensitive { !isTesting }
}
Button {
if isConnecting {
Spinner()
} else {
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 {
if isLoading {
Spinner()
} else {
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.
///
/// 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.
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.
///
/// - Parameter input: The generated `GetPublicSystemInfo` input.

View file

@ -25,6 +25,10 @@ import LuminateCore
import OpenAPIRuntime
import OpenAPIURLSession
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// 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
@ -68,16 +72,21 @@ package actor JellyfinClient: JellyfinService {
///
/// - Parameters:
/// - 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(
configuration: JellyfinClientConfiguration,
transport: any ClientTransport = URLSessionTransport()
makeTransport: @escaping @Sendable () -> any ClientTransport = JellyfinClient.defaultTransport
) {
let makeAPI: @Sendable (JellyfinClientConfiguration) -> (any JellyfinAPI)? = { configuration in
guard let serverURL = configuration.serverURL else { return nil }
return Client(
serverURL: serverURL,
transport: transport,
configuration: .init(dateTranscoder: .iso8601WithFractionalSeconds),
transport: makeTransport(),
middlewares: [
AuthenticationMiddleware(
clientName: configuration.clientName,
@ -94,6 +103,20 @@ package actor JellyfinClient: JellyfinService {
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.
///
/// The supplied implementation is reused across configuration changes, so a test can observe
@ -144,6 +167,20 @@ package actor JellyfinClient: JellyfinService {
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.
///
/// 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 {
/// The decoded body, whichever JSON profile the server negotiated.
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")
}
@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
//
import Foundation
import Testing
@testable import LuminateCore
@ -42,4 +43,46 @@ import Testing
#expect(Double.nan.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()
}
}
@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 HTTPTypes
import LuminateAPI
import LuminateCore
import OpenAPIRuntime
import Testing
@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.
@Suite struct JellyfinClientAuthTests {
/// Builds a client wired to a double, pointed at a URL that is never dialled.
@ -42,6 +61,92 @@ import Testing
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")
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")
func clearingTokenSignsOut() async {
let client = makeClient(MockJellyfinAPI())

View file

@ -146,4 +146,171 @@ import Testing
_ = 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 LuminateServices
import Synchronization
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.
///
/// 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,
/// so the whole double is a `Sendable` struct with no isolation escape hatches.
struct MockJellyfinAPI: JellyfinAPI {
/// Records the input each operation method received, keyed by operation identifier.
let inputs = OperationInputLog()
/// The output returned by ``authenticateUserByName(_:)``.
var authenticateUserByNameOutput: Operations.AuthenticateUserByName.Output?
@ -38,6 +61,9 @@ struct MockJellyfinAPI: JellyfinAPI {
/// The output returned by ``getPublicUsers(_:)``.
var getPublicUsersOutput: Operations.GetPublicUsers.Output?
/// The output returned by ``getCurrentUser(_:)``.
var getCurrentUserOutput: Operations.GetCurrentUser.Output?
/// The output returned by ``getPublicSystemInfo(_:)``.
var getPublicSystemInfoOutput: Operations.GetPublicSystemInfo.Output?
@ -115,116 +141,145 @@ struct MockJellyfinAPI: JellyfinAPI {
func authenticateUserByName(_ input: Operations.AuthenticateUserByName.Input) async throws
-> 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
-> 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 {
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
-> 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 {
try unwrap(getSystemInfoOutput, "GetSystemInfo")
inputs.record(input, for: "GetSystemInfo")
return try unwrap(getSystemInfoOutput, "GetSystemInfo")
}
func getQuickConnectEnabled(_ input: Operations.GetQuickConnectEnabled.Input) async throws
-> 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
-> 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
-> 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
-> 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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
try unwrap(getItemImageOutput, "GetItemImage")
inputs.record(input, for: "GetItemImage")
return try unwrap(getItemImageOutput, "GetItemImage")
}
func getItemImageByIndex(_ input: Operations.GetItemImageByIndex.Input) async throws
-> 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 {
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
{
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
{
try unwrap(markFavoriteItemOutput, "MarkFavoriteItem")
inputs.record(input, for: "MarkFavoriteItem")
return try unwrap(markFavoriteItemOutput, "MarkFavoriteItem")
}
func unmarkFavoriteItem(_ input: Operations.UnmarkFavoriteItem.Input) async throws
-> 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"))
}
@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) {
let directory = FileManager.default.temporaryDirectory
.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 serverInfo() async throws -> JellyfinServerInfo { JellyfinServerInfo() }
func publicUsers() async throws -> [JellyfinUser] { [] }
func currentUser() async throws -> JellyfinUser { JellyfinUser() }
func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
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.
@Suite(.serialized) @MainActor struct PreferenceTests {
@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")
func liveUpdate() {
func liveUpdate() async {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
@ -76,7 +96,7 @@ private struct PreferenceLabel: View {
let pointer = label.pointer
preferences.slot(for: .serverURL).value = URL(string: "http://jellyfin.test:8096")
pump {
await asyncPump {
label.getText() == "http://jellyfin.test:8096"
}
@ -122,15 +142,95 @@ private struct PreferenceLabel: View {
#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 {
if condition() { return }
_ = 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")
private nonisolated func preference_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?,