From f72c50dcebf0cda757c4948cc38f5d13d8747d95 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sun, 11 Oct 2026 23:58:47 +0800 Subject: [PATCH] feat(plugins): configure plugin transport mode --- GLOSSARY.md | 7 +- .../PluginManagementModel.swift | 21 +++-- .../App/Resources/Localizable.xcstrings | 33 ++++++++ .../UI/Servers/Plugin/PluginEditorSheet.swift | 20 ++++- .../Plugin/PluginManagementSection.swift | 9 +-- .../Activation/ActivationStateMachine.swift | 4 +- .../Plugin/ManagedPluginProviding.swift | 5 +- .../Domain/Plugin/PluginCatalog.swift | 10 +++ .../Domain/Plugin/PluginCatalogSnapshot.swift | 7 +- .../Plugin/PluginMappingFileStore.swift | 53 +++++++++++++ .../Domain/Plugin/PluginTransportMode.swift | 4 + .../Runtime/SslocalRuntimeDocument.swift | 2 + ShadowsocksX-NG2/README.md | 4 +- .../Tests/Plugin/PluginManagementTests.swift | 41 ++++++++-- .../Plugin/PluginMappingCommitTests.swift | 17 +++++ .../Tests/Plugin/PluginRecordTests.swift | 76 ++++++++++++++++++- ShadowsocksX-NG2/project.yml | 1 + ...records-own-aliases-and-local-arguments.md | 8 +- docs/design/plugin-transport-mode.md | 47 ++++++++++++ 19 files changed, 333 insertions(+), 36 deletions(-) create mode 100644 ShadowsocksX-NG2/Domain/Plugin/PluginTransportMode.swift create mode 100644 docs/design/plugin-transport-mode.md diff --git a/GLOSSARY.md b/GLOSSARY.md index 8dc79777..b04505d8 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -75,10 +75,13 @@ _避免用词_:可移除节点(Removable node)。 由产品随应用发布的 SIP003 插件,其发布版本与来源事实属于随应用提供的程序,不代表用户自备的同名程序。 **插件记录(Plugin record)**: -一份可由多个名称引用的本机插件资料,包含可执行程序的选择和共享的本地启动参数;用户记录具有独立于名称的持久身份。 +一份可由多个名称引用的本机插件资料,包含可执行程序的选择、共享的本地启动参数与插件传输模式;用户记录具有独立于名称的持久身份。 + +**插件传输模式(Plugin transport mode)**: +一条插件记录承载的传输协议范围:`tcp_only` 表示插件仅承载 TCP,`tcp_and_udp` 表示插件承载 TCP 与 UDP。它不表示插件已经具备 SIP003u 支持,也不同于本地 SOCKS5 端点的 UDP 中继能力。 **插件别名(Plugin alias)**: -引用同一插件记录的名称之一。同一记录的名称共享程序与本地启动参数,服务器资料保留自己使用的名称。 +引用同一插件记录的名称之一。同一记录的名称共享程序、本地启动参数与插件传输模式,服务器资料保留自己使用的名称。 **本地插件启动参数(Local plugin arguments)**: 当前 macOS 用户为一条插件记录设置的有序命令行参数,由所有引用该记录的服务器共享。它与服务器连接资料中的插件选项不同,不随服务器资料分享。 diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/PluginManagementModel.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/PluginManagementModel.swift index 6b48b6ea..3dc44591 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/PluginManagementModel.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/PluginManagementModel.swift @@ -9,6 +9,7 @@ struct PluginEditorSession: Identifiable { let initialNames: [String] let initialPath: String let initialArguments: [String] + var initialMode: PluginTransportMode = .tcpOnly } struct PluginManagementRow: Identifiable { @@ -74,7 +75,8 @@ final class PluginManagementModel: ObservableObject { else { return nil } return PluginEditorSession( recordID: id, managedProgram: nil, isEditing: true, - initialNames: record.names, initialPath: record.path, initialArguments: record.arguments) + initialNames: record.names, initialPath: record.path, initialArguments: record.arguments, + initialMode: record.mode) } func beginOverriding(_ program: String) -> PluginEditorSession? { @@ -86,14 +88,15 @@ final class PluginManagementModel: ObservableObject { initialNames: [program], initialPath: "", initialArguments: []) } - func beginManagedArguments(_ program: String) -> PluginEditorSession? { + func beginManagedSettings(_ program: String) -> PluginEditorSession? { guard !snapshot.mappingsUnreadable, let info = ManagedPluginCatalog.plugins.first(where: { $0.program == program }) else { return nil } return PluginEditorSession( recordID: nil, managedProgram: program, isEditing: true, initialNames: info.names, initialPath: "", - initialArguments: snapshot.managedArguments[program] ?? []) + initialArguments: snapshot.managedArguments[program] ?? [], + initialMode: snapshot.managedRecords[program]?.mode ?? .tcpOnly) } func namesIssue(_ names: [String], session: PluginEditorSession) -> PluginMappingError? { @@ -110,11 +113,17 @@ final class PluginManagementModel: ObservableObject { path.hasPrefix("/") && !path.contains("\0") ? nil : .invalidPath } - func save(_ session: PluginEditorSession, names: [String], path: String, arguments: [String] = []) + func save( + _ session: PluginEditorSession, names: [String], path: String, arguments: [String] = [], + mode: PluginTransportMode = .tcpOnly + ) throws { if let program = session.managedProgram { - try catalog.commit([.managedArguments(program: program, arguments: arguments)]) + try catalog.commit([ + .managedArguments(program: program, arguments: arguments), + .managedMode(program: program, mode: mode), + ]) } else { if let issue = namesIssue(names, session: session) { throw issue } if let issue = pathIssue(path) { throw issue } @@ -122,7 +131,7 @@ final class PluginManagementModel: ObservableObject { !session.isEditing || catalog.userRecords.contains(where: { $0.id == id }) else { throw PluginMappingError.nameMissing } try catalog.commit([ - .save(UserPluginRecord(id: id, names: names, path: path, arguments: arguments)) + .save(UserPluginRecord(id: id, names: names, path: path, arguments: arguments, mode: mode)) ]) } refresh() diff --git a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings index 232f1fd3..eb61aafb 100644 --- a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings @@ -1397,6 +1397,39 @@ } } } + }, + "设置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "插件传输模式": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin Transport Mode" + } + } + } + }, + "内置插件设置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in Plugin Settings" + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginEditorSheet.swift b/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginEditorSheet.swift index 938fd6c7..9ceb63af 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginEditorSheet.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginEditorSheet.swift @@ -13,6 +13,7 @@ struct PluginEditorSheet: View { /// Each presentation seeds fresh drafts; row identities survive edits and moves. @State private var names: [PluginTextDraft] @State private var arguments: [PluginTextDraft] + @State private var mode: PluginTransportMode @State private var path: String @State private var failure: String? @State private var showFilePicker = false @@ -23,6 +24,7 @@ struct PluginEditorSheet: View { _names = State(initialValue: session.initialNames.map { PluginTextDraft(value: $0) }) _arguments = State(initialValue: session.initialArguments.map { PluginTextDraft(value: $0) }) _path = State(initialValue: session.initialPath) + _mode = State(initialValue: session.initialMode) } private var nameValues: [String] { names.map(\.value) } @@ -60,8 +62,20 @@ struct PluginEditorSheet: View { .font(.caption).foregroundStyle(.secondary) } } + Picker("插件传输模式", selection: $mode) { + ForEach(PluginTransportMode.allCases, id: \.self) { mode in + Text(verbatim: mode.rawValue).tag(mode) + } + } + .pickerStyle(.menu) VStack(alignment: .leading, spacing: 8) { - Text("本地启动参数") + HStack { + Text("本地启动参数") + Spacer() + if isManaged { + Button("清空参数") { arguments.removeAll() }.disabled(arguments.isEmpty) + } + } PluginValueList(values: $arguments, placeholder: "参数", allowsReordering: true) Text("每项作为一个完整参数传递,无需 shell 引号。仅在本机生效。") .font(.caption).foregroundStyle(.secondary) @@ -78,7 +92,7 @@ struct PluginEditorSheet: View { Button("保存") { do { try model.save( - session, names: nameValues, path: path, arguments: arguments.map(\.value)) + session, names: nameValues, path: path, arguments: arguments.map(\.value), mode: mode) dismiss() } catch { failure = PluginManagementCopy.error(error) } } @@ -102,7 +116,7 @@ struct PluginEditorSheet: View { } private var title: String { - if isManaged { return String(localized: "内置插件启动参数") } + if isManaged { return String(localized: "内置插件设置") } return session.isEditing ? String(localized: "编辑插件") : String(localized: "新增插件") } diff --git a/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginManagementSection.swift b/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginManagementSection.swift index 73d3179d..d855173c 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginManagementSection.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/Plugin/PluginManagementSection.swift @@ -97,20 +97,15 @@ struct PluginManagementSection: View { .frame(maxWidth: .infinity, alignment: .leading) if let program = entry.managedProgram { Button { - editor = model.beginManagedArguments(program) + editor = model.beginManagedSettings(program) } label: { HStack(spacing: 8) { - Text("启动参数") + Text("设置") Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) } } .buttonStyle(.bordered) - Button("清空参数") { - do { try model.clearManagedArguments(program) } catch { - failure = PluginManagementCopy.error(error) - } - }.disabled(model.snapshot.managedArguments[program] == nil) Menu("覆盖名称") { ForEach(entry.names, id: \.self) { name in Button(name) { editor = model.beginOverriding(name) } diff --git a/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift b/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift index e21d299a..89388eef 100644 --- a/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift +++ b/ShadowsocksX-NG2/Domain/Activation/ActivationStateMachine.swift @@ -206,10 +206,12 @@ struct ActivationStateMachine: Equatable, Sendable { var pluginPath: String? var pluginOpts: String? var pluginArgs: [String]? + var pluginMode: PluginTransportMode? if let program = fields.pluginProgram { guard let resolved = plugins.resolve(program: program) else { return .failure(.invalidLeaf(node: leafID, reason: .pluginNotProvided(program: program))) } + pluginMode = resolved.mode == .tcpOnly ? nil : resolved.mode pluginPath = resolved.path pluginArgs = resolved.arguments.isEmpty ? nil : resolved.arguments if let optionsRef = fields.pluginOptionsRef { @@ -229,7 +231,7 @@ struct ActivationStateMachine: Equatable, Sendable { password: password, method: fields.encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines), plugin: pluginPath, - pluginOpts: pluginOpts, pluginArgs: pluginArgs)) + pluginOpts: pluginOpts, pluginArgs: pluginArgs, pluginMode: pluginMode)) } /// 凭据解析:无条目与读取失败分别点名(D5「凭据不可解析 → 停止并清理」)。 diff --git a/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift b/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift index ff4cf895..906e9261 100644 --- a/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift +++ b/ShadowsocksX-NG2/Domain/Plugin/ManagedPluginProviding.swift @@ -1,10 +1,11 @@ import Foundation -/// 插件解析 seam:路径与本地启动参数,以及一次派生使用的只读目录快照。 +/// 插件解析 seam:路径、本地启动参数与传输模式,以及一次派生使用的只读目录快照。 /// nil 表示本地不可用;托管与用户映射共用这一激活语义。 struct ResolvedPlugin: Equatable, Sendable { let path: String let arguments: [String] + var mode: PluginTransportMode = .tcpOnly } protocol PluginExecutableResolving { @@ -25,7 +26,7 @@ extension PluginExecutableResolving { return PluginCatalogSnapshot.Entry( program: program, path: resolved?.path, source: .managed, availability: resolved == nil ? .missing : .available, managedInfo: info, - arguments: resolved?.arguments ?? []) + arguments: resolved?.arguments ?? [], mode: resolved?.mode ?? .tcpOnly) } }) } diff --git a/ShadowsocksX-NG2/Domain/Plugin/PluginCatalog.swift b/ShadowsocksX-NG2/Domain/Plugin/PluginCatalog.swift index ea68846f..95f1991d 100644 --- a/ShadowsocksX-NG2/Domain/Plugin/PluginCatalog.swift +++ b/ShadowsocksX-NG2/Domain/Plugin/PluginCatalog.swift @@ -8,6 +8,7 @@ final class PluginCatalog: ObservableObject, PluginExecutableResolving { case save(UserPluginRecord) case remove(UUID) case managedArguments(program: String, arguments: [String]) + case managedMode(program: String, mode: PluginTransportMode) } private struct State { @@ -88,6 +89,15 @@ final class PluginCatalog: ObservableObject, PluginExecutableResolving { throw PluginMappingError.nameMissing } proposed.userPlugins.remove(at: index) + case .managedMode(let program, let mode): + guard ManagedPluginCatalog.plugins.contains(where: { $0.program == program }) else { + throw PluginMappingError.nameMissing + } + if mode == .tcpOnly { + proposed.managedModes.removeValue(forKey: program) + } else { + proposed.managedModes[program] = mode + } case .managedArguments(let program, let arguments): guard ManagedPluginCatalog.plugins.contains(where: { $0.program == program }) else { throw PluginMappingError.nameMissing diff --git a/ShadowsocksX-NG2/Domain/Plugin/PluginCatalogSnapshot.swift b/ShadowsocksX-NG2/Domain/Plugin/PluginCatalogSnapshot.swift index b0478ac1..4e5b0899 100644 --- a/ShadowsocksX-NG2/Domain/Plugin/PluginCatalogSnapshot.swift +++ b/ShadowsocksX-NG2/Domain/Plugin/PluginCatalogSnapshot.swift @@ -23,6 +23,7 @@ struct PluginCatalogSnapshot: PluginExecutableResolving, Equatable, Sendable { let availability: Availability let managedInfo: ManagedPluginInfo? var arguments: [String] = [] + var mode: PluginTransportMode = .tcpOnly var recordID: UUID? } @@ -60,6 +61,7 @@ struct PluginCatalogSnapshot: PluginExecutableResolving, Equatable, Sendable { managedInfo: source == .managed ? info : nil, arguments: user?.arguments ?? info.flatMap { configuration.managedArguments[$0.program] } ?? [], + mode: user?.mode ?? info.flatMap { configuration.managedModes[$0.program] } ?? .tcpOnly, recordID: user?.id) } self.managedRecords = Dictionary( @@ -72,7 +74,8 @@ struct PluginCatalogSnapshot: PluginExecutableResolving, Equatable, Sendable { program: info.program, path: path, source: .managed, availability: mappingsUnreadable ? .unreadable : Self.availability(at: path, fileManager: managed.fileManager), - managedInfo: info, arguments: configuration.managedArguments[info.program] ?? []) + managedInfo: info, arguments: configuration.managedArguments[info.program] ?? [], + mode: configuration.managedModes[info.program] ?? .tcpOnly) ) }) self.managedArguments = configuration.managedArguments @@ -124,7 +127,7 @@ struct PluginCatalogSnapshot: PluginExecutableResolving, Equatable, Sendable { guard !mappingsUnreadable, let entry = entry(for: program), entry.availability == .available, let path = entry.path else { return nil } - return ResolvedPlugin(path: path, arguments: entry.arguments) + return ResolvedPlugin(path: path, arguments: entry.arguments, mode: entry.mode) } func catalogSnapshot() -> PluginCatalogSnapshot { self } diff --git a/ShadowsocksX-NG2/Domain/Plugin/PluginMappingFileStore.swift b/ShadowsocksX-NG2/Domain/Plugin/PluginMappingFileStore.swift index 5b5b349b..6f893052 100644 --- a/ShadowsocksX-NG2/Domain/Plugin/PluginMappingFileStore.swift +++ b/ShadowsocksX-NG2/Domain/Plugin/PluginMappingFileStore.swift @@ -5,12 +5,60 @@ struct UserPluginRecord: Codable, Equatable, Identifiable, Sendable { var names: [String] var path: String var arguments: [String] = [] + var mode: PluginTransportMode = .tcpOnly + + enum CodingKeys: String, CodingKey { + case id, names, path, arguments + case mode = "plugin_mode" + } + + init( + id: UUID = UUID(), names: [String], path: String, arguments: [String] = [], + mode: PluginTransportMode = .tcpOnly + ) { + self.id = id + self.names = names + self.path = path + self.arguments = arguments + self.mode = mode + } + + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(UUID.self, forKey: .id) + names = try values.decode([String].self, forKey: .names) + path = try values.decode(String.self, forKey: .path) + arguments = try values.decode([String].self, forKey: .arguments) + mode = + values.contains(.mode) ? try values.decode(PluginTransportMode.self, forKey: .mode) : .tcpOnly + } } struct PluginConfiguration: Codable, Equatable, Sendable { var userPlugins: [UserPluginRecord] = [] /// Keys are fixed managed-record identities, independent of their aliases. var managedArguments: [String: [String]] = [:] + var managedModes: [String: PluginTransportMode] = [:] + + enum CodingKeys: String, CodingKey { case userPlugins, managedArguments, managedModes } + + init( + userPlugins: [UserPluginRecord] = [], managedArguments: [String: [String]] = [:], + managedModes: [String: PluginTransportMode] = [:] + ) { + self.userPlugins = userPlugins + self.managedArguments = managedArguments + self.managedModes = managedModes + } + + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + userPlugins = try values.decode([UserPluginRecord].self, forKey: .userPlugins) + managedArguments = try values.decode([String: [String]].self, forKey: .managedArguments) + managedModes = + values.contains(.managedModes) + ? try values.decode([String: PluginTransportMode].self, forKey: .managedModes) : [:] + } func validate() throws { var names = Set() @@ -30,6 +78,11 @@ struct PluginConfiguration: Codable, Equatable, Sendable { } try Self.validateArguments(record.arguments) } + for program in managedModes.keys { + guard ManagedPluginCatalog.plugins.contains(where: { $0.program == program }) else { + throw PluginMappingError.nameMissing + } + } for (program, arguments) in managedArguments { guard ManagedPluginCatalog.plugins.contains(where: { $0.program == program }) else { throw PluginMappingError.nameMissing diff --git a/ShadowsocksX-NG2/Domain/Plugin/PluginTransportMode.swift b/ShadowsocksX-NG2/Domain/Plugin/PluginTransportMode.swift new file mode 100644 index 00000000..59285f98 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Plugin/PluginTransportMode.swift @@ -0,0 +1,4 @@ +enum PluginTransportMode: String, Codable, CaseIterable, Sendable { + case tcpOnly = "tcp_only" + case tcpAndUDP = "tcp_and_udp" +} diff --git a/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift index 1e5a728f..f67ff57a 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift @@ -187,6 +187,7 @@ struct SslocalServerDocument: Codable, Equatable, Sendable { let plugin: String? let pluginOpts: String? var pluginArgs: [String]? + var pluginMode: PluginTransportMode? enum CodingKeys: String, CodingKey { case id, server @@ -194,6 +195,7 @@ struct SslocalServerDocument: Codable, Equatable, Sendable { case password, method, plugin case pluginOpts = "plugin_opts" case pluginArgs = "plugin_args" + case pluginMode = "plugin_mode" } } diff --git a/ShadowsocksX-NG2/README.md b/ShadowsocksX-NG2/README.md index 01953ee7..aa4f23b3 100644 --- a/ShadowsocksX-NG2/README.md +++ b/ShadowsocksX-NG2/README.md @@ -153,8 +153,8 @@ Python 是唯一的上游转换实现;Swift 只加载、校验并消费快照 ### 用户插件目录 -`PluginCatalog` 合成当前托管清单与本机 `plugins.json` 用户记录,名称精确区分大小写。用户映射优先,失效 override 不退回托管程序;删除 override 恢复当前 app 默认值。文件读取错误保留阻断事实,不按空映射处理。用户路径与本地启动参数不进入服务器资料、订阅或分享文件。用户路径也不进入诊断报告。 +`PluginCatalog` 合成当前托管清单与本机 `plugins.json` 用户记录,名称精确区分大小写。用户映射优先,失效 override 不退回托管程序;删除 override 恢复当前 app 默认值。文件读取错误保留阻断事实,不按空映射处理。用户路径、本地启动参数与插件传输模式不进入服务器资料、订阅或分享文件。用户路径也不进入诊断报告。 -设置页底部的“插件”区域统一展示内置与用户插件,按记录展示全部名称,支持新增、编辑名称集合与路径、逐名称覆盖内置、删除记录与恢复同名内置。内置记录可单独设置或清空本地启动参数。名称与启动参数使用列表编辑,启动参数保留顺序和完整字符串;路径支持手动输入与文件选择。读取失败时隐藏条目并提供只读重试,管理 UI 不提供整体重置。映射提交与显式修复 interface 在组合根统一接入表单、激活与诊断。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。`plugins.json` v1 每项独立迁移为带持久身份的用户记录,下一次保存写入 v2,不按相同路径合并。现有内置程序同时提供 `v2ray-plugin` 和 `v2ray-plugin-v1`。详见 [ADR-0033](../docs/adr/0033-plugin-records-own-aliases-and-local-arguments.md)。 +设置页底部的“插件”区域统一展示内置与用户插件,按记录展示全部名称,支持新增、编辑名称集合与路径、逐名称覆盖内置、删除记录与恢复同名内置。用户与内置记录可以设置插件传输模式 `tcp_only` 或 `tcp_and_udp`;默认 `tcp_only`,生成 sslocal 配置时省略 `plugin_mode`。应用不探测插件的 SIP003u 支持。内置编辑器中的“清空参数”只清空草稿启动参数并保留模式,保存后生效。名称与启动参数使用列表编辑,启动参数保留顺序和完整字符串;路径支持手动输入与文件选择。读取失败时隐藏条目并提供只读重试,管理 UI 不提供整体重置。映射提交与显式修复 interface 在组合根统一接入表单、激活与诊断。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。`plugins.json` v1 每项独立迁移为带持久身份的用户记录,下一次保存写入 v2,不按相同路径合并。现有内置程序同时提供 `v2ray-plugin` 和 `v2ray-plugin-v1`。详见 [ADR-0033](../docs/adr/0033-plugin-records-own-aliases-and-local-arguments.md)。 `MacOSPluginInspection` 只读取下载隔离、签名与系统评估事实,不启动用户插件、不修改系统策略。检查失败或不适用为未知;策略评估拒绝不等于实际执行禁令,也不阻止映射保存或激活。真实 macOS 拦截提示不由 hostless 替身测试证明。 diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginManagementTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginManagementTests.swift index 41f0c7c0..6a1106cb 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginManagementTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginManagementTests.swift @@ -5,6 +5,35 @@ import Testing @MainActor struct PluginManagementTests { + @Test func editingTransportModePersistsForUserAndManagedRecords() throws { + let fixture = try ManagementFixture() + defer { fixture.cleanUp() } + let model = PluginManagementModel(catalog: fixture.catalog) + let add = model.beginAdding() + #expect(add.initialMode == .tcpOnly) + try model.save( + add, names: ["custom", "custom-v2"], path: fixture.first.path, + arguments: ["--verbose"], mode: .tcpAndUDP) + let edit = try #require(model.testBeginEditing("custom")) + #expect(edit.initialMode == .tcpAndUDP) + try model.save( + edit, names: edit.initialNames, path: edit.initialPath, + arguments: [], mode: edit.initialMode) + #expect(model.snapshot.resolve(program: "custom-v2")?.mode == .tcpAndUDP) + #expect(model.snapshot.resolve(program: "custom-v2")?.arguments == []) + let managed = try #require(model.beginManagedSettings("v2ray-plugin")) + #expect(managed.initialMode == .tcpOnly) + try model.save( + managed, names: managed.initialNames, path: "", + arguments: ["--verbose"], mode: .tcpAndUDP) + let restored = PluginManagementModel( + catalog: PluginCatalog(store: fixture.store, inspector: ManagementInspector())) + #expect(restored.beginManagedSettings("v2ray-plugin")?.initialMode == .tcpAndUDP) + #expect(restored.testBeginEditing("custom")?.initialMode == .tcpAndUDP) + try restored.save(managed, names: managed.initialNames, path: "", mode: .tcpOnly) + #expect(restored.beginManagedSettings("v2ray-plugin")?.initialMode == .tcpOnly) + } + @Test func fullyOverriddenManagedRecordKeepsItsOwnFileFacts() throws { let fixture = try ManagementFixture() defer { fixture.cleanUp() } @@ -18,12 +47,12 @@ struct PluginManagementTests { #expect(covered.availability == original.availability) #expect(covered.overriddenNames == ["v2ray-plugin", "v2ray-plugin-v1"]) #expect(covered.path != fixture.first.path) - let edit = try #require(model.beginManagedArguments("v2ray-plugin")) + let edit = try #require(model.beginManagedSettings("v2ray-plugin")) try model.save(edit, names: edit.initialNames, path: "", arguments: ["--threads", "8"]) - #expect(model.beginManagedArguments("v2ray-plugin")?.initialArguments == ["--threads", "8"]) + #expect(model.beginManagedSettings("v2ray-plugin")?.initialArguments == ["--threads", "8"]) #expect(model.snapshot.resolve(program: "v2ray-plugin")?.arguments == ["--verbose"]) try model.clearManagedArguments("v2ray-plugin") - #expect(model.beginManagedArguments("v2ray-plugin")?.initialArguments == []) + #expect(model.beginManagedSettings("v2ray-plugin")?.initialArguments == []) } @Test func editingChangesNamesAndUpdatesExecutable() throws { @@ -95,14 +124,16 @@ struct PluginManagementTests { try FileManager.default.moveItem(at: config, to: backup) try Data("blocking file".utf8).write(to: config) #expect(throws: (any Error).self) { - try model.save(edit, names: ["custom"], path: fixture.second.path) + try model.save(edit, names: ["custom"], path: fixture.second.path, mode: .tcpAndUDP) } #expect(model.snapshot.entry(for: "custom")?.path == fixture.first.path) #expect(model.testBeginEditing("custom")?.initialPath == fixture.first.path) + #expect(model.testBeginEditing("custom")?.initialMode == .tcpOnly) try FileManager.default.removeItem(at: config) try FileManager.default.moveItem(at: backup, to: config) - try model.save(edit, names: ["custom"], path: fixture.second.path) + try model.save(edit, names: ["custom"], path: fixture.second.path, mode: .tcpAndUDP) #expect(model.snapshot.entry(for: "custom")?.path == fixture.second.path) + #expect(model.testBeginEditing("custom")?.initialMode == .tcpAndUDP) } @Test func unavailableFilesAndDuplicateNamesAreRejectedAndRemovalKeepsOtherMappings() throws { diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift index 142920a0..2f15a23e 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginMappingCommitTests.swift @@ -6,6 +6,23 @@ import XCTest @MainActor struct PluginMappingCommitTests { + @Test func savedTransportModeUpdatesActiveRuntime() async throws { + let fixture = try PluginMappingCommitFixture() + defer { fixture.cleanUp() } + try await fixture.controller.activate(fixture.group) + var record = try #require(fixture.plugins.userRecords.first { $0.names.contains("tool") }) + record.mode = .tcpAndUDP + try fixture.plugins.commit([.save(record)]) + _ = await fixture.nextApplication() + #expect( + fixture.files.loadDocument()?.servers.first { $0.plugin != nil }?.pluginMode == .tcpAndUDP) + #expect(fixture.controller.state == .running) + record.mode = .tcpOnly + try fixture.plugins.commit([.save(record)]) + _ = await fixture.nextApplication() + #expect(fixture.files.loadDocument()?.servers.first { $0.plugin != nil }?.pluginMode == nil) + } + @Test func runtimeFailureDoesNotUndoSavedMapping() async throws { let fixture = try PluginMappingCommitFixture() defer { fixture.cleanUp() } diff --git a/ShadowsocksX-NG2/Tests/Plugin/PluginRecordTests.swift b/ShadowsocksX-NG2/Tests/Plugin/PluginRecordTests.swift index 83ea3811..3b59d5e1 100644 --- a/ShadowsocksX-NG2/Tests/Plugin/PluginRecordTests.swift +++ b/ShadowsocksX-NG2/Tests/Plugin/PluginRecordTests.swift @@ -4,6 +4,66 @@ import Testing @testable import ShadowsocksX_NG2 struct PluginRecordTests { + @Test(arguments: [ + #"{"userPlugins":[{"id":"00000000-0000-0000-0000-000000000001","names":["tool"],"path":"/missing/tool","arguments":[],"plugin_mode":"udp_only"}],"managedArguments":{}}"#, + #"{"userPlugins":[],"managedArguments":{},"managedModes":{"v2ray-plugin":"udp_only"}}"#, + ]) + func unknownTransportModeBlocksReadingWithoutRewriting(configurationJSON: String) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) + let original = Data( + "{\"version\":2,\"configuration\":\(configurationJSON)}".utf8) + try original.write(to: store.fileURL) + #expect(throws: PluginMappingError.unreadable) { try store.load() } + #expect(try Data(contentsOf: store.fileURL) == original) + } + + @Test func oldV2RecordsDefaultToTCPWithoutRewriting() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) + let original = Data( + #"{"version":2,"configuration":{"userPlugins":[{"id":"00000000-0000-0000-0000-000000000001","names":["tool"],"path":"/missing/tool","arguments":[]}],"managedArguments":{}}}"# + .utf8) + try original.write(to: store.fileURL) + let configuration = try store.load() + #expect(configuration.userPlugins.first?.mode == .tcpOnly) + #expect( + PluginCatalogSnapshot(configuration: configuration).managedRecords["v2ray-plugin"]?.mode + == .tcpOnly) + #expect(try Data(contentsOf: store.fileURL) == original) + try store.save(configuration) + #expect(try store.load() == configuration) + } + + @Test @MainActor func managedModePersistsAcrossAliasesAndArgumentClearing() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let binary = root.appendingPathComponent("Contents/Helpers/Plugins/v2ray-plugin") + try FileManager.default.createDirectory( + at: binary.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: binary) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + let store = PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")) + try Data( + #"{"version":2,"configuration":{"userPlugins":[],"managedArguments":{"v2ray-plugin":["--verbose"]},"managedModes":{"v2ray-plugin":"tcp_and_udp"}}}"# + .utf8 + ).write(to: store.fileURL) + let managed = BundleManagedPluginProvider(bundleURL: root) + let catalog = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) + #expect(catalog.resolve(program: "v2ray-plugin-v1")?.mode == .tcpAndUDP) + try catalog.commit([.managedArguments(program: "v2ray-plugin", arguments: [])]) + let restored = PluginCatalog(store: store, managed: managed, inspector: QuietPluginInspector()) + #expect(restored.resolve(program: "v2ray-plugin")?.mode == .tcpAndUDP) + #expect(restored.resolve(program: "v2ray-plugin")?.arguments == []) + try restored.commit([.save(UserPluginRecord(names: ["v2ray-plugin"], path: binary.path))]) + #expect(restored.resolve(program: "v2ray-plugin")?.mode == .tcpOnly) + #expect(restored.resolve(program: "v2ray-plugin-v1")?.mode == .tcpAndUDP) + } + @Test @MainActor func sharingKeepsReferenceAndOptionsWithoutLocalArguments() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } @@ -18,7 +78,7 @@ struct PluginRecordTests { .save( UserPluginRecord( names: ["tool", "tool-v2"], path: binary.path, - arguments: ["--local-argument-sentinel"])) + arguments: ["--local-argument-sentinel"], mode: .tcpAndUDP)) ]) let workflow = makeCatalogWorkflow( coordinator: CatalogCommitCoordinator( @@ -36,9 +96,11 @@ struct PluginRecordTests { #expect(server["plugin"] as? String == "tool-v2") #expect(server["plugin_opts"] as? String == "tls;host=example.com") #expect(server["plugin_args"] == nil) + #expect(server["plugin_mode"] == nil) let uri = try workflow.shareURI(for: id) #expect(uri.contains("tool-v2")) #expect(!uri.contains("local-argument-sentinel")) + #expect(!uri.contains("tcp_and_udp")) #expect(!String(decoding: draft.data, as: UTF8.self).contains(binary.path)) } @@ -111,7 +173,9 @@ struct PluginRecordTests { #expect(Set(configuration.userPlugins.map(\.id)).count == 2) #expect(configuration.userPlugins.map(\.names) == [["tool"], ["tool-v2"]]) #expect( - configuration.userPlugins.allSatisfy { $0.arguments.isEmpty && $0.path == "/missing/tool" }) + configuration.userPlugins.allSatisfy { + $0.arguments.isEmpty && $0.path == "/missing/tool" && $0.mode == .tcpOnly + }) #expect(try Data(contentsOf: store.fileURL) == original) try store.save(configuration) #expect(try store.load() == configuration) @@ -120,7 +184,10 @@ struct PluginRecordTests { #expect(json["version"] as? Int == 2) } - @Test @MainActor func activationWritesLocalArgumentsWithoutChangingServerOptions() throws { + @Test(arguments: [PluginTransportMode.tcpOnly, .tcpAndUDP]) + @MainActor func activationWritesLocalArgumentsWithoutChangingServerOptions( + mode: PluginTransportMode + ) throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) @@ -134,7 +201,7 @@ struct PluginRecordTests { .save( UserPluginRecord( names: ["tool", "tool-v2"], path: binary.path, - arguments: ["--threads", "4", "--log-file", "/tmp/My Log.txt", ""])) + arguments: ["--threads", "4", "--log-file", "/tmp/My Log.txt", ""], mode: mode)) ]) let credentials = InMemoryCredentialStore() var catalog = ConfigurationCatalog() @@ -154,6 +221,7 @@ struct PluginRecordTests { let server = try #require((json["servers"] as? [[String: Any]])?.first) #expect( server["plugin_args"] as? [String] == ["--threads", "4", "--log-file", "/tmp/My Log.txt", ""]) + #expect(server["plugin_mode"] as? String == (mode == .tcpAndUDP ? "tcp_and_udp" : nil)) #expect(server["plugin"] as? String == binary.path) #expect(server["plugin_opts"] as? String == "tls;host=example.com") #expect(try ActivationFixture.serverFields(of: leaf, in: catalog).pluginProgram == "tool-v2") diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index f98e9b52..387aebb5 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -195,6 +195,7 @@ targets: - path: Domain/Runtime/ProxyACL.swift - path: Domain/Rules/ProxyRule.swift - path: Domain/Runtime/SslocalRuntimeDocument.swift + - path: Domain/Plugin/PluginTransportMode.swift - path: Domain/Runtime/SslocalListenSettings.swift - path: Domain/Runtime/RuntimeLog.swift - path: Domain/Runtime/EndpointHealthProbe.swift diff --git a/docs/adr/0033-plugin-records-own-aliases-and-local-arguments.md b/docs/adr/0033-plugin-records-own-aliases-and-local-arguments.md index fd513d7f..754bd9e6 100644 --- a/docs/adr/0033-plugin-records-own-aliases-and-local-arguments.md +++ b/docs/adr/0033-plugin-records-own-aliases-and-local-arguments.md @@ -1,10 +1,10 @@ # 插件记录拥有别名与本地启动参数 -插件记录以名称集合、可执行程序选择与有序本地启动参数表达同一个插件版本。服务器继续保存原始引用名称;名称精确区分大小写,不解析版本后缀、不探测二进制版本。无版本名称指向最新大版本是记录维护者的显式选择,带版本名称用于固定大版本。这样同一版本的多个引用共享本机配置,而目录解析 module 集中承担名称归属与程序选择。 +插件记录以名称集合、可执行程序选择、有序本地启动参数与插件传输模式表达同一个插件版本。服务器继续保存原始引用名称;名称精确区分大小写,不解析版本后缀、不探测二进制版本。无版本名称指向最新大版本是记录维护者的显式选择,带版本名称用于固定大版本。这样同一版本的多个引用共享本机配置,而目录解析 module 集中承担名称归属与程序选择。 用户记录具有独立持久身份,可编辑名称集合。不同用户记录禁止重名;用户只覆盖明确声明的内置名称,失效覆盖不回退内置程序。删除或移动名称不改写服务器和订阅引用。无版本名称通过先从旧记录删除并保存、再加入新记录并保存转移,接受中间暂时不可解析,以免增加专门的跨记录转移流程。 -内置记录的固定身份、引用名称与打包文件名分开维护。现有 v2ray-plugin v1.3.2 同时提供 `v2ray-plugin` 与 `v2ray-plugin-v1`,仍使用原二进制。内置名称由应用发布维护;本机可以只设置或清空内置记录的启动参数,不必覆盖路径。仍使用内置记录的名称共享这些参数,被用户覆盖的名称使用用户记录自己的参数。 +内置记录的固定身份、引用名称与打包文件名分开维护。现有 v2ray-plugin v1.3.2 同时提供 `v2ray-plugin` 与 `v2ray-plugin-v1`,仍使用原二进制。内置名称由应用发布维护;本机可以设置内置记录的启动参数与插件传输模式,不必覆盖路径。仍使用内置记录的名称共享这些参数与模式,被用户覆盖的名称使用用户记录自己的参数与模式。 本地启动参数按普通配置保存,不额外接入凭据存储或增加敏感信息处理。每项是一个完整 argument,保留顺序、空格与空字符串,不执行 shell 解析,拒绝无法传给进程的 NUL。运行配置生成时写入 `plugin_args`,与服务器 `plugin_opts` 独立;本地路径和启动参数不进入服务器分享、导出或订阅资料。 @@ -13,3 +13,7 @@ 设置列表按记录显示全部名称、路径、来源与可用性,内置记录标记被覆盖的名称。编辑器以列表编辑用户名称与启动参数,参数可调整顺序;服务器选择器显示每个可引用名称并保存所选原文。删除用户记录删除其全部名称,并恢复同名内置选择。 验证覆盖目录与运行配置 seam、持久化迁移、失败不发布及源码界面。此次不运行真实 sslocal 参数传递验证,按用户确认的验收范围执行。 + +插件传输模式提供 `tcp_only` 与 `tcp_and_udp`,由用户判断插件是否支持 SIP003u,不探测二进制能力、不维护支持清单。默认 `tcp_only`,运行配置省略 `plugin_mode`;`tcp_and_udp` 则显式写入该值。它只决定流量是否经过插件,不改变本地 SOCKS5 UDP 中继能力,也不保证插件自身支持 UDP。模式属于本机记录,不进入分享或订阅。旧 v1、v2 文件缺少模式时使用默认值;未知值按读取失败处理,保留原文件并阻止修改。内置记录的模式按固定身份保存,与用户覆盖独立。 + +编辑器显示「插件传输模式」。内置入口为「设置」,标题为「内置插件设置」。「清空参数」移入编辑器,只清空草稿中的启动参数并保留模式,点击「保存」才提交,点击「取消」放弃。模式与参数一次保存并沿用既有提交后运行更新流程。本次模式验收不包含真实 SIP003u 通信。 diff --git a/docs/design/plugin-transport-mode.md b/docs/design/plugin-transport-mode.md new file mode 100644 index 00000000..2f6c5586 --- /dev/null +++ b/docs/design/plugin-transport-mode.md @@ -0,0 +1,47 @@ +# 插件记录的传输模式 + +日期:2026-10-11。状态:实施与验收完成。本文记录本次设计,不代表当前代码行为。 + +## 需求与已确认决定 + +- 插件记录增加 `plugin_mode`,候选值仅为 `tcp_only` 和 `tcp_and_udp`。 +- 默认值为 `tcp_only`;生成 sslocal 配置时省略该值。 +- `tcp_and_udp` 使 TCP 与 UDP 经过插件,用于用户自行配置支持 SIP003u 的插件。应用不探测支持情况,不维护支持清单。 +- 用户与内置插件记录均可以配置传输模式。同一记录的别名共享模式;用户覆盖使用用户记录自己的模式。 +- 模式仅保存在本机插件配置,不进入服务器资料、分享或订阅。 +- 旧文件缺少字段时使用 `tcp_only`;未知值使读取失败,保留原文件,并沿用读取失败时阻止修改的行为。 +- 保存成功后发布目录快照,并更新活动运行配置。保存失败不发布;运行更新失败不撤销成功保存的配置。 +- 本次验收包含配置与源码构建的界面,不包含真实 SIP003u 通信。 + +## 界面 + +- 编辑器新增「插件传输模式」,选项原样显示 `tcp_only` 与 `tcp_and_udp`。 +- 内置记录入口从「启动参数」改为「设置」,编辑器标题改为「内置插件设置」。 +- 将内置记录的「清空参数」从设置列表移入编辑器的「本地启动参数」区域。 +- 「清空参数」只清空当前编辑草稿的启动参数,保留传输模式;点击「保存」才写入配置,点击「取消」放弃草稿。参数为空时禁用按钮。 +- 不增加列表模式摘要或单独的恢复默认操作。 + +## 验收 + +- 新记录默认使用 `tcp_only`;旧 v1、v2 文件可以读取,缺少模式时保持原行为。 +- 两种模式保存后可以重新读取;未知模式触发读取失败。 +- `tcp_only` 的 sslocal 服务器配置省略 `plugin_mode`;`tcp_and_udp` 写入 `plugin_mode: "tcp_and_udp"`;无插件时省略全部插件字段。 +- 用户记录、内置记录、同记录别名与用户覆盖使用正确模式。 +- 保存失败不发布新模式;保存成功触发既有运行更新流程。 +- 源码构建的界面验证模式选择、草稿清空、取消与保存行为,并验证列表不再显示「清空参数」。 +- 真实 SIP003u 通信标记为未验证。 + +## 上游依据 + +- 固定 shadowsocks-rust v1.25.0 的[配置解析](https://github.com/shadowsocks/shadowsocks-rust/blob/v1.25.0/crates/shadowsocks-service/src/config.rs#L2192-L2217):缺少 `plugin_mode` 时使用 `TcpOnly`。 +- 固定版本的[地址选择](https://github.com/shadowsocks/shadowsocks-rust/blob/v1.25.0/crates/shadowsocks/src/config.rs#L712-L728):插件模式控制 TCP 与 UDP 是否使用插件地址。`tcp_only` 不关闭本地 SOCKS5 UDP 中继;`tcp_and_udp` 不保证插件自身支持 UDP。 + +## 本次验收结果 + +- 定向插件测试:22 项通过,0 失败,0 跳过。 +- `task test`:1007 项 hostless 测试通过,0 失败,0 跳过;规则转换测试 21 项通过。该命令构建当前 app、Core 与 Agent。 +- 现有真实 sslocal 插件进程冒烟测试:1 项通过;只验证默认 TCP 插件进程启动、监听与退出,不代表 SIP003u 通信验收。 +- 修改文件的 `swift format lint --strict` 与 `git diff --check` 通过;全仓 SwiftLint 为 29 项警告,0 严重违规。 +- 当前仓库源码编译的隔离原生预览通过:列表入口、两种模式、保存后重新打开、清空草稿、取消保留原参数、保存清空后保留模式、用户记录默认模式。夹具使用临时目录,不读取实际用户插件配置。 +- Standards 审查:0 项标准违规;将内置编辑入口改名为 `beginManagedSettings`,对应当前职责。Spec 审查:0 项缺陷。 +- 真实 SIP003u 通信未验证,按确认的验收范围执行。