155 lines
6.1 KiB
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)))
|
|
}
|
|
}
|
|
}
|
|
}
|