diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 8a9359fb..cba85b6e 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 34, + "api_version": 35, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.58.0" + "version": "0.59.0" } diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 8a9359fb..cba85b6e 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 34, + "api_version": 35, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.58.0" + "version": "0.59.0" } diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 118ef22f..7071c204 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -191,7 +191,10 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { // 产品图里没有插件:插件页是空的,写入一律拒绝 Plugins: () => [], PluginInspect: refuse, - UpdatePlugin: refuse, + PluginRewrite: refuse, + CreatePlugin: refuse, + SavePlugin: refuse, + ApprovePluginFile: refuse, PluginSourceDiff: refuse, DeletePlugin: refuse, ReorderPlugins: refuse, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 063f5c0f..3f16c474 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5279,8 +5279,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "serde", "serde_json", @@ -5291,8 +5291,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "serde", "serde_json", @@ -5300,8 +5300,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "base64 0.22.1", "bytes", @@ -5316,8 +5316,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "serde", "serde_json", @@ -5343,8 +5343,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "serde", "ts-rs", @@ -5352,8 +5352,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "notify", "thiserror 2.0.21", @@ -5362,8 +5362,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.58.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.58.0#c2a7bc637421e7be13bae185b2db609703b74473" +version = "0.59.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.59.0#ef10a8b0bbc1325edf43553a87169029672808a4" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c4cfee3f..7b30fce9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.58.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.58.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.58.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.58.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.58.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.58.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.59.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.59.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.59.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.59.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.59.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.59.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 595ad4ed..f2216add 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -11,10 +11,11 @@ //! 命令拼好再给)。路径参数由 `tw_api::fill` 做百分号编码,所以界面给的名字 //! 只能是一段,拼不出别的路径。 //! -//! **插件的四步有意不给**:安装(`CreatePlugin`)、更换代码(`ReplacePluginSource`)、确认变了 -//! 的文件(`ApprovePluginFile`),以及确认过的改动(`UpdatePluginConfirmed`:打开改得了回答 -//! 里工具调用的插件、改它的设置或范围)。界面里的脚本自己就能点网页上的「确定」,所以这 -//! 几步只能经过 `plugins` 里的命令,在系统原生对话框里确认(I12)。 +//! **插件要点头的三步有意不给**:`CreatePluginConfirmed`、`SavePluginConfirmed`、 +//! `ApprovePluginFileConfirmed`(约定附录 4 §3:装上、打开改得了回答里工具调用的插件,改它的 +//! 代码,批准它磁盘上改过的文件)。界面里的脚本自己就能点网页上的「确定」,所以这几步只能 +//! 经过 `plugins` 里的命令,在系统原生对话框里确认。不必点头的装、存、批准(`CreatePlugin`、 +//! `SavePlugin`、`ApprovePluginFile`)在这里:要点头时 core 答 403。 //! //! 做的事不止转发的命令(打开浏览器、写剪贴板、拼概览)仍然各是一个命令。 //! 其中有三个端点**只能经过那些命令**,因为这台机器上的客户端要一起照顾到:删密钥 @@ -137,11 +138,13 @@ webview_endpoints![ ChatgptResets, UseChatgptReset, ZaiLoginStatus, - // 插件。装、换代码、批准、确认过的改动走 Rust 这边的命令,见下面的测试。改得了工具调用 - // 的插件,`UpdatePlugin` 在 core 那边只许停用、改出错时怎么办 + // 插件。要点头的三步(`…Confirmed`)走 Rust 这边的命令,见下面的测试 Plugins, PluginInspect, - UpdatePlugin, + PluginRewrite, + CreatePlugin, + SavePlugin, + ApprovePluginFile, PluginSourceDiff, DeletePlugin, ReorderPlugins, @@ -184,30 +187,46 @@ mod tests { "DeleteKey", "RotateKey", "ClientKey", - // 插件的这几步要在原生对话框里确认(I12),见模块说明 - "CreatePlugin", - "ReplacePluginSource", - "ApprovePluginFile", - "UpdatePluginConfirmed", + // 插件要点头的三步只能在原生对话框里确认,见模块说明 + "CreatePluginConfirmed", + "SavePluginConfirmed", + "ApprovePluginFileConfirmed", ] { assert!(!ALLOWED.contains(&name), "{name}"); } } - /// 确认过的插件改动只有一条路:`plugin_update_confirmed` 先弹系统的确认框。界面的清单 - /// 里连这个名字都不该有 —— 有了,网页里的脚本就能替用户打开一个改工具调用的插件 + /// 要点头的插件写入只有一条路:`plugins` 里的命令先弹系统的确认框。界面的清单里连这几个 + /// 名字都不该有 —— 有了,网页里的脚本就能替用户装上、打开、改写一个改工具调用的插件 #[test] - fn a_confirmed_plugin_update_only_goes_through_the_native_dialog() { - assert!(!ALLOWED.contains(&"UpdatePluginConfirmed")); - // 它确实是 core 的一个端点(不是拼错了名字才「不在清单里」) - assert!( - ep::ALL - .iter() - .any(|e| e.name == "UpdatePluginConfirmed" && e.path == "/plugins/{id}/confirmed") - ); + fn the_confirmed_plugin_writes_only_go_through_the_native_dialog() { let ts = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/control.ts")) .unwrap(); - assert!(!ts.contains("\"UpdatePluginConfirmed\"")); + for (name, path) in [ + ("CreatePluginConfirmed", "/plugins/confirmed"), + ("SavePluginConfirmed", "/plugins/{id}/confirmed"), + ( + "ApprovePluginFileConfirmed", + "/plugins/{id}/approve/confirmed", + ), + ] { + assert!(!ALLOWED.contains(&name), "{name}"); + assert!(!ts.contains(&format!("\"{name}\"")), "{name}"); + // 它确实是 core 的一个端点(不是拼错了名字才「不在清单里」) + assert!( + ep::ALL.iter().any(|e| e.name == name && e.path == path), + "{name}" + ); + } + // 不必点头的那几步在清单里:要点头时 core 答 403,界面再请 Rust + for name in [ + "CreatePlugin", + "SavePlugin", + "ApprovePluginFile", + "PluginRewrite", + ] { + assert!(ALLOWED.contains(&name), "{name}"); + } } /// 前端那份清单和这里一样。多一个,界面调了会被拒;少一个,界面上的类型 diff --git a/src-tauri/src/core_text.rs b/src-tauri/src/core_text.rs index 260f73d9..40c5c127 100644 --- a/src-tauri/src/core_text.rs +++ b/src-tauri/src/core_text.rs @@ -54,12 +54,20 @@ pub fn clause(m: &Msg) -> String { } /// 按码说中文,连同外面套的场合。说不出来是 None +/// +/// **插件名按中文说**:`{plugin}` 是默认插件 core 发的英文名时,换成它的中文名(和插件页上 +/// 一样,见 `plugins::defaults::name_in`)。消息里只有名字,按名字认 pub fn zh(m: &Msg) -> Option { let t = table(); if m.code.starts_with("//") { return None; } let say = t.messages.get(&m.code)?; + // 填进中文句子的参数。认场合比的是英文原句,那边照旧用原样的参数 + let mut zh_args = m.args.clone(); + if let Some(p) = zh_args.get_mut("plugin") { + *p = crate::plugins::defaults::name_in(crate::i18n::Lang::Zh, None, p); + } let mut leads = String::new(); let mut rest = m.text.as_str(); 'peel: loop { @@ -76,14 +84,14 @@ pub fn zh(m: &Msg) -> Option { else { continue; }; - leads.push_str(&render(&c.zh, &m.args, &t.tables)?); + leads.push_str(&render(&c.zh, &zh_args, &t.tables)?); leads.push(':'); rest = after; continue 'peel; } break; } - Some(leads + &render(say, &m.args, &t.tables)?) + Some(leads + &render(say, &zh_args, &t.tables)?) } // ------------------------------------------------------------ 写法 @@ -317,6 +325,35 @@ mod tests { zh: String, } + /// 默认插件的英文名在中文里换成插件页上的名字;英文照 core 的原句。别人的插件照它写的 + #[test] + fn a_default_plugin_is_named_like_on_the_plugins_page() { + let m = |plugin: &str| Msg { + code: "control.plugin.needs_confirmation".into(), + args: BTreeMap::from([("plugin".into(), plugin.into())]), + text: format!("Plugin `{plugin}` can change the tool calls in replies, so …"), + }; + let wsl = m("Convert WSL and Windows paths"); + crate::i18n::with_lang(crate::i18n::Lang::Zh, || { + assert!( + text(&wsl).starts_with("插件「WSL 路径转换」可以修改"), + "{}", + text(&wsl) + ); + let theirs = m("Rename tools"); + assert!( + text(&theirs).starts_with("插件「Rename tools」"), + "{}", + text(&theirs) + ); + }); + crate::i18n::with_lang(crate::i18n::Lang::En, || { + assert_eq!(text(&wsl), wsl.text); + // 中文那一句不看当前语言 + assert!(zh(&wsl).unwrap().contains("「WSL 路径转换」")); + }); + } + /// 和界面跑同一份用例:两份实现对同一条消息说同一句话 #[test] fn the_shared_cases_say_the_same_as_the_interface() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f56cb7ba..7c43a07a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -246,10 +246,9 @@ pub fn run() { mcp::mcp_targets, mcp::plan_mcp, mcp::apply_mcp, - plugins::plugin_install, - plugins::plugin_replace_source, - plugins::plugin_approve, - plugins::plugin_update_confirmed, + plugins::plugin_install_confirmed, + plugins::plugin_save_confirmed, + plugins::plugin_approve_confirmed, scan::scan_clients, diagnostics::save_diagnostics, ]) diff --git a/src-tauri/src/plugins/confirm/mod.rs b/src-tauri/src/plugins/confirm/mod.rs index 9bb9a57a..01e72033 100644 --- a/src-tauri/src/plugins/confirm/mod.rs +++ b/src-tauri/src/plugins/confirm/mod.rs @@ -1,8 +1,9 @@ //! 系统原生的确认对话框(I12)。 //! -//! **为什么不能在网页里问。**安装插件、更换代码、确认文件变更,是把一段会改写每一个请求的 -//! 代码放进网关。网页里的「确定」,网页里的脚本自己就能点 —— 一段混进页面的脚本可以一声 -//! 不响地装上一个插件。系统的对话框画在网页之外,脚本点不到、键盘事件也伪造不到。 +//! **为什么不能在网页里问。**装上、打开一个改得了回答里工具调用的插件,改它的代码,确认它 +//! 改过的文件,是让一段代码改写客户端将要执行的命令。网页里的「确定」,网页里的脚本自己就能 +//! 点 —— 一段混进页面的脚本可以一声不响地装上这样一个插件。系统的对话框画在网页之外,脚本 +//! 点不到、键盘事件也伪造不到。 //! //! 三个平台各用自己的:macOS 是 `NSAlert`,Windows 是 `MessageBoxW`,Linux 是 GTK 的 //! `MessageDialog`(都是应用本来就链接着的东西,不多一个依赖)。 diff --git a/src-tauri/src/plugins/defaults.rs b/src-tauri/src/plugins/defaults.rs index e2822fa7..546c71eb 100644 --- a/src-tauri/src/plugins/defaults.rs +++ b/src-tauri/src/plugins/defaults.rs @@ -1,4 +1,5 @@ -//! core 自带的默认插件在这一侧的说法:系统的确认框、通知里的名字和设置项的标签。 +//! core 自带的默认插件在这一侧的说法:系统的确认框、通知、core 消息的中文里的名字,和设置项 +//! 的标签。 //! //! **表只有一张,在 `src/i18n/plugin-defaults.json`**,界面(`src/plugins/defaults.ts`) //! 读的是同一份,这里 `include_str!`。默认插件的 manifest 里名字、说明和标签都是英文; @@ -7,13 +8,16 @@ //! //! **按 id 认,manifest 的名字也得对得上**(core 发的那一个):用户删掉默认插件之后自己 //! 装了一个、恰好用了同一个 id 的,照它自己写的名字说 —— 不能拿默认插件的名字替一个 -//! 别人写的插件作保。 +//! 别人写的插件作保。**只有名字的按名字认**:core 的消息里嵌着的插件名(`{plugin}`)不带 +//! id,和 core 发的英文名一字不差的按默认插件的名字说。设置项的标签一律要 id。 use std::collections::HashMap; use std::sync::OnceLock; use serde::Deserialize; +use crate::i18n::Lang; + const SOURCE: &str = include_str!("../../../src/i18n/plugin-defaults.json"); #[derive(Deserialize)] @@ -46,27 +50,40 @@ fn table() -> &'static Table { T.get_or_init(|| serde_json::from_str(SOURCE).expect("plugin-defaults.json 读不出来")) } -/// 是 core 自带的那一个插件:id 对得上,manifest 的名字也是 core 发的那一个 -fn entry(id: Option<&str>, name: &str) -> Option<&'static Words> { - let id = id?; - let e = table() +/// 是 core 自带的那一个插件:manifest 的名字是 core 发的那一个,知道 id 的 id 也对得上 +fn entry(id: Option<&str>, name: &str) -> Option<&'static Entry> { + table() .plugins .iter() - .find(|e| e.id == id && e.manifest_name == name)?; - Some(tr!(&e.zh, &e.en)) + .find(|e| e.manifest_name == name && id.is_none_or(|id| e.id == id)) } -/// 插件在这一侧叫什么。默认插件按当前语言说,别的照它自己写的 -pub fn name(id: Option<&str>, name: &str) -> String { +fn words(e: &Entry, lang: Lang) -> &Words { + match lang { + Lang::Zh => &e.zh, + Lang::En => &e.en, + } +} + +/// 插件在某种语言里叫什么:确认框、通知、core 的消息里(`{plugin}`)**都用这一个**。知道 id +/// 的按 id 认(manifest 的名字也得对得上);只知道名字的(`id` 是 `None`)按 core 发的英文名 +/// 认,一字不差才算。别的照它自己写的 +pub fn name_in(lang: Lang, id: Option<&str>, name: &str) -> String { entry(id, name) - .and_then(|w| w.name.clone()) + .and_then(|e| words(e, lang).name.clone()) .unwrap_or_else(|| name.to_string()) } -/// 一个设置项的标签。参数同 [`name`],`key` 是设置项的键,`label` 是 manifest 写的 +/// 同 [`name_in`],按当前语言 +pub fn name(id: Option<&str>, name: &str) -> String { + name_in(crate::i18n::current(), id, name) +} + +/// 一个设置项的标签,按当前语言。`id`、`name` 是插件的(**一定要 id**),`key` 是设置项的 +/// 键,`label` 是 manifest 写的 pub fn label(id: &str, name: &str, key: &str, label: &str) -> String { entry(Some(id), name) - .and_then(|w| w.settings.get(key).cloned()) + .and_then(|e| words(e, crate::i18n::current()).settings.get(key).cloned()) .unwrap_or_else(|| label.to_string()) } @@ -104,7 +121,8 @@ mod tests { }); } - /// 同一个 id、别人写的插件:照它自己写的说 + /// 同一个 id、别人写的插件:照它自己写的说。用了默认插件的英文名、id 却不一样的,知道 + /// id 时也照它自己写的说 #[test] fn someone_elses_plugin_under_a_default_id_keeps_its_own_name() { crate::i18n::with_lang(crate::i18n::Lang::Zh, || { @@ -114,7 +132,41 @@ mod tests { "开关" ); let wsl = "Convert WSL and Windows paths"; + assert_eq!(name(Some("my-paths"), wsl), wsl); + assert_eq!(label("my-paths", wsl, "windows_client", "开关"), "开关"); + }); + } + + /// 只知道名字(core 的消息里那样):和 core 发的英文名一字不差才换,大小写、空白不同的不算 + #[test] + fn a_name_alone_is_matched_exactly() { + let wsl = "Convert WSL and Windows paths"; + assert_eq!(name_in(Lang::Zh, None, wsl), "WSL 路径转换"); + assert_eq!(name_in(Lang::En, None, wsl), wsl); + for near in [ + "convert wsl and windows paths", + " Convert WSL and Windows paths", + "WSL 路径转换", + ] { + assert_eq!(name_in(Lang::Zh, None, near), near); + } + // 不看当前语言:按码说中文的那一边(`core_text`)传的是 Zh + crate::i18n::with_lang(Lang::En, || { + assert_eq!(name_in(Lang::Zh, None, wsl), "WSL 路径转换"); assert_eq!(name(None, wsl), wsl); }); } + + /// 默认插件的英文名不会是一个 id:消息里 `{plugin}` 有时是 id(`config.plugin.*` 这些), + /// 按名字认的时候不会把一个 id 认成默认插件 + #[test] + fn no_manifest_name_reads_as_an_id() { + for e in &table().plugins { + let id_like = !e.manifest_name.is_empty() + && e.manifest_name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + assert!(!id_like, "{}", e.manifest_name); + } + } } diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 17032b8d..65b821d7 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -1,48 +1,42 @@ -//! 插件:要在系统的确认框里点头的那几步(I12)。 +//! 插件:要在系统的确认框里点头的那几步(约定附录 4 §3)。 //! -//! 装插件(`CreatePlugin`)、更换代码(`ReplacePluginSource`)、确认变了的文件 -//! (`ApprovePluginFile`),以及打开改得了回答里工具调用的插件、改它的设置或范围 -//! (`UpdatePluginConfirmed`)**不在网页的白名单里**(见 `call.rs`)。网页只能请这里去做, +//! **只有一种情形要点头**:插件改得了回答里的工具调用(原来的或者新的代码里有 +//! `reply_tool_calls`),而这次要装上它、打开它、改它的代码,或者批准它磁盘上改过的文件。 +//! 这时网页那条路(`CreatePlugin`、`SavePlugin`、`ApprovePluginFile`)答 403 +//! `control.plugin.needs_confirmation`,点头之后发的那三个端点(`…Confirmed`)**不在网页的 +//! 白名单里**(见 `call.rs`):网页里的脚本自己就能点网页上的「确定」,所以只能请这里去做。 //! 而这里不信网页给的任何关于插件的说法: //! //! 1. **自己再读一遍**:代码交给 core 的 `PluginInspect`(不写任何东西);装着的插件从 -//! `Plugins` 读,读不出它要什么权限的(core 那边没有它的 manifest),把批准的那份代码 -//! 再交给 core 编一遍。名字、权限、处理哪几种请求、SHA-256 都从这一次读出来。网页给的 -//! 只有代码本身和用户的选择(ID、范围、设置项、出错时、开关)。 -//! 2. 在系统的确认框里写明插件名、它能做什么,以及 SHA-256 的前几位(审核窗口里写的是同一 -//! 段,对得上就是同一份代码)或者这次改什么。**默认按钮是取消**。 +//! `Plugins` 读,确认过的那一份代码从 `PluginSourceDiff` 取、再读一遍。名字、权限、处理 +//! 哪几种请求、SHA-256、这次改了什么都从这几次读出来。网页给的只有代码本身、ID 和开关。 +//! 2. 在系统的确认框里写明插件名、它能做什么、这次改什么,以及 SHA-256 的前几位(应用里 +//! 写的是同一段,对得上就是同一份代码)。**默认按钮是取消**。 //! 3. 用户点了确认,才把**给人看过的那同一份**交给 core。 //! //! 用户在对话框里取消不是失败:回执是 `cancelled`,网页那边什么都不用报,界面照原样。 -//! -//! 其余插件端点(列出、读代码、开关和设置、删除、排序、试运行、日志)网页直接经过 `call` -//! 走;改得了工具调用的插件,core 在那条路上只许停用、改出错时怎么办,别的改动答 -//! `control.plugin.needs_confirmation`,网页再请这里。 use std::collections::BTreeMap; -use tw_api::{ManifestView, PluginUpdate, PluginView, SettingSpecView, SettingValue, ep}; +use tw_api::{ManifestView, Permission, PluginInspection, PluginView, SettingValue, ep}; use crate::AppState; use crate::control::ControlClient; use crate::error::{CmdError, Out, text}; -use crate::wire::{ - PluginApproveRequest, PluginInstallRequest, PluginReplaceRequest, PluginUpdateRequest, - PluginWrite, -}; +use crate::wire::{PluginApproveRequest, PluginInstallRequest, PluginSaveRequest, PluginWrite}; mod confirm; pub mod defaults; pub mod words; -use words::{Can, Change, ScopePart}; +use words::{Change, ScopePart}; /// 插件文件的上限,和 core 一样 const MAX_SOURCE: usize = 1024 * 1024; -/// 安装一个插件 +/// 装一个插件(改得了工具调用的那种) #[tauri::command] -pub async fn plugin_install( +pub async fn plugin_install_confirmed( app: tauri::AppHandle, state: tauri::State<'_, AppState>, req: PluginInstallRequest, @@ -54,22 +48,19 @@ pub async fn plugin_install( &defaults::name(req.id.as_deref(), &m.name), &m.permissions, &m.requests, - &req.scope, + &m.scope, &read.sha256, ); if !confirmed(&app, ask).await? { return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[], &tw_api::PluginCreate { source: req.source, id: req.id, enabled: req.enabled, - on_error: req.on_error, - scope: req.scope, - settings: req.settings, base_version: req.base_version, }, ) @@ -78,33 +69,70 @@ pub async fn plugin_install( Ok(PluginWrite::Done { version: w.version }) } -/// 更换一个插件的代码 +/// 保存一个插件:整份代码和开关。打开改得了工具调用的插件、改它的代码时用。 +/// +/// 这次改什么**由这里比出来**:开关和 core 那边现在的比;代码和确认过的那一份比 —— 按新代码 +/// 里的值改写确认过的那一份(`PluginRewrite`),正好得到交上来的这一份,就只是改了数据(出错 +/// 时、适用范围、设置的值),逐项写出来;不然就是改了代码,写出新旧两个 SHA-256。 #[tauri::command] -pub async fn plugin_replace_source( +pub async fn plugin_save_confirmed( app: tauri::AppHandle, state: tauri::State<'_, AppState>, - req: PluginReplaceRequest, + req: PluginSaveRequest, ) -> Out { let c = &state.control; let before = installed(c, &req.id).await?; let read = inspect(c, &req.source).await?; + let approved = approved_code(c, &before).await; + let old = match approved.as_deref() { + Some(src) => inspect(c, src).await.ok().map(|r| r.manifest), + None => None, + }; + let mut changes = Vec::new(); + match (before.enabled, req.enabled) { + (false, true) => changes.push(Change::TurnOn), + (true, false) => changes.push(Change::TurnOff), + _ => {} + } + if read.sha256 != before.sha256 { + let data_only = match (approved.as_deref(), &old) { + (Some(src), Some(_)) => rewrite(c, src, &read.manifest) + .await + .is_some_and(|s| s == req.source), + _ => false, + }; + if !data_only { + changes.push(Change::Code { + from: before.sha256.clone(), + to: read.sha256.clone(), + }); + } + if let Some(old) = &old { + changes.extend(data_changes(&before.id, old, &read.manifest)); + } + } + let previous = match &old { + Some(m) => Some(m.permissions.as_slice()), + None => known(&before), + }; let m = &read.manifest; - let ask = words::replace( + let ask = words::save( &shown_name(&before), &defaults::name(Some(&req.id), &m.name), &m.permissions, &m.requests, - known(&before), - &read.sha256, + previous, + &changes, ); if !confirmed(&app, ask).await? { return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[&req.id], - &tw_api::PluginSourceReplace { + &tw_api::PluginSave { source: req.source, + enabled: req.enabled, base_version: req.base_version, }, ) @@ -113,10 +141,10 @@ pub async fn plugin_replace_source( Ok(PluginWrite::Done { version: w.version }) } -/// 确认一个插件变了的文件。**文件由这里自己去取**(`PluginSourceDiff`),读的、给人看的、 +/// 批准一个插件磁盘上改过的文件。**文件由这里自己去取**(`PluginSourceDiff`),读的、给人看的、 /// 交给 core 认的是同一个 SHA-256;在这期间文件又变了的话,core 那边对不上就不认 #[tauri::command] -pub async fn plugin_approve( +pub async fn plugin_approve_confirmed( app: tauri::AppHandle, state: tauri::State<'_, AppState>, req: PluginApproveRequest, @@ -152,7 +180,7 @@ pub async fn plugin_approve( return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[&req.id], &tw_api::PluginApprove { sha256: sha, @@ -164,109 +192,31 @@ pub async fn plugin_approve( Ok(PluginWrite::Done { version: w.version }) } -/// 打开一个改得了回答里工具调用的插件,或者改它的设置、范围(addendum 1 B)。 -/// -/// 网页那条路(`UpdatePlugin`)对这种插件只许停用、改出错时怎么办:网页里注入的脚本要是 -/// 能打开它、改它的设置,就能借它改客户端要执行的命令。这里**从 core 读这个插件现在的 -/// 样子**,和网页交来的那一份比出这次改什么,连同它能做什么一起摆进系统的确认框,点了头 -/// 才发 `UpdatePluginConfirmed` —— 发的就是比过、给人看过的那一份。 -#[tauri::command] -pub async fn plugin_update_confirmed( - app: tauri::AppHandle, - state: tauri::State<'_, AppState>, - req: PluginUpdateRequest, -) -> Out { - let c = &state.control; - let before = installed(c, &req.id).await?; - // 读不出权限的(core 那边没有它的 manifest):把批准的那份代码再编一遍 - let reread = if before.permissions.is_empty() { - reread(c, &before).await - } else { - None - }; - let (name, perms, kinds, schema) = match &reread { - Some(m) => (&m.name, &m.permissions, &m.requests, &m.settings_schema), - None => ( - &before.name, - &before.permissions, - &before.requests, - &before.settings_schema, - ), - }; - let can = if perms.is_empty() { - Can::Unknown - } else { - Can::Known { perms, kinds } - }; - let ask = words::update( - &defaults::name(Some(&before.id), name), - can, - &changes(&before, name, schema, &req.update), - ); - if !confirmed(&app, ask).await? { - return Ok(PluginWrite::Cancelled); - } - let w = c - .call::(&[&req.id], &req.update) - .await - .map_err(text)?; - Ok(PluginWrite::Done { version: w.version }) -} - -/// 这次改了什么,按确认框里的先后:开关、设置、范围(每一项单独说)、出错时怎么办。 -/// -/// **设置按生效的值比**(和 core 一样):没写进去的按默认值算,所以表单原样交回来的默认值 -/// 不算一次改动。范围不看顺序、空白和重复。`name` 是 manifest 里的名字(认默认插件、 -/// 取它的标签用),`schema` 是它的设置项。 -fn changes( - before: &PluginView, - name: &str, - schema: &[SettingSpecView], - next: &PluginUpdate, -) -> Vec { +/// 两份 manifest 的数据改了什么,按确认框里的先后:设置(按声明的顺序)、适用范围(每一项 +/// 单独说)、出错时怎么办。设置的标签按新代码里写的(默认插件按界面语言);范围不看顺序、 +/// 空白和重复 +fn data_changes(id: &str, old: &ManifestView, new: &ManifestView) -> Vec { let mut out = Vec::new(); - match (before.enabled, next.enabled) { - (false, true) => out.push(Change::TurnOn), - (true, false) => out.push(Change::TurnOff), - _ => {} - } - let effective = |given: &BTreeMap| { - let mut all = given.clone(); - for s in schema { - all.entry(s.key.clone()) - .or_insert_with(|| s.default.clone()); - } - all - }; - let (was, will) = (effective(&before.settings), effective(&next.settings)); - // 声明的那几项按声明的顺序,声明之外的(读不出 manifest 时)跟在后面 - let mut keys: Vec<&String> = schema.iter().map(|s| &s.key).collect(); - for k in was.keys().chain(will.keys()) { - if !keys.contains(&k) { - keys.push(k); - } - } - for key in keys { - let (a, b) = (was.get(key), will.get(key)); - if a == b { + let was: BTreeMap<&str, &SettingValue> = old + .settings_schema + .iter() + .map(|s| (s.key.as_str(), &s.value)) + .collect(); + for s in &new.settings_schema { + let Some(&from) = was.get(s.key.as_str()) else { continue; - } - let label = schema - .iter() - .find(|s| &s.key == key) - .map(|s| defaults::label(&before.id, name, key, &s.label)) - .unwrap_or_else(|| key.clone()); - let value = |v: Option<&SettingValue>| { - words::setting_value(v.unwrap_or(&SettingValue::String(String::new()))) }; + if from == &s.value { + continue; + } out.push(Change::Setting { - label, - from: value(a), - to: value(b), + label: defaults::label(id, &new.name, &s.key, &s.label), + from: words::setting_value(from), + to: words::setting_value(&s.value), }); } for part in ScopePart::ALL { - let (a, b) = (norm(part.of(&before.scope)), norm(part.of(&next.scope))); + let (a, b) = (norm(part.of(&old.scope)), norm(part.of(&new.scope))); if a != b { out.push(Change::Scope { part, @@ -275,10 +225,10 @@ fn changes( }); } } - if before.on_error != next.on_error { + if old.on_error != new.on_error { out.push(Change::OnError { - from: before.on_error, - to: next.on_error, + from: old.on_error, + to: new.on_error, }); } out @@ -298,7 +248,7 @@ struct Read { sha256: String, } -/// 交给 core 读一遍。读不了(语法、清单不对)就停在这里:审核窗口里已经说过原因, +/// 交给 core 读一遍。读不了(语法、清单不对)就停在这里:应用里已经说过原因, /// 走到这一步只可能是网页没照规矩来 async fn inspect(c: &ControlClient, source: &str) -> Out { if source.len() > MAX_SOURCE { @@ -307,7 +257,7 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { "The plugin file is over the 1 MB limit." ))); } - let i = c + let i: PluginInspection = c .call::( &[], &tw_api::PluginSource { @@ -332,25 +282,34 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { }) } -/// 读不出权限的插件:把**批准的那一份**代码交给 core 再编一遍。只认哈希和配置里批准的 -/// 一样的那一份(底稿,或者没被改过的插件文件);读不成是 `None`,确认框按读不出说 -async fn reread(c: &ControlClient, p: &PluginView) -> Option { +/// 确认过的那一份代码:底稿,或者没被改过的插件文件(哈希和配置里确认的一样)。都没有是 `None` +async fn approved_code(c: &ControlClient, p: &PluginView) -> Option { let src = c.call::(&[&p.id], &()).await.ok()?; - let code = if !src.approved.is_empty() && src.approved_sha256 == p.sha256 { - src.approved + if !src.approved.is_empty() && src.approved_sha256 == p.sha256 { + Some(src.approved) } else if src.current_sha256.as_deref() == Some(p.sha256.as_str()) { - src.current? + src.current } else { - return None; + None + } +} + +/// 按这份 manifest 里的值改写一段代码(每一个设置项都给)。没成是 `None` +async fn rewrite(c: &ControlClient, source: &str, m: &ManifestView) -> Option { + let req = tw_api::PluginRewriteRequest { + source: source.to_string(), + on_error: m.on_error, + scope: m.scope.clone(), + settings: m + .settings_schema + .iter() + .map(|s| (s.key.clone(), s.value.clone())) + .collect(), }; - let i = c - .call::(&[], &tw_api::PluginSource { source: code }) + c.call::(&[], &req) .await - .ok()?; - if i.sha256 != p.sha256 { - return None; - } - i.manifest + .ok() + .map(|r| r.source) } /// 装着的那一个插件现在的样子(core 说的) @@ -371,7 +330,7 @@ fn shown_name(p: &PluginView) -> String { /// 装着的那一版申请的权限。读不出来(core 那边没有它的 manifest)是 `None`:不知道哪一项 /// 是新的,就不标「新增」 -fn known(p: &PluginView) -> Option<&[tw_api::Permission]> { +fn known(p: &PluginView) -> Option<&[Permission]> { (!p.permissions.is_empty()).then_some(p.permissions.as_slice()) } @@ -396,8 +355,7 @@ fn changed_meanwhile() -> CmdError { mod tests { use super::*; use tw_api::{ - OnError, Permission, PluginScope, PluginStats, PluginStatus, ReplyMode, RequestKind, - SettingKind, + OnError, PluginHooks, PluginScope, ReplyMode, RequestKind, SettingKind, SettingSpecView, }; #[test] @@ -411,99 +369,69 @@ mod tests { assert_eq!(no, serde_json::json!({ "kind": "cancelled" })); } - /// 网页给的安装请求里**没有清单**:多给了也不读(名字、权限由这里自己读) + /// 网页给的安装请求里**没有清单**:多给了也不读(名字、权限、范围由这里自己读) #[test] fn an_install_request_carries_no_manifest() { let req: PluginInstallRequest = serde_json::from_value(serde_json::json!({ "source": "export const manifest = {}", "id": "x", "enabled": true, - "on_error": "reject", - "scope": { "clients": [], "models": [], "upstreams": [] }, - "settings": { "note": "今天", "n": 3, "on": true }, "base_version": null, - "manifest": { "name": "伪造的名字", "permissions": [] } + "manifest": { "name": "伪造的名字", "permissions": [] }, + "scope": { "clients": ["伪造的范围"], "models": [], "upstreams": [] } })) .unwrap(); assert_eq!(req.id.as_deref(), Some("x")); - assert!(!format!("{req:?}").contains("伪造的名字")); - assert_eq!(req.settings["n"], SettingValue::Number(3.0)); + let shown = format!("{req:?}"); + assert!( + !shown.contains("伪造的名字") && !shown.contains("伪造的范围"), + "{shown}" + ); } - fn view() -> PluginView { - PluginView { - id: "reply-language".into(), + fn manifest() -> ManifestView { + ManifestView { name: "Answer in a chosen language".into(), description: None, - enabled: false, - on_error: OnError::Reject, permissions: vec![Permission::System], requests: vec![RequestKind::Conversation], scope: PluginScope::default(), + on_error: OnError::Reject, reply_mode: ReplyMode::Block, + hooks: PluginHooks { + request: true, + ..PluginHooks::default() + }, settings_schema: vec![SettingSpecView { key: "language".into(), kind: SettingKind::String, - label: "回答语言".into(), - default: SettingValue::String("简体中文".into()), + label: "Answer language".into(), + value: SettingValue::String("简体中文".into()), }], - settings: BTreeMap::from([( - "language".to_string(), - SettingValue::String("简体中文".into()), - )]), - sha256: "aa".into(), - status: PluginStatus::Disabled, - stats: PluginStats::default(), - } - } - - fn update_of(p: &PluginView) -> PluginUpdate { - PluginUpdate { - enabled: p.enabled, - on_error: p.on_error, - scope: p.scope.clone(), - settings: p.settings.clone(), - base_version: None, } } - /// 只拨开关:确认框里只有「启用」这一项 - #[test] - fn turning_on_is_the_only_change_when_only_the_switch_moves() { - let p = view(); - let next = PluginUpdate { - enabled: true, - ..update_of(&p) - }; - assert_eq!( - changes(&p, &p.name, &p.settings_schema, &next), - [Change::TurnOn] - ); - } - - /// 设置按生效的值比:表单不交默认值、交回原样的默认值,都不算改动;范围不看顺序 + /// 只改了数据:每一项单独说,设置的标签按界面语言(默认插件),两个值都写出来; + /// 范围不看顺序和空白 #[test] - fn defaults_and_reordered_scopes_are_not_changes() { - let mut p = view(); - p.scope.models = vec!["b*".into(), "a*".into()]; - let mut next = update_of(&p); - next.settings.clear(); - next.scope.models = vec!["a*".into(), " b* ".into(), "a*".into()]; - assert!(changes(&p, &p.name, &p.settings_schema, &next).is_empty()); - } - - #[test] - fn a_changed_setting_names_its_label_and_both_values() { + fn data_changes_name_each_setting_scope_part_and_on_error() { crate::i18n::with_lang(crate::i18n::Lang::Zh, || { - let p = view(); - let mut next = update_of(&p); - next.settings - .insert("language".into(), SettingValue::String("English".into())); - next.scope.upstreams = vec!["deepseek".into()]; - next.on_error = OnError::Skip; - let c = changes(&p, &p.name, &p.settings_schema, &next); + let old = ManifestView { + scope: PluginScope { + models: vec!["b*".into(), "a*".into()], + ..PluginScope::default() + }, + ..manifest() + }; + let mut new = manifest(); + new.scope.models = vec![" a*".into(), "b*".into(), "a*".into()]; + assert!(data_changes("reply-language", &old, &new).is_empty()); + + new.settings_schema[0].value = SettingValue::String("English".into()); + new.scope.upstreams = vec!["deepseek".into()]; + new.on_error = OnError::Skip; assert_eq!( - c, + data_changes("reply-language", &old, &new), [ Change::Setting { label: "回答语言".into(), @@ -523,4 +451,18 @@ mod tests { ); }); } + + /// 新代码里才有的设置项不算一项改动(它在「改了代码」里) + #[test] + fn a_new_setting_is_part_of_the_code_change() { + let old = manifest(); + let mut new = manifest(); + new.settings_schema.push(SettingSpecView { + key: "tone".into(), + kind: SettingKind::String, + label: "Tone".into(), + value: SettingValue::String("formal".into()), + }); + assert!(data_changes("reply-language", &old, &new).is_empty()); + } } diff --git a/src-tauri/src/plugins/words.rs b/src-tauri/src/plugins/words.rs index 3838f39f..bfb7f51b 100644 --- a/src-tauri/src/plugins/words.rs +++ b/src-tauri/src/plugins/words.rs @@ -142,7 +142,8 @@ fn invisible(c: char) -> bool { matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0xFEFF) } -/// SHA-256 的前 16 位,四个一组。审核窗口里写的是同一段,对得上就是同一份代码 +/// SHA-256 的前 16 位,四个一组。应用里(插件编辑器的标题栏、审核更改)写的是同一段,对得上就是 +/// 同一份代码 pub fn sha_prefix(hex: &str) -> String { let head: Vec = hex.chars().take(16).collect(); head.chunks(4) @@ -239,8 +240,8 @@ fn scope_line(scope: &PluginScope) -> String { fn check_line() -> &'static str { tr!( - "请核对 SHA-256 与审核窗口中显示的一致。", - "Check that the SHA-256 matches the one shown in the review window." + "请核对 SHA-256 与应用中显示的一致。", + "Check that the SHA-256 matches the one shown in the app." ) } @@ -285,8 +286,8 @@ pub fn install( } } -/// 换了代码之后插件改了名字:正文第一行说出新名字。标题里写的是**现在装着的那个名字** -/// —— 用户点开的是它,换上来的代码自称什么由它自己说 +/// 改了代码之后插件改了名字:正文第一行说出新名字。标题里写的是**现在装着的那个名字** +/// —— 用户点开的是它,新代码自称什么由它自己说 fn renamed(current: &str, next: &str) -> String { if current == next { return String::new(); @@ -297,45 +298,9 @@ fn renamed(current: &str, next: &str) -> String { ) } -/// 更换一个插件的代码。`name`:现在装着的那个的名字;`new_name`、`perms`、`kinds`:新代码 -/// 里的;`previous`:原来那一版申请的权限(读不出来是 `None`,就不标新增) -pub fn replace( - name: &str, - new_name: &str, - perms: &[Permission], - kinds: &[RequestKind], - previous: Option<&[Permission]>, - sha256: &str, -) -> Ask { - let (name, new_name) = (clean_name(name), clean_name(new_name)); - let renamed = renamed(&name, &new_name); - let can = abilities(perms, previous, kinds); - let detail = tr!( - format!( - "{renamed}新的代码可以:\n{can}\n\nSHA-256:{}\n\n{}", - sha_prefix(sha256), - check_line() - ), - format!( - "{renamed}The new code can:\n{can}\n\nSHA-256: {}\n\n{}", - sha_prefix(sha256), - check_line() - ) - ); - Ask { - title: tr!("更换插件代码", "Replace Plugin Code").to_string(), - message: tr!( - format!("更换插件「{name}」的代码"), - format!("Replace the Code of Plugin “{name}”") - ), - detail, - accept: tr!("更换", "Replace").to_string(), - ok_hint: tr!("选择「确定」更换代码。", "Choose OK to replace the code.").to_string(), - danger: perms.contains(&Permission::ReplyToolCalls), - } -} - -/// 确认一个插件变了的文件。参数同 [`replace`],`from` / `to` 是确认过的和现在的 SHA-256 +/// 确认一个插件磁盘上变了的文件。`name`:现在装着的那个的名字;`new_name`、`perms`、`kinds`: +/// 改过的文件里的;`previous`:原来那一版申请的权限(读不出来是 `None`,就不标新增); +/// `from` / `to` 是确认过的和现在的 SHA-256 pub fn approve( name: &str, new_name: &str, @@ -375,22 +340,16 @@ pub fn approve( } } -/// 插件能做什么。**读不出来的**(core 那边没有它的 manifest,再读一遍也读不成)按改得了 -/// 工具调用对待 —— core 拦它的理由正是这个 -#[derive(Debug, Clone, Copy)] -pub enum Can<'a> { - Known { - perms: &'a [Permission], - kinds: &'a [RequestKind], - }, - Unknown, -} - -/// 一次改动里的一项。值都已经写成给人看的样子([`setting_value`]) +/// 一次保存里的一项。值都已经写成给人看的样子([`setting_value`]) #[derive(Debug, Clone, PartialEq)] pub enum Change { TurnOn, TurnOff, + /// 改了代码(不只是出错时、范围、设置的值):确认过的和新的 SHA-256 + Code { + from: String, + to: String, + }, OnError { from: OnError, to: OnError, @@ -431,6 +390,18 @@ fn change_line(c: &Change) -> String { match c { Change::TurnOn => tr!("启用此插件", "Turn on the plugin").to_string(), Change::TurnOff => tr!("停用此插件", "Turn off the plugin").to_string(), + Change::Code { from, to } => tr!( + format!( + "修改代码(SHA-256:{} → {})", + sha_prefix(from), + sha_prefix(to) + ), + format!( + "Change the code (SHA-256: {} → {})", + sha_prefix(from), + sha_prefix(to) + ) + ), Change::OnError { from, to } => tr!( format!("出错时:{} → {}", on_error_text(*from), on_error_text(*to)), format!( @@ -463,44 +434,46 @@ fn change_line(c: &Change) -> String { } } -/// 打开一个改得了工具调用的插件,或者改它的设置、范围(`UpdatePluginConfirmed`)。 +/// 保存一个改得了工具调用的插件:打开它,或者改了它的代码(`SavePluginConfirmed`)。 /// -/// 先写**这次改什么**,再写**它能做什么**:用户点开的是一次改动,要确认的是这一次。 -/// 只是打开它的,标题和按钮都说「启用」 -pub fn update(name: &str, can: Can<'_>, changes: &[Change]) -> Ask { - let name = clean_name(name); +/// 先写**这次改什么**,再写**它能做什么**:用户点开的是一次保存,要确认的是这一次。只是 +/// 打开它的,标题和按钮都说「启用」。`name`:现在装着的那个的名字;`new_name`、`perms`、 +/// `kinds`:要保存的代码里的;`previous`:确认过的那一版申请的权限(读不出来是 `None`: +/// 不标新增,按改得了工具调用对待) +pub fn save( + name: &str, + new_name: &str, + perms: &[Permission], + kinds: &[RequestKind], + previous: Option<&[Permission]>, + changes: &[Change], +) -> Ask { + let (name, new_name) = (clean_name(name), clean_name(new_name)); let only_on = changes == [Change::TurnOn]; + let code = changes.iter().any(|c| matches!(c, Change::Code { .. })); + let renamed = if code { + renamed(&name, &new_name) + } else { + String::new() + }; let mut lines: Vec = changes .iter() .map(|c| format!("• {}", change_line(c))) .collect(); if lines.is_empty() { - // core 认为有要点头的改动、这里比不出来:照实说是一次保存 - lines.push(format!( - "• {}", - tr!("保存此插件的设置", "Save the plugin's settings") - )); - } - let (can_text, danger) = match can { - Can::Known { perms, kinds } => ( - abilities(perms, None, kinds), - perms.contains(&Permission::ReplyToolCalls), - ), - Can::Unknown => ( - format!( - "• {}", - tr!( - "申请的权限无法读取,可能包括修改回答中的工具调用(高风险)", - "Its permissions cannot be read; they may include changing tool calls in replies (high risk)" - ) - ), - true, - ), + // core 认为要点头、这里比不出改了什么:照实说是一次保存 + lines.push(format!("• {}", tr!("保存此插件", "Save the plugin"))); + } + let changes_text = lines.join("\n"); + let can = abilities(perms, previous, kinds); + let check = if code { + format!("\n\n{}", check_line()) + } else { + String::new() }; - let changes = lines.join("\n"); let detail = tr!( - format!("本次改动:\n{changes}\n\n此插件可以:\n{can_text}"), - format!("Changes:\n{changes}\n\nThis plugin can:\n{can_text}") + format!("{renamed}本次改动:\n{changes_text}\n\n此插件可以:\n{can}{check}"), + format!("{renamed}Changes:\n{changes_text}\n\nThis plugin can:\n{can}{check}") ); Ask { title: tr!("确认插件改动", "Confirm Plugin Changes").to_string(), @@ -511,8 +484,8 @@ pub fn update(name: &str, can: Can<'_>, changes: &[Change]) -> Ask { ) } else { tr!( - format!("更改插件「{name}」"), - format!("Change Plugin “{name}”") + format!("保存插件「{name}」"), + format!("Save Plugin “{name}”") ) }, detail, @@ -530,7 +503,8 @@ pub fn update(name: &str, can: Can<'_>, changes: &[Change]) -> Ask { } else { tr!("选择「确定」保存改动。", "Choose OK to save the changes.").to_string() }, - danger, + danger: perms.contains(&Permission::ReplyToolCalls) + || previous.is_none_or(|p| p.contains(&Permission::ReplyToolCalls)), } } @@ -606,7 +580,7 @@ mod tests { let perms = [Permission::Messages]; let asks = [ install("p", &perms, &all, &PluginScope::default(), "aa"), - replace("p", "p", &perms, &all, Some(&perms), "aa"), + save("p", "p", &perms, &all, Some(&perms), &[Change::TurnOn]), approve("p", "p", &perms, &all, Some(&perms), "aa", "bb"), ]; for a in &asks { @@ -645,30 +619,38 @@ mod tests { assert!(!a.danger); } - /// 原来那一版的权限读不出来:不标新增(不知道哪一项是新的) + /// 原来那一版的权限读不出来:不标新增(不知道哪一项是新的),按改得了工具调用对待 #[test] fn nothing_is_marked_new_when_the_old_permissions_are_unknown() { - let a = replace( + let a = save( "p", "p", &[Permission::System], &[RequestKind::Conversation], None, - "aa", + &[Change::TurnOn], ); assert!(!a.detail.contains("新增"), "{}", a.detail); + assert!(a.danger); } #[test] fn new_code_under_another_name_says_so() { let kinds = [RequestKind::Conversation]; let sys = [Permission::System]; - let same = replace("附加日期", "附加日期", &sys, &kinds, Some(&sys), "aa"); - let other = replace("附加日期", "清空系统提示", &sys, &kinds, Some(&sys), "aa"); + let code = [Change::Code { + from: "aaaa".into(), + to: "bbbb".into(), + }]; + let same = save("附加日期", "附加日期", &sys, &kinds, Some(&sys), &code); + let other = save("附加日期", "清空系统提示", &sys, &kinds, Some(&sys), &code); // 标题是装着的那个名字,新名字写在正文里 assert!(other.message.contains("附加日期"), "{}", other.message); assert!(other.detail.contains("清空系统提示"), "{}", other.detail); assert!(!same.detail.contains("附加日期"), "{}", same.detail); + // 改了代码:写新旧两个 SHA-256,请人核对 + assert!(same.detail.contains("aaaa → bbbb"), "{}", same.detail); + assert!(same.detail.contains(check_line()), "{}", same.detail); } #[test] @@ -691,12 +673,12 @@ mod tests { fn turning_a_tool_call_plugin_on_says_what_changes_and_what_it_can_do() { let perms = [Permission::Messages, Permission::ReplyToolCalls]; let kinds = [RequestKind::Conversation]; - let a = update( + let a = save( + "WSL 路径转换", "WSL 路径转换", - Can::Known { - perms: &perms, - kinds: &kinds, - }, + &perms, + &kinds, + Some(&perms), &[Change::TurnOn], ); assert_eq!(a.message, "启用插件「WSL 路径转换」"); @@ -708,14 +690,21 @@ mod tests { .find(permission_text(Permission::ReplyToolCalls)) .unwrap(); assert!(change < can, "{}", a.detail); + // 没改代码:不用核对 SHA-256 + assert!(!a.detail.contains(check_line()), "{}", a.detail); } #[test] - fn a_settings_change_lists_each_change_and_the_values_stay_on_one_line() { - let a = update( + fn a_data_change_lists_each_change_and_the_values_stay_on_one_line() { + let perms = [Permission::Messages, Permission::ReplyToolCalls]; + let a = save( + "p", "p", - Can::Unknown, + &perms, + &[RequestKind::Conversation], + Some(&perms), &[ + Change::TurnOn, Change::Setting { label: "替换表\n伪造的一行".into(), from: setting_value(&tw_api::SettingValue::String("a=b\nc=d".into())), @@ -729,7 +718,6 @@ mod tests { ], ); assert_eq!(a.accept, "保存"); - // 读不出权限的按高风险对待 assert!(a.danger); assert!( a.detail @@ -744,6 +732,29 @@ mod tests { ); } + /// 新代码多要了工具调用:标「新增」,警示的样子 + #[test] + fn new_code_that_adds_tool_calls_is_marked() { + let a = save( + "p", + "p", + &[Permission::System, Permission::ReplyToolCalls], + &[RequestKind::Conversation], + Some(&[Permission::System]), + &[Change::Code { + from: "aa".into(), + to: "bb".into(), + }], + ); + let line = a + .detail + .lines() + .find(|l| l.contains(permission_text(Permission::ReplyToolCalls))) + .unwrap(); + assert!(line.contains("新增"), "{line}"); + assert!(a.danger); + } + #[test] fn setting_values_read_like_the_form() { use tw_api::SettingValue::*; diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 15b4db17..7730bf6e 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -751,40 +751,39 @@ pub struct ImportProposal { // ---------------------------------------------------------- 插件:在系统的确认框里点头的几步 // -// 装插件、换代码、批准改过的文件、打开改得了工具调用的插件(或者改它的设置、范围), -// 这几个端点**不在网页的白名单里**(`call.rs`)。网页只能请 Rust 去做:Rust 自己把插件 -// 再读一遍,在系统的确认框里写明它是谁、能做什么、这次改什么,点了头才写配置 -// (`plugins` 模块)。 +// 改得了回答里工具调用的插件,装上它、打开它、改它的代码、批准它磁盘上改过的文件,要在系统 +// 的确认框里点头(约定附录 4 §3)。点头之后发的那几个端点(`…Confirmed`)**不在网页的白名单 +// 里**(`call.rs`):网页只能请 Rust 去做,Rust 自己把插件再读一遍,在系统的确认框里写明它是 +// 谁、能做什么、这次改什么,点了头才写配置(`plugins` 模块)。 -/// 装一个插件(`plugin_install`):代码,和审核窗口里选的。 +/// 装一个插件(`plugin_install_confirmed`):代码、ID 和开关,和 `CreatePlugin` 一样。 /// -/// **没有 manifest**:名字、权限、处理哪几种请求由 Rust 把代码交给 core 再读一遍,网页 -/// 说的不算。 +/// **没有 manifest**:名字、权限、处理哪几种请求、适用范围由 Rust 把代码交给 core 再读一遍, +/// 网页说的不算。出错时怎么办、适用范围、设置都写在代码里 #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PluginInstallRequest { pub source: String, - /// 审核窗口里填的 ID。不给由 core 按名字起 + /// 添加插件时「设置」页上的插件 ID。不给由 core 按名字起 #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, pub enabled: bool, - pub on_error: tw_api::OnError, - pub scope: tw_api::PluginScope, - pub settings: std::collections::BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_version: Option, } -/// 换一个插件的代码(`plugin_replace_source`) +/// 保存一个插件(`plugin_save_confirmed`):整份代码和开关,和 `SavePlugin` 一样。这次改 +/// 什么由 Rust 和 core 那边确认过的那一份比出来 #[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PluginReplaceRequest { +pub struct PluginSaveRequest { pub id: String, pub source: String, + pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_version: Option, } -/// 批准一个插件改过的文件(`plugin_approve`)。**文件由 Rust 自己去取**:读的、给人看的、 -/// 交给 core 认的是同一个 SHA-256 +/// 批准一个插件磁盘上改过的文件(`plugin_approve_confirmed`)。**文件由 Rust 自己去取**: +/// 读的、给人看的、交给 core 认的是同一个 SHA-256 #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PluginApproveRequest { pub id: String, @@ -792,14 +791,6 @@ pub struct PluginApproveRequest { pub base_version: Option, } -/// 一次要点头的改动(`plugin_update_confirmed`):和 `UpdatePlugin` 一样整份交,交上来的 -/// 就是保存之后的样子 -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PluginUpdateRequest { - pub id: String, - pub update: tw_api::PluginUpdate, -} - /// 写成了(配置的新版本),或者在系统的确认框里点了取消 —— **取消不是失败**,什么都 /// 没写,界面照原样 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index a3d726dc..7a1c0d8c 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -443,15 +443,16 @@ async fn a_bedrock_upstream_is_saved_the_way_the_dialog_sends_it() { assert_eq!(m.code, "config.credential.aws_profile_and_keys", "{m:?}"); } -/// 改得了工具调用的插件,网页那条路(`UpdatePlugin`)打不开它:core 答 +/// 改得了工具调用的插件,网页那条路(`SavePlugin`)打不开它:core 答 /// `control.plugin.needs_confirmation`,界面据此请 Rust 弹系统的确认框。确认过的那一条 -/// (`UpdatePluginConfirmed`,桌面端点过头才发)打得开;停用照常走网页那条。 +/// (`SavePluginConfirmed`,桌面端点过头才发)打得开;只改数据(按表单改写的设置值)、 +/// 停用照常走网页那条(约定附录 4 §3)。 /// -/// core 第一次起来时装上的默认插件里就有这样一个(`wsl-paths`),停用着 +/// core 第一次起来时装上的默认插件里就有这样一个(`wsl-paths`),停用着。 #[tokio::test] -async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_update() { +async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { use thinkwatch_lite_lib::control::Refused; - use tw_api::{Permission, PluginUpdate, ep}; + use tw_api::{PluginRewriteRequest, PluginSave, PluginSource, SettingValue, ep}; let core = Core::start(); core.wait_ready().await; @@ -459,43 +460,89 @@ async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_update() { // 默认插件在启动时装上:等它出现在单子上 let deadline = Instant::now() + Duration::from_secs(10); - let wsl = loop { + loop { let all = c.call::(&[], &()).await.unwrap(); if let Some(p) = all.into_iter().find(|p| p.id == "wsl-paths") { - break p; + assert!(!p.enabled, "默认插件装上时停用着"); + break; } assert!(Instant::now() < deadline, "默认插件没有装上"); tokio::time::sleep(Duration::from_millis(100)).await; - }; - assert!(!wsl.enabled, "默认插件装上时停用着"); - assert!( - wsl.permissions.is_empty() || wsl.permissions.contains(&Permission::ReplyToolCalls), - "{:?}", - wsl.permissions - ); + } + let source = c + .call::(&["wsl-paths"], &()) + .await + .unwrap() + .approved; - let on = PluginUpdate { + let on = PluginSave { + source: source.clone(), enabled: true, - on_error: wsl.on_error, - scope: wsl.scope.clone(), - settings: wsl.settings.clone(), base_version: None, }; let e = c - .call::(&["wsl-paths"], &on) + .call::(&["wsl-paths"], &on) .await .unwrap_err(); let m = &e.downcast_ref::().expect("被拒时要带码").0; assert_eq!(m.code, "control.plugin.needs_confirmation", "{m:?}"); - - c.call::(&["wsl-paths"], &on) + c.call::(&["wsl-paths"], &on) .await .unwrap(); - let off = PluginUpdate { - enabled: false, - ..on - }; - c.call::(&["wsl-paths"], &off) + + // 开着的时候只改一个设置的值:网页那条就够 + let read = c + .call::( + &[], + &PluginSource { + source: source.clone(), + }, + ) .await .unwrap(); + let m = read.manifest.expect("默认插件读得出 manifest"); + let rewritten = c + .call::( + &[], + &PluginRewriteRequest { + source, + on_error: m.on_error, + scope: m.scope.clone(), + settings: m + .settings_schema + .iter() + .map(|s| { + let v = match s.value { + SettingValue::Bool(b) => SettingValue::Bool(!b), + ref other => other.clone(), + }; + (s.key.clone(), v) + }) + .collect(), + }, + ) + .await + .unwrap() + .source; + c.call::( + &["wsl-paths"], + &PluginSave { + source: rewritten.clone(), + enabled: true, + base_version: None, + }, + ) + .await + .unwrap(); + // 停用照常走网页那条 + c.call::( + &["wsl-paths"], + &PluginSave { + source: rewritten, + enabled: false, + base_version: None, + }, + ) + .await + .unwrap(); } diff --git a/src-tauri/tests/ts_bindings.rs b/src-tauri/tests/ts_bindings.rs index 28109780..0ae703c1 100644 --- a/src-tauri/tests/ts_bindings.rs +++ b/src-tauri/tests/ts_bindings.rs @@ -130,9 +130,8 @@ fn lite_typescript() -> String { c.root::(); c.root::(); c.root::(); - c.root::(); + c.root::(); c.root::(); - c.root::(); c.root::(); // **契约里已经有的名字从那边引用**(`Msg`,以及 core 还在发的同名同形的类型), diff --git a/src/App.tsx b/src/App.tsx index 6e306250..709d4801 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1108,7 +1108,16 @@ function Shell({ first }: { first: boolean }) { }} /> )} - {historyOpen && setHistoryOpen(false)} />} + {historyOpen && ( + setHistoryOpen(false)} + onOpenPlugins={() => { + setHistoryOpen(false); + open("plugins"); + }} + /> + )} {/* 等连上 core、拿到概览再弹:名称是否重名、保存基于哪一版都要它 */} {importing && ov && !remoteLost && ( void; + /** 关掉这里、去插件页(恢复的版本要装上、启用改得了工具调用的插件时) */ + onOpenPlugins?: () => void; }) { const t = useText(configDialogsText); const [versions, setVersions] = useState(null); const [busy, setBusy] = useState(null); + /** core 不让这次恢复装上、启用、换掉批准的代码的那个插件(它的那句话) */ + const [pluginRefusal, setPluginRefusal] = useState(null); useEffect(() => { let alive = true; @@ -118,10 +124,13 @@ export function VersionHistoryDialog({ async function restore(version: string) { setBusy(version); + setPluginRefusal(null); try { await call("ConfigRollback", { version }); } catch (e) { - toast.error(errorText(e)); + // 和保存配置文件一样:恢复这条路没有点过头的那一条,只能去插件页 + if (needsConfirmation(e)) setPluginRefusal(errorText(e)); + else toast.error(errorText(e)); } finally { setBusy(null); } @@ -134,6 +143,9 @@ export function VersionHistoryDialog({ {t.historyTitle} {t.historyDescription} + {pluginRefusal && ( + + )}
{versions == null ? (

diff --git a/src/ConfigText.i18n.ts b/src/ConfigText.i18n.ts index 02c2144f..f30de2f8 100644 --- a/src/ConfigText.i18n.ts +++ b/src/ConfigText.i18n.ts @@ -22,6 +22,17 @@ export const configTextText = messages( cursorAt: (section: string) => `光标位于${section}`, showInApp: "在界面中查看", unsaved: "有未保存的修改", + /** + * 写配置原文(保存文件、恢复版本)要装上、启用、换掉批准的代码的那个插件改得了回答里的 + * 工具调用:这条路做不了,只能在插件页里做(那里会弹系统的确认框) + */ + pluginConfirm: { + title: "此更改须在插件页中确认", + /** `reason` 是 core 的那句话 */ + notSaved: (reason: string) => `配置文件未保存。${reason}`, + notRestored: (reason: string) => `未恢复此版本。${reason}`, + open: "打开插件页", + }, }, { sections: { @@ -43,5 +54,11 @@ export const configTextText = messages( cursorAt: (section: string) => `Cursor in ${section}`, showInApp: "Show in app", unsaved: "Unsaved changes", + pluginConfirm: { + title: "This change has to be confirmed on the Plugins page", + notSaved: (reason: string) => `The config file was not saved. ${reason}`, + notRestored: (reason: string) => `This version was not restored. ${reason}`, + open: "Open Plugins", + }, }, ); diff --git a/src/ConfigText.tsx b/src/ConfigText.tsx index 6b0867f3..92abe816 100644 --- a/src/ConfigText.tsx +++ b/src/ConfigText.tsx @@ -10,6 +10,7 @@ import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { configTextText } from "./ConfigText.i18n"; import { errorText } from "@/i18n/core.i18n"; +import { needsConfirmation } from "@/plugins/write"; /** * 编辑器**打开这个对话框时才加载**。CodeMirror 连同 YAML 语法有几百 KB,整个应用 @@ -53,6 +54,8 @@ export default function ConfigTextMode({ const sections: Record = t.sections; const [draft, setDraft] = useState(doc.text); const [busy, setBusy] = useState(false); + /** core 不让这次保存装上、启用、换掉批准的代码的那个插件(它的那句话) */ + const [pluginRefusal, setPluginRefusal] = useState(null); /** 打开这一版时文件是什么样。**保存时带的就是它** */ const base = useRef(doc.version); const dirty = draft !== doc.text; @@ -106,12 +109,16 @@ export default function ConfigTextMode({ async function save() { setBusy(true); + setPluginRefusal(null); try { await call("PutConfig", { text: draft, base_version: base.current }); base.current = ""; onSaved(); } catch (e) { - toast.error(errorText(e)); + // 改得了工具调用的插件:这条路没有点过头的那一条,只能去插件页。几秒就消失的 + // toast 说不清去哪儿,这一句留在编辑器上 + if (needsConfirmation(e)) setPluginRefusal(errorText(e)); + else toast.error(errorText(e)); } finally { setBusy(false); } @@ -153,6 +160,14 @@ export default function ConfigTextMode({ )} + {pluginRefusal && ( + onJumpToForm({ name: "", section: "plugins" }))} + /> + )} +

); } + +/** + * 写配置原文(保存文件、恢复版本)被 core 拒了:这次会装上、启用一个改得了回答里工具调用的 + * 插件,或者换掉它批准的代码(403 `control.plugin.needs_confirmation`)。这条路没有点过头的 + * 那一条,**只能在插件页里做**,那里会弹系统的确认框。写明没写成、为什么、去哪儿 + */ +export function PluginConfirmNotice({ + reason, + result, + onOpenPlugins, +}: { + /** core 的那句话(按界面语言) */ + reason: string; + /** 没做成的是什么 */ + result: "notSaved" | "notRestored"; + onOpenPlugins?: () => void; +}) { + const t = useText(configTextText).pluginConfirm; + return ( + + {t.title} + +

{t[result](reason)}

+ {onOpenPlugins && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/RequestDrawer.tsx b/src/RequestDrawer.tsx index 0e1bdb52..99862897 100644 --- a/src/RequestDrawer.tsx +++ b/src/RequestDrawer.tsx @@ -863,7 +863,7 @@ function PluginRuns({ runs, attempts }: { runs: PluginRunView[]; attempts: Attem {run.error && ( - + )} diff --git a/src/control.ts b/src/control.ts index 6f41d9c8..9f6079cf 100644 --- a/src/control.ts +++ b/src/control.ts @@ -19,9 +19,10 @@ import type { ENDPOINTS, Endpoints } from "./generated/tw-api"; * 界面能直接调的端点。**和 `src-tauri/src/call.rs` 的 `ALLOWED` 是同一份** * (那边的测试核对):不在这里的端点,界面够不着。 * - * 插件的装、换代码、批准改过的文件、确认过的改动(`CreatePlugin`、`ReplacePluginSource`、 - * `ApprovePluginFile`、`UpdatePluginConfirmed`)**有意不在这里**:只能请 Rust 弹系统的确认框 - * (`src/plugins/native.ts`)。 + * 插件要在系统的确认框里点头的三步(`CreatePluginConfirmed`、`SavePluginConfirmed`、 + * `ApprovePluginFileConfirmed`)**有意不在这里**:只能请 Rust 弹系统的确认框 + * (`src/plugins/write.ts`)。不必点头的装、存、批准(`CreatePlugin`、`SavePlugin`、 + * `ApprovePluginFile`)在这里,core 说要点头时答 403。 */ export const WEBVIEW_ENDPOINTS = [ "Interfaces", @@ -96,7 +97,10 @@ export const WEBVIEW_ENDPOINTS = [ "ZaiLoginStatus", "Plugins", "PluginInspect", - "UpdatePlugin", + "PluginRewrite", + "CreatePlugin", + "SavePlugin", + "ApprovePluginFile", "PluginSourceDiff", "DeletePlugin", "ReorderPlugins", diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index 397174cb..baa0634e 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -1,6 +1,6 @@ // Generated from src-tauri/src/wire.rs (`tests/ts_bindings.rs`). Do not edit by hand. -import type { CostBucketGroup, CostGroup, Msg, OnError, PluginScope, PluginUpdate, Protocol, SettingValue } from "./tw-api"; +import type { CostBucketGroup, CostGroup, Msg, Protocol } from "./tw-api"; export type AdoptResponse = { real: string, backup: string, created: boolean, /** @@ -470,33 +470,28 @@ also?: Array, bedrock?: BedrockDraft | null, }; /** - * 批准一个插件改过的文件(`plugin_approve`)。**文件由 Rust 自己去取**:读的、给人看的、 - * 交给 core 认的是同一个 SHA-256 + * 批准一个插件磁盘上改过的文件(`plugin_approve_confirmed`)。**文件由 Rust 自己去取**: + * 读的、给人看的、交给 core 认的是同一个 SHA-256 */ export type PluginApproveRequest = { id: string, base_version?: string | null, }; /** - * 装一个插件(`plugin_install`):代码,和审核窗口里选的。 + * 装一个插件(`plugin_install_confirmed`):代码、ID 和开关,和 `CreatePlugin` 一样。 * - * **没有 manifest**:名字、权限、处理哪几种请求由 Rust 把代码交给 core 再读一遍,网页 - * 说的不算。 + * **没有 manifest**:名字、权限、处理哪几种请求、适用范围由 Rust 把代码交给 core 再读一遍, + * 网页说的不算。出错时怎么办、适用范围、设置都写在代码里 */ export type PluginInstallRequest = { source: string, /** - * 审核窗口里填的 ID。不给由 core 按名字起 + * 添加插件时「设置」页上的插件 ID。不给由 core 按名字起 */ -id?: string | null, enabled: boolean, on_error: OnError, scope: PluginScope, settings: { [key in string]: SettingValue }, base_version?: string | null, }; +id?: string | null, enabled: boolean, base_version?: string | null, }; /** - * 换一个插件的代码(`plugin_replace_source`) + * 保存一个插件(`plugin_save_confirmed`):整份代码和开关,和 `SavePlugin` 一样。这次改 + * 什么由 Rust 和 core 那边确认过的那一份比出来 */ -export type PluginReplaceRequest = { id: string, source: string, base_version?: string | null, }; - -/** - * 一次要点头的改动(`plugin_update_confirmed`):和 `UpdatePlugin` 一样整份交,交上来的 - * 就是保存之后的样子 - */ -export type PluginUpdateRequest = { id: string, update: PluginUpdate, }; +export type PluginSaveRequest = { id: string, source: string, enabled: boolean, base_version?: string | null, }; /** * 写成了(配置的新版本),或者在系统的确认框里点了取消 —— **取消不是失败**,什么都 diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index a6983096..df95d279 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 34; +export const CONTROL_API_VERSION = 35; /** * 一个账号上游登的是哪个账号。 @@ -1770,6 +1770,9 @@ export type LoginStatus = "pending" | "done" | "failed" | "expired" | "cancelled /** * 插件文件里的 manifest,加上它导出了哪些钩子。名字、说明、设置项的 `label` * **都是插件写的字**。 + * + * 出错时怎么办、范围、设置的值**都在文件里**:这里读到的就是这份源码装上之后的样子。 + * 要改它们,用 `POST /plugins/rewrite` 改写源码 */ export type ManifestView = { name: string, description: string | null, permissions: Array, /** @@ -1778,9 +1781,17 @@ export type ManifestView = { name: string, description: string | null, permissio */ requests: Array, /** - * 插件建议的范围。装上时照它填 + * 管哪些请求(manifest 的 `match`) + */ +scope: PluginScope, +/** + * 出错时怎么办(manifest 的 `on_error`)。没写是 `reject` */ -scope: PluginScope, reply_mode: ReplyMode, settings_schema: Array, hooks: PluginHooks, }; +on_error: OnError, reply_mode: ReplyMode, +/** + * 设置项,按插件写的先后,带着此刻的值 + */ +settings_schema: Array, hooks: PluginHooks, }; /** * 一条规则按什么认。**给界面说明用**,界面按类型写成自己的话。 @@ -2065,8 +2076,10 @@ export type PatchValue = string | number | boolean | null; export type Permission = "system" | "messages" | "tools" | "params" | "reply_text" | "reply_tool_calls"; /** - * 批准磁盘上改过的那个文件(`POST /plugins/{id}/approve`)。**网页不能调**,理由同 - * [`PluginCreate`]。 + * 批准磁盘上改过的那个文件(`POST /plugins/{id}/approve`、 + * `POST /plugins/{id}/approve/confirmed`)。插件改得了回答里的工具调用(新旧两份里有一份的 + * 权限有 [`Permission::ReplyToolCalls`],或者读不出旧的那份要什么权限)时,前一条拒绝(403, + * `control.plugin.needs_confirmation`),要在系统的确认框里点过头、走后一条。 */ export type PluginApprove = { /** @@ -2076,20 +2089,18 @@ export type PluginApprove = { sha256: string, base_version?: string | null, }; /** - * 装一个插件(`POST /plugins`)。 + * 装一个插件(`POST /plugins`、`POST /plugins/confirmed`)。出错时怎么办、范围、设置的值 + * 都在源码里(`POST /plugins/rewrite` 改写)。 * - * **网页不能调。**装插件要在系统的确认框里点头,那一步在桌面端的 Rust 里:它自己 - * 再编一遍源码、把名字和权限摆给人看,点了头才发这个请求。 + * 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`])时,`POST /plugins` + * 拒绝(403,`control.plugin.needs_confirmation`),要在系统的确认框里点过头、走 + * `POST /plugins/confirmed`。 */ export type PluginCreate = { source: string, /** * 不给就从名字生成一个 */ -id?: string | null, enabled: boolean, on_error: OnError, scope: PluginScope, -/** - * 没给的取默认值 - */ -settings: { [key in string]: SettingValue }, base_version?: string | null, }; +id?: string | null, enabled: boolean, base_version?: string | null, }; /** * 插件在一个请求的哪一段上跑。 @@ -2164,6 +2175,18 @@ export type PluginOrder = { ids: Array, base_version?: string | null, }; */ export type PluginOutcome = "unchanged" | "changed" | "rejected" | "error" | "skipped"; +/** + * 改写一份源码里的数据(`POST /plugins/rewrite`):出错时怎么办、范围、设置的值。**什么都不 + * 留下**,只交回改写之后的源码([`PluginSource`])—— 只换 manifest 字面量那一段,别的字节 + * 一个不动;字面量里的注释不保留。 + * + * `on_error` 和 `scope` 是改完的样子;`settings` 只改给了的那几个,没给的照旧。插件没声明的 + * 设置(`gw.plugin.setting_unknown`)、类型不对的值(`gw.plugin.setting_type`)、范围里空着 + * 的一项(`control.plugin.blank_pattern`)、不是纯数据的 manifest + * (`gw.plugin.manifest_not_data_at` / `gw.plugin.manifest_not_data`)都是 400。 + */ +export type PluginRewriteRequest = { source: string, on_error: OnError, scope: PluginScope, settings: { [key in string]: SettingValue }, }; + /** * 一个插件在一个请求上的一次运行(详情抽屉的时间线)。 */ @@ -2182,6 +2205,19 @@ attempt: number, outcome: PluginOutcome, */ error: Msg | null, cpu_us: number, }; +/** + * 保存一个插件(`PUT /plugins/{id}`、`PUT /plugins/{id}/confirmed`):源码和开关,**整份交** + * —— 交上来的就是保存之后的样子。出错时怎么办、范围、设置的值都在源码里。 + * + * core 拿它和批准的那一份比:manifest 字面量以外一个字节不差、manifest 里只差出错时怎么办、 + * 范围和设置的值,是**只改了数据**;别的都是**改了代码**。插件改得了回答里的工具调用 + * (新旧两份里有一份的权限有 [`Permission::ReplyToolCalls`],或者读不出旧的那份要什么 + * 权限)时,改代码、打开它在 `PUT /plugins/{id}` 上拒绝(403, + * `control.plugin.needs_confirmation`),要在系统的确认框里点过头、走 `/confirmed` 那一条; + * 只改数据、停用照常。源码和批准的一字不差时只改开关,文件不动。 + */ +export type PluginSave = { source: string, enabled: boolean, base_version?: string | null, }; + /** * 插件管哪些请求。**每张单子里都是 `*` 通配**(不分大小写),空着是「都管」。 */ @@ -2204,11 +2240,6 @@ upstreams: Array, }; */ export type PluginSource = { source: string, }; -/** - * 换一份源码(`PUT /plugins/{id}/source`)。**网页不能调**,理由同 [`PluginCreate`]。 - */ -export type PluginSourceReplace = { source: string, base_version?: string | null, }; - /** * 批准过的那一份和磁盘上现在那一份(`GET /plugins/{id}/source`)。 */ @@ -2291,21 +2322,6 @@ logs: Array, */ error: Msg | null, }; -/** - * 改一个插件的开关、出错时怎么办、范围、设置(`PUT /plugins/{id}`)。**整份交**: - * 交上来的就是保存之后的样子。 - * - * 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`],或者读不出它要 - * 什么权限)时,打开它、改设置、改范围这条路不收(`control.plugin.needs_confirmation`), - * 同一份请求体交给 `PUT /plugins/{id}/confirmed`:那个端点网页调不了,桌面端在系统的 - * 确认框里点了头才发。比的是生效的值:没写进配置的设置按默认值算,范围不看顺序。 - */ -export type PluginUpdate = { enabled: boolean, on_error: OnError, scope: PluginScope, -/** - * 没给的取默认值 - */ -settings: { [key in string]: SettingValue }, base_version?: string | null, }; - /** * 一个装上了的插件(`GET /plugins`),按运行的顺序。 */ @@ -2317,7 +2333,12 @@ name: string, /** * 插件写的字 */ -description: string | null, enabled: boolean, on_error: OnError, +description: string | null, enabled: boolean, +/** + * 出错时怎么办:插件文件里写的。读不出 manifest 时照批准的那份文件里写着的,再读不出 + * 是 `reject` + */ +on_error: OnError, /** * 读不出 manifest 时是空的 */ @@ -2328,13 +2349,14 @@ permissions: Array, */ requests: Array, /** - * 生效的范围(配置里的) + * 管哪些请求:插件文件里写的(同 `on_error`,读不出时是空的 —— 都管) */ -scope: PluginScope, reply_mode: ReplyMode, settings_schema: Array, +scope: PluginScope, reply_mode: ReplyMode, /** - * 交给插件的值:配置里写的,没写的是默认值 + * 设置项,按插件写的先后,带着交给插件的值([`SettingSpecView::value`])。读不出 + * manifest 时是空的 */ -settings: { [key in string]: SettingValue }, +settings_schema: Array, /** * 批准过的那一份的 SHA-256,小写十六进制 */ @@ -3784,13 +3806,14 @@ field: SetField, value: string, }; export type SettingKind = "string" | "number" | "boolean"; /** - * 插件声明的一个设置项。`label` 是**插件写的字**:界面当纯文本显示。 + * 插件声明的一个设置项,连同它此刻的值。`label` 是**插件写的字**:界面当纯文本显示。 */ export type SettingSpecView = { key: string, kind: SettingKind, label: string, /** - * 和 `kind` 同一种类型 + * 此刻的值:插件文件的 manifest 里 `value` 写的,没写是这种类型的空值(`""`、`0`、 + * `false`)。和 `kind` 同一种类型 */ -default: SettingValue, }; +value: SettingValue, }; /** * 一个设置的值:字符串、数字或 true/false。 @@ -4378,14 +4401,16 @@ export const ENDPOINTS = { TestSecurity: { method: "POST", path: "/security/{guard}/test", params: ["guard"], format: "json" }, Plugins: { method: "GET", path: "/plugins", params: [], format: "json" }, PluginInspect: { method: "POST", path: "/plugins/inspect", params: [], format: "json" }, + PluginRewrite: { method: "POST", path: "/plugins/rewrite", params: [], format: "json" }, CreatePlugin: { method: "POST", path: "/plugins", params: [], format: "json" }, + CreatePluginConfirmed: { method: "POST", path: "/plugins/confirmed", params: [], format: "json" }, ReorderPlugins: { method: "PUT", path: "/plugins/order", params: [], format: "json" }, - UpdatePlugin: { method: "PUT", path: "/plugins/{id}", params: ["id"], format: "json" }, - UpdatePluginConfirmed: { method: "PUT", path: "/plugins/{id}/confirmed", params: ["id"], format: "json" }, + SavePlugin: { method: "PUT", path: "/plugins/{id}", params: ["id"], format: "json" }, + SavePluginConfirmed: { method: "PUT", path: "/plugins/{id}/confirmed", params: ["id"], format: "json" }, DeletePlugin: { method: "DELETE", path: "/plugins/{id}", params: ["id"], format: "json" }, - ReplacePluginSource: { method: "PUT", path: "/plugins/{id}/source", params: ["id"], format: "json" }, PluginSourceDiff: { method: "GET", path: "/plugins/{id}/source", params: ["id"], format: "json" }, ApprovePluginFile: { method: "POST", path: "/plugins/{id}/approve", params: ["id"], format: "json" }, + ApprovePluginFileConfirmed: { method: "POST", path: "/plugins/{id}/approve/confirmed", params: ["id"], format: "json" }, TrialPlugin: { method: "POST", path: "/plugins/{id}/trial", params: ["id"], format: "json" }, PluginLogs: { method: "GET", path: "/plugins/{id}/logs", params: ["id"], format: "json" }, StartChatgptLogin: { method: "POST", path: "/chatgpt/login", params: [], format: "json" }, @@ -4489,14 +4514,16 @@ export type Endpoints = { TestSecurity: { req: SecurityTestRequest; res: SecurityTestResult }; Plugins: { req: null; res: Array }; PluginInspect: { req: PluginSource; res: PluginInspection }; + PluginRewrite: { req: PluginRewriteRequest; res: PluginSource }; CreatePlugin: { req: PluginCreate; res: ConfigWritten }; + CreatePluginConfirmed: { req: PluginCreate; res: ConfigWritten }; ReorderPlugins: { req: PluginOrder; res: ConfigWritten }; - UpdatePlugin: { req: PluginUpdate; res: ConfigWritten }; - UpdatePluginConfirmed: { req: PluginUpdate; res: ConfigWritten }; + SavePlugin: { req: PluginSave; res: ConfigWritten }; + SavePluginConfirmed: { req: PluginSave; res: ConfigWritten }; DeletePlugin: { req: BaseVersion; res: ConfigWritten }; - ReplacePluginSource: { req: PluginSourceReplace; res: ConfigWritten }; PluginSourceDiff: { req: null; res: PluginSourceView }; ApprovePluginFile: { req: PluginApprove; res: ConfigWritten }; + ApprovePluginFileConfirmed: { req: PluginApprove; res: ConfigWritten }; TrialPlugin: { req: PluginTrial; res: PluginTrialResult }; PluginLogs: { req: null; res: Array }; StartChatgptLogin: { req: ChatgptLoginStart; res: ChatgptLogin }; diff --git a/src/i18n/core.i18n.ts b/src/i18n/core.i18n.ts index b3d3540c..9932c168 100644 --- a/src/i18n/core.i18n.ts +++ b/src/i18n/core.i18n.ts @@ -1,4 +1,5 @@ import type { Msg } from "@/types"; +import { localizedPluginNames, pluginName } from "@/plugins/defaults"; import { getLang } from "./index"; import CORE_ZH from "./core.zh.json"; import { render, wordsOf, type Args, type Tables } from "./template"; @@ -75,24 +76,29 @@ export function plain(text: string): Msg { * 一条 core 消息在界面上怎么说。 * * 传字符串进来也行:控制面之外的错误(连不上 socket 之类)本来就是 - * 一句现成的话。 + * 一句现成的话。`pluginId`:知道这句话说的是哪个插件时给(见 `zhOf`)。 */ -export function coreText(m: Msg | string | null | undefined): string { +export function coreText(m: Msg | string | null | undefined, pluginId?: string | null): string { if (m == null) return ""; if (typeof m === "string") return m; if (getLang() === "en") return m.text; - return zhOf(m) ?? m.text; + return zhOf(m, pluginId) ?? m.text; } /** * 按码说中文,连同外面套的场合。**说不出来是 `undefined`**:码不在表里,或者 * 缺参数、词表里查不到。少一个参数时不能写出半句中文或一个「undefined」—— * core 改了参数名而这张表还没跟上时,一句完整的英文比一句缺了主语的中文好。 + * + * **插件名按界面说**:`{plugin}` 是默认插件 core 发的英文名时,换成它的中文名(`pluginName`, + * 插件页上也叫这个名字)。知道是哪个插件的(`pluginId`)按 id 认,不知道的按名字认。 */ -export function zhOf(m: Msg): string | undefined { +export function zhOf(m: Msg, pluginId?: string | null): string | undefined { const say = MESSAGES[m.code]; if (!say || m.code.startsWith("//")) return undefined; const args: Args = m.args ?? {}; + // 填进中文句子的参数。认场合比的是英文原句,那边照旧用原样的参数 + const zhArgs: Args = args.plugin === undefined ? args : { ...args, plugin: pluginName(pluginId, args.plugin, "zh") }; const leads: string[] = []; for (let rest = m.text; ; ) { let hit: { zh: string; en: string } | undefined; @@ -100,7 +106,7 @@ export function zhOf(m: Msg): string | undefined { if (args[c.arg] === undefined) continue; const en = render(c.en, args, CORE_TABLES); if (en === undefined || !rest.startsWith(`${en}: `)) continue; - const zh = render(c.zh, args, CORE_TABLES); + const zh = render(c.zh, zhArgs, CORE_TABLES); if (zh === undefined) return undefined; hit = { zh, en }; break; @@ -109,7 +115,7 @@ export function zhOf(m: Msg): string | undefined { leads.push(`${hit.zh}:`); rest = rest.slice(hit.en.length + 2); } - const zh = render(say, args, CORE_TABLES); + const zh = render(say, zhArgs, CORE_TABLES); return zh === undefined ? undefined : leads.join("") + zh; } @@ -122,14 +128,19 @@ export function zhOf(m: Msg): string | undefined { * 英文原句里本来就有,core 按 `q` 对原句就对上了。**宁可多给**:多给的那几条,界面拿到 * 之后照着屏幕上的那句话再筛一遍(见 `traffic/historySearch.ts`)。 * + * **默认插件的中文名也算**:句子里的插件名换成了它(见 `zhOf`),英文原句里却是 core 发的 + * 英文名。搜的是其中一个中文名的,句子里有插件名的码都交过去。 + * * 英文界面上看到的就是原句,交空的。`q` 已经去掉首尾空白、转了小写。 */ export function codesMatching(q: string): string[] { if (!q || getLang() === "en") return []; + const pluginNamed = localizedPluginNames("zh").some((n) => n.toLowerCase().includes(q)); const out: string[] = []; for (const [code, say] of Object.entries(MESSAGES)) { if (code.startsWith("//")) continue; - if (wordsOf(say, CORE_TABLES).some((w) => w.toLowerCase().includes(q))) out.push(code); + if (wordsOf(say, CORE_TABLES).some((w) => w.toLowerCase().includes(q)) || (pluginNamed && say.includes("{plugin}"))) + out.push(code); } return out; } @@ -140,9 +151,10 @@ export function codesMatching(q: string): string[] { * **命令失败时交出来的是一条 `Msg` 形状的对象**(src-tauri 的 `CmdError`): * 控制面的失败带着 core 的码,按码翻;桌面端自己的失败码是空串,照 `text` * 显示。Tauri 自己拒掉的调用(命令不存在、没有权限)仍然是一句字符串。 + * `pluginId` 同 `coreText`。 */ -export function errorText(e: unknown): string { - if (isMsg(e)) return coreText(e); +export function errorText(e: unknown, pluginId?: string | null): string { + if (isMsg(e)) return coreText(e, pluginId); if (typeof e === "string") return e; if (e instanceof Error) return e.message; return String(e); diff --git a/src/i18n/core.test.ts b/src/i18n/core.test.ts index c9cde125..b6df7942 100644 --- a/src/i18n/core.test.ts +++ b/src/i18n/core.test.ts @@ -136,6 +136,39 @@ describe("core 的错误:码加参数", () => { }); }); +describe("消息里的插件名", () => { + const refusal = (plugin: string) => ({ + code: "control.plugin.needs_confirmation", + args: { plugin }, + text: `Plugin \`${plugin}\` can change the tool calls in replies, so …`, + }); + const WSL = "Convert WSL and Windows paths"; + + it("默认插件 core 发的英文名,中文界面说插件页上的名字", () => { + expect(inLang("zh", () => coreText(refusal(WSL)))).toMatch(/^插件「WSL 路径转换」可以修改回答中的工具调用/); + expect(inLang("zh", () => errorText(refusal(WSL)))).toMatch(/^插件「WSL 路径转换」/); + // 英文界面照 core 的原句 + expect(inLang("en", () => coreText(refusal(WSL)))).toBe(refusal(WSL).text); + }); + + it("只有名字时一字不差才换;别人写的插件照它自己写的", () => { + for (const name of ["convert wsl and windows paths", `${WSL} `, "Rename tools"]) { + expect(inLang("zh", () => coreText(refusal(name)))).toMatch(new RegExp(`^插件「${name}」`)); + } + }); + + it("知道是哪个插件的按 id 认:用了默认插件英文名的别的插件照它自己写的", () => { + expect(inLang("zh", () => coreText(refusal(WSL), "wsl-paths"))).toMatch(/^插件「WSL 路径转换」/); + expect(inLang("zh", () => coreText(refusal(WSL), "my-paths"))).toMatch(/^插件「Convert WSL and Windows paths」/); + expect(inLang("zh", () => errorText(refusal(WSL), "my-paths"))).toMatch(/^插件「Convert WSL and Windows paths」/); + }); + + it("参数是 ID 的那几句照原样", () => { + const m = { code: "control.plugin.id_taken", args: { plugin: "wsl-paths" }, text: "There is already a plugin `wsl-paths`." }; + expect(inLang("zh", () => coreText(m, "wsl-paths"))).toBe("已存在 ID 为「wsl-paths」的插件。"); + }); +}); + describe("扫描发现:句子由词拼出来", () => { it("按 kind 和 rule 拼出中文", () => { const m = { diff --git a/src/i18n/core.zh.cases.json b/src/i18n/core.zh.cases.json index 8a70af85..4cca53d3 100644 --- a/src/i18n/core.zh.cases.json +++ b/src/i18n/core.zh.cases.json @@ -260,5 +260,46 @@ "text": "Plugin `统一用词` was not started for this answer: the limit of 8 plugins running on answers at the same time was reached." }, "zh": "插件「统一用词」未处理此回答:同时处理回答的插件已达上限(8 个)。" + }, + { + "msg": { + "code": "gw.plugin.rejected", + "args": { + "plugin": "Convert WSL and Windows paths", + "reason": "the path is outside the workspace" + }, + "text": "Plugin `Convert WSL and Windows paths` refused this request: the path is outside the workspace" + }, + "zh": "插件「WSL 路径转换」拒绝了此请求:the path is outside the workspace" + }, + { + "msg": { + "code": "control.plugin.needs_confirmation", + "args": { + "plugin": "Answer in a chosen language" + }, + "text": "Plugin `Answer in a chosen language` can change the tool calls in replies, so installing it, turning it on, changing its code or approving a change to its file has to be confirmed in the app." + }, + "zh": "插件「指定回答语言」可以修改回答中的工具调用,因此安装、启用此插件,修改其代码或确认其文件的更改,都需要在应用中确认。" + }, + { + "msg": { + "code": "gw.plugin.file_changed", + "args": { + "plugin": "convert wsl and windows paths" + }, + "text": "The file of plugin `convert wsl and windows paths` changed on disk, so it no longer runs. Review the change and approve it in the app." + }, + "zh": "插件「convert wsl and windows paths」的文件已更改,此插件不再运行。请在应用中审核并确认更改。" + }, + { + "msg": { + "code": "control.plugin.not_found", + "args": { + "plugin": "wsl-paths" + }, + "text": "There is no plugin `wsl-paths`." + }, + "zh": "插件「wsl-paths」不存在。" } ] diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index cb3f8934..fc8d322e 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -330,6 +330,8 @@ "gw.plugin.syntax": "插件有语法错误:{detail}", "gw.plugin.syntax_at": "插件第 {line} 行第 {column} 列有语法错误:{detail}", "gw.plugin.manifest": "插件清单无效:{detail}", + "gw.plugin.manifest_not_data": "插件清单不是纯数据:{detail}", + "gw.plugin.manifest_not_data_at": "插件清单第 {line} 行第 {column} 列不是纯数据:{detail}", "gw.plugin.api": "此插件按插件 API {api} 编写,目前只支持 API 1。", "gw.plugin.engine": "插件引擎无法加载插件:{detail}", "gw.plugin.unreadable": "无法读取插件文件 {file}:{detail}", @@ -550,7 +552,7 @@ "control.plugin.blank_pattern": "适用范围中有空白项。请删除该项,或填写名称或带 * 的通配。", "control.plugin.unreadable": "无法读取插件文件 {file}:{detail}", "control.plugin.write_failed": "无法写入 {path}:{detail}", - "control.plugin.needs_confirmation": "启用插件「{plugin}」或修改其设置、适用范围,需要在应用中确认:此插件可以修改回答中的工具调用。", + "control.plugin.needs_confirmation": "插件「{plugin}」可以修改回答中的工具调用,因此安装、启用此插件,修改其代码或确认其文件的更改,都需要在应用中确认。", "control.plugin.file_missing": "插件「{plugin}」的文件已不存在,没有可确认的更改。请更换代码,或删除此插件。", "control.plugin.file_moved_on": "插件「{plugin}」的文件在审核之后再次被改动,请重新审核。", "control.plugin.order": "新的顺序须包含每个插件,且每个只出现一次。", @@ -602,8 +604,6 @@ "config.plugin.duplicate": "插件 ID「{plugin}」重复。", "config.plugin.file": "插件「{plugin}」的文件为 {file},应为 plugins/{plugin}.js。", "config.plugin.sha256": "插件「{plugin}」的 sha256 应为 64 位小写十六进制字符。", - "config.plugin.blank_pattern": "插件「{plugin}」的适用范围中有空白项。", - "config.plugin.setting_type": "插件「{plugin}」的设置项 {key} 只能是字符串、数字或 true/false。", "config.store.read_failed": "无法读取 {path}:{detail}", "config.store.missing": "{path} 不存在。", "config.store.conflict": "配置文件在此期间已被修改(当前版本 {current},本次修改基于 {expected}),未覆盖。请查看当前内容后重试。", diff --git a/src/labels.ts b/src/labels.ts index 69d666e3..ad588271 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -265,3 +265,6 @@ const APPS: Record = { export function appLabel(hint: string): string { return APPS[hint] ?? hint; } + +/** 认得出名字的那几个应用(插件适用范围里给的建议) */ +export const KNOWN_APPS: readonly string[] = Object.keys(APPS); diff --git a/src/plugins/ChangedDialog.i18n.tsx b/src/plugins/ChangedDialog.i18n.tsx index 3b91f7ad..3f850b7b 100644 --- a/src/plugins/ChangedDialog.i18n.tsx +++ b/src/plugins/ChangedDialog.i18n.tsx @@ -13,10 +13,10 @@ export const changedDialogText = messages( cannotLoad: "更改后的代码无法加载", at: (where: string) => `位置:${where}`, missing: "插件文件已不存在或无法读取。", - missingHint: "可以更换为新的代码,或删除此插件。", + missingHint: "可以编辑代码后保存,或删除此插件。", loadFailed: "文件内容读取失败", approve: "确认更改", - replace: "更换代码…", + edit: "编辑代码…", cancelled: "已取消,配置未改动。", }, { @@ -30,10 +30,10 @@ export const changedDialogText = messages( cannotLoad: "The changed code cannot be loaded", at: (where: string) => `At ${where}.`, missing: "The plugin file no longer exists or cannot be read.", - missingHint: "Replace it with new code, or delete the plugin.", + missingHint: "Edit and save the code, or delete the plugin.", loadFailed: "The file could not be read", approve: "Approve changes", - replace: "Replace code…", + edit: "Edit code…", cancelled: "Cancelled. The configuration was not changed.", }, ); diff --git a/src/plugins/ChangedDialog.tsx b/src/plugins/ChangedDialog.tsx index a849a0fc..512eaee3 100644 --- a/src/plugins/ChangedDialog.tsx +++ b/src/plugins/ChangedDialog.tsx @@ -16,32 +16,33 @@ import type { PluginView } from "@/types"; import { changedDialogText } from "./ChangedDialog.i18n"; import { pluginName } from "./defaults"; import { shaPrefix } from "./model"; -import { approvePluginFile } from "./native"; -import { CodeBox, PermissionList, PluginText, RequestKinds, SourceDiff } from "./parts"; +import { CodeBox, codeErrorOf, PermissionList, PluginText, RequestKinds, SourceDiff } from "./parts"; import { pluginPartsText } from "./parts.i18n"; -import type { NativeWrite } from "./SourceDialog"; +import { approvePlugin, type NativeWrite } from "./write"; /** * 插件文件在确认之后被改过(状态「文件已更改」):看清改了什么,再确认。 * * 给人看的三样:**和确认过的那一份逐行对比**(也可以看全文);**这一版申请的权限**,比原来 - * 多要的标成「新增」;两个 SHA-256。确认由 Rust 去做(`plugin_approve`):它自己再取一次 - * 文件、再读一遍,在系统原生对话框里写明插件名、权限和新的 SHA-256,点了才算数(I12)。 + * 多要的标成「新增」;两个 SHA-256。改得了回答里工具调用的插件(原来的或者改过的文件里), + * 确认要在系统的确认框里点头(Rust 自己再取一次文件、再读一遍,写明插件名、权限和新的 + * SHA-256);别的插件按一下就确认了(`write.ts`)。 * - * 文件没了、或者改坏了读不了的,确认不了:给「更换代码」。 + * 文件没了、或者改坏了读不了的,确认不了:给「编辑代码」,在编辑器里改好再保存。 */ export function ChangedDialog({ plugin, native, onClose, onApproved, - onReplace, + onEdit, }: { plugin: PluginView; native: NativeWrite; onClose: () => void; onApproved: () => void; - onReplace: () => void; + /** 打开编辑器改代码:`current` 是改磁盘上那一份(读不了的),`approved` 是从确认过的那一份起头(文件没了) */ + onEdit: (from: "approved" | "current") => void; }) { const t = useText(changedDialogText); const pt = useText(pluginPartsText); @@ -65,11 +66,13 @@ export function ChangedDialog({ setError(null); setCancelled(false); try { - const r = await native((base) => approvePluginFile({ id: plugin.id, base_version: base })); + const sha = diff.data?.current_sha256; + if (!sha) return; + const r = await native((base) => approvePlugin(plugin, sha, manifest, base)); if (r === "done") onApproved(); else setCancelled(true); } catch (e) { - setError(errorText(e)); + setError(errorText(e, plugin.id)); } finally { setWriting(false); } @@ -115,7 +118,7 @@ export function ChangedDialog({ {loadError && (

- +

{loadError.line != null &&

{t.at(pt.errorAt(loadError.line, loadError.column ?? null))}

}
@@ -133,7 +136,7 @@ export function ChangedDialog({ )} - {read.error !== undefined && !read.loading && !read.data && } + {read.error !== undefined && !read.loading && !read.data && }
@@ -151,7 +154,7 @@ export function ChangedDialog({ {view === "changes" ? ( ) : ( - + )}
@@ -163,8 +166,13 @@ export function ChangedDialog({ {(current == null || loadError != null) && diff.data !== undefined && ( - )} + + + + )} + + + !o && setAsking(null)}> + + + {t.discardTitle} + {isNew ? t.discardNewDescription : t.discardDescription} + + + {t.keepEditing} + { + const then = asking; + setAsking(null); + then?.(); + }} + > + {t.discard} + + + + + + ); +} + +/** 装着的插件叫什么:默认插件按界面语言说;读不出 manifest 的只有 id */ +function InstalledName({ plugin }: { plugin: PluginView }) { + return manifestUnknown(plugin) ? ( + {plugin.id} + ) : ( + + ); +} + +/** 标题和下面一行:ID、SHA-256、权限、处理的请求种类,和贴着底边的两个标签 */ +function Header({ title, meta, tabs }: { title: ReactNode; meta?: ReactNode; tabs?: ReactNode }) { + return ( + + {title} + +
{meta}
+
+ {/* 左移到标签的字和标题对齐:列表有 3px 内边距、每个标签左右 6px */} + {tabs &&
{tabs}
} +
+ ); +} + +/** 一段代码的 SHA-256(UTF-8),十六进制。core 读同一份代码得到的是同一串:系统的确认框里写的就是它 */ +function useSha256(text: string): string | null { + const [sha, setSha] = useState<{ text: string; hex: string } | null>(null); + useEffect(() => { + let live = true; + void crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)).then((buf) => { + if (!live) return; + setSha({ text, hex: [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("") }); + }); + return () => { + live = false; + }; + }, [text]); + return sha?.text === text ? sha.hex : null; +} + +/** + * 取到代码之后的编辑器。**编辑和添加是同一个**,只差在几处: + * + * - 标题:装着的插件写它现在的名字;新插件写代码里的名字(照它自己写的),跟着代码变。 + * - 新插件的「设置」页多一个插件 ID(`idProblem` 查写法、保留词、重名);启用默认关着。 + * - 按钮:「保存」(`SavePlugin`,有改动才能按)和「安装」(`CreatePlugin`)。 + * + * 导出给测试用。 + */ +export function Editing({ + target, + loaded, + initialTab, + ov, + native, + onState, + onCancel, + onDone, + onReview, +}: { + target: Target; + loaded: Loaded; + initialTab: EditorTab; + ov: Overview; + native: NativeWrite; + onState: (s: { dirty: boolean; busy: boolean }) => void; + onCancel: () => void; + onDone: (id: string) => void; + onReview?: () => void; +}) { + const t = useText(editorText); + const lt = useText(pluginLabelsText); + const ft = useText(pluginFieldsText); + const common = useText(commonText); + const plugin = target.kind === "edit" ? target.plugin : null; + const taken = target.kind === "new" ? target.taken : null; + const ed = useEditing(plugin?.id ?? null, loaded.start); + const [tab, setTab] = useState( + // 代码读不了、也没有可以填的表单:直接看代码 + initialTab === "settings" && !loaded.start.inspection.manifest ? "code" : initialTab, + ); + const [enabled, setEnabled] = useState(plugin?.enabled ?? false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [cancelled, setCancelled] = useState(false); + const suggestions = useScopeSuggestions(ov); + /** 新插件自己改过的 ID。没改过(`null`)就跟着建议走 */ + const [idDraft, setIdDraft] = useState(null); + /** 新插件最近一次从哪个文件导入的:建议的 ID 先按文件名 */ + const [fileName, setFileName] = useState(null); + + const m = ed.manifest; + const idOf = (name: string) => plugin?.id ?? idDraft ?? suggestId(name, fileName, taken ?? []); + const id = idOf(m?.name ?? ""); + const badId = taken ? idProblem(id, taken) : null; + const dirty = ed.source !== loaded.start.source || enabled !== (plugin?.enabled ?? false) || idDraft !== null; + const problem = ed.form ? valuesOf(ed.form).problem : null; + const problemText = problem + ? problem.kind === "scope" + ? ft.needOne(lt.scopeParts[problem.part]) + : ft.numberBad(problem.label) + : badId + ? t.idProblems[badId] + : null; + useEffect(() => onState({ dirty, busy: saving }), [dirty, saving, onState]); + + const switchTab = (next: EditorTab) => { + // 切过去之前把这一页的改动交出去:另一页看到的是一致的样子 + void ed.flush(); + setTab(next); + }; + + async function commit() { + setSaving(true); + setError(null); + setCancelled(false); + /** 写的是哪一个插件 */ + let wrote = id; + try { + await ed.flush(); + // 刚打的字可能刚读完:按「现在」的判断,不按点下按钮那一刻渲染的样子 + const { source, manifest: next, error: broken } = ed.now(); + if (broken || !next) return; + let r: "done" | "cancelled"; + if (plugin) { + const old = loaded.approvedManifest; + const asks = saveAsks({ + old: old?.permissions ?? (plugin.permissions.length > 0 ? plugin.permissions : null), + next: next.permissions, + turningOn: enabled && !plugin.enabled, + // 一定改了代码:manifest 里数据以外的东西变了,或者确认过的那一份读不了而代码不一样。 + // 别的(manifest 以外改了没有)交给 core 判断,它说要点头再请 Rust + codeChanged: source !== loaded.approved && (old == null || shapeChanged(old, next)), + }); + r = await native((base) => savePlugin({ id: plugin.id, source, enabled, base_version: base }, asks)); + } else { + // 代码里的名字可能刚变:跟着建议走的 ID 按现在的名字给 + wrote = idOf(next.name); + if (idProblem(wrote, taken ?? [])) return; + // 改得了工具调用的直接请 Rust(一个系统的确认框),别的直接装上 + const newId = wrote; + r = await native((base) => installPlugin({ source, id: newId, enabled, base_version: base }, next)); + } + if (r === "cancelled") setCancelled(true); + else onDone(wrote); + } catch (e) { + setError(errorText(e, wrote)); + } finally { + setSaving(false); + } + } + + const sha = useSha256(ed.source); + const meta = ( + <> + {id && {t.id(id)}} + {sha && {t.sha(shaPrefix(sha))}} + {m && ( + + )} + {m && } + + ); + // 新插件的名字照代码里写的,跟着代码变(还没读出来时说这是在添加插件) + const title = plugin ? : m ? : t.newTitle; + + return ( + switchTab(v as EditorTab)} className="flex min-h-0 flex-1 flex-col gap-0"> +
+ {t.tabSettings} + + {t.tabCode} + {ed.error && } + + + } + /> + +
+ + switchTab("code")} + onReview={onReview} + /> + + + ed.editCode(s)} + onImport={(s, name) => { + ed.editCode(s, true); + setFileName(name); + }} + error={ed.error} + checking={ed.checking} + /> + +
+ +
+ + {cancelled &&

{t.cancelled}

} +
+ + + {problemText ? ( + {problemText} + ) : ( + plugin && dirty && {t.unsaved} + )} + + + + + ); +} + +/** 两页叠在同一块里,没选中的藏起来(不卸载:代码的撤销记录、滚动位置都留着) */ +const PANE = "absolute inset-0 data-[state=inactive]:hidden"; + +function SettingsPane({ + switchId, + idField, + form, + onForm, + codeError, + diskChanged, + enabled, + onEnabled, + suggestions, + onViewCode, + onReview, +}: { + /** 启用开关的 id(标签用) */ + switchId: string; + /** 新插件的 ID:输入框里的值、哪里不对、改了 */ + idField: { value: string; problem: string | null; onChange: (id: string) => void } | null; + form: Form | null; + onForm: (f: Form) => void; + codeError: boolean; + diskChanged: boolean; + enabled: boolean; + onEnabled: (on: boolean) => void; + suggestions: ReturnType; + onViewCode: () => void; + onReview?: () => void; +}) { + const t = useText(editorText); + const lt = useText(pluginLabelsText); + const ft = useText(pluginFieldsText); + // 横幅钉在上面、不跟着表单滚走:表单滚到底时代码出了错,也一眼看得见 + const banners = (diskChanged || codeError) && ( +
+ {diskChanged && ( + + {t.reviewChanges} + + ) + } + > + {t.diskChangedHint} + + )} + {codeError && ( + + {t.viewCode} + + } + > + {form ? t.codeErrorHint : t.codeErrorNoForm} + + )} +
+ ); + return ( + <> + {banners} +
+ {idField && ( + + idField.onChange(e.target.value.trim())} + /> + + )} + +
+
+ +

{t.enabledHint}

+
+ +
+ + {form && ( + // 代码读不了时停在上一次读得了的样子,不能改:改了也写不回读不了的代码 +
+ onForm({ ...form, onError })} /> +
+

{lt.scope}

+ onForm({ ...form, scope })} suggestions={suggestions} /> +
+ {form.schema.length > 0 && ( +
+

{ft.settings}

+ onForm({ ...form, settings })} + /> +
+ )} +
+ )} +
+ + ); +} + +function CodePane({ + source, + onChange, + onImport, + error, + checking, +}: { + source: string; + onChange: (s: string) => void; + /** 从文件导入的整份代码,和文件名 */ + onImport: (s: string, fileName: string) => void; + error: ReturnType["error"]; + checking: boolean; +}) { + const t = useText(editorText); + const pt = useText(pluginPartsText); + const picker = useRef(null); + const [importError, setImportError] = useState(null); + const [jump, setJump] = useState<{ line: number; column: number | null; at: number } | null>(null); + const codeError = useMemo(() => codeErrorOf(error), [error]); + const lines = useMemo(() => source.split("\n").length, [source]); + // 读一遍通常只要几毫秒:慢了才说「正在检查」,免得每停一下就闪一下 + const [slow, setSlow] = useState(false); + useEffect(() => { + if (!checking) { + setSlow(false); + return; + } + const h = setTimeout(() => setSlow(true), 300); + return () => clearTimeout(h); + }, [checking]); + + async function choose(f: File | undefined) { + setImportError(null); + if (!f) return; + if (f.size > MAX_SOURCE) { + setImportError(t.tooLarge(size(f.size))); + return; + } + try { + onImport(await f.text(), f.name); + } catch { + setImportError(t.readFailed); + } + } + + return ( + <> +
+ + {error ? ( + + + {t.cannotLoad} + {error.line != null && ( + + )} + + ) : ( + + {pt.lines(lines)} · {size(new Blob([source]).size)} + + )} + {slow && {t.checking}} + + + { + void choose(e.target.files?.[0]); + // 同一个文件再选一次也要触发 + e.target.value = ""; + }} + /> +
+ {importError &&

{importError}

} + {/* 报错没有行号(清单不对之类):写在代码上面;有行号的写在那一行下面 */} + {error && error.line == null && ( + + + + + + )} +
+ }> + + +
+ + ); +} diff --git a/src/plugins/ListDialogs.tsx b/src/plugins/ListDialogs.tsx index aa3b15b9..953611fb 100644 --- a/src/plugins/ListDialogs.tsx +++ b/src/plugins/ListDialogs.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon } from "lucide-react"; import { AlertDialog, AlertDialogCancel, @@ -12,10 +12,12 @@ import { import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { errorText } from "@/i18n/core.i18n"; import { ConfirmAction, focusSelf } from "@/keys/parts"; +import { useReorder } from "@/routing/useReorder"; import { DialogError } from "@/upstreams/parts"; import type { PluginView } from "@/types"; import { pluginName } from "./defaults"; @@ -64,7 +66,7 @@ export function DeleteDialog({ {t.deleteDescription} - {error !== null && errorText(error)} + {error !== null && errorText(error, target.id)} {common.cancel} @@ -78,8 +80,9 @@ export function DeleteDialog({ } /** - * 调整运行顺序。**顺序有意义**:前一个插件改过的内容交给后一个。上下移好了一次保存,写成 - * 一个配置版本(`ReorderPlugins`),不是每挪一下写一次。 + * 调整运行顺序。**顺序有意义**:前一个插件改过的内容交给后一个。拖动一行(按住左边的把手, + * 或者整行的空白处)放到别处,也可以用每行右边的上移、下移(键盘用这两个)。排好了一次保存, + * 写成一个配置版本(`ReorderPlugins`),不是每挪一下写一次。 */ export function ReorderDialog({ list, @@ -97,15 +100,27 @@ export function ReorderDialog({ const [error, setError] = useState(null); const byId = new Map(list.map((p) => [p.id, p])); const dirty = order.some((id, i) => id !== list[i]?.id); + const reorder = useReorder((from, to) => setOrder((o) => moved(o, from, to))); + /** 按钮挪到头之后自己失效了:焦点交给同一行的另一个按钮,键盘接着能按 */ + const [refocus, setRefocus] = useState<{ id: string; dir: "up" | "down" } | null>(null); + const list$ = useRef(null); + useEffect(() => { + if (!refocus) return; + list$.current + ?.querySelector(`[data-row="${CSS.escape(refocus.id)}"] [data-move="${refocus.dir}"]`) + ?.focus(); + setRefocus(null); + }, [refocus]); - const move = (i: number, d: -1 | 1) => - setOrder((o) => { - const j = i + d; - if (j < 0 || j >= o.length) return o; - const next = [...o]; - [next[i], next[j]] = [next[j]!, next[i]!]; - return next; - }); + const move = (i: number, d: -1 | 1) => { + const j = i + d; + if (j < 0 || j >= order.length) return; + const id = order[i]!; + setOrder((o) => moved(o, i, j)); + // 挪到了头(或尾):这个方向的按钮失效,焦点换到另一个 + if (j === 0) setRefocus({ id, dir: "down" }); + else if (j === order.length - 1) setRefocus({ id, dir: "up" }); + }; async function save() { setSaving(true); @@ -125,19 +140,39 @@ export function ReorderDialog({ {t.reorderTitle} {t.reorderDescription} -
    +
      {order.map((id, i) => { const p = byId.get(id); if (!p) return null; + const mark = reorder.marker(i, order.length); return ( -
    1. - {i + 1} - - +
    2. + {/* 把手和名字这一段都能拖;右边的按钮不在里面,点它们不会开始拖动 */} + + + {i + 1} + + + } > {p.status.kind === "error" && (

      - +

      )}

      {reject ? t.failedRejecting : t.failedSkipping}

      @@ -396,7 +397,7 @@ function Stopped({ ); } -type RowAction = "settings" | "trial" | "logs" | "review" | "replace" | "delete"; +type RowAction = "settings" | "code" | "trial" | "logs" | "review" | "delete"; /** * 插件列表,**按运行的顺序**:前一个改过的内容交给后一个,所以行首写着它是第几个。 @@ -427,9 +428,9 @@ function PluginList({ const menu = (p: PluginView): MenuItems => [ ...(p.status.kind === "changed" ? [{ kind: "item" as const, label: t.menu.review, onSelect: () => onOpen("review", p) }] : []), { kind: "item", label: t.menu.settings, onSelect: () => onOpen("settings", p) }, + { kind: "item", label: t.menu.code, onSelect: () => onOpen("code", p) }, { kind: "item", label: t.menu.trial, onSelect: () => onOpen("trial", p), disabled: p.status.kind === "error" }, { kind: "item", label: t.menu.logs, onSelect: () => onOpen("logs", p) }, - { kind: "item", label: t.menu.replace, onSelect: () => onOpen("replace", p) }, { kind: "sep" }, { kind: "item", label: t.menu.remove, onSelect: () => onOpen("delete", p), danger: true }, ]; @@ -450,7 +451,8 @@ function PluginList({ highlight === p.id && "bg-muted/60", rowMotion(presence), )} - {...openable(() => onOpen("settings", p))} + // 加载失败的:点开先看代码(设置读不出来) + {...openable(() => onOpen(p.status.kind === "error" ? "code" : "settings", p))} > {i + 1} @@ -471,7 +473,7 @@ function PluginList({
{p.status.kind === "error" ? (

- +

) : ( w.description && ( @@ -493,7 +495,7 @@ function PluginList({ {t.appliesTo} - + )} @@ -503,8 +505,8 @@ function PluginList({ )} {p.status.kind === "error" && ( - )} onOpen("settings", p)}>{t.settings} diff --git a/src/plugins/SettingsDialog.i18n.ts b/src/plugins/SettingsDialog.i18n.ts deleted file mode 100644 index fd551120..00000000 --- a/src/plugins/SettingsDialog.i18n.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { messages } from "@/i18n"; - -export const settingsDialogText = messages( - { - title: "插件设置", - enabled: "启用", - enabledHint: "停用后此插件不运行,适用范围内的请求照常转发。", - id: (id: string) => `ID ${id}`, - sha: (prefix: string) => `SHA-256 ${prefix}`, - replace: "更换代码…", - /** 系统的确认框里点了「取消」:什么都没写 */ - cancelled: "已取消,配置未改动。", - }, - { - title: "Plugin settings", - enabled: "Enabled", - enabledHint: "When disabled, the plugin does not run and the requests it applies to are forwarded as usual.", - id: (id: string) => `ID ${id}`, - sha: (prefix: string) => `SHA-256 ${prefix}`, - replace: "Replace code…", - cancelled: "Cancelled. The configuration was not changed.", - }, -); diff --git a/src/plugins/SettingsDialog.tsx b/src/plugins/SettingsDialog.tsx deleted file mode 100644 index c71fc3d6..00000000 --- a/src/plugins/SettingsDialog.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; -import { Switch } from "@/ui/switch"; -import { useText } from "@/i18n"; -import { commonText } from "@/i18n/common.i18n"; -import { errorText } from "@/i18n/core.i18n"; -import { focusSelf, useDialogFocus } from "@/keys/parts"; -import { DialogError } from "@/upstreams/parts"; -import type { OnError, PluginView } from "@/types"; -import { localSchema, pluginName } from "./defaults"; -import { draftOf, scopeOf, scopeProblem, ScopeFields, settingsDraftOf, settingsOf, SettingsFields } from "./fields"; -import { pluginFieldsText } from "./fields.i18n"; -import { pluginLabelsText } from "./labels.i18n"; -import { manifestUnknown, shaPrefix } from "./model"; -import { savePlugin } from "./native"; -import { PermissionChips, PluginText, RequestKinds } from "./parts"; -import { settingsDialogText } from "./SettingsDialog.i18n"; -import { OnErrorField } from "./SourceDialog"; - -/** - * 一个插件的设置:启用、出错时、适用范围、插件自己的设置项。**改完点保存才写** - * (一次写成一个配置版本)。 - * - * 保存带的是**打开时的版本号**:对话框开着的时候别处改了配置,core 会说「版本不一致」, - * 而不是让这份旧表单把别人的改动盖掉(和密钥对话框同一条)。 - * - * **改得了工具调用的插件**(或者读不出权限的),打开它、改设置、改范围要在系统的确认框里 - * 点头(`savePlugin`)。在那里点了取消,表单原样留着、什么都没写。 - * - * 代码不在这里改:「更换代码」走另一个对话框,最后要在系统对话框里确认。 - */ -export function SettingsDialog({ - plugin, - version, - onClose, - onSaved, - onReplace, -}: { - plugin: PluginView; - version: { get: () => string }; - onClose: () => void; - onSaved: (version: string) => void; - onReplace: () => void; -}) { - const t = useText(settingsDialogText); - const lt = useText(pluginLabelsText); - const ft = useText(pluginFieldsText); - const common = useText(commonText); - const dialogFocus = useDialogFocus(); - const [base] = useState(() => version.get()); - const [enabled, setEnabled] = useState(plugin.enabled); - const [onError, setOnError] = useState(plugin.on_error); - const [scope, setScope] = useState(() => draftOf(plugin.scope)); - const [settings, setSettings] = useState(() => settingsDraftOf(plugin.settings_schema, plugin.settings)); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [cancelled, setCancelled] = useState(false); - - const unknown = manifestUnknown(plugin); - // 默认插件的设置项标签按界面语言说;键、类型、默认值不变 - const schema = localSchema(plugin.id, plugin.name, plugin.settings_schema); - const check = settingsOf(schema, settings); - const nextScope = scopeOf(scope); - const dirty = - enabled !== plugin.enabled || - onError !== plugin.on_error || - JSON.stringify(nextScope) !== JSON.stringify(plugin.scope) || - JSON.stringify(check.values) !== JSON.stringify(settingsOf(schema, settingsDraftOf(schema, plugin.settings)).values); - const missing = scopeProblem(scope); - const problem = missing ? ft.needOne(lt.scopeParts[missing]) : check.bad[0] ? ft.numberBad(check.bad[0]) : null; - - async function save() { - setSaving(true); - setError(null); - setCancelled(false); - try { - const r = await savePlugin(plugin, { - enabled, - on_error: onError, - scope: nextScope, - settings: check.values, - base_version: base, - }); - if (r.kind === "cancelled") { - // 系统的确认框里点了取消:表单原样留着 - setCancelled(true); - setSaving(false); - return; - } - onSaved(r.version); - } catch (e) { - setError(errorText(e)); - setSaving(false); - } - } - - return ( - !o && !saving && onClose()}> - - - {t.title} - -
- - {unknown ? {plugin.id} : } - - - {t.id(plugin.id)} - {t.sha(shaPrefix(plugin.sha256))} - {!unknown && } - {!unknown && } - -
-
-
- -
-
-
- -

{t.enabledHint}

-
- -
- - - -
-

{lt.scope}

- -
- - {schema.length > 0 && ( -
-

{ft.settings}

- -
- )} -
- - - {cancelled &&

{t.cancelled}

} - - - - {problem && {problem}} - - - -
-
- ); -} diff --git a/src/plugins/SourceDialog.i18n.tsx b/src/plugins/SourceDialog.i18n.tsx deleted file mode 100644 index c78cd54d..00000000 --- a/src/plugins/SourceDialog.i18n.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { ReactNode } from "react"; -import { messages } from "@/i18n"; - -export const sourceDialogText = messages( - { - addTitle: "添加插件", - replaceTitle: (name: ReactNode) => <>更换「{name}」的代码, - sourceDescription: "选择本地的 .js 文件,或粘贴代码。", - fromFile: "选择文件", - fromPaste: "粘贴代码", - chooseFile: "选择 .js 文件", - chooseAgain: "重新选择", - fileHint: "单个 ES 模块文件,不超过 1 MB。", - pastePlaceholder: "export const manifest = { name: \"…\", api: 1, permissions: [\"system\"] };\n\nexport function onRequest(req, ctx) {\n …\n}", - tooLarge: (size: string) => `文件为 ${size},超过 1 MB 的上限。`, - readFailed: "无法读取此文件。", - next: "下一步", - back: "返回", - - reviewTitle: "审核插件", - cannotLoad: "代码无法加载", - at: (where: string) => `位置:${where}`, - sha: "SHA-256", - code: "代码", - fullCode: "完整代码", - compare: "与当前代码对比", - options: "安装选项", - id: "插件 ID", - idHint: "小写字母、数字和连字符,最多 40 个。", - idBad: "只能使用小写字母、数字和连字符,最多 40 个。", - idTaken: "已有插件使用此 ID。", - install: "安装", - replace: "更换代码", - /** 原生对话框里点了「取消」:什么都没写 */ - cancelled: "已取消,配置未改动。", - /** 只有回答钩子的插件,没有申请改请求的权限 */ - noPermissions: "未申请任何权限。", - }, - { - addTitle: "Add plugin", - replaceTitle: (name: ReactNode) => <>Replace the code of “{name}”, - sourceDescription: "Choose a local .js file, or paste the code.", - fromFile: "Choose a file", - fromPaste: "Paste code", - chooseFile: "Choose a .js file", - chooseAgain: "Choose another", - fileHint: "A single ES module file, up to 1 MB.", - pastePlaceholder: "export const manifest = { name: \"…\", api: 1, permissions: [\"system\"] };\n\nexport function onRequest(req, ctx) {\n …\n}", - tooLarge: (size: string) => `The file is ${size}, over the 1 MB limit.`, - readFailed: "The file could not be read.", - next: "Next", - back: "Back", - - reviewTitle: "Review plugin", - cannotLoad: "The code cannot be loaded", - at: (where: string) => `At ${where}.`, - sha: "SHA-256", - code: "Code", - fullCode: "Full code", - compare: "Compare with current code", - options: "Install options", - id: "Plugin ID", - idHint: "Lowercase letters, digits and hyphens, up to 40.", - idBad: "Use only lowercase letters, digits and hyphens, up to 40.", - idTaken: "Another plugin already uses this ID.", - install: "Install", - replace: "Replace code", - cancelled: "Cancelled. The configuration was not changed.", - noPermissions: "No permissions requested.", - }, -); diff --git a/src/plugins/SourceDialog.tsx b/src/plugins/SourceDialog.tsx deleted file mode 100644 index 6742479d..00000000 --- a/src/plugins/SourceDialog.tsx +++ /dev/null @@ -1,455 +0,0 @@ -import { useRef, useState, type ReactNode } from "react"; -import { FileCodeIcon } from "lucide-react"; -import { Banner } from "@/ui/banner"; -import { Button } from "@/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; -import { Input } from "@/ui/input"; -import { Segmented } from "@/ui/segmented"; -import { Textarea } from "@/ui/textarea"; -import { call } from "@/control"; -import { useResource } from "@/lib/resource"; -import { cn } from "@/lib/utils"; -import { useText } from "@/i18n"; -import { commonText } from "@/i18n/common.i18n"; -import { coreText, errorText } from "@/i18n/core.i18n"; -import { size } from "@/format"; -import { focusSelf } from "@/keys/parts"; -import { DialogError, FormItem } from "@/upstreams/parts"; -import type { OnError, PluginInspection, PluginView, PluginWrite } from "@/types"; -import { localSchema, pluginDescription, pluginName } from "./defaults"; -import { draftOf, scopeOf, scopeProblem, ScopeFields, settingsDraftOf, settingsOf, SettingsFields, type ScopeDraft, type SettingsDraft } from "./fields"; -import { pluginFieldsText } from "./fields.i18n"; -import { pluginLabelsText } from "./labels.i18n"; -import { ID_RE, shaPrefix, suggestId } from "./model"; -import { installPlugin, replacePluginSource } from "./native"; -import { CodeBox, PermissionList, PluginText, RequestKinds, SourceDiff } from "./parts"; -import { pluginPartsText } from "./parts.i18n"; -import { sourceDialogText } from "./SourceDialog.i18n"; - -/** 插件文件的上限,和 core 一样 */ -export const MAX_SOURCE = 1024 * 1024; - -/** - * 一次要原生确认的写入,排进这一页的写入队列(见 `PluginsPage`)。`done` 是写成了, - * `cancelled` 是用户在系统对话框里点了取消 —— 什么都没写,不是失败。 - */ -export type NativeWrite = (run: (base: string) => Promise) => Promise<"done" | "cancelled">; - -type Mode = { kind: "add" } | { kind: "replace"; plugin: PluginView }; - -/** - * 添加插件、更换插件的代码:两步。 - * - * 1. **代码从哪儿来**:选一个本地的 `.js` 文件,或者粘贴。只从本地来 —— 不从链接装, - * 没有插件市场。选好之后交给 core 读一遍(`PluginInspect`,不写任何东西)。 - * 2. **审核**:完整的代码、申请的每一项权限和它的后果、适用范围、设置项、ID、出错时的 - * 处置;读不了的说清第几行第几列。按「安装」之后,**由 Rust 再读一遍这份代码**,在 - * 系统原生对话框里写明插件名、权限和 SHA-256 的前几位,点了那里的「安装」才写配置 - * (I12:网页自己完成不了这一步)。这里显示的 SHA-256 和系统对话框里的是同一段, - * 对得上就是同一份代码。 - * - * 更换代码时没有安装选项(ID、范围、设置项都留着),权限和原来的对比:新增的标出来; - * 代码可以和现在确认过的那一份对比。 - */ -export function SourceDialog({ - mode, - taken, - native, - onClose, - onDone, -}: { - mode: Mode; - /** 已有的插件 ID */ - taken: readonly string[]; - native: NativeWrite; - onClose: () => void; - /** 写成了:插件的 ID */ - onDone: (id: string) => void; -}) { - const t = useText(sourceDialogText); - const ft = useText(pluginFieldsText); - const common = useText(commonText); - const [step, setStep] = useState<"source" | "review">("source"); - const [how, setHow] = useState<"file" | "paste">("file"); - const [file, setFile] = useState<{ name: string; size: number; text: string } | null>(null); - const [paste, setPaste] = useState(""); - const [inputError, setInputError] = useState(null); - const [error, setError] = useState(null); - const [inspecting, setInspecting] = useState(false); - /** core 读过的那一份:结果,和读的是哪段代码 */ - const [read, setRead] = useState<{ result: PluginInspection; source: string; fileName: string | null } | null>(null); - const [writing, setWriting] = useState(false); - const [cancelled, setCancelled] = useState(false); - const picker = useRef(null); - - // 安装选项(只有添加时有) - const [id, setId] = useState(""); - const [onError, setOnError] = useState("reject"); - const [scope, setScope] = useState(() => draftOf({ clients: [], models: [], upstreams: [] })); - const [settings, setSettings] = useState({}); - - const source = how === "file" ? (file?.text ?? "") : paste; - const replacing = mode.kind === "replace" ? mode.plugin : null; - - async function choose(f: File | undefined) { - setInputError(null); - setError(null); - if (!f) return; - if (f.size > MAX_SOURCE) { - setFile(null); - setInputError(t.tooLarge(size(f.size))); - return; - } - try { - setFile({ name: f.name, size: f.size, text: await f.text() }); - } catch { - setFile(null); - setInputError(t.readFailed); - } - } - - async function inspect() { - if (new Blob([source]).size > MAX_SOURCE) { - setInputError(t.tooLarge(size(new Blob([source]).size))); - return; - } - setInspecting(true); - setError(null); - try { - const result = await call("PluginInspect", { source }); - const fileName = how === "file" ? (file?.name ?? null) : null; - setRead({ result, source, fileName }); - const m = result.manifest; - if (m && !replacing) { - setId((cur) => cur || suggestId(m.name, fileName, taken)); - setScope(draftOf(m.scope)); - setSettings(settingsDraftOf(m.settings_schema, {})); - } - setCancelled(false); - setStep("review"); - } catch (e) { - setError(errorText(e)); - } finally { - setInspecting(false); - } - } - - const manifest = read?.result.manifest ?? null; - const loadError = read?.result.error ?? null; - const idProblem = replacing ? null : !ID_RE.test(id) ? t.idBad : taken.includes(id) ? t.idTaken : null; - // 装的是 core 自带的那个默认插件(id 和名字都对得上)时,标签按界面语言说 - const schema = manifest ? localSchema(replacing?.id ?? id, manifest.name, manifest.settings_schema) : []; - const settingsCheck = manifest ? settingsOf(schema, settings) : { values: {}, bad: [] }; - const blocked = - !manifest || loadError != null || idProblem != null || (!replacing && scopeProblem(scope) != null) || settingsCheck.bad.length > 0; - - async function write() { - if (!read || blocked) return; - setWriting(true); - setError(null); - setCancelled(false); - try { - const r = replacing - ? await native((base) => replacePluginSource({ id: replacing.id, source: read.source, base_version: base })) - : await native((base) => - installPlugin({ - source: read.source, - id, - enabled: true, - on_error: onError, - scope: scopeOf(scope), - settings: settingsCheck.values, - base_version: base, - }), - ); - if (r === "done") onDone(replacing ? replacing.id : id); - else setCancelled(true); - } catch (e) { - setError(errorText(e)); - } finally { - setWriting(false); - } - } - - // 名字是插件写的:按纯文本画(`PluginText`),不拼进字符串 - const title = replacing - ? t.replaceTitle() - : step === "source" - ? t.addTitle - : t.reviewTitle; - - return ( - !o && !writing && onClose()}> - - - -{title} - - {step === "source" ? ( - {t.sourceDescription} - ) : ( - {t.reviewTitle} - )} - - -
- {step === "source" ? ( -
- - value={how} - options={[ - { id: "file", label: t.fromFile }, - { id: "paste", label: t.fromPaste }, - ]} - onChange={(v) => { - setHow(v); - setInputError(null); - }} - /> - {how === "file" ? ( -
- {file ? ( - <> - - - {file.name} - {size(file.size)} - - - - ) : ( - <> - - {t.fileHint} - - )} - { - void choose(e.target.files?.[0]); - // 同一个文件再选一次也要触发 - e.target.value = ""; - }} - /> -
- ) : ( -