577 lines
25 KiB
Swift
577 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 }
|
|
}
|
|
|
|
private enum Choice<ID: Hashable>: Hashable {
|
|
case unset
|
|
case custom
|
|
case existing(ID)
|
|
case new
|
|
}
|
|
|
|
let app: MobileAppModel
|
|
private var library: HostLibraryModel { app.library }
|
|
let original: Host
|
|
@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 body: some View {
|
|
MobileEditorSheet(
|
|
title: String(localized: "Host"),
|
|
isDirty: 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 || (1...65_535).contains(draft.sshPort))
|
|
&& (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort))
|
|
&& 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)),
|
|
save: {
|
|
try customIdentity.save(into: &draft, using: library)
|
|
},
|
|
content: {
|
|
HostOrganizationSection(host: $draft) {
|
|
Picker("Group", selection: groupChoice) {
|
|
Text("None").tag(Choice<HostGroup.ID>.unset)
|
|
ForEach(library.groups) { group in
|
|
Text(group.name).tag(Choice.existing(group.id))
|
|
}
|
|
Divider()
|
|
Text("New Group").tag(Choice<HostGroup.ID>.new)
|
|
}
|
|
} tagsField: {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Tags")
|
|
MobileTagField(tags: $draft.tags, suggestions: library.allTags)
|
|
}
|
|
}
|
|
HostConnectionFields(host: $draft, protocol: $selectedProtocol)
|
|
if selectedProtocol == .ssh && draft.sshEnabled {
|
|
Section("Authentication") {
|
|
Picker("Identity", selection: identityChoice) {
|
|
Text("Custom").tag(Choice<Identity.ID>.unset)
|
|
Divider()
|
|
ForEach(library.reusableIdentities) { identity in
|
|
Text(identity.name).tag(Choice.existing(identity.id))
|
|
}
|
|
Divider()
|
|
Text("New Identity").tag(Choice<Identity.ID>.new)
|
|
}
|
|
if usesCustomIdentity {
|
|
LabeledContent("Username") {
|
|
TextField("Username", text: $customIdentity.sshUsername.singleLineText)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
LabeledContent("Password") {
|
|
RevealableSecretField("Optional", text: $customIdentity.sshPassword)
|
|
.multilineTextAlignment(.trailing)
|
|
.disabled(!customIdentity.sshPasswordIsEditable)
|
|
}
|
|
Picker("Key", selection: $customIdentity.sshKeyID) {
|
|
Text("None").tag(SSHKeyRef.ID?.none)
|
|
ForEach(library.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) }
|
|
}
|
|
}
|
|
}
|
|
Section("Routing") {
|
|
Picker("Jump Host", selection: $draft.jumpHostID) {
|
|
Text("None").tag(UUID?.none)
|
|
ForEach(library.hosts.filter { $0.id != draft.id && $0.sshEnabled }) {
|
|
Text($0.name).tag(Optional($0.id))
|
|
}
|
|
}
|
|
Picker("Proxy", selection: proxyKind) {
|
|
Text("None").tag(HostProxy.ProtocolKind?.none)
|
|
ForEach(HostProxy.ProtocolKind.allCases) { kind in
|
|
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
|
|
}
|
|
}
|
|
if draft.proxy != nil {
|
|
LabeledContent("Hostname") {
|
|
TextField("Hostname", text: proxyString(\.hostname).singleLineText, axis: .horizontal)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.keyboardType(.URL)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
.accessibilityLabel("Proxy Hostname")
|
|
LabeledContent("Port") {
|
|
TextField("Port", value: proxyInt(\.port), format: .number.grouping(.never)).lineLimit(
|
|
1
|
|
)
|
|
.keyboardType(.numberPad)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
.accessibilityLabel("Proxy Port")
|
|
Picker("Identity", selection: proxyIdentityChoice) {
|
|
Text("None").tag(Choice<Identity.ID>.unset)
|
|
Text("Custom").tag(Choice<Identity.ID>.custom)
|
|
Divider()
|
|
ForEach(proxyIdentities) { identity in
|
|
Text("\(identity.name) · \(identity.username)")
|
|
.tag(Choice.existing(identity.id))
|
|
}
|
|
Divider()
|
|
Text("New Identity").tag(Choice<Identity.ID>.new)
|
|
}
|
|
.accessibilityLabel("Proxy Identity")
|
|
if customIdentity.usesCustomProxy {
|
|
LabeledContent("Username") {
|
|
TextField("Username", text: proxyString(\.username).singleLineText)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
LabeledContent("Password") {
|
|
RevealableSecretField("Required", text: $customIdentity.proxyPassword)
|
|
.multilineTextAlignment(.trailing)
|
|
.disabled(!customIdentity.proxyPasswordIsEditable)
|
|
}
|
|
} else if draft.proxy?.identityID != nil {
|
|
LabeledContent("Username") {
|
|
TextField(
|
|
"Username", text: proxyString(\.username).singleLineText, axis: .horizontal
|
|
)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
.accessibilityLabel("Proxy Username")
|
|
}
|
|
}
|
|
}
|
|
HostSessionSection(
|
|
charset: $draft.sshCharset,
|
|
sftpInitialPath: $draft.sftpChannelInitialPath,
|
|
startupSnippetID: startupSnippetChoice,
|
|
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("Session") {
|
|
Picker("Charset", 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("Authentication") {
|
|
Picker("Identity", selection: telnetIdentityChoice) {
|
|
Text("None").tag(Choice<Identity.ID>.unset)
|
|
Text("Custom").tag(Choice<Identity.ID>.custom)
|
|
Divider()
|
|
ForEach(telnetIdentities) { identity in
|
|
Text(identity.name).tag(Choice.existing(identity.id))
|
|
}
|
|
Divider()
|
|
Text("New Identity").tag(Choice<Identity.ID>.new)
|
|
}
|
|
if customIdentity.usesCustomTelnet(for: draft) {
|
|
LabeledContent("Username") {
|
|
TextField("Username", text: $customIdentity.telnetUsername.singleLineText)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
LabeledContent("Password") {
|
|
RevealableSecretField("Required", text: $customIdentity.telnetPassword)
|
|
.multilineTextAlignment(.trailing)
|
|
.disabled(!customIdentity.telnetPasswordIsEditable)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var telnetRoutingSection: some View {
|
|
Section("Routing") {
|
|
Picker("Proxy", selection: telnetProxyKind) {
|
|
Text("None").tag(HostProxy.ProtocolKind?.none)
|
|
ForEach(HostProxy.ProtocolKind.allCases) { kind in
|
|
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
|
|
}
|
|
}
|
|
if draft.telnetProxy != nil {
|
|
LabeledContent("Hostname") {
|
|
TextField("Hostname", text: telnetProxyString(\.hostname).singleLineText)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
LabeledContent("Port") {
|
|
TextField("Port", value: telnetProxyInt(\.port), format: .number.grouping(.never))
|
|
.keyboardType(.numberPad).multilineTextAlignment(.trailing)
|
|
}
|
|
Picker("Identity", selection: telnetProxyIdentityChoice) {
|
|
Text("None").tag(Choice<Identity.ID>.unset)
|
|
Text("Custom").tag(Choice<Identity.ID>.custom)
|
|
Divider()
|
|
ForEach(telnetIdentities) { identity in
|
|
Text(identity.name).tag(Choice.existing(identity.id))
|
|
}
|
|
Divider()
|
|
Text("New Identity").tag(Choice<Identity.ID>.new)
|
|
}
|
|
if customIdentity.usesCustomTelnetProxy {
|
|
LabeledContent("Username") {
|
|
TextField("Username", text: telnetProxyString(\.username).singleLineText)
|
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
LabeledContent("Password") {
|
|
RevealableSecretField("Required", text: $customIdentity.telnetProxyPassword)
|
|
.multilineTextAlignment(.trailing)
|
|
.disabled(!customIdentity.telnetProxyPasswordIsEditable)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var telnetCommandSection: some View {
|
|
if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
Section("Telnet Command") {
|
|
Text(library.telnetCommand(for: draft))
|
|
.font(.caption.monospaced()).foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var identityChoice: Binding<Choice<Identity.ID>> {
|
|
Binding {
|
|
guard let id = draft.identityID, library.identity(id)?.isHostCustom != true else { return .unset }
|
|
return .existing(id)
|
|
} set: { choice in
|
|
switch choice {
|
|
case .unset, .custom:
|
|
customIdentity.selectCustomSSH(on: &draft)
|
|
case .existing(let id):
|
|
guard let identity = library.identity(id) else { return }
|
|
customIdentity.selectSSHIdentity(identity, on: &draft)
|
|
case .new: editorRoute = .hostIdentity
|
|
}
|
|
}
|
|
}
|
|
|
|
private var telnetIdentityChoice: Binding<Choice<Identity.ID>> {
|
|
Binding {
|
|
if customIdentity.usesCustomTelnetSelection { return .custom }
|
|
guard let id = draft.telnetIdentityID else { return .unset }
|
|
return library.identity(id)?.isHostCustom == true ? .custom : .existing(id)
|
|
} set: { choice in
|
|
switch choice {
|
|
case .unset: customIdentity.clearTelnetIdentity(on: &draft)
|
|
case .custom: customIdentity.selectCustomTelnet(on: &draft)
|
|
case .existing(let id):
|
|
guard let identity = library.identity(id) else { return }
|
|
customIdentity.selectTelnetIdentity(identity, on: &draft)
|
|
case .new: editorRoute = .telnetIdentity
|
|
}
|
|
}
|
|
}
|
|
|
|
private var telnetIdentities: [Identity] {
|
|
library.reusableIdentities.filter(library.isProxyEligibleOnThisDevice)
|
|
}
|
|
|
|
private var groupChoice: Binding<Choice<HostGroup.ID>> {
|
|
Binding {
|
|
draft.groupID.map(Choice.existing) ?? .unset
|
|
} set: { choice in
|
|
switch choice {
|
|
case .unset, .custom: draft.groupID = nil
|
|
case .existing(let id): draft.groupID = id
|
|
case .new: editorRoute = .group
|
|
}
|
|
}
|
|
}
|
|
|
|
private var startupSnippetChoice: Binding<Snippet.ID?> {
|
|
Binding {
|
|
draft.startupSnippetID.flatMap { library.snippet($0)?.id }
|
|
} set: {
|
|
draft.startupSnippetID = $0
|
|
}
|
|
}
|
|
|
|
private var proxyKind: Binding<HostProxy.ProtocolKind?> {
|
|
Binding {
|
|
draft.proxy?.kind
|
|
} set: { kind in
|
|
draft.proxy = MobileHostEditorPolicy.selectProxy(kind, current: draft.proxy)
|
|
}
|
|
}
|
|
|
|
private var telnetProxyKind: Binding<HostProxy.ProtocolKind?> {
|
|
Binding {
|
|
draft.telnetProxy?.kind
|
|
} set: { kind in
|
|
draft.telnetProxy = MobileHostEditorPolicy.selectProxy(kind, current: draft.telnetProxy)
|
|
}
|
|
}
|
|
|
|
private func proxyString(_ keyPath: WritableKeyPath<HostProxy, String>) -> Binding<String> {
|
|
Binding(
|
|
get: { draft.proxy?[keyPath: keyPath] ?? "" },
|
|
set: { draft.proxy?[keyPath: keyPath] = $0 }
|
|
)
|
|
}
|
|
|
|
private func proxyInt(_ keyPath: WritableKeyPath<HostProxy, Int>) -> Binding<Int> {
|
|
Binding(
|
|
get: { draft.proxy?[keyPath: keyPath] ?? 0 },
|
|
set: { draft.proxy?[keyPath: keyPath] = $0 }
|
|
)
|
|
}
|
|
|
|
private func telnetProxyString(_ keyPath: WritableKeyPath<HostProxy, String>) -> Binding<String> {
|
|
Binding(
|
|
get: { draft.telnetProxy?[keyPath: keyPath] ?? "" },
|
|
set: { draft.telnetProxy?[keyPath: keyPath] = $0 })
|
|
}
|
|
|
|
private func telnetProxyInt(_ keyPath: WritableKeyPath<HostProxy, Int>) -> Binding<Int> {
|
|
Binding(
|
|
get: { draft.telnetProxy?[keyPath: keyPath] ?? 0 },
|
|
set: { draft.telnetProxy?[keyPath: keyPath] = $0 })
|
|
}
|
|
|
|
private var proxyIdentityChoice: Binding<Choice<Identity.ID>> {
|
|
Binding {
|
|
if customIdentity.usesCustomProxy { return .custom }
|
|
return draft.proxy?.identityID.map(Choice.existing) ?? .unset
|
|
} set: { choice in
|
|
switch choice {
|
|
case .unset:
|
|
customIdentity.clearProxyIdentity(on: &draft)
|
|
case .custom:
|
|
customIdentity.selectCustomProxy(on: &draft, using: library)
|
|
case .existing(let id):
|
|
guard let identity = library.identity(id) else { return }
|
|
customIdentity.selectProxyIdentity(identity, on: &draft)
|
|
case .new:
|
|
editorRoute = .proxyIdentity
|
|
}
|
|
}
|
|
}
|
|
|
|
private var telnetProxyIdentityChoice: Binding<Choice<Identity.ID>> {
|
|
Binding {
|
|
if customIdentity.usesCustomTelnetProxy { return .custom }
|
|
return draft.telnetProxy?.identityID.map(Choice.existing) ?? .unset
|
|
} set: { choice in
|
|
switch choice {
|
|
case .unset: customIdentity.clearTelnetProxyIdentity(on: &draft)
|
|
case .custom: customIdentity.selectCustomTelnetProxy(on: &draft, using: library)
|
|
case .existing(let id):
|
|
guard let identity = library.identity(id) else { return }
|
|
customIdentity.selectTelnetProxyIdentity(identity, on: &draft)
|
|
case .new: editorRoute = .telnetProxyIdentity
|
|
}
|
|
}
|
|
}
|
|
|
|
private var proxyIdentities: [Identity] {
|
|
library.reusableIdentities.filter(library.isProxyEligibleOnThisDevice)
|
|
}
|
|
|
|
private var usesCustomIdentity: Bool {
|
|
customIdentity.usesCustomSSH(for: draft)
|
|
}
|
|
|
|
private func loadCustomOnce() {
|
|
guard !didLoadCustom else { return }
|
|
didLoadCustom = true
|
|
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 ? "New Environment Variable" : "Edit Environment Variable"),
|
|
isDirty: draft != original,
|
|
canSave: draft.isValid && !usedVariables.contains(draft.variable),
|
|
compact: true,
|
|
save: { didSave(draft) },
|
|
content: {
|
|
LabeledContent("Variable") {
|
|
TextField("Variable", text: $draft.variable.singleLineText)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.labelsHidden()
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
.font(.body.monospaced())
|
|
}
|
|
LabeledContent("Value") {
|
|
TextField("Value", text: $draft.value.singleLineText)
|
|
.singleLinePaste()
|
|
.lineLimit(1)
|
|
.labelsHidden()
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.multilineTextAlignment(.trailing)
|
|
.font(.body.monospaced())
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
nonisolated enum MobileHostEditorPolicy {
|
|
static func selectProxy(
|
|
_ kind: HostProxy.ProtocolKind?, current: HostProxy?
|
|
) -> HostProxy? {
|
|
guard let kind else { return nil }
|
|
guard var proxy = current else {
|
|
return HostProxy(kind: kind, port: kind.defaultPort)
|
|
}
|
|
if proxy.kind != kind {
|
|
let oldDefaultPort = proxy.kind.defaultPort
|
|
proxy.kind = kind
|
|
if proxy.port == oldDefaultPort { proxy.port = kind.defaultPort }
|
|
}
|
|
return proxy
|
|
}
|
|
|
|
}
|
|
|
|
nonisolated enum MobileEditorError: LocalizedError {
|
|
case message(String)
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .message(let value): value
|
|
}
|
|
}
|
|
}
|