From 1329390f98fbfa42ed627b867e8240fc9af009b9 Mon Sep 17 00:00:00 2001 From: huangjianwu Date: Sat, 9 May 2026 14:27:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Sidecar=20=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E5=BA=A6=E9=9D=A2=E6=9D=BF=20+=20=E9=87=8D=E5=90=AF=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 已经把 backend-warning / backend-terminated 横幅做出来了;P2 把 lib.rs 那条 stdout/stderr/terminated 信息流真正落到一个常驻 UI 上: - 右下角浮动状态点(绿/黄/红),轮询 /api/sys_health 决定颜色 - 点开抽屉看最近 200 行日志(ring buffer),含「重启后端」「复制日志」按钮 Rust: - src-tauri/src/lib.rs:把 sidecar 启动抽出 spawn_backend_sidecar(), CommandChild 存进 SidecarHandle(Mutex>) 这个 state - 新增 #[tauri::command] restart_backend_sidecar:kill 旧 child + 重新 spawn + emit 'backend-restarted' 给前端 - 监听任务 stdout/stderr emit 时不再用 format!("'{}'", ...) 包引号,原文直传; 前端 hook 同时兼容旧形式(兜底剥引号) 前端: - components/BackendHealth/useBackendEvents.ts:listen 四个事件 + ring buffer (MAX 200 行) + invoke restart + clipboard 复制日志 - BackendHealthIndicator.tsx:右下角浮动状态点,5s 轮询 /api/sys_health; 连续 3 次失败或 backend-terminated 触发 → 红 - BackendLogPanel.tsx:右侧抽屉,深色 monospace 日志区 + 操作按钮 - 纯 web 环境(无 __TAURI_INTERNALS__)下静默不挂载 P3 / P4 还在路上。 Co-Authored-By: Claude Opus 4.7 (1M context) --- BillNote_frontend/src-tauri/src/lib.rs | 180 +++++++++++------- BillNote_frontend/src/App.tsx | 2 + .../BackendHealth/BackendHealthIndicator.tsx | 111 +++++++++++ .../BackendHealth/BackendLogPanel.tsx | 108 +++++++++++ .../BackendHealth/useBackendEvents.ts | 119 ++++++++++++ 5 files changed, 450 insertions(+), 70 deletions(-) create mode 100644 BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx create mode 100644 BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx create mode 100644 BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts diff --git a/BillNote_frontend/src-tauri/src/lib.rs b/BillNote_frontend/src-tauri/src/lib.rs index 9c33e0a..2c8be15 100644 --- a/BillNote_frontend/src-tauri/src/lib.rs +++ b/BillNote_frontend/src-tauri/src/lib.rs @@ -1,11 +1,15 @@ -use tauri::{Manager, Emitter}; +use tauri::{Manager, Emitter, State}; use tauri_plugin_shell::ShellExt; -use tauri_plugin_shell::process::CommandEvent; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use std::env; use std::collections::HashMap; use std::path::Path; +use std::sync::Mutex; use serde::Serialize; +// Sidecar 子进程句柄,用 Mutex 包裹方便 restart 时杀旧进程 +struct SidecarHandle(Mutex>); + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -20,7 +24,6 @@ pub fn run() { } let exe_path = env::current_exe().expect("无法获取当前可执行文件路径"); - let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录"); // 安装路径诊断:PyInstaller sidecar 在含非 ASCII / 空格的路径下经常炸(README 已警告但缺主动防御) // 命中时把诊断信息 emit 给前端,由顶端横幅展示,不阻断启动 @@ -37,75 +40,15 @@ pub fn run() { }); } - // 收集所有系统环境变量 - let mut all_env_vars = HashMap::new(); - for (key, value) in env::vars() { - all_env_vars.insert(key, value); - } - - // 增强 PATH 环境变量,添加常见的二进制路径 - let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default(); - let additional_paths = get_additional_binary_paths(); - let enhanced_path = enhance_path_variable(¤t_path, &additional_paths); - all_env_vars.insert("PATH".to_string(), enhanced_path); - - // 打印一些关键环境变量用于调试 - println!("Enhanced PATH: {}", all_env_vars.get("PATH").unwrap_or(&"Not found".to_string())); - println!("Total environment variables: {}", all_env_vars.len()); - // 检查 ffmpeg 是否在 PATH 中可用 check_ffmpeg_availability(); - // 启动 Python 后端侧车 - let mut sidecar_command = app.shell().sidecar("BiliNoteBackend").unwrap(); - - // 设置所有环境变量到 sidecar - for (key, value) in &all_env_vars { - sidecar_command = sidecar_command.env(key, value); - } - - let (mut rx, _child) = sidecar_command - .current_dir(sidecar_dir) - .spawn() - .expect("Failed to spawn sidecar"); - - // 获取主窗口句柄用于发送事件 - let window = app.get_webview_window("main").unwrap(); - - tauri::async_runtime::spawn(async move { - // 读取诸如 stdout 之类的事件 - while let Some(event) = rx.recv().await { - match event { - CommandEvent::Stdout(line) => { - let output = String::from_utf8_lossy(&line); - println!("Backend stdout: {}", output); - - // 发送到前端 - window - .emit("backend-message", Some(format!("'{}'", output))) - .expect("failed to emit event"); - } - CommandEvent::Stderr(line) => { - let error = String::from_utf8_lossy(&line); - eprintln!("Backend stderr: {}", error); - - window - .emit("backend-error", Some(format!("'{}'", error))) - .expect("failed to emit event"); - } - CommandEvent::Terminated(payload) => { - println!("Backend terminated with code: {:?}", payload.code); - window - .emit("backend-terminated", Some(payload.code)) - .expect("failed to emit event"); - break; - } - _ => { - println!("Backend event: {:?}", event); - } - } - } - }); + // 启动 Sidecar 并把 child handle 存到 state,方便后续 restart_backend_sidecar 使用 + let child = spawn_backend_sidecar(app.handle()).map_err(|e| { + eprintln!("Sidecar 启动失败: {}", e); + e + })?; + app.manage(SidecarHandle(Mutex::new(Some(child)))); Ok(()) }) @@ -114,7 +57,8 @@ pub fn run() { find_executable_path, run_command_with_env, test_ffmpeg_access, - get_install_path_diagnostics + get_install_path_diagnostics, + restart_backend_sidecar ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -286,6 +230,102 @@ async fn test_ffmpeg_access() -> Result { run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await } +// 启动后端 Sidecar:负责装环境变量、spawn、挂 stdout/stderr/terminated 监听并 emit 给前端。 +// 第一次启动 + restart_backend_sidecar 都走这里,保持单一启动路径。 +fn spawn_backend_sidecar(app_handle: &tauri::AppHandle) -> Result { + let exe_path = env::current_exe().map_err(|e| format!("无法获取可执行文件路径: {}", e))?; + let sidecar_dir = exe_path + .parent() + .ok_or("无法获取可执行文件父目录")? + .to_path_buf(); + + // 收集所有系统环境变量并增强 PATH(含 ffmpeg 常见安装位置) + let mut all_env_vars = HashMap::new(); + for (key, value) in env::vars() { + all_env_vars.insert(key, value); + } + let current_path = all_env_vars.get("PATH").cloned().unwrap_or_default(); + let additional_paths = get_additional_binary_paths(); + let enhanced_path = enhance_path_variable(¤t_path, &additional_paths); + all_env_vars.insert("PATH".to_string(), enhanced_path); + + let mut sidecar_command = app_handle + .shell() + .sidecar("BiliNoteBackend") + .map_err(|e| format!("找不到 BiliNoteBackend sidecar: {}", e))?; + for (key, value) in &all_env_vars { + sidecar_command = sidecar_command.env(key, value); + } + + let (mut rx, child) = sidecar_command + .current_dir(sidecar_dir) + .spawn() + .map_err(|e| format!("spawn sidecar 失败: {}", e))?; + + // 异步监听 stdout / stderr / terminated 事件,转发到前端 webview + let app_handle_for_listener = app_handle.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + // window 句柄每次重新取,允许窗口关闭重开 + let window = app_handle_for_listener.get_webview_window("main"); + match event { + CommandEvent::Stdout(line) => { + let output = String::from_utf8_lossy(&line).to_string(); + println!("Backend stdout: {}", output); + if let Some(w) = window { + let _ = w.emit("backend-message", Some(output)); + } + } + CommandEvent::Stderr(line) => { + let error = String::from_utf8_lossy(&line).to_string(); + eprintln!("Backend stderr: {}", error); + if let Some(w) = window { + let _ = w.emit("backend-error", Some(error)); + } + } + CommandEvent::Terminated(payload) => { + println!("Backend terminated with code: {:?}", payload.code); + if let Some(w) = window { + let _ = w.emit("backend-terminated", Some(payload.code)); + } + break; + } + _ => { + println!("Backend event: {:?}", event); + } + } + } + }); + + Ok(child) +} + +// 重启 sidecar:杀旧 child,spawn 新 child,回写到 state。 +#[tauri::command] +fn restart_backend_sidecar( + state: State<'_, SidecarHandle>, + app: tauri::AppHandle, +) -> Result<(), String> { + // 1. 拿出旧 child 并 kill(kill 失败也继续,可能进程已经退了) + { + let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?; + if let Some(child) = guard.take() { + let _ = child.kill(); + } + } + // 2. 重新 spawn + let new_child = spawn_backend_sidecar(&app)?; + { + let mut guard = state.0.lock().map_err(|e| format!("锁 sidecar state 失败: {}", e))?; + *guard = Some(new_child); + } + // 3. emit 一个事件让前端知道已重启 + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("backend-restarted", ()); + } + Ok(()) +} + // 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸; // 父目录不可写时模型 / 配置 / 日志也无法落盘 #[derive(Serialize, Clone)] diff --git a/BillNote_frontend/src/App.tsx b/BillNote_frontend/src/App.tsx index ad7709f..94616d4 100644 --- a/BillNote_frontend/src/App.tsx +++ b/BillNote_frontend/src/App.tsx @@ -6,6 +6,7 @@ import { useCheckBackend } from '@/hooks/useCheckBackend.ts' import { systemCheck } from '@/services/system.ts' import BackendInitDialog from '@/components/BackendInitDialog' import StartupBanner from '@/components/SystemDiagnostic/StartupBanner' +import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator' import Index from '@/pages/Index.tsx' import { HomePage } from './pages/HomePage/Home.tsx' @@ -45,6 +46,7 @@ function App() { return ( <> + 加载中…}> diff --git a/BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx b/BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx new file mode 100644 index 0000000..ce8217e --- /dev/null +++ b/BillNote_frontend/src/components/BackendHealth/BackendHealthIndicator.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { useBackendEvents } from './useBackendEvents' +import BackendLogPanel from './BackendLogPanel' + +// 健康度判定: +// - 绿:sidecar running 且 /sys_health 通 +// - 黄:sidecar running 但 /sys_health 失败 (ffmpeg 缺等) +// - 红:sidecar terminated 或 /sys_health 连续 3 次失败 + +type Health = 'green' | 'yellow' | 'red' | 'unknown' + +const HEALTH_POLL_MS = 5000 +const SYS_HEALTH_PATH = '/api/sys_health' + +function backendBase(): string { + // 与 services/request.ts 用的一致 + const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined + return (fromEnv ?? '').replace(/\/$/, '') +} + +const BackendHealthIndicator = () => { + const { status, isTauri, exitCode, logs, restart, copyLogs } = useBackendEvents() + const [open, setOpen] = useState(false) + const [healthCheckFailures, setHealthCheckFailures] = useState(0) + const [lastHealthOk, setLastHealthOk] = useState(null) + + // 仅在 Tauri 环境挂指示器;纯 web 用户由 useCheckBackend 接管 + useEffect(() => { + if (!isTauri) return + let mounted = true + + async function ping() { + try { + const res = await fetch(`${backendBase()}${SYS_HEALTH_PATH}`) + const ok = res.ok + if (!mounted) return + if (ok) { + setHealthCheckFailures(0) + setLastHealthOk(true) + } + else { + setHealthCheckFailures(c => c + 1) + setLastHealthOk(false) + } + } + catch { + if (!mounted) return + setHealthCheckFailures(c => c + 1) + setLastHealthOk(false) + } + } + + ping() + const t = setInterval(ping, HEALTH_POLL_MS) + return () => { + mounted = false + clearInterval(t) + } + }, [isTauri]) + + if (!isTauri) return null + + const health: Health = (() => { + if (status === 'terminated') return 'red' + if (healthCheckFailures >= 3) return 'red' + if (lastHealthOk === false) return 'yellow' + if (lastHealthOk === true) return 'green' + return 'unknown' + })() + + const colorMap: Record = { + green: 'bg-green-500', + yellow: 'bg-amber-500', + red: 'bg-red-500', + unknown: 'bg-gray-400', + } + + const labelMap: Record = { + green: '后端运行正常', + yellow: '后端运行中(部分检查未通过)', + red: status === 'terminated' ? `后端已退出 (code=${exitCode ?? 'unknown'})` : '后端无响应', + unknown: '后端状态未知', + } + + return ( + <> + + + {open && ( + setOpen(false)} + /> + )} + + ) +} + +export default BackendHealthIndicator diff --git a/BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx b/BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx new file mode 100644 index 0000000..8683400 --- /dev/null +++ b/BillNote_frontend/src/components/BackendHealth/BackendLogPanel.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState } from 'react' +import type { LogEntry, BackendStatus } from './useBackendEvents' + +interface Props { + status: BackendStatus + exitCode: number | null + logs: LogEntry[] + health: 'green' | 'yellow' | 'red' | 'unknown' + onRestart: () => Promise + onCopyLogs: () => Promise + onClose: () => void +} + +const BackendLogPanel = ({ status, exitCode, logs, health, onRestart, onCopyLogs, onClose }: Props) => { + const [restarting, setRestarting] = useState(false) + const [copied, setCopied] = useState(false) + const scrollRef = useRef(null) + + // 新日志进来自动滚到底 + useEffect(() => { + if (scrollRef.current) + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + }, [logs]) + + async function handleRestart() { + setRestarting(true) + try { await onRestart() } + catch { /* errors already in log via useBackendEvents */ } + finally { setRestarting(false) } + } + + async function handleCopy() { + const ok = await onCopyLogs() + setCopied(ok) + setTimeout(() => setCopied(false), 1500) + } + + return ( + <> + {/* 半透明遮罩 */} +
+ + + + ) +} + +export default BackendLogPanel diff --git a/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts b/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts new file mode 100644 index 0000000..cd2a5d2 --- /dev/null +++ b/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react' + +// 桌面端 Sidecar 健康度。监听 Tauri 侧的 backend-message / backend-error / +// backend-terminated / backend-restarted 事件,把 stdout/stderr 缓冲成 ring buffer, +// 同时维护进程运行状态。 + +const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +export type LogLevel = 'info' | 'error' + +export interface LogEntry { + level: LogLevel + text: string + ts: number +} + +export type BackendStatus = 'running' | 'terminated' + +const MAX_LOG_LINES = 200 + +interface BackendEvents { + status: BackendStatus + exitCode: number | null + logs: LogEntry[] + /** 调 Tauri 命令重启 sidecar */ + restart: () => Promise + /** 复制全部日志到剪贴板 */ + copyLogs: () => Promise + isTauri: boolean +} + +export function useBackendEvents(): BackendEvents { + const [status, setStatus] = useState('running') + const [exitCode, setExitCode] = useState(null) + const [logs, setLogs] = useState([]) + // 用 ref 持有最新 logs 数组,append 时不被闭包陷阱卡到旧值 + const logsRef = useRef([]) + + function append(entry: LogEntry) { + const next = logsRef.current.concat(entry) + if (next.length > MAX_LOG_LINES) + next.splice(0, next.length - MAX_LOG_LINES) + logsRef.current = next + setLogs(next) + } + + useEffect(() => { + if (!isTauri) return + + let unlisteners: Array<() => void> = [] + + ;(async () => { + const { listen } = await import('@tauri-apps/api/event') + + const offMsg = await listen('backend-message', event => { + append({ level: 'info', text: stripQuotes(event.payload), ts: Date.now() }) + }) + const offErr = await listen('backend-error', event => { + append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() }) + }) + const offTerm = await listen('backend-terminated', event => { + setStatus('terminated') + setExitCode(event.payload ?? null) + append({ + level: 'error', + text: `[Backend terminated] code=${event.payload ?? 'unknown'}`, + ts: Date.now(), + }) + }) + const offRestart = await listen('backend-restarted', () => { + setStatus('running') + setExitCode(null) + append({ level: 'info', text: '[Backend restarted]', ts: Date.now() }) + }) + + unlisteners = [offMsg, offErr, offTerm, offRestart] + })() + + return () => { + unlisteners.forEach(fn => fn()) + } + }, []) + + async function restart() { + if (!isTauri) return + const { invoke } = await import('@tauri-apps/api/core') + try { + await invoke('restart_backend_sidecar') + } + catch (e) { + append({ level: 'error', text: `[Restart failed] ${(e as Error).message ?? e}`, ts: Date.now() }) + throw e + } + } + + async function copyLogs() { + const text = logsRef.current + .map(l => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.level === 'error' ? 'E' : 'I'} ${l.text}`) + .join('\n') + try { + await navigator.clipboard.writeText(text) + return true + } + catch { + return false + } + } + + return { status, exitCode, logs, restart, copyLogs, isTauri } +} + +// Rust 早期版本 emit 时把 stdout 包了一层 '...',新版本已经直接 emit 原文。 +// 这里做兼容:去掉外层单引号(如果有的话)。 +function stripQuotes(s: string): string { + if (typeof s !== 'string') return String(s) + if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) + return s.slice(1, -1) + return s +}