Add proxy settings to mobile host editor

This commit is contained in:
Kaede Akino committed 2026-09-14 08:33:29 +08:00
1 parent 054d92a9b6
commit fdc85ef1a9
3 files changed
+168 -2

No files matched your search

+105 -2
View File
@@ -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? {
+7
View File
@@ -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,
+56
View File
@@ -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))