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)} +
  • + ))} +
+ ); +}