mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 22:04:19 +00:00
Use grouped Form style with LabeledContent rows and move primary actions into the sheet toolbar for SMB, WebDAV, Local Folder, Remote Server and the Edit sheet. iOS and tvOS branches unchanged.
821 lines
29 KiB
Swift
821 lines
29 KiB
Swift
//
|
|
// AddRemoteServerView.swift
|
|
// Yattee
|
|
//
|
|
// View for adding a remote server (Invidious, Piped, PeerTube, Yattee Server) as a source.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - UI State Machine
|
|
|
|
/// Represents the current state of the Add Remote Server view.
|
|
private enum RemoteServerUIState: Equatable {
|
|
/// Initial state: URL field visible, manual fields hidden
|
|
case initial
|
|
/// Detection in progress: skeleton loading visible
|
|
case detecting
|
|
/// Detection succeeded: fields revealed with pre-filled values
|
|
case detected(InstanceType)
|
|
/// Detection failed: fields auto-revealed with error message
|
|
case error(String)
|
|
/// Detection hit a 401: the instance is fronted by HTTP Basic Auth.
|
|
/// The view exposes username/password fields and a "Retry detection" button.
|
|
/// `invalidCredentials` is true after a retry that also returned 401.
|
|
case basicAuthRequired(invalidCredentials: Bool)
|
|
|
|
static func == (lhs: RemoteServerUIState, rhs: RemoteServerUIState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.initial, .initial): return true
|
|
case (.detecting, .detecting): return true
|
|
case (.detected(let a), .detected(let b)): return a == b
|
|
case (.error(let a), .error(let b)): return a == b
|
|
case (.basicAuthRequired(let a), .basicAuthRequired(let b)): return a == b
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AddRemoteServerView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
// MARK: - UI State
|
|
|
|
@State private var uiState: RemoteServerUIState = .initial
|
|
@State private var detectionTask: Task<Void, Never>?
|
|
|
|
// MARK: - URL Entry
|
|
|
|
@State private var urlString = ""
|
|
|
|
// MARK: - Server Configuration
|
|
|
|
@State private var name = ""
|
|
@State private var detectedType: InstanceType?
|
|
@State private var detectionResult: InstanceDetectionResult?
|
|
@State private var allowInvalidCertificates = false
|
|
@State private var showSSLToggle = false
|
|
|
|
// HTTP Basic Auth credentials. Required for Yattee Server, optional for any other
|
|
// instance type that sits behind a reverse proxy requiring HTTP Basic Auth.
|
|
@State private var basicAuthUsername = ""
|
|
@State private var basicAuthPassword = ""
|
|
@State private var isValidatingCredentials = false
|
|
@State private var credentialValidationError: String?
|
|
|
|
// Yattee Server warning dialog
|
|
@State private var showingYatteeServerWarning = false
|
|
@State private var pendingYatteeServerInstance: Instance?
|
|
|
|
// Closure to dismiss the parent sheet
|
|
var dismissSheet: DismissAction?
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var isFieldsRevealed: Bool {
|
|
switch uiState {
|
|
case .initial, .detecting, .basicAuthRequired:
|
|
return false
|
|
case .detected, .error:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var isAwaitingBasicAuth: Bool {
|
|
if case .basicAuthRequired = uiState { return true }
|
|
return false
|
|
}
|
|
|
|
private var canAdd: Bool {
|
|
guard !urlString.isEmpty else { return false }
|
|
|
|
// For detected Yattee Server, require credentials
|
|
if detectedType == .yatteeServer {
|
|
return !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
VStack(spacing: 0) {
|
|
formContent
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "sources.yatteeServer.warning.title"),
|
|
isPresented: $showingYatteeServerWarning,
|
|
titleVisibility: .visible
|
|
) {
|
|
yatteeServerWarningButtons
|
|
} message: {
|
|
Text(String(localized: "sources.yatteeServer.warning.message"))
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
#else
|
|
formContent
|
|
.navigationTitle(String(localized: "sources.addRemoteServer"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.confirmationDialog(
|
|
String(localized: "sources.yatteeServer.warning.title"),
|
|
isPresented: $showingYatteeServerWarning,
|
|
titleVisibility: .visible
|
|
) {
|
|
yatteeServerWarningButtons
|
|
} message: {
|
|
Text(String(localized: "sources.yatteeServer.warning.message"))
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var yatteeServerWarningButtons: some View {
|
|
Button(String(localized: "sources.yatteeServer.warning.disableOthers"), role: .destructive) {
|
|
if let instance = pendingYatteeServerInstance {
|
|
appEnvironment?.instancesManager.disableOtherYatteeServerInstances(except: instance.id)
|
|
appEnvironment?.instancesManager.add(instance)
|
|
if let dismissSheet {
|
|
dismissSheet()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
pendingYatteeServerInstance = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Form Content
|
|
|
|
private var formContent: some View {
|
|
Form {
|
|
urlEntrySection
|
|
|
|
if case .detecting = uiState {
|
|
skeletonSection
|
|
}
|
|
|
|
if case .error(let message) = uiState {
|
|
errorSection(message)
|
|
}
|
|
|
|
if case .basicAuthRequired(let invalidCredentials) = uiState {
|
|
basicAuthRequiredSection(invalidCredentials: invalidCredentials)
|
|
}
|
|
|
|
if isFieldsRevealed {
|
|
serverConfigurationFields
|
|
#if !os(macOS)
|
|
actionSection
|
|
#endif
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
#endif
|
|
#if os(macOS)
|
|
.formStyle(.grouped)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
if isFieldsRevealed {
|
|
Button {
|
|
addSource()
|
|
} label: {
|
|
if isValidatingCredentials {
|
|
HStack(spacing: 6) {
|
|
ProgressView().controlSize(.small)
|
|
Text(String(localized: "sources.validatingCredentials"))
|
|
}
|
|
} else {
|
|
Text(String(localized: "sources.addSource"))
|
|
}
|
|
}
|
|
.disabled(!canAdd || isValidatingCredentials)
|
|
.keyboardShortcut(.defaultAction)
|
|
.accessibilityIdentifier("addRemoteServer.actionButton")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - URL Entry Section
|
|
|
|
private var urlEntrySection: some View {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.placeholder.urlOrAddress"), text: $urlString)
|
|
.onChange(of: urlString) { _, _ in
|
|
handleURLChange()
|
|
}
|
|
|
|
if !isFieldsRevealed && !isAwaitingBasicAuth {
|
|
Button {
|
|
startDetection()
|
|
} label: {
|
|
if case .detecting = uiState {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "sources.detecting"))
|
|
}
|
|
} else {
|
|
Text(String(localized: "sources.detect"))
|
|
}
|
|
}
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
.disabled(urlString.isEmpty || uiState == .detecting)
|
|
}
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.url")) {
|
|
TextField("", text: $urlString, prompt: Text(String(localized: "sources.placeholder.urlOrAddress")))
|
|
.textContentType(.URL)
|
|
.autocorrectionDisabled()
|
|
.accessibilityIdentifier("addRemoteServer.urlField")
|
|
.onChange(of: urlString) { _, _ in
|
|
handleURLChange()
|
|
}
|
|
}
|
|
|
|
if !isFieldsRevealed && !isAwaitingBasicAuth {
|
|
HStack {
|
|
Spacer()
|
|
Button {
|
|
startDetection()
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
if case .detecting = uiState {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
Text(String(localized: "sources.detect"))
|
|
}
|
|
}
|
|
.disabled(urlString.isEmpty || uiState == .detecting)
|
|
.accessibilityIdentifier("addRemoteServer.detectButton")
|
|
}
|
|
}
|
|
#else
|
|
TextField(String(localized: "sources.placeholder.urlOrAddress"), text: $urlString)
|
|
.textContentType(.URL)
|
|
#if os(iOS)
|
|
.keyboardType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
.accessibilityIdentifier("addRemoteServer.urlField")
|
|
.onChange(of: urlString) { _, _ in
|
|
handleURLChange()
|
|
}
|
|
|
|
if !isFieldsRevealed && !isAwaitingBasicAuth {
|
|
Button {
|
|
startDetection()
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
if case .detecting = uiState {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
Text(String(localized: "sources.detect"))
|
|
}
|
|
}
|
|
.disabled(urlString.isEmpty || uiState == .detecting)
|
|
.accessibilityIdentifier("addRemoteServer.detectButton")
|
|
}
|
|
#endif
|
|
} footer: {
|
|
Text(String(localized: "sources.footer.remoteServer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Skeleton Loading Section
|
|
|
|
private var skeletonSection: some View {
|
|
Group {
|
|
Section {
|
|
Text("my-server.example.com")
|
|
.redacted(reason: .placeholder)
|
|
} header: {
|
|
Text(String(localized: "sources.detecting"))
|
|
}
|
|
|
|
Section {
|
|
Text(String(localized: "sources.field.name"))
|
|
.redacted(reason: .placeholder)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Section
|
|
|
|
private func errorSection(_ message: String) -> some View {
|
|
Section {
|
|
Label(message, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
.accessibilityIdentifier("addRemoteServer.detectionError")
|
|
}
|
|
}
|
|
|
|
// MARK: - Basic Auth Required Section
|
|
|
|
@ViewBuilder
|
|
private func basicAuthRequiredSection(invalidCredentials: Bool) -> some View {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
|
|
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.username")) {
|
|
TextField("", text: $basicAuthUsername)
|
|
.textContentType(.username)
|
|
.autocorrectionDisabled()
|
|
}
|
|
LabeledContent(String(localized: "sources.field.password")) {
|
|
SecureField("", text: $basicAuthPassword)
|
|
.textContentType(.password)
|
|
}
|
|
#else
|
|
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
|
|
.textContentType(.username)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
|
|
SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
|
|
.textContentType(.password)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "sources.header.basicAuth"))
|
|
} footer: {
|
|
if invalidCredentials {
|
|
Text(String(localized: "sources.error.basicAuthInvalid"))
|
|
.foregroundStyle(.red)
|
|
} else {
|
|
Text(String(localized: "sources.footer.basicAuthRequired"))
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button {
|
|
retryDetectionWithBasicAuth()
|
|
} label: {
|
|
if case .detecting = uiState {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "sources.detecting"))
|
|
}
|
|
} else {
|
|
Text(String(localized: "sources.retryDetection"))
|
|
}
|
|
}
|
|
.disabled(basicAuthUsername.isEmpty || basicAuthPassword.isEmpty || uiState == .detecting)
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
.accessibilityIdentifier("addRemoteServer.retryDetectionButton")
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Configuration Fields
|
|
|
|
@ViewBuilder
|
|
private var serverConfigurationFields: some View {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.nameOptional"), text: $name)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.name")) {
|
|
TextField("", text: $name, prompt: Text(String(localized: "sources.field.nameOptional")))
|
|
}
|
|
#else
|
|
TextField(String(localized: "sources.field.nameOptional"), text: $name)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "sources.header.displayName"))
|
|
}
|
|
|
|
// Show detected type badge
|
|
if let detectedType {
|
|
Section {
|
|
HStack {
|
|
Label(detectedType.displayName, systemImage: detectedType.systemImage)
|
|
.foregroundStyle(.green)
|
|
Spacer()
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
}
|
|
.accessibilityIdentifier("addRemoteServer.detectedType")
|
|
} header: {
|
|
Text(String(localized: "sources.detectedType"))
|
|
}
|
|
}
|
|
|
|
// SSL Certificate toggle (show if SSL error occurred)
|
|
if showSSLToggle {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsToggle(
|
|
title: String(localized: "sources.field.allowInvalidCertificates"),
|
|
isOn: $allowInvalidCertificates
|
|
)
|
|
#else
|
|
Toggle(String(localized: "sources.field.allowInvalidCertificates"), isOn: $allowInvalidCertificates)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: "sources.header.security"))
|
|
} footer: {
|
|
Text(String(localized: "sources.footer.allowInvalidCertificates"))
|
|
}
|
|
}
|
|
|
|
// HTTP Basic Auth credentials.
|
|
// Required for Yattee Server (always shown). Optional for other types — show only
|
|
// when credentials were already provided (e.g., via the basic-auth-required retry
|
|
// path), so we don't clutter the form for the normal "no proxy" case.
|
|
let showBasicAuthSection = detectedType == .yatteeServer
|
|
|| (!basicAuthUsername.isEmpty || !basicAuthPassword.isEmpty)
|
|
|
|
if showBasicAuthSection {
|
|
Section {
|
|
#if os(tvOS)
|
|
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
|
|
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
|
|
#elseif os(macOS)
|
|
LabeledContent(String(localized: "sources.field.username")) {
|
|
TextField("", text: $basicAuthUsername)
|
|
.textContentType(.username)
|
|
.autocorrectionDisabled()
|
|
}
|
|
LabeledContent(String(localized: "sources.field.password")) {
|
|
SecureField("", text: $basicAuthPassword)
|
|
.textContentType(.password)
|
|
}
|
|
#else
|
|
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
|
|
.textContentType(.username)
|
|
#if os(iOS)
|
|
.textInputAutocapitalization(.never)
|
|
#endif
|
|
.autocorrectionDisabled()
|
|
|
|
SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
|
|
.textContentType(.password)
|
|
#endif
|
|
} header: {
|
|
Text(String(localized: detectedType == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth"))
|
|
} footer: {
|
|
Text(String(localized: detectedType == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth"))
|
|
}
|
|
|
|
if let error = credentialValidationError {
|
|
Section {
|
|
Label(error, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Action Section
|
|
|
|
private var actionSection: some View {
|
|
Section {
|
|
Button {
|
|
addSource()
|
|
} label: {
|
|
if isValidatingCredentials {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(String(localized: "sources.validatingCredentials"))
|
|
}
|
|
} else {
|
|
Text(String(localized: "sources.addSource"))
|
|
}
|
|
}
|
|
.accessibilityIdentifier("addRemoteServer.actionButton")
|
|
.disabled(!canAdd || isValidatingCredentials)
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsButtonStyle())
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func handleURLChange() {
|
|
cancelDetection()
|
|
|
|
if isFieldsRevealed || isAwaitingBasicAuth {
|
|
withAnimation {
|
|
uiState = .initial
|
|
detectedType = nil
|
|
detectionResult = nil
|
|
showSSLToggle = false
|
|
basicAuthUsername = ""
|
|
basicAuthPassword = ""
|
|
credentialValidationError = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cancelDetection() {
|
|
detectionTask?.cancel()
|
|
detectionTask = nil
|
|
}
|
|
|
|
private func startDetection() {
|
|
guard !urlString.isEmpty else { return }
|
|
|
|
guard let url = Instance.normalizeSourceURL(urlString) else {
|
|
withAnimation {
|
|
uiState = .error(String(localized: "sources.validation.invalidURL"))
|
|
}
|
|
return
|
|
}
|
|
|
|
cancelDetection()
|
|
|
|
withAnimation {
|
|
uiState = .detecting
|
|
}
|
|
|
|
detectionTask = Task {
|
|
await performDetection(url: url)
|
|
}
|
|
}
|
|
|
|
private func performDetection(url: URL, basicAuthHeader: String? = nil) async {
|
|
guard let appEnvironment else { return }
|
|
|
|
let detector: InstanceDetector
|
|
if allowInvalidCertificates {
|
|
let insecureClient = appEnvironment.httpClientFactory.createClient(allowInvalidCertificates: true)
|
|
detector = InstanceDetector(httpClient: insecureClient)
|
|
} else {
|
|
detector = appEnvironment.instanceDetector
|
|
}
|
|
|
|
let result = await detector.detectWithResult(url: url, basicAuthHeader: basicAuthHeader)
|
|
|
|
if Task.isCancelled { return }
|
|
|
|
await MainActor.run {
|
|
switch result {
|
|
case .success(let detectionResult):
|
|
LoggingService.shared.debug("[AddRemoteServerView] Detection succeeded: \(detectionResult.type)", category: .api)
|
|
withAnimation {
|
|
self.detectedType = detectionResult.type
|
|
self.detectionResult = detectionResult
|
|
self.uiState = .detected(detectionResult.type)
|
|
}
|
|
|
|
case .failure(.basicAuthRequired):
|
|
LoggingService.shared.debug("[AddRemoteServerView] Detection requires basic auth credentials", category: .api)
|
|
withAnimation {
|
|
self.uiState = .basicAuthRequired(invalidCredentials: false)
|
|
}
|
|
|
|
case .failure(.basicAuthInvalid):
|
|
LoggingService.shared.debug("[AddRemoteServerView] Basic auth credentials rejected by server", category: .api)
|
|
withAnimation {
|
|
self.uiState = .basicAuthRequired(invalidCredentials: true)
|
|
}
|
|
|
|
case .failure(let error):
|
|
LoggingService.shared.debug("[AddRemoteServerView] Detection failed: \(error)", category: .api)
|
|
withAnimation {
|
|
if case .sslCertificateError = error {
|
|
self.showSSLToggle = true
|
|
}
|
|
self.uiState = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func retryDetectionWithBasicAuth() {
|
|
guard !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty else { return }
|
|
guard let url = Instance.normalizeSourceURL(urlString) else {
|
|
withAnimation {
|
|
uiState = .error(String(localized: "sources.validation.invalidURL"))
|
|
}
|
|
return
|
|
}
|
|
|
|
let credentials = "\(basicAuthUsername):\(basicAuthPassword)"
|
|
guard let credentialData = credentials.data(using: .utf8) else { return }
|
|
let authHeader = "Basic \(credentialData.base64EncodedString())"
|
|
|
|
cancelDetection()
|
|
|
|
withAnimation {
|
|
uiState = .detecting
|
|
}
|
|
|
|
detectionTask = Task {
|
|
await performDetection(url: url, basicAuthHeader: authHeader)
|
|
}
|
|
}
|
|
|
|
private func addSource() {
|
|
guard let appEnvironment else { return }
|
|
|
|
guard let url = Instance.normalizeSourceURL(urlString) else {
|
|
withAnimation {
|
|
uiState = .error(String(localized: "sources.validation.invalidURL"))
|
|
}
|
|
return
|
|
}
|
|
|
|
// If we have a detected type, use it directly
|
|
if let detectedType {
|
|
addServer(type: detectedType, url: url, appEnvironment: appEnvironment)
|
|
return
|
|
}
|
|
|
|
// Otherwise, detect first then add
|
|
withAnimation {
|
|
uiState = .detecting
|
|
}
|
|
|
|
detectionTask = Task {
|
|
let detector: InstanceDetector
|
|
if allowInvalidCertificates {
|
|
let insecureClient = appEnvironment.httpClientFactory.createClient(allowInvalidCertificates: true)
|
|
detector = InstanceDetector(httpClient: insecureClient)
|
|
} else {
|
|
detector = appEnvironment.instanceDetector
|
|
}
|
|
|
|
let result = await detector.detectWithResult(url: url)
|
|
|
|
if Task.isCancelled { return }
|
|
|
|
await MainActor.run {
|
|
switch result {
|
|
case .success(let detectionResult):
|
|
self.detectedType = detectionResult.type
|
|
self.detectionResult = detectionResult
|
|
|
|
// For Yattee Server, show auth fields instead of auto-adding
|
|
if detectionResult.type == .yatteeServer {
|
|
withAnimation {
|
|
self.uiState = .detected(detectionResult.type)
|
|
}
|
|
} else {
|
|
// Auto-add for other types
|
|
addServer(type: detectionResult.type, url: url, appEnvironment: appEnvironment)
|
|
}
|
|
|
|
case .failure(.basicAuthRequired):
|
|
withAnimation {
|
|
self.uiState = .basicAuthRequired(invalidCredentials: false)
|
|
}
|
|
|
|
case .failure(.basicAuthInvalid):
|
|
withAnimation {
|
|
self.uiState = .basicAuthRequired(invalidCredentials: true)
|
|
}
|
|
|
|
case .failure(let error):
|
|
withAnimation {
|
|
if case .sslCertificateError = error {
|
|
self.showSSLToggle = true
|
|
}
|
|
self.uiState = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func addServer(type: InstanceType, url: URL, appEnvironment: AppEnvironment) {
|
|
// For Yattee Server, always validate credentials first
|
|
if type == .yatteeServer {
|
|
guard !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty else {
|
|
credentialValidationError = String(localized: "sources.error.credentialsRequired")
|
|
return
|
|
}
|
|
|
|
isValidatingCredentials = true
|
|
credentialValidationError = nil
|
|
|
|
Task {
|
|
let isValid = await validateYatteeServerCredentials(
|
|
url: url,
|
|
username: basicAuthUsername,
|
|
password: basicAuthPassword,
|
|
appEnvironment: appEnvironment
|
|
)
|
|
|
|
await MainActor.run {
|
|
isValidatingCredentials = false
|
|
|
|
if isValid {
|
|
let instance = Instance(
|
|
type: type,
|
|
url: url,
|
|
name: name.isEmpty ? nil : name,
|
|
allowInvalidCertificates: allowInvalidCertificates
|
|
)
|
|
|
|
appEnvironment.basicAuthCredentialsManager.setCredentials(
|
|
username: basicAuthUsername,
|
|
password: basicAuthPassword,
|
|
for: instance
|
|
)
|
|
|
|
if !appEnvironment.instancesManager.enabledYatteeServerInstances.isEmpty {
|
|
pendingYatteeServerInstance = instance
|
|
showingYatteeServerWarning = true
|
|
} else {
|
|
appEnvironment.instancesManager.add(instance)
|
|
if let dismissSheet {
|
|
dismissSheet()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
} else {
|
|
credentialValidationError = String(localized: "sources.error.invalidCredentials")
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// For other instance types: optionally store HTTP Basic Auth credentials
|
|
// (used when the instance is fronted by a reverse proxy that requires basic auth).
|
|
let instance = Instance(
|
|
type: type,
|
|
url: url,
|
|
name: name.isEmpty ? nil : name,
|
|
allowInvalidCertificates: allowInvalidCertificates
|
|
)
|
|
|
|
if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty {
|
|
appEnvironment.basicAuthCredentialsManager.setCredentials(
|
|
username: basicAuthUsername,
|
|
password: basicAuthPassword,
|
|
for: instance
|
|
)
|
|
}
|
|
|
|
appEnvironment.instancesManager.add(instance)
|
|
if let dismissSheet {
|
|
dismissSheet()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
private func validateYatteeServerCredentials(url: URL, username: String, password: String, appEnvironment: AppEnvironment) async -> Bool {
|
|
let client: HTTPClient
|
|
if allowInvalidCertificates {
|
|
client = appEnvironment.httpClientFactory.createClient(allowInvalidCertificates: true)
|
|
} else {
|
|
client = appEnvironment.httpClient
|
|
}
|
|
|
|
let credentials = "\(username):\(password)"
|
|
guard let credentialData = credentials.data(using: .utf8) else { return false }
|
|
let authHeader = "Basic \(credentialData.base64EncodedString())"
|
|
|
|
let endpoint = GenericEndpoint.get("/info", customHeaders: ["Authorization": authHeader])
|
|
|
|
do {
|
|
let response: YatteeServerInfoValidation = try await client.fetch(endpoint, baseURL: url)
|
|
return response.version != nil
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Yattee Server Validation Response
|
|
|
|
private struct YatteeServerInfoValidation: Decodable {
|
|
let name: String?
|
|
let version: String?
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
AddRemoteServerView()
|
|
.appEnvironment(.preview)
|
|
}
|
|
}
|