feat(plugins): add settings management UI

This commit is contained in:
Qiu Yuzhou
2026-10-05 12:03:52 +08:00
parent 3d8d56b8e1
commit 86779b63ee
12 changed files with 1218 additions and 3 deletions
+638
View File
@@ -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"
+1
View File
@@ -194,6 +194,7 @@ private struct AppComposition {
workflow: catalogWorkflow,
control: proxyControl,
diagnostics: diagnosticsWorkflow,
plugins: PluginManagementModel(catalog: plugins),
settingsWorkflow: settingsWorkflow,
loginController: loginController,
silentLaunch: silentLaunch,
+2 -1
View File
@@ -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)
@@ -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)
}
}
@@ -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: "安全策略:无法判断")
}
}
}
@@ -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()
}
}
@@ -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)
}
}
}
+3 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -103,6 +103,6 @@ Python 是唯一的上游转换实现;Swift 只加载、校验并消费快照
`PluginCatalog` 合成当前托管清单与本机 `plugins.json` 用户映射,名称精确区分大小写。用户映射优先,失效 override 不退回托管程序;删除 override 恢复当前 app 默认值。文件读取错误保留阻断事实,不按空映射处理。用户路径不进入服务器资料、订阅或分享文件,也不进入诊断报告。
当前实现提供映射提交与显式修复 interface,并在组合根统一接入表单、激活与诊断;管理 UI 留待后续实现。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。
设置页底部的“插件”区域统一展示内置与用户插件,支持新增、编辑路径、覆盖内置、删除与恢复内置。编辑名称只读;路径支持手动输入与文件选择。读取失败时隐藏条目并提供只读重试,管理 UI 不提供整体重置。映射提交与显式修复 interface 在组合根统一接入表单、激活与诊断。保存成功后刷新激活候选并异步收敛,运行失败不撤销保存。没有文件替换监控。
`MacOSPluginInspection` 只读取下载隔离、签名与系统评估事实,不启动用户插件、不修改系统策略。检查失败或不适用为未知;策略评估拒绝不等于实际执行禁令,也不阻止映射保存或激活。真实 macOS 拦截提示不由 hostless 替身测试证明。
@@ -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) }
}
@@ -0,0 +1,16 @@
# 用户插件管理 UI(2026-10-05)
本文件记录本轮已确认规格,现状以代码为准。
- 设置视图底部新增“插件”区域,统一展示内置与用户插件;同名覆盖占一行,区分内置、用户添加、用户覆盖。
- 行展示名称、来源、单行中间截断路径(悬停完整内容)及异常;直接提供覆盖、编辑、删除或恢复内置按钮,无只有删除的更多菜单。
- 新增、编辑和覆盖使用 sheet。新增名称/路径为空;编辑名称只读、路径预填;覆盖名称只读、路径为空。选择文件仅填路径。
- 字段为插件名称 / Plugin name、可执行文件路径 / Executable path;按钮选择文件… / Choose File…、取消 / Cancel、保存 / Save。
- 不支持改名。空名称、用户重名、非绝对路径在字段旁提示;保存重新检查文件存在且可执行。同名内置允许覆盖并提示。
- 保存失败保留 sheet 与草稿;写入成功关闭。运行时异步应用,失败不回滚映射,沿用运行时错误入口。
- 取消/Escape 直接丢弃草稿,再次打开读取已保存值。
- 删除与恢复内置都需确认;说明服务器配置保留,删除可能令原名称不可用,恢复后使用内置插件。
- 安全提示给出隔离、签名、策略的具体证据,允许展开详情;不声称能保证是否允许运行、不阻止保存、不自动解除隔离。检查无法判定时明确提示。
- 映射读取失败隐藏列表与修改按钮,展示错误与重试读取。重试仅读取配置,不整体重置。
- 进入设置、设置可见时应用重新激活、保存/删除后刷新文件可用性及安全事实;另提供刷新按钮。不添加文件监听。
- 复用组合根现有 PluginCatalog,不另建持久化目录实例。