Files
yattee/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift
Arkadiusz Fal 3dd4073db7 Allow HTTP Basic Auth credentials for any remote-server instance type
EditSourceView now exposes the basic-auth username/password fields for every
instance type (Invidious, Piped, PeerTube, Yattee Server), keeping the
existing required-credentials UI for Yattee Server and adding an optional
section for the others. Credentials are loaded and persisted via
BasicAuthCredentialsManager regardless of type, and clearing both fields
deletes stored credentials for non-Yattee types.

AddRemoteServerView gains a new basicAuthRequired UI state: when instance
detection hits a 401 (the entire instance is behind a reverse proxy), the
view reveals username/password fields and a Retry Detection button. The
retry calls the detector with the credentials injected as an Authorization
header; on success the form transitions into the normal detected state with
the credentials pre-populated. A repeat 401 shows an inline "invalid
credentials" message instead of restarting the flow. For non-Yattee types,
any credentials entered during the flow are persisted alongside the new
instance.
2026-04-18 20:38:00 +02:00

742 lines
26 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
actionSection
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#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)
}
#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)
#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)
#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)
#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)
}
}