luminate/Sources/LuminateOnboarding/SetupPage.swift

155 lines
6.1 KiB
Swift

//
// SetupPage.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 SetupPage: 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?
@Environment(\.onboardPath) private var onboardPath
private let probe = ServerConnectionProbe()
private let toasts = ToastManager()
package init() {}
package var body: some View {
ToolbarView {
ToastOverlay {
Clamp {
StatusPage(description: "Enter your Jellyfin server information", title: "Let's get started") {
VStack(spacing: 8) {
PreferencesGroup {
EntryRow("Server URL", text: $serverAddress)
ExpanderRow("Advanced Options") {
EntryRow("Port", text: $port)
SwitchRow(
"Force HTTPS", subtitle: "Enforces connection over HTTPS", active: $forceHttps)
}
} headerSuffix: {
Button {
if isTesting {
Spinner()
} else {
Label("Test Connection")
}
} onClicked: {
connect(persisting: false)
}
.sensitive { !isTesting }
}
Button {
if isConnecting {
Spinner()
} else {
Label("Connect")
}
} onClicked: {
connect(persisting: true)
}
.sensitive { !isConnecting }
.circular()
.suggestedAction()
}
}
}
}
.toastManager(toasts)
.hexpand(true)
.vexpand(true)
} top: {
HeaderBar()
}
.navigationTitle("Server Setup")
.onAppear {
if let preconfiguredServerPath = serverURL?.absoluteString {
serverAddress = preconfiguredServerPath
}
}
}
/// 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)))
}
}
}
}