diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f9930..761eef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ --- +## [0.1.2] - 未发布 + +### 优化 +- 优化「备份与迁移」:导出不再弹出保存位置对话框,「配置」与「全部数据」都直接保存到默认下载目录,导出完成后就地显示文件路径与「打开所在文件夹」按钮,和其他下载体验保持一致。同时优化了页面文案,更清楚地说明两种备份分别包含什么、适合什么场景(「全部数据」在「配置」基础上额外包含历史记录与录音)。 + +### 修复 +- 修复长时间后台运行后偶发「一直显示处理中、最后超时且无结果」的问题:SayIt 主窗口常驻后台隐藏时,系统会节流其心跳定时器,导致与服务器的连接被判为空闲而每隔约 5 分钟被回收;若录音恰好在连接被回收的瞬间开始,本次识别就会丢失并超时。现已让后台定时器不再被节流,保持心跳正常、连接稳定。 +- 识别超时时不再丢失录音:即使这次识别超时没有结果,录音也会保存到历史记录中,可随时点「重新识别」重试。 + +--- + ## [0.1.1] - 2026-07-22 ### 新增 diff --git a/client/package.json b/client/package.json index c615f30..666bfbe 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "sayit-tauri", - "version": "0.1.1", + "version": "0.1.2", "description": "SayIt - Voice to Text Desktop App (Tauri)", "type": "module", "scripts": { diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 7c5845e..b169e0f 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -4024,7 +4024,7 @@ dependencies = [ [[package]] name = "sayit" -version = "0.1.1" +version = "0.1.2" dependencies = [ "base64 0.22.1", "bzip2", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index dbc986b..ac39bde 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sayit" -version = "0.1.1" +version = "0.1.2" authors = ["Liu Qianglong", "Claude"] edition = "2021" diff --git a/client/src-tauri/src/commands/backup.rs b/client/src-tauri/src/commands/backup.rs new file mode 100644 index 0000000..d540437 --- /dev/null +++ b/client/src-tauri/src/commands/backup.rs @@ -0,0 +1,479 @@ +// 备份 / 恢复:配置(JSON)与全部数据(zip,含音频)的导出与导入。 +// +// 设计要点: +// - 导入走活动数据库的正常写入通道(app_settings upsert + 集合表整表替换), +// 不替换 sqlite 文件、不涉及文件锁,导入完成后由前端触发重启使内存状态重载。 +// - 语义为「覆盖」:集合表整表替换;app_settings 逐 key 覆盖(不删除未出现的 key)。 +// - 「配置」不含使用统计 stats,避免覆盖本机计数;「全部」包含 stats。 +// - 全部导入时,历史记录里的 audioFilePath 是旧机绝对路径,会重写为本机 audio 目录下的同名文件。 + +use serde::Serialize; +use serde_json::{json, Value}; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, State}; + +use crate::storage::Storage; + +/// 备份文件格式版本。导入时若备份版本高于此值则拒绝。 +const FORMAT_VERSION: i64 = 1; +/// 「配置」档不导出/导入的 app_settings key(使用统计属于使用数据,不属于配置)。 +const CONFIG_EXCLUDE: &[&str] = &["stats"]; + +fn app_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +fn audio_dir() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("com.sayit.app") + .join("audio") +} + +fn backup_dir() -> PathBuf { + dirs::download_dir() + .or_else(dirs::document_dir) + .unwrap_or_else(|| { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("com.sayit.app") + }) + .join("SayIt Backups") +} + +fn timestamped_backup_path(prefix: &str, extension: &str) -> PathBuf { + let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S-%3f"); + backup_dir().join(format!("{}-{}.{}", prefix, timestamp, extension)) +} + +/// 取路径的文件名部分(兼容 / 和 \ 分隔符,跨平台备份用)。 +fn basename(path: &str) -> String { + path.rsplit(|c| c == '/' || c == '\\') + .next() + .unwrap_or(path) + .to_string() +} + +fn build_config_value(storage: &Storage) -> Value { + json!({ + "kind": "config", + "formatVersion": FORMAT_VERSION, + "appVersion": app_version(), + "exportedAt": chrono::Utc::now().to_rfc3339(), + "appSettings": storage.export_app_settings(CONFIG_EXCLUDE), + "promptPresets": storage.get("promptPresets", None), + "appPromptRules": storage.get("appPromptRules", None), + }) +} + +fn build_full_value(storage: &Storage) -> Value { + json!({ + "kind": "full", + "formatVersion": FORMAT_VERSION, + "appVersion": app_version(), + "exportedAt": chrono::Utc::now().to_rfc3339(), + "appSettings": storage.export_app_settings(&[]), + "promptPresets": storage.get("promptPresets", None), + "appPromptRules": storage.get("appPromptRules", None), + "history": storage.get("history", None), + "manualCorrections": storage.get("manualCorrections", None), + "feedbackQueue": storage.get("feedbackQueue", None), + }) +} + +fn check_version(data: &Value) -> Result<(), String> { + let v = data.get("formatVersion").and_then(|x| x.as_i64()).unwrap_or(0); + if v > FORMAT_VERSION { + return Err(format!( + "备份文件版本({})高于当前应用支持的版本({}),请升级 SayIt 后再导入。", + v, FORMAT_VERSION + )); + } + Ok(()) +} + +/// 应用配置部分(设置 + prompt 预设 + 分应用规则),两档共用。 +fn apply_config_part(storage: &Storage, data: &Value, settings_exclude: &[&str]) -> Result<(), String> { + if let Some(obj) = data.get("appSettings").and_then(|v| v.as_object()) { + storage + .import_app_settings(obj, settings_exclude) + .map_err(|e| format!("写入设置失败: {}", e))?; + } + if let Some(arr) = data.get("promptPresets") { + if arr.is_array() { + storage.set("promptPresets", arr).map_err(|e| format!("写入 Prompt 预设失败: {}", e))?; + } + } + if let Some(arr) = data.get("appPromptRules") { + if arr.is_array() { + storage.set("appPromptRules", arr).map_err(|e| format!("写入应用规则失败: {}", e))?; + } + } + Ok(()) +} + +/// 重写单条历史记录的音频路径:把目录段换成本机 audio 目录,文件名不变。 +fn rewrite_audio_path(rec: &Value, adir: &Path) -> Value { + let mut rec = rec.clone(); + if let Some(obj) = rec.as_object_mut() { + if let Some(p) = obj.get("audioFilePath").and_then(|v| v.as_str()).map(|s| s.to_string()) { + let base = basename(&p); + if !base.is_empty() { + let new_path = adir.join(&base).to_string_lossy().to_string(); + obj.insert("audioFilePath".to_string(), Value::String(new_path)); + } + } + } + rec +} + +// ─── 导出 ─── + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct BackupExportProgress { + status: String, + phase: String, + file_path: String, + current_file: Option, + processed_files: u64, + total_files: u64, + processed_bytes: u64, + total_bytes: u64, + percent: f64, + error: Option, +} + +struct AudioExportFile { + path: PathBuf, + name: String, + size: u64, +} + +fn emit_export_progress(app: &AppHandle, progress: BackupExportProgress) { + let _ = app.emit("backup-export-progress", progress); +} + +fn collect_audio_export_files() -> Vec { + let mut files = Vec::new(); + let adir = audio_dir(); + if let Ok(entries) = fs::read_dir(adir) { + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if !name.is_empty() { + files.push(AudioExportFile { + path, + name, + size: metadata.len(), + }); + } + } + } + files.sort_by(|a, b| a.name.cmp(&b.name)); + files +} + +fn audio_progress_percent(processed_bytes: u64, total_bytes: u64) -> f64 { + if total_bytes == 0 { + 95.0 + } else { + (5.0 + processed_bytes as f64 / total_bytes as f64 * 90.0).min(95.0) + } +} + +#[tauri::command] +pub fn get_backup_directory() -> Result { + let directory = backup_dir(); + fs::create_dir_all(&directory).map_err(|e| format!("创建备份目录失败: {}", e))?; + Ok(directory.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn export_config(storage: State<'_, Storage>) -> Result { + let out_path = timestamped_backup_path("sayit-config", "json"); + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建备份目录失败: {}", e))?; + } + let payload = build_config_value(storage.inner()); + let content = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; + fs::write(&out_path, content).map_err(|e| format!("写入文件失败: {}", e))?; + let path = out_path.to_string_lossy().to_string(); + log::info!("Config backup exported to {}", path); + Ok(path) +} + +#[tauri::command] +pub async fn export_full( + app: AppHandle, + storage: State<'_, Storage>, +) -> Result { + let output = timestamped_backup_path("sayit-backup", "zip"); + let out_path = output.to_string_lossy().to_string(); + let temp_path = PathBuf::from(format!("{}.part", out_path)); + let audio_files = collect_audio_export_files(); + let total_files = audio_files.len() as u64; + let total_bytes = audio_files.iter().map(|file| file.size).sum::(); + + emit_export_progress( + &app, + BackupExportProgress { + status: "running".to_string(), + phase: "preparing".to_string(), + file_path: out_path.clone(), + current_file: None, + processed_files: 0, + total_files, + processed_bytes: 0, + total_bytes, + percent: 2.0, + error: None, + }, + ); + + let export_result = (|| -> Result<(), String> { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建导出目录失败: {}", e))?; + } + + let payload = build_full_value(storage.inner()); + let json_str = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; + emit_export_progress( + &app, + BackupExportProgress { + status: "running".to_string(), + phase: "packingData".to_string(), + file_path: out_path.clone(), + current_file: Some("backup.json".to_string()), + processed_files: 0, + total_files, + processed_bytes: 0, + total_bytes, + percent: 5.0, + error: None, + }, + ); + + let file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?; + let mut zip = zip::ZipWriter::new(file); + let text_opts = zip::write::FileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + let audio_opts = zip::write::FileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + + zip.start_file("backup.json", text_opts) + .map_err(|e| e.to_string())?; + zip.write_all(json_str.as_bytes()) + .map_err(|e| e.to_string())?; + + let mut processed_bytes = 0u64; + let mut processed_files = 0u64; + let mut last_emit = Instant::now() - Duration::from_secs(1); + let mut buffer = vec![0u8; 256 * 1024]; + + for audio in &audio_files { + zip.start_file(format!("audio/{}", audio.name), audio_opts) + .map_err(|e| e.to_string())?; + let mut source = fs::File::open(&audio.path) + .map_err(|e| format!("读取音频 {} 失败: {}", audio.name, e))?; + + loop { + let read = source + .read(&mut buffer) + .map_err(|e| format!("读取音频 {} 失败: {}", audio.name, e))?; + if read == 0 { + break; + } + zip.write_all(&buffer[..read]).map_err(|e| e.to_string())?; + processed_bytes += read as u64; + + if last_emit.elapsed() >= Duration::from_millis(150) { + emit_export_progress( + &app, + BackupExportProgress { + status: "running".to_string(), + phase: "packingAudio".to_string(), + file_path: out_path.clone(), + current_file: Some(audio.name.clone()), + processed_files, + total_files, + processed_bytes, + total_bytes, + percent: audio_progress_percent(processed_bytes, total_bytes), + error: None, + }, + ); + last_emit = Instant::now(); + } + } + + processed_files += 1; + emit_export_progress( + &app, + BackupExportProgress { + status: "running".to_string(), + phase: "packingAudio".to_string(), + file_path: out_path.clone(), + current_file: Some(audio.name.clone()), + processed_files, + total_files, + processed_bytes, + total_bytes, + percent: audio_progress_percent(processed_bytes, total_bytes), + error: None, + }, + ); + } + + emit_export_progress( + &app, + BackupExportProgress { + status: "running".to_string(), + phase: "finalizing".to_string(), + file_path: out_path.clone(), + current_file: None, + processed_files, + total_files, + processed_bytes, + total_bytes, + percent: 98.0, + error: None, + }, + ); + zip.finish().map_err(|e| e.to_string())?; + + if output.exists() { + fs::remove_file(&output).map_err(|e| format!("替换旧备份失败: {}", e))?; + } + fs::rename(&temp_path, &output).map_err(|e| format!("完成备份文件失败: {}", e))?; + Ok(()) + })(); + + match export_result { + Ok(()) => { + emit_export_progress( + &app, + BackupExportProgress { + status: "completed".to_string(), + phase: "completed".to_string(), + file_path: out_path.clone(), + current_file: None, + processed_files: total_files, + total_files, + processed_bytes: total_bytes, + total_bytes, + percent: 100.0, + error: None, + }, + ); + log::info!("Full backup exported to {}", out_path); + Ok(out_path) + } + Err(error) => { + let _ = fs::remove_file(&temp_path); + emit_export_progress( + &app, + BackupExportProgress { + status: "failed".to_string(), + phase: "failed".to_string(), + file_path: out_path, + current_file: None, + processed_files: 0, + total_files, + processed_bytes: 0, + total_bytes, + percent: 0.0, + error: Some(error.clone()), + }, + ); + Err(error) + } + } +} + +// ─── 导入 ─── + +#[tauri::command] +pub async fn import_config(in_path: String, storage: State<'_, Storage>) -> Result<(), String> { + let content = fs::read_to_string(&in_path).map_err(|e| format!("读取文件失败: {}", e))?; + let data: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析失败(文件可能损坏或格式不正确): {}", e))?; + check_version(&data)?; + // 配置导入排除 stats,避免覆盖本机使用统计 + apply_config_part(storage.inner(), &data, CONFIG_EXCLUDE)?; + Ok(()) +} + +#[tauri::command] +pub async fn import_full(in_path: String, storage: State<'_, Storage>) -> Result<(), String> { + let file = fs::File::open(&in_path).map_err(|e| format!("打开文件失败: {}", e))?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("不是有效的备份压缩包: {}", e))?; + + // 1. 读取并校验 backup.json + let mut json_str = String::new(); + { + let mut entry = archive + .by_name("backup.json") + .map_err(|_| "备份包缺少 backup.json,可能不是 SayIt 全量备份。".to_string())?; + entry.read_to_string(&mut json_str).map_err(|e| e.to_string())?; + } + let data: Value = serde_json::from_str(&json_str).map_err(|e| format!("解析 backup.json 失败: {}", e))?; + check_version(&data)?; + + // 2. 释放音频文件到本机 audio 目录 + let adir = audio_dir(); + fs::create_dir_all(&adir).map_err(|e| format!("创建音频目录失败: {}", e))?; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; + let name = entry.name().to_string(); + if let Some(rest) = name.strip_prefix("audio/") { + if rest.is_empty() || entry.is_dir() { + continue; + } + let base = basename(rest); + if base.is_empty() { + continue; + } + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| e.to_string())?; + fs::write(adir.join(&base), &buf).map_err(|e| format!("写入音频 {} 失败: {}", base, e))?; + } + } + + // 3. 应用配置部分(含 stats) + apply_config_part(storage.inner(), &data, &[])?; + + // 4. 历史:重写音频路径后整表替换 + if let Some(history) = data.get("history").and_then(|v| v.as_array()) { + let rewritten: Vec = history.iter().map(|rec| rewrite_audio_path(rec, &adir)).collect(); + storage + .set("history", &Value::Array(rewritten)) + .map_err(|e| format!("写入历史失败: {}", e))?; + } + if let Some(arr) = data.get("manualCorrections") { + if arr.is_array() { + storage.set("manualCorrections", arr).map_err(|e| format!("写入人工校正失败: {}", e))?; + } + } + if let Some(arr) = data.get("feedbackQueue") { + if arr.is_array() { + storage.set("feedbackQueue", arr).map_err(|e| format!("写入反馈队列失败: {}", e))?; + } + } + Ok(()) +} + +/// 导入完成后由前端调用,重启应用使内存中的设置/供应商状态全量重载。 +#[tauri::command] +pub fn restart_app(app: tauri::AppHandle) { + app.restart(); +} diff --git a/client/src-tauri/src/commands/mod.rs b/client/src-tauri/src/commands/mod.rs index 9c8faaa..d04c8ed 100644 --- a/client/src-tauri/src/commands/mod.rs +++ b/client/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod audio; pub mod audio_mute; +pub mod backup; pub mod diagnostics; pub mod export; pub mod paste; diff --git a/client/src-tauri/src/commands/system.rs b/client/src-tauri/src/commands/system.rs index 29937ea..7c72a88 100644 --- a/client/src-tauri/src/commands/system.rs +++ b/client/src-tauri/src/commands/system.rs @@ -45,12 +45,17 @@ fn open_log_file() -> Option { .ok() } -fn rotate_if_needed() { +fn rotate_if_needed(file: &mut Option) { let path = log_file_path(); let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); if size < MAX_LOG_SIZE { return; } + + // Windows 不允许重命名仍由本进程打开的日志文件。先释放句柄,否则轮转会 + // 静默失败,正式版长期运行后 sayit.log 会无限增长。 + *file = None; + // Rotate: sayit.log -> sayit.1.log, sayit.1.log -> sayit.2.log, etc. let dir = log_dir(); for i in (1..ROTATED_FILES_KEEP).rev() { @@ -60,6 +65,7 @@ fn rotate_if_needed() { } let rotated = dir.join("sayit.1.log"); let _ = std::fs::rename(&path, &rotated); + *file = open_log_file(); } pub fn write_log_line(line: &str) { @@ -68,7 +74,7 @@ pub fn write_log_line(line: &str) { // 之后所有诊断日志都写不出来"的雪崩,恰恰是排查间歇性问题最怕遇到的情况。 let mut guard = LOG_FILE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); // Rotate check — reopen file if needed - rotate_if_needed(); + rotate_if_needed(&mut guard); if guard.is_none() { *guard = open_log_file(); } @@ -507,3 +513,32 @@ pub fn reveal_file_in_folder(file_path: String) -> Result<(), String> { } Ok(()) } + +/// 直接打开指定文件夹。 +#[tauri::command] +pub fn open_folder(folder_path: String) -> Result<(), String> { + let path = std::path::PathBuf::from(&folder_path); + if !path.is_dir() { + return Err(format!("文件夹不存在: {}", folder_path)); + } + + #[cfg(target_os = "windows")] + std::process::Command::new("explorer") + .arg(&path) + .spawn() + .map_err(|e| format!("打开文件夹失败: {}", e))?; + + #[cfg(target_os = "macos")] + std::process::Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| format!("打开文件夹失败: {}", e))?; + + #[cfg(target_os = "linux")] + std::process::Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| format!("打开文件夹失败: {}", e))?; + + Ok(()) +} diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index ca7fa1d..c0cff96 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -19,6 +19,26 @@ use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent} use tauri::menu::{MenuBuilder, MenuItemBuilder}; use std::thread; +/// Environment-level WebView2 flags must be identical for every webview that shares +/// com.sayit.app/EBWebView. Keep them global; never copy them into a single window's +/// `additionalBrowserArgs` in tauri.conf.json (WebView2 rejects the second environment +/// with ERROR_INVALID_STATE when the option sets differ). +const WEBVIEW2_BROWSER_ARGS: &str = + "--ignore-certificate-errors --auto-accept-camera-and-microphone-capture \ + --disable-backgrounding-occluded-windows --disable-renderer-backgrounding \ + --disable-background-timer-throttling"; + +fn browser_args_fingerprint(value: &str) -> String { + // Stable FNV-1a fingerprint: enough to compare field reports without logging a + // potentially sensitive inherited proxy argument verbatim. + let mut hash = 0xcbf29ce484222325u64; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + /// Clean up expired audio files based on retention setting. fn cleanup_expired_audio(storage: &Storage) { let retention_val = storage.get("audioRetentionDays", Some(&serde_json::json!(30))); @@ -118,9 +138,21 @@ fn cleanup_expired_logs(storage: &Storage) { } fn main() { - // Initialize logger so log::info!/warn!/error! produce output + // Mirror the Rust log facade to sayit.log as well as stderr. This is essential on + // Windows release builds (`windows_subsystem = "windows"`), where stderr is invisible. + // It also captures tauri-runtime-wry errors such as the original WebView2 HRESULT. env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .format_timestamp_millis() + .format(|buf, record| { + use std::io::Write; + let line = format!( + "[RUST-LOG] level={} target={} {}", + record.level(), + record.target(), + record.args() + ); + commands::system::write_log_line(&line); + writeln!(buf, "{line}") + }) .init(); // ── 全局 panic 钩子(悬浮窗/热键间歇性失效排查埋点)── @@ -154,7 +186,12 @@ fn main() { })); } - log::info!("SayIt starting, version={}", env!("CARGO_PKG_VERSION")); + log::info!( + "SayIt starting version={} profile={} pid={}", + env!("CARGO_PKG_VERSION"), + if cfg!(debug_assertions) { "debug" } else { "release" }, + std::process::id(), + ); log::info!("Log file: {:?}", dirs::data_local_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("com.sayit.app") @@ -168,14 +205,23 @@ fn main() { // or not focused. Without these flags, WebView2 throttles setInterval timers in the // background, delaying the heartbeat well past the server's idle timeout and causing // spurious "idle timeout" disconnects while the app is just sitting idle in the tray. - if std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").is_err() { - std::env::set_var( - "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", - "--ignore-certificate-errors --auto-accept-camera-and-microphone-capture \ - --disable-backgrounding-occluded-windows --disable-renderer-backgrounding \ - --disable-background-timer-throttling", - ); - } + let (browser_args_source, browser_args) = match std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") { + Ok(value) => ("inherited", value), + Err(_) => { + std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", WEBVIEW2_BROWSER_ARGS); + ("app-default", WEBVIEW2_BROWSER_ARGS.to_string()) + } + }; + log::info!( + "WebView2 environment userDataDir={:?} argsSource={} argsLen={} argsFingerprint={}", + dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("com.sayit.app") + .join("EBWebView"), + browser_args_source, + browser_args.len(), + browser_args_fingerprint(&browser_args), + ); let db_path = dirs::data_local_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) @@ -200,8 +246,8 @@ fn main() { let ptt_str = ptt_setting_val.as_str().unwrap_or("ShiftRight").to_string(); let hf_setting_val = storage.get("shortcutHandsFree", None); let hf_str = hf_setting_val.as_str().unwrap_or("AltRight").to_string(); - eprintln!("[main] PTT setting from DB: raw={:?} parsed={:?}", ptt_setting_val, ptt_str); - eprintln!("[main] HF setting from DB: raw={:?} parsed={:?}", hf_setting_val, hf_str); + log::info!("PTT setting from DB: raw={:?} parsed={:?}", ptt_setting_val, ptt_str); + log::info!("HF setting from DB: raw={:?} parsed={:?}", hf_setting_val, hf_str); let window_state = WindowState::new(); let keyboard_hook = KeyboardHookManager::new(); @@ -225,6 +271,30 @@ fn main() { .manage(keyboard_hook) .manage(context_detector) .setup(move |app| { + // SayIt's main window and lazy overlay share one WebView2 user-data directory. + // Per-window browser arguments violate WebView2's environment compatibility + // contract. Fail loudly during startup instead of leaving a half-working app + // whose main window works but whose overlay can never be created. + let unsafe_windows = app + .config() + .app + .windows + .iter() + .filter_map(|window| { + window.additional_browser_args.as_ref().map(|args| { + format!("{}(argsLen={})", window.label, args.len()) + }) + }) + .collect::>(); + if !unsafe_windows.is_empty() { + let message = format!( + "per-window additionalBrowserArgs is forbidden for shared WebView2 data: {}", + unsafe_windows.join(", ") + ); + log::error!("WebView2 configuration invariant failed: {message}"); + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, message).into()); + } + let hook: tauri::State = app.state(); hook.start(app.handle(), &ptt_str, &hf_str); @@ -392,6 +462,7 @@ fn main() { commands::system::append_debug_log, commands::system::save_audio_to_downloads, commands::system::reveal_file_in_folder, + commands::system::open_folder, // Audio commands::audio::save_audio_file, commands::audio::save_pcm_as_wav, @@ -410,6 +481,13 @@ fn main() { commands::export::save_text_export, commands::export::save_export_bundle, commands::export::save_full_export, + // Backup / Restore (配置与全部数据的导入导出) + commands::backup::get_backup_directory, + commands::backup::export_config, + commands::backup::export_full, + commands::backup::import_config, + commands::backup::import_full, + commands::backup::restart_app, // Diagnostics commands::diagnostics::collect_settings, commands::diagnostics::get_diagnostics_preview, @@ -458,3 +536,38 @@ fn main() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod config_tests { + use serde_json::Value; + + #[test] + fn configured_windows_do_not_override_webview2_browser_args() { + let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json must be valid JSON"); + let windows = config + .pointer("/app/windows") + .and_then(Value::as_array) + .expect("tauri.conf.json must define app.windows"); + + let offenders = windows + .iter() + .filter_map(|window| { + window + .get("additionalBrowserArgs") + .filter(|value| !value.is_null()) + .map(|_| { + window + .get("label") + .and_then(Value::as_str) + .unwrap_or("") + }) + }) + .collect::>(); + + assert!( + offenders.is_empty(), + "WebView2 args must be global; per-window overrides found on: {offenders:?}" + ); + } +} diff --git a/client/src-tauri/src/storage/mod.rs b/client/src-tauri/src/storage/mod.rs index 9646f48..9e7b0c1 100644 --- a/client/src-tauri/src/storage/mod.rs +++ b/client/src-tauri/src/storage/mod.rs @@ -711,3 +711,51 @@ fn parse_stats(json: &Option) -> (f64, i64) { None => (0.0, 0), } } + +// ─── 备份 / 恢复辅助(导入导出用)─── +impl Storage { + /// 导出 app_settings 为 { key: value } 对象;exclude 中的 key 会被跳过。 + pub fn export_app_settings(&self, exclude: &[&str]) -> Value { + let db = self.db.lock().unwrap(); + let mut map = serde_json::Map::new(); + if let Ok(mut stmt) = db.prepare("SELECT key, value_json FROM app_settings") { + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }); + if let Ok(rows) = rows { + for row in rows.flatten() { + let (key, value_json) = row; + if exclude.contains(&key.as_str()) { + continue; + } + let val: Value = serde_json::from_str(&value_json).unwrap_or(Value::Null); + map.insert(key, val); + } + } + } + Value::Object(map) + } + + /// 导入 app_settings:逐 key upsert(覆盖同名,不删除备份中未出现的 key)。 + /// exclude 中的 key 会被跳过。 + pub fn import_app_settings( + &self, + obj: &serde_json::Map, + exclude: &[&str], + ) -> SqlResult<()> { + let db = self.db.lock().unwrap(); + let now = chrono::Utc::now().timestamp_millis(); + for (key, val) in obj { + if exclude.contains(&key.as_str()) { + continue; + } + let json_str = serde_json::to_string(val).unwrap_or_else(|_| "null".to_string()); + db.execute( + "INSERT INTO app_settings (key, value_json, updated_at) VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at", + params![key, json_str, now], + )?; + } + Ok(()) + } +} diff --git a/client/src-tauri/src/window/mod.rs b/client/src-tauri/src/window/mod.rs index c37a9b7..d141da7 100644 --- a/client/src-tauri/src/window/mod.rs +++ b/client/src-tauri/src/window/mod.rs @@ -3,6 +3,7 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::Mutex; use std::thread; use std::time::{Duration, Instant}; +use tauri::webview::PageLoadEvent; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::commands::system::write_log_line; @@ -72,6 +73,9 @@ pub struct WindowState { last_ack_generation: AtomicU64, last_ack_at_ms: AtomicI64, recovery_started_show_id: AtomicU64, + page_load_started_at_ms: AtomicI64, + page_load_finished_at_ms: AtomicI64, + renderer_ready_at_ms: AtomicI64, /// 进程/状态创建时间,用于日志记录运行时长(配合资源计数排查“长时间运行后悬浮窗失效”) created_at: Instant, } @@ -91,6 +95,9 @@ impl WindowState { last_ack_generation: AtomicU64::new(0), last_ack_at_ms: AtomicI64::new(0), recovery_started_show_id: AtomicU64::new(0), + page_load_started_at_ms: AtomicI64::new(0), + page_load_finished_at_ms: AtomicI64::new(0), + renderer_ready_at_ms: AtomicI64::new(0), created_at: Instant::now(), } } @@ -181,6 +188,7 @@ impl WindowState { /// React listener,因此在 ready 时重放最近状态并重新请求确认。 pub fn overlay_ready(&self, app: &AppHandle) { let show_id = self.active_show_id.load(Ordering::SeqCst); + self.renderer_ready_at_ms.store(now_ms(), Ordering::SeqCst); if show_id == 0 { if let Some(overlay) = app.get_webview_window("overlay") { let _ = overlay.hide(); @@ -189,9 +197,10 @@ impl WindowState { } write_log_line(&format!( - "[overlay-health] renderer ready show_id={} generation={}", + "[overlay-health] renderer ready show_id={} generation={} diagnostic={}", show_id, self.active_generation.load(Ordering::SeqCst), + compact_json(&self.diagnostic_snapshot(app)), )); if self.ensure_visible(app, show_id) { self.emit_latest(app, true); @@ -242,6 +251,7 @@ impl WindowState { let ack_show_id = self.last_ack_show_id.load(Ordering::SeqCst); let ack_generation = self.last_ack_generation.load(Ordering::SeqCst); let recovery_started = self.recovery_started_show_id.load(Ordering::SeqCst) == show_id; + let diagnostic = self.diagnostic_snapshot(app); json!({ "showId": show_id, "activeShowId": active_show_id, @@ -251,10 +261,65 @@ impl WindowState { "ackLatencyMs": if ack_show_id == show_id { self.ack_latency_ms() } else { -1 }, "recoveryStarted": recovery_started, "recoverySucceeded": recovery_started && ack_show_id == show_id && ack_generation > 0, + "failureClass": self.failure_class(app), + // Keep the existing frontend response contract; `diagnostic` adds detail. "window": overlay_window_snapshot(app), + "diagnostic": diagnostic, }) } + fn reset_renderer_lifecycle(&self) { + self.page_load_started_at_ms.store(0, Ordering::SeqCst); + self.page_load_finished_at_ms.store(0, Ordering::SeqCst); + self.renderer_ready_at_ms.store(0, Ordering::SeqCst); + } + + fn record_page_load(&self, event: PageLoadEvent, url: &str) { + let timestamp = now_ms(); + let event_name = match event { + PageLoadEvent::Started => { + self.page_load_started_at_ms.store(timestamp, Ordering::SeqCst); + "started" + } + PageLoadEvent::Finished => { + self.page_load_finished_at_ms.store(timestamp, Ordering::SeqCst); + "finished" + } + }; + write_log_line(&format!( + "[overlay-health] page load {} show_id={} generation={} url={}", + event_name, + self.active_show_id.load(Ordering::SeqCst), + self.active_generation.load(Ordering::SeqCst), + url, + )); + } + + fn diagnostic_snapshot(&self, app: &AppHandle) -> Value { + let page_started_at = self.page_load_started_at_ms.load(Ordering::SeqCst); + let page_finished_at = self.page_load_finished_at_ms.load(Ordering::SeqCst); + let renderer_ready_at = self.renderer_ready_at_ms.load(Ordering::SeqCst); + let current_ms = now_ms(); + json!({ + "window": overlay_window_snapshot(app), + "pageLoadStarted": page_started_at > 0, + "pageLoadFinished": page_finished_at > 0, + "rendererReady": renderer_ready_at > 0, + "pageLoadStartedAgeMs": event_age_ms(current_ms, page_started_at), + "pageLoadFinishedAgeMs": event_age_ms(current_ms, page_finished_at), + "rendererReadyAgeMs": event_age_ms(current_ms, renderer_ready_at), + }) + } + + fn failure_class(&self, app: &AppHandle) -> &'static str { + classify_overlay_failure( + &overlay_window_snapshot(app), + self.page_load_started_at_ms.load(Ordering::SeqCst) > 0, + self.page_load_finished_at_ms.load(Ordering::SeqCst) > 0, + self.renderer_ready_at_ms.load(Ordering::SeqCst) > 0, + ) + } + fn ack_latency_ms(&self) -> i64 { let ack_at = self.last_ack_at_ms.load(Ordering::SeqCst); let started_at = self.show_started_at_ms.load(Ordering::SeqCst); @@ -449,8 +514,10 @@ impl WindowState { self.last_ack_generation.store(0, Ordering::SeqCst); self.recovery_started_show_id.store(show_id, Ordering::SeqCst); write_log_line(&format!( - "[overlay-health] recovery begin show_id={} snapshot={}", - show_id, compact_json(&overlay_window_snapshot(app)), + "[overlay-health] recovery begin show_id={} failure_class={} diagnostic={}", + show_id, + self.failure_class(app), + compact_json(&self.diagnostic_snapshot(app)), )); if let Some(overlay) = app.get_webview_window("overlay") { @@ -471,8 +538,9 @@ impl WindowState { if app.get_webview_window("overlay").is_some() { write_log_line(&format!( - "[overlay-health] recovery FAILED show_id={} reason=stale_handle", + "[overlay-health] recovery FAILED show_id={} reason=stale_handle diagnostic={}", show_id, + compact_json(&self.diagnostic_snapshot(app)), )); return; } @@ -483,6 +551,7 @@ impl WindowState { } fn create_overlay(&self, app: &AppHandle, layout: OverlayLayout, base_width: f64) { + self.reset_renderer_lifecycle(); let bounds = calc_overlay_bounds(app, &layout, base_width); let builder = WebviewWindowBuilder::new( app, @@ -499,7 +568,11 @@ impl WindowState { .skip_taskbar(true) .resizable(false) .focused(false) - .visible(false); + .visible(false) + .on_page_load(|window, payload| { + let state = window.app_handle().state::(); + state.record_page_load(payload.event(), payload.url().as_str()); + }); match builder.build() { Ok(overlay) => { @@ -507,13 +580,14 @@ impl WindowState { set_overlay_interactivity(&overlay, layout.is_interactive()); let show_error = overlay.show().err().map(|error| format!("{:?}", error)); write_log_line(&format!( - "[overlay-health] create OK show_id={} generation={} initial_bounds={:?} position_error={:?} show_error={:?} visible={}", + "[overlay-health] create dispatched show_id={} generation={} initial_bounds={:?} position_error={:?} show_error={:?} shell_visible={} diagnostic={}", self.active_show_id.load(Ordering::SeqCst), self.active_generation.load(Ordering::SeqCst), bounds, position_error, show_error, overlay.is_visible().unwrap_or(false), + compact_json(&self.diagnostic_snapshot(app)), )); } Err(error) => { @@ -602,8 +676,10 @@ fn spawn_render_watchdog(app: AppHandle, show_id: u64) { return; } write_log_line(&format!( - "[overlay-health] ack timeout phase=1 show_id={} snapshot={}", - show_id, compact_json(&overlay_window_snapshot(&app)), + "[overlay-health] ack timeout phase=1 show_id={} failure_class={} diagnostic={}", + show_id, + state.failure_class(&app), + compact_json(&state.diagnostic_snapshot(&app)), )); state.emit_latest(&app, true); } @@ -615,8 +691,10 @@ fn spawn_render_watchdog(app: AppHandle, show_id: u64) { return; } write_log_line(&format!( - "[overlay-health] ack timeout phase=2 show_id={} — confirmed unhealthy", + "[overlay-health] ack timeout phase=2 show_id={} failure_class={} — confirmed unhealthy diagnostic={}", show_id, + state.failure_class(&app), + compact_json(&state.diagnostic_snapshot(&app)), )); state.recover_overlay(&app, show_id); } @@ -628,16 +706,17 @@ fn spawn_render_watchdog(app: AppHandle, show_id: u64) { } if state.is_acked(show_id, 1) { write_log_line(&format!( - "[overlay-health] recovery OK show_id={} latency_ms={} snapshot={}", + "[overlay-health] recovery OK show_id={} latency_ms={} diagnostic={}", show_id, state.ack_latency_ms(), - compact_json(&overlay_window_snapshot(&app)), + compact_json(&state.diagnostic_snapshot(&app)), )); } else { write_log_line(&format!( - "[overlay-health] recovery FAILED show_id={} reason=no_render_ack snapshot={}", + "[overlay-health] recovery FAILED show_id={} reason=no_render_ack failure_class={} diagnostic={}", show_id, - compact_json(&overlay_window_snapshot(&app)), + state.failure_class(&app), + compact_json(&state.diagnostic_snapshot(&app)), )); } }); @@ -662,6 +741,54 @@ fn gui_resource_counts() -> (u32, u32) { (0, 0) } +fn event_age_ms(current_ms: i64, event_ms: i64) -> i64 { + if event_ms <= 0 { + -1 + } else { + current_ms.saturating_sub(event_ms) + } +} + +fn classify_overlay_failure( + window: &Value, + page_load_started: bool, + page_load_finished: bool, + renderer_ready: bool, +) -> &'static str { + if window.get("handleExists").and_then(Value::as_bool) != Some(true) { + return "handle_missing"; + } + + // Tauri can register a WebviewWindow label before wry asynchronously creates the + // native WebView2 controller. A label with no readable position/size is therefore a + // shell/phantom handle, not a successfully-created overlay. + let native_window_ready = window.get("position").is_some_and(|value| !value.is_null()) + && window.get("size").is_some_and(|value| !value.is_null()); + if !native_window_ready { + return "native_create_failed"; + } + if !page_load_started { + return "page_load_not_started"; + } + if !page_load_finished { + return "page_load_incomplete"; + } + if !renderer_ready { + return "renderer_not_ready"; + } + + let visible = window.get("visible").and_then(Value::as_bool) == Some(true); + let on_screen = window + .get("intersectsAnyMonitor") + .and_then(Value::as_bool) + == Some(true); + if !visible || !on_screen { + return "visibility_or_position"; + } + + "render_ack_missing" +} + fn compact_json(value: &Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } @@ -723,6 +850,68 @@ fn set_overlay_interactivity(overlay: &tauri::WebviewWindow, interactive: bool) let _ = overlay.set_ignore_cursor_events(!interactive); } +#[cfg(test)] +mod tests { + use super::classify_overlay_failure; + use serde_json::json; + + fn native_window() -> serde_json::Value { + json!({ + "handleExists": true, + "visible": true, + "intersectsAnyMonitor": true, + "position": { "x": 10, "y": 20 }, + "size": { "width": 200, "height": 56 } + }) + } + + #[test] + fn classifies_phantom_tauri_handle_as_native_create_failure() { + let window = json!({ + "handleExists": true, + "visible": null, + "intersectsAnyMonitor": false, + "position": null, + "size": null + }); + assert_eq!( + classify_overlay_failure(&window, false, false, false), + "native_create_failed" + ); + } + + #[test] + fn classifies_page_and_renderer_stages() { + let window = native_window(); + assert_eq!( + classify_overlay_failure(&window, false, false, false), + "page_load_not_started" + ); + assert_eq!( + classify_overlay_failure(&window, true, false, false), + "page_load_incomplete" + ); + assert_eq!( + classify_overlay_failure(&window, true, true, false), + "renderer_not_ready" + ); + assert_eq!( + classify_overlay_failure(&window, true, true, true), + "render_ack_missing" + ); + } + + #[test] + fn classifies_visible_but_offscreen_window() { + let mut window = native_window(); + window["intersectsAnyMonitor"] = json!(false); + assert_eq!( + classify_overlay_failure(&window, true, true, true), + "visibility_or_position" + ); + } +} + fn monitor_info(app: &AppHandle) -> (f64, f64, f64) { if let Some(monitor) = app.primary_monitor().ok().flatten() { let size = monitor.size(); diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index f8e9303..3c2bd87 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "SayIt", - "version": "0.1.1", + "version": "0.1.2", "identifier": "com.sayit.app", "build": { "frontendDist": "../dist", diff --git a/client/src/features/settings/BackupSection.tsx b/client/src/features/settings/BackupSection.tsx new file mode 100644 index 0000000..7d91d8b --- /dev/null +++ b/client/src/features/settings/BackupSection.tsx @@ -0,0 +1,226 @@ +import { useEffect, useMemo, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { message } from '@tauri-apps/plugin-dialog' +import { Check, Download, FolderOpen, Loader2, Upload } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Tooltip } from '@/components/ui/tooltip' +import { + exportConfigFile, + exportFullFile, + importConfigFile, + importFullFile, + onBackupExportProgress, + type BackupExportProgress, +} from '@/services/backup' + +type BusyAction = 'exportConfig' | 'exportFull' | 'importConfig' | 'importFull' | null + +const phaseLabels: Record = { + preparing: '正在准备备份…', + packingData: '正在整理配置和历史记录…', + packingAudio: '正在打包录音文件…', + finalizing: '正在完成备份…', + completed: '备份完成', + failed: '备份失败', +} + +function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let size = value + let unit = 0 + while (size >= 1024 && unit < units.length - 1) { + size /= 1024 + unit += 1 + } + return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}` +} + +function SavedPath({ + label, + path, + onOpen, + success = false, +}: { + label: string + path: string + onOpen: () => void + success?: boolean +}) { + return ( +
+ {success && } + {label} + {path} + + + +
+ ) +} + +function ExportProgress({ progress }: { progress: BackupExportProgress }) { + const percent = Math.max(0, Math.min(100, progress.percent)) + const detail = progress.phase === 'packingAudio' && progress.totalFiles > 0 + ? `${progress.processedFiles}/${progress.totalFiles} 个录音 · ${formatBytes(progress.processedBytes)} / ${formatBytes(progress.totalBytes)}` + : progress.currentFile || '请稍候' + + return ( +
+
+ {phaseLabels[progress.phase]} + {Math.round(percent)}% +
+
+
+
+

{detail}

+

+ 保存到 {progress.filePath} +

+
+ ) +} + +export default function BackupSection() { + const [busyAction, setBusyAction] = useState(null) + const [configPath, setConfigPath] = useState('') + const [fullPath, setFullPath] = useState('') + const [fullProgress, setFullProgress] = useState(null) + + useEffect(() => { + const unlisten = onBackupExportProgress((progress) => { + setFullProgress(progress) + if (progress.filePath) setFullPath(progress.filePath) + }) + return () => { void unlisten.then((fn) => fn()) } + }, []) + + const isBusy = busyAction !== null + const fullExportRunning = fullProgress?.status === 'running' + const fullProgressError = useMemo( + () => fullProgress?.status === 'failed' ? fullProgress.error || '未知错误' : '', + [fullProgress], + ) + + const revealFile = (filePath: string) => { + void invoke('reveal_file_in_folder', { filePath }) + } + + const handleExportConfig = async () => { + setBusyAction('exportConfig') + try { + const result = await exportConfigFile() + if (result.filePath) setConfigPath(result.filePath) + } catch (error) { + await message(`导出失败:${String(error)}`, { title: '导出失败', kind: 'error' }) + } finally { + setBusyAction(null) + } + } + + const handleExportFull = async () => { + setBusyAction('exportFull') + setFullProgress(null) + try { + const result = await exportFullFile() + if (result.filePath) setFullPath(result.filePath) + } catch (error) { + await message(`导出失败:${String(error)}`, { title: '导出失败', kind: 'error' }) + } finally { + setBusyAction(null) + } + } + + const handleImport = async (kind: 'config' | 'full') => { + setBusyAction(kind === 'config' ? 'importConfig' : 'importFull') + try { + if (kind === 'config') await importConfigFile() + else await importFullFile() + } catch (error) { + await message(`导入失败:${String(error)}`, { title: '导入失败', kind: 'error' }) + } finally { + setBusyAction(null) + } + } + + return ( + + +

备份与迁移

+

+ 用于换机、重装或定期备份。导入会覆盖现有数据,请先确认。 +

+ +
+
+
+

配置

+

仅设置、密钥、热词与提示词,文件小、便于日常备份

+
+
+ + +
+
+ {configPath && ( + revealFile(configPath)} /> + )} +
+ +
+
+
+

全部数据

+

在配置基础上,额外包含历史记录与录音,适合完整迁移

+
+
+ + +
+
+ + {fullExportRunning && fullProgress && } + {fullProgress?.status === 'completed' && fullPath && ( + revealFile(fullPath)} /> + )} + {fullProgressError && ( +
+ 导出失败:{fullProgressError} +
+ )} +
+
+
+ ) +} diff --git a/client/src/features/settings/GeneralSettingsPage.tsx b/client/src/features/settings/GeneralSettingsPage.tsx index dc5e28a..cfdd77c 100644 --- a/client/src/features/settings/GeneralSettingsPage.tsx +++ b/client/src/features/settings/GeneralSettingsPage.tsx @@ -3,19 +3,16 @@ import * as bridge from '@/services/bridge' import { refreshPTTSetting } from '@/services/webviewKeyboardFallback' import { useCallback, useEffect, useRef, useState } from 'react' -import { invoke } from '@tauri-apps/api/core' -import { listen } from '@tauri-apps/api/event' -import { Download, FolderOpen, Check, Info } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { Info } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { Tooltip } from '@/components/ui/tooltip' import { listMicrophones } from '@/services/audio' -import { exportAllDataBundle, exportSettings } from '@/services/exports' import { refreshRecorderSettings } from '@/services/recorder' import { getSetting, setSetting } from '@/services/store' import { drawBars, resetWaveform } from '@/services/waveform' import { Switch } from '@/components/ui/switch' import AppSection from './AppSection' +import BackupSection from './BackupSection' import MicrophoneSection from './MicrophoneSection' import type { MicVolumeLevel } from './MicrophoneSection' import { ComboShortcutInput, PTTShortcutInput } from './ShortcutInputs' @@ -122,37 +119,6 @@ export default function GeneralSettingsPage() { } } - const [exportResult, setExportResult] = useState<{ filePath: string | null; canceled: boolean } | null>(null) - const [exporting, setExporting] = useState(false) - const [exportProgress, setExportProgress] = useState<{ current: number; total: number } | null>(null) - - const handleExportSettings = async () => { - const r = await exportSettings() - setExportResult(r) - if (!r.canceled) setTimeout(() => setExportResult(null), 8000) - } - const handleExportAll = async () => { - setExporting(true) - setExportProgress(null) - const unlisten = await listen<{ current: number; total: number }>('export-progress', (e) => { - setExportProgress({ current: e.payload.current, total: e.payload.total }) - }) - try { - const r = await exportAllDataBundle() - setExportResult(r) - if (!r.canceled) setTimeout(() => setExportResult(null), 15000) - } finally { - unlisten() - setExporting(false) - setExportProgress(null) - } - } - const handleRevealExport = () => { - if (exportResult?.filePath) { - void invoke('reveal_file_in_folder', { filePath: exportResult.filePath }) - } - } - return (

设置

@@ -248,52 +214,7 @@ export default function GeneralSettingsPage() { - - -
-
-

数据导出

-

可导出当前设置,或一键打包导出历史、收藏、热词和设置。

-
-
- - -
-
- {exporting && exportProgress && ( -
-
-
-
-

- {exportProgress.current} / {exportProgress.total} 文件 -

-
- )} - {exportResult && !exportResult.canceled && exportResult.filePath && ( -
- - 已保存到 {exportResult.filePath} - -
- )} - {exportResult?.canceled && ( -

已取消导出。

- )} - - +
) diff --git a/client/src/features/update/releaseHighlights.ts b/client/src/features/update/releaseHighlights.ts index d63a1c1..d1574bc 100644 --- a/client/src/features/update/releaseHighlights.ts +++ b/client/src/features/update/releaseHighlights.ts @@ -10,14 +10,9 @@ export interface ReleaseHighlights { } export const RELEASE_HIGHLIGHTS: ReleaseHighlights = { - version: '0.1.1', + version: '0.1.2', items: [ - '修复悬浮窗被其他置顶程序(如播放器、会议共享工具条)遮挡的问题,现在稳定显示在最上层', - '「按住说话」「免提模式」新增支持绑定鼠标中键', - '历史记录搜索:命中的关键词现在会高亮显示,更容易定位', - '数字规范化默认开启,并修复「九点三十二分」等时间被误当小数的问题', - '优化热词管理:过多时自动折叠、给出温和提示,并用表格说明各模型的热词支持', - '优化低音量提醒与「未检测到有效声音」提示,减少打扰、更清晰', - '键盘快捷键设置新增使用说明(ⓘ)', + '修复长时间后台运行后偶发「一直处理中、超时无结果」的问题:后台连接更稳定,不再被误判空闲而断开', + '识别超时不再丢录音:即使这次超时没结果,录音也会保存到历史,可随时「重新识别」', ], } diff --git a/client/src/services/backup.ts b/client/src/services/backup.ts new file mode 100644 index 0000000..a12a7d2 --- /dev/null +++ b/client/src/services/backup.ts @@ -0,0 +1,84 @@ +// 备份 / 恢复:配置(JSON)与全部数据(zip,含音频)的导入导出。 +// +// 配置导出:直接写入默认备份目录。 +// 全量导出:直接写入默认备份目录,由 Rust 后台压缩,并通过事件汇报进度。 +// 导入:选文件 → 二次确认(会覆盖当前数据)→ 调 Rust 恢复 → 提示并重启。 +// 重启用自定义命令 restart_app(Tauri 内置 app.restart()),未依赖 process 插件。 + +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { open, confirm, message } from '@tauri-apps/plugin-dialog' + +export interface ExportResult { + canceled: boolean + filePath: string | null +} + +export interface BackupExportProgress { + status: 'running' | 'completed' | 'failed' + phase: 'preparing' | 'packingData' | 'packingAudio' | 'finalizing' | 'completed' | 'failed' + filePath: string + currentFile: string | null + processedFiles: number + totalFiles: number + processedBytes: number + totalBytes: number + percent: number + error: string | null +} + +export function getBackupDirectory(): Promise { + return invoke('get_backup_directory') +} + +export function onBackupExportProgress( + handler: (progress: BackupExportProgress) => void, +): Promise { + return listen('backup-export-progress', (event) => handler(event.payload)) +} + +/** 导出配置(设置 / 供应商与密钥 / 热词 / Prompt)为 JSON。 */ +export async function exportConfigFile(): Promise { + const path = await invoke('export_config') + return { canceled: false, filePath: path } +} + +/** 导出全部数据(配置 + 历史 + 录音)为 zip,直接写入默认备份目录。 */ +export async function exportFullFile(): Promise { + const path = await invoke('export_full') + return { canceled: false, filePath: path } +} + +/** 导入结果:done = 已导入(即将重启);canceled = 用户取消。 */ +export type ImportOutcome = 'done' | 'canceled' + +async function pickConfirmImport(kind: 'config' | 'full'): Promise { + const filters = + kind === 'config' + ? [{ name: 'SayIt 配置', extensions: ['json'] }] + : [{ name: 'SayIt 备份', extensions: ['zip'] }] + + const picked = await open({ multiple: false, directory: false, filters }) + const inPath = typeof picked === 'string' ? picked : null + if (!inPath) return 'canceled' + + const what = + kind === 'config' + ? '设置、供应商配置与密钥、热词、Prompt' + : '全部数据(设置、历史记录与录音)' + const ok = await confirm(`导入将用备份覆盖当前的${what},此操作无法撤销。确定继续吗?`, { + title: '确认导入', + kind: 'warning', + okLabel: '覆盖导入', + cancelLabel: '取消', + }) + if (!ok) return 'canceled' + + await invoke(kind === 'config' ? 'import_config' : 'import_full', { inPath }) + await message('导入成功,应用将重启以使更改生效。', { title: '导入完成' }) + await invoke('restart_app') + return 'done' +} + +export const importConfigFile = () => pickConfirmImport('config') +export const importFullFile = () => pickConfirmImport('full') diff --git a/client/src/services/recorder/RecorderOrchestrator.ts b/client/src/services/recorder/RecorderOrchestrator.ts index 33bd68c..883016f 100644 --- a/client/src/services/recorder/RecorderOrchestrator.ts +++ b/client/src/services/recorder/RecorderOrchestrator.ts @@ -1230,18 +1230,64 @@ export class RecorderOrchestrator { addRuntimeEvent('warn', 'recorder', '处理超时但文本插入仍在进行,延长等待') return } - this.timedOutProcessingContext = { + const timedOutCtx = { timedOutAt: Date.now(), audioDurationSec: audioDur, wallTimeSec, promptResolution: this.currentPromptResolution ? { ...this.currentPromptResolution } : null, appContext: this.currentActiveAppContext ? { ...this.currentActiveAppContext } : null, } + this.timedOutProcessingContext = timedOutCtx addRuntimeEvent('warn', 'recorder', '处理超时,自动回到空闲状态', { audioSec: audioDur, timeoutMs: processingTimeoutMs, lateFinalGraceMs: LATE_FINAL_GRACE_MS, }) + + // 安全网:快照本次录音音频与元数据。若宽限期内没等到迟到的 final,就把音频存入历史 + // (标记为空结果),用户可在历史里“重新识别”,避免超时丢失整段录音。 + // resetToIdle 不会清空 recordedChunks,这里再 slice 一份,防止后续录音替换缓冲。 + const audioChunks = this.recordedChunks.slice() + const historyMeta = this.buildHistoryMetadata(timedOutCtx.promptResolution) + window.setTimeout(() => { + // onFinal 若在宽限期内消费了 context(迟到 final 已落历史)→ 跳过,避免重复记录。 + if (this.timedOutProcessingContext !== timedOutCtx) return + this.timedOutProcessingContext = null + void (async () => { + try { + let audioFilePath: string | undefined + const recordId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + const saveAudioEnabled = await getSetting('audioRetentionEnabled', true) + if (saveAudioEnabled && audioChunks.length > 0) { + try { + const savedPath = await saveRecordingAudio(recordId, audioChunks) + if (savedPath) audioFilePath = savedPath + } catch (err) { + addRuntimeEvent('warn', 'recorder', '保存音频文件失败(超时兜底)', { error: String(err) }) + } + } + await addHistory({ + id: recordId, + timestamp: Date.now(), + asrText: '', + llmText: '', + asrMs: 0, + llmMs: 0, + durationSec: timedOutCtx.wallTimeSec, + audioDurationSec: timedOutCtx.audioDurationSec > 0 ? timedOutCtx.audioDurationSec : undefined, + charCount: 0, + isEmpty: true, + audioFilePath, + ...historyMeta, + }) + void bridge.emit('history-updated') + addRuntimeEvent('info', 'recorder', '处理超时:已把录音存入历史,可重新识别', { audioSec: timedOutCtx.audioDurationSec }) + } catch (err) { + addRuntimeEvent('warn', 'recorder', '超时兜底写入历史失败', { error: String(err) }) + } + })() + }, LATE_FINAL_GRACE_MS) + this.resetToIdle({ preserveLateFinalContext: true }) }, processingTimeoutMs) }