From 86779b63ee08ce4754843e65c8747eaddce115bd Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 5 Oct 2026 12:03:52 +0800 Subject: [PATCH] feat(plugins): add settings management UI --- ShadowsocksX-NG2/App/Localizable.xcstrings | 638 ++++++++++++++++++ ShadowsocksX-NG2/App/MainApp.swift | 1 + ShadowsocksX-NG2/App/MainWindowView.swift | 3 +- ShadowsocksX-NG2/App/PluginEditorSheet.swift | 90 +++ .../App/PluginManagementCopy.swift | 85 +++ .../App/PluginManagementModel.swift | 81 +++ .../App/PluginManagementSection.swift | 139 ++++ ShadowsocksX-NG2/App/SettingsView.swift | 4 +- ShadowsocksX-NG2/Domain/PluginCatalog.swift | 13 + ShadowsocksX-NG2/README.md | 2 +- .../Tests/PluginManagementTests.swift | 149 ++++ docs/design/user-plugin-management-ui-spec.md | 16 + 12 files changed, 1218 insertions(+), 3 deletions(-) create mode 100644 ShadowsocksX-NG2/App/PluginEditorSheet.swift create mode 100644 ShadowsocksX-NG2/App/PluginManagementCopy.swift create mode 100644 ShadowsocksX-NG2/App/PluginManagementModel.swift create mode 100644 ShadowsocksX-NG2/App/PluginManagementSection.swift create mode 100644 ShadowsocksX-NG2/Tests/PluginManagementTests.swift create mode 100644 docs/design/user-plugin-management-ui-spec.md diff --git a/ShadowsocksX-NG2/App/Localizable.xcstrings b/ShadowsocksX-NG2/App/Localizable.xcstrings index b18e4b38..6cc38c27 100644 --- a/ShadowsocksX-NG2/App/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Localizable.xcstrings @@ -330,6 +330,644 @@ } } } + }, + "插件": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugins" + } + } + } + }, + "新增插件…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Plugin…" + } + } + } + }, + "编辑…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit…" + } + } + } + }, + "覆盖…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Override…" + } + } + } + }, + "删除…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete…" + } + } + } + }, + "恢复内置…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore Built-in…" + } + } + } + }, + "恢复内置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore Built-in" + } + } + } + }, + "重试读取": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + } + } + }, + "刷新": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + } + } + }, + "插件名称": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin name" + } + } + } + }, + "可执行文件路径": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Executable path" + } + } + } + }, + "选择文件…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose File…" + } + } + } + }, + "取消": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "保存": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + } + } + }, + "确定": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" + } + } + } + }, + "新增插件": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Plugin" + } + } + } + }, + "编辑插件": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit Plugin" + } + } + } + }, + "覆盖内置插件": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Override Built-in Plugin" + } + } + } + }, + "内置": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in" + } + } + } + }, + "用户添加": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User-added" + } + } + } + }, + "用户覆盖": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User override" + } + } + } + }, + "恢复内置插件?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore Built-in Plugin?" + } + } + } + }, + "删除用户插件?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete User Plugin?" + } + } + } + }, + "服务器配置将保留,后续连接使用同名内置插件。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server configurations will be kept. Future connections will use the built-in plugin with this name." + } + } + } + }, + "服务器配置将保留,使用此名称的服务器可能无法连接。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server configurations will be kept. Servers using this name may be unable to connect." + } + } + } + }, + "插件配置操作失败": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugin Configuration Failed" + } + } + } + }, + "无法保存插件配置,请检查配置文件的访问权限后重试。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to save plugin configuration. Check access permissions for the configuration file and try again." + } + } + } + }, + "无法读取插件配置。请修复配置文件后重试读取。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to read plugin configuration. Repair the configuration file and retry." + } + } + } + }, + "请输入插件名称;已保存的名称不能修改。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a plugin name. Saved names cannot be changed." + } + } + } + }, + "已有同名用户插件,请编辑现有条目或使用其他名称。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A user plugin already has this name. Edit the existing entry or choose another name." + } + } + } + }, + "请输入可执行文件的绝对路径。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter an absolute path to the executable file." + } + } + } + }, + "文件不存在或不可执行,请选择可执行文件。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file is missing or is not executable. Choose an executable file." + } + } + } + }, + "该用户插件已不存在,请关闭表单后重试。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This user plugin no longer exists. Close the form and try again." + } + } + } + }, + "插件文件不存在。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The plugin file is missing." + } + } + } + }, + "插件文件不可执行。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The plugin file is not executable." + } + } + } + }, + "无法读取插件文件。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to read the plugin file." + } + } + } + }, + "macOS 安全策略检查未通过,运行可能受到限制。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The macOS security policy assessment failed. Execution may be restricted." + } + } + } + }, + "文件带有下载隔离标记,运行时可能需要 macOS 授权。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file has a download quarantine flag. Execution may require macOS approval." + } + } + } + }, + "文件签名无效,运行可能受到 macOS 限制。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file signature is invalid. macOS may restrict execution." + } + } + } + }, + "文件未签名,运行可能受到 macOS 限制。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file is unsigned. macOS may restrict execution." + } + } + } + }, + "无法完成安全检查。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to complete the security check." + } + } + } + }, + "隔离标记:存在": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quarantine flag: present" + } + } + } + }, + "隔离标记:无": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quarantine flag: absent" + } + } + } + }, + "隔离标记:无法判断": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quarantine flag: unknown" + } + } + } + }, + "签名:有效": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signature: valid" + } + } + } + }, + "签名:未签名": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signature: unsigned" + } + } + } + }, + "签名:无效": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signature: invalid" + } + } + } + }, + "签名:不适用": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signature: not applicable" + } + } + } + }, + "签名:无法判断": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signature: unknown" + } + } + } + }, + "安全策略:检查通过": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security policy: assessment passed" + } + } + } + }, + "安全策略:检查未通过": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security policy: assessment failed" + } + } + } + }, + "安全策略:不适用": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security policy: not applicable" + } + } + } + }, + "安全策略:无法判断": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security policy: unknown" + } + } + } + }, + "安全检查详情": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Security Check Details" + } + } + } + }, + "检查结果仅供参考,不能保证 macOS 允许或禁止运行。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These results are advisory and do not guarantee whether macOS will allow or block execution." + } + } + } + }, + "正在检查安全信息…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking security information…" + } + } + } + }, + "保存后将覆盖同名内置插件。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saving will override the built-in plugin with this name." + } + } + } + }, + "无法选择文件,请重试。": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to choose a file. Try again." + } + } + } } }, "version": "1.0" diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 7b115328..0aeb23d3 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -194,6 +194,7 @@ private struct AppComposition { workflow: catalogWorkflow, control: proxyControl, diagnostics: diagnosticsWorkflow, + plugins: PluginManagementModel(catalog: plugins), settingsWorkflow: settingsWorkflow, loginController: loginController, silentLaunch: silentLaunch, diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 58a8c14e..96ba0cc1 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -26,6 +26,7 @@ struct MainWindowView: View { @ObservedObject var workflow: CatalogWorkflow @ObservedObject var control: ProxyControlWorkflow let diagnostics: DiagnosticsWorkflow + let plugins: PluginManagementModel let settingsWorkflow: SettingsWorkflow let loginController: LaunchAtLoginController let silentLaunch: SilentLaunchController @@ -134,7 +135,7 @@ struct MainWindowView: View { RulesView(workflow: rulesWorkflow) case .settings: SettingsView( - workflow: settingsWorkflow, loginController: loginController, + plugins: plugins, workflow: settingsWorkflow, loginController: loginController, silentLaunch: silentLaunch ) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/ShadowsocksX-NG2/App/PluginEditorSheet.swift b/ShadowsocksX-NG2/App/PluginEditorSheet.swift new file mode 100644 index 00000000..aaf3ad6b --- /dev/null +++ b/ShadowsocksX-NG2/App/PluginEditorSheet.swift @@ -0,0 +1,90 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct PluginEditorSheet: View { + @ObservedObject var model: PluginManagementModel + let session: PluginEditorSession + @Environment(\.dismiss) private var dismiss + /// Each presentation intentionally seeds a fresh, independent draft. + @State private var name: String + @State private var path: String + @State private var failure: String? + @State private var showFilePicker = false + + init(model: PluginManagementModel, session: PluginEditorSession) { + self.model = model + self.session = session + _name = State(initialValue: session.program ?? "") + _path = State(initialValue: session.initialPath) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(title).font(.title2) + VStack(alignment: .leading, spacing: 5) { + Text("插件名称") + if session.program != nil { + Text(name).textSelection(.enabled) + } else { + TextField("插件名称", text: $name).textFieldStyle(.roundedBorder) + } + if let issue = model.nameIssue(name, session: session) { + fieldError(issue) + } + } + VStack(alignment: .leading, spacing: 5) { + Text("可执行文件路径") + HStack { + TextField("可执行文件路径", text: $path).textFieldStyle(.roundedBorder) + Button("选择文件…") { showFilePicker = true } + } + if let issue = model.pathIssue(path) { fieldError(issue) } + } + if !session.isEditing + && ManagedPluginCatalog.info( + forProgram: name.trimmingCharacters(in: .whitespacesAndNewlines)) != nil + { + Text("保存后将覆盖同名内置插件。") + .font(.caption).foregroundStyle(.secondary) + } + if let failure { + Label(failure, systemImage: "exclamationmark.triangle.fill") + .font(.footnote).foregroundStyle(.red) + } + HStack { + Spacer() + Button("取消", role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction) + Button("保存") { + do { + try model.save(session, name: name, path: path) + dismiss() + } catch { + failure = PluginManagementCopy.error(error) + } + } + .keyboardShortcut(.defaultAction) + .disabled( + model.snapshot.mappingsUnreadable || model.nameIssue(name, session: session) != nil + || model.pathIssue(path) != nil) + } + } + .padding(24) + .frame(width: 560) + .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in + switch result { + case .success(let url): path = url.path + case .failure: failure = String(localized: "无法选择文件,请重试。") + } + } + } + + private var title: String { + if session.isEditing { return String(localized: "编辑插件") } + return session.program == nil + ? String(localized: "新增插件") : String(localized: "覆盖内置插件") + } + + private func fieldError(_ error: PluginMappingError) -> some View { + Text(PluginManagementCopy.error(error)).font(.caption).foregroundStyle(.red) + } +} diff --git a/ShadowsocksX-NG2/App/PluginManagementCopy.swift b/ShadowsocksX-NG2/App/PluginManagementCopy.swift new file mode 100644 index 00000000..c2497821 --- /dev/null +++ b/ShadowsocksX-NG2/App/PluginManagementCopy.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Localized management feedback shared by the list and editor. +enum PluginManagementCopy { + static func error(_ error: Error) -> String { + guard let issue = error as? PluginMappingError else { + return String(localized: "无法保存插件配置,请检查配置文件的访问权限后重试。") + } + switch issue { + case .unreadable, .unsupportedVersion: + return String(localized: "无法读取插件配置。请修复配置文件后重试读取。") + case .invalidName: + return String(localized: "请输入插件名称;已保存的名称不能修改。") + case .nameExists: + return String(localized: "已有同名用户插件,请编辑现有条目或使用其他名称。") + case .invalidPath: + return String(localized: "请输入可执行文件的绝对路径。") + case .unavailableFile: + return String(localized: "文件不存在或不可执行,请选择可执行文件。") + case .nameMissing: + return String(localized: "该用户插件已不存在,请关闭表单后重试。") + } + } + + static func source(_ entry: PluginCatalogSnapshot.Entry) -> String { + if entry.source == .managed { return String(localized: "内置") } + return ManagedPluginCatalog.info(forProgram: entry.program) == nil + ? String(localized: "用户添加") : String(localized: "用户覆盖") + } + + static func availability(_ value: PluginCatalogSnapshot.Availability) -> String? { + switch value { + case .available: nil + case .missing: String(localized: "插件文件不存在。") + case .notExecutable: String(localized: "插件文件不可执行。") + case .unreadable: String(localized: "无法读取插件文件。") + } + } + + static func securitySummary(_ facts: PluginSecurityFacts) -> String? { + if facts.policy == .rejected { + return String(localized: "macOS 安全策略检查未通过,运行可能受到限制。") + } + if facts.quarantine == .present { + return String(localized: "文件带有下载隔离标记,运行时可能需要 macOS 授权。") + } + if facts.signature == .invalid { + return String(localized: "文件签名无效,运行可能受到 macOS 限制。") + } + if facts.signature == .unsigned { + return String(localized: "文件未签名,运行可能受到 macOS 限制。") + } + if facts.quarantine == .unknown || facts.signature == .unknown || facts.policy == .unknown { + return String(localized: "无法完成安全检查。") + } + return nil + } + + static func quarantine(_ value: PluginSecurityFacts.Quarantine) -> String { + switch value { + case .present: String(localized: "隔离标记:存在") + case .absent: String(localized: "隔离标记:无") + case .unknown: String(localized: "隔离标记:无法判断") + } + } + + static func signature(_ value: PluginSecurityFacts.Signature) -> String { + switch value { + case .valid: String(localized: "签名:有效") + case .unsigned: String(localized: "签名:未签名") + case .invalid: String(localized: "签名:无效") + case .notApplicable: String(localized: "签名:不适用") + case .unknown: String(localized: "签名:无法判断") + } + } + + static func policy(_ value: PluginSecurityFacts.Policy) -> String { + switch value { + case .accepted: String(localized: "安全策略:检查通过") + case .rejected: String(localized: "安全策略:检查未通过") + case .notApplicable: String(localized: "安全策略:不适用") + case .unknown: String(localized: "安全策略:无法判断") + } + } +} diff --git a/ShadowsocksX-NG2/App/PluginManagementModel.swift b/ShadowsocksX-NG2/App/PluginManagementModel.swift new file mode 100644 index 00000000..c433aec8 --- /dev/null +++ b/ShadowsocksX-NG2/App/PluginManagementModel.swift @@ -0,0 +1,81 @@ +import Combine +import Foundation + +struct PluginEditorSession: Identifiable { + let id = UUID() + /// nil only for a new plugin; fixed names cannot be changed by editing. + let program: String? + let isEditing: Bool + let initialPath: String +} + +@MainActor +final class PluginManagementModel: ObservableObject { + let catalog: PluginCatalog + @Published private(set) var snapshot: PluginCatalogSnapshot + private var subscription: AnyCancellable? + + init(catalog: PluginCatalog) { + self.catalog = catalog + snapshot = catalog.catalogSnapshot() + subscription = catalog.didCommit.sink { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.snapshot = self.catalog.catalogSnapshot() + } + } + } + + func beginAdding() -> PluginEditorSession { + PluginEditorSession(program: nil, isEditing: false, initialPath: "") + } + + func beginEditing(_ program: String) -> PluginEditorSession? { + guard !snapshot.mappingsUnreadable, let path = catalog.userMappings[program] else { return nil } + return PluginEditorSession(program: program, isEditing: true, initialPath: path) + } + + func beginOverriding(_ program: String) -> PluginEditorSession? { + guard !snapshot.mappingsUnreadable, catalog.userMappings[program] == nil, + ManagedPluginCatalog.info(forProgram: program) != nil + else { return nil } + return PluginEditorSession(program: program, isEditing: false, initialPath: "") + } + + func nameIssue(_ name: String, session: PluginEditorSession) -> PluginMappingError? { + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty || name.contains("\0") { return .invalidName } + if let fixed = session.program, fixed != name { return .invalidName } + if !session.isEditing && catalog.userMappings[name] != nil { return .nameExists } + return nil + } + + func pathIssue(_ path: String) -> PluginMappingError? { + path.hasPrefix("/") && !path.contains("\0") ? nil : .invalidPath + } + + func save(_ session: PluginEditorSession, name: String, path: String) throws { + if let issue = nameIssue(name, session: session) { throw issue } + if let issue = pathIssue(path) { throw issue } + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + try catalog.commit([ + session.isEditing ? .update(program: name, path: path) : .add(program: name, path: path) + ]) + refresh() + } + + func remove(_ program: String) throws { + try catalog.commit([.remove(program: program)]) + refresh() + } + + func retryReading() throws { + try catalog.retryReadingMappings() + refresh() + } + + func refresh() { + snapshot = catalog.catalogSnapshot() + catalog.refreshSecurityFacts() + } +} diff --git a/ShadowsocksX-NG2/App/PluginManagementSection.swift b/ShadowsocksX-NG2/App/PluginManagementSection.swift new file mode 100644 index 00000000..f917a04a --- /dev/null +++ b/ShadowsocksX-NG2/App/PluginManagementSection.swift @@ -0,0 +1,139 @@ +import AppKit +import SwiftUI + +struct PluginManagementSection: View { + @ObservedObject var model: PluginManagementModel + @ObservedObject private var catalog: PluginCatalog + @State private var editor: PluginEditorSession? + @State private var removal: PluginCatalogSnapshot.Entry? + @State private var failure: String? + + init(model: PluginManagementModel) { + self.model = model + catalog = model.catalog + } + + var body: some View { + Section { + if model.snapshot.mappingsUnreadable { + Text(PluginManagementCopy.error(PluginMappingError.unreadable)) + .foregroundStyle(.red) + Button("重试读取") { + do { try model.retryReading() } catch { failure = PluginManagementCopy.error(error) } + } + } else { + ForEach(model.snapshot.entries, id: \.program) { entry in + pluginRow(entry) + } + HStack { + Button("新增插件…") { editor = model.beginAdding() } + Spacer() + Button("刷新") { model.refresh() } + } + } + } header: { + Text("插件").font(.headline).padding(.vertical, 2) + } + .onAppear { model.refresh() } + .onReceive( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + ) { _ in + model.refresh() + } + .sheet(item: $editor) { session in + PluginEditorSheet(model: model, session: session) + } + .confirmationDialog( + removalIsOverride ? String(localized: "恢复内置插件?") : String(localized: "删除用户插件?"), + isPresented: Binding(get: { removal != nil }, set: { if !$0 { removal = nil } }), + titleVisibility: .visible, presenting: removal + ) { entry in + Button( + removalIsOverride ? String(localized: "恢复内置") : String(localized: "删除"), + role: .destructive + ) { + do { try model.remove(entry.program) } catch { failure = PluginManagementCopy.error(error) } + } + Button("取消", role: .cancel) {} + } message: { entry in + Text( + entry.program + "\n\n" + + (removalIsOverride + ? String(localized: "服务器配置将保留,后续连接使用同名内置插件。") + : String(localized: "服务器配置将保留,使用此名称的服务器可能无法连接。"))) + } + .alert( + "插件配置操作失败", + isPresented: Binding( + get: { failure != nil }, set: { if !$0 { failure = nil } } + ) + ) { + Button("确定", role: .cancel) { failure = nil } + } message: { + Text(failure ?? "") + } + } + + private var removalIsOverride: Bool { + removal.map { ManagedPluginCatalog.info(forProgram: $0.program) != nil } ?? false + } + + private func pluginRow(_ entry: PluginCatalogSnapshot.Entry) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 3) { + Text(entry.program).font(.body) + Text(PluginManagementCopy.source(entry)).font(.caption).foregroundStyle(.secondary) + if let path = entry.path { + Text(path).font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle).help(path).textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if entry.source == .managed { + Button("覆盖…") { editor = model.beginOverriding(entry.program) } + } else { + Button("编辑…") { editor = model.beginEditing(entry.program) } + Button( + ManagedPluginCatalog.info(forProgram: entry.program) == nil + ? String(localized: "删除…") : String(localized: "恢复内置…") + ) { removal = entry } + } + } + if let issue = PluginManagementCopy.availability(entry.availability) { + Label(issue, systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.red) + } + if entry.source == .user && entry.availability == .available { + PluginSecurityDetails(facts: catalog.securityFacts[entry.program]) + } + } + .padding(.vertical, 4) + } +} + +private struct PluginSecurityDetails: View { + let facts: PluginSecurityFacts? + + var body: some View { + if let facts { + if let warning = PluginManagementCopy.securitySummary(facts) { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(.orange) + } + DisclosureGroup("安全检查详情") { + VStack(alignment: .leading, spacing: 3) { + Text(PluginManagementCopy.quarantine(facts.quarantine)) + Text(PluginManagementCopy.signature(facts.signature)) + Text(PluginManagementCopy.policy(facts.policy)) + Text("检查结果仅供参考,不能保证 macOS 允许或禁止运行。") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .font(.caption) + } else { + Text("正在检查安全信息…").font(.caption).foregroundStyle(.secondary) + } + } +} diff --git a/ShadowsocksX-NG2/App/SettingsView.swift b/ShadowsocksX-NG2/App/SettingsView.swift index fefdefce..b56fe7c6 100644 --- a/ShadowsocksX-NG2/App/SettingsView.swift +++ b/ShadowsocksX-NG2/App/SettingsView.swift @@ -1,9 +1,10 @@ import SwiftUI -/// 设置分区(issue #33/#44,地图 #52 票 #57):常规、后台代理客户端和系统代理三张 +/// 设置分区:常规、后台代理客户端、系统代理与插件管理,采用 /// 分组卡,行式呈现(主文案 + 次说明 + 行尾控件)。设置项由各自编辑器独立保存; /// 登录项与静默启动开关绑定独立控制器。 struct SettingsView: View { + let plugins: PluginManagementModel @ObservedObject var workflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController @ObservedObject var silentLaunch: SilentLaunchController @@ -14,6 +15,7 @@ struct SettingsView: View { generalSection endpointSection systemProxySection + PluginManagementSection(model: plugins) } .formStyle(.grouped) .sheet(item: $presentedEditor) { editor in diff --git a/ShadowsocksX-NG2/Domain/PluginCatalog.swift b/ShadowsocksX-NG2/Domain/PluginCatalog.swift index fa830f84..a038b268 100644 --- a/ShadowsocksX-NG2/Domain/PluginCatalog.swift +++ b/ShadowsocksX-NG2/Domain/PluginCatalog.swift @@ -121,6 +121,19 @@ final class PluginCatalog: ObservableObject, PluginExecutableResolving { @MainActor private func publishAfterSaving(_ proposed: [String: String]) throws { try store.save(proposed) + publish(proposed) + } + + /// Retry a failed initial read without replacing or writing user intent. + @MainActor + func retryReadingMappings() throws { + guard lock.withLock({ state.unreadable }) else { return } + let mappings = try store.load() + publish(mappings) + } + + @MainActor + private func publish(_ proposed: [String: String]) { objectWillChange.send() let generation = lock.withLock { state.mappings = proposed diff --git a/ShadowsocksX-NG2/README.md b/ShadowsocksX-NG2/README.md index d88ada02..58ab3241 100644 --- a/ShadowsocksX-NG2/README.md +++ b/ShadowsocksX-NG2/README.md @@ -103,6 +103,6 @@ Python 是唯一的上游转换实现;Swift 只加载、校验并消费快照 `PluginCatalog` 合成当前托管清单与本机 `plugins.json` 用户映射,名称精确区分大小写。用户映射优先,失效 override 不退回托管程序;删除 override 恢复当前 app 默认值。文件读取错误保留阻断事实,不按空映射处理。用户路径不进入服务器资料、订阅或分享文件,也不进入诊断报告。 -当前实现提供映射提交与显式修复 interface,并在组合根统一接入表单、激活与诊断;管理 UI 留待后续实现。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。 +设置页底部的“插件”区域统一展示内置与用户插件,支持新增、编辑路径、覆盖内置、删除与恢复内置。编辑名称只读;路径支持手动输入与文件选择。读取失败时隐藏条目并提供只读重试,管理 UI 不提供整体重置。映射提交与显式修复 interface 在组合根统一接入表单、激活与诊断。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。 `MacOSPluginInspection` 只读取下载隔离、签名与系统评估事实,不启动用户插件、不修改系统策略。检查失败或不适用为未知;策略评估拒绝不等于实际执行禁令,也不阻止映射保存或激活。真实 macOS 拦截提示不由 hostless 替身测试证明。 diff --git a/ShadowsocksX-NG2/Tests/PluginManagementTests.swift b/ShadowsocksX-NG2/Tests/PluginManagementTests.swift new file mode 100644 index 00000000..6ea2fcae --- /dev/null +++ b/ShadowsocksX-NG2/Tests/PluginManagementTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct PluginManagementTests { + @Test func editingKeepsNameAndUpdatesExecutable() throws { + let fixture = try ManagementFixture() + defer { fixture.cleanUp() } + let model = PluginManagementModel(catalog: fixture.catalog) + let add = model.beginAdding() + try model.save(add, name: "custom", path: fixture.first.path) + let edit = try #require(model.beginEditing("custom")) + #expect(edit.program == "custom") + #expect(edit.initialPath == fixture.first.path) + #expect(throws: PluginMappingError.invalidName) { + try model.save(edit, name: "renamed", path: fixture.second.path) + } + #expect(fixture.catalog.userMappings["custom"] == fixture.first.path) + try model.save(edit, name: "custom", path: fixture.second.path) + #expect(model.snapshot.entry(for: "custom")?.path == fixture.second.path) + #expect(model.snapshot.entry(for: "renamed") == nil) + } + + @Test func overrideCanBeEditedAndRestoredWithoutDeletingManagedEntry() throws { + let fixture = try ManagementFixture() + defer { fixture.cleanUp() } + let model = PluginManagementModel(catalog: fixture.catalog) + let override = try #require(model.beginOverriding("v2ray-plugin")) + #expect(override.program == "v2ray-plugin") + #expect(override.initialPath.isEmpty) + #expect(!override.isEditing) + try model.save(override, name: "v2ray-plugin", path: fixture.first.path) + #expect(model.snapshot.entry(for: "v2ray-plugin")?.source == .user) + #expect(model.beginOverriding("v2ray-plugin") == nil) + let edit = try #require(model.beginEditing("v2ray-plugin")) + try model.save(edit, name: "v2ray-plugin", path: fixture.second.path) + #expect(model.snapshot.entry(for: "v2ray-plugin")?.path == fixture.second.path) + try model.remove("v2ray-plugin") + #expect(model.snapshot.entry(for: "v2ray-plugin")?.source == .managed) + #expect(model.beginEditing("v2ray-plugin") == nil) + #expect(model.beginOverriding("v2ray-plugin") != nil) + } + + @Test func unreadableMappingsBlockChangesUntilSuccessfulReadRetry() throws { + let fixture = try ManagementFixture(corrupt: true) + defer { fixture.cleanUp() } + let model = PluginManagementModel(catalog: fixture.catalog) + #expect(model.snapshot.mappingsUnreadable) + #expect(model.beginOverriding("v2ray-plugin") == nil) + #expect(throws: PluginMappingError.unreadable) { + try model.save(model.beginAdding(), name: "custom", path: fixture.first.path) + } + #expect(throws: PluginMappingError.unreadable) { try model.retryReading() } + #expect(model.snapshot.mappingsUnreadable) + try fixture.store.save(["custom": fixture.first.path]) + let repairedBytes = try Data(contentsOf: fixture.store.fileURL) + try model.retryReading() + #expect(!model.snapshot.mappingsUnreadable) + #expect(model.snapshot.entry(for: "custom")?.path == fixture.first.path) + #expect(try Data(contentsOf: fixture.store.fileURL) == repairedBytes) + #expect(model.beginEditing("custom") != nil) + } + + @Test func failedSavePreservesMappingAndAllowsSameEditorToRetry() throws { + let fixture = try ManagementFixture() + defer { fixture.cleanUp() } + let model = PluginManagementModel(catalog: fixture.catalog) + try model.save(model.beginAdding(), name: "custom", path: fixture.first.path) + let edit = try #require(model.beginEditing("custom")) + let config = fixture.store.fileURL.deletingLastPathComponent() + let backup = fixture.root.appendingPathComponent("backup") + 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, name: "custom", path: fixture.second.path) + } + #expect(model.snapshot.entry(for: "custom")?.path == fixture.first.path) + #expect(model.beginEditing("custom")?.initialPath == fixture.first.path) + try FileManager.default.removeItem(at: config) + try FileManager.default.moveItem(at: backup, to: config) + try model.save(edit, name: "custom", path: fixture.second.path) + #expect(model.snapshot.entry(for: "custom")?.path == fixture.second.path) + } + + @Test func unavailableFilesAndDuplicateNamesAreRejectedAndRemovalKeepsOtherMappings() throws { + let fixture = try ManagementFixture() + defer { fixture.cleanUp() } + let model = PluginManagementModel(catalog: fixture.catalog) + let add = model.beginAdding() + #expect(add.program == nil) + #expect(add.initialPath.isEmpty) + #expect(model.nameIssue("", session: add) == .invalidName) + #expect(model.pathIssue("relative") == .invalidPath) + try model.save(add, name: "custom", path: fixture.first.path) + #expect(model.nameIssue("custom", session: model.beginAdding()) == .nameExists) + #expect(throws: PluginMappingError.nameExists) { + try model.save(model.beginAdding(), name: "custom", path: fixture.second.path) + } + #expect(throws: PluginMappingError.unavailableFile) { + try model.save( + model.beginAdding(), name: "missing", + path: fixture.root.appendingPathComponent("missing").path) + } + try model.save(model.beginAdding(), name: "other", path: fixture.second.path) + try model.remove("custom") + #expect(model.snapshot.entry(for: "custom") == nil) + #expect(model.snapshot.entry(for: "other")?.path == fixture.second.path) + try FileManager.default.removeItem(at: fixture.second) + model.refresh() + #expect(model.snapshot.entry(for: "other")?.availability == .missing) + #expect(model.beginEditing("other")?.initialPath == fixture.second.path) + } + +} + +private struct ManagementInspector: PluginInspecting { + func inspect(_ executable: URL) async -> PluginSecurityFacts { + PluginSecurityFacts(quarantine: .absent, signature: .notApplicable, policy: .notApplicable) + } +} + +@MainActor +private struct ManagementFixture { + let root: URL + let first: URL + let second: URL + let store: PluginMappingFileStore + let catalog: PluginCatalog + + init(corrupt: Bool = false) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + first = root.appendingPathComponent("first") + second = root.appendingPathComponent("second") + store = PluginMappingFileStore(fileURL: root.appendingPathComponent("config/plugins.json")) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: store.fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + for binary in [first, second] { + try Data("#!/bin/sh\n".utf8).write(to: binary) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + } + if corrupt { try Data("broken".utf8).write(to: store.fileURL) } + catalog = PluginCatalog(store: store, inspector: ManagementInspector()) + } + + func cleanUp() { try? FileManager.default.removeItem(at: root) } +} diff --git a/docs/design/user-plugin-management-ui-spec.md b/docs/design/user-plugin-management-ui-spec.md new file mode 100644 index 00000000..a04bb0a0 --- /dev/null +++ b/docs/design/user-plugin-management-ui-spec.md @@ -0,0 +1,16 @@ +# 用户插件管理 UI(2026-10-05) + +本文件记录本轮已确认规格,现状以代码为准。 + +- 设置视图底部新增“插件”区域,统一展示内置与用户插件;同名覆盖占一行,区分内置、用户添加、用户覆盖。 +- 行展示名称、来源、单行中间截断路径(悬停完整内容)及异常;直接提供覆盖、编辑、删除或恢复内置按钮,无只有删除的更多菜单。 +- 新增、编辑和覆盖使用 sheet。新增名称/路径为空;编辑名称只读、路径预填;覆盖名称只读、路径为空。选择文件仅填路径。 +- 字段为插件名称 / Plugin name、可执行文件路径 / Executable path;按钮选择文件… / Choose File…、取消 / Cancel、保存 / Save。 +- 不支持改名。空名称、用户重名、非绝对路径在字段旁提示;保存重新检查文件存在且可执行。同名内置允许覆盖并提示。 +- 保存失败保留 sheet 与草稿;写入成功关闭。运行时异步应用,失败不回滚映射,沿用运行时错误入口。 +- 取消/Escape 直接丢弃草稿,再次打开读取已保存值。 +- 删除与恢复内置都需确认;说明服务器配置保留,删除可能令原名称不可用,恢复后使用内置插件。 +- 安全提示给出隔离、签名、策略的具体证据,允许展开详情;不声称能保证是否允许运行、不阻止保存、不自动解除隔离。检查无法判定时明确提示。 +- 映射读取失败隐藏列表与修改按钮,展示错误与重试读取。重试仅读取配置,不整体重置。 +- 进入设置、设置可见时应用重新激活、保存/删除后刷新文件可用性及安全事实;另提供刷新按钮。不添加文件监听。 +- 复用组合根现有 PluginCatalog,不另建持久化目录实例。