import SwiftUI import TermindApplication import TermindDomain @MainActor public protocol ConnectionFormKeyboardAdapting { func bridge(canSubmit: Bool, submit: @escaping () -> Void, cancel: @escaping () -> Void) -> AnyView } extension View { @ViewBuilder func connectionFormChrome(isEnabled: Bool) -> some View { if isEnabled { padding(18).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(.quaternary) } } else { self } } } public struct SSHAuthenticationForm: View { let prompt: SSHAuthenticationPrompt let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void let cancel: () -> Void let respondKeyboardInteractive: (([String]) -> Void)? var usesContainerChrome = true let library: HostLibraryModel let keyboard: (any ConnectionFormKeyboardAdapting)? @State private var identityID: Identity.ID? @State private var username: String @State private var password = "" @State private var passphrase = "" @State private var keyID: SSHKeyRef.ID? @FocusState private var focusedField: Field? private enum Field { case username, password, passphrase } public init( prompt: SSHAuthenticationPrompt, submit: @escaping (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void, cancel: @escaping () -> Void, usesContainerChrome: Bool = true, library: HostLibraryModel, keyboard: (any ConnectionFormKeyboardAdapting)? = nil, respondKeyboardInteractive: (([String]) -> Void)? = nil ) { self.respondKeyboardInteractive = respondKeyboardInteractive self.library = library self.keyboard = keyboard self.prompt = prompt self.submit = submit self.cancel = cancel self.usesContainerChrome = usesContainerChrome let initialIdentityID = prompt.selectedIdentityID.flatMap { id -> Identity.ID? in guard let identity = library.identity(id) else { return nil } if identity.isHostCustom { return nil } switch library.passwordAvailability(for: identity) { case .missing, .failed: return nil case .notConfigured, .available: return id } } _identityID = State(initialValue: initialIdentityID) _username = State(initialValue: prompt.username) _keyID = State(initialValue: prompt.selectedKeyID) } private var needsPassphrase: Bool { prompt.requiresPassphrase } private var canContinue: Bool { !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (!needsPassphrase || !passphrase.isEmpty) && (!prompt.isProxy || !password.isEmpty) && !selectedIdentityPasswordIsUnavailable } private var selectedIdentityPasswordIsUnavailable: Bool { guard let identity = selectedIdentity ?? initialCustomIdentity else { return false } return switch library.passwordAvailability(for: identity) { case .missing, .failed: true case .notConfigured, .available: false } } private var needsPasswordEntry: Bool { guard let selectedIdentity else { return true } return library.passwordAvailability(for: selectedIdentity) != .available } private var title: LocalizedStringKey { if needsPassphrase { return "Passphrase required" } if prompt.isProxy { return "Proxy authentication required" } return prompt.mode == .localIdentity ? "Identity required" : "Authentication required" } public var body: some View { Group { if let challenge = prompt.keyboardInteractive, let respondKeyboardInteractive { SSHKeyboardInteractiveForm( challenge: challenge, submit: respondKeyboardInteractive, cancel: cancel, keyboard: keyboard ) } else { form } } .connectionFormChrome(isEnabled: usesContainerChrome) .background { if prompt.keyboardInteractive == nil, let keyboard { keyboard.bridge( canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel) } } .onAppear { if identityID != nil, selectedIdentity == nil { identityID = nil } loadPassword(for: selectedIdentity ?? initialCustomIdentity) updateFocusForRequirement() } } private var form: some View { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 3) { Text(title) .font(.headline) Text(prompt.host.displayAddress).font(.caption.monospaced()).foregroundStyle(.secondary) } Spacer() Image(systemName: "key.fill").font(.title2) .foregroundStyle(Color.accentColor) } if let message = prompt.failureMessage { Label(message, systemImage: "exclamationmark.circle.fill") .font(.caption).foregroundStyle(Color.red) .textSelection(.enabled) } Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { if needsPassphrase { GridRow { Text("Passphrase").foregroundStyle(.secondary) RevealableSecretField("Required for this connection", text: $passphrase) .focused($focusedField, equals: .passphrase) } } else { GridRow { Text("Identity").foregroundStyle(.secondary) fullWidthMenu(title: identityLabel) { Button { identitySelection.wrappedValue = nil } label: { selectionMenuLabel( String(localized: "Custom"), selected: identityID == nil ) } Divider() ForEach(library.reusableIdentities) { identity in Button { identitySelection.wrappedValue = identity.id } label: { selectionMenuLabel( "\(identity.name) · \(identity.username)", selected: identityID == identity.id ) } } } } GridRow { Text(prompt.isProxy ? "Proxy Username" : "Username").foregroundStyle(.secondary) TextField("Username", text: usernameInput.singleLineText, axis: .horizontal) .singleLinePaste().lineLimit(1) .focused($focusedField, equals: .username) } GridRow { Text("Password").foregroundStyle(.secondary) RevealableSecretField(prompt.isProxy ? "Required" : "Optional", text: passwordInput) .focused($focusedField, equals: .password) .disabled(selectedIdentityPasswordIsUnavailable) } if !prompt.isProxy { GridRow { Text("Key").foregroundStyle(.secondary) fullWidthMenu(title: keyLabel) { Button { keySelection.wrappedValue = nil } label: { selectionMenuLabel(String(localized: "None"), selected: keyID == nil) } if !library.keys.isEmpty { Divider() } ForEach(library.keys) { key in Button { keySelection.wrappedValue = key.id } label: { selectionMenuLabel(key.label, selected: keyID == key.id) } } } } } } } .frame(maxWidth: .infinity) .textFieldStyle(.roundedBorder) #if os(iOS) .multilineTextAlignment(.leading) .textInputAutocapitalization(.never) .autocorrectionDisabled() #endif actions } } @ViewBuilder private var actions: some View { if needsPassphrase { ViewThatFits(in: .horizontal) { HStack { cancelButton Spacer() passphraseContinueButton } VStack(spacing: 10) { cancelButton.frame(maxWidth: .infinity, alignment: .leading) passphraseContinueButton.frame(maxWidth: .infinity, alignment: .trailing) } } } else { ViewThatFits(in: .horizontal) { HStack { cancelButton Spacer() continueButton if prompt.allowsSaving { saveAndContinueButton } } VStack(spacing: 10) { HStack { cancelButton Spacer() continueButton } #if os(iOS) HStack { Spacer() if prompt.allowsSaving { saveAndContinueButton } } #else if prompt.allowsSaving { saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing) } #endif } } } } private var cancelButton: some View { Button(action: cancel) { ConnectionActionLabel(title: "Cancel", symbol: "xmark") } .keyboardShortcut(.cancelAction) } private var continueButton: some View { Button { commit(save: false) } label: { ConnectionActionLabel(title: "Continue", symbol: "arrow.right") } .disabled(!canContinue) } private var passphraseContinueButton: some View { continueButton .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } private var saveAndContinueButton: some View { Button { commit(save: true) } label: { ConnectionActionLabel(title: "Save and Continue", symbol: "checkmark") } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) .disabled(!canContinue) } private func updateFocusForRequirement() { if needsPassphrase { focusedField = .passphrase } else if identityID == nil { focusedField = username.isEmpty ? .username : .password } else { focusedField = nil } } private func commit(save: Bool) { let submittedPassword = save ? password : (password.isEmpty ? nil : password) let submittedPassphrase = passphrase.isEmpty ? nil : passphrase password = "" passphrase = "" submit( identityID, username, submittedPassword, submittedPassphrase, keyID, save ) } private var selectedIdentity: Identity? { identityID.flatMap { library.identity($0) } } private var initialCustomIdentity: Identity? { prompt.selectedIdentityID.flatMap { library.identity($0) }.flatMap { $0.isHostCustom ? $0 : nil } } private var identityLabel: String { selectedIdentity.map { "\($0.name) · \($0.username)" } ?? String(localized: "Custom") } private var keyLabel: String { keyID.flatMap { library.key($0) }?.label ?? String(localized: "None") } private func fullWidthMenu( title: String, @ViewBuilder content: @escaping () -> Content ) -> some View { GeometryReader { geometry in Menu(content: content) { HStack(spacing: 8) { Text(title) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 0) Image(systemName: "chevron.down") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) } .padding(.horizontal, 10) .frame(width: geometry.size.width, height: 28) .background(Color.primary.opacity(0.08), in: .rect(cornerRadius: 6)) .overlay { RoundedRectangle(cornerRadius: 6) .strokeBorder(Color.primary.opacity(0.055)) } .contentShape(Rectangle()) } #if os(macOS) .menuStyle(.button) .menuIndicator(.hidden) #endif .buttonStyle(.plain) .frame(width: geometry.size.width, height: 28) .accessibilityLabel(title) } .frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28) } @ViewBuilder private func selectionMenuLabel(_ title: String, selected: Bool) -> some View { if selected { Label(title, systemImage: "checkmark") } else { Text(title) } } private var identitySelection: Binding { Binding { identityID } set: { id in identityID = id guard let identity = id.flatMap({ library.identity($0) }) else { let custom = initialCustomIdentity username = custom?.username ?? prompt.username keyID = custom?.keyID ?? prompt.selectedKeyID loadPassword(for: custom) return } if !prompt.isProxy { username = identity.username } loadPassword(for: identity) passphrase = "" keyID = identity.keyID } } private func loadPassword(for identity: Identity?) { password = "" guard let identity else { return } if case .available = library.passwordAvailability(for: identity) { password = (try? library.editablePassword(for: identity)) ?? "" } } private var usernameInput: Binding { Binding { username } set: { value in username = value if !prompt.isProxy, value != selectedIdentity?.username { identityID = nil } } } private var passwordInput: Binding { Binding { password } set: { value in password = value } } private var keySelection: Binding { Binding { keyID } set: { value in keyID = value if value != selectedIdentity?.keyID { identityID = nil } passphrase = "" } } } public struct SSHHostKeyConfirmationForm: View { let prompt: SSHHostKeyPrompt var displayAddress: String? = nil let proceed: () -> Void let trust: (() -> Void)? let deny: () -> Void var usesContainerChrome = true let keyboard: (any ConnectionFormKeyboardAdapting)? public init( prompt: SSHHostKeyPrompt, displayAddress: String? = nil, proceed: @escaping () -> Void, trust: (() -> Void)?, deny: @escaping () -> Void, usesContainerChrome: Bool = true, keyboard: (any ConnectionFormKeyboardAdapting)? = nil ) { self.prompt = prompt self.displayAddress = displayAddress self.proceed = proceed self.trust = trust self.deny = deny self.usesContainerChrome = usesContainerChrome self.keyboard = keyboard } private var observation: SSHHostKeyObservation { prompt.observation } private var isAdditionalKey: Bool { if case .additionalKey = prompt.kind { return true } return false } public var body: some View { form .connectionFormChrome(isEnabled: usesContainerChrome) .background { if let keyboard { keyboard.bridge(canSubmit: true, submit: { trust?() ?? proceed() }, cancel: deny) } } } private var form: some View { VStack(alignment: .leading, spacing: 14) { HStack { VStack(alignment: .leading, spacing: 3) { Text( isAdditionalKey ? "Unrecognized additional host key" : "Verify host identity" ) .font(.headline) if let displayAddress { Text(displayAddress) .font(.caption.monospaced()) .foregroundStyle(.secondary) } } Spacer() Image( systemName: isAdditionalKey ? "exclamationmark.triangle.fill" : "checkmark.shield.fill" ) .font(.title2) .foregroundStyle( isAdditionalKey ? Color.orange : Color.accentColor ) } Text(explanation) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 4) { Text(observation.algorithm.displayName).font(.caption).foregroundStyle(.secondary) Text(observation.fingerprint).font(.body.monospaced()).textSelection(.enabled) if prompt.uriFingerprintMatched { Label( "The legacy fingerprint in the link matches this host key.", systemImage: "link.badge.checkmark" ) .font(.caption) .foregroundStyle(.secondary) } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(.background.opacity(0.65), in: .rect(cornerRadius: 8)) actions } } @ViewBuilder private var actions: some View { if trust != nil { ViewThatFits(in: .horizontal) { HStack { denyButton Spacer() continueButton trustButton } VStack(spacing: 10) { HStack { denyButton Spacer() continueButton } trustButton.frame(maxWidth: .infinity, alignment: .trailing) } } } else { ViewThatFits(in: .horizontal) { HStack { denyButton Spacer() continueButton } VStack(spacing: 10) { denyButton.frame(maxWidth: .infinity, alignment: .leading) continueButton.frame(maxWidth: .infinity, alignment: .trailing) } } } } private var denyButton: some View { Button(role: .destructive, action: deny) { ConnectionActionLabel(title: "Deny", symbol: "xmark") } .keyboardShortcut(.cancelAction) } @ViewBuilder private var continueButton: some View { if trust == nil { baseContinueButton .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } else { baseContinueButton .help("Continue once without saving this host key") } } private var baseContinueButton: some View { Button(action: proceed) { ConnectionActionLabel(title: "Continue", symbol: "arrow.right") } } private var trustButton: some View { Button(action: { trust?() }) { ConnectionActionLabel(title: "Trust and Continue", symbol: "checkmark.shield") } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } private var explanation: String { switch prompt.kind { case .firstUse: String( localized: "No trusted key is saved for \(observation.hostname). Confirm this fingerprint before continuing." ) case .additionalKey(let trustedKeyCount): String( localized: "This key is not among the \(trustedKeyCount) trusted keys saved for \(observation.hostname). Verify it carefully before adding it." ) } } } private struct ConnectionActionLabel: View { let title: LocalizedStringKey let symbol: String var body: some View { #if os(iOS) Label(title, systemImage: symbol) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .frame(minHeight: 44) #else Text(title) #endif } }