mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 23:56:20 +00:00
feat(plugins): configure plugin transport mode
This commit is contained in:
+5
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 通信。
|
||||
|
||||
@@ -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 通信未验证,按确认的验收范围执行。
|
||||
Reference in New Issue
Block a user