From d993437a79710cd1d3d6a638885861cc8e306f9f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:04:01 +0800 Subject: [PATCH] Use core v0.61.0: safe mode says which line of the config is wrong, and repairs it in one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the configuration did not load, core exited in safe mode too and the app showed "core 未运行 / 已停止" with no reason. Core 0.61.0 now serves the control plane with a stand-in in that case and reports the rejection from the start; this is the app side. - New page in safe mode when the configuration does not load: file, line, reason (translated: the commonest field errors now have their own codes), the line itself, and actions: one-click repair (when core offers one: values outside their choices go back to the default, unknown fields are removed), open the configuration file at that line, version history, restart. Other pages are replaced by it (settings stays). - The yellow "config rejected" banner lists the same fixes with a Repair button. - Once a working configuration lands in safe mode (repair, save, rollback, an edit in another editor), the shell restarts core in normal mode on its own. - `safe_mode:`: the app connects to a safe-mode core on a cold start too; before, it only connected to `running:` and never learned the reason. - `exited:`: when the safe-mode core still exits, the shell keeps core's stderr and shows its last error ("core 无法启动" + the reason) instead of "Stopped". Menu bar and notices treat it like a failed start. - ConfigRepairPlan / RepairConfig whitelisted; zh translations for the new codes. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/status.json | 4 +- scripts/shots/core/zh/status.json | 4 +- scripts/shots/mock/core.ts | 2 + src-tauri/Cargo.lock | 44 +++---- src-tauri/Cargo.toml | 12 +- src-tauri/src/call.rs | 3 + src-tauri/src/gateway.rs | 24 +++- src-tauri/src/menubar/mod.rs | 2 +- src-tauri/src/notices/rules.rs | 18 +++ src-tauri/src/supervisor/mod.rs | 184 +++++++++++++++++++++++++++-- src/App.tsx | 56 +++++++-- src/connection/Unlinked.tsx | 83 ++++++++++++- src/connection/connection.i18n.tsx | 24 ++++ src/control.ts | 2 + src/generated/tw-api.ts | 49 +++++++- src/i18n/core.zh.json | 3 + src/launch/trouble.exited.test.ts | 16 +++ src/launch/trouble.i18n.ts | 2 + src/launch/trouble.ts | 11 +- src/repair.i18n.ts | 22 ++++ src/repair.test.ts | 22 ++++ src/repair.tsx | 67 +++++++++++ 22 files changed, 603 insertions(+), 51 deletions(-) create mode 100644 src/launch/trouble.exited.test.ts create mode 100644 src/repair.i18n.ts create mode 100644 src/repair.test.ts create mode 100644 src/repair.tsx diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 1c1abf89..233a6d66 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 36, + "api_version": 37, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.60.0" + "version": "0.61.0" } diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 1c1abf89..233a6d66 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 36, + "api_version": 37, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.60.0" + "version": "0.61.0" } diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 7071c204..3c119808 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -83,6 +83,8 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { ConfigHistory: () => [{ version: configVersion(), at_ms: NOW - 2 * DAY, origin: "ui", bytes: 2_714, current: true }], ConfigAt: () => ({ section: null, name: null }), ConfigRollback: refuse, + ConfigRepairPlan: () => ({ base_version: configVersion(), fixes: [] }), + RepairConfig: refuse, SaveListen: refuse, History: (req) => { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5519ab35..0821e58b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -974,7 +974,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1175,7 +1175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2651,7 +2651,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3591,7 +3591,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3647,7 +3647,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4733,7 +4733,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5281,8 +5281,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "serde", "serde_json", @@ -5293,8 +5293,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "serde", "serde_json", @@ -5302,8 +5302,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "base64 0.22.1", "bytes", @@ -5318,8 +5318,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "serde", "serde_json", @@ -5345,8 +5345,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "serde", "ts-rs", @@ -5354,8 +5354,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "notify", "thiserror 2.0.21", @@ -5364,8 +5364,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.60.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.60.0#291ef531b121117872815cd6db731fdd04ac336a" +version = "0.61.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.61.0#57f0ac21afdced765c8cf4f3d841196e285507c8" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5862,7 +5862,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 17d22304..8ccacade 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.60.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.60.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.60.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.60.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.60.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.60.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.61.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index f2216add..c1692722 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -68,6 +68,9 @@ webview_endpoints![ ConfigHistory, ConfigAt, ConfigRollback, + // 一键修复:修的是磁盘上那一份,界面只交版本号 + ConfigRepairPlan, + RepairConfig, SaveListen, // 记录。搜索翻的是整个库,界面自己发起、自己翻页 History, diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 1fd59078..2a174b00 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -285,8 +285,12 @@ pub(crate) fn describe_state(s: &CoreState) -> String { CoreState::Starting => "starting".into(), CoreState::Running { pid } => format!("running:{pid}"), CoreState::Restarting { attempt, in_ms } => format!("restarting:{attempt}:{in_ms}"), - CoreState::SafeMode { .. } => "safe_mode".into(), + // 控制面答应了就带上 pid:界面见到它才去连(安全模式下也能改配置、一键修复)。 + // 还没答应的时候连了也是白连 + CoreState::SafeMode { pid: Some(pid) } => format!("safe_mode:{pid}"), + CoreState::SafeMode { pid: None } => "safe_mode".into(), CoreState::Stopped => "stopped".into(), + CoreState::Exited { reason } => format!("exited:{reason}"), CoreState::Failed { reason } => format!("failed:{reason}"), } } @@ -426,6 +430,24 @@ pub(crate) async fn bridge_events(app: tauri::AppHandle) { if matches!(ev, tw_api::Event::EventsDropped { .. }) { reconcile_notices(&a); } + // **安全模式下换进了一份能用的配置**(界面里改好保存、一键修复、回滚、在 + // 编辑器里改好):按正常模式重启,网关自己恢复,不用再去点「重新启动」 + if matches!(ev, tw_api::Event::ConfigReloaded { .. }) + && let Some(st) = a.try_state::() + && !st.link.is_remote() + && matches!( + *st.supervisor.watch().borrow(), + CoreState::SafeMode { pid: Some(_) } + ) + { + tracing::info!("安全模式下配置已能读进来,按正常模式重启 core"); + let app = a.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = restart_gateway(&app).await { + tracing::warn!("安全模式之后重启 core 没成:{e:?}"); + } + }); + } let _ = a.emit("core-event", &ev); }, ); diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 17f646dc..24c08686 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -346,7 +346,7 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits CoreState::Running { .. } => Gateway::Running, CoreState::Starting | CoreState::Restarting { .. } => Gateway::Starting, CoreState::SafeMode { .. } => Gateway::SafeMode, - CoreState::Failed { .. } => Gateway::Failed, + CoreState::Failed { .. } | CoreState::Exited { .. } => Gateway::Failed, CoreState::Stopped => Gateway::Stopped, }; let notices = app.try_state::>(); diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index 8d503243..36c116dc 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -702,6 +702,24 @@ pub fn from_core_state(state: &crate::supervisor::CoreState) -> Vec { .now() .suppressing(suppresses("gateway")), ], + // 程序本身运行不了、或者安全模式里也起不来:不会自己好,转发已经停了 + CoreState::Exited { .. } => vec![ + Signal::raised( + "gateway", + Level::Critical, + tr!("网关无法启动", "Gateway Cannot Start"), + ) + .body( + tr!( + "core 启动后退出,转发已停止。打开窗口可以查看原因并重试。", + "core exited right after starting, so forwarding has stopped. Open the window to see why and try again." + ) + .to_string(), + ) + .view(SETTINGS) + .now() + .suppressing(suppresses("gateway")), + ], // 程序本身运行不了:不会自己好,转发已经停了 CoreState::Failed { .. } => vec![ Signal::raised( diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index cfa061dd..07091375 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -66,6 +66,14 @@ pub enum CoreState { }, /// UI 主动停的,不再重启 Stopped, + /// 安全模式里的 core 也退出了:起不来的原因不在配置的某一行(那种情况 core 顶着一份 + /// 临时配置照样起控制面),而在别处 —— 另一个 core 占着、配置坏到连 YAML 都读不了。 + /// + /// **原因是 core 自己最后说的话**(它的 stderr)。以前这里报的是 `Stopped`,界面上 + /// 只有一句「已停止」,原因只在日志里。守护停下,等用户点「重新启动」 + Exited { + reason: String, + }, /// 拉不起来:程序运行不了(找不到、没有执行权限、不是这台机器能跑的)。 /// /// **这不是「core 在崩」**,重启循环掩盖不了它,所以守护停下来等用户点重试。 @@ -100,6 +108,81 @@ fn why_not(e: &std::io::Error) -> String { } } +/// core 的 stderr:读到的每一行照写到我们自己的 stderr,最后几行留着。 +struct Tail { + lines: Arc>>, + reader: tokio::task::JoinHandle<()>, +} + +/// 留多少行。`Error:` 连同 `Caused by:` 下面那几层,十几行足够 +const TAIL_LINES: usize = 40; + +fn tail_of(stderr: tokio::process::ChildStderr) -> Tail { + use tokio::io::AsyncBufReadExt; + let lines = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())); + let keep = lines.clone(); + let reader = tokio::spawn(async move { + let mut r = tokio::io::BufReader::new(stderr).lines(); + while let Ok(Some(line)) = r.next_line().await { + eprintln!("{line}"); + if let Ok(mut l) = keep.lock() { + if l.len() == TAIL_LINES { + l.pop_front(); + } + l.push_back(line); + } + } + }); + Tail { lines, reader } +} + +impl Tail { + /// 进程退出之后它说的最后那件事。管道在进程退出时关上,读的那一头随即读完; + /// **最多等一会儿**,读不完也不能把守护循环挂住 + async fn reason(self) -> Option { + let _ = tokio::time::timeout(Duration::from_millis(500), self.reader).await; + let lines: Vec = self.lines.lock().ok()?.iter().cloned().collect(); + last_error(&lines) + } +} + +/// 从 core 的 stderr 里取出它最后报的那个错。 +/// +/// twcore 的 `main` 返回错误时,Rust 打的是 `Error: 那句话`,带着上下文的再跟一段 +/// `Caused by:` 和编了号的几层。**整段连起来**:外层说在做什么(「loading …/config.yaml」), +/// 里层说为什么。没有 `Error:` 的(被信号杀掉、崩溃)取最后几行非空的 +pub(crate) fn last_error(lines: &[String]) -> Option { + let start = lines.iter().rposition(|l| l.starts_with("Error: ")); + let Some(start) = start else { + let tail: Vec<&str> = lines + .iter() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + let from = tail.len().saturating_sub(3); + return (!tail.is_empty()).then(|| tail[from..].join("\n")); + }; + let mut parts = Vec::new(); + for (i, l) in lines[start..].iter().enumerate() { + let l = if i == 0 { + &l["Error: ".len()..] + } else { + l.as_str() + }; + let l = l.trim(); + if l.is_empty() || l == "Caused by:" { + continue; + } + // 「0: 」「1: 」这种层号 + let l = match l.split_once(": ") { + Some((n, rest)) if n.chars().all(|c| c.is_ascii_digit()) => rest, + _ => l, + }; + parts.push(l.to_string()); + } + (!parts.is_empty()).then(|| parts.join("\n")) +} + /// 一次 core 退出之后,守护循环下一步做什么。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Next { @@ -321,7 +404,7 @@ impl Supervisor { "Core is starting; try again in a moment" )) } - CoreState::Stopped | CoreState::Failed { .. } => { + CoreState::Stopped | CoreState::Exited { .. } | CoreState::Failed { .. } => { anyhow::bail!(tr!( "core 未运行,无法重启", "Core is not running and cannot be restarted" @@ -366,7 +449,7 @@ impl Supervisor { // 先取出来再看:`borrow` 握着读锁,不能带过下面的 await let now = rx.borrow_and_update().clone(); match now { - CoreState::Stopped | CoreState::Failed { .. } => return, + CoreState::Stopped | CoreState::Exited { .. } | CoreState::Failed { .. } => return, CoreState::Running { pid } | CoreState::SafeMode { pid: Some(pid) } => { return self.stop_running(pid, timeout).await; } @@ -520,7 +603,10 @@ impl Supervisor { // **不交凭据。**控制面的钥匙在 config.yaml 里,由 core 自己生成、补上; // 这一侧连接时从同一个文件读(见 `crate::control`) cmd.args(&args) - // core 的日志走它自己的 stderr;UI 侧只需要知道它活着。 + // **stderr 接过来**:退出时 core 最后说的那几句(`Error: …`)就是起不来的原因, + // 安全模式里的 core 也退出时要拿它给界面看(`Exited`)。读到的每一行照样写到 + // 我们自己的 stderr,开发时在终端里看到的和以前一样 + .stderr(std::process::Stdio::piped()) .kill_on_drop(true); // **不给它开控制台窗口。**twcore 是个命令行程序,Windows 上起一个控制台 // 子系统的程序、父进程自己又没有控制台时,系统会给它新开一个 —— 用户 @@ -543,8 +629,11 @@ impl Supervisor { _ => break, } } - let mut child = match spawned { - Ok(c) => c, + let (mut child, last_words) = match spawned { + Ok(mut c) => { + let t = c.stderr.take().map(tail_of); + (c, t) + } Err(e) => { let path = self.binary.display(); let why = why_not(&e); @@ -626,8 +715,16 @@ impl Supervisor { } if safe { - // 安全模式下的 core 退出了,说明用户主动停的或者更严重的问题。 - self.set(CoreState::Stopped); + // 安全模式下的 core 也退出了:配置哪一行错了的话它会顶着临时配置起来,走到这里 + // 是更根本的问题。**把它最后说的话交给界面**,说不出什么才报「已停止」 + let reason = match last_words { + Some(t) => t.reason().await, + None => None, + }; + self.set(match reason { + Some(reason) => CoreState::Exited { reason }, + None => CoreState::Stopped, + }); return Ok(Next::Stop); } @@ -1158,6 +1255,79 @@ mod tests { (tmp, warmed(bin), starts) } + /// **安全模式里的 core 也退出了,界面拿到的是它最后说的那句话**,不是一句「已停止」 + #[tokio::test] + async fn a_safe_mode_core_that_exits_reports_what_it_said() { + let tmp = fake_core_dir("exits"); + let dir = tmp.path(); + #[cfg(unix)] + let bin = { + let bin = dir.join("fake-core"); + let script = format!( + "#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\necho 'Error: loading /x/config.yaml' >&2\necho '' >&2\necho 'Caused by:' >&2\necho ' 已有 twcore 实例正在运行(pid 7)。' >&2\nexit 1\n" + ); + std::fs::write(&bin, script).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + bin + }; + #[cfg(windows)] + let bin = { + let bin = dir.join("fake-core.cmd"); + let script = format!( + "@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@echo Error: loading /x/config.yaml 1>&2\r\n@echo Caused by: 1>&2\r\n@echo another twcore is running 1>&2\r\n@exit /b 1\r\n" + ); + std::fs::write(&bin, script).unwrap(); + bin + }; + let s = Supervisor::new( + warmed(bin), + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + assert_eq!(s.run_once(true).await.unwrap(), Next::Stop); + let CoreState::Exited { reason } = s.state() else { + panic!("该报 Exited,实际是 {:?}", s.state()); + }; + assert!(reason.starts_with("loading /x/config.yaml\n"), "{reason}"); + #[cfg(unix)] + assert!( + reason.ends_with("已有 twcore 实例正在运行(pid 7)。"), + "{reason}" + ); + } + + #[test] + fn the_last_error_is_the_whole_chain_without_numbers() { + let lines: Vec = [ + "some earlier noise", + "Error: loading /x/config.yaml", + "", + "Caused by:", + " 0: schema error (line 3): bad", + " 1: deeper", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + last_error(&lines).as_deref(), + Some("loading /x/config.yaml\nschema error (line 3): bad\ndeeper") + ); + // 没有 `Error:`(被信号杀掉、崩溃)就取最后几行 + let crash: Vec = ["a", "", "thread 'main' panicked at x", "boom"] + .into_iter() + .map(String::from) + .collect(); + assert_eq!( + last_error(&crash).as_deref(), + Some("a\nthread 'main' panicked at x\nboom") + ); + assert_eq!(last_error(&[]), None); + } + /// 起过几次 fn starts(file: &std::path::Path) -> usize { std::fs::read_to_string(file).map_or(0, |s| s.lines().count()) diff --git a/src/App.tsx b/src/App.tsx index 709d4801..5ed0dd9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,7 +53,8 @@ import { LaunchScreen } from "./launch/LaunchScreen"; import { warm } from "./launch/warm"; import { ConnectionProvider, useConnections } from "./connection/ConnectionProvider"; import { Switcher } from "./connection/Switcher"; -import { Unlinked } from "./connection/Unlinked"; +import { ConfigBroken, Unlinked } from "./connection/Unlinked"; +import { FixList, repairText, useRepair } from "./repair"; import { currentProfile } from "./connection/api"; import { connText } from "./connection/connection.i18n"; import { NavContext, SURFACES, type Nav, type NavDelivery, type NavParams, type Surface } from "./nav"; @@ -188,9 +189,10 @@ function describeCore(raw: string): { return { text: t.restarting(`${attempt}`), short: t.restartingShort, tone: "warn" }; } // 安全模式必须显眼:这时候网关不转发了,所有 AI 客户端都停着 - if (raw === "safe_mode") return { text: t.safeMode, short: t.safeModeShort, tone: "bad" }; + if (raw.startsWith("safe_mode")) return { text: t.safeMode, short: t.safeModeShort, tone: "bad" }; // 程序运行不了。原因在启动画面上说(见 launch/trouble.ts) - if (raw.startsWith("failed:")) return { text: t.cannotStart, short: t.cannotStart, tone: "bad" }; + if (raw.startsWith("failed:") || raw.startsWith("exited:")) + return { text: t.cannotStart, short: t.cannotStart, tone: "bad" }; return { text: t.stopped, short: t.stopped, tone: "bad" }; } @@ -562,8 +564,11 @@ function Shell({ first }: { first: boolean }) { * (`nudge`)。**一串触发只读一次**(`COALESCE_MS`)。 */ useEffect(() => { - // 头一次连上之前,core 不在跑就不去读:读回来的只有一句「连不上」 - if (!linkedRef.current && !core.startsWith("running:")) return; + /* + 头一次连上之前,控制面没答应就不去读:读回来的只有一句「连不上」。**安全模式的控制面 + 也算**(`safe_mode:`):一启动就进了安全模式的那次,界面要连上它,才说得出哪儿错了 + */ + if (!linkedRef.current && !core.startsWith("running:") && !core.startsWith("safe_mode:")) return; let alive = true; let timer: ReturnType | null = null; let n = 0; @@ -615,6 +620,14 @@ function Shell({ first }: { first: boolean }) { }, [reloads, nudge, health, models, listening, upstreamState, core, setStatus, setOv]); const c = describeCore(core); + /** + * 安全模式、配置文件读不了。core 只起了控制面,配置是它临时顶上的空配置 —— 各页画出来 + * 的都不是用户的东西,所以内容区换成说明这件事的那一页(设置页除外,同未连接时) + */ + const broken = linked && core.startsWith("safe_mode") ? (status?.config_rejected ?? null) : null; + /** 出错页和配置被拒的横幅在的时候,问一下能不能一键修复。被拒的那一处、配置换了一份,重问 */ + const repair = useRepair(broken !== null || rejected !== null, `${broken?.at_ms}:${rejected?.at_ms}:${reloads}`); + const rt = useText(repairText); /** 连上过、又断了。**只在这时候挂那条横幅**。连着远程时断线另有一条(见下面) */ const lost = linked && tries > 0 && !remote ? trouble(core, tries) : null; /** @@ -895,7 +908,18 @@ function Shell({ first }: { first: boolean }) { 配置没通过校验。**一直挂着,直到下一次成功换入** —— 一闪而过的提示等于 没提示。第一句先说「还在按旧配置转发」:那是最想知道的,会不会断。 */} - + 0 && ( + + ) + } + > {rejected && ( <>

@@ -907,6 +931,12 @@ function Shell({ first }: { first: boolean }) { {rejected.line}│ {rejected.excerpt} )} + {repair.fixes.length > 0 && ( +

+

{rt.title}

+ +
+ )} )}
@@ -1026,6 +1056,18 @@ function Shell({ first }: { first: boolean }) { ) : ( ) + ) : broken && tab !== "settings" ? ( + setConfigFile({ focus: null })} + onHistory={() => setHistoryOpen(true)} + /> + ) : broken ? ( + ) : tab === "dashboard" ? ( setLanded(true)} /> ) : tab === "clients" ? ( @@ -1098,7 +1140,7 @@ function Shell({ first }: { first: boolean }) { setConfigFile(null)} onJump={(section) => { setConfigFile(null); diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx index 90bdedd2..0d9405d0 100644 --- a/src/connection/Unlinked.tsx +++ b/src/connection/Unlinked.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { PlugZapIcon, TriangleAlertIcon } from "lucide-react"; +import { FileWarningIcon, PlugZapIcon, TriangleAlertIcon } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { Button } from "@/ui/button"; import { Page } from "@/ui/page"; @@ -7,6 +7,10 @@ import { StatusLabel, type StatusTone } from "@/ui/status-dot"; import { usePending } from "@/ui/notify"; import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; +import { coreText } from "@/i18n/core.i18n"; +import { stageLabel } from "@/labels"; +import type { ConfigFix, ConfigRejection } from "@/types"; +import { FixList, repairText } from "@/repair"; import { useNow } from "@/useNow"; import { trouble } from "@/launch/trouble"; import { troubleText } from "@/launch/trouble.i18n"; @@ -174,6 +178,83 @@ export function Mismatch({ view, ours, theirs }: { view: ConnView; ours: string; ); } +/** + * 安全模式、配置文件读不了:core 只起了控制面,网关不转发。 + * + * **说清是哪一行、原文是什么**,再给两条出路:打开配置文件改(编辑器里那一行是标出来的), + * 或者回滚到能用的一版。保存了一份能用的配置之后,外壳自动按正常模式重启 core(见 App)。 + * 这时候别的页面画的都是 core 临时顶上的空配置,所以整个内容区只有这一页。 + */ +export function ConfigBroken({ + rejection, + path, + fixes, + repairing, + onRepair, + onOpenFile, + onHistory, +}: { + rejection: ConfigRejection; + path: string; + /** 一键修复要改的几处(见 `useRepair`)。空的就是修不了,只剩另外两条路 */ + fixes: ConfigFix[]; + repairing: boolean; + onRepair: () => void; + onOpenFile: () => void; + onHistory: () => void; +}) { + const t = useText(connText); + const tt = useText(troubleText); + const rt = useText(repairText); + const fixable = fixes.length > 0; + const [restarting, run] = usePending(); + const stage = stageLabel(rejection.stage); + return ( + } tone="error" title={t.configBroken} status={t.safeMode}> +

{t.configBrokenLead}

+
+ {path}], + [t.location, rejection.line != null ? t.lineAt(rejection.line, stage) : t.stageOnly(stage)], + [t.reason, coreText(rejection.message)], + ]} + /> + {rejection.excerpt && ( +
+            {rejection.line} │ 
+            {rejection.excerpt}
+          
+ )} +
+ {fixable && ( +
+

{rt.title}

+ +

{rt.history}

+
+ )} +
+ {fixable && ( + + )} + + + +
+

{t.configBrokenResume}

+ + ); +} + /** 连着本机、本机 core 没起来(启动画面等过了 8 秒之后) */ function LocalDown({ core }: { core: string }) { const t = useText(connText); diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx index a1b5510a..2a4903f7 100644 --- a/src/connection/connection.i18n.tsx +++ b/src/connection/connection.i18n.tsx @@ -165,6 +165,18 @@ export const connText = messages( localDown: "本机 core 未在运行", restartLocal: "重新启动", + // 安全模式、配置文件读不了 + configBroken: "配置文件有误,网关未启动", + safeMode: "安全模式", + configBrokenLead: "core 无法读取配置文件,客户端的请求暂不转发。修正配置文件或回滚到可用的版本即可恢复。", + file: "文件", + location: "位置", + lineAt: (line: number, stage: string) => `第 ${line} 行 · ${stage}错误`, + stageOnly: (stage: string) => `${stage}错误`, + openConfigFile: "打开配置文件", + versionHistory: "版本历史", + configBrokenResume: "保存修正后的配置后,网关自动恢复。", + // 断线横幅 lostBanner: (name: string, attempt: number) => attempt > 0 @@ -311,6 +323,18 @@ export const connText = messages( localDown: "The local core is not running", restartLocal: "Restart", + configBroken: "The configuration file has an error; the gateway is not running", + safeMode: "Safe mode", + configBrokenLead: + "Core cannot read its configuration file, so client requests are not forwarded. Fix the file or roll back to a working version to recover.", + file: "File", + location: "Location", + lineAt: (line: number, stage: string) => `Line ${line} · ${stage} error`, + stageOnly: (stage: string) => `${stage} error`, + openConfigFile: "Open configuration file", + versionHistory: "Version history", + configBrokenResume: "Once a corrected configuration is saved, the gateway resumes on its own.", + lostBanner: (name: string, attempt: number) => attempt > 0 ? `Disconnected from ${name}. Reconnecting (attempt ${attempt}). The page shows the state before the disconnect and cannot be changed for now.` diff --git a/src/control.ts b/src/control.ts index 9f6079cf..3407f834 100644 --- a/src/control.ts +++ b/src/control.ts @@ -35,6 +35,8 @@ export const WEBVIEW_ENDPOINTS = [ "ConfigHistory", "ConfigAt", "ConfigRollback", + "ConfigRepairPlan", + "RepairConfig", "SaveListen", "History", "HistorySearch", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 7b268c3e..bb9802d0 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 = 36; +export const CONTROL_API_VERSION = 37; /** * 一个账号上游登的是哪个账号。 @@ -425,6 +425,32 @@ name: string | null, }; */ export type ConfigAtQuery = { offset: number, }; +/** + * 一键修复要改的一处。 + */ +export type ConfigFix = { kind: ConfigFixKind, +/** + * 字段的路径:`client_probes.titling`、`providers[0].protocol` + */ +field: string, +/** + * 修之前的原文里是第几行,1 起 + */ +line: number | null, +/** + * 原来写着的值,**已脱敏**。不认识的字段的值不是一个标量时没有 + */ +value: string | null, +/** + * 修完之后的值,也就是默认值。不认识的字段、或者默认值写不成一个标量时没有 + */ +now: string | null, }; + +/** + * 一键修复改的是哪一种错。 + */ +export type ConfigFixKind = "unknown_value" | "unknown_field"; + /** * 一版配置是谁写的。 */ @@ -459,6 +485,23 @@ line: number | null, */ excerpt: string | null, at_ms: number, }; +/** + * 配置读不进来时,一键修复会改哪几处(`GET /config/repair`)。 + * + * **只修两种,都是删掉一个键**:取值不在可选范围里的(回到默认值)、不认识的字段。修完 + * 整份配置读得进来才给;修不了、或者本来就读得进来,`fixes` 是空的。 + */ +export type ConfigRepair = { +/** + * 按磁盘上哪一版算的。修的时候带回来(`POST /config/repair`):文件变了就不修 + */ +base_version: string, fixes: Array, }; + +/** + * 照 [`ConfigRepair`] 修好并写回。 + */ +export type ConfigRepairRequest = { base_version: string, }; + /** * 配置错在哪一层。 */ @@ -4323,6 +4366,8 @@ export const ENDPOINTS = { ConfigHistory: { method: "GET", path: "/config/history", params: [], format: "json" }, ConfigAt: { method: "GET", path: "/config/at", params: [], format: "json" }, ConfigRollback: { method: "POST", path: "/config/rollback", params: [], format: "json" }, + ConfigRepairPlan: { method: "GET", path: "/config/repair", params: [], format: "json" }, + RepairConfig: { method: "POST", path: "/config/repair", params: [], format: "json" }, SaveListen: { method: "PUT", path: "/listen", params: [], format: "json" }, Summary: { method: "GET", path: "/summary", params: [], format: "json" }, CostBuckets: { method: "GET", path: "/summary/buckets", params: [], format: "json" }, @@ -4436,6 +4481,8 @@ export type Endpoints = { ConfigHistory: { req: null; res: Array }; ConfigAt: { req: ConfigAtQuery; res: ConfigAt }; ConfigRollback: { req: RollbackRequest; res: ConfigWritten }; + ConfigRepairPlan: { req: null; res: ConfigRepair }; + RepairConfig: { req: ConfigRepairRequest; res: ConfigWritten }; SaveListen: { req: ListenSave; res: ConfigWritten }; Summary: { req: Window; res: Summary }; CostBuckets: { req: BucketQuery; res: Array }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index ce5dbcda..0459aa2f 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -509,6 +509,7 @@ "control.records_unreadable": "无法读取请求记录:{detail}", "control.listen.bind_invalid": "bind 只能是 loopback、all、网卡名(如 en0)或地址(如 192.168.1.5),当前为 {bind}。", "// ── control:改配置时的失败 ────────────────────────────────────────": "", + "control.config_not_repairable": "配置文件里没有可以自动修复的错误。请修改配置文件,或回滚到之前的版本。", "control.config_stale": "版本不一致:本次修改基于 {base},当前版本为 {current}。请刷新后重新修改。", "control.no_such_version": "版本历史中没有 {version}。", "control.patch.no_entry": "{path} 中没有名为「{name}」的条目。列表条目按名称或序号查找。", @@ -568,6 +569,8 @@ "// 语法和字段错误的原话是 serde 的英文,这边只翻外面那一层(哪一关、第几行)": "", "config.rejected": "配置有{stage:stage}错误:{detail}", "config.rejected_at": "配置第 {line} 行有{stage:stage}错误:{detail}", + "config.unknown_field": "{field} 不是可用的字段,可用的有:{expected}", + "config.unknown_variant": "{field} 不能取 {value},可选值:{expected}", "config.schema_too_new": "配置的格式版本为 {found},当前 twcore 最高支持 {supported}。请升级应用,或将配置改回旧格式。", "config.remote_port_zero": "listen.control.remote.port 为 0,应在 1–65535 之间。twcore remote enable 会自动选择一个空闲端口。", "config.remote_port_is_gateway": "listen.control.remote.port 为 {port},与网关端口相同。两者需要使用不同的端口。", diff --git a/src/launch/trouble.exited.test.ts b/src/launch/trouble.exited.test.ts new file mode 100644 index 00000000..05074a8f --- /dev/null +++ b/src/launch/trouble.exited.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { trouble } from "./trouble"; + +describe("trouble: core exited in safe mode too", () => { + it("says it cannot start and gives core's own words, with a retry", () => { + const t = trouble("exited:已有 twcore 实例正在运行(pid 7)。", 0); + expect(t.what).toBe("core 无法启动"); + expect(t.next).toBe("已有 twcore 实例正在运行(pid 7)。"); + expect(t.retry).toBe(true); + expect(t.bad).toBe(true); + }); + + it("treats safe mode with the control plane up like safe mode", () => { + expect(trouble("safe_mode:4242", 0).what).toBe(trouble("safe_mode", 0).what); + }); +}); diff --git a/src/launch/trouble.i18n.ts b/src/launch/trouble.i18n.ts index ff488da5..b429078d 100644 --- a/src/launch/trouble.i18n.ts +++ b/src/launch/trouble.i18n.ts @@ -4,6 +4,7 @@ export const troubleText = messages( { missing: "未找到 core 程序", failed: "core 程序无法运行", + exited: "core 无法启动", restarting: (attempt: string) => `core 已退出,正在进行第 ${attempt} 次重启`, retryIn: (secs: number) => `${secs} 秒后重试`, safeMode: "安全模式:网关未运行", @@ -19,6 +20,7 @@ export const troubleText = messages( { missing: "The core program was not found", failed: "The core program could not run", + exited: "core could not start", restarting: (attempt: string) => `core exited; restarting (attempt ${attempt})`, retryIn: (secs: number) => `Retrying in ${secs} s`, safeMode: "Safe mode: the gateway is not running", diff --git a/src/launch/trouble.ts b/src/launch/trouble.ts index 2052588e..e3d6453a 100644 --- a/src/launch/trouble.ts +++ b/src/launch/trouble.ts @@ -31,6 +31,15 @@ export function trouble(raw: string, tries: number): Trouble { retry: true, }; } + // core 起来了又退出,安全模式也一样:**原因是 core 自己最后说的话**,给重试 + if (raw.startsWith("exited:")) { + return { + what: t.exited, + next: raw.slice("exited:".length), + bad: true, + retry: true, + }; + } if (raw.startsWith("missing:")) { return { what: t.missing, @@ -49,7 +58,7 @@ export function trouble(raw: string, tries: number): Trouble { retry: false, }; } - if (raw === "safe_mode") { + if (raw.startsWith("safe_mode")) { return { what: t.safeMode, next: t.safeModeNext, diff --git a/src/repair.i18n.ts b/src/repair.i18n.ts new file mode 100644 index 00000000..e9d8aedb --- /dev/null +++ b/src/repair.i18n.ts @@ -0,0 +1,22 @@ +import { messages } from "@/i18n"; + +export const repairText = messages( + { + title: "可以一键修复", + action: "一键修复", + value: (field: string, value: string, now: string) => `${field}:${value} 改为默认值 ${now}`, + valueNoDefault: (field: string, value: string) => `${field}:删除 ${value},改用默认值`, + field: (field: string) => `删除不认识的字段 ${field}`, + line: (n: number) => `第 ${n} 行`, + history: "修复前的版本保存在「版本历史」中。", + }, + { + title: "Can be repaired in one click", + action: "Repair", + value: (field: string, value: string, now: string) => `${field}: ${value} → default ${now}`, + valueNoDefault: (field: string, value: string) => `${field}: remove ${value} and use the default`, + field: (field: string) => `Remove the unknown field ${field}`, + line: (n: number) => `Line ${n}`, + history: "The version before the repair is kept in Version history.", + }, +); diff --git a/src/repair.test.ts b/src/repair.test.ts new file mode 100644 index 00000000..4151b1a8 --- /dev/null +++ b/src/repair.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { fixText } from "./repair"; + +describe("fixText", () => { + it("says what a value goes back to", () => { + expect( + fixText({ kind: "unknown_value", field: "client_probes.titling", line: 48, value: "passthrough", now: "forward" }), + ).toBe("client_probes.titling:passthrough 改为默认值 forward"); + }); + + it("still says something when the default is not a single value", () => { + expect(fixText({ kind: "unknown_value", field: "security.mode", line: 3, value: "strict", now: null })).toBe( + "security.mode:删除 strict,改用默认值", + ); + }); + + it("names an unknown field it removes", () => { + expect(fixText({ kind: "unknown_field", field: "retention.body_dayz", line: 53, value: "3", now: null })).toBe( + "删除不认识的字段 retention.body_dayz", + ); + }); +}); diff --git a/src/repair.tsx b/src/repair.tsx new file mode 100644 index 00000000..48be926c --- /dev/null +++ b/src/repair.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { call } from "@/control"; +import { textOf, useText } from "@/i18n"; +import { usePending } from "@/ui/notify"; +import type { ConfigFix, ConfigRepair } from "@/types"; +import { repairText } from "./repair.i18n"; + +export { repairText }; + +/** + * 配置文件读不进来时的一键修复:core 算出要改的那几处(`ConfigRepairPlan`),点一下照着修好 + * 写回(`RepairConfig`)。 + * + * **只修两种**:取值不在可选范围里的(回到默认值)、不认识的字段(删掉)。修完整份配置读得 + * 进来 core 才给;给不了的时候不出现这个按钮,只剩「打开配置文件」和「版本历史」。 + * + * `active` 为真时才去问(出错页、配置被拒的横幅在的时候);`key` 变了重问 —— 被拒的那一处 + * 换了、配置又换了一份,修法就不一样了。修完写回走的是和保存同一条路:进版本历史,可以回滚。 + */ +export function useRepair(active: boolean, key: unknown) { + const [plan, setPlan] = useState(null); + const [repairing, run] = usePending(); + useEffect(() => { + if (!active) { + setPlan(null); + return; + } + let alive = true; + call("ConfigRepairPlan", null) + .then((p) => alive && setPlan(p)) + // 问不到就当修不了:出错页上照样有另外两条路 + .catch(() => alive && setPlan(null)); + return () => { + alive = false; + }; + }, [active, key]); + const fixes = plan?.fixes ?? []; + return { + fixes, + repairing, + repair: () => + plan && fixes.length > 0 ? void run(() => call("RepairConfig", { base_version: plan.base_version })) : undefined, + }; +} + +/** 一处修复说成一句话 */ +export function fixText(f: ConfigFix): string { + const t = textOf(repairText); + if (f.kind === "unknown_field") return t.field(f.field); + const value = f.value ?? ""; + return f.now != null ? t.value(f.field, value, f.now) : t.valueNoDefault(f.field, value); +} + +/** 要改的那几处,一行一处,行号在前 */ +export function FixList({ fixes }: { fixes: ConfigFix[] }) { + const t = useText(repairText); + return ( +
    + {fixes.map((f) => ( +
  • + {f.line != null && {t.line(f.line)}} + {fixText(f)} +
  • + ))} +
+ ); +}