diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift index 0141c08d..03e878ce 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift @@ -138,42 +138,37 @@ final class CatalogWorkflow: ObservableObject { } else { selection = .none } - let provided: Bool - if case .named(let program) = selection { - provided = snapshot.executablePath(forProgram: program) != nil - } else { - provided = false - } - return PluginSectionState( - selection: selection, managed: ManagedPluginCatalog.plugins, provided: provided, - optionsPresent: fields.pluginOptionsRef != nil, options: "", - programs: snapshot.entries.map(\.program)) + return pluginSection( + selection: selection, snapshot: snapshot, + optionsPresent: fields.pluginOptionsRef != nil) } private func loadedPluginState(for fields: ServerFields) throws -> PluginSectionState { - let facts = pluginPresentation(for: fields) - let options: String + var facts = pluginPresentation(for: fields) if case .named = facts.selection, let reference = fields.pluginOptionsRef { - options = try requiredServerSecret(reference) - } else { - options = "" + facts.options = try requiredServerSecret(reference) } - return PluginSectionState( - selection: facts.selection, managed: facts.managed, provided: facts.provided, - optionsPresent: facts.optionsPresent, options: options, programs: facts.programs) + return facts } - /// 新建表单的插件区状态:无既有引用与既有参数,选中态跟随草稿。新表单 - /// 只能产生「无」或目录中的具名选择,`provided` 恒为真——新建尚无引用可点名, - /// 可执行文件缺失由激活语义拒绝并在编辑面呈现。 func newFormPluginSection(selection: PluginSelection) -> PluginSectionState { - let snapshot = dependencies.plugins.catalogSnapshot() - return PluginSectionState( + pluginSection( + selection: selection, snapshot: dependencies.plugins.catalogSnapshot(), + optionsPresent: false) + } + + private func pluginSection( + selection: PluginSelection, snapshot: PluginCatalogSnapshot, optionsPresent: Bool + ) -> PluginSectionState { + PluginSectionState( selection: selection, - managed: ManagedPluginCatalog.plugins, - provided: true, - optionsPresent: false, - options: "", programs: snapshot.entries.map(\.program)) + programs: snapshot.entries.map { + PluginSectionState.Program( + program: $0.program, source: $0.source, + availability: $0.availability) + }, + mappingsUnreadable: snapshot.mappingsUnreadable, + optionsPresent: optionsPresent, options: "") } /// 添加落点:选中手动分组 → 组内;选中服务器 → 其父组(仅手动);其余 → 根。 diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift index bb180406..b2dc6bc7 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift @@ -277,18 +277,28 @@ enum PluginSelection: Hashable { case unknown(program: String) } -/// 插件区表单状态:选中态、受管事实表、提供事实与参数明文(编辑面)。 +/// 表单所需的目录事实,不包含本机路径或托管发布信息。 struct PluginSectionState: Equatable { + struct Program: Equatable { + let program: String + let source: PluginCatalogSnapshot.Source + let availability: PluginCatalogSnapshot.Availability + } + let selection: PluginSelection - /// 本版本受管集(「无」不由这里提供)。 - let managed: [ManagedPluginInfo] - /// 当前具名引用的可执行文件是否在位(生成配置时的存在性检查事实)。 - let provided: Bool - /// 参数是否已配置(存于钥匙串)。 + let programs: [Program] + let mappingsUnreadable: Bool let optionsPresent: Bool - /// 参数明文(仅目录中的具名选中态解析,供参数输入框预填;其余为空串)。 - let options: String - var programs: [String]? + var options: String + + /// 当前草稿引用可能与已保存选择不同,目录移除不能让它从表单消失。 + func unresolvedProgram(for selection: PluginSelection) -> String? { + switch selection { + case .none: return nil + case .named(let program), .unknown(let program): + return programs.contains { $0.program == program } ? nil : program + } + } } // MARK: - 订阅 projection @@ -390,8 +400,8 @@ struct ServerFormPresentation: Equatable { init(isEditable: Bool, plugin: PluginSectionState) { self.isEditable = isEditable - self.plugin = PluginSectionState( - selection: plugin.selection, managed: plugin.managed, provided: plugin.provided, - optionsPresent: plugin.optionsPresent, options: "") + var facts = plugin + facts.options = "" + self.plugin = facts } } diff --git a/ShadowsocksX-NG2/App/Localizable.xcstrings b/ShadowsocksX-NG2/App/Localizable.xcstrings index 2923510d..b18e4b38 100644 --- a/ShadowsocksX-NG2/App/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Localizable.xcstrings @@ -275,6 +275,61 @@ } } } + }, + "未找到插件“%@”。引用和已保存参数将保留;可添加同名用户插件,或改选其他插件或“无”。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin “%@” was not found. Its reference and saved options will be preserved. Add a user plugin with the same name, select another plugin, or select None." + } + } + } + }, + "插件文件不存在,请检查用户插件路径或应用安装。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The plugin file is missing. Check the user plugin path or the app installation." + } + } + } + }, + "插件文件不可执行,请检查文件类型和执行权限。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The plugin file is not executable. Check its file type and execute permissions." + } + } + } + }, + "无法读取插件文件信息,请检查路径和访问权限。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin file information could not be read. Check the path and access permissions." + } + } + } + }, + "无法读取用户插件映射,插件暂不可用。请修复映射后重试。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User plugin mappings could not be read. Plugins are unavailable until the mappings are repaired." + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/ServerPluginSection.swift b/ShadowsocksX-NG2/App/ServerPluginSection.swift index ce41ac75..ca8fd9a9 100644 --- a/ShadowsocksX-NG2/App/ServerPluginSection.swift +++ b/ShadowsocksX-NG2/App/ServerPluginSection.swift @@ -1,10 +1,6 @@ import SwiftUI -/// 服务器详情的插件区(issue #38/D10,地图 #52 票 #55):有效插件目录选择器—— -/// 「无」+ 受管列表;集外引用(Legacy 导入/订阅带入)追加显式「本版本未 -/// 提供」项使当前状态可见并原样保留。选中受管项才显示参数编辑区与可用性 -/// 警告。编辑面与订阅只读面是两个独立视图(issue #81),都经共享的模式 -/// 切换器支持参数列表 ⇄ 原始文本查看。 +/// 服务器表单通过目录事实解释名称引用;参数草稿独立于插件来源。 struct ServerPluginSection: View { @Binding var selection: PluginSelection @ObservedObject var options: PluginOptionsDraft @@ -26,11 +22,16 @@ struct ServerPluginSection: View { if let plugin { Picker("插件", selection: $selection) { Text("无").tag(PluginSelection.none) - ForEach(plugin.programs ?? plugin.managed.map(\.program), id: \.self) { program in + ForEach(plugin.programs.map(\.program), id: \.self) { program in Text(program).tag(PluginSelection.named(program: program)) } - if case .unknown(let program) = plugin.selection { - Text("\(program)(本版本未提供)").tag(PluginSelection.unknown(program: program)) + if let program = plugin.unresolvedProgram(for: selection) { + Text(program).tag(selection) + } + if plugin.selection != selection, + let program = plugin.unresolvedProgram(for: plugin.selection) + { + Text(program).tag(plugin.selection) } } .labelsHidden() @@ -47,38 +48,31 @@ struct ServerPluginSection: View { switch selection { case .none: EmptyView() - case .named(let program): - namedPluginDetails(program: program, plugin: plugin) - case .unknown(let program): - unknownNotice(program: program, plugin: plugin) + case .named(let program), .unknown(let program): + if plugin.unresolvedProgram(for: selection) != nil { + unknownNotice(program: program, plugin: plugin) + } else { + namedPluginDetails(program: program, plugin: plugin) + } } } } - /// 选中受管项:参数编辑区与可用性警告;订阅只读走独立只读视图。 + /// 目录中的名称引用:参数编辑区与可用性警告;订阅只读走独立只读视图。 @ViewBuilder private func namedPluginDetails(program: String, plugin: PluginSectionState) -> some View { - if (plugin.programs ?? plugin.managed.map(\.program)).contains(program) { - VStack(alignment: .leading, spacing: 8) { - if !plugin.provided { - Label( - "不可用", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.footnote) - .foregroundStyle(.orange) - } - Text("插件参数") - .font(.callout.weight(.medium)) - .foregroundStyle(.secondary) - if isEditable { - PluginOptionsEditor( - draft: options, - fieldError: optionsError, - fieldFocus: optionsFocus) - } else { - PluginOptionsReadOnlyEditor(draft: options) - } + VStack(alignment: .leading, spacing: 8) { + availabilityNotice(program: program, plugin: plugin) + Text("插件参数") + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + if isEditable { + PluginOptionsEditor( + draft: options, + fieldError: optionsError, + fieldFocus: optionsFocus) + } else { + PluginOptionsReadOnlyEditor(draft: options) } } } @@ -87,12 +81,11 @@ struct ServerPluginSection: View { @ViewBuilder private func unknownNotice(program: String, plugin: PluginSectionState) -> some View { VStack(alignment: .leading, spacing: 8) { - Label( - "本版本未提供「\(program)」:引用原样保留,激活包含该服务器会被点名拒绝;可改选「无」或受管插件。", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.footnote) - .foregroundStyle(.orange) + if plugin.mappingsUnreadable { + availabilityNotice(program: program, plugin: plugin) + } else { + warning(Text("未找到插件“\(program)”。引用和已保存参数将保留;可添加同名用户插件,或改选其他插件或“无”。")) + } if plugin.optionsPresent { Text("插件参数已配置(存于钥匙串,原样保留)。") .font(.footnote) @@ -100,6 +93,34 @@ struct ServerPluginSection: View { } } } + + @ViewBuilder + private func availabilityNotice(program: String, plugin: PluginSectionState) -> some View { + if plugin.mappingsUnreadable { + warning(Text("无法读取用户插件映射,插件暂不可用。请修复映射后重试。")) + } else if let facts = plugin.programs.first(where: { $0.program == program }) { + switch facts.availability { + case .available: + EmptyView() + case .missing: + warning(Text("插件文件不存在,请检查用户插件路径或应用安装。")) + case .notExecutable: + warning(Text("插件文件不可执行,请检查文件类型和执行权限。")) + case .unreadable: + warning(Text("无法读取插件文件信息,请检查路径和访问权限。")) + } + } + } + + private func warning(_ text: Text) -> some View { + Label { + text + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundStyle(.orange) + } } /// 查看模式切换(参数列表 ⇄ 原始文本);被未完成行拒绝时回弹到当前模式。 diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift index 521afd9a..0bf05097 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowPluginTests.swift @@ -96,24 +96,25 @@ final class CatalogWorkflowPluginTests: XCTestCase { let id = try await addPluginServer(program: pluginBinaryName, options: "mode=websocket") let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName)) - XCTAssertTrue(plugin.provided, "二进制在位即提供") + XCTAssertEqual(plugin.programs.first?.availability, .available, "二进制在位即提供") XCTAssertTrue(plugin.optionsPresent) XCTAssertEqual(plugin.options, "mode=websocket", "参数明文供输入框预填") - XCTAssertEqual(plugin.managed.map(\.program), [pluginBinaryName]) + XCTAssertEqual(plugin.programs.map(\.program), [pluginBinaryName]) } func testMissingBinaryShowsManagedButNotProvided() async throws { let id = try await addPluginServer(program: pluginBinaryName, options: nil) let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .named(program: pluginBinaryName)) - XCTAssertFalse(plugin.provided, "打包损坏/降级丢插件时点名呈现") + XCTAssertEqual(plugin.programs.first?.availability, .missing, "打包损坏/降级丢插件时点名呈现") } func testUnknownProgramRendersExplicitUnknownState() async throws { let id = try await addPluginServer(program: "obfs-local", options: "obfs=http") let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .unknown(program: "obfs-local"), "集外引用显式未提供") - XCTAssertFalse(plugin.provided) + XCTAssertFalse( + plugin.programs.contains { $0.program == "obfs-local" && $0.availability == .available }) XCTAssertTrue(plugin.optionsPresent, "已配置参数的事实照常呈现") XCTAssertEqual(plugin.options, "", "集外引用不解析参数明文") } @@ -122,7 +123,6 @@ final class CatalogWorkflowPluginTests: XCTestCase { let id = try await addPluginServer(program: nil, options: nil) let plugin = try XCTUnwrap(try workflow.serverEditForm(for: id)?.plugin) XCTAssertEqual(plugin.selection, .none) - XCTAssertFalse(plugin.provided) XCTAssertFalse(plugin.optionsPresent) } diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift index ae53ae3a..b05f35ac 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowTests.swift @@ -81,7 +81,7 @@ final class CatalogWorkflowTests: XCTestCase { XCTAssertEqual(plugin.selection, .named(program: "v2ray-plugin")) XCTAssertTrue(plugin.optionsPresent) // NoManagedPluginProvider:程序引用保留但可执行文件缺失(集外点名事实)。 - XCTAssertFalse(plugin.provided) + XCTAssertEqual(plugin.programs.first { $0.program == "v2ray-plugin" }?.availability, .missing) XCTAssertEqual(plugin.options, "mode=websocket;host=example.com") } diff --git a/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift b/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift index d08f335d..047b6ab4 100644 --- a/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift +++ b/ShadowsocksX-NG2/Tests/PluginCatalogTests.swift @@ -65,6 +65,9 @@ struct PluginCatalogTests { remark: "Custom", plugin: .named(program: "custom-plugin"), pluginOptions: "flag"), into: nil) #expect(try workflow.serverEditForm(for: id)?.plugin.options == "flag") + let presentation = try #require(workflow.serverFormPresentation(for: id)) + #expect( + presentation.plugin.programs.contains { $0.program == "custom-plugin" && $0.source == .user }) } @Test @MainActor func commitPublishesBeforeSchedulingRuntimeApplication() async throws { @@ -106,6 +109,14 @@ struct PluginCatalogTests { store: PluginMappingFileStore(fileURL: file), managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector()) #expect(catalog.catalogSnapshot().mappingsUnreadable) + let workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: root.appendingPathComponent("catalog.json")), + runtime: FakeCatalogRuntime()), + credentials: InMemoryCredentialStore(), plugins: catalog) + #expect( + workflow.newFormPluginSection(selection: .named(program: "v2ray-plugin")).mappingsUnreadable) + #expect(catalog.executablePath(forProgram: "v2ray-plugin") == nil) #expect(catalog.catalogSnapshot().entry(for: "v2ray-plugin")?.source == .unknown) #expect(throws: PluginMappingError.unreadable) { try catalog.commit([]) } @@ -161,7 +172,7 @@ struct PluginCatalogTests { let id = NodeID.fresh() let fields = ServerFormFields() var plugin = PluginSectionState( - selection: .unknown(program: "tool"), managed: [], provided: false, + selection: .unknown(program: "tool"), programs: [], mappingsUnreadable: false, optionsPresent: true, options: "") let original = ServerEditForm( address: "127.0.0.1", port: 8388, encryptionMethod: "aes-256-gcm", password: "pw", @@ -169,8 +180,10 @@ struct PluginCatalogTests { fields.showServer(id) { _ in original } fields.remark = "Unsaved name" plugin = PluginSectionState( - selection: .named(program: "tool"), managed: [], provided: true, - optionsPresent: true, options: "flag", programs: ["tool"]) + selection: .named(program: "tool"), + programs: [.init(program: "tool", source: .user, availability: .available)], + mappingsUnreadable: false, + optionsPresent: true, options: "flag") let mapped = ServerEditForm( address: original.address, port: original.port, encryptionMethod: original.encryptionMethod, password: original.password, remark: original.remark, plugin: plugin, isEditable: true) @@ -372,3 +385,120 @@ private final class UnreadablePluginFileManager: FileManager, @unchecked Sendabl throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) } } + +/// Server-form behavior through workflow and draft interfaces. +struct PluginFormCatalogTests { + @Test(arguments: [ + PluginCatalogSnapshot.Availability.available, .missing, .notExecutable, .unreadable, + ]) + @MainActor func formRetainsAvailabilityForNewlySelectedPlugin( + availability: PluginCatalogSnapshot.Availability + ) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let snapshot = PluginCatalogSnapshot(entries: [ + .init( + program: "tool", path: "/private/tool", source: .user, + availability: availability, managedInfo: nil) + ]) + let workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: root.appendingPathComponent("catalog.json")), + runtime: FakeCatalogRuntime()), + credentials: InMemoryCredentialStore(), plugins: snapshot) + let form = workflow.newFormPluginSection(selection: .named(program: "tool")) + #expect(form.programs.first?.source == .user) + #expect(form.programs.first?.availability == availability) + #expect(!form.mappingsUnreadable) + } + + @Test @MainActor func missingUnsavedChoiceHasUnresolvedPresentation() { + let plugin = PluginSectionState( + selection: .named(program: "saved"), + programs: [.init(program: "saved", source: .managed, availability: .available)], + mappingsUnreadable: false, optionsPresent: true, options: "flag") + let form = ServerEditForm( + address: "127.0.0.1", port: 8388, + encryptionMethod: "aes-256-gcm", password: "pw", remark: "Original", + plugin: plugin, isEditable: true) + let fields = ServerFormFields() + fields.showServer(.fresh()) { _ in form } + fields.pluginChoice = .named(program: "removed") + fields.pluginOptions.load("host=unsaved") + fields.updatePresentation(ServerFormPresentation(isEditable: true, plugin: plugin)) + #expect(fields.pluginChoice == .named(program: "removed")) + #expect(fields.presentation?.plugin.unresolvedProgram(for: fields.pluginChoice) == "removed") + #expect(fields.pluginOptions.composedString == "host=unsaved") + #expect(plugin.unresolvedProgram(for: .none) == nil) + #expect(plugin.unresolvedProgram(for: .named(program: "saved")) == nil) + } + + @Test @MainActor func mappingRemovalAndOverrideChangesPreserveServerDraft() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let managedBinary = root.appendingPathComponent("Contents/Helpers/Plugins/v2ray-plugin") + try FileManager.default.createDirectory( + at: managedBinary.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: managedBinary) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: managedBinary.path) + let plugins = PluginCatalog( + store: PluginMappingFileStore(fileURL: root.appendingPathComponent("plugins.json")), + managed: BundleManagedPluginProvider(bundleURL: root), inspector: QuietPluginInspector()) + try plugins.commit([.add(program: "tool", path: managedBinary.path)]) + let workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator( + fileStore: CatalogFileStore(fileURL: root.appendingPathComponent("catalog.json")), + runtime: FakeCatalogRuntime()), + credentials: InMemoryCredentialStore(), plugins: plugins) + let id = try await workflow.createServer( + ServerEditDraft( + address: "127.0.0.1", port: 8388, encryptionMethod: "aes-256-gcm", + password: "pw", remark: "Original", plugin: .named(program: "tool"), pluginOptions: "flag"), + into: nil) + let fields = ServerFormFields() + fields.showServer(id, load: workflow.serverEditForm) + fields.remark = "Unsaved" + fields.pluginOptions.load("host=unsaved") + try plugins.commit([.remove(program: "tool")]) + fields.updatePresentation( + workflow.serverFormPresentation(for: id), load: workflow.serverEditForm) + #expect(fields.pluginChoice == .unknown(program: "tool")) + #expect(fields.pluginOptions.composedString == "host=unsaved") + try plugins.commit([.add(program: "tool", path: managedBinary.path)]) + fields.updatePresentation( + workflow.serverFormPresentation(for: id), load: workflow.serverEditForm) + #expect(fields.pluginChoice == .named(program: "tool")) + #expect(fields.pluginOptions.composedString == "host=unsaved") + #expect(try workflow.serverEditForm(for: id)?.plugin.options == "flag") + #expect(fields.remark == "Unsaved") + + // An unsaved choice disappearing must retain a selectable unresolved reference. + fields.pluginChoice = .named(program: "tool") + try plugins.commit([.remove(program: "tool")]) + fields.updatePresentation( + workflow.serverFormPresentation(for: id), load: workflow.serverEditForm) + #expect(fields.presentation?.plugin.unresolvedProgram(for: .named(program: "tool")) == "tool") + #expect( + workflow.newFormPluginSection(selection: .named(program: "tool")) + .unresolvedProgram(for: .named(program: "tool")) == "tool") + #expect(fields.pluginOptions.composedString == "host=unsaved") + + fields.pluginChoice = .named(program: "v2ray-plugin") + try plugins.commit([.add(program: "v2ray-plugin", path: managedBinary.path)]) + fields.updatePresentation( + workflow.serverFormPresentation(for: id), load: workflow.serverEditForm) + #expect( + fields.presentation?.plugin.programs.first { $0.program == "v2ray-plugin" }?.source == .user) + try plugins.commit([.remove(program: "v2ray-plugin")]) + fields.updatePresentation( + workflow.serverFormPresentation(for: id), load: workflow.serverEditForm) + #expect( + fields.presentation?.plugin.programs.first { $0.program == "v2ray-plugin" }?.source + == .managed) + #expect(fields.pluginChoice == .named(program: "v2ray-plugin")) + #expect(fields.pluginOptions.composedString == "host=unsaved") + #expect(fields.remark == "Unsaved") + } + +} diff --git a/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift b/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift index 4391ca5c..83ddac74 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormFieldLimitsTests.swift @@ -30,8 +30,8 @@ struct ServerFormFieldLimitsTests { remark: name, plugin: PluginSectionState( selection: .named(program: "v2ray-plugin"), - managed: ManagedPluginCatalog.plugins, - provided: true, + programs: [.init(program: "v2ray-plugin", source: .managed, availability: .available)], + mappingsUnreadable: false, optionsPresent: !pluginOptions.isEmpty, options: pluginOptions), isEditable: true) diff --git a/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift b/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift index c23c2ee2..c3f91668 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormLoadingTests.swift @@ -209,7 +209,7 @@ struct ServerFormLoadingTests { address: "203.0.113.1", port: 8388, encryptionMethod: "aes-256-gcm", password: password, remark: "服务器", plugin: PluginSectionState( - selection: .none, managed: ManagedPluginCatalog.plugins, provided: false, + selection: .none, programs: [], mappingsUnreadable: false, optionsPresent: false, options: ""), isEditable: isEditable) } } diff --git a/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift b/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift index ea43011d..51a86add 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift @@ -19,8 +19,8 @@ struct ServerFormPluginOptionsTests { remark: "服务器", plugin: PluginSectionState( selection: .named(program: "v2ray-plugin"), - managed: ManagedPluginCatalog.plugins, - provided: true, + programs: [.init(program: "v2ray-plugin", source: .managed, availability: .available)], + mappingsUnreadable: false, optionsPresent: !pluginOptions.isEmpty, options: pluginOptions), isEditable: true) diff --git a/docs/design/user-plugin-form-spec.md b/docs/design/user-plugin-form-spec.md new file mode 100644 index 00000000..655a08c8 --- /dev/null +++ b/docs/design/user-plugin-form-spec.md @@ -0,0 +1,17 @@ +# 用户插件表单目录事实 + +2026-10-05 候选 2 的已确认实施范围,基线 `128efcc`。本文记录本次任务规格,不代表持续维护的项目现状。 + +- 表单只接收目录派生的名称、来源和可用性事实,不依赖 managed 发布清单、不保留兼容回退,不携带本机可执行文件路径。 +- 来源不参与服务器引用身份,不增加来源标签,用户覆盖不继承托管版本信息。 +- 新建、编辑和订阅只读表单都根据当前所选名称展示缺文件、不可执行、文件检查失败或映射读取失败的提示;不可用映射仍可保存服务器引用,激活校验负责阻断。 +- 未解析引用和已保存参数沿用现行保留规则;映射新增、删除或同名覆盖切换不得覆盖未保存参数和其他服务器字段。 +- 非敏感 presentation 保留完整目录事实,但参数明文为空。 +- 未解析引用提示采用:未找到插件“%@”。引用和已保存参数将保留;可添加同名用户插件,或改选其他插件或“无”。 +- 文件缺失提示采用:插件文件不存在,请检查用户插件路径或应用安装。 +- 不可执行提示采用:插件文件不可执行,请检查文件类型和执行权限。 +- 文件检查失败提示采用:无法读取插件文件信息,请检查路径和访问权限。 +- 映射读取失败提示采用:无法读取用户插件映射,插件暂不可用。请修复映射后重试。 +- 对应英文采用本次对话已批准的五组文案,维护于 Localizable.xcstrings。 +- 验证 seam 为 CatalogWorkflow 的新建/编辑/展示 interface 及 ServerFormFields 的装载、草稿编辑与展示刷新 interface。 +- 本轮不实现插件管理 UI、来源标签或系统安全检查提示展示。