// // QuickConnectPage.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import 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 }