feat(plugins): configure plugin transport mode

This commit is contained in:
Qiu Yuzhou
2026-10-11 23:58:47 +08:00
parent 87a5e48120
commit f72c50dceb
19 changed files with 333 additions and 36 deletions
+5 -2
View File
@@ -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 用户为一条插件记录设置的有序命令行参数,由所有引用该记录的服务器共享。它与服务器连接资料中的插件选项不同,不随服务器资料分享。
@@ -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()
@@ -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"
@@ -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: "新增插件")
}
@@ -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) }
@@ -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「凭据不可解析 → 停止并清理」)。
@@ -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)
}
})
}
@@ -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
@@ -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 }
@@ -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<String>()
@@ -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
@@ -0,0 +1,4 @@
enum PluginTransportMode: String, Codable, CaseIterable, Sendable {
case tcpOnly = "tcp_only"
case tcpAndUDP = "tcp_and_udp"
}
@@ -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"
}
}
+2 -2
View File
@@ -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 替身测试证明。
@@ -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 {
@@ -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() }
@@ -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")
+1
View File
@@ -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
@@ -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 通信。
+47
View File
@@ -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 通信未验证,按确认的验收范围执行。