154 lines
6.4 KiB
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
|
|
}
|