640 lines
23 KiB
Swift
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
|
|
}
|
|
}
|