mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 07:33:46 +00:00
feat(plugins): add settings management UI
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -194,6 +194,7 @@ private struct AppComposition {
|
||||
workflow: catalogWorkflow,
|
||||
control: proxyControl,
|
||||
diagnostics: diagnosticsWorkflow,
|
||||
plugins: PluginManagementModel(catalog: plugins),
|
||||
settingsWorkflow: settingsWorkflow,
|
||||
loginController: loginController,
|
||||
silentLaunch: silentLaunch,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,不另建持久化目录实例。
|
||||
Reference in New Issue
Block a user