diff --git a/Package.swift b/Package.swift index 10b7e23..d2d852a 100644 --- a/Package.swift +++ b/Package.swift @@ -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"], diff --git a/Sources/Luminate/Luminate.swift b/Sources/Luminate/Luminate.swift index 23d6c30..df2f07a 100644 --- a/Sources/Luminate/Luminate.swift +++ b/Sources/Luminate/Luminate.swift @@ -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) } } } diff --git a/Sources/LuminateCore/Errors/JellyfinClientError.swift b/Sources/LuminateCore/Errors/JellyfinClientError.swift index 32449e2..79542c8 100644 --- a/Sources/LuminateCore/Errors/JellyfinClientError.swift +++ b/Sources/LuminateCore/Errors/JellyfinClientError.swift @@ -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 } diff --git a/Sources/LuminateCore/Errors/ServerAddressError.swift b/Sources/LuminateCore/Errors/ServerAddressError.swift new file mode 100644 index 0000000..f7ddc98 --- /dev/null +++ b/Sources/LuminateCore/Errors/ServerAddressError.swift @@ -0,0 +1,62 @@ +// +// ServerAddressError.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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" + } + } +} diff --git a/Sources/LuminateCore/Errors/ServerConnectionError.swift b/Sources/LuminateCore/Errors/ServerConnectionError.swift new file mode 100644 index 0000000..8cec20f --- /dev/null +++ b/Sources/LuminateCore/Errors/ServerConnectionError.swift @@ -0,0 +1,43 @@ +// +// ServerConnectionError.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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." + } + } +} diff --git a/Sources/LuminateCore/Models/JellyfinQuickConnect.swift b/Sources/LuminateCore/Models/JellyfinQuickConnect.swift new file mode 100644 index 0000000..7e32d32 --- /dev/null +++ b/Sources/LuminateCore/Models/JellyfinQuickConnect.swift @@ -0,0 +1,129 @@ +// +// JellyfinQuickConnect.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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 { + 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.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.. +// +// 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 . +// +// 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 +} \ No newline at end of file diff --git a/Sources/LuminateCore/Models/ServerAddress.swift b/Sources/LuminateCore/Models/ServerAddress.swift new file mode 100644 index 0000000..2dc292c --- /dev/null +++ b/Sources/LuminateCore/Models/ServerAddress.swift @@ -0,0 +1,139 @@ +// +// ServerAddress.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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 + } +} diff --git a/Sources/LuminateCore/Models/ServerConnection.swift b/Sources/LuminateCore/Models/ServerConnection.swift new file mode 100644 index 0000000..ffe9292 --- /dev/null +++ b/Sources/LuminateCore/Models/ServerConnection.swift @@ -0,0 +1,50 @@ +// +// ServerConnection.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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." + } +} diff --git a/Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift b/Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift new file mode 100644 index 0000000..9ee3715 --- /dev/null +++ b/Sources/LuminateCore/Protocols/JellyfinService+QuickConnect.swift @@ -0,0 +1,28 @@ +// +// JellyfinService+QuickConnect.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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) + } +} diff --git a/Sources/LuminateOnboarding/ForgotPasswordPage.swift b/Sources/LuminateOnboarding/ForgotPasswordPage.swift new file mode 100644 index 0000000..e8b0693 --- /dev/null +++ b/Sources/LuminateOnboarding/ForgotPasswordPage.swift @@ -0,0 +1,76 @@ +// +// ForgotPasswordPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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")) + } +} diff --git a/Sources/LuminateOnboarding/IntroPage.swift b/Sources/LuminateOnboarding/IntroPage.swift new file mode 100644 index 0000000..d9efb7c --- /dev/null +++ b/Sources/LuminateOnboarding/IntroPage.swift @@ -0,0 +1,38 @@ +// +// IntroPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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) + } +} diff --git a/Sources/LuminateOnboarding/LoginPage.swift b/Sources/LuminateOnboarding/LoginPage.swift new file mode 100644 index 0000000..4e9f0d1 --- /dev/null +++ b/Sources/LuminateOnboarding/LoginPage.swift @@ -0,0 +1,135 @@ +// +// LoginPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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)") + } + } +} diff --git a/Sources/LuminateOnboarding/ManualLoginPage.swift b/Sources/LuminateOnboarding/ManualLoginPage.swift new file mode 100644 index 0000000..8b71be9 --- /dev/null +++ b/Sources/LuminateOnboarding/ManualLoginPage.swift @@ -0,0 +1,97 @@ +// +// ManualLoginPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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)") + } + } +} diff --git a/Sources/LuminateOnboarding/OnboardWindow.swift b/Sources/LuminateOnboarding/OnboardWindow.swift new file mode 100644 index 0000000..3a36afc --- /dev/null +++ b/Sources/LuminateOnboarding/OnboardWindow.swift @@ -0,0 +1,65 @@ +// +// OnboardWindow.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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() + } + } + } + } +} diff --git a/Sources/LuminateOnboarding/QuickConnectPage.swift b/Sources/LuminateOnboarding/QuickConnectPage.swift new file mode 100644 index 0000000..18b6938 --- /dev/null +++ b/Sources/LuminateOnboarding/QuickConnectPage.swift @@ -0,0 +1,154 @@ +// +// QuickConnectPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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 +} \ No newline at end of file diff --git a/Sources/LuminateOnboarding/ServerConfigPage.swift b/Sources/LuminateOnboarding/ServerConfigPage.swift new file mode 100644 index 0000000..e3f84a1 --- /dev/null +++ b/Sources/LuminateOnboarding/ServerConfigPage.swift @@ -0,0 +1,158 @@ +// +// ServerConfigPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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))) + } + } + } +} diff --git a/Sources/LuminateOnboarding/UserLoginPage.swift b/Sources/LuminateOnboarding/UserLoginPage.swift new file mode 100644 index 0000000..ca20309 --- /dev/null +++ b/Sources/LuminateOnboarding/UserLoginPage.swift @@ -0,0 +1,105 @@ +// +// UserLoginPage.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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)") + } + } +} diff --git a/Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift b/Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift new file mode 100644 index 0000000..8267dd7 --- /dev/null +++ b/Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift @@ -0,0 +1,48 @@ +// +// HTTPSDowngradeGuard.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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) + } +} diff --git a/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift b/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift new file mode 100644 index 0000000..3b40d6c --- /dev/null +++ b/Sources/LuminateServices/Discovery/ServerConnectionProbe.swift @@ -0,0 +1,98 @@ +// +// ServerConnectionProbe.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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 +} diff --git a/Sources/LuminateServices/Session/JellyfinClient.swift b/Sources/LuminateServices/Session/JellyfinClient.swift index 63f8eb5..3fb9744 100644 --- a/Sources/LuminateServices/Session/JellyfinClient.swift +++ b/Sources/LuminateServices/Session/JellyfinClient.swift @@ -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( diff --git a/Sources/LuminateUI/Preferences/Preferences+Session.swift b/Sources/LuminateUI/Preferences/Preferences+Session.swift new file mode 100644 index 0000000..e8974c3 --- /dev/null +++ b/Sources/LuminateUI/Preferences/Preferences+Session.swift @@ -0,0 +1,32 @@ +// +// Preferences+Session.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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 } +} diff --git a/Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift b/Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift new file mode 100644 index 0000000..905a90c --- /dev/null +++ b/Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift @@ -0,0 +1,133 @@ +// +// JellyfinQuickConnectTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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] + private(set) var callCount = 0 + + init(_ queue: [Result]) { + self.queue = queue + } + + func next() throws -> JellyfinQuickConnectState { + callCount += 1 + guard !queue.isEmpty else { throw JellyfinClientError.notFound } + return try queue.removeFirst().get() + } +} diff --git a/Tests/LuminateCoreTests/JellyfinServiceMock.swift b/Tests/LuminateCoreTests/JellyfinServiceMock.swift new file mode 100644 index 0000000..9e48403 --- /dev/null +++ b/Tests/LuminateCoreTests/JellyfinServiceMock.swift @@ -0,0 +1,233 @@ +// +// JellyfinServiceMock.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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?, _ 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) + } +} diff --git a/Tests/LuminateCoreTests/ServerAddressTests.swift b/Tests/LuminateCoreTests/ServerAddressTests.swift new file mode 100644 index 0000000..6cc9dd0 --- /dev/null +++ b/Tests/LuminateCoreTests/ServerAddressTests.swift @@ -0,0 +1,169 @@ +// +// ServerAddressTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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.") + } +} diff --git a/Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift b/Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift new file mode 100644 index 0000000..0bfbf81 --- /dev/null +++ b/Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift @@ -0,0 +1,46 @@ +// +// ServerConnectionSummaryTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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.") + } +} diff --git a/Tests/LuminateOnboardingTests/OnboardWindowTests.swift b/Tests/LuminateOnboardingTests/OnboardWindowTests.swift new file mode 100644 index 0000000..8fa6597 --- /dev/null +++ b/Tests/LuminateOnboardingTests/OnboardWindowTests.swift @@ -0,0 +1,33 @@ +// +// OnboardWindowTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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() + } +} diff --git a/Tests/LuminateOnboardingTests/ServerConfigPageTests.swift b/Tests/LuminateOnboardingTests/ServerConfigPageTests.swift new file mode 100644 index 0000000..d38d86e --- /dev/null +++ b/Tests/LuminateOnboardingTests/ServerConfigPageTests.swift @@ -0,0 +1,41 @@ +// +// ServerConfigPageTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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() + } +} diff --git a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift index 4a83355..b9279c4 100644 --- a/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift +++ b/Tests/LuminateServicesTests/JellyfinClientAuthTests.swift @@ -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 { diff --git a/Tests/LuminateServicesTests/ServerConnectionProbeTests.swift b/Tests/LuminateServicesTests/ServerConnectionProbeTests.swift new file mode 100644 index 0000000..8fa75c4 --- /dev/null +++ b/Tests/LuminateServicesTests/ServerConnectionProbeTests.swift @@ -0,0 +1,162 @@ +// +// ServerConnectionProbeTests.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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) + } +} diff --git a/Tests/LuminateUITests/PreferenceTests.swift b/Tests/LuminateUITests/PreferenceTests.swift index 13664ab..7e4fcbb 100644 --- a/Tests/LuminateUITests/PreferenceTests.swift +++ b/Tests/LuminateUITests/PreferenceTests.swift @@ -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..