mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-12 03:00:09 +08:00
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:
961
BillNote_frontend/src-tauri/Cargo.lock
generated
961
BillNote_frontend/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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.10(commit 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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭期统一杀 sidecar,take() 把 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 并 kill(kill 失败也继续,可能进程已经退了)
|
||||
{
|
||||
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 line,64 字节够了
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user