feat(desktop): 启动期路径诊断 + 顶端横幅,主动暴露已知失败因素

桌面端历史痛点: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) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-09 14:22:41 +08:00
parent e89090bed0
commit 9a64a2da8e
3 changed files with 193 additions and 1 deletions

View File

@@ -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();
// 等前端首屏挂载好 listenersetup 阶段 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<String, String> {
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(

View File

@@ -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 (
<>
<StartupBanner />
<BackendInitDialog open={loading} />
</>
)
@@ -42,6 +44,7 @@ function App() {
// 后端已初始化,渲染主应用
return (
<>
<StartupBanner />
<BrowserRouter>
<Suspense fallback={<div className="flex h-screen items-center justify-center"></div>}>
<Routes>

View File

@@ -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<BannerState | null>(null)
useEffect(() => {
if (!isTauri) return
let unlisteners: Array<() => void> = []
;(async () => {
const { listen } = await import('@tauri-apps/api/event')
const offWarning = await listen<DiagnosticPayload>('backend-warning', event => {
const { title, detail } = describeWarning(event.payload || {})
setBanner({
severity: 'warning',
title,
detail,
payload: event.payload,
dismissible: true,
})
})
const offTerminated = await listen<number | null>('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<Severity, string> = {
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<Severity, string> = {
info: '',
warning: '⚠️',
error: '✕',
}
return (
<div
className={`fixed left-0 right-0 top-0 z-[9999] flex items-start gap-3 border-b px-4 py-2 text-sm shadow-sm ${colorByLevel[banner.severity]}`}
>
<span className="text-lg">{iconByLevel[banner.severity]}</span>
<div className="flex-1 min-w-0">
<div className="font-medium">{banner.title}</div>
<pre className="mt-0.5 whitespace-pre-wrap break-words font-sans text-xs opacity-90">
{banner.detail}
</pre>
</div>
{banner.dismissible && (
<button
className="shrink-0 rounded px-2 py-0.5 text-xs hover:bg-black/10"
onClick={() => setBanner(null)}
>
</button>
)}
</div>
)
}
export default StartupBanner