Initial onboarding flow
This commit is contained in:
parent
f74cb400f5
commit
b852979215
31 changed files with 2462 additions and 7 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
62
Sources/LuminateCore/Errors/ServerAddressError.swift
Normal file
62
Sources/LuminateCore/Errors/ServerAddressError.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// ServerAddressError.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
/// A validation failure while converting onboarding input into a server URL.
|
||||
package enum ServerAddressError: Error, Hashable, Sendable {
|
||||
/// The address field contained no non-whitespace characters.
|
||||
case emptyAddress
|
||||
/// The address used a URL scheme other than HTTP or HTTPS.
|
||||
case unsupportedScheme(String)
|
||||
/// Foundation could not parse the supplied address as URL components.
|
||||
case malformedAddress
|
||||
/// The parsed address did not contain a host name or IP address.
|
||||
case missingHost
|
||||
/// The address included credentials, which belong on the sign-in screen.
|
||||
case credentialsNotAllowed
|
||||
/// The address included a query string or fragment, which is not part of a server base URL.
|
||||
case queryOrFragmentNotAllowed
|
||||
/// The supplied port text was not an integer from 1 through 65535.
|
||||
case invalidPort(String)
|
||||
/// Both the address and the separate port field supplied a port.
|
||||
case conflictingPorts
|
||||
|
||||
/// The user-facing explanation for this validation failure.
|
||||
package var message: String {
|
||||
switch self {
|
||||
case .emptyAddress:
|
||||
"Enter a server address"
|
||||
case .unsupportedScheme(let scheme):
|
||||
"\(scheme) is not supported. Use http or https"
|
||||
case .malformedAddress:
|
||||
"That server address could not be understood"
|
||||
case .missingHost:
|
||||
"The server address must contain a host name or IP address"
|
||||
case .credentialsNotAllowed:
|
||||
"Remove the username and password from the server address; sign in on the next screen instead"
|
||||
case .queryOrFragmentNotAllowed:
|
||||
"The server address cannot contain a query string or fragment"
|
||||
case .invalidPort(let text):
|
||||
"\(text) is not a valid port. Enter a number from 1 to 65535"
|
||||
case .conflictingPorts:
|
||||
"Specify the port in the address or in the port field, not both"
|
||||
}
|
||||
}
|
||||
}
|
||||
43
Sources/LuminateCore/Errors/ServerConnectionError.swift
Normal file
43
Sources/LuminateCore/Errors/ServerConnectionError.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// ServerConnectionError.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
import Foundation
|
||||
|
||||
/// A failure while probing a normalized server address.
|
||||
package enum ServerConnectionError: Error, Hashable, Sendable {
|
||||
/// Every candidate failed at the transport layer.
|
||||
case unreachable(attempted: [URL])
|
||||
/// A candidate answered, but its response was not accepted as a Jellyfin response.
|
||||
case rejected(url: URL, detail: String)
|
||||
/// A reachable server identified itself as a different product.
|
||||
case notJellyfin(url: URL, productName: String?)
|
||||
|
||||
/// The user-facing explanation for this connection failure.
|
||||
package var message: String {
|
||||
switch self {
|
||||
case .unreachable(let attempted):
|
||||
"Could not reach a server at \(attempted.map(\.absoluteString).joined(separator: " or "))."
|
||||
case .rejected(let url, let detail):
|
||||
"\(url.absoluteString) answered, but not as a Jellyfin server: \(detail)"
|
||||
case .notJellyfin(let url, let productName):
|
||||
"\(url.absoluteString) is running \(productName ?? "an unknown product"), not Jellyfin."
|
||||
}
|
||||
}
|
||||
}
|
||||
129
Sources/LuminateCore/Models/JellyfinQuickConnect.swift
Normal file
129
Sources/LuminateCore/Models/JellyfinQuickConnect.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//
|
||||
// JellyfinQuickConnect.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
/// Drives one Quick Connect pairing attempt as an async event stream.
|
||||
///
|
||||
/// Reach it through ``JellyfinService/quickConnect``. Iterating ``connect(pollInterval:maxPolls:)``
|
||||
/// initiates the request, yields the code to show the user, then polls the server on the caller's
|
||||
/// behalf until the request is approved from another signed-in client:
|
||||
///
|
||||
/// ```swift
|
||||
/// for try await event in client.quickConnect.connect() {
|
||||
/// switch event {
|
||||
/// case let .polling(code: code):
|
||||
/// showCode(code)
|
||||
/// case let .authenticated(secret: secret):
|
||||
/// let authentication = try await client.authenticateWithQuickConnect(secret: secret)
|
||||
/// accessToken = authentication.accessToken
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The polling loop is a plain `Task` owned by the stream, cancelled from
|
||||
/// `Continuation.onTermination` -- ending iteration (breaking out of the loop, or the enclosing
|
||||
/// `.task` being cancelled on unmount) stops the polling task with it. Nothing keeps requesting
|
||||
/// once nobody is listening.
|
||||
package struct JellyfinQuickConnect: Sendable {
|
||||
/// One step of a Quick Connect pairing attempt.
|
||||
package enum Event: Equatable, Sendable {
|
||||
/// The request was created; show the associated code to the user.
|
||||
case polling(code: String)
|
||||
/// The request was approved from another client; redeem the secret for a token via
|
||||
/// ``JellyfinService/authenticateWithQuickConnect(secret:)``.
|
||||
case authenticated(secret: String)
|
||||
}
|
||||
|
||||
private let service: any JellyfinService
|
||||
|
||||
/// Wraps a service for Quick Connect.
|
||||
///
|
||||
/// - Parameter service: The service to initiate and poll through. Use
|
||||
/// ``JellyfinService/quickConnect`` instead of calling this directly.
|
||||
init(service: any JellyfinService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// Starts a Quick Connect pairing attempt when iterated.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pollInterval: Time between polls. Defaults to five seconds, matching Jellyfin's web
|
||||
/// client.
|
||||
/// - maxPolls: The maximum number of polls before giving up. Defaults to 200, about sixteen
|
||||
/// minutes at the default interval.
|
||||
/// - Returns: A stream of one ``Event/polling(code:)`` followed by at most one
|
||||
/// ``Event/authenticated(secret:)``. The stream throws ``JellyfinClientError/notFound`` if
|
||||
/// the server expires the request first, or ``JellyfinClientError/quickConnectTimedOut`` if
|
||||
/// `maxPolls` is exhausted without an answer either way.
|
||||
package func connect(
|
||||
pollInterval: Duration = .seconds(5),
|
||||
maxPolls: Int = 200
|
||||
) -> AsyncThrowingStream<Event, Error> {
|
||||
precondition(pollInterval > .zero, "Poll interval must be positive")
|
||||
precondition(maxPolls > 0, "Maximum poll count must be positive")
|
||||
|
||||
return AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
try await run(pollInterval: pollInterval, maxPolls: maxPolls, into: continuation)
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Initiates the request, yields its code, then polls until it is approved or exhausted.
|
||||
///
|
||||
/// - Throws: ``JellyfinClientError/missingPayload(operation:)`` if the server omitted the
|
||||
/// secret or code, whatever ``JellyfinService/quickConnectState(secret:)`` throws (notably
|
||||
/// ``JellyfinClientError/notFound`` once the server expires the request), or
|
||||
/// ``JellyfinClientError/quickConnectTimedOut`` after `maxPolls` unanswered polls.
|
||||
private func run(
|
||||
pollInterval: Duration,
|
||||
maxPolls: Int,
|
||||
into continuation: AsyncThrowingStream<Event, Error>.Continuation
|
||||
) async throws {
|
||||
let state = try await service.initiateQuickConnect()
|
||||
guard let secret = state.secret, let code = state.code else {
|
||||
throw JellyfinClientError.missingPayload(operation: "InitiateQuickConnect")
|
||||
}
|
||||
|
||||
continuation.yield(.polling(code: code))
|
||||
|
||||
for _ in 0..<maxPolls {
|
||||
try Task.checkCancellation()
|
||||
|
||||
if try await service.quickConnectState(secret: secret).authenticated == true {
|
||||
continuation.yield(.authenticated(secret: secret))
|
||||
return
|
||||
}
|
||||
|
||||
try await Task.sleep(for: pollInterval)
|
||||
}
|
||||
|
||||
throw JellyfinClientError.quickConnectTimedOut
|
||||
}
|
||||
}
|
||||
32
Sources/LuminateCore/Models/OnboardDestination.swift
Normal file
32
Sources/LuminateCore/Models/OnboardDestination.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// OnboardDestination.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
/// The paths that can be appended to `OnboardWindow`'s `onboardPath`
|
||||
///
|
||||
/// Appending a specific enum type will present its cooresponding page
|
||||
/// as the top-most view in the navigation stack.
|
||||
package enum OnboardDestination: Hashable, Sendable {
|
||||
case login
|
||||
case userLoginPage(user: JellyfinUser)
|
||||
case manualLoginPage
|
||||
case quickConnectPage
|
||||
case forgotPasswordPage
|
||||
}
|
||||
139
Sources/LuminateCore/Models/ServerAddress.swift
Normal file
139
Sources/LuminateCore/Models/ServerAddress.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
//
|
||||
// ServerAddress.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Controls whether server discovery may try plaintext HTTP after HTTPS fails.
|
||||
package enum ServerSchemePolicy: Sendable, Hashable {
|
||||
/// Try HTTPS first and permit HTTP fallback only for an unprefixed address.
|
||||
case automatic
|
||||
/// Use HTTPS only, preserving any port supplied by the user.
|
||||
case httpsOnly
|
||||
}
|
||||
|
||||
/// A validated Jellyfin server base URL and its scheme provenance.
|
||||
package struct ServerAddress: Sendable, Hashable {
|
||||
/// The normalized base URL, including a trailing slash.
|
||||
package let baseURL: URL
|
||||
/// Whether the user supplied a scheme in the original address.
|
||||
package let schemeWasExplicit: Bool
|
||||
|
||||
private let allowsInsecureFallback: Bool
|
||||
|
||||
/// The candidates to probe, ordered from preferred to fallback URL.
|
||||
package var probeCandidates: [URL] {
|
||||
allowsInsecureFallback ? [baseURL, baseURL.withScheme("http")] : [baseURL]
|
||||
}
|
||||
|
||||
/// Normalizes free-text onboarding fields into a validated server address.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - address: A host name, IP address, or HTTP(S) URL, optionally with a path and port.
|
||||
/// - port: An optional separate TCP port field.
|
||||
/// - policy: The scheme policy selected by the user.
|
||||
/// - Returns: A validated server address whose base URL is ready for probing.
|
||||
/// - Throws: `ServerAddressError` when the input is empty, malformed, unsupported, or conflicting.
|
||||
package static func normalize(
|
||||
address rawAddress: String,
|
||||
port rawPort: String,
|
||||
policy: ServerSchemePolicy
|
||||
) throws(ServerAddressError) -> ServerAddress {
|
||||
let address = rawAddress.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let portText = rawPort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !address.isEmpty else {
|
||||
throw .emptyAddress
|
||||
}
|
||||
|
||||
let schemeWasExplicit =
|
||||
address.range(
|
||||
of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
let parseable = schemeWasExplicit ? address : "https://" + address
|
||||
guard var components = URLComponents(string: parseable) else {
|
||||
throw .malformedAddress
|
||||
}
|
||||
guard let parsedScheme = components.scheme?.lowercased() else {
|
||||
throw .malformedAddress
|
||||
}
|
||||
guard parsedScheme == "http" || parsedScheme == "https" else {
|
||||
throw .unsupportedScheme(parsedScheme)
|
||||
}
|
||||
guard let host = components.host, !host.isEmpty else {
|
||||
throw .missingHost
|
||||
}
|
||||
guard components.user == nil, components.password == nil else {
|
||||
throw .credentialsNotAllowed
|
||||
}
|
||||
guard components.query == nil, components.fragment == nil else {
|
||||
throw .queryOrFragmentNotAllowed
|
||||
}
|
||||
|
||||
let addressPort = components.port
|
||||
let enteredPort: Int?
|
||||
if portText.isEmpty {
|
||||
enteredPort = nil
|
||||
} else {
|
||||
guard let value = Int(portText), (1...65_535).contains(value) else {
|
||||
throw .invalidPort(portText)
|
||||
}
|
||||
enteredPort = value
|
||||
}
|
||||
if addressPort != nil && enteredPort != nil {
|
||||
throw .conflictingPorts
|
||||
}
|
||||
|
||||
var chosenPort = enteredPort ?? addressPort
|
||||
if enteredPort == nil,
|
||||
(parsedScheme == "http" && chosenPort == 80) || (parsedScheme == "https" && chosenPort == 443)
|
||||
{
|
||||
chosenPort = nil
|
||||
}
|
||||
components.scheme = policy == .httpsOnly ? "https" : parsedScheme
|
||||
components.port = chosenPort
|
||||
var path = components.path
|
||||
if path.isEmpty {
|
||||
path = "/"
|
||||
} else if !path.hasSuffix("/") {
|
||||
path += "/"
|
||||
}
|
||||
components.path = path
|
||||
guard let baseURL = components.url else {
|
||||
throw .malformedAddress
|
||||
}
|
||||
|
||||
return ServerAddress(
|
||||
baseURL: baseURL,
|
||||
schemeWasExplicit: schemeWasExplicit,
|
||||
allowsInsecureFallback: !schemeWasExplicit && policy == .automatic
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func withScheme(_ scheme: String) -> URL {
|
||||
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
|
||||
return self
|
||||
}
|
||||
components.scheme = scheme
|
||||
return components.url ?? self
|
||||
}
|
||||
}
|
||||
50
Sources/LuminateCore/Models/ServerConnection.swift
Normal file
50
Sources/LuminateCore/Models/ServerConnection.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// ServerConnection.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
import Foundation
|
||||
|
||||
/// A reachable Jellyfin server together with the URL that answered the probe.
|
||||
package struct ServerConnection: Sendable, Hashable {
|
||||
/// The validated base URL that successfully answered.
|
||||
package let url: URL
|
||||
/// The identity information returned by the server.
|
||||
package let serverInfo: JellyfinServerInfo
|
||||
|
||||
/// Creates a connection from a successful probe result.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: The validated base URL that answered.
|
||||
/// - serverInfo: The identity information returned by the server.
|
||||
package init(url: URL, serverInfo: JellyfinServerInfo) {
|
||||
self.url = url
|
||||
self.serverInfo = serverInfo
|
||||
}
|
||||
/// Whether the successful connection used HTTPS.
|
||||
package var isEncrypted: Bool { url.scheme?.lowercased() == "https" }
|
||||
|
||||
/// A concise success message suitable for the onboarding toast.
|
||||
package var summary: String {
|
||||
let name = serverInfo.serverName ?? "the server"
|
||||
if isEncrypted {
|
||||
return "Connected to \(name) over HTTPS."
|
||||
}
|
||||
return "Connected to \(name) over HTTP - this connection is not encrypted."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// JellyfinService+QuickConnect.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
/// The Quick Connect entry point for a ``JellyfinService``.
|
||||
extension JellyfinService {
|
||||
/// Starts a Quick Connect pairing attempt. See ``JellyfinQuickConnect`` for usage.
|
||||
package var quickConnect: JellyfinQuickConnect {
|
||||
JellyfinQuickConnect(service: self)
|
||||
}
|
||||
}
|
||||
76
Sources/LuminateOnboarding/ForgotPasswordPage.swift
Normal file
76
Sources/LuminateOnboarding/ForgotPasswordPage.swift
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// ForgotPasswordPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import LuminateCore
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
|
||||
|
||||
/// The password-reset entry page.
|
||||
///
|
||||
/// The reset flow is not implemented yet: submitting the form shows an explanatory toast instead of
|
||||
/// resetting a password. See ``signIn()``.
|
||||
package struct ForgotPasswordPage: View {
|
||||
@State private var isLoading = false
|
||||
@State private var username: String = ""
|
||||
|
||||
private let toastManager = ToastManager()
|
||||
|
||||
package init() {}
|
||||
|
||||
package var body: some View {
|
||||
ToastOverlay {
|
||||
ToolbarView {
|
||||
Clamp {
|
||||
StatusPage(description: "Enter your username, if you remember it", title: "Reset Your Password") {
|
||||
PreferencesGroup {
|
||||
EntryRow("Username", text: $username)
|
||||
ButtonRow("Continue") {
|
||||
Task { await signIn() }
|
||||
}
|
||||
.suggestedAction()
|
||||
.sensitive({ !isLoading })
|
||||
}
|
||||
.separateRows(true)
|
||||
}
|
||||
}
|
||||
.vexpand(true)
|
||||
.valign(.center)
|
||||
} top: {
|
||||
HeaderBar()
|
||||
}
|
||||
.navigationTitle("Forgot Password")
|
||||
}
|
||||
.toastManager(toastManager)
|
||||
}
|
||||
|
||||
private func signIn() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
logger.notice("Forgot password functionality is currently unimplemented and will be added at a later date")
|
||||
toastManager.addToast(.init(title: "Password reset isn't available yet"))
|
||||
}
|
||||
}
|
||||
38
Sources/LuminateOnboarding/IntroPage.swift
Normal file
38
Sources/LuminateOnboarding/IntroPage.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// IntroPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Portico
|
||||
|
||||
/// The first carousel page in `OnboardWindow`: a brief welcome screen before server setup.
|
||||
package struct IntroPage: View {
|
||||
package init() {}
|
||||
|
||||
package var body: some View {
|
||||
Clamp {
|
||||
StatusPage()
|
||||
.title("Welcome to Luminate!")
|
||||
.description("Luminate is a modern Jellyfin client for the Linux desktop. Continue to the next page to connect to a server")
|
||||
.iconName("person-symbolic")
|
||||
}
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
}
|
||||
}
|
||||
135
Sources/LuminateOnboarding/LoginPage.swift
Normal file
135
Sources/LuminateOnboarding/LoginPage.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//
|
||||
// LoginPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import LuminateCore
|
||||
import LuminateServices
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
|
||||
|
||||
/// The sign-in landing page: lists the server's public users, or offers manual/Quick
|
||||
/// Connect/forgot-password entry points when none are public or none are shown.
|
||||
package struct LoginPage: View {
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var showPublicUsers: Bool = false
|
||||
@State private var users: [JellyfinUser] = []
|
||||
@State private var isMobile: Bool = false
|
||||
|
||||
@Environment(\.client) private var client
|
||||
|
||||
private var onboardPath: Binding<[OnboardDestination]>
|
||||
|
||||
package init(_ onboardPath: Binding<[OnboardDestination]>) {
|
||||
self.onboardPath = onboardPath
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
ToolbarView {
|
||||
BreakpointBin {
|
||||
Clamp {
|
||||
EitherView($isLoading) {
|
||||
Spinner()
|
||||
} second: {
|
||||
EitherView($showPublicUsers) {
|
||||
publicUsersView
|
||||
} second: {
|
||||
Label("No users... :-(")
|
||||
}
|
||||
}
|
||||
.margin(12)
|
||||
}
|
||||
}
|
||||
.breakpoint(.maxWidth(400, .sp), isActive: $isMobile)
|
||||
.preferredWidth(200)
|
||||
.preferredHeight(200)
|
||||
} top: {
|
||||
HeaderBar()
|
||||
}
|
||||
.navigationTitle("Sign In")
|
||||
.task {
|
||||
await getPublicUsers()
|
||||
}
|
||||
}
|
||||
|
||||
private var publicUsersView: some View {
|
||||
VStack(spacing: 32) {
|
||||
WrapBox {
|
||||
ForEach($users, id: \.id) { user in
|
||||
userView(user)
|
||||
}
|
||||
}
|
||||
.childSpacing(16)
|
||||
.lineSpacing(16)
|
||||
.align(0.5)
|
||||
|
||||
PreferencesGroup {
|
||||
ButtonRow("Manual Login", endIconName: "right-symbolic") {
|
||||
onboardPath.wrappedValue.append(.manualLoginPage)
|
||||
}
|
||||
ButtonRow("Quick Connect", endIconName: "right-symbolic") {
|
||||
onboardPath.wrappedValue.append(.quickConnectPage)
|
||||
}
|
||||
ButtonRow("Forgot Password", endIconName: "right-symbolic") {
|
||||
onboardPath.wrappedValue.append(.forgotPasswordPage)
|
||||
}
|
||||
}
|
||||
.separateRows(true)
|
||||
}
|
||||
.vexpand(true)
|
||||
.valign(.center)
|
||||
}
|
||||
|
||||
private func userView(_ user: JellyfinUser) -> some View {
|
||||
Button {
|
||||
VStack(spacing: 8) {
|
||||
Avatar(size: 0, text: user.name, showInitials: true)
|
||||
.size({ isMobile ? 96 : 128 })
|
||||
Label(str: user.name)
|
||||
}
|
||||
.margin(8)
|
||||
}
|
||||
.onClicked {
|
||||
onboardPath.wrappedValue.append(.userLoginPage(user: user))
|
||||
}
|
||||
.flat()
|
||||
}
|
||||
|
||||
private func getPublicUsers() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let users = try await Task.detached(priority: .userInitiated) {
|
||||
try await client.publicUsers()
|
||||
}.value
|
||||
|
||||
self.users = users
|
||||
self.showPublicUsers = users.count > 0
|
||||
} catch let error as JellyfinClientError {
|
||||
logger.error("Server connection failure: \(error)")
|
||||
} catch {
|
||||
logger.error("Unexpected error occurred: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
97
Sources/LuminateOnboarding/ManualLoginPage.swift
Normal file
97
Sources/LuminateOnboarding/ManualLoginPage.swift
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
// ManualLoginPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import LuminateCore
|
||||
import LuminateServices
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
|
||||
|
||||
/// The manual sign-in page for a private user.
|
||||
///
|
||||
/// A successful sign-in stores the access token in the `.accessToken` preference. That write is the
|
||||
/// only login-state mutation in the app: the application's scene conditional observes the same
|
||||
/// slot and replaces the onboarding window with the main window.
|
||||
package struct ManualLoginPage: View {
|
||||
@Environment(\.client) private var client
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
|
||||
@Preference(.accessToken) private var accessToken: String?
|
||||
|
||||
package init() {}
|
||||
|
||||
package var body: some View {
|
||||
ToolbarView {
|
||||
Clamp {
|
||||
EitherView($isLoading) {
|
||||
Spinner()
|
||||
} second: {
|
||||
StatusPage("Let's get to know each other", description: "Enter Jellyfin account information to continue", iconName: "people-symbolic") {
|
||||
VStack(spacing: 12) {
|
||||
PreferencesGroup {
|
||||
EntryRow("Username", text: $username)
|
||||
PasswordEntryRow("Password", text: $password)
|
||||
}
|
||||
PreferencesGroup {
|
||||
ButtonRow("Sign In") {
|
||||
Task { await signIn() }
|
||||
}
|
||||
.suggestedAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.vexpand(true)
|
||||
.valign(.center)
|
||||
} top: {
|
||||
HeaderBar()
|
||||
}
|
||||
.navigationTitle("Sign In")
|
||||
}
|
||||
|
||||
private func signIn() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let authentication = try await client.authenticate(
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
|
||||
let authenticatedUser = authentication.user?.name ?? username
|
||||
logger.info("Authenticated as \(authenticatedUser)!")
|
||||
|
||||
accessToken = authentication.accessToken
|
||||
} catch let error as JellyfinClientError {
|
||||
logger.error("Server connection failure: \(error)")
|
||||
} catch {
|
||||
logger.error("Unexpected error occurred: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Sources/LuminateOnboarding/OnboardWindow.swift
Normal file
65
Sources/LuminateOnboarding/OnboardWindow.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// OnboardWindow.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Adw
|
||||
import LuminateCore
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
/// The root view for the unauthenticated onboarding window.
|
||||
///
|
||||
/// Hosts the intro/server-setup carousel and the `.login`-rooted navigation stack (public-user
|
||||
/// picker, manual sign-in, Quick Connect, forgot password) that follows a successful server
|
||||
/// connection.
|
||||
package struct OnboardWindow: View {
|
||||
@State private var onboardPath: [OnboardDestination] = []
|
||||
|
||||
@WidgetRef private var carousel: Adw.Carousel?
|
||||
|
||||
package init() {}
|
||||
|
||||
package var body: some View {
|
||||
NavigationView(path: $onboardPath) {
|
||||
ToolbarView {
|
||||
Carousel {
|
||||
IntroPage()
|
||||
ServerConfigPage($onboardPath)
|
||||
}
|
||||
.ref(_carousel)
|
||||
} top: {
|
||||
HeaderBar {
|
||||
CarouselIndicatorDots()
|
||||
.carousel($carousel)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Server Setup")
|
||||
.navigationDestination(for: OnboardDestination.self) { destination in
|
||||
switch destination {
|
||||
case .login: LoginPage($onboardPath)
|
||||
case .userLoginPage(let user): UserLoginPage(user)
|
||||
case .manualLoginPage: ManualLoginPage()
|
||||
case .quickConnectPage: QuickConnectPage()
|
||||
case .forgotPasswordPage: ForgotPasswordPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
154
Sources/LuminateOnboarding/QuickConnectPage.swift
Normal file
154
Sources/LuminateOnboarding/QuickConnectPage.swift
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//
|
||||
// QuickConnectPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import LuminateCore
|
||||
import LuminateServices
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
|
||||
|
||||
/// The Quick Connect pairing page: shows a one-time code and waits for another signed-in client to
|
||||
/// approve it.
|
||||
///
|
||||
/// A successful pairing stores the access token in the `.accessToken` preference. That write is the
|
||||
/// only login-state mutation in the app: the application's scene conditional observes the same
|
||||
/// slot and replaces the onboarding window with the main window.
|
||||
package struct QuickConnectPage: View {
|
||||
@Environment(\.client) private var client
|
||||
@Environment(\.clipboard) private var clipboard
|
||||
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var quickConnectCode: [CodeCharacter] = []
|
||||
|
||||
@Preference(.accessToken) private var accessToken: String?
|
||||
|
||||
private let toastManager = ToastManager()
|
||||
|
||||
package init() {}
|
||||
|
||||
package var body: some View {
|
||||
ToastOverlay {
|
||||
ToolbarView {
|
||||
Clamp {
|
||||
StatusPage("Quick Connect", description: "Enter the code below in Jellyfin's Settings > Quick Connect") {
|
||||
VStack(spacing: 12) {
|
||||
EitherView($isLoading) {
|
||||
Spinner()
|
||||
} second: {
|
||||
VStack(spacing: 48) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach($quickConnectCode, id: \.id) { character in
|
||||
Bin {
|
||||
Label(str: character.value)
|
||||
.numericStyle()
|
||||
.title1()
|
||||
.marginTop(8)
|
||||
.marginBottom(8)
|
||||
.marginStart(12)
|
||||
.marginEnd(12)
|
||||
}
|
||||
.card()
|
||||
}
|
||||
}
|
||||
.hexpand(true)
|
||||
.halign(.center)
|
||||
|
||||
Button {
|
||||
ButtonContent(iconName: "edit-copy-symbolic", label: "Copy to Clipboard")
|
||||
} onClicked: {
|
||||
copyCodeToClipboard()
|
||||
}
|
||||
.suggestedAction()
|
||||
.halign(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.vexpand(true)
|
||||
.valign(.center)
|
||||
} top: {
|
||||
HeaderBar()
|
||||
}
|
||||
.navigationTitle("Sign In")
|
||||
}
|
||||
.toastManager(toastManager)
|
||||
.task {
|
||||
await startQuickConnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the Quick Connect flow to completion: shows the code, then waits for approval.
|
||||
///
|
||||
/// ``JellyfinService/quickConnect`` owns the initiate-then-poll exchange as an event stream;
|
||||
/// this only reacts to what it yields. Cancelling this `.task` (on unmount) cancels the
|
||||
/// stream's polling with it, so leaving the page stops the network chatter.
|
||||
private func startQuickConnect() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
for try await event in client.quickConnect.connect() {
|
||||
switch event {
|
||||
case .polling(let code):
|
||||
quickConnectCode = code.enumerated().map { CodeCharacter(id: $0.offset, value: String($0.element)) }
|
||||
isLoading = false
|
||||
case .authenticated(let secret):
|
||||
let authentication = try await client.authenticateWithQuickConnect(secret: secret)
|
||||
let authenticatedUser = authentication.user?.name ?? "unknown user"
|
||||
logger.info("Authenticated via Quick Connect as \(authenticatedUser)!")
|
||||
accessToken = authentication.accessToken
|
||||
}
|
||||
}
|
||||
} catch JellyfinClientError.notFound, JellyfinClientError.quickConnectTimedOut {
|
||||
logger.warning("Quick connect request expired before it was approved")
|
||||
toastManager.addToast(.init(title: "Code expired, go back and try again"))
|
||||
} catch let error as JellyfinClientError {
|
||||
logger.error("Server connection failure: \(error)")
|
||||
} catch {
|
||||
logger.error("Unexpected error occurred: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins the displayed digits and hands the result to the system clipboard.
|
||||
///
|
||||
/// The write runs off the main actor because it spawns a short-lived helper process; the
|
||||
/// result is reported back on the main actor so failures reach the log.
|
||||
private func copyCodeToClipboard() {
|
||||
let code = quickConnectCode.map(\.value).joined()
|
||||
guard !code.isEmpty else { return }
|
||||
|
||||
clipboard.setText(code)
|
||||
|
||||
toastManager.addToast(.init(title: "Code copied to clipboard"))
|
||||
}
|
||||
}
|
||||
|
||||
/// One character of a Quick Connect code, keyed by its fixed position rather than its value so
|
||||
/// repeated characters (Quick Connect codes are not guaranteed unique per digit, e.g. "AA12BB")
|
||||
/// don't collide during ``ForEach`` diffing.
|
||||
private struct CodeCharacter: Identifiable {
|
||||
let id: Int
|
||||
let value: String
|
||||
}
|
||||
158
Sources/LuminateOnboarding/ServerConfigPage.swift
Normal file
158
Sources/LuminateOnboarding/ServerConfigPage.swift
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//
|
||||
// ServerConfigPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import LuminateCore
|
||||
import LuminateServices
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
|
||||
|
||||
/// The server connection setup page: enter or paste a server address, test or connect to it.
|
||||
///
|
||||
/// A successful connect stores the resolved URL in the `.serverURL` preference and pushes
|
||||
/// `.login` onto the shared onboarding navigation path.
|
||||
package struct ServerConfigPage: View {
|
||||
@State private var serverAddress: String = ""
|
||||
@State private var port: String = ""
|
||||
@State private var forceHttps: Bool = false
|
||||
@State private var isTesting: Bool = false
|
||||
@State private var isConnecting: Bool = false
|
||||
|
||||
@Preference(.serverURL) private var serverURL: URL?
|
||||
|
||||
private var onboardPath: Binding<[OnboardDestination]>
|
||||
private let probe = ServerConnectionProbe()
|
||||
private let toasts = ToastManager()
|
||||
|
||||
package init(_ onboardPath: Binding<[OnboardDestination]>) {
|
||||
self.onboardPath = onboardPath
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
ToastOverlay {
|
||||
Clamp {
|
||||
StatusPage {
|
||||
VStack(spacing: 8) {
|
||||
PreferencesGroup {
|
||||
EntryRow()
|
||||
.title("Server URL")
|
||||
.text($serverAddress)
|
||||
ExpanderRow()
|
||||
.title("Advanced Options")
|
||||
.addRow {
|
||||
EntryRow()
|
||||
.title("Port")
|
||||
.text($port)
|
||||
}
|
||||
.addRow {
|
||||
SwitchRow()
|
||||
.title("Force HTTPS")
|
||||
.subtitle("Enforces connection over HTTPS")
|
||||
.active($forceHttps)
|
||||
}
|
||||
} headerSuffix: {
|
||||
Button {
|
||||
EitherView($isTesting) {
|
||||
Spinner()
|
||||
} second: {
|
||||
Label("Test Connection")
|
||||
}
|
||||
} onClicked: {
|
||||
connect(persisting: false)
|
||||
}
|
||||
.sensitive { !isTesting }
|
||||
}
|
||||
|
||||
Button {
|
||||
EitherView($isConnecting) {
|
||||
Spinner()
|
||||
} second: {
|
||||
Label("Connect")
|
||||
}
|
||||
} onClicked: {
|
||||
connect(persisting: true)
|
||||
}
|
||||
.sensitive { !isConnecting }
|
||||
.suggestedAction()
|
||||
}
|
||||
}
|
||||
.title("Let's get started")
|
||||
.description("Enter your Jellyfin server information")
|
||||
}
|
||||
}
|
||||
.toastManager(toasts)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
}
|
||||
|
||||
/// Normalizes the form, probes the server, and optionally persists the successful URL.
|
||||
///
|
||||
/// Normalization is pure and stays on the main actor; the network probe runs in a detached
|
||||
/// task so the reachability request never occupies main-actor isolation, and its result is
|
||||
/// applied to view state after the `await` resumes back on the main actor.
|
||||
///
|
||||
/// - Parameter persisting: Whether a successful probe should save the URL and open sign-in.
|
||||
private func connect(persisting: Bool) {
|
||||
guard !isConnecting && !isTesting else { return }
|
||||
if persisting {
|
||||
isConnecting = true
|
||||
} else {
|
||||
isTesting = true
|
||||
}
|
||||
let policy: ServerSchemePolicy = forceHttps ? .httpsOnly : .automatic
|
||||
let probe = probe
|
||||
Task { @MainActor in
|
||||
defer {
|
||||
if persisting {
|
||||
isConnecting = false
|
||||
} else {
|
||||
isTesting = false
|
||||
}
|
||||
}
|
||||
do {
|
||||
let address = try ServerAddress.normalize(address: serverAddress, port: port, policy: policy)
|
||||
let connection = try await Task.detached(priority: .userInitiated) {
|
||||
try await probe.connect(to: address)
|
||||
}.value
|
||||
if persisting {
|
||||
serverURL = connection.url
|
||||
onboardPath.wrappedValue.append(.login)
|
||||
} else {
|
||||
toasts.addToast(.init(title: connection.summary))
|
||||
}
|
||||
} catch let error as ServerAddressError {
|
||||
logger.debug("Rejected server address: \(error)")
|
||||
toasts.addToast(.init(title: error.message))
|
||||
} catch let error as ServerConnectionError {
|
||||
logger.debug("Server connection failed: \(error)")
|
||||
toasts.addToast(.init(title: error.message))
|
||||
} catch is CancellationError {
|
||||
logger.debug("Server connection cancelled")
|
||||
} catch {
|
||||
logger.error("Unexpected server connection failure: \(error)")
|
||||
toasts.addToast(.init(title: String(describing: error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
Sources/LuminateOnboarding/UserLoginPage.swift
Normal file
105
Sources/LuminateOnboarding/UserLoginPage.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// UserLoginPage.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Logging
|
||||
import LuminateCore
|
||||
import LuminateServices
|
||||
import LuminateUI
|
||||
import Portico
|
||||
|
||||
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
|
||||
|
||||
/// The password sign-in page for one public user.
|
||||
///
|
||||
/// A successful sign-in stores the access token in the `.accessToken` preference. That write is the
|
||||
/// only login-state mutation in the app: the application's scene conditional observes the same
|
||||
/// slot and replaces the onboarding window with the main window.
|
||||
package struct UserLoginPage: View {
|
||||
@Environment(\.client) private var client
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var password: String = ""
|
||||
|
||||
@Preference(.accessToken) private var accessToken: String?
|
||||
|
||||
private let user: JellyfinUser
|
||||
|
||||
package init(_ user: JellyfinUser) {
|
||||
self.user = user
|
||||
}
|
||||
|
||||
package var body: some View {
|
||||
ToolbarView {
|
||||
Clamp {
|
||||
EitherView($isLoading) {
|
||||
Spinner()
|
||||
} second: {
|
||||
VStack(spacing: 32) {
|
||||
Avatar(size: 128, text: user.name, showInitials: true)
|
||||
|
||||
PreferencesGroup {
|
||||
PasswordEntryRow("Password", text: $password)
|
||||
ButtonRow("Sign In") {
|
||||
Task { await signIn() }
|
||||
}
|
||||
.suggestedAction()
|
||||
}
|
||||
.separateRows(true)
|
||||
}
|
||||
}
|
||||
.margin(12)
|
||||
}
|
||||
.vexpand(true)
|
||||
.valign(.center)
|
||||
} top: {
|
||||
HeaderBar()
|
||||
}
|
||||
.navigationTitle {
|
||||
if let userName = user.name {
|
||||
"Sign In as \(userName)"
|
||||
} else {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func signIn() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let authentication = try await client.authenticate(
|
||||
username: user.name ?? "",
|
||||
password: password
|
||||
)
|
||||
|
||||
let authenticatedUser = authentication.user?.name ?? user.name ?? "unknown user"
|
||||
logger.info("Authenticated as \(authenticatedUser)!")
|
||||
|
||||
accessToken = authentication.accessToken
|
||||
} catch let error as JellyfinClientError {
|
||||
logger.error("Server connection failure: \(error)")
|
||||
} catch {
|
||||
logger.error("Unexpected error occurred: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift
Normal file
48
Sources/LuminateServices/Discovery/HTTPSDowngradeGuard.swift
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//
|
||||
// HTTPSDowngradeGuard.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Prevents an HTTPS connection probe from silently following a redirect to HTTP.
|
||||
final class HTTPSDowngradeGuard: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
/// Decides whether a redirect preserves transport security.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - session: The URL session handling the redirect.
|
||||
/// - task: The task receiving the redirect.
|
||||
/// - response: The response that requested the redirect.
|
||||
/// - request: The proposed redirected request.
|
||||
/// - completionHandler: Receives the request to follow, or `nil` to reject it.
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
let from = response.url?.scheme?.lowercased()
|
||||
let to = request.url?.scheme?.lowercased()
|
||||
completionHandler(from == "https" && to == "http" ? nil : request)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// ServerConnectionProbe.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
import LuminateAPI
|
||||
import LuminateCore
|
||||
import OpenAPIRuntime
|
||||
import OpenAPIURLSession
|
||||
|
||||
/// Probes normalized server URLs and accepts only reachable Jellyfin identities.
|
||||
package struct ServerConnectionProbe: Sendable {
|
||||
/// Asks one candidate URL to identify itself.
|
||||
package typealias Request = @Sendable (URL) async throws -> JellyfinServerInfo
|
||||
|
||||
private let request: Request
|
||||
|
||||
/// Creates a probe using the supplied request seam or the live HTTP implementation.
|
||||
///
|
||||
/// - Parameter request: The request closure used for each candidate URL.
|
||||
package init(request: @escaping Request = ServerConnectionProbe.liveRequest) {
|
||||
self.request = request
|
||||
}
|
||||
|
||||
/// Tries the candidates in order and returns the first accepted Jellyfin connection.
|
||||
///
|
||||
/// - Parameter address: The normalized address whose candidates should be probed.
|
||||
/// - Returns: The candidate URL and server identity that answered successfully.
|
||||
/// - Throws: ``ServerConnectionError`` for unreachable, rejected, or non-Jellyfin servers;
|
||||
/// cancellation is propagated as `CancellationError`.
|
||||
package func connect(to address: ServerAddress) async throws -> ServerConnection {
|
||||
for url in address.probeCandidates {
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
do {
|
||||
let info = try await request(url)
|
||||
guard isJellyfin(info) else {
|
||||
throw ServerConnectionError.notJellyfin(url: url, productName: info.productName)
|
||||
}
|
||||
return ServerConnection(url: url, serverInfo: info)
|
||||
} catch let error as ServerConnectionError {
|
||||
throw error
|
||||
} catch {
|
||||
let underlying = (error as? ClientError)?.underlyingError ?? error
|
||||
if underlying is URLError {
|
||||
continue
|
||||
}
|
||||
throw ServerConnectionError.rejected(url: url, detail: String(describing: underlying))
|
||||
}
|
||||
}
|
||||
throw ServerConnectionError.unreachable(attempted: address.probeCandidates)
|
||||
}
|
||||
|
||||
/// Performs the public Jellyfin system-info request against one candidate URL.
|
||||
///
|
||||
/// - Parameter url: The normalized base URL to probe.
|
||||
/// - Returns: The identifying information returned by Jellyfin.
|
||||
/// - Throws: Transport, OpenAPI runtime, or mapped Jellyfin client errors from the request.
|
||||
package static let liveRequest: Request = { url in
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 10
|
||||
configuration.timeoutIntervalForResource = 15
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
let session = URLSession(configuration: configuration, delegate: HTTPSDowngradeGuard(), delegateQueue: nil)
|
||||
let client = JellyfinClient(
|
||||
configuration: JellyfinClientConfiguration(serverURL: url),
|
||||
transport: URLSessionTransport(configuration: .init(session: session))
|
||||
)
|
||||
return try await client.publicServerInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func isJellyfin(_ info: JellyfinServerInfo) -> Bool {
|
||||
guard let productName = info.productName else {
|
||||
return true
|
||||
}
|
||||
return productName.range(of: "jellyfin", options: .caseInsensitive) != nil
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
32
Sources/LuminateUI/Preferences/Preferences+Session.swift
Normal file
32
Sources/LuminateUI/Preferences/Preferences+Session.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// Preferences+Session.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import LuminateCore
|
||||
|
||||
extension Preferences {
|
||||
/// Whether a usable session token is stored.
|
||||
///
|
||||
/// Reading this tracks the `.accessToken` slot, so any Portico `DependencyTracker` that
|
||||
/// evaluates it - including the one `SceneHost` wraps around `App.body` - re-runs when the
|
||||
/// token is written or cleared. An empty string counts as signed out, matching
|
||||
/// `JellyfinClient.isAuthenticated`.
|
||||
package var isAuthenticated: Bool { self[.accessToken]?.isEmpty == false }
|
||||
}
|
||||
133
Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift
Normal file
133
Tests/LuminateCoreTests/JellyfinQuickConnectTests.swift
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
//
|
||||
// JellyfinQuickConnectTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
|
||||
/// Exercises ``JellyfinQuickConnect``'s initiate-then-poll stream against a scripted
|
||||
/// ``JellyfinServiceMock``.
|
||||
@Suite struct JellyfinQuickConnectTests {
|
||||
@Test("Yields the code, then the secret once a poll reports authenticated")
|
||||
func happyPath() async throws {
|
||||
let polls = PolledResults([
|
||||
.success(JellyfinQuickConnectState(authenticated: false)),
|
||||
.success(JellyfinQuickConnectState(authenticated: true, secret: "top-secret")),
|
||||
])
|
||||
let service = JellyfinServiceMock(
|
||||
initiateQuickConnect: { JellyfinQuickConnectState(secret: "top-secret", code: "123456") },
|
||||
quickConnectState: { _ in try await polls.next() }
|
||||
)
|
||||
|
||||
var events: [JellyfinQuickConnect.Event] = []
|
||||
for try await event in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {
|
||||
events.append(event)
|
||||
}
|
||||
|
||||
#expect(events == [.polling(code: "123456"), .authenticated(secret: "top-secret")])
|
||||
#expect(await polls.callCount == 2)
|
||||
}
|
||||
|
||||
@Test("A server omitting the secret or code fails without polling")
|
||||
func missingPayloadThrows() async {
|
||||
let service = JellyfinServiceMock(
|
||||
initiateQuickConnect: { JellyfinQuickConnectState(secret: nil, code: "123456") }
|
||||
)
|
||||
|
||||
await #expect(throws: JellyfinClientError.missingPayload(operation: "InitiateQuickConnect")) {
|
||||
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The server expiring the request propagates from a poll")
|
||||
func serverExpiryPropagates() async {
|
||||
let polls = PolledResults([
|
||||
.success(JellyfinQuickConnectState(authenticated: false)),
|
||||
.failure(JellyfinClientError.notFound),
|
||||
])
|
||||
let service = JellyfinServiceMock(
|
||||
initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") },
|
||||
quickConnectState: { _ in try await polls.next() }
|
||||
)
|
||||
|
||||
await #expect(throws: JellyfinClientError.notFound) {
|
||||
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 10) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Exhausting maxPolls without an answer gives up")
|
||||
func maxPollsExhaustedThrows() async {
|
||||
let polls = PolledResults(Array(repeating: .success(JellyfinQuickConnectState(authenticated: false)), count: 3))
|
||||
let service = JellyfinServiceMock(
|
||||
initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") },
|
||||
quickConnectState: { _ in try await polls.next() }
|
||||
)
|
||||
|
||||
await #expect(throws: JellyfinClientError.quickConnectTimedOut) {
|
||||
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 3) {}
|
||||
}
|
||||
#expect(await polls.callCount == 3)
|
||||
}
|
||||
|
||||
@Test("Cancelling the consuming task stops polling instead of leaking it")
|
||||
func cancellationStopsPolling() async throws {
|
||||
let polls = PolledResults(
|
||||
Array(repeating: .success(JellyfinQuickConnectState(authenticated: false)), count: 1000)
|
||||
)
|
||||
let service = JellyfinServiceMock(
|
||||
initiateQuickConnect: { JellyfinQuickConnectState(secret: "sec", code: "123456") },
|
||||
quickConnectState: { _ in try await polls.next() }
|
||||
)
|
||||
|
||||
let task = Task {
|
||||
for try await _ in service.quickConnect.connect(pollInterval: .milliseconds(5), maxPolls: 1000) {}
|
||||
}
|
||||
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
task.cancel()
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
let countAtCancel = await polls.callCount
|
||||
|
||||
try await Task.sleep(for: .milliseconds(60))
|
||||
let countLater = await polls.callCount
|
||||
|
||||
#expect(countLater == countAtCancel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands back queued Quick Connect poll results in call order and counts how many were consumed.
|
||||
///
|
||||
/// A small actor rather than a plain array captured in a closure because ``JellyfinQuickConnect``
|
||||
/// polls from a task the stream owns internally, so consumption genuinely races the test.
|
||||
private actor PolledResults {
|
||||
private var queue: [Result<JellyfinQuickConnectState, Error>]
|
||||
private(set) var callCount = 0
|
||||
|
||||
init(_ queue: [Result<JellyfinQuickConnectState, Error>]) {
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
func next() throws -> JellyfinQuickConnectState {
|
||||
callCount += 1
|
||||
guard !queue.isEmpty else { throw JellyfinClientError.notFound }
|
||||
return try queue.removeFirst().get()
|
||||
}
|
||||
}
|
||||
233
Tests/LuminateCoreTests/JellyfinServiceMock.swift
Normal file
233
Tests/LuminateCoreTests/JellyfinServiceMock.swift
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//
|
||||
// JellyfinServiceMock.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
|
||||
/// A ``JellyfinService`` double that answers each requirement with a test-supplied handler.
|
||||
///
|
||||
/// Mirrors `MockJellyfinAPI` in `LuminateServicesTests`: every requirement is implemented exactly
|
||||
/// once here, so a test configures only the handlers it needs through the initializer and every
|
||||
/// other call fails the test with a clear message rather than a fabricated success. Handlers are
|
||||
/// `let`, so the whole instance is provably `Sendable` with no isolation escape hatches -- a
|
||||
/// handler that must vary its answer across calls closes over its own actor or queue instead of
|
||||
/// mutating this type.
|
||||
final class JellyfinServiceMock: JellyfinService, Sendable {
|
||||
private let isAuthenticatedValue: Bool
|
||||
|
||||
private let publicServerInfoHandler: (@Sendable () async throws -> JellyfinServerInfo)?
|
||||
private let serverInfoHandler: (@Sendable () async throws -> JellyfinServerInfo)?
|
||||
private let publicUsersHandler: (@Sendable () async throws -> [JellyfinUser])?
|
||||
private let authenticateHandler: (@Sendable (String, String) async throws -> JellyfinAuthentication)?
|
||||
private let quickConnectEnabledHandler: (@Sendable () async throws -> Bool)?
|
||||
private let initiateQuickConnectHandler: (@Sendable () async throws -> JellyfinQuickConnectState)?
|
||||
private let quickConnectStateHandler: (@Sendable (String) async throws -> JellyfinQuickConnectState)?
|
||||
private let authenticateWithQuickConnectHandler: (@Sendable (String) async throws -> JellyfinAuthentication)?
|
||||
private let updateUserPasswordHandler: (@Sendable (String?, String?, String?, String?, Bool?) async throws -> Void)?
|
||||
private let librariesHandler: (@Sendable (JellyfinListOptions) async throws -> [JellyfinLibrary])?
|
||||
private let itemsHandler: (@Sendable (JellyfinMediaQuery) async throws -> JellyfinMediaPage)?
|
||||
private let itemHandler: (@Sendable (String, String?) async throws -> JellyfinMediaItem)?
|
||||
private let resumeItemsHandler: (@Sendable (JellyfinListOptions) async throws -> JellyfinMediaPage)?
|
||||
private let nextUpHandler: (@Sendable (String?, JellyfinListOptions) async throws -> JellyfinMediaPage)?
|
||||
private let latestMediaHandler: (@Sendable (JellyfinListOptions) async throws -> [JellyfinMediaItem])?
|
||||
private let seasonsHandler: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)?
|
||||
private let episodesHandler: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)?
|
||||
private let searchHintsHandler: (@Sendable (String, JellyfinListOptions) async throws -> [JellyfinSearchHint])?
|
||||
private let imageHandler: (
|
||||
@Sendable (String, JellyfinImageType, Int32?, JellyfinImageRequest) async throws -> Data
|
||||
)?
|
||||
private let markPlayedHandler: (@Sendable (String, String?, Date?) async throws -> JellyfinUserData)?
|
||||
private let markUnplayedHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)?
|
||||
private let markFavoriteHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)?
|
||||
private let unmarkFavoriteHandler: (@Sendable (String, String?) async throws -> JellyfinUserData)?
|
||||
|
||||
/// Creates a mock; every parameter defaults to `nil`, so a call the test never stubbed fails
|
||||
/// with a clear message instead of returning a fabricated value.
|
||||
init(
|
||||
isAuthenticated: Bool = false,
|
||||
publicServerInfo: (@Sendable () async throws -> JellyfinServerInfo)? = nil,
|
||||
serverInfo: (@Sendable () async throws -> JellyfinServerInfo)? = nil,
|
||||
publicUsers: (@Sendable () async throws -> [JellyfinUser])? = nil,
|
||||
authenticate: (@Sendable (String, String) async throws -> JellyfinAuthentication)? = nil,
|
||||
quickConnectEnabled: (@Sendable () async throws -> Bool)? = nil,
|
||||
initiateQuickConnect: (@Sendable () async throws -> JellyfinQuickConnectState)? = nil,
|
||||
quickConnectState: (@Sendable (String) async throws -> JellyfinQuickConnectState)? = nil,
|
||||
authenticateWithQuickConnect: (@Sendable (String) async throws -> JellyfinAuthentication)? = nil,
|
||||
updateUserPassword: (@Sendable (String?, String?, String?, String?, Bool?) async throws -> Void)? = nil,
|
||||
libraries: (@Sendable (JellyfinListOptions) async throws -> [JellyfinLibrary])? = nil,
|
||||
items: (@Sendable (JellyfinMediaQuery) async throws -> JellyfinMediaPage)? = nil,
|
||||
item: (@Sendable (String, String?) async throws -> JellyfinMediaItem)? = nil,
|
||||
resumeItems: (@Sendable (JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
|
||||
nextUp: (@Sendable (String?, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
|
||||
latestMedia: (@Sendable (JellyfinListOptions) async throws -> [JellyfinMediaItem])? = nil,
|
||||
seasons: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
|
||||
episodes: (@Sendable (String, JellyfinListOptions) async throws -> JellyfinMediaPage)? = nil,
|
||||
searchHints: (@Sendable (String, JellyfinListOptions) async throws -> [JellyfinSearchHint])? = nil,
|
||||
image: (@Sendable (String, JellyfinImageType, Int32?, JellyfinImageRequest) async throws -> Data)? = nil,
|
||||
markPlayed: (@Sendable (String, String?, Date?) async throws -> JellyfinUserData)? = nil,
|
||||
markUnplayed: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil,
|
||||
markFavorite: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil,
|
||||
unmarkFavorite: (@Sendable (String, String?) async throws -> JellyfinUserData)? = nil
|
||||
) {
|
||||
self.isAuthenticatedValue = isAuthenticated
|
||||
self.publicServerInfoHandler = publicServerInfo
|
||||
self.serverInfoHandler = serverInfo
|
||||
self.publicUsersHandler = publicUsers
|
||||
self.authenticateHandler = authenticate
|
||||
self.quickConnectEnabledHandler = quickConnectEnabled
|
||||
self.initiateQuickConnectHandler = initiateQuickConnect
|
||||
self.quickConnectStateHandler = quickConnectState
|
||||
self.authenticateWithQuickConnectHandler = authenticateWithQuickConnect
|
||||
self.updateUserPasswordHandler = updateUserPassword
|
||||
self.librariesHandler = libraries
|
||||
self.itemsHandler = items
|
||||
self.itemHandler = item
|
||||
self.resumeItemsHandler = resumeItems
|
||||
self.nextUpHandler = nextUp
|
||||
self.latestMediaHandler = latestMedia
|
||||
self.seasonsHandler = seasons
|
||||
self.episodesHandler = episodes
|
||||
self.searchHintsHandler = searchHints
|
||||
self.imageHandler = image
|
||||
self.markPlayedHandler = markPlayed
|
||||
self.markUnplayedHandler = markUnplayed
|
||||
self.markFavoriteHandler = markFavorite
|
||||
self.unmarkFavoriteHandler = unmarkFavorite
|
||||
}
|
||||
|
||||
var isAuthenticated: Bool { isAuthenticatedValue }
|
||||
|
||||
/// Returns a stubbed handler, or fails the test naming the unstubbed operation.
|
||||
private func unwrap<Handler>(_ handler: Handler?, _ operation: String) throws -> Handler {
|
||||
try #require(handler, "JellyfinServiceMock received an unstubbed call to \(operation)")
|
||||
}
|
||||
|
||||
func publicServerInfo() async throws -> JellyfinServerInfo {
|
||||
try await unwrap(publicServerInfoHandler, "publicServerInfo")()
|
||||
}
|
||||
|
||||
func serverInfo() async throws -> JellyfinServerInfo {
|
||||
try await unwrap(serverInfoHandler, "serverInfo")()
|
||||
}
|
||||
|
||||
func publicUsers() async throws -> [JellyfinUser] {
|
||||
try await unwrap(publicUsersHandler, "publicUsers")()
|
||||
}
|
||||
|
||||
func authenticate(username: String, password: String) async throws -> JellyfinAuthentication {
|
||||
try await unwrap(authenticateHandler, "authenticate")(username, password)
|
||||
}
|
||||
|
||||
func quickConnectEnabled() async throws -> Bool {
|
||||
try await unwrap(quickConnectEnabledHandler, "quickConnectEnabled")()
|
||||
}
|
||||
|
||||
func initiateQuickConnect() async throws -> JellyfinQuickConnectState {
|
||||
try await unwrap(initiateQuickConnectHandler, "initiateQuickConnect")()
|
||||
}
|
||||
|
||||
func quickConnectState(secret: String) async throws -> JellyfinQuickConnectState {
|
||||
try await unwrap(quickConnectStateHandler, "quickConnectState")(secret)
|
||||
}
|
||||
|
||||
func authenticateWithQuickConnect(secret: String) async throws -> JellyfinAuthentication {
|
||||
try await unwrap(authenticateWithQuickConnectHandler, "authenticateWithQuickConnect")(secret)
|
||||
}
|
||||
|
||||
func updateUserPassword(
|
||||
userID: String?,
|
||||
currentPassword: String?,
|
||||
currentPIN: String?,
|
||||
newPassword: String?,
|
||||
resetPassword: Bool?
|
||||
) async throws {
|
||||
try await unwrap(updateUserPasswordHandler, "updateUserPassword")(
|
||||
userID,
|
||||
currentPassword,
|
||||
currentPIN,
|
||||
newPassword,
|
||||
resetPassword
|
||||
)
|
||||
}
|
||||
|
||||
func libraries(_ options: JellyfinListOptions) async throws -> [JellyfinLibrary] {
|
||||
try await unwrap(librariesHandler, "libraries")(options)
|
||||
}
|
||||
|
||||
func items(_ query: JellyfinMediaQuery) async throws -> JellyfinMediaPage {
|
||||
try await unwrap(itemsHandler, "items")(query)
|
||||
}
|
||||
|
||||
func item(id: String, userID: String?) async throws -> JellyfinMediaItem {
|
||||
try await unwrap(itemHandler, "item")(id, userID)
|
||||
}
|
||||
|
||||
func resumeItems(_ options: JellyfinListOptions) async throws -> JellyfinMediaPage {
|
||||
try await unwrap(resumeItemsHandler, "resumeItems")(options)
|
||||
}
|
||||
|
||||
func nextUp(seriesID: String?, options: JellyfinListOptions) async throws -> JellyfinMediaPage {
|
||||
try await unwrap(nextUpHandler, "nextUp")(seriesID, options)
|
||||
}
|
||||
|
||||
func latestMedia(_ options: JellyfinListOptions) async throws -> [JellyfinMediaItem] {
|
||||
try await unwrap(latestMediaHandler, "latestMedia")(options)
|
||||
}
|
||||
|
||||
func seasons(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage {
|
||||
try await unwrap(seasonsHandler, "seasons")(seriesID, options)
|
||||
}
|
||||
|
||||
func episodes(seriesID: String, options: JellyfinListOptions) async throws -> JellyfinMediaPage {
|
||||
try await unwrap(episodesHandler, "episodes")(seriesID, options)
|
||||
}
|
||||
|
||||
func searchHints(term: String, options: JellyfinListOptions) async throws -> [JellyfinSearchHint] {
|
||||
try await unwrap(searchHintsHandler, "searchHints")(term, options)
|
||||
}
|
||||
|
||||
func image(
|
||||
itemID: String,
|
||||
type: JellyfinImageType,
|
||||
index: Int32?,
|
||||
request: JellyfinImageRequest
|
||||
) async throws -> Data {
|
||||
try await unwrap(imageHandler, "image")(itemID, type, index, request)
|
||||
}
|
||||
|
||||
func markPlayed(itemID: String, userID: String?, datePlayed: Date?) async throws -> JellyfinUserData {
|
||||
try await unwrap(markPlayedHandler, "markPlayed")(itemID, userID, datePlayed)
|
||||
}
|
||||
|
||||
func markUnplayed(itemID: String, userID: String?) async throws -> JellyfinUserData {
|
||||
try await unwrap(markUnplayedHandler, "markUnplayed")(itemID, userID)
|
||||
}
|
||||
|
||||
func markFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData {
|
||||
try await unwrap(markFavoriteHandler, "markFavorite")(itemID, userID)
|
||||
}
|
||||
|
||||
func unmarkFavorite(itemID: String, userID: String?) async throws -> JellyfinUserData {
|
||||
try await unwrap(unmarkFavoriteHandler, "unmarkFavorite")(itemID, userID)
|
||||
}
|
||||
}
|
||||
169
Tests/LuminateCoreTests/ServerAddressTests.swift
Normal file
169
Tests/LuminateCoreTests/ServerAddressTests.swift
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//
|
||||
// ServerAddressTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
|
||||
/// Covers normalization, validation, and candidate selection for onboarding server addresses.
|
||||
@Suite struct ServerAddressTests {
|
||||
@Test("Normalizes a bare host and permits HTTPS fallback")
|
||||
func bareHost() throws {
|
||||
let address = try ServerAddress.normalize(address: " jellyfin.example.com ", port: "", policy: .automatic)
|
||||
#expect(address.baseURL.absoluteString == "https://jellyfin.example.com/")
|
||||
#expect(
|
||||
address.probeCandidates.map(\.absoluteString) == [
|
||||
"https://jellyfin.example.com/",
|
||||
"http://jellyfin.example.com/",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Preserves a custom port on a bare host")
|
||||
func customPort() throws {
|
||||
let address = try ServerAddress.normalize(address: "jellyfin.example.com", port: "8096", policy: .automatic)
|
||||
#expect(address.baseURL.absoluteString == "https://jellyfin.example.com:8096/")
|
||||
}
|
||||
|
||||
@Test("Honors an explicit HTTP scheme without fallback")
|
||||
func explicitHTTP() throws {
|
||||
let address = try ServerAddress.normalize(
|
||||
address: "http://192.168.1.20:8096",
|
||||
port: "",
|
||||
policy: .automatic
|
||||
)
|
||||
#expect(address.baseURL.absoluteString == "http://192.168.1.20:8096/")
|
||||
#expect(address.probeCandidates.map(\.absoluteString) == ["http://192.168.1.20:8096/"])
|
||||
}
|
||||
|
||||
@Test("Force HTTPS preserves an explicit nonstandard port")
|
||||
func forceHTTPSPreservesPort() throws {
|
||||
let address = try ServerAddress.normalize(
|
||||
address: "http://192.168.1.20:8096",
|
||||
port: "",
|
||||
policy: .httpsOnly
|
||||
)
|
||||
#expect(address.baseURL.absoluteString == "https://192.168.1.20:8096/")
|
||||
#expect(address.probeCandidates.map(\.absoluteString) == ["https://192.168.1.20:8096/"])
|
||||
}
|
||||
|
||||
@Test("Lowercases an explicit HTTPS scheme without changing its host")
|
||||
func lowercasesScheme() throws {
|
||||
let address = try ServerAddress.normalize(address: "HTTPS://Host.Example", port: "", policy: .automatic)
|
||||
#expect(address.baseURL.absoluteString == "https://Host.Example/")
|
||||
}
|
||||
|
||||
@Test("Preserves a reverse proxy path prefix")
|
||||
func preservesPathPrefix() throws {
|
||||
let address = try ServerAddress.normalize(
|
||||
address: "https://example.com/jellyfin",
|
||||
port: "",
|
||||
policy: .automatic
|
||||
)
|
||||
#expect(address.baseURL.absoluteString == "https://example.com/jellyfin/")
|
||||
}
|
||||
|
||||
@Test("Strips a redundant HTTPS default port")
|
||||
func stripsDefaultPort() throws {
|
||||
let address = try ServerAddress.normalize(address: "https://example.com:443", port: "", policy: .automatic)
|
||||
#expect(address.baseURL.absoluteString == "https://example.com/")
|
||||
}
|
||||
|
||||
@Test("Preserves IPv6 literal brackets")
|
||||
func preservesIPv6() throws {
|
||||
let address = try ServerAddress.normalize(
|
||||
address: "http://[2001:db8::10]:8096",
|
||||
port: "",
|
||||
policy: .automatic
|
||||
)
|
||||
#expect(address.baseURL.absoluteString == "http://[2001:db8::10]:8096/")
|
||||
}
|
||||
|
||||
@Test("Force HTTPS disables fallback for a bare host")
|
||||
func forceHTTPSDisablesFallback() throws {
|
||||
let address = try ServerAddress.normalize(address: "jellyfin.example.com", port: "", policy: .httpsOnly)
|
||||
#expect(address.probeCandidates.map(\.absoluteString) == ["https://jellyfin.example.com/"])
|
||||
}
|
||||
|
||||
@Test("Rejects an empty address")
|
||||
func emptyAddress() {
|
||||
#expect(throws: ServerAddressError.emptyAddress) {
|
||||
_ = try ServerAddress.normalize(address: "", port: "", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejects an unsupported scheme")
|
||||
func unsupportedScheme() {
|
||||
#expect(throws: ServerAddressError.unsupportedScheme("ftp")) {
|
||||
_ = try ServerAddress.normalize(address: "ftp://host", port: "", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejects conflicting port fields")
|
||||
func conflictingPorts() {
|
||||
#expect(throws: ServerAddressError.conflictingPorts) {
|
||||
_ = try ServerAddress.normalize(address: "https://host:8920", port: "8096", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejects ports outside the TCP range")
|
||||
func invalidPortRange() {
|
||||
#expect(throws: ServerAddressError.invalidPort("70000")) {
|
||||
_ = try ServerAddress.normalize(address: "host", port: "70000", policy: .automatic)
|
||||
}
|
||||
#expect(throws: ServerAddressError.invalidPort("0")) {
|
||||
_ = try ServerAddress.normalize(address: "host", port: "0", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejects nonnumeric port text")
|
||||
func invalidPortText() {
|
||||
#expect(throws: ServerAddressError.invalidPort("80abc")) {
|
||||
_ = try ServerAddress.normalize(address: "host", port: "80abc", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejects credentials in the server address")
|
||||
func rejectsCredentials() {
|
||||
#expect(throws: ServerAddressError.credentialsNotAllowed) {
|
||||
_ = try ServerAddress.normalize(address: "https://user:pw@host", port: "", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rejects query strings and fragments")
|
||||
func rejectsQueryAndFragment() {
|
||||
#expect(throws: ServerAddressError.queryOrFragmentNotAllowed) {
|
||||
_ = try ServerAddress.normalize(address: "https://host/?token=x", port: "", policy: .automatic)
|
||||
}
|
||||
#expect(throws: ServerAddressError.queryOrFragmentNotAllowed) {
|
||||
_ = try ServerAddress.normalize(address: "https://host/#login", port: "", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Explains invalid address input")
|
||||
func errorMessages() {
|
||||
#expect(ServerAddressError.emptyAddress.message == "Enter a server address.")
|
||||
#expect(ServerAddressError.unsupportedScheme("ftp").message == "ftp is not supported. Use http or https.")
|
||||
#expect(
|
||||
ServerAddressError.invalidPort("70000").message
|
||||
== "70000 is not a valid port. Enter a number from 1 to 65535.")
|
||||
}
|
||||
}
|
||||
46
Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift
Normal file
46
Tests/LuminateCoreTests/ServerConnectionSummaryTests.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// ServerConnectionSummaryTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import LuminateCore
|
||||
|
||||
/// Verifies the onboarding success copy for encrypted and plaintext connections.
|
||||
@Suite struct ServerConnectionSummaryTests {
|
||||
@Test("Reports an HTTPS connection by server name")
|
||||
func reportsHTTPS() {
|
||||
let connection = ServerConnection(
|
||||
url: URL(string: "https://jellyfin.test/")!,
|
||||
serverInfo: JellyfinServerInfo(serverName: "Basement")
|
||||
)
|
||||
#expect(connection.summary == "Connected to Basement over HTTPS.")
|
||||
}
|
||||
|
||||
@Test("Warns that an HTTP connection is unencrypted")
|
||||
func warnsForHTTP() {
|
||||
let connection = ServerConnection(
|
||||
url: URL(string: "http://jellyfin.test/")!,
|
||||
serverInfo: JellyfinServerInfo()
|
||||
)
|
||||
#expect(connection.summary == "Connected to the server over HTTP - this connection is not encrypted.")
|
||||
}
|
||||
}
|
||||
33
Tests/LuminateOnboardingTests/OnboardWindowTests.swift
Normal file
33
Tests/LuminateOnboardingTests/OnboardWindowTests.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// OnboardWindowTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import LuminateOnboarding
|
||||
|
||||
/// Verifies the onboarding target exposes its root view.
|
||||
@MainActor
|
||||
struct OnboardWindowTests {
|
||||
@Test("Creates the onboarding root view")
|
||||
func createsRootView() {
|
||||
_ = OnboardWindow()
|
||||
}
|
||||
}
|
||||
41
Tests/LuminateOnboardingTests/ServerConfigPageTests.swift
Normal file
41
Tests/LuminateOnboardingTests/ServerConfigPageTests.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// ServerConfigPageTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
@_spi(SGTKInternal) import Gtk
|
||||
import LuminateCore
|
||||
@_spi(Portico) import Portico
|
||||
import Testing
|
||||
|
||||
@testable import LuminateOnboarding
|
||||
@testable import LuminateUI
|
||||
|
||||
/// Verifies that the server configuration page mounts with its live button modifiers.
|
||||
@Suite(.serialized) @MainActor struct ServerConfigPageTests {
|
||||
@Test("Mounts the server configuration page")
|
||||
func mounts() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let preferences = Preferences(store: EphemeralPreferenceStore())
|
||||
let context = MountContext()
|
||||
@State var onboardPath: [OnboardDestination] = []
|
||||
_ = AnyView(ServerConfigPage($onboardPath).environment(\.preferences, preferences)).makeWidget(context)
|
||||
context.registry.teardown()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
162
Tests/LuminateServicesTests/ServerConnectionProbeTests.swift
Normal file
162
Tests/LuminateServicesTests/ServerConnectionProbeTests.swift
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
//
|
||||
// ServerConnectionProbeTests.swift
|
||||
//
|
||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import LuminateCore
|
||||
import OpenAPIRuntime
|
||||
import Testing
|
||||
|
||||
@testable import LuminateServices
|
||||
|
||||
/// Records probe candidates without introducing shared mutable state into test closures.
|
||||
private actor ProbeRecorder {
|
||||
private(set) var urls: [URL] = []
|
||||
|
||||
func record(_ url: URL) {
|
||||
urls.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercises HTTPS preference, transport fallback, and server identity classification.
|
||||
@Suite struct ServerConnectionProbeTests {
|
||||
private let httpsURL = URL(string: "https://jellyfin.test/")!
|
||||
private let httpURL = URL(string: "http://jellyfin.test/")!
|
||||
|
||||
@Test("Uses the HTTPS candidate when it answers")
|
||||
func usesHTTPS() async throws {
|
||||
let address = try makeAddress()
|
||||
let probe = ServerConnectionProbe { url in
|
||||
#expect(url == self.httpsURL)
|
||||
return JellyfinServerInfo(serverName: "Home", productName: "Jellyfin Server")
|
||||
}
|
||||
|
||||
let connection = try await probe.connect(to: address)
|
||||
#expect(connection.url == httpsURL)
|
||||
#expect(connection.isEncrypted)
|
||||
}
|
||||
|
||||
@Test("Falls back to HTTP after an HTTPS transport failure")
|
||||
func fallsBackAfterTransportFailure() async throws {
|
||||
let address = try makeAddress()
|
||||
let recorder = ProbeRecorder()
|
||||
let probe = ServerConnectionProbe { url in
|
||||
await recorder.record(url)
|
||||
if url.scheme == "https" {
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
return JellyfinServerInfo(productName: "Jellyfin Server")
|
||||
}
|
||||
|
||||
let connection = try await probe.connect(to: address)
|
||||
#expect(connection.url == httpURL)
|
||||
#expect(!connection.isEncrypted)
|
||||
#expect(await recorder.urls == [httpsURL, httpURL])
|
||||
}
|
||||
|
||||
@Test("Does not fall back when HTTPS answers with an error status")
|
||||
func doesNotFallbackAfterAnswer() async throws {
|
||||
let address = try makeAddress()
|
||||
let recorder = ProbeRecorder()
|
||||
let probe = ServerConnectionProbe { url in
|
||||
await recorder.record(url)
|
||||
throw JellyfinClientError.unauthorized
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await probe.connect(to: address)
|
||||
Issue.record("Expected the answered HTTPS candidate to be rejected")
|
||||
} catch let error as ServerConnectionError {
|
||||
guard case .rejected(let url, let detail) = error else {
|
||||
Issue.record("Expected a rejected connection, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(url == httpsURL)
|
||||
#expect(!detail.isEmpty)
|
||||
}
|
||||
#expect(await recorder.urls == [httpsURL])
|
||||
}
|
||||
|
||||
@Test("Unwraps an OpenAPI ClientError to reach the URLError")
|
||||
func unwrapsClientError() async throws {
|
||||
let address = try makeAddress()
|
||||
let recorder = ProbeRecorder()
|
||||
let probe = ServerConnectionProbe { url in
|
||||
await recorder.record(url)
|
||||
if url.scheme == "https" {
|
||||
throw ClientError(
|
||||
operationID: "GetPublicSystemInfo",
|
||||
operationInput: "test",
|
||||
causeDescription: "transport failed",
|
||||
underlyingError: URLError(.timedOut)
|
||||
)
|
||||
}
|
||||
return JellyfinServerInfo(productName: "Jellyfin Server")
|
||||
}
|
||||
|
||||
let connection = try await probe.connect(to: address)
|
||||
#expect(connection.url == httpURL)
|
||||
#expect(await recorder.urls == [httpsURL, httpURL])
|
||||
}
|
||||
|
||||
@Test("Rejects a reachable server that is not Jellyfin")
|
||||
func rejectsNonJellyfin() async throws {
|
||||
let address = try makeAddress()
|
||||
do {
|
||||
let probe = ServerConnectionProbe { _ in
|
||||
JellyfinServerInfo(productName: "Emby Server")
|
||||
}
|
||||
_ = try await probe.connect(to: address)
|
||||
Issue.record("Expected a non-Jellyfin identity to be rejected")
|
||||
} catch let error as ServerConnectionError {
|
||||
#expect(error == .notJellyfin(url: httpsURL, productName: "Emby Server"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Accepts a server that omits its product name")
|
||||
func acceptsMissingProductName() async throws {
|
||||
let address = try makeAddress()
|
||||
let probe = ServerConnectionProbe { _ in JellyfinServerInfo(serverName: "Proxy") }
|
||||
let connection = try await probe.connect(to: address)
|
||||
#expect(connection.serverInfo.serverName == "Proxy")
|
||||
}
|
||||
|
||||
@Test("Reports every attempted URL when nothing answers")
|
||||
func reportsUnreachableCandidates() async throws {
|
||||
let address = try makeAddress()
|
||||
let recorder = ProbeRecorder()
|
||||
let probe = ServerConnectionProbe { url in
|
||||
await recorder.record(url)
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await probe.connect(to: address)
|
||||
Issue.record("Expected all candidates to be reported as unreachable")
|
||||
} catch let error as ServerConnectionError {
|
||||
#expect(error == .unreachable(attempted: [httpsURL, httpURL]))
|
||||
}
|
||||
#expect(await recorder.urls == [httpsURL, httpURL])
|
||||
}
|
||||
|
||||
private func makeAddress() throws -> ServerAddress {
|
||||
try ServerAddress.normalize(address: "jellyfin.test", port: "", policy: .automatic)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,11 @@ private struct PreferenceLabel: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Records each value a tracked closure observes, so a test can assert re-evaluation.
|
||||
private final class SessionRecorder {
|
||||
var values: [Bool] = []
|
||||
}
|
||||
|
||||
/// Exercises preference mounting, observation, and persistence wiring.
|
||||
@Suite(.serialized) @MainActor struct PreferenceTests {
|
||||
@Test("An unset preference reads nil at mount")
|
||||
|
|
@ -122,6 +127,35 @@ private struct PreferenceLabel: View {
|
|||
#expect(await store.storedValues["server.url"] == nil)
|
||||
}
|
||||
|
||||
@Test("A stored token reports an authenticated coordinator")
|
||||
func authenticatedWithStoredToken() {
|
||||
let store = EphemeralPreferenceStore(initialSnapshot: ["auth.accessToken": .text("tok")])
|
||||
#expect(Preferences(store: store).isAuthenticated)
|
||||
}
|
||||
|
||||
@Test("A missing or empty token reports an unauthenticated coordinator")
|
||||
func unauthenticatedWithoutToken() {
|
||||
let preferences = Preferences(store: EphemeralPreferenceStore())
|
||||
#expect(preferences.isAuthenticated == false)
|
||||
preferences.slot(for: .accessToken).value = ""
|
||||
#expect(preferences.isAuthenticated == false)
|
||||
}
|
||||
|
||||
@Test("Writing the access token re-evaluates a tracker that read the session state")
|
||||
func authenticationDrivesTrackedReevaluation() {
|
||||
guard Gtk.initCheck() else { return }
|
||||
let preferences = Preferences(store: EphemeralPreferenceStore())
|
||||
let recorder = SessionRecorder()
|
||||
let tracker = DependencyTracker { recorder.values.append(preferences.isAuthenticated) }
|
||||
tracker.run()
|
||||
defer { tracker.teardown() }
|
||||
|
||||
preferences.slot(for: .accessToken).value = "tok"
|
||||
pump { recorder.values.count > 1 }
|
||||
|
||||
#expect(recorder.values == [false, true])
|
||||
}
|
||||
|
||||
private func pump(until condition: () -> Bool = { false }, turns: Int = 200) {
|
||||
for _ in 0..<turns {
|
||||
if condition() { return }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue