From 9a64a2da8e9064916ccee35d103423da6897bbf4 Mon Sep 17 00:00:00 2001 From: huangjianwu Date: Sat, 9 May 2026 14:22:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E5=90=AF=E5=8A=A8=E6=9C=9F?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=AF=8A=E6=96=AD=20+=20=E9=A1=B6=E7=AB=AF?= =?UTF-8?q?=E6=A8=AA=E5=B9=85=EF=BC=8C=E4=B8=BB=E5=8A=A8=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=E5=B7=B2=E7=9F=A5=E5=A4=B1=E8=B4=A5=E5=9B=A0=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面端历史痛点:PyInstaller sidecar 在含非 ASCII / 含空格 / 不可写的安装路径下 直接炸(README:36 仅文字警告"不要中文路径",无主动防御)。Sidecar 退出事件 lib.rs 已 emit 但前端没 listener 消化,用户看到的是空白主页。 - src-tauri/src/lib.rs: · setup 中 env::current_exe() 之后做 InstallPathDiagnostics(is_ascii / 空格 / 父目录 write_probe),命中任一异常就 emit 'backend-warning' 给前端 · 用 std::thread::spawn + 1500ms sleep 等首屏 listener 挂上再 emit,避免事件丢失 · 新增 Tauri command get_install_path_diagnostics,前端可主动重查(用户卸载到 新目录后首次启动) - BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx(新建): · 监听 backend-warning(路径警告,可关闭)+ backend-terminated(致命,常驻) · 纯 web 环境(无 __TAURI_INTERNALS__)下静默不挂载,对桌面端定向起效 · backend-error(stderr 噪音)暂不展示,留给后续 P2 日志面板 - App.tsx:StartupBanner 在 BackendInitDialog 之前就挂载,让"后端还在初始化时" 也能看见路径警告(正是该场景容易炸) 不改任何业务逻辑;纯桌面端 UX 加固。 Co-Authored-By: Claude Opus 4.7 (1M context) --- BillNote_frontend/src-tauri/src/lib.rs | 68 +++++++++- BillNote_frontend/src/App.tsx | 3 + .../SystemDiagnostic/StartupBanner.tsx | 123 ++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx diff --git a/BillNote_frontend/src-tauri/src/lib.rs b/BillNote_frontend/src-tauri/src/lib.rs index f6407df..9c33e0a 100644 --- a/BillNote_frontend/src-tauri/src/lib.rs +++ b/BillNote_frontend/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::process::CommandEvent; use std::env; use std::collections::HashMap; +use std::path::Path; +use serde::Serialize; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -20,6 +22,21 @@ pub fn run() { let exe_path = env::current_exe().expect("无法获取当前可执行文件路径"); let sidecar_dir = exe_path.parent().expect("无法获取可执行文件的父目录"); + // 安装路径诊断:PyInstaller sidecar 在含非 ASCII / 空格的路径下经常炸(README 已警告但缺主动防御) + // 命中时把诊断信息 emit 给前端,由顶端横幅展示,不阻断启动 + let diag = analyze_install_path(&exe_path); + if diag.path_has_non_ascii || diag.path_has_space || !diag.parent_writable { + let app_handle = app.handle().clone(); + // 等前端首屏挂载好 listener;setup 阶段 window 已存在但 React 还没 render + // 用独立线程 + 标准 sleep,不引入 tokio 依赖 + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.emit("backend-warning", &diag); + } + }); + } + // 收集所有系统环境变量 let mut all_env_vars = HashMap::new(); for (key, value) in env::vars() { @@ -96,7 +113,8 @@ pub fn run() { get_system_env_vars, find_executable_path, run_command_with_env, - test_ffmpeg_access + test_ffmpeg_access, + get_install_path_diagnostics ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -268,6 +286,54 @@ async fn test_ffmpeg_access() -> Result { run_command_with_env("ffmpeg".to_string(), vec!["-version".to_string()]).await } +// 安装路径诊断:PyInstaller 在含非 ASCII / 空格的路径下加载 _internal/* 经常炸; +// 父目录不可写时模型 / 配置 / 日志也无法落盘 +#[derive(Serialize, Clone)] +struct InstallPathDiagnostics { + exe_path: String, + path_has_non_ascii: bool, + path_has_space: bool, + parent_writable: bool, + platform: String, +} + +fn analyze_install_path(exe_path: &Path) -> InstallPathDiagnostics { + let path_str = exe_path.to_string_lossy().to_string(); + // 不在 ASCII 范围内的字符(中文 / 日文 / 西里尔等都会命中 PyInstaller 路径解析坑) + let has_non_ascii = path_str.chars().any(|c| !c.is_ascii()); + // 空格本身在 Windows shell 引号场景偶尔出问题,且 macOS path 里也偶尔触发 sidecar 启动失败 + let has_space = path_str.contains(' '); + // 父目录可写:PyInstaller 解压 _internal/、写日志、写配置都需要这个 + let parent = exe_path.parent(); + let parent_writable = parent + .and_then(|p| { + let probe = p.join(".bilinote_write_probe"); + match std::fs::write(&probe, b"x") { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + Some(true) + } + Err(_) => Some(false), + } + }) + .unwrap_or(false); + + InstallPathDiagnostics { + exe_path: path_str, + path_has_non_ascii: has_non_ascii, + path_has_space: has_space, + parent_writable, + platform: std::env::consts::OS.to_string(), + } +} + +// Tauri 命令:让前端按需重新查询诊断结果(比如用户卸载到新目录后重启) +#[tauri::command] +fn get_install_path_diagnostics() -> InstallPathDiagnostics { + let exe_path = env::current_exe().unwrap_or_default(); + analyze_install_path(&exe_path) +} + // 可选:添加一个函数来动态更新 sidecar 的环境变量 #[tauri::command] async fn update_sidecar_environment( diff --git a/BillNote_frontend/src/App.tsx b/BillNote_frontend/src/App.tsx index ea47b7f..ad7709f 100644 --- a/BillNote_frontend/src/App.tsx +++ b/BillNote_frontend/src/App.tsx @@ -5,6 +5,7 @@ import { useTaskPolling } from '@/hooks/useTaskPolling.ts' import { useCheckBackend } from '@/hooks/useCheckBackend.ts' import { systemCheck } from '@/services/system.ts' import BackendInitDialog from '@/components/BackendInitDialog' +import StartupBanner from '@/components/SystemDiagnostic/StartupBanner' import Index from '@/pages/Index.tsx' import { HomePage } from './pages/HomePage/Home.tsx' @@ -34,6 +35,7 @@ function App() { if (!initialized) { return ( <> + ) @@ -42,6 +44,7 @@ function App() { // 后端已初始化,渲染主应用 return ( <> + 加载中…}> diff --git a/BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx b/BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx new file mode 100644 index 0000000..221ceef --- /dev/null +++ b/BillNote_frontend/src/components/SystemDiagnostic/StartupBanner.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react' + +// 桌面端启动诊断横幅。监听 Tauri 侧 emit 的 backend-warning / backend-error / backend-terminated。 +// 只在 Tauri 环境生效;纯 web 环境(无 window.__TAURI_INTERNALS__)下静默不挂载。 + +type Severity = 'info' | 'warning' | 'error' + +interface DiagnosticPayload { + exe_path?: string + path_has_non_ascii?: boolean + path_has_space?: boolean + parent_writable?: boolean + platform?: string +} + +interface BannerState { + severity: Severity + title: string + detail: string + payload?: DiagnosticPayload + dismissible: boolean +} + +const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +function describeWarning(payload: DiagnosticPayload): { title: string; detail: string } { + const parts: string[] = [] + if (payload.path_has_non_ascii) { + parts.push('安装路径包含非 ASCII 字符(中文 / 日文等)') + } + if (payload.path_has_space) { + parts.push('安装路径包含空格') + } + if (payload.parent_writable === false) { + parts.push('安装目录不可写(缺少权限或只读)') + } + return { + title: '检测到可能导致后端启动失败的安装路径', + detail: + `${parts.join(';')}。\n` + + '建议把 BiliNote 重新安装到一个纯英文、无空格、可写的路径下(如 C:\\BiliNote\\ 或 /Applications/)。\n' + + `当前路径:${payload.exe_path || '未知'}`, + } +} + +const StartupBanner = () => { + const [banner, setBanner] = useState(null) + + useEffect(() => { + if (!isTauri) return + + let unlisteners: Array<() => void> = [] + + ;(async () => { + const { listen } = await import('@tauri-apps/api/event') + + const offWarning = await listen('backend-warning', event => { + const { title, detail } = describeWarning(event.payload || {}) + setBanner({ + severity: 'warning', + title, + detail, + payload: event.payload, + dismissible: true, + }) + }) + + const offTerminated = await listen('backend-terminated', event => { + setBanner({ + severity: 'error', + title: '后端进程已退出', + detail: `退出码:${event.payload ?? '未知'}。打开「部署监控」或重启应用以恢复。`, + dismissible: false, + }) + }) + + // backend-error 是 sidecar stderr,量大噪音多,这里不直接展示,留给 P2 的日志面板。 + unlisteners = [offWarning, offTerminated] + })() + + return () => { + unlisteners.forEach(fn => fn()) + } + }, []) + + if (!banner) return null + + const colorByLevel: Record = { + info: 'bg-blue-50 border-blue-300 text-blue-900', + warning: 'bg-amber-50 border-amber-300 text-amber-900', + error: 'bg-red-50 border-red-300 text-red-900', + } + + const iconByLevel: Record = { + info: 'ℹ️', + warning: '⚠️', + error: '✕', + } + + return ( +
+ {iconByLevel[banner.severity]} +
+
{banner.title}
+
+          {banner.detail}
+        
+
+ {banner.dismissible && ( + + )} +
+ ) +} + +export default StartupBanner