Files

640 lines
23 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import SwiftUI
import TermindApplication
import TermindDomain
@MainActor
public protocol ConnectionFormKeyboardAdapting {
func bridge(canSubmit: Bool, submit: @escaping () -> Void, cancel: @escaping () -> Void) -> AnyView
}
extension View {
@ViewBuilder
public 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 !prompt.usernameOnly else { return nil }
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 {
if prompt.usernameOnly { return !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return !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 title: LocalizedStringResource {
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 prompt.usernameOnly {
usernameForm
} else 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: prompt.allowsSaving) }, cancel: cancel)
}
}
.onDisappear {
password = ""
passphrase = ""
}
.onAppear {
if prompt.usernameOnly {
focusedField = .username
return
}
if identityID != nil, selectedIdentity == nil { identityID = nil }
loadPassword(for: selectedIdentity ?? initialCustomIdentity)
updateFocusForRequirement()
}
}
private var usernameForm: some View {
VStack(alignment: .leading, spacing: 14) {
if let message = prompt.failureMessage {
Text(message).font(.caption).foregroundStyle(.red).textSelection(.enabled)
}
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(AppStrings.Vault.username.swiftUIKey, text: $username.singleLineText)
.singleLinePaste().lineLimit(1)
.focused($focusedField, equals: .username)
#if os(iOS)
.textInputAutocapitalization(.never).autocorrectionDisabled()
#endif
}
actions
}
}
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(AppStrings.Keychain.passphrase).foregroundStyle(.secondary)
RevealableSecretField(
"Required for this connection", text: $passphrase, appearance: .roundedBorder
)
.focused($focusedField, equals: .passphrase)
}
} else {
GridRow {
Text(AppStrings.Vault.identity).foregroundStyle(.secondary)
fullWidthMenu(title: identityLabel) {
Button {
identitySelection.wrappedValue = nil
} label: {
selectionMenuLabel(
String(localized: AppStrings.Vault.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 ? AppStrings.Vault.proxyUsername : AppStrings.Vault.username)
.foregroundStyle(.secondary)
TextField(
AppStrings.Vault.username.swiftUIKey, text: usernameInput.singleLineText, axis: .horizontal
)
.singleLinePaste().lineLimit(1)
.focused($focusedField, equals: .username)
}
GridRow {
Text(AppStrings.Vault.password).foregroundStyle(.secondary)
RevealableSecretField(
prompt.isProxy ? AppStrings.Vault.required : AppStrings.Common.optional,
text: passwordInput, appearance: .roundedBorder
)
.focused($focusedField, equals: .password)
.disabled(selectedIdentityPasswordIsUnavailable)
}
if !prompt.isProxy {
GridRow {
Text(AppStrings.Vault.key).foregroundStyle(.secondary)
fullWidthMenu(title: keyLabel) {
Button {
keySelection.wrappedValue = nil
} label: {
selectionMenuLabel(
String(localized: AppStrings.Common.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 {
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: AppStrings.Common.cancel, symbol: "xmark") }
.keyboardShortcut(.cancelAction)
}
@ViewBuilder
private var continueButton: some View {
if prompt.allowsSaving {
baseContinueButton
} else {
baseContinueButton.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
}
}
private var baseContinueButton: some View {
Button {
commit(save: false)
} label: {
ConnectionActionLabel(title: AppStrings.Common.continue, symbol: "arrow.right")
}
.disabled(!canContinue)
}
private var saveAndContinueButton: some View {
Button {
commit(save: true)
} label: {
ConnectionActionLabel(title: AppStrings.Keychain.saveAndContinue, 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 save = save && prompt.allowsSaving
if prompt.usernameOnly {
submit(nil, username.trimmingCharacters(in: .whitespacesAndNewlines), nil, nil, nil, save)
return
}
let submittedPassword = needsPassphrase ? nil : (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: AppStrings.Vault.custom)
}
private var keyLabel: String {
keyID.flatMap { library.key($0) }?.label ?? String(localized: AppStrings.Common.none)
}
private func fullWidthMenu<Content: View>(
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<Identity.ID?> {
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<String> {
Binding {
username
} set: { value in
username = value
if !prompt.isProxy, value != selectedIdentity?.username { identityID = nil }
}
}
private var passwordInput: Binding<String> {
Binding {
password
} set: { value in
password = value
}
}
private var keySelection: Binding<SSHKeyRef.ID?> {
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: AppStrings.Common.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: AppStrings.Common.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: LocalizedStringResource
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
}
}