diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 57e59ebd..f0e522ba 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -26,8 +26,7 @@ struct MobileHostEditor: View { var body: some View { MobileEditorSheet( title: String(localized: "Host"), isDirty: draft != original || draftForwardings != originalForwardings, - canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && (1...65535).contains(draft.port) && draftForwardings.allSatisfy(\.isValid), + canSave: MobileHostEditorPolicy.canSave(draft, forwardings: draftForwardings), onCancel: cancel, save: { if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = draft.host } @@ -47,6 +46,44 @@ struct MobileHostEditor: View { Text("None").tag(UUID?.none) ForEach(library.hosts.filter { $0.id != draft.id }) { 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)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .multilineTextAlignment(.trailing) + } + .accessibilityLabel("Proxy Hostname") + LabeledContent("Port") { + TextField("Port", value: proxyInt(\.port), format: .number.grouping(.never)) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + } + .accessibilityLabel("Proxy Port") + Picker("Identity", selection: proxyIdentityID) { + Text("None").tag(UUID?.none) + ForEach(proxyIdentities) { identity in + Text("\(identity.name) ยท \(identity.username)").tag(Optional(identity.id)) + } + } + .accessibilityLabel("Proxy Identity") + if draft.proxy?.identityID != nil { + LabeledContent("Username") { + TextField("Username", text: proxyString(\.username)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .multilineTextAlignment(.trailing) + } + .accessibilityLabel("Proxy Username") + } + } } Section("Port Forwarding") { ForEach(draftForwardings) { rule in @@ -129,6 +166,41 @@ struct MobileHostEditor: View { app.forwardings.discardPending(draftForwardings.map(\.id)) } + private var proxyKind: Binding { + Binding { + draft.proxy?.kind + } set: { kind in + draft.proxy = MobileHostEditorPolicy.selectProxy(kind, current: draft.proxy) + } + } + + 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 var proxyIdentityID: Binding { + Binding { + draft.proxy?.identityID + } set: { identityID in + draft.proxy = MobileHostEditorPolicy.selectProxyIdentity( + identityID.flatMap(library.identity), current: draft.proxy) + } + } + + private var proxyIdentities: [Identity] { + library.identities.filter(library.isProxyEligibleOnThisDevice) + } + private func forwardingState( for rule: PortForwardingRule, runtimeIsActive: Bool ) -> PortForwardingEditorPolicy { @@ -139,6 +211,37 @@ struct MobileHostEditor: View { } } +nonisolated enum MobileHostEditorPolicy { + static func canSave(_ host: Host, forwardings: [PortForwardingRule]) -> Bool { + !host.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (1...65_535).contains(host.port) + && (host.proxy?.isValid ?? true) + && forwardings.allSatisfy(\.isValid) + } + + 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 + } + + static func selectProxyIdentity(_ identity: Identity?, current: HostProxy?) -> HostProxy? { + guard var proxy = current else { return nil } + proxy.identityID = identity?.id + proxy.username = identity?.username ?? "" + return proxy + } +} + nonisolated enum MobileEditorError: LocalizedError { case message(String) var errorDescription: String? { diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index 69c964d9..28dde411 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -34,6 +34,9 @@ struct MobileIconRenderingTests { let forwardingHost = Host(name: "Example", host: "example.invalid") let forwardingRule = PortForwardingRule(hostID: forwardingHost.id, label: "Web") #expect(app.library.upsert(forwardingHost, replacingPortForwardings: [forwardingRule])) + let proxyHost = Host( + name: "Proxied", host: "proxied.example.invalid", + proxy: HostProxy(kind: .socks5, hostname: "proxy.example.invalid", port: 1080)) let snippetApp = try MobileAppModel(inMemory: true) let snippetHost = Host(name: "Production", host: "production.example.invalid") let snippetPackage = SnippetPackage(name: "Operations") @@ -135,6 +138,10 @@ struct MobileIconRenderingTests { MobileHostEditor(app: app, host: Host(name: "Example", host: "example.invalid")) .environment(\.locale, locale) } + try await capture("editor-proxy-\(suffix)", language: language, scheme: scheme) { + MobileHostEditor(app: app, host: proxyHost) + .environment(\.locale, locale) + } try await capture("forwarding-editor-\(suffix)", language: language, scheme: scheme) { MobileForwardingEditor( app: app, host: forwardingHost, rule: forwardingRule, isNew: false, diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 506e886b..ff3cf8ca 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -10,6 +10,62 @@ import Testing @Suite @MainActor struct MobileVaultFeatureTests { + @Test func hostProxyEditorAppliesDefaultsPreservesCustomPortsAndValidatesAuthentication() { + var host = TermindDomain.Host(name: "Remote", host: "example.invalid") + #expect(MobileHostEditorPolicy.canSave(host, forwardings: [])) + + host.proxy = MobileHostEditorPolicy.selectProxy(.http, current: nil) + #expect(host.proxy?.port == 8080) + #expect(!MobileHostEditorPolicy.canSave(host, forwardings: [])) + + host.proxy?.hostname = "proxy.example" + #expect(MobileHostEditorPolicy.canSave(host, forwardings: [])) + host.proxy?.port = 0 + #expect(!MobileHostEditorPolicy.canSave(host, forwardings: [])) + host.proxy?.port = 8080 + host.proxy = MobileHostEditorPolicy.selectProxy(.socks5, current: host.proxy) + #expect(host.proxy?.port == 1080) + #expect(host.proxy?.hostname == "proxy.example") + + host.proxy?.port = 9000 + host.proxy = MobileHostEditorPolicy.selectProxy(.http, current: host.proxy) + #expect(host.proxy?.port == 9000) + + let identity = Identity( + name: "Proxy", username: "proxy-user", credentialRef: "termind.password.proxy") + host.proxy = MobileHostEditorPolicy.selectProxyIdentity(identity, current: host.proxy) + #expect(host.proxy?.identityID == identity.id) + #expect(host.proxy?.username == "proxy-user") + #expect(MobileHostEditorPolicy.canSave(host, forwardings: [])) + + host.proxy?.username = "" + #expect(!MobileHostEditorPolicy.canSave(host, forwardings: [])) + host.proxy = MobileHostEditorPolicy.selectProxyIdentity(nil, current: host.proxy) + #expect(host.proxy?.identityID == nil) + #expect(host.proxy?.username.isEmpty == true) + #expect(MobileHostEditorPolicy.canSave(host, forwardings: [])) + + host.proxy = MobileHostEditorPolicy.selectProxy(nil, current: host.proxy) + #expect(host.proxy == nil) + } + + @Test func proxyIdentityChoicesRequireAnAvailablePasswordOnThisDevice() throws { + let app = try MobileAppModel(inMemory: true) + let available = Identity(name: "Available", username: "proxy") + let missing = Identity( + name: "Missing", username: "proxy", credentialRef: "termind.password.missing") + let keyOnly = Identity(name: "Key only", username: "proxy", keyID: UUID()) + + try app.library.upsert(available, passwordUpdate: .replace("secret")) + app.library.upsert(missing) + app.library.upsert(keyOnly) + + let storedAvailable = try #require(app.library.identity(available.id)) + #expect(app.library.isProxyEligibleOnThisDevice(storedAvailable)) + #expect(!app.library.isProxyEligibleOnThisDevice(missing)) + #expect(!app.library.isProxyEligibleOnThisDevice(keyOnly)) + } + @Test func forwardingEnabledIntentUsesInjectedDevicePreferences() throws { let suite = "MobileForwardingIntentTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite))