Add proxy settings to mobile host editor
This commit is contained in:
1 parent
054d92a9b6
commit
fdc85ef1a9
3 files changed
+168
-2
No files matched your search
@@ -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<HostProxy.ProtocolKind?> {
|
||||
Binding {
|
||||
draft.proxy?.kind
|
||||
} set: { kind in
|
||||
draft.proxy = MobileHostEditorPolicy.selectProxy(kind, current: draft.proxy)
|
||||
}
|
||||
}
|
||||
|
||||
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 var proxyIdentityID: Binding<Identity.ID?> {
|
||||
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? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user