mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
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.
742 lines
26 KiB
Swift
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)
|
|
}
|
|
}
|