Initial onboarding flow

This commit is contained in:
Brendan Szymanski 2026-08-12 14:40:47 -04:00
parent f74cb400f5
commit b852979215
31 changed files with 2462 additions and 7 deletions

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"],

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

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

@ -0,0 +1,169 @@
//
// 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("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,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

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

@ -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 {

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

@ -36,6 +36,11 @@ 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] = []
}
/// Exercises preference mounting, observation, and persistence wiring.
@Suite(.serialized) @MainActor struct PreferenceTests {
@Test("An unset preference reads nil at mount")
@ -122,6 +127,35 @@ private struct PreferenceLabel: View {
#expect(await store.storedValues["server.url"] == nil)
}
@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() {
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"
pump { recorder.values.count > 1 }
#expect(recorder.values == [false, true])
}
private func pump(until condition: () -> Bool = { false }, turns: Int = 200) {
for _ in 0..<turns {
if condition() { return }