mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 22:04:19 +00:00
Piped's session token reuses the Authorization header, so a fronting basic auth proxy can't coexist with logged-in Piped use — the two would clobber each other's credentials on every authenticated request. Add a supportsHTTPBasicAuthProxy capability on Instance/InstanceType (false for Piped, true for everything else) and route it through: - AddRemoteServerView refuses Piped if detection only succeeded behind basic auth, surfacing a localized "not supported" error instead of a silently broken instance, and hides the optional credentials section for Piped. - EditSourceView hides the basic auth fields for Piped instances and clears any legacy stored credentials on save, in case a Piped source was added with credentials before this change.
831 lines
30 KiB
Swift
831 lines
30 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 types that can sit
|
|
// behind a basic-auth proxy — 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?.supportsHTTPBasicAuthProxy ?? false) &&
|
|
(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)
|
|
|
|
if basicAuthHeader != nil, !detectionResult.type.supportsHTTPBasicAuthProxy {
|
|
withAnimation {
|
|
self.uiState = .error(String(localized: "sources.error.pipedBasicAuthUnsupported"))
|
|
}
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|