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: 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.unset) ForEach(library.groups) { group in Text(group.name).tag(Choice.existing(group.id)) } Divider() Text("New Group").tag(Choice.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.unset) Divider() ForEach(library.reusableIdentities) { identity in Text(identity.name).tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.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.unset) Text("Custom").tag(Choice.custom) Divider() ForEach(proxyIdentities) { identity in Text("\(identity.name) ยท \(identity.username)") .tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.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.unset) Text("Custom").tag(Choice.custom) Divider() ForEach(telnetIdentities) { identity in Text(identity.name).tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.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.unset) Text("Custom").tag(Choice.custom) Divider() ForEach(telnetIdentities) { identity in Text(identity.name).tag(Choice.existing(identity.id)) } Divider() Text("New Identity").tag(Choice.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> { 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> { 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> { 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 { Binding { draft.startupSnippetID.flatMap { library.snippet($0)?.id } } set: { draft.startupSnippetID = $0 } } private var proxyKind: Binding { Binding { draft.proxy?.kind } set: { kind in draft.proxy = MobileHostEditorPolicy.selectProxy(kind, current: draft.proxy) } } private var telnetProxyKind: Binding { Binding { draft.telnetProxy?.kind } set: { kind in draft.telnetProxy = MobileHostEditorPolicy.selectProxy(kind, current: draft.telnetProxy) } } private func proxyString(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.proxy?[keyPath: keyPath] ?? "" }, set: { draft.proxy?[keyPath: keyPath] = $0 } ) } private func proxyInt(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.proxy?[keyPath: keyPath] ?? 0 }, set: { draft.proxy?[keyPath: keyPath] = $0 } ) } private func telnetProxyString(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.telnetProxy?[keyPath: keyPath] ?? "" }, set: { draft.telnetProxy?[keyPath: keyPath] = $0 }) } private func telnetProxyInt(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { draft.telnetProxy?[keyPath: keyPath] ?? 0 }, set: { draft.telnetProxy?[keyPath: keyPath] = $0 }) } private var proxyIdentityChoice: Binding> { 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> { 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 let didSave: (HostEnvironmentVariable) -> Void @State private var draft: HostEnvironmentVariable init( variable: HostEnvironmentVariable?, usedVariables: Set, 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 } } }