diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index d2b62736..6a58ef6c 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -14560,6 +14560,17 @@ } } }, + "sources.retryDetection" : { + "comment" : "Button to retry instance detection after providing HTTP Basic Auth credentials", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry Detection" + } + } + } + }, "sources.editSource" : { "comment" : "Title for edit source view", "localizations" : { @@ -15011,6 +15022,28 @@ } } }, + "sources.footer.basicAuth" : { + "comment" : "Footer text explaining optional HTTP Basic Auth credentials for instances behind a reverse proxy", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional. Provide a username and password if this instance sits behind a reverse proxy that requires HTTP Basic Auth." + } + } + } + }, + "sources.footer.basicAuthRequired" : { + "comment" : "Footer text shown when the instance is behind HTTP Basic Auth and credentials are required to detect it", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This server is behind HTTP Basic Auth. Enter the username and password to continue." + } + } + } + }, "sources.footer.yatteeServerAuth" : { "comment" : "Footer text explaining Yattee Server authentication", "localizations" : { @@ -15055,6 +15088,17 @@ } } }, + "sources.header.basicAuth" : { + "comment" : "Section header for HTTP Basic Auth credentials", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HTTP Basic Authentication" + } + } + } + }, "sources.header.displayName" : { "comment" : "Section header for display name", "localizations" : { diff --git a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift index 963783e7..8d31e80c 100644 --- a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift +++ b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift @@ -19,6 +19,10 @@ private enum RemoteServerUIState: Equatable { 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) { @@ -26,6 +30,7 @@ private enum RemoteServerUIState: Equatable { 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 } } @@ -52,9 +57,10 @@ struct AddRemoteServerView: View { @State private var allowInvalidCertificates = false @State private var showSSLToggle = false - // Yattee Server authentication (always required) - @State private var yatteeServerUsername = "" - @State private var yatteeServerPassword = "" + // 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? @@ -69,19 +75,24 @@ struct AddRemoteServerView: View { private var isFieldsRevealed: Bool { switch uiState { - case .initial, .detecting: + 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 !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty + return !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty } return true @@ -155,6 +166,10 @@ struct AddRemoteServerView: View { errorSection(message) } + if case .basicAuthRequired(let invalidCredentials) = uiState { + basicAuthRequiredSection(invalidCredentials: invalidCredentials) + } + if isFieldsRevealed { serverConfigurationFields actionSection @@ -175,7 +190,7 @@ struct AddRemoteServerView: View { handleURLChange() } - if !isFieldsRevealed { + if !isFieldsRevealed && !isAwaitingBasicAuth { Button { startDetection() } label: { @@ -205,7 +220,7 @@ struct AddRemoteServerView: View { handleURLChange() } - if !isFieldsRevealed { + if !isFieldsRevealed && !isAwaitingBasicAuth { Button { startDetection() } label: { @@ -254,6 +269,58 @@ struct AddRemoteServerView: View { } } + // 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 @@ -302,27 +369,33 @@ struct AddRemoteServerView: View { } } - // Authentication fields for Yattee Server (always required) - if detectedType == .yatteeServer { + // 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: $yatteeServerUsername) - TVSettingsTextField(title: String(localized: "sources.field.password"), text: $yatteeServerPassword, isSecure: true) + 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: $yatteeServerUsername) + TextField(String(localized: "sources.field.username"), text: $basicAuthUsername) .textContentType(.username) #if os(iOS) .textInputAutocapitalization(.never) #endif .autocorrectionDisabled() - SecureField(String(localized: "sources.field.password"), text: $yatteeServerPassword) + SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword) .textContentType(.password) #endif } header: { - Text(String(localized: "sources.header.auth")) + Text(String(localized: detectedType == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth")) } footer: { - Text(String(localized: "sources.footer.yatteeServerAuth")) + Text(String(localized: detectedType == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth")) } if let error = credentialValidationError { @@ -364,12 +437,15 @@ struct AddRemoteServerView: View { private func handleURLChange() { cancelDetection() - if isFieldsRevealed { + if isFieldsRevealed || isAwaitingBasicAuth { withAnimation { uiState = .initial detectedType = nil detectionResult = nil showSSLToggle = false + basicAuthUsername = "" + basicAuthPassword = "" + credentialValidationError = nil } } } @@ -400,7 +476,7 @@ struct AddRemoteServerView: View { } } - private func performDetection(url: URL) async { + private func performDetection(url: URL, basicAuthHeader: String? = nil) async { guard let appEnvironment else { return } let detector: InstanceDetector @@ -411,7 +487,7 @@ struct AddRemoteServerView: View { detector = appEnvironment.instanceDetector } - let result = await detector.detectWithResult(url: url) + let result = await detector.detectWithResult(url: url, basicAuthHeader: basicAuthHeader) if Task.isCancelled { return } @@ -425,6 +501,18 @@ struct AddRemoteServerView: View { 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 { @@ -437,6 +525,30 @@ struct AddRemoteServerView: View { } } + 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 } @@ -487,6 +599,16 @@ struct AddRemoteServerView: View { 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 { @@ -502,7 +624,7 @@ struct AddRemoteServerView: View { private func addServer(type: InstanceType, url: URL, appEnvironment: AppEnvironment) { // For Yattee Server, always validate credentials first if type == .yatteeServer { - guard !yatteeServerUsername.isEmpty, !yatteeServerPassword.isEmpty else { + guard !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty else { credentialValidationError = String(localized: "sources.error.credentialsRequired") return } @@ -513,8 +635,8 @@ struct AddRemoteServerView: View { Task { let isValid = await validateYatteeServerCredentials( url: url, - username: yatteeServerUsername, - password: yatteeServerPassword, + username: basicAuthUsername, + password: basicAuthPassword, appEnvironment: appEnvironment ) @@ -530,8 +652,8 @@ struct AddRemoteServerView: View { ) appEnvironment.basicAuthCredentialsManager.setCredentials( - username: yatteeServerUsername, - password: yatteeServerPassword, + username: basicAuthUsername, + password: basicAuthPassword, for: instance ) @@ -554,7 +676,8 @@ struct AddRemoteServerView: View { return } - // For other instance types (no auth required) + // 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, @@ -562,6 +685,14 @@ struct AddRemoteServerView: View { allowInvalidCertificates: allowInvalidCertificates ) + if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty { + appEnvironment.basicAuthCredentialsManager.setCredentials( + username: basicAuthUsername, + password: basicAuthPassword, + for: instance + ) + } + appEnvironment.instancesManager.add(instance) if let dismissSheet { dismissSheet() diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 9ccbc234..56b45ad2 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -36,9 +36,13 @@ private struct EditRemoteServerContent: View { @State private var allowInvalidCertificates: Bool @State private var proxiesVideos: Bool - // Yattee Server credentials - @State private var yatteeServerUsername: String = "" - @State private var yatteeServerPassword: String = "" + // HTTP Basic Auth credentials (for any instance type behind a reverse proxy; + // required for Yattee Server, optional for Invidious/Piped/PeerTube). + @State private var basicAuthUsername: String = "" + @State private var basicAuthPassword: String = "" + /// Tracks whether credentials existed when the view loaded, so we can detect + /// "user cleared the fields" and delete the stored credentials on save. + @State private var hadStoredBasicAuth: Bool = false // Invidious login state @State private var showLoginSheet = false @@ -137,28 +141,28 @@ private struct EditRemoteServerContent: View { #endif } - if instance.type == .yatteeServer { - Section { - #if os(tvOS) - TVSettingsTextField(title: String(localized: "sources.field.username"), text: $yatteeServerUsername) - TVSettingsTextField(title: String(localized: "sources.field.password"), text: $yatteeServerPassword, isSecure: true) - #else - TextField(String(localized: "sources.field.username"), text: $yatteeServerUsername) - .textContentType(.username) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif - .autocorrectionDisabled() - - SecureField(String(localized: "sources.field.password"), text: $yatteeServerPassword) - .textContentType(.password) + 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 - } header: { - Text(String(localized: "sources.header.auth")) - } footer: { - Text(String(localized: "sources.footer.yatteeServerAuth")) - } + .autocorrectionDisabled() + SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword) + .textContentType(.password) + #endif + } header: { + Text(String(localized: instance.type == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth")) + } footer: { + Text(String(localized: instance.type == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth")) + } + + if instance.type == .yatteeServer { // Server Info Section Section { if isLoadingServerInfo { @@ -344,11 +348,11 @@ private struct EditRemoteServerContent: View { .onAppear { isLoggedIn = appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false - // Load existing Yattee Server credentials - if instance.type == .yatteeServer, - let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) { - yatteeServerUsername = credentials.username - yatteeServerPassword = credentials.password + // Load existing HTTP Basic Auth credentials (works for any instance type) + if let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) { + basicAuthUsername = credentials.username + basicAuthPassword = credentials.password + hadStoredBasicAuth = true } } .task { @@ -436,13 +440,17 @@ private struct EditRemoteServerContent: View { updated.allowInvalidCertificates = allowInvalidCertificates updated.proxiesVideos = proxiesVideos - // Save Yattee Server credentials if provided - if instance.type == .yatteeServer && !yatteeServerUsername.isEmpty && !yatteeServerPassword.isEmpty { + // Save / clear HTTP Basic Auth credentials. + // Works for any instance type, but for Yattee Server we never auto-clear + // (credentials are required there; the user can re-enter to overwrite). + if !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty { appEnvironment?.basicAuthCredentialsManager.setCredentials( - username: yatteeServerUsername, - password: yatteeServerPassword, + username: basicAuthUsername, + password: basicAuthPassword, for: instance ) + } else if hadStoredBasicAuth && instance.type != .yatteeServer { + appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) } appEnvironment?.instancesManager.update(updated)