feat(desktop): 后端健康监控韧性 + onboarding 修复 + 全局代理 UI

- useCheckBackend 重写:60s 总超时取代 while(true) 死轮询,订阅 Tauri
  backend-ready/terminated/startup-timeout 事件,裸 fetch 探测避免
  启动期 toast 叠堆
- Tauri lib.rs:spawn 后 HTTP 探针轮询 /api/sys_check 拿 200 才算就绪
  (之前 TCP connect 会被孤儿进程误判);RunEvent::Exit 钩子退出前
  kill sidecar,修孤儿进程占端口;restart 前发 backend-restarting
  让前端忽略主动 kill 引发的 terminated
- BackendInitDialog:失败态展示原因 + 最近 stderr + 重启/复制日志按钮
- StartupBanner:收到 restarted/ready 自动清「已退出」横幅
- BackendHealthIndicator:修 /api/api/sys_health 双前缀 404
- Onboarding:step1 后端连通改自动重试 + 事件触发 + 手动按钮;step2
  撞预置供应商名时改为更新已存在供应商;errText 统一错误文案
- 全局代理 UI:下载配置页新增代理卡片(services/proxy.ts + ProxyConfig)
- request.ts 加 suppressToast 配置位,预期失败不弹全局红 toast
- NoteForm/taskStore:捕获就绪门禁错误,引导去音频转写配置页下载
- providerCard:整行可点切换(之前只有 icon 区域响应)
- Monitor 页 Whisper 卡显示模型本地下载状态
- tauri/api 升级对齐 2.11,修 vite build 版本不匹配

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-14 19:01:37 +08:00
parent 41f17592c2
commit 37f7ee6e15
22 changed files with 1273 additions and 651 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,16 @@ name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.2.0", features = [] }
# tauri-build / tauri crate 与 @tauri-apps/api 大版本必须对齐CLI 在 build 前会校验)。
# @tauri-apps/api 已升 2.10commit bb9a70e这里同步到 2.x 最新让 cargo 解析到匹配版本。
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.5.0", features = ["devtools"] }
tauri-plugin-log = "2.0.0-rc"
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-log = "2"
tauri-plugin-shell = "2"
[package.metadata.tauri.bundle.macOS]

View File

@@ -3,10 +3,19 @@ use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use std::env;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
// Sidecar 启动期内前端不该看到「加载中」无限转。
// 总等待上限 = 启动期 PyInstaller 解压 + uvicorn bind 时间的最坏估计,
// 实测 macOS / Windows 慢盘大概 5-20s设 45s 留余量但不至于让用户绝望。
const BACKEND_STARTUP_TIMEOUT_SECS: u64 = 45;
const BACKEND_DEFAULT_PORT: u16 = 8483;
// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程
struct SidecarHandle(Mutex<Option<CommandChild>>);
@@ -50,6 +59,10 @@ pub fn run() {
})?;
app.manage(SidecarHandle(Mutex::new(Some(child))));
// 启动 ready probe异步轮询本地 BACKEND_PORT 是否在监听,
// 解决前端 useCheckBackend 在 PyInstaller 解压期瞎猜后端起没起的问题。
spawn_backend_ready_probe(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -60,8 +73,33 @@ pub fn run() {
get_install_path_diagnostics,
restart_backend_sidecar
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
// 用 build()+run() 拿到 RunEvent 流关键诉求app 退出前必须 kill 掉 PyInstaller
// sidecar否则它会变成持有 8483 端口的孤儿进程,下次启动 BiliNote 直接 bind 失败。
// 之前漏掉这一步导致用户 PID 96739 那种「上次没关干净 → 这次起不来」的死循环。
.run(|app_handle, event| {
match event {
// ExitRequested 在用户 Cmd-Q / 点关闭 / Dock 退出时触发,先于实际进程结束。
// Exit 是兜底——任何走到 Tauri 主循环结束的路径都会经过它。
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
kill_backend_sidecar(app_handle);
}
_ => {}
}
});
}
// 关闭期统一杀 sidecartake() 把 child 从 state 拿走避免重复 kill。
fn kill_backend_sidecar(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<SidecarHandle>() {
if let Ok(mut guard) = state.0.lock() {
if let Some(child) = guard.take() {
eprintln!("[shutdown] killing backend sidecar before app exit");
let _ = child.kill();
}
}
}
}
// 获取额外的二进制路径
@@ -306,6 +344,12 @@ fn restart_backend_sidecar(
state: State<'_, SidecarHandle>,
app: tauri::AppHandle,
) -> Result<(), String> {
// 0. 先告诉前端「我们要重启了」。前端可以借此忽略接下来 N 秒内的 backend-terminated
// 事件——那是我们主动 kill 老 sidecar 的副作用,不是真异常。否则会出现:
// terminated 事件延迟到达 → 覆盖掉 'running' 状态 → 面板永远显示「已退出」。
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-restarting", ());
}
// 1. 拿出旧 child 并 killkill 失败也继续,可能进程已经退了)
{
let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?;
@@ -323,9 +367,88 @@ fn restart_backend_sidecar(
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-restarted", ());
}
// 4. 重启后同样起一次 ready probe让前端能及时退出失败态
spawn_backend_ready_probe(app);
Ok(())
}
// 后端就绪探测:异步轮询 GET /api/sys_check要求 HTTP 200 才算就绪。
//
// 旧实现只做 TcpStream::connect_timeout——但端口被另一个孤儿 sidecar 占着时也会
// 连得通,导致 emit('backend-ready') 误判:前端进入主界面,但真正的新 sidecar
// 没 bind 上立刻就死banner 永远停在「后端进程已退出」。
//
// 真发一个 HTTP 请求拿 200 才算「这是我们的后端在响应」。
fn spawn_backend_ready_probe(app: tauri::AppHandle) {
let port: u16 = env::var("BACKEND_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(BACKEND_DEFAULT_PORT);
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().expect("invalid backend addr");
let timeout = Duration::from_secs(BACKEND_STARTUP_TIMEOUT_SECS);
std::thread::spawn(move || {
let start = Instant::now();
let probe_interval = Duration::from_millis(500);
loop {
if probe_sys_check(&addr) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.emit("backend-ready", port);
println!("Backend ready on port {} after {:?}", port, start.elapsed());
}
return;
}
if start.elapsed() >= timeout {
if let Some(window) = app.get_webview_window("main") {
let payload = format!(
"后端在 {}s 内 /api/sys_check 未返回 200疑似启动失败或端口 {} 被其他进程占用",
timeout.as_secs(),
port
);
let _ = window.emit("backend-startup-timeout", payload);
eprintln!(
"Backend startup timeout: /api/sys_check did not return 200 on 127.0.0.1:{} after {:?}",
port,
start.elapsed()
);
}
return;
}
std::thread::sleep(probe_interval);
}
});
}
// 极简 HTTP/1.0 GET /api/sys_check —— 用 std::net 手写避免引 reqwest/ureq 的重依赖。
// 任何错都视为「还没就绪」,下次 tick 再试。
fn probe_sys_check(addr: &SocketAddr) -> bool {
let connect_timeout = Duration::from_millis(800);
let rw_timeout = Duration::from_millis(1500);
let mut stream = match TcpStream::connect_timeout(addr, connect_timeout) {
Ok(s) => s,
Err(_) => return false,
};
let _ = stream.set_read_timeout(Some(rw_timeout));
let _ = stream.set_write_timeout(Some(rw_timeout));
// HTTP/1.0 + Connection: close 让服务端发完响应就关,免去 chunked / keep-alive 解析
let req = format!(
"GET /api/sys_check HTTP/1.0\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n",
addr.port()
);
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
// 只要 status line64 字节够了
let mut buf = [0u8; 64];
let n = match stream.read(&mut buf) {
Ok(n) => n,
Err(_) => return false,
};
let head = std::str::from_utf8(&buf[..n]).unwrap_or("");
// 兼容 HTTP/1.0 / 1.1 起始行
head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
}
// 安装路径诊断PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸;
// 父目录不可写时模型 / 配置 / 日志也无法落盘
#[derive(Serialize, Clone)]