refactor(connectivity): store test URL in user defaults

This commit is contained in:
Qiu Yuzhou
2026-10-18 08:31:13 +08:00
parent d8a11f7b6b
commit 6cbef4e57a
14 changed files with 153 additions and 121 deletions
+4 -1
View File
@@ -77,6 +77,9 @@ _避免用词_:可移除节点(Removable node)。
**服务器连通性测试(Server connectivity test)**:
用户手动请求,经指定服务器配置向测试 URL 发起应用 TCP 请求并检查响应的操作。测试经过该配置的实际传输路径,包括使用 UDP 传输的插件隧道;它不验证应用 UDP 中继。
**测试 URL(Connectivity test URL)**:
服务器连通性测试请求访问的目标地址,是用户可修改的非敏感偏好信息,在展示、日志与诊断中无需脱敏。
**代理响应延迟(Proxy response latency)**:
完成目标地址选择与预热后,经指定服务器配置访问测试 URL 的 HTTP 响应头耗时,以成功样本的中位数表示。它与测试目标及测试条件有关,不表示服务器 IP 的纯网络往返时间或下载速度。
@@ -274,7 +277,7 @@ _避免用词_:菜单栏形态(Menu bar form,应用形态并非恒定)
### 凭据与诊断
**敏感信息(Sensitive information)**:
服务器密码、完整插件选项,以及用户提供的非空远程 URL,包括订阅 URL。
服务器密码、完整插件选项,以及用户提供的非空远程 URL,包括订阅 URL,但不包括测试 URL。
**凭据暴露边界(Credential exposure boundary)**:
产品降低意外泄露及向其他用户账户暴露凭据的保护范围。该范围不包含同一用户进程被攻破、root 访问或快照与备份,也不承诺安全擦除。
@@ -73,15 +73,10 @@ extension ProxyRuntimeController {
/// status remains observable through the controller's status stream.
func updateSettings(_ proposed: ProxySettings) async throws {
let catalog = catalogSnapshotReader.catalogSnapshot
var connectionIndependent = settings
connectionIndependent.connectivityURLReference = proposed.connectivityURLReference
let onlyTestURLChanged =
settings.connectivityURLReference != proposed.connectivityURLReference
&& connectionIndependent == proposed
try settingsStore.save(proposed)
settings = proposed
settingsUnreadable = false
guard state != .off, !onlyTestURLChanged else { return }
guard state != .off else { return }
await convergeToReexpanded(catalog)
}
@@ -1,39 +1,20 @@
import Foundation
extension SettingsWorkflow {
func beginConnectivityURLEditing() throws -> String {
guard let reference = committing.committedSettings.connectivityURLReference else {
return ServerConnectivity.defaultURL.absoluteString
}
// Previously supported URLs remain editable after the accepted scheme changes.
guard let secret = try credentials.secret(for: reference), !secret.isEmpty
else {
throw ServerFormLoadError.credentialsUnavailable
}
return secret
private static let connectivityURLKey = "connectivityTestURL"
func beginConnectivityURLEditing() -> String {
let url =
(connectivityDefaults.object(forKey: Self.connectivityURLKey) as? String)
.flatMap(ServerConnectivity.validURL) ?? ServerConnectivity.defaultURL
return url.absoluteString
}
/// Each custom URL gets a fresh secret; failed preference saves roll it back.
func saveConnectivityURL(_ raw: String) async -> Bool {
guard let url = ServerConnectivity.validURL(raw), startCommit() else { return false }
defer { finishCommit() }
let previous = committing.committedSettings.connectivityURLReference
let reference: CredentialReference? = url == ServerConnectivity.defaultURL ? nil : .fresh()
do {
if let reference { try credentials.save(url.absoluteString, for: reference) }
var proposed = committing.committedSettings
proposed.connectivityURLReference = reference
let outcome = await persistSettings(proposed)
guard outcome == .persisted else {
if let reference { try? credentials.delete(reference) }
return false
}
connectivityURLDidChange?(url)
if let previous { try? credentials.delete(previous) }
return true
} catch {
if let reference { try? credentials.delete(reference) }
return false
}
connectivityDefaults.set(url.absoluteString, forKey: Self.connectivityURLKey)
connectivityURLDidChange?(url)
return true
}
}
@@ -5,8 +5,8 @@ import Foundation
/// The module exposes validation, occupancy facts, shared commit state, and typed
/// commands; editor-local drafts and presentation state remain in the UI.
///
/// Writes depend on the narrow `SettingsCommitting` seam. The module does not
/// depend on SwiftUI, persistence formats, or runtime convergence details.
/// Proxy setting writes use `SettingsCommitting`; the independent test URL
/// preference uses UserDefaults. Runtime convergence stays behind the commit seam.
@MainActor
final class SettingsWorkflow: ObservableObject {
let listenerOccupancy: ListenerOccupancy
@@ -21,14 +21,17 @@ final class SettingsWorkflow: ObservableObject {
let committing: SettingsCommitting
let credentials: CredentialStoring
let connectivityDefaults: UserDefaults
init(
committing: SettingsCommitting,
occupancyProbe: PortOccupancyProbing = SystemPortOccupancyProbe(),
credentials: CredentialStoring = KeychainCredentialStore()
credentials: CredentialStoring = KeychainCredentialStore(),
connectivityDefaults: UserDefaults = .standard
) {
self.committing = committing
self.credentials = credentials
self.connectivityDefaults = connectivityDefaults
listenerOccupancy = ListenerOccupancy(committing: committing, probe: occupancyProbe)
occupancyChanges = listenerOccupancy.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
@@ -210,7 +210,7 @@ private struct AppComposition {
measure: testRuntime.measure)
catalogWorkflow.connectivity = connectivity
connectivity.setURL(
(try? settingsWorkflow.beginConnectivityURLEditing()).flatMap(ServerConnectivity.validURL))
ServerConnectivity.validURL(settingsWorkflow.beginConnectivityURLEditing()))
settingsWorkflow.connectivityURLDidChange = { [weak connectivity] in connectivity?.setURL($0) }
let connectivityLifecycle = ConnectivityLifecycle(module: connectivity)
// 诊断工作流 module:诊断区唯一 seam,组合根接线一次;进程期
@@ -256,19 +256,19 @@
}
}
},
"无法读取或保存测试 URL": {
"无法保存测试 URL": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Unable to read or save the test URL"
"value": "Unable to save the test URL"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "无法读取或保存测试 URL"
"value": "无法保存测试 URL"
}
}
}
@@ -33,7 +33,7 @@ struct ConnectivityURLSettings: View {
}
if failure {
Text(ConnectivityCopy.text("无法读取或保存测试 URL"))
Text(ConnectivityCopy.text("无法保存测试 URL"))
.font(.caption)
.foregroundStyle(.red)
}
@@ -47,7 +47,7 @@ struct ConnectivityURLSettings: View {
TextField("HTTP", text: $value).textFieldStyle(.roundedBorder)
Text(ConnectivityCopy.text("仅支持 HTTP。预热后采样 3 次,至少成功 2 次;显示成功样本的中位数。"))
.font(.caption).foregroundStyle(.secondary)
if failure { Text(ConnectivityCopy.text("无法读取或保存测试 URL")).foregroundStyle(.red) }
if failure { Text(ConnectivityCopy.text("无法保存测试 URL")).foregroundStyle(.red) }
HStack {
Button(ConnectivityCopy.text("恢复默认")) {
value = ServerConnectivity.defaultURL.absoluteString
@@ -75,24 +75,14 @@ struct ConnectivityURLSettings: View {
}
private func refreshDisplayedURL() {
do {
displayedURL = try workflow.beginConnectivityURLEditing()
failure = false
} catch {
displayedURL = ""
failure = true
}
displayedURL = workflow.beginConnectivityURLEditing()
failure = false
}
private func beginEditing() {
do {
value = try workflow.beginConnectivityURLEditing()
displayedURL = value
failure = false
editing = true
} catch {
displayedURL = ""
failure = true
}
value = workflow.beginConnectivityURLEditing()
displayedURL = value
failure = false
editing = true
}
}
@@ -126,8 +126,8 @@ struct DiagnosticPluginFacts: Equatable, Sendable {
/// 诊断报告构建器:只输出状态、存在性、权限、
/// 大小/时间、数量与脱敏元数据。测试夹具以投毒数据验证导出不含任何敏感
/// 类目(密码、插件参数、Keychain 值、完整订阅 URL、URL token、运行时
/// JSON 内容、服务器地址与备注)。
/// 类目(密码、插件参数、Keychain 值、完整订阅 URL 及其 token、运行时
/// JSON 内容、服务器地址与备注)。测试 URL 为非敏感信息,无需脱敏。
enum DiagnosticReportBuilder {
/// 目录数量元数据(只含计数,不含名称/地址/备注)。
struct CatalogCounts: Equatable, Sendable {
@@ -168,7 +168,7 @@ enum DiagnosticReportBuilder {
lines.append("- 系统:\(snapshot.systemSummary ?? "未提供")")
lines.append("")
lines.append("本报告仅含状态、存在性、权限、大小/时间、数量与脱敏元数据;不包含")
lines.append("密码、插件参数、钥匙串值、完整订阅 URL、URL token、运行时 JSON")
lines.append("密码、插件参数、钥匙串值、完整订阅 URL 及其 token、运行时 JSON")
lines.append("内容、服务器地址与备注。")
lines.append("")
lines.append(contentsOf: statusLines(snapshot))
@@ -1,10 +1,10 @@
import Foundation
/// 运行时日志基线:事件是封闭枚举,脱敏由构造保证——永不
/// 携带密码、插件参数、Keychain 值、完整订阅 URL、URL token 或运行时 JSON
/// 内容;服务器地址与备注默认不进普通日志(端点探测失败点名的 host:port
/// 是本机监听端点,属于需要呈现的事实字段,不受此限)。日志查看与诊断
/// 日志查看与诊断导出均以本事件流为数据源。
/// 携带密码、插件参数、Keychain 值、完整订阅 URL 及其 token 或运行时 JSON
/// 内容;测试 URL 属于非敏感信息,可完整记录。服务器地址与备注默认不进
/// 普通日志(端点探测失败点名的 host:port 是本机监听端点,属于需要呈现的
/// 事实字段,不受此限)。日志查看与诊断导出均以本事件流为数据源。
enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable {
/// 契约文件已原子写入(只携带数量元数据,非内容)。
case contractWritten(serverCount: Int)
@@ -123,8 +123,9 @@ protocol RuntimeEventSink: Sendable {
/// 敏感信息脱敏工具(「敏感信息」定义的读取面)。
enum Redactor {
/// 非空远程 URL 只保留 scheme+host:路径、查询与片段全部略去,完整订阅
/// 敏感远程 URL 只保留 scheme+host:路径、查询与片段全部略去,完整订阅
/// URL 与 URL token 永不入日志。解析失败一律归并为占位符。
/// 测试 URL 无需脱敏,不使用此方法。
static func remoteURL(_ url: String) -> String {
guard let parsed = URL(string: url), let host = parsed.host(), !host.isEmpty else {
return "<redacted-url>"
@@ -5,7 +5,6 @@ import Foundation
struct ProxySettings: Equatable, Sendable {
static let defaultProxyExceptions = ""
var connectivityURLReference: CredentialReference?
var listen: SslocalListenSettings
/// User-added system-proxy exceptions; these entries do not become ACL rules.
var proxyExceptions: String
@@ -23,14 +22,12 @@ struct ProxySettings: Equatable, Sendable {
init(
listen: SslocalListenSettings = SslocalListenSettings(),
connectivityURLReference: CredentialReference? = nil,
proxyExceptions: String = ProxySettings.defaultProxyExceptions,
preferredMode: ProxyModeKind = .rule,
ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched,
agentEnabled: Bool = false,
systemProxyEnabled: Bool = false
) {
self.connectivityURLReference = connectivityURLReference
self.listen = listen
self.proxyExceptions = proxyExceptions
self.preferredMode = preferredMode
@@ -73,7 +73,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
return try validated(
ProxySettings(
listen: listen,
connectivityURLReference: record.connectivityURLReference,
proxyExceptions: record.proxyExceptions,
preferredMode: record.preferredMode,
ruleDefaultAction: record.ruleDefaultAction,
@@ -83,7 +82,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
private func record(from settings: ProxySettings) -> ProxySettingsRecord {
var record = ProxySettingsRecord()
record.connectivityURLReference = settings.connectivityURLReference
record.localListen = settings.listen.local
record.lanListen = settings.listen.lan
record.proxyExceptions = settings.proxyExceptions
@@ -102,7 +100,6 @@ struct ProxySettingsFileStore: ProxySettingsStoring {
}
private struct ProxySettingsRecord: Codable, Equatable, Sendable {
var connectivityURLReference: CredentialReference?
var localListen = LocalListeningSettings()
var lanListen = LANListeningSettings()
var proxyExceptions: String = ProxySettings.defaultProxyExceptions
@@ -115,8 +112,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
connectivityURLReference = try container.decodeIfPresent(
CredentialReference.self, forKey: .connectivityURLReference)
// 未正式发版:忽略旧的单组监听字段,不迁移独立旧监听文件。
localListen =
try container.decodeIfPresent(LocalListeningSettings.self, forKey: .localListen)
@@ -149,7 +144,6 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable {
extension ProxySettingsRecord {
fileprivate enum CodingKeys: String, CodingKey {
case connectivityURLReference
case localListen, lanListen
case proxyExceptions
case preferredMode
@@ -41,7 +41,11 @@ struct ConnectivityFormalRuntimeTests {
let calls = agent.registerCount
let target = controller.activeTargetID
let document = try Data(contentsOf: runtime.contract)
let workflow = SettingsWorkflow(committing: controller, credentials: credentials)
let suite = "ConnectivityFormalRuntimeTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let workflow = SettingsWorkflow(
committing: controller, credentials: credentials, connectivityDefaults: defaults)
#expect(await workflow.saveConnectivityURL("http://example.com/probe?token=private"))
#expect(controller.state == state)
#expect(controller.activeTargetID == target)
@@ -5,49 +5,100 @@ import Testing
@MainActor
struct ConnectivitySettingsTests {
@Test func customURLIsSecretAndFailureDoesNotPublishOrChangeSavedURL() async throws {
@Test func customURLPersistsWithoutCredentialsOrProxySettingsCommit() async throws {
let suite = "ConnectivitySettingsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let committer = FakeSettingsCommitter()
let credentials = InMemoryCredentialStore()
let workflow = SettingsWorkflow(committing: committer, credentials: credentials)
var published = 0
workflow.connectivityURLDidChange = { _ in published += 1 }
let secretURL = "http://example.com/probe?token=private"
#expect(await workflow.saveConnectivityURL(secretURL))
#expect(try workflow.beginConnectivityURLEditing() == secretURL)
#expect(credentials.storageCount == 1)
let saved = committer.committedSettings
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let file = directory.appendingPathComponent("settings.json")
defer { try? FileManager.default.removeItem(at: directory) }
let store = ProxySettingsFileStore(fileURL: file)
try store.save(saved)
#expect(try store.load() == saved)
let text = try String(contentsOf: file, encoding: .utf8)
#expect(!text.contains("private"))
#expect(!text.contains("example.com"))
committer.updateError = ProxySettingsStoreError.ioFailure(detail: "fixture")
#expect(await !workflow.saveConnectivityURL("http://example.com/new"))
#expect(committer.committedSettings == saved)
#expect(credentials.storageCount == 1)
#expect(published == 1)
#expect(try workflow.beginConnectivityURLEditing() == secretURL)
committer.updateError = nil
#expect(await workflow.saveConnectivityURL(ServerConnectivity.defaultURL.absoluteString))
#expect(committer.committedSettings.connectivityURLReference == nil)
#expect(credentials.storageCount == 0)
}
@Test func previouslySavedHTTPSURLCanBeOpenedAndReplacedWithHTTP() async throws {
let committer = FakeSettingsCommitter()
let credentials = InMemoryCredentialStore()
let reference = CredentialReference.fresh()
try credentials.save("https://example.com/old?token=private", for: reference)
committer.committedSettings.connectivityURLReference = reference
let workflow = SettingsWorkflow(committing: committer, credentials: credentials)
#expect(try workflow.beginConnectivityURLEditing() == "https://example.com/old?token=private")
#expect(await !workflow.saveConnectivityURL("https://example.com/new"))
#expect(await workflow.saveConnectivityURL("http://example.com/new"))
#expect(try workflow.beginConnectivityURLEditing() == "http://example.com/new")
#expect(try credentials.secret(for: reference) == nil)
committer.updateError = ProxySettingsStoreError.ioFailure(detail: "must not commit")
let credentials = UnavailableConnectivityCredentials()
let workflow = SettingsWorkflow(
committing: committer, credentials: credentials, connectivityDefaults: defaults)
var published: [URL] = []
workflow.connectivityURLDidChange = { published.append($0) }
let url = "http://example.com/probe?token=public"
#expect(await workflow.saveConnectivityURL(url))
#expect(workflow.beginConnectivityURLEditing() == url)
#expect(committer.committedSettings == ProxySettings())
#expect(published.map(\.absoluteString) == [url])
let reopened = SettingsWorkflow(
committing: committer, credentials: credentials, connectivityDefaults: defaults)
#expect(reopened.beginConnectivityURLEditing() == url)
}
@Test func missingOrInvalidPreferencesUseDefaultURL() throws {
let suite = "ConnectivitySettingsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let workflow = SettingsWorkflow(
committing: FakeSettingsCommitter(), credentials: UnavailableConnectivityCredentials(),
connectivityDefaults: defaults)
#expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204")
for value: Any in [
42, "", "https://example.com", "http://user:pass@example.com",
"http://example.com/#fragment", "http://example.com:0",
] {
defaults.set(value, forKey: "connectivityTestURL")
#expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204")
}
}
@Test func invalidSaveKeepsPreviousURLAndDefaultCanBeRestored() async throws {
let suite = "ConnectivitySettingsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let workflow = SettingsWorkflow(
committing: FakeSettingsCommitter(), credentials: UnavailableConnectivityCredentials(),
connectivityDefaults: defaults)
var published: [URL] = []
workflow.connectivityURLDidChange = { published.append($0) }
#expect(await workflow.saveConnectivityURL(" http://example.com/probe?token=public \n"))
#expect(await !workflow.saveConnectivityURL("https://example.com"))
#expect(workflow.beginConnectivityURLEditing() == "http://example.com/probe?token=public")
#expect(published.count == 1)
#expect(await workflow.saveConnectivityURL("http://www.gstatic.com/generate_204"))
#expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204")
#expect(published.count == 2)
}
@Test func oldCredentialReferenceIsIgnoredWithoutLosingOtherSettings() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appendingPathComponent("settings.json")
try Data(
#"{"connectivityURLReference":{"obsolete":true},"preferredMode":"global"}"#.utf8
).write(to: file)
let store = ProxySettingsFileStore(fileURL: file)
let settings = try store.load()
#expect(settings.preferredMode == .global)
try store.save(settings)
#expect(try !String(contentsOf: file, encoding: .utf8).contains("connectivityURLReference"))
let suite = "ConnectivitySettingsTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let committer = FakeSettingsCommitter()
committer.committedSettings = settings
let workflow = SettingsWorkflow(
committing: committer, credentials: UnavailableConnectivityCredentials(),
connectivityDefaults: defaults)
#expect(workflow.beginConnectivityURLEditing() == "http://www.gstatic.com/generate_204")
}
}
private struct UnavailableConnectivityCredentials: CredentialStoring {
func save(_ secret: String, for reference: CredentialReference) throws {
Issue.record("Test URL must not write credentials")
throw CredentialStoreError.secretNotUTF8
}
func secret(for reference: CredentialReference) throws -> String? {
Issue.record("Test URL must not read credentials")
throw CredentialStoreError.secretNotUTF8
}
func delete(_ reference: CredentialReference) throws {
Issue.record("Test URL must not delete credentials")
throw CredentialStoreError.secretNotUTF8
}
}
@@ -0,0 +1,13 @@
# 测试 URL 作为非敏感偏好存入 UserDefaults
Status: Accepted(2026-10-18,用户已通过 implement 指令确认实施)。
测试 URL 是非敏感偏好,以明文存入 UserDefaults,不再通过 Keychain 和代理设置中的凭据引用保存。展示、日志、诊断报告及其他路径均不要求对测试 URL 脱敏,包括其路径与查询参数;这修订 ADR-0006 对 URL 的一概排除规则,仅限测试 URL。订阅 URL、服务器密码及其他凭据的保护边界保持不变。ADR-0035 对测试 URL 的范围外凭据描述由本决定替代。
不迁移已有自定义值,不访问或清理对应旧 Keychain 项,移除旧测试 URL 引用的使用。没有新偏好时使用默认值 `http://www.gstatic.com/generate_204`;旧 Keychain 条目可能残留,不再影响测试功能。
UserDefaults 键缺失、类型错误或保存的字符串不符合现有 URL 校验时,统一使用默认 URL。保存时保留现有校验:仅允许 HTTP,要求非空主机,禁止用户名、密码与 fragment,显式端口须在 1…65535 范围内,允许路径与查询参数。合法值按项目现有 UserDefaults 方式保存并立即生效,不承诺原文件保存路径的落盘错误报告与回滚保证。
URL 变化后沿用现有行为:清空历史测试结果并取消正在执行的测试,不重启代理。界面布局、测试协议、测量算法及其他设置的存储不在本次调整范围内。
实施验收覆盖独立 UserDefaults 测试域中的自定义值往返、默认值与异常值处理、无效输入拒绝、旧引用被忽略且不访问 Keychain,以及 URL 变化的发布行为。核查测试 URL 相关脱敏规则与诊断输出,使其符合非敏感分类,同时保持其他凭据的保护。