luminate/Sources/LuminateOnboarding/QuickConnectPage.swift

154 lines
6.4 KiB
Swift

//
// QuickConnectPage.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Logging
import LuminateCore
import LuminateUI
import Portico
private let logger = Logger(label: "\(AppInfo.identifier).onboarding")
/// The Quick Connect pairing page: shows a one-time code and waits for another signed-in client to
/// approve it.
///
/// A successful pairing stores the access token in the `.accessToken` preference. That write is the
/// only login-state mutation in the app: the application's scene conditional observes the same
/// slot and replaces the onboarding window with the main window.
package struct QuickConnectPage: View {
@Environment(\.client) private var client
@Environment(\.clipboard) private var clipboard
@State private var isLoading: Bool = false
@State private var quickConnectCode: [CodeCharacter] = []
@Preference(.accessToken) private var accessToken: String?
private let toastManager = ToastManager()
package init() {}
package var body: some View {
ToastOverlay {
ToolbarView {
Clamp {
StatusPage(
"Quick Connect", description: "Enter the code below in Jellyfin's Settings > Quick Connect"
) {
VStack(spacing: 12) {
if isLoading {
Spinner()
} else {
VStack(spacing: 48) {
HStack(spacing: 8) {
ForEach($quickConnectCode, id: \.id) { character in
Bin {
Label(str: character.value)
.numericStyle()
.title1()
.marginTop(8)
.marginBottom(8)
.marginStart(12)
.marginEnd(12)
}
.card()
}
}
.hexpand(true)
.halign(.center)
Button {
ButtonContent(iconName: "edit-copy-symbolic", label: "Copy to Clipboard")
} onClicked: {
copyCodeToClipboard()
}
.suggestedAction()
.halign(.center)
}
}
}
}
}
.vexpand(true)
.valign(.center)
} top: {
HeaderBar()
}
.navigationTitle("Sign In")
}
.toastManager(toastManager)
.task {
await startQuickConnect()
}
}
/// Runs the Quick Connect flow to completion: shows the code, then waits for approval.
///
/// ``JellyfinService/quickConnect`` owns the initiate-then-poll exchange as an event stream;
/// this only reacts to what it yields. Cancelling this `.task` (on unmount) cancels the
/// stream's polling with it, so leaving the page stops the network chatter.
private func startQuickConnect() async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
for try await event in client.quickConnect.connect() {
switch event {
case .polling(let code):
quickConnectCode = code.enumerated().map { CodeCharacter(id: $0.offset, value: String($0.element)) }
isLoading = false
case .authenticated(let secret):
let authentication = try await client.authenticateWithQuickConnect(secret: secret)
let authenticatedUser = authentication.user?.name ?? "unknown user"
logger.info("Authenticated via Quick Connect as \(authenticatedUser)!")
accessToken = authentication.accessToken
}
}
} catch JellyfinClientError.notFound, JellyfinClientError.quickConnectTimedOut {
logger.warning("Quick connect request expired before it was approved")
toastManager.addToast(.init(title: "Code expired, go back and try again"))
} catch let error as JellyfinClientError {
logger.error("Server connection failure: \(error)")
} catch {
logger.error("Unexpected error occurred: \(error)")
}
}
/// Joins the displayed digits and hands the result to the system clipboard.
///
/// The write runs off the main actor because it spawns a short-lived helper process; the
/// result is reported back on the main actor so failures reach the log.
private func copyCodeToClipboard() {
let code = quickConnectCode.map(\.value).joined()
guard !code.isEmpty else { return }
clipboard.setText(code)
toastManager.addToast(.init(title: "Code copied to clipboard"))
}
}
/// One character of a Quick Connect code, keyed by its fixed position rather than its value so
/// repeated characters (Quick Connect codes are not guaranteed unique per digit, e.g. "AA12BB")
/// don't collide during ``ForEach`` diffing.
private struct CodeCharacter: Identifiable {
let id: Int
let value: String
}