mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 15:43:45 +00:00
refactor(plugins): derive server form facts from effective catalog
This commit is contained in:
@@ -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: "")
|
||||
}
|
||||
|
||||
/// 添加落点:选中手动分组 → 组内;选中服务器 → 其父组(仅手动);其余 → 根。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 查看模式切换(参数列表 ⇄ 原始文本);被未完成行拒绝时回弹到当前模式。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 用户插件表单目录事实
|
||||
|
||||
2026-10-05 候选 2 的已确认实施范围,基线 `128efcc`。本文记录本次任务规格,不代表持续维护的项目现状。
|
||||
|
||||
- 表单只接收目录派生的名称、来源和可用性事实,不依赖 managed 发布清单、不保留兼容回退,不携带本机可执行文件路径。
|
||||
- 来源不参与服务器引用身份,不增加来源标签,用户覆盖不继承托管版本信息。
|
||||
- 新建、编辑和订阅只读表单都根据当前所选名称展示缺文件、不可执行、文件检查失败或映射读取失败的提示;不可用映射仍可保存服务器引用,激活校验负责阻断。
|
||||
- 未解析引用和已保存参数沿用现行保留规则;映射新增、删除或同名覆盖切换不得覆盖未保存参数和其他服务器字段。
|
||||
- 非敏感 presentation 保留完整目录事实,但参数明文为空。
|
||||
- 未解析引用提示采用:未找到插件“%@”。引用和已保存参数将保留;可添加同名用户插件,或改选其他插件或“无”。
|
||||
- 文件缺失提示采用:插件文件不存在,请检查用户插件路径或应用安装。
|
||||
- 不可执行提示采用:插件文件不可执行,请检查文件类型和执行权限。
|
||||
- 文件检查失败提示采用:无法读取插件文件信息,请检查路径和访问权限。
|
||||
- 映射读取失败提示采用:无法读取用户插件映射,插件暂不可用。请修复映射后重试。
|
||||
- 对应英文采用本次对话已批准的五组文案,维护于 Localizable.xcstrings。
|
||||
- 验证 seam 为 CatalogWorkflow 的新建/编辑/展示 interface 及 ServerFormFields 的装载、草稿编辑与展示刷新 interface。
|
||||
- 本轮不实现插件管理 UI、来源标签或系统安全检查提示展示。
|
||||
Reference in New Issue
Block a user