535 lines
18 KiB
Swift
535 lines
18 KiB
Swift
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
@MainActor
|
|
public protocol ConnectionFormKeyboardAdapting {
|
|
func bridge(canSubmit: Bool, submit: @escaping () -> Void, cancel: @escaping () -> Void) -> AnyView
|
|
}
|
|
|
|
extension View {
|
|
@ViewBuilder
|
|
fileprivate 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
|
|
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
|
|
) {
|
|
self.library = library
|
|
self.keyboard = keyboard
|
|
self.prompt = prompt
|
|
self.submit = submit
|
|
self.cancel = cancel
|
|
self.usesContainerChrome = usesContainerChrome
|
|
_identityID = State(initialValue: prompt.selectedIdentityID)
|
|
_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)
|
|
}
|
|
|
|
private var title: LocalizedStringKey {
|
|
if needsPassphrase { return "Passphrase required" }
|
|
return prompt.mode == .localIdentity
|
|
? "Identity required" : "Authentication required"
|
|
}
|
|
|
|
public var body: some View {
|
|
form
|
|
.connectionFormChrome(isEnabled: usesContainerChrome)
|
|
.background {
|
|
if let keyboard {
|
|
keyboard.bridge(canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel)
|
|
}
|
|
}
|
|
.onAppear {
|
|
if identityID != nil, selectedIdentity == nil { identityID = nil }
|
|
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)
|
|
SecureField("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
|
|
)
|
|
}
|
|
if !library.identities.isEmpty { Divider() }
|
|
ForEach(library.identities) { identity in
|
|
Button {
|
|
identitySelection.wrappedValue = identity.id
|
|
} label: {
|
|
selectionMenuLabel(
|
|
"\(identity.name) · \(identity.username)",
|
|
selected: identityID == identity.id
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if identityID == nil {
|
|
GridRow {
|
|
Text("Username").foregroundStyle(.secondary)
|
|
TextField("Username", text: usernameInput)
|
|
.focused($focusedField, equals: .username)
|
|
}
|
|
GridRow {
|
|
Text("Password").foregroundStyle(.secondary)
|
|
SecureField("Optional", text: passwordInput)
|
|
.focused($focusedField, equals: .password)
|
|
}
|
|
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
|
|
saveAndContinueButton
|
|
}
|
|
VStack(spacing: 10) {
|
|
HStack {
|
|
cancelButton
|
|
Spacer()
|
|
continueButton
|
|
}
|
|
saveAndContinueButton.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
submit(
|
|
identityID, username, password.isEmpty ? nil : password,
|
|
passphrase.isEmpty ? nil : passphrase, keyID, save
|
|
)
|
|
}
|
|
|
|
private var selectedIdentity: Identity? {
|
|
identityID.flatMap { library.identity($0) }
|
|
}
|
|
|
|
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<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 { return }
|
|
username = identity.username
|
|
password = ""
|
|
passphrase = ""
|
|
keyID = identity.keyID
|
|
}
|
|
}
|
|
|
|
private var usernameInput: Binding<String> {
|
|
Binding {
|
|
username
|
|
} set: { value in
|
|
username = value
|
|
if value != selectedIdentity?.username { identityID = nil }
|
|
}
|
|
}
|
|
|
|
private var passwordInput: Binding<String> {
|
|
Binding {
|
|
password
|
|
} set: { value in
|
|
password = value
|
|
if !value.isEmpty { identityID = nil }
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
.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
|
|
}
|
|
}
|