diff --git a/README.md b/README.md index 538b4a1..d11979f 100644 --- a/README.md +++ b/README.md @@ -145,12 +145,12 @@ scripts\build.bat desktop 输出: ```text -dist\desktop\*.msi dist\desktop\*-setup.exe ``` 桌面版使用 Tauri 启动 Python sidecar。sidecar 默认监听 `127.0.0.1:19082`,运行数据写入系统 app data 目录,不写入安装目录。 首次启动会把安装包内置的 `Configure.json` 和 `ReportScript.sql` 复制到运行数据目录;Windows 下通常是 `%APPDATA%\com.nixevol.capacityreport\`。安装目录中的 `_up_` 只是 Tauri 打包资源目录,程序运行时不会直接编辑它。 +Windows 桌面版使用 NSIS 安装器,卸载时会询问是否同时删除 `%APPDATA%\com.nixevol.capacityreport\` 中的配置、脚本、授权、缓存和日志。 构建桌面版需要 Rust 和 Tauri CLI。脚本会在缺少 Tauri CLI 时自动执行: diff --git a/docs/project_context.md b/docs/project_context.md index 9a60395..852d043 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -1,4 +1,12 @@ # 项目上下文记录 +## 2026-05-20:修复桌面版残留 sidecar 和卸载用户数据选择 + +- `src-tauri/src/main.rs` 启动 sidecar 前会先清理占用 `19082` 的旧 `capareport-server` 监听进程,避免卸载/重装或异常退出后连到旧服务;启动后不只检查端口可连接,还会请求 `/health` 返回 HTTP 200 才继续。 +- `frontend/src/api/client.ts` 在 Tauri 运行环境下即使构建时未注入 `VITE_API_BASE`,也会兜底使用 `http://127.0.0.1:19082`,并保留短暂 fetch 重试,避免桌面版出现配置页默认空值和脚本页 `Failed to fetch`。 +- Windows 桌面包收敛为 NSIS `setup.exe`,不再同时产出 MSI;新增 `src-tauri/windows/nsis-hooks.nsh`,卸载前会尝试关闭桌面进程和 sidecar,卸载后会询问是否删除 `%APPDATA%\com.nixevol.capacityreport` 中的配置、脚本、授权、缓存和日志。 +- 已执行 `scripts\build.bat desktop`,产物为 `dist\desktop\CapacityReport_2.0.2_x64-setup.exe`;脚本已自动清理 `dist/.tmp`、`frontend/dist`、`src-tauri/target` 和 `src-tauri/binaries`。 +- 已用新 NSIS 包静默覆盖安装并启动桌面版验证:`/health` 正常,`/api/config/full` 读取到 32 个字段映射,`/api/script/content` 成功读取 AppData 下的 `ReportScript.sql`;验证结束后已停止测试启动的桌面和 sidecar 进程。 + ## 2026-05-20:修复桌面版启动期配置和脚本加载竞态 - 桌面版运行配置和脚本仍从安装包资源 `Configure.json`、`ReportScript.sql` 首次复制到系统 AppData 后读取;安装目录中的 `_up_` 是 Tauri 对 `../` 资源的打包目录,不是后端实际运行目录。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bd07348..b964a5a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,8 @@ import type { ApiError, ApiErrorDetail } from '../types'; const TOKEN_KEY = 'capacity_report_token'; -const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); +const DESKTOP_API_BASE = 'http://127.0.0.1:19082'; +const API_BASE = resolveApiBase(); const API_FETCH_RETRIES = 8; const API_FETCH_RETRY_DELAY_MS = 500; let onUnauthorized: (() => void) | null = null; @@ -255,6 +256,22 @@ function apiUrl(url: string): string { return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`; } +function resolveApiBase(): string { + const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, ''); + if (configured) { + return configured; + } + return isTauriRuntime() ? DESKTOP_API_BASE : ''; +} + +function isTauriRuntime(): boolean { + const win = window as Window & { + __TAURI__?: unknown; + __TAURI_INTERNALS__?: unknown; + }; + return Boolean(win.__TAURI__ || win.__TAURI_INTERNALS__ || navigator.userAgent.includes('Tauri')); +} + function isLoginRequest(url: string): boolean { try { const parsed = new URL(url, window.location.origin); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4cfef84..c97b33c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,7 @@ use std::error::Error; use std::fs; +use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -69,6 +70,7 @@ fn start_server(app: &tauri::App) -> Result<(), Box> { fs::create_dir_all(data_dir.join("logs"))?; copy_resource(app, "Configure.json", &data_dir)?; copy_resource(app, "ReportScript.sql", &data_dir)?; + stop_existing_server_on_port(19082); let (mut rx, child) = app .shell() @@ -80,7 +82,7 @@ fn start_server(app: &tauri::App) -> Result<(), Box> { .args(["--host", "127.0.0.1", "--port", "19082"]) .spawn()?; - if let Err(error) = wait_for_server("127.0.0.1:19082", Duration::from_secs(20)) { + if let Err(error) = wait_for_server("127.0.0.1:19082", Duration::from_secs(60)) { let _ = child.kill(); return Err(error); } @@ -97,7 +99,7 @@ fn wait_for_server(addr: &str, timeout: Duration) -> Result<(), Box> let socket_addr: SocketAddr = addr.parse()?; let started_at = Instant::now(); while started_at.elapsed() < timeout { - if TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)).is_ok() { + if health_check(&socket_addr).unwrap_or(false) { return Ok(()); } thread::sleep(Duration::from_millis(250)); @@ -105,6 +107,48 @@ fn wait_for_server(addr: &str, timeout: Duration) -> Result<(), Box> Err(format!("Server did not start within {} seconds", timeout.as_secs()).into()) } +fn health_check(socket_addr: &SocketAddr) -> Result> { + let mut stream = TcpStream::connect_timeout(socket_addr, Duration::from_millis(500))?; + stream.set_read_timeout(Some(Duration::from_millis(500)))?; + stream.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")) +} + +#[cfg(target_os = "windows")] +fn stop_existing_server_on_port(port: u16) { + let Ok(output) = std::process::Command::new("netstat") + .args(["-ano", "-p", "tcp"]) + .output() + else { + return; + }; + + let marker = format!(":{port}"); + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if !line.contains(&marker) || !line.to_ascii_uppercase().contains("LISTENING") { + continue; + } + + let Some(pid) = line.split_whitespace().last() else { + continue; + }; + if pid.parse::().is_err() { + continue; + } + + let _ = std::process::Command::new("taskkill") + .args(["/F", "/T", "/PID", pid]) + .status(); + } +} + +#[cfg(not(target_os = "windows"))] +fn stop_existing_server_on_port(_port: u16) {} + fn stop_server(app: &tauri::AppHandle) { let state = app.state::(); let child = state.0.lock().expect("server state lock poisoned").take(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8c4cc28..61c59c4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,9 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": [ + "nsis" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -43,6 +45,7 @@ "language": "zh-CN" }, "nsis": { + "installerHooks": "./windows/nsis-hooks.nsh", "languages": [ "SimpChinese" ] diff --git a/src-tauri/windows/nsis-hooks.nsh b/src-tauri/windows/nsis-hooks.nsh new file mode 100644 index 0000000..1d9a206 --- /dev/null +++ b/src-tauri/windows/nsis-hooks.nsh @@ -0,0 +1,11 @@ +!macro NSIS_HOOK_PREUNINSTALL + nsExec::ExecToLog 'taskkill /F /T /IM capacity-report-desktop.exe' + nsExec::ExecToLog 'taskkill /F /T /IM capareport-server.exe' +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + MessageBox MB_YESNO|MB_ICONQUESTION "是否同时删除本机用户数据?$\r$\n$\r$\n这将删除配置、脚本、授权、缓存和日志:$\r$\n$APPDATA\com.nixevol.capacityreport" IDYES DeleteUserData IDNO Done + DeleteUserData: + RMDir /r "$APPDATA\com.nixevol.capacityreport" + Done: +!macroend