Compare commits

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

2 commits

Author SHA1 Message Date
a96d43a603 Fix unit tests + expand coverage 2026-08-12 15:44:08 -04:00
b852979215 Initial onboarding flow 2026-08-12 14:40:47 -04:00
45 changed files with 3875 additions and 36 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

@ -21,6 +21,7 @@
import Foundation
import LuminateCore
import LuminateOnboarding
import LuminateServices
import LuminateStore
import LuminateUI
@ -29,30 +30,39 @@ import Portico
/// 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(_:)`. The stored access token is the only
/// login state, and writing it swaps the onboarding window for the main window.
@main
struct Luminate: App {
var applicationId: String? { AppInfo.identifier }
private let jellyfin: JellyfinClient
private let jellyfinClient: JellyfinClient
private let sessionBinder: ClientSessionBinder
init() {
LogBootstrap.bootstrap()
Preferences.bootstrap(PreferenceBackend.open())
jellyfin = JellyfinClient(
jellyfinClient = JellyfinClient(
configuration: JellyfinClientConfiguration(
serverURL: Preferences.shared[.serverURL],
accessToken: Preferences.shared[.accessToken]
)
)
sessionBinder = ClientSessionBinder(client: jellyfin)
sessionBinder = ClientSessionBinder(client: jellyfinClient)
}
var body: some Scene {
ApplicationWindow { _ in
RootView()
.environment(\.client, jellyfin)
if Preferences.shared.isAuthenticated {
ApplicationWindow { _ in
RootView()
.environment(\.client, jellyfinClient)
}
} else {
ApplicationWindow { _ in
OnboardWindow()
.environment(\.client, jellyfinClient)
}
.defaultSize(width: 800, height: 600)
}
}
}

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 paths that can be appended to `OnboardWindow`'s `onboardPath`
///
/// Appending a specific enum type will present its cooresponding page
/// as the top-most view in the navigation stack.
package enum OnboardDestination: Hashable, Sendable {
case login
case userLoginPage(user: JellyfinUser)
case manualLoginPage
case quickConnectPage
case forgotPasswordPage
}

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
)
}
}
private extension URL {
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

@ -0,0 +1,76 @@
//
// 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 Foundation
import Logging
import LuminateCore
import LuminateUI
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,38 @@
//
// 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 {
package init() {}
package var body: some View {
Clamp {
StatusPage()
.title("Welcome to Luminate!")
.description("Luminate is a modern Jellyfin client for the Linux desktop. Continue to the next page to connect to a server")
.iconName("person-symbolic")
}
.hexpand(true)
.vexpand(true)
}
}

View file

@ -0,0 +1,135 @@
//
// 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 Foundation
import Logging
import LuminateCore
import LuminateServices
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
private var onboardPath: Binding<[OnboardDestination]>
package init(_ onboardPath: Binding<[OnboardDestination]>) {
self.onboardPath = onboardPath
}
package var body: some View {
ToolbarView {
BreakpointBin {
Clamp {
EitherView($isLoading) {
Spinner()
} second: {
EitherView($showPublicUsers) {
publicUsersView
} second: {
Label("No users... :-(")
}
}
.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: "right-symbolic") {
onboardPath.wrappedValue.append(.manualLoginPage)
}
ButtonRow("Quick Connect", endIconName: "right-symbolic") {
onboardPath.wrappedValue.append(.quickConnectPage)
}
ButtonRow("Forgot Password", endIconName: "right-symbolic") {
onboardPath.wrappedValue.append(.forgotPasswordPage)
}
}
.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(.userLoginPage(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,97 @@
//
// 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 Foundation
import Logging
import LuminateCore
import LuminateServices
import LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The manual sign-in page for a private 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 ManualLoginPage: View {
@Environment(\.client) private var client
@State private var isLoading = false
@State private var username: String = ""
@State private var password: String = ""
@Preference(.accessToken) private var accessToken: String?
package init() {}
package var body: some View {
ToolbarView {
Clamp {
EitherView($isLoading) {
Spinner()
} second: {
StatusPage("Let's get to know each other", description: "Enter Jellyfin account information to continue", iconName: "people-symbolic") {
VStack(spacing: 12) {
PreferencesGroup {
EntryRow("Username", text: $username)
PasswordEntryRow("Password", text: $password)
}
PreferencesGroup {
ButtonRow("Sign In") {
Task { await signIn() }
}
.suggestedAction()
}
}
}
}
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle("Sign In")
}
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,65 @@
//
// 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 Adw
import LuminateCore
import LuminateUI
import Portico
/// The root view for the unauthenticated onboarding window.
///
/// Hosts the intro/server-setup carousel and the `.login`-rooted navigation stack (public-user
/// picker, manual sign-in, Quick Connect, forgot password) that follows a successful server
/// connection.
package struct OnboardWindow: View {
@State private var onboardPath: [OnboardDestination] = []
@WidgetRef private var carousel: Adw.Carousel?
package init() {}
package var body: some View {
NavigationView(path: $onboardPath) {
ToolbarView {
Carousel {
IntroPage()
ServerConfigPage($onboardPath)
}
.ref(_carousel)
} top: {
HeaderBar {
CarouselIndicatorDots()
.carousel($carousel)
}
}
.navigationTitle("Server Setup")
.navigationDestination(for: OnboardDestination.self) { destination in
switch destination {
case .login: LoginPage($onboardPath)
case .userLoginPage(let user): UserLoginPage(user)
case .manualLoginPage: ManualLoginPage()
case .quickConnectPage: QuickConnectPage()
case .forgotPasswordPage: ForgotPasswordPage()
}
}
}
}
}

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

View file

@ -0,0 +1,158 @@
//
// ServerConfigPage.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 ServerConfigPage: 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?
private var onboardPath: Binding<[OnboardDestination]>
private let probe = ServerConnectionProbe()
private let toasts = ToastManager()
package init(_ onboardPath: Binding<[OnboardDestination]>) {
self.onboardPath = onboardPath
}
package var body: some View {
ToastOverlay {
Clamp {
StatusPage {
VStack(spacing: 8) {
PreferencesGroup {
EntryRow()
.title("Server URL")
.text($serverAddress)
ExpanderRow()
.title("Advanced Options")
.addRow {
EntryRow()
.title("Port")
.text($port)
}
.addRow {
SwitchRow()
.title("Force HTTPS")
.subtitle("Enforces connection over HTTPS")
.active($forceHttps)
}
} headerSuffix: {
Button {
EitherView($isTesting) {
Spinner()
} second: {
Label("Test Connection")
}
} onClicked: {
connect(persisting: false)
}
.sensitive { !isTesting }
}
Button {
EitherView($isConnecting) {
Spinner()
} second: {
Label("Connect")
}
} onClicked: {
connect(persisting: true)
}
.sensitive { !isConnecting }
.suggestedAction()
}
}
.title("Let's get started")
.description("Enter your Jellyfin server information")
}
}
.toastManager(toasts)
.hexpand(true)
.vexpand(true)
}
/// 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,105 @@
//
// 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 Foundation
import Logging
import LuminateCore
import LuminateServices
import LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The password sign-in page for one public user.
///
/// A successful sign-in stores the access token in the `.accessToken` preference. That write is the
/// only login-state mutation in the app: the application's scene conditional observes the same
/// slot and replaces the onboarding window with the main window.
package struct UserLoginPage: View {
@Environment(\.client) private var client
@State private var isLoading = false
@State private var password: String = ""
@Preference(.accessToken) private var accessToken: String?
private let user: JellyfinUser
package init(_ user: JellyfinUser) {
self.user = user
}
package var body: some View {
ToolbarView {
Clamp {
EitherView($isLoading) {
Spinner()
} second: {
VStack(spacing: 32) {
Avatar(size: 128, text: user.name, showInitials: true)
PreferencesGroup {
PasswordEntryRow("Password", text: $password)
ButtonRow("Sign In") {
Task { await signIn() }
}
.suggestedAction()
}
.separateRows(true)
}
}
.margin(12)
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle {
if let userName = user.name {
"Sign In as \(userName)"
} else {
"Sign In"
}
}
}
private func signIn() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let authentication = try await client.authenticate(
username: user.name ?? "",
password: password
)
let authenticatedUser = authentication.user?.name ?? user.name ?? "unknown user"
logger.info("Authenticated as \(authenticatedUser)!")
accessToken = authentication.accessToken
} catch let error as JellyfinClientError {
logger.error("Server connection failure: \(error)")
} catch {
logger.error("Unexpected error occurred: \(error)")
}
}
}

View file

@ -0,0 +1,48 @@
//
// 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,98 @@
//
// 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
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import LuminateAPI
import LuminateCore
import OpenAPIRuntime
import OpenAPIURLSession
/// 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),
transport: 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

@ -77,6 +77,7 @@ package actor JellyfinClient: JellyfinService {
guard let serverURL = configuration.serverURL else { return nil }
return Client(
serverURL: serverURL,
configuration: .init(dateTranscoder: .iso8601WithFractionalSeconds),
transport: transport,
middlewares: [
AuthenticationMiddleware(

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,253 @@
//
// 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,233 @@
//
// 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 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,
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.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 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,33 @@
//
// 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 Testing
@testable import LuminateOnboarding
/// Verifies the onboarding target exposes its root view.
@MainActor
struct OnboardWindowTests {
@Test("Creates the onboarding root view")
func createsRootView() {
_ = OnboardWindow()
}
}

View file

@ -0,0 +1,41 @@
//
// ServerConfigPageTests.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 server configuration page mounts with its live button modifiers.
@Suite(.serialized) @MainActor struct ServerConfigPageTests {
@Test("Mounts the server configuration page")
func mounts() {
guard Gtk.initCheck() else { return }
let preferences = Preferences(store: EphemeralPreferenceStore())
let context = MountContext()
@State var onboardPath: [OnboardDestination] = []
_ = AnyView(ServerConfigPage($onboardPath).environment(\.preferences, preferences)).makeWidget(context)
context.registry.teardown()
}
}

View file

@ -0,0 +1,80 @@
//
// 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
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing
@testable import LuminateServices
/// 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,67 @@ 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"
),
transport: 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("A successful sign-in maps the result and adopts the token")
func signInAdoptsToken() async throws {
@ -101,6 +181,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,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,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?
@ -115,116 +138,140 @@ 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 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,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

@ -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?,