refactor(plugins): derive server form facts from effective catalog

This commit is contained in:
Qiu Yuzhou
2026-10-05 00:46:43 +08:00
parent 128efcc0d6
commit e74f8dfc01
11 changed files with 321 additions and 93 deletions
@@ -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"
+61 -40
View File
@@ -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")
}
+133 -3
View File
@@ -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)
+17
View File
@@ -0,0 +1,17 @@
# 用户插件表单目录事实
2026-10-05 候选 2 的已确认实施范围,基线 `128efcc`。本文记录本次任务规格,不代表持续维护的项目现状。
- 表单只接收目录派生的名称、来源和可用性事实,不依赖 managed 发布清单、不保留兼容回退,不携带本机可执行文件路径。
- 来源不参与服务器引用身份,不增加来源标签,用户覆盖不继承托管版本信息。
- 新建、编辑和订阅只读表单都根据当前所选名称展示缺文件、不可执行、文件检查失败或映射读取失败的提示;不可用映射仍可保存服务器引用,激活校验负责阻断。
- 未解析引用和已保存参数沿用现行保留规则;映射新增、删除或同名覆盖切换不得覆盖未保存参数和其他服务器字段。
- 非敏感 presentation 保留完整目录事实,但参数明文为空。
- 未解析引用提示采用:未找到插件“%@”。引用和已保存参数将保留;可添加同名用户插件,或改选其他插件或“无”。
- 文件缺失提示采用:插件文件不存在,请检查用户插件路径或应用安装。
- 不可执行提示采用:插件文件不可执行,请检查文件类型和执行权限。
- 文件检查失败提示采用:无法读取插件文件信息,请检查路径和访问权限。
- 映射读取失败提示采用:无法读取用户插件映射,插件暂不可用。请修复映射后重试。
- 对应英文采用本次对话已批准的五组文案,维护于 Localizable.xcstrings。
- 验证 seam 为 CatalogWorkflow 的新建/编辑/展示 interface 及 ServerFormFields 的装载、草稿编辑与展示刷新 interface。
- 本轮不实现插件管理 UI、来源标签或系统安全检查提示展示。