// // SetupPage.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 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))) } } } }