diff --git a/CONTEXT.md b/CONTEXT.md index 688d41f4..af5668dd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,7 @@ - **Agent switch**: The user's independent on/off choice for the proxy agent (the per-user background proxy runtime). It defaults to off: a fresh user has no server configuration, so neither install nor first launch starts the agent until the user turns it on. An explicit choice is persisted, survives GUI restarts, and quitting the GUI never stops the launchd-hosted agent once it is running. The settings view does not expose it. Turning the agent off first restores the system proxy settings 2.0 holds and then stops the local listeners. _Avoid_: proxy switch (ambiguous with the system proxy switch). - **System proxy switch**: The user's independent choice to have 2.0 take over macOS system proxy settings. It defaults to off, so a first run never rewrites the user's proxy dictionary. Turning it off restores only the settings 2.0 holds and never unregisters the agent or stops the local SOCKS/HTTP listeners. Its intent (persisted), actual application, and typed failures are presented as separate facts, distinct from the agent's run state. _Avoid_: proxy switch (ambiguous with the agent switch). - **Login item**: The GUI app's registration in the user's login items. It is an independent preference domain: off until the user enables it, and its state is only ever the system login-item registration status. +- **Independently saved setting item**: A user-facing setting or coherent group of related values that the user explicitly saves as one unit; saving persists and applies that item without committing unrelated unsaved settings. _Avoid_: immediate edit (can imply applying on every keystroke). - **HTTP proxy inbound**: The permanent local inbound that accepts HTTP/HTTPS proxy requests through the external tunnel service and its active ACL; it is not a user preference and cannot be disabled, and it is distinct from proxy modes and from the Legacy Privoxy adapter. _Avoid_: HTTP proxy mode. - **SOCKS5 UDP relay**: The fixed capability of the local SOCKS5 endpoint to accept UDP ASSOCIATE traffic and relay it through the proxy runtime. It is not a user preference and does not imply that all system UDP traffic is proxied. _Avoid_: system-wide UDP proxy, automatic UDP proxying. - **Legacy Privoxy adapter**: The frozen implementation's HTTP(S)-to-local-SOCKS5 bridge. It is a Legacy-only dependency and is not migrated into 2.0. @@ -70,7 +71,10 @@ - A Legacy import migrates only server records; mode, target, login-item, and all other preferences stay untouched. Legacy passwords, plugin options, and user-provided remote URLs move to credential references; generated configurations, caches, diagnostics, logs, and binaries do not migrate. The imported plugin program reference is preserved verbatim even when 2.0 does not provide that plugin. - 2.0 and Legacy are independent applications that may run concurrently. 2.0 never stops Legacy processes, retires Legacy runtime jobs, or rewrites Legacy-owned system proxy settings; nothing transfers between them except the server records brought over by a Legacy import. Their factory-default local endpoint ports are distinct so neither app's defaults collide with the other's. - The external tunnel service's inbound listeners follow one shared listen scope, so a proxy address shared to another machine always points at a reachable proxy; loopback is the default scope. -- The ports of the locally provided proxy endpoints are explicit user configuration: the system never rewrites a port on its own, and a port conflict rejects proxy start with an error naming the endpoint until the user explicitly chooses a new port. A recovery suggestion only proposes candidate ports (32768–65535, free and distinct from the other endpoints' configured values) and applies only after the user confirms and saves. +- Saving an independently saved setting item persists and applies only that item's complete value. Other unsaved settings remain pending and cannot overwrite the saved item when they are later committed. +- The ports of the locally provided proxy endpoints are explicit user configuration in the range 1000–65535: the system never rewrites a port on its own, and a port conflict rejects proxy start with an error naming the endpoint until the user explicitly chooses a new port. A recovery suggestion only proposes candidate ports (32768–65535, free and distinct from the other endpoints' configured values) and applies only after the user confirms and saves. +- The SOCKS5 and HTTP listener ports are one independently saved setting item and are validated and persisted together. Invalid or duplicate values and known port conflicts block saving, except when the complete proposed listener configuration matches the active runtime; unknown occupancy is a warning that does not block saving, and a suggested port remains pending until the user saves. +- A successfully saved port item remains persisted if runtime convergence fails; the UI reflects the saved port values and does not need a separate runtime-error message for this item. - The external tunnel service starts and stops with the proxy runtime: its SOCKS and HTTP inbounds are reachable exactly while the agent is running, and neither depends on the GUI being open. The agent runs when the agent switch says so: off by default, and — once turned on — even without an active target it keeps listening with an empty server list. The GUI writes the system proxy configuration only when the system proxy switch is on, endpoint health passes, and the selected mode has an available exit; rule and global require an active server target, while direct mode uses an empty-proxy-list ACL and needs no server target. Otherwise the system proxy intent stays pending and converges automatically once the conditions recover. - Diagnostic collection is read-only: it never changes the configuration catalog, credentials, proxy runtime, or system proxy settings. A diagnostic report is best-effort; an unavailable fact source is represented as unavailable rather than causing unrelated available facts to be discarded. - A diagnostic report contains aggregate catalog facts only. The complete configuration catalog, catalog entries, server addresses, remarks, and credential references never cross into the diagnostic report projection. diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index 4c7442f4..958ff948 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -254,15 +254,15 @@ extension AppPresentation { static func proxyMode(_ error: ProxyModeError) -> String { switch error { - case .invalidSOCKSPort: return "SOCKS 端口无效" - case .invalidHTTPPort: return "HTTP 端口无效" + case .invalidSOCKSPort: return "SOCKS 端口无效,必须是 1000–65535 之间的整数" + case .invalidHTTPPort: return "HTTP 端口无效,必须是 1000–65535 之间的整数" } } private static func port(_ error: PortSettingError) -> String { switch error { case .portOutOfRange(let endpoint, let portNumber): - return "\(endpoint.displayName) 端口 \(portNumber) 无效,必须是 1–65535 之间的整数" + return "\(endpoint.displayName) 端口 \(portNumber) 无效,必须是 1000–65535 之间的整数" case .duplicatePort(let endpoint, let otherEndpoint, let portNumber): return "\(endpoint.displayName) 端口与 \(otherEndpoint.displayName) 端口冲突(都是 \(portNumber)),请为每个端点配置不同的端口" diff --git a/ShadowsocksX-NG2/App/SettingsView.swift b/ShadowsocksX-NG2/App/SettingsView.swift index 38e9023c..32b4801d 100644 --- a/ShadowsocksX-NG2/App/SettingsView.swift +++ b/ShadowsocksX-NG2/App/SettingsView.swift @@ -8,6 +8,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject var workflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController + @State private var portSettingsEditor: PortSettingsEditorSession? var body: some View { VStack(spacing: 0) { @@ -24,6 +25,9 @@ struct SettingsView: View { .onAppear { Task { _ = await workflow.reloadFromCommitted() } } + .sheet(item: $portSettingsEditor) { session in + PortSettingsEditorSheet(workflow: workflow, initialDraft: session.initialDraft) + } } // MARK: - 提交动作行(内容顶部,固定不随表单滚动) @@ -132,8 +136,7 @@ struct SettingsView: View { } .padding(.vertical, 2) } - portRow(.socks, title: "SOCKS5 端口") - portRow(.http, title: "HTTP 代理端口") + portSettingsRow } header: { sectionHeader("代理端点", subtitle: "监听范围和本地服务端口", trailing: "不会自动换端口") } @@ -150,7 +153,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) if workflow.hasBlockingPortOccupancy { - Label("检测到端口已被占用;请先使用对应的「建议空闲端口」,再保存设置。", systemImage: "exclamationmark.triangle") + Label("检测到端口已被占用;请在「端口设置」中选择建议空闲端口并保存。", systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.orange) } @@ -216,68 +219,154 @@ struct SettingsView: View { } } -// MARK: - 端口行(同文件扩展,保持 private 访问) +// MARK: - 端口设置摘要与编辑框 extension SettingsView { - private func portRow(_ id: SettingsPortID, title: String) -> some View { - let state = workflow.portFieldState(for: id) - return settingRow(title) { - VStack(alignment: .trailing, spacing: 4) { - TextField( - title, - value: portBinding(for: id), - format: .number.grouping(.never) - ) - .textFieldStyle(.roundedBorder) - .labelsHidden() - .multilineTextAlignment(.trailing) - .frame(width: 96) - ForEach(Array(state.issues.enumerated()), id: \.offset) { _, issue in - Text(AppPresentation.message(for: issue)) - .font(.caption) - .foregroundStyle(.red) - .frame(width: 220, alignment: .trailing) - } - if let occupancy = state.occupancy { - Text(occupancyText(occupancy, state: state)) - .font(.caption) - .foregroundStyle(occupancyColor(occupancy)) - .frame(width: 220, alignment: .trailing) - } - if state.canSuggestFreePort { - Button("建议空闲端口") { - Task { _ = await workflow.suggestFreePort(for: id) } - } - .font(.caption) + fileprivate var portSettingsRow: some View { + let ports = workflow.committedPortDraft + return settingRow("端口设置") { + Button { + portSettingsEditor = PortSettingsEditorSession( + initialDraft: workflow.beginPortSettingsEditing()) + } label: { + HStack(spacing: 8) { + Text("SOCKS5 \(ports.socksPort) / HTTP \(ports.httpPort)") + .monospacedDigit() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) } } + .buttonStyle(.bordered) + .accessibilityLabel("端口设置") + .accessibilityValue("SOCKS5 \(ports.socksPort),HTTP 代理 \(ports.httpPort)") } - .padding(.vertical, 2) } - private func portBinding(for id: SettingsPortID) -> Binding { - Binding( - get: { workflow.draft.portValue(for: id) }, - set: { workflow.draft.setPortValue($0, for: id) }) - } - - private func issuesRow(_ field: SettingsFieldID) -> some View { - ForEach(Array(workflow.issues(for: field).enumerated()), id: \.offset) { _, issue in + fileprivate func issuesRow(_ field: SettingsFieldID) -> some View { + ForEach(workflow.issues(for: field), id: \.self) { issue in Text(AppPresentation.message(for: issue)) .font(.caption) .foregroundStyle(.red) } } +} - private func occupancyText( - _ occupancy: SettingsPortOccupancy, state: SettingsPortFieldState - ) -> String { +private struct PortSettingsEditorSession: Identifiable { + let id = UUID() + let initialDraft: SettingsPortDraft +} + +private struct PortSettingsEditorSheet: View { + @ObservedObject var workflow: SettingsWorkflow + @Environment(\.dismiss) private var dismiss + @State private var draft: SettingsPortDraft + + init(workflow: SettingsWorkflow, initialDraft: SettingsPortDraft) { + self.workflow = workflow + _draft = State(initialValue: initialDraft) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("端口设置") + .font(.title2) + Text("端口范围为 1000–65535,SOCKS5 与 HTTP 代理端口必须不同。") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 14) { + portField(.socks, title: "SOCKS 代理端口") + portField(.http, title: "HTTP 代理端口") + } + + if let failure = workflow.lastFailure { + Label(failure.presentableMessage, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("取消", role: .cancel) { + dismiss() + } + .keyboardShortcut(.cancelAction) + .disabled(workflow.isCommitting) + + Button(workflow.isCommitting ? "保存中…" : "保存") { + Task { + if case .persisted = await workflow.savePortSettings(draft) { + dismiss() + } + } + } + .keyboardShortcut(.defaultAction) + .disabled(!workflow.canSavePortSettings(draft)) + } + } + .padding(24) + .frame(width: 460) + .interactiveDismissDisabled(workflow.isCommitting) + .task(id: draft) { + workflow.refreshPortEditorOccupancy(for: draft) + } + } + + private func portField(_ id: SettingsPortID, title: String) -> some View { + let state = workflow.portFieldState(for: id, editorDraft: draft) + return VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 16) { + Text(title) + Spacer(minLength: 12) + TextField(title, value: portBinding(for: id), format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .monospacedDigit() + .frame(width: 112) + .disabled(workflow.isCommitting) + } + + ForEach(state.issues, id: \.self) { issue in + Text(AppPresentation.message(for: issue)) + .font(.caption) + .foregroundStyle(.red) + } + + if let occupancy = occupancyMessage(for: state) { + Text(occupancy) + .font(.caption) + .foregroundStyle(occupancyColor(for: state)) + } + + if state.canSuggestFreePort { + Button("建议空闲端口") { + Task { + let outcome = await workflow.suggestFreePort(for: id, from: draft) + if case .draftUpdated(let port, let value) = outcome { + draft.setPortValue(value, for: port) + } + } + } + .font(.caption) + .disabled(workflow.isCommitting) + } + } + } + + private func portBinding(for id: SettingsPortID) -> Binding { + Binding( + get: { draft.portValue(for: id) }, + set: { draft.setPortValue($0, for: id) }) + } + + private func occupancyMessage(for state: SettingsPortFieldState) -> String? { + guard let occupancy = state.occupancy else { return nil } switch occupancy { case .free: - return "当前端口可用" + return nil case .occupied(let occupier): if state.isRuntimePortException { - return "当前代理正在使用此端口,保存其他设置不会触发冲突" + return "当前代理正在使用此端口" } return "当前端口已占用" + (occupier.map { "(" + $0 + ")" } ?? "") case .unknown(let detail): @@ -285,12 +374,13 @@ extension SettingsView { } } - private func occupancyColor(_ occupancy: SettingsPortOccupancy) -> Color { + private func occupancyColor(for state: SettingsPortFieldState) -> Color { + guard let occupancy = state.occupancy else { return .secondary } switch occupancy { - case .free: .secondary - case .occupied: .orange - case .unknown: .secondary + case .free, .unknown: + return .secondary + case .occupied: + return state.isRuntimePortException ? .secondary : .orange } } - } diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift index 9c307780..ad781b24 100644 --- a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflow.swift @@ -22,6 +22,10 @@ final class SettingsWorkflow: ObservableObject { /// 各端口的尽力而为占用判定(非权威,只影响编辑期提示与保存门禁)。 @Published private var occupancyByPort: [SettingsPortID: SettingsPortOccupancy] = [:] + /// 独立端口设置编辑器的占用事实;基于已提交的监听范围和编辑中的端口对。 + @Published private var portEditorOccupancyByPort: [SettingsPortID: SettingsPortOccupancy] = [:] + private var portEditorOccupancyDraft: SettingsPortDraft? + private var portEditorOccupancyGeneration = 0 /// 提交进行中(按钮进入进行中状态且不可重复触发)。 @Published private(set) var isCommitting = false /// 最近一次提交失败的 typed fact(nil = 无)。 @@ -78,6 +82,130 @@ final class SettingsWorkflow: ObservableObject { canSuggestFreePort: canSuggest) } + /// 当前已提交的端口对;设置页摘要与编辑器入口都以此为准。 + var committedPortDraft: SettingsPortDraft { + let listen = committing.committedSettings.listen + return SettingsPortDraft(socksPort: listen.socksPort, httpPort: listen.httpPort) + } + + /// 打开独立端口设置项时,从已提交值开始,丢弃上次编辑器探测的暂存事实。 + func beginPortSettingsEditing() -> SettingsPortDraft { + lastFailure = nil + portEditorOccupancyGeneration += 1 + portEditorOccupancyDraft = nil + portEditorOccupancyByPort = [:] + return committedPortDraft + } + + /// 编辑器端口字段状态;监听范围取已提交设置,不受设置页其他草稿影响。 + func portFieldState( + for id: SettingsPortID, editorDraft: SettingsPortDraft + ) -> SettingsPortFieldState { + let exception = isRuntimePortException(editorDraft: editorDraft) + let occupancy = + portEditorOccupancyDraft == editorDraft + ? portEditorOccupancyByPort[id] : nil + let issues = portIssues(for: editorDraft).filter { $0.field == .port(id) } + let canSuggest: Bool + if case .occupied? = occupancy { + canSuggest = !exception + } else { + canSuggest = false + } + return SettingsPortFieldState( + id: id, + draftValue: editorDraft.portValue(for: id), + occupancy: occupancy, + issues: issues, + isRuntimePortException: exception, + canSuggestFreePort: canSuggest) + } + + /// 刷新编辑器端口对的占用事实。旧结果不能覆盖较新的端口编辑。 + func refreshPortEditorOccupancy(for editorDraft: SettingsPortDraft) { + portEditorOccupancyGeneration += 1 + let generation = portEditorOccupancyGeneration + portEditorOccupancyDraft = editorDraft + portEditorOccupancyByPort = [:] + let listen = listenSettings(for: editorDraft) + let facts = RuntimeListenFacts(listen: listen) + let probe = occupancyProbe + Task { @MainActor in + let result = await Task.detached(priority: .utility) { + Dictionary( + uniqueKeysWithValues: SettingsPortID.allCases.map { id in + let endpoint = SettingsDraftAdapter.endpoint(for: id) + let request = PortOccupancyProbeRequest(endpoint: endpoint, listen: facts) + return (id, SettingsPortOccupancy(probe.occupancy(for: request))) + }) + }.value + guard generation == portEditorOccupancyGeneration, + portEditorOccupancyDraft == editorDraft + else { return } + portEditorOccupancyByPort = result + } + } + + /// 编辑器的保存门禁:等待占用探测完成;无效值和已知外部占用阻止保存,未知只提示。 + func canSavePortSettings(_ editorDraft: SettingsPortDraft) -> Bool { + portIssues(for: editorDraft).isEmpty + && hasCurrentPortEditorOccupancy(for: editorDraft) + && blockingPortIDs(for: editorDraft).isEmpty + && !isCommitting + } + + /// 给编辑器中的端口生成空闲候选;只返回候选,等待用户再次保存端口对。 + @discardableResult + func suggestFreePort( + for id: SettingsPortID, from editorDraft: SettingsPortDraft + ) async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } + let listen = listenSettings(for: editorDraft) + let facts = RuntimeListenFacts(listen: listen) + let endpoint = SettingsDraftAdapter.endpoint(for: id) + let generation = portEditorOccupancyGeneration + let probe = occupancyProbe + let candidate = await Task.detached(priority: .utility) { + listen.suggestedPort(for: endpoint) { port in + let request = PortOccupancyProbeRequest( + endpoint: endpoint, listen: facts.replacingPort(port, for: endpoint), port: port) + if case .free = probe.occupancy(for: request) { return true } + return false + } + }.value + guard generation == portEditorOccupancyGeneration, + portEditorOccupancyDraft == editorDraft + else { + return record(.rejected(.superseded)) + } + guard let candidate else { + return record(.rejected(.noFreePort(id))) + } + return record(.draftUpdated(port: id, value: candidate)) + } + + /// 单独保存 SOCKS5/HTTP 端口对,不提交设置页里其他未保存字段。 + @discardableResult + func savePortSettings(_ editorDraft: SettingsPortDraft) async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } + let issues = portIssues(for: editorDraft) + guard issues.isEmpty else { + return record(.rejected(.validation(issues))) + } + guard hasCurrentPortEditorOccupancy(for: editorDraft) else { + return record(.rejected(.inProgress)) + } + let blockedPorts = blockingPortIDs(for: editorDraft) + guard blockedPorts.isEmpty else { + return record(.rejected(.occupied(blockedPorts))) + } + + var proposed = committing.committedSettings + proposed.listen.socksPort = editorDraft.socksPort + proposed.listen.httpPort = editorDraft.httpPort + return await commitPortSettings(proposed) + } + /// 明确阻塞保存的端口。 var blockingPortIDs: [SettingsPortID] { SettingsPortID.allCases.filter { id in @@ -164,6 +292,62 @@ extension SettingsWorkflow { SettingsDraftAdapter.settings(from: draft, preservingModeOf: committing.committedSettings) } + private func listenSettings(for editorDraft: SettingsPortDraft) -> SslocalListenSettings { + var listen = committing.committedSettings.listen + listen.socksPort = editorDraft.socksPort + listen.httpPort = editorDraft.httpPort + return listen + } + + private func portIssues(for editorDraft: SettingsPortDraft) -> [SettingsFieldIssue] { + let errors = listenSettings(for: editorDraft).portValidationErrors() + .map(ProxySettingsValidationError.init) + return SettingsDraftAdapter.fieldIssues(from: errors) + } + + private func isRuntimePortException(editorDraft: SettingsPortDraft) -> Bool { + guard let runtime = committing.runtimeListenFacts else { return false } + return runtime == RuntimeListenFacts(listen: listenSettings(for: editorDraft)) + } + + private func blockingPortIDs(for editorDraft: SettingsPortDraft) -> [SettingsPortID] { + SettingsPortID.allCases.filter { id in + guard !isRuntimePortException(editorDraft: editorDraft) else { return false } + if case .occupied? = portFieldState(for: id, editorDraft: editorDraft).occupancy { + return true + } + return false + } + } + + private func hasCurrentPortEditorOccupancy(for editorDraft: SettingsPortDraft) -> Bool { + portEditorOccupancyDraft == editorDraft + && portEditorOccupancyByPort.count == SettingsPortID.allCases.count + } + + private func commitPortSettings(_ proposed: ProxySettings) async -> SettingsCommandOutcome { + guard !isCommitting else { return record(.rejected(.inProgress)) } + isCommitting = true + lastFailure = nil + do { + let runtime = try await committing.updateSettings(proposed) + var nextDraft = draft + nextDraft.socksPort = committing.committedSettings.listen.socksPort + nextDraft.httpPort = committing.committedSettings.listen.httpPort + draft = nextDraft + isCommitting = false + // Port persistence succeeded. Runtime convergence failure is owned by runtime + // status and does not become a separate settings-page error for this item. + lastFailure = nil + return record(.persisted(runtime: runtime)) + } catch { + isCommitting = false + let failure = Self.workflowFailure(for: error) + lastFailure = failure + return record(.persistenceFailed(Self.persistenceFailure(for: error))) + } + } + /// 例外只在当前 runtime 的完整有效监听身份与草稿身份相同时成立。 /// 例外只影响保存门禁与提示,不改持久化。 private func isRuntimePortException(_ id: SettingsPortID) -> Bool { diff --git a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift index 4d70ac77..1da4b58e 100644 --- a/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/SettingsWorkflow/SettingsWorkflowModels.swift @@ -9,6 +9,26 @@ enum SettingsPortID: CaseIterable, Hashable, Sendable { case http } +/// The SOCKS5 and HTTP ports edited and saved together as one settings item. +struct SettingsPortDraft: Equatable, Sendable { + var socksPort: Int + var httpPort: Int + + func portValue(for id: SettingsPortID) -> Int { + switch id { + case .socks: socksPort + case .http: httpPort + } + } + + mutating func setPortValue(_ value: Int, for id: SettingsPortID) { + switch id { + case .socks: socksPort = value + case .http: httpPort = value + } + } +} + /// 平坦的 UI 形状编辑草稿:字段全部是 UI 原语。监听范围拆成布尔开关与公布 /// 地址文本两个字段,端口与超时保持整数;持久化的当前模式不在草稿中。 /// 与 Domain 快照的互转只在 `SettingsDraftAdapter`。 @@ -47,7 +67,7 @@ enum SettingsFieldID: Hashable, Sendable { /// 按字段归位的校验问题:每条保留 Domain 的 typed fact;文案由 App /// presentation edge 派生。 -enum SettingsFieldIssue: Equatable, Sendable { +enum SettingsFieldIssue: Hashable, Sendable { case port(SettingsPortID, error: ProxySettingsValidationError) case advertisedAddress(error: ProxySettingsValidationError) case timeoutSeconds(error: ProxySettingsValidationError) diff --git a/ShadowsocksX-NG2/Domain/ProxyMode.swift b/ShadowsocksX-NG2/Domain/ProxyMode.swift index b424134a..d62aa29c 100644 --- a/ShadowsocksX-NG2/Domain/ProxyMode.swift +++ b/ShadowsocksX-NG2/Domain/ProxyMode.swift @@ -79,10 +79,10 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable { for document: SslocalRuntimeDocument, exceptions: [String]? = nil ) throws -> SystemProxyConfiguration { - guard (1...65535).contains(document.socksPort) else { + guard ProxyPortRange.valid.contains(document.socksPort) else { throw ProxyModeError.invalidSOCKSPort(document.socksPort) } - guard (1...65535).contains(document.httpPort) else { + guard ProxyPortRange.valid.contains(document.httpPort) else { throw ProxyModeError.invalidHTTPPort(document.httpPort) } return SystemProxyConfiguration( diff --git a/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift b/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift index 58038197..5d135740 100644 --- a/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift +++ b/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift @@ -5,7 +5,7 @@ /// 期尽力而为的即时提示。 /// 本地代理端点;显示名与 D8 及诊断报告的措辞一致。PAC 端点已随 issue #67 移除。 -enum ProxyEndpointKind: Equatable, Sendable, CaseIterable { +enum ProxyEndpointKind: Hashable, Sendable, CaseIterable { case socks case http @@ -18,7 +18,7 @@ enum ProxyEndpointKind: Equatable, Sendable, CaseIterable { } /// 端口配置校验错误:点名端点与端口(D8 错误信息要求)。 -enum PortSettingError: Error, Equatable, Sendable { +enum PortSettingError: Error, Hashable, Sendable { case portOutOfRange(endpoint: ProxyEndpointKind, port: Int) /// 两个端点的配置端口相同;`endpoint` 在枚举序(socks → http)中在前。 case duplicatePort(endpoint: ProxyEndpointKind, otherEndpoint: ProxyEndpointKind, port: Int) @@ -46,13 +46,13 @@ extension SslocalListenSettings { } } - /// 端口配置校验:范围 1–65535、两端点配置值互异。错误按固定次序 + /// 端口配置校验:范围 1000–65535、两端点配置值互异。错误按固定次序 /// (socks/http 范围检查,再 socks-http 冲突)。 func portValidationErrors() -> [PortSettingError] { var errors: [PortSettingError] = [] for endpoint in ProxyEndpointKind.allCases { let port = configuredPort(for: endpoint) - if !(1...65535).contains(port) { + if !ProxyPortRange.valid.contains(port) { errors.append(.portOutOfRange(endpoint: endpoint, port: port)) } } diff --git a/ShadowsocksX-NG2/Domain/ProxySettings.swift b/ShadowsocksX-NG2/Domain/ProxySettings.swift index 370ca05e..0b6d0266 100644 --- a/ShadowsocksX-NG2/Domain/ProxySettings.swift +++ b/ShadowsocksX-NG2/Domain/ProxySettings.swift @@ -77,7 +77,7 @@ struct ProxySettings: Equatable, Sendable { } /// 设置快照的点名校验错误;端口错误保留 D8 的端点与数值信息。 -enum ProxySettingsValidationError: Error, Equatable, Sendable { +enum ProxySettingsValidationError: Error, Hashable, Sendable { case portOutOfRange(endpoint: ProxyEndpointKind, port: Int) case duplicatePort(endpoint: ProxyEndpointKind, otherEndpoint: ProxyEndpointKind, port: Int) case invalidTimeout(Int) diff --git a/ShadowsocksX-NG2/Domain/SslocalListenSettings.swift b/ShadowsocksX-NG2/Domain/SslocalListenSettings.swift index 36e65141..9e36b67e 100644 --- a/ShadowsocksX-NG2/Domain/SslocalListenSettings.swift +++ b/ShadowsocksX-NG2/Domain/SslocalListenSettings.swift @@ -1,5 +1,10 @@ import Foundation +/// The allowed range for local proxy listener ports. +enum ProxyPortRange { + static let valid = 1000...65_535 +} + /// 本地端点与单一监听范围的派生设置。HTTP 入站恒开启,与 SOCKS 共用同一 /// 范围;默认端口与 Legacy 隔离(11086/11087)。PAC 端点已随 issue #67 移除。 struct SslocalListenSettings: Equatable, Sendable { diff --git a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift index a70cafbc..e94fda1a 100644 --- a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift @@ -267,7 +267,7 @@ extension SslocalRuntimeDocument { locals.allSatisfy({ local in let expectedMode = local.inboundProtocol == "socks" ? "tcp_and_udp" : "tcp_only" return local.localAddress == expectedBind - && (1...65535).contains(local.localPort) + && ProxyPortRange.valid.contains(local.localPort) && local.mode == expectedMode }) else { return false } diff --git a/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift b/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift index 44002854..2d5e3a38 100644 --- a/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift @@ -46,7 +46,7 @@ final class ProxyPortSemanticsTests: XCTestCase { XCTAssertEqual( AppPresentation.message( for: PortSettingError.portOutOfRange(endpoint: .http, port: 70000)), - "HTTP 端口 70000 无效,必须是 1–65535 之间的整数") + "HTTP 端口 70000 无效,必须是 1000–65535 之间的整数") XCTAssertEqual( AppPresentation.message( for: PortSettingError.duplicatePort( diff --git a/docs/adr/0013-save-settings-as-independent-items.md b/docs/adr/0013-save-settings-as-independent-items.md new file mode 100644 index 00000000..0fbe61e2 --- /dev/null +++ b/docs/adr/0013-save-settings-as-independent-items.md @@ -0,0 +1,5 @@ +# Save settings as independent items + +**Status**: accepted + +Settings editing is moving gradually from one form-level commit to independently saved setting items. Saving an item persists and applies only that item's complete value, while unrelated unsaved settings remain pending; the first item is the SOCKS5 and HTTP port pair, saved together because the values are cross-validated and restricted to 1000–65535. Runtime convergence remains separate from persistence: a saved port change is not rolled back after a runtime failure, and the settings item does not need a separate runtime-error message.