Files
Termind/TFI/Features/HostEditor/MobileHostEditor.swift
T
Kaede Akino 9738605dd3 feat(terminal): add Mosh and Eternal Terminal transports
Add SSH-authenticated transports with independent settings, protocol-aware menus and snippets, and tmux session persistence.

Restore eligible iOS workspaces from encrypted transport checkpoints without adding a macOS recovery entry. Include schema migrations and regression coverage.
2026-10-16 08:36:56 +08:00

497 lines
25 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
struct MobileHostEditor: View {
private enum EditorRoute: Hashable, Identifiable {
case hostIdentity
case telnetIdentity
case proxyIdentity
case telnetProxyIdentity
case group
case environmentVariable(Int?)
var id: Self { self }
}
let app: MobileAppModel
private var library: HostLibraryModel { app.library }
let original: Host
@State private var tailscaleUsername = ""
@State private var originalTailscaleUsername = ""
@State private var draft: Host
@State private var editorRoute: EditorRoute?
@State private var customIdentity = HostCustomIdentityDraft()
@State private var didLoadCustom = false
@State private var selectedProtocol: HostTerminalProtocol
init(app: MobileAppModel, host: Host) {
self.app = app
original = host
_draft = State(initialValue: host)
_selectedProtocol = State(initialValue: host.sshEnabled ? .ssh : .telnet)
}
var editorTitle: String {
app.library.host(original.id) == nil
? String(localized: AppStrings.Vault.addHost)
: String(localized: AppStrings.Vault.editHost)
}
var body: some View {
MobileEditorSheet(
title: editorTitle,
isDirty: tailscaleUsername != originalTailscaleUsername || draft != original
|| !customIdentity.sshUsername.isEmpty
|| !customIdentity.sshPassword.isEmpty || customIdentity.sshKeyID != nil
|| !customIdentity.telnetUsername.isEmpty || !customIdentity.telnetPassword.isEmpty
|| !customIdentity.telnetProxyPassword.isEmpty,
canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& draft.preferredTerminalProtocol != nil
&& (!draft.sshEnabled || (draft.terminalTransports.isValid && (1...65_535).contains(draft.sshPort)))
&& (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort))
&& (draft.isTailscaleManaged || customIdentity.isSSHValid(for: draft))
&& customIdentity.isProxyValid(for: draft)
&& customIdentity.isTelnetValid(for: draft)
&& customIdentity.isTelnetProxyValid(for: draft)
&& (!draft.sshEnabled || draft.environmentVariablesAreValid),
topBar: AnyView(
HostProtocolProfileControls(protocol: $selectedProtocol).disabled(draft.isTailscaleManaged)),
discardQuestion: app.library.host(original.id) == nil
? AppStrings.Common.discardNewHostQuestion : AppStrings.Common.discardChangesQuestion,
save: {
if draft.isTailscaleManaged {
guard library.saveTailscaleHost(draft, username: tailscaleUsername) else {
throw MobileEditorError.message(
library.storageError ?? TailscaleConfigurationError.unavailable.localizedDescription)
}
} else {
try customIdentity.save(into: &draft, using: library)
}
},
content: {
HostOrganizationSection(host: $draft) {
Picker(AppStrings.Vault.group.swiftUIKey, selection: fields.groupChoice) {
Text(AppStrings.Common.none).tag(HostEditorChoice<HostGroup.ID>.unset)
ForEach(library.groups) { group in
Text(group.name).tag(HostEditorChoice.existing(group.id))
}
Divider()
Text(AppStrings.Vault.newGroup).tag(HostEditorChoice<HostGroup.ID>.new)
}
} tagsField: {
VStack(alignment: .leading, spacing: 8) {
Text(AppStrings.Vault.tags)
MobileTagField(tags: $draft.tags, suggestions: library.allTags)
}
}
HostConnectionFields(host: $draft, protocol: selectedProtocol).disabled(draft.isTailscaleManaged)
if selectedProtocol == .ssh && draft.sshEnabled {
HostTransportSection(host: $draft)
if draft.isTailscaleManaged {
Section(AppStrings.Vault.authentication.swiftUIKey) {
TextField(AppStrings.Vault.username.swiftUIKey, text: $tailscaleUsername)
.textInputAutocapitalization(.never).autocorrectionDisabled()
}
} else {
Section(AppStrings.Vault.authentication.swiftUIKey) {
Picker(AppStrings.Vault.identity.swiftUIKey, selection: fields.identityChoice) {
Text(AppStrings.Vault.custom).tag(HostEditorChoice<Identity.ID>.unset)
Divider()
ForEach(library.reusableIdentities) { identity in
Text(identity.name).tag(HostEditorChoice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(HostEditorChoice<Identity.ID>.new)
}
if usesCustomIdentity {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: $customIdentity.sshUsername.singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(AppStrings.Common.optional, text: $customIdentity.sshPassword)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.sshPasswordIsEditable)
}
Picker(AppStrings.Vault.key.swiftUIKey, selection: $customIdentity.sshKeyID) {
Text(AppStrings.Common.none).tag(SSHKeyRef.ID?.none)
ForEach(library.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) }
}
}
}
Section(AppStrings.Vault.routing.swiftUIKey) {
Picker(AppStrings.Vault.jumpHost.swiftUIKey, selection: $draft.jumpHostID) {
Text(AppStrings.Common.none).tag(UUID?.none)
ForEach(library.hosts.filter { $0.id != draft.id && $0.sshEnabled }) {
Text($0.name).tag(Optional($0.id))
}
}
Picker(AppStrings.Vault.proxy.swiftUIKey, selection: fields.proxyKind) {
Text(AppStrings.Common.none).tag(HostProxy.ProtocolKind?.none)
ForEach(HostProxy.ProtocolKind.allCases) { kind in
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
}
}
if draft.proxy != nil {
LabeledContent(AppStrings.Vault.hostname.swiftUIKey) {
TextField(
AppStrings.Vault.hostname.swiftUIKey,
text: fields.proxyString(\.hostname).singleLineText, axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyHostname.swiftUIKey)
LabeledContent(AppStrings.Common.port.swiftUIKey) {
TextField(
AppStrings.Common.port.swiftUIKey, value: fields.proxyInt(\.port),
format: .number.grouping(.never)
).lineLimit(
1
)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyPort.swiftUIKey)
Picker(AppStrings.Vault.identity.swiftUIKey, selection: fields.proxyIdentityChoice) {
Text(AppStrings.Common.none).tag(HostEditorChoice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(HostEditorChoice<Identity.ID>.custom)
Divider()
ForEach(proxyIdentities) { identity in
Text("\(identity.name) · \(identity.username)")
.tag(HostEditorChoice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(HostEditorChoice<Identity.ID>.new)
}
.accessibilityLabel(AppStrings.Vault.proxyIdentity.swiftUIKey)
if customIdentity.usesCustomProxy {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: fields.proxyString(\.username).singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(
AppStrings.Vault.required, text: $customIdentity.proxyPassword
)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.proxyPasswordIsEditable)
}
} else if draft.proxy?.identityID != nil {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: fields.proxyString(\.username).singleLineText, axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
}
}
}
}
HostSessionSection(
charset: $draft.sshCharset,
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: fields.startupSnippetChoice,
agentForwardingEnabled: $draft.agentForwardingEnabled,
sessionPersistence: $draft.sessionPersistence,
allowsAgentForwarding: !draft.isTailscaleManaged,
environmentVariables: $draft.environmentVariables,
snippets: library.snippets,
snippetPackagePath: library.snippetPackagePath,
editEnvironmentVariable: { editorRoute = .environmentVariable($0) }
)
HostIntegrationSection(mode: $draft.shellIntegrationMode)
} else if selectedProtocol == .telnet && draft.telnetEnabled {
telnetAuthenticationSection
telnetRoutingSection
Section(AppStrings.Vault.session.swiftUIKey) {
Picker(AppStrings.Vault.charset.swiftUIKey, selection: $draft.telnetCharset) {
ForEach(RemoteCharset.allCases) { charset in
Text(charset.displayName).tag(charset)
}
}
}
telnetCommandSection
}
}
)
.sheet(item: $editorRoute) { route in
switch route {
case .hostIdentity:
MobileIdentityEditor(
app: app,
identity: Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage)
) { identity in
customIdentity.selectSSHIdentity(identity, on: &draft)
}
case .telnetIdentity:
MobileIdentityEditor(
app: app,
identity: Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage),
configuration: .proxy
) { identity in
customIdentity.selectTelnetIdentity(identity, on: &draft)
}
case .proxyIdentity:
MobileIdentityEditor(
app: app,
identity: Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage),
configuration: .proxy
) { identity in
customIdentity.selectProxyIdentity(identity, on: &draft)
}
case .telnetProxyIdentity:
MobileIdentityEditor(
app: app,
identity: Identity(
name: "", username: "",
passwordStorage: app.newVaultPasswordStorage),
configuration: .proxy
) { identity in
customIdentity.selectTelnetProxyIdentity(identity, on: &draft)
}
case .group:
MobileHostGroupEditor(app: app, group: nil, parentID: nil) { group in
draft.groupID = group.id
}
case .environmentVariable(let index):
MobileHostEnvironmentVariableEditor(
variable: index.map { draft.environmentVariables[$0] },
usedVariables: Set(
draft.environmentVariables.indices.compactMap {
$0 == index ? nil : draft.environmentVariables[$0].variable
})
) { variable in
if let index {
draft.environmentVariables[index] = variable
} else {
draft.environmentVariables.append(variable)
}
}
}
}
.onAppear(perform: loadCustomOnce)
}
private var telnetAuthenticationSection: some View {
Section(AppStrings.Vault.authentication.swiftUIKey) {
Picker(AppStrings.Vault.identity.swiftUIKey, selection: fields.telnetIdentityChoice) {
Text(AppStrings.Common.none).tag(HostEditorChoice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(HostEditorChoice<Identity.ID>.custom)
Divider()
ForEach(telnetIdentities) { identity in
Text(identity.name).tag(HostEditorChoice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(HostEditorChoice<Identity.ID>.new)
}
if customIdentity.usesCustomTelnet(for: draft) {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey, text: $customIdentity.telnetUsername.singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(AppStrings.Vault.required, text: $customIdentity.telnetPassword)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.telnetPasswordIsEditable)
}
}
}
}
@ViewBuilder
private var telnetRoutingSection: some View {
Section(AppStrings.Vault.routing.swiftUIKey) {
Picker(AppStrings.Vault.proxy.swiftUIKey, selection: fields.telnetProxyKind) {
Text(AppStrings.Common.none).tag(HostProxy.ProtocolKind?.none)
ForEach(HostProxy.ProtocolKind.allCases) { kind in
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
}
}
if draft.telnetProxy != nil {
LabeledContent(AppStrings.Vault.hostname.swiftUIKey) {
TextField(
AppStrings.Vault.hostname.swiftUIKey, text: fields.telnetProxyString(\.hostname).singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Common.port.swiftUIKey) {
TextField(
AppStrings.Common.port.swiftUIKey, value: fields.telnetProxyInt(\.port),
format: .number.grouping(.never)
)
.keyboardType(.numberPad).multilineTextAlignment(.trailing)
}
Picker(AppStrings.Vault.identity.swiftUIKey, selection: fields.telnetProxyIdentityChoice) {
Text(AppStrings.Common.none).tag(HostEditorChoice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(HostEditorChoice<Identity.ID>.custom)
Divider()
ForEach(telnetIdentities) { identity in
Text(identity.name).tag(HostEditorChoice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(HostEditorChoice<Identity.ID>.new)
}
if customIdentity.usesCustomTelnetProxy {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: fields.telnetProxyString(\.username).singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(AppStrings.Vault.required, text: $customIdentity.telnetProxyPassword)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.telnetProxyPasswordIsEditable)
}
}
}
}
}
@ViewBuilder
private var telnetCommandSection: some View {
if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Section(AppStrings.Vault.telnetCommand.swiftUIKey) {
Text(library.telnetCommand(for: draft))
.font(.caption.monospaced()).foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
private var telnetIdentities: [Identity] {
library.reusableIdentities.filter(library.isProxyEligibleOnThisDevice)
}
private var proxyIdentities: [Identity] {
library.reusableIdentities.filter(library.isProxyEligibleOnThisDevice)
}
private var fields: HostEditorBindings {
HostEditorBindings(host: $draft, customIdentity: $customIdentity, library: library) { creation in
switch creation {
case .identity: editorRoute = .hostIdentity
case .telnetIdentity: editorRoute = .telnetIdentity
case .proxyIdentity: editorRoute = .proxyIdentity
case .telnetProxyIdentity: editorRoute = .telnetProxyIdentity
case .group: editorRoute = .group
}
}
}
private var usesCustomIdentity: Bool {
customIdentity.usesCustomSSH(for: draft)
}
private func loadCustomOnce() {
guard !didLoadCustom else { return }
didLoadCustom = true
if draft.isTailscaleManaged {
tailscaleUsername = library.tailscaleUsername(for: draft)
originalTailscaleUsername = tailscaleUsername
} else {
customIdentity = HostCustomIdentityDraft(host: draft, library: library)
}
}
}
private struct MobileHostEnvironmentVariableEditor: View {
let original: HostEnvironmentVariable
let isNew: Bool
let usedVariables: Set<String>
let didSave: (HostEnvironmentVariable) -> Void
@State private var draft: HostEnvironmentVariable
init(
variable: HostEnvironmentVariable?, usedVariables: Set<String>,
didSave: @escaping (HostEnvironmentVariable) -> Void
) {
let initial = variable ?? HostEnvironmentVariable(variable: "", value: "")
original = initial
isNew = variable == nil
self.usedVariables = usedVariables
self.didSave = didSave
_draft = State(initialValue: initial)
}
var body: some View {
MobileEditorSheet(
title: String(
localized: isNew ? AppStrings.Vault.newEnvironmentVariable : AppStrings.Vault.editEnvironmentVariable),
isDirty: draft != original,
canSave: draft.isValid && !usedVariables.contains(draft.variable),
compact: true,
discardQuestion: isNew
? AppStrings.Common.discardNewEnvironmentVariableQuestion : AppStrings.Common.discardChangesQuestion,
save: { didSave(draft) },
content: {
LabeledContent(AppStrings.Vault.variable.swiftUIKey) {
TextField(AppStrings.Vault.variable.swiftUIKey, text: $draft.variable.singleLineText)
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
}
LabeledContent(AppStrings.Common.value.swiftUIKey) {
TextField(AppStrings.Common.value.swiftUIKey, text: $draft.value.singleLineText)
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
}
}
)
}
}
nonisolated enum MobileEditorError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case .message(let value): value
}
}
}