修复悬浮窗创建失败并增加分级诊断日志;新增本地备份/恢复;版本 0.1.2

- 悬浮窗:WebView2 浏览器参数统一到 main.rs 全局常量,移除主窗专属 additionalBrowserArgs,并加启动一致性护栏

- 悬浮窗:创建链路改为分级日志(create dispatched / page load / renderer ready / render ack)与失败分类

- 新增本地备份与恢复功能

- 版本号 bump 到 0.1.2
This commit is contained in:
crosswk committed 2026-07-24 10:55:10 +08:00
1 parent 77bfe8b74f
commit fc1eb4b0e7
16 files changed
+1271 -123

No files matched your search

+11
View File
@@ -9,6 +9,17 @@
---
## [0.1.2] - 未发布
### 优化
- 优化「备份与迁移」:导出不再弹出保存位置对话框,「配置」与「全部数据」都直接保存到默认下载目录,导出完成后就地显示文件路径与「打开所在文件夹」按钮,和其他下载体验保持一致。同时优化了页面文案,更清楚地说明两种备份分别包含什么、适合什么场景(「全部数据」在「配置」基础上额外包含历史记录与录音)。
### 修复
- 修复长时间后台运行后偶发「一直显示处理中、最后超时且无结果」的问题:SayIt 主窗口常驻后台隐藏时,系统会节流其心跳定时器,导致与服务器的连接被判为空闲而每隔约 5 分钟被回收;若录音恰好在连接被回收的瞬间开始,本次识别就会丢失并超时。现已让后台定时器不再被节流,保持心跳正常、连接稳定。
- 识别超时时不再丢失录音:即使这次识别超时没有结果,录音也会保存到历史记录中,可随时点「重新识别」重试。
---
## [0.1.1] - 2026-07-22
### 新增
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -4024,7 +4024,7 @@ dependencies = [
[[package]]
name = "sayit"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"base64 0.22.1",
"bzip2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sayit"
version = "0.1.1"
version = "0.1.2"
authors = ["Liu Qianglong", "Claude"]
edition = "2021"
+479
View File
@@ -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<String>,
processed_files: u64,
total_files: u64,
processed_bytes: u64,
total_bytes: u64,
percent: f64,
error: Option<String>,
}
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<AudioExportFile> {
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<String, String> {
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<String, String> {
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<String, String> {
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::<u64>();
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<Value> = 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();
}
+1
View File
@@ -1,5 +1,6 @@
pub mod audio;
pub mod audio_mute;
pub mod backup;
pub mod diagnostics;
pub mod export;
pub mod paste;
+37 -2
View File
@@ -45,12 +45,17 @@ fn open_log_file() -> Option<std::fs::File> {
.ok()
}
fn rotate_if_needed() {
fn rotate_if_needed(file: &mut Option<std::fs::File>) {
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(())
}
+126 -13
View File
@@ -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::<Vec<_>>();
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<KeyboardHookManager> = 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("<unknown>")
})
})
.collect::<Vec<_>>();
assert!(
offenders.is_empty(),
"WebView2 args must be global; per-window overrides found on: {offenders:?}"
);
}
}
+48
View File
@@ -711,3 +711,51 @@ fn parse_stats(json: &Option<String>) -> (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<String, Value>,
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(())
}
}
+202 -13
View File
@@ -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::<WindowState>();
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();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "SayIt",
"version": "0.1.1",
"version": "0.1.2",
"identifier": "com.sayit.app",
"build": {
"frontendDist": "../dist",
@@ -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<BackupExportProgress['phase'], string> = {
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 (
<div className={`mt-2 flex min-w-0 items-center gap-2 rounded-md px-3 py-2 text-xs ${success ? 'bg-success/10 text-success' : 'bg-muted/50 text-muted-foreground'}`}>
{success && <Check className="h-3.5 w-3.5 shrink-0" />}
<span className="shrink-0">{label}</span>
<span className="min-w-0 flex-1 truncate text-foreground/80" title={path}>{path}</span>
<Tooltip content="打开所在文件夹">
<button
type="button"
onClick={onOpen}
aria-label="打开所在文件夹"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
)
}
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 (
<div className="mt-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-foreground">{phaseLabels[progress.phase]}</span>
<span className="tabular-nums text-muted-foreground">{Math.round(percent)}%</span>
</div>
<div
className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="全部数据导出进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-200"
style={{ width: `${percent}%` }}
/>
</div>
<p className="mt-1.5 truncate text-xs text-muted-foreground" title={detail}>{detail}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground/80" title={progress.filePath}>
保存到 {progress.filePath}
</p>
</div>
)
}
export default function BackupSection() {
const [busyAction, setBusyAction] = useState<BusyAction>(null)
const [configPath, setConfigPath] = useState('')
const [fullPath, setFullPath] = useState('')
const [fullProgress, setFullProgress] = useState<BackupExportProgress | null>(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 (
<Card>
<CardContent className="p-6">
<h2 className="text-lg font-semibold">备份与迁移</h2>
<p className="mt-1 text-sm text-muted-foreground">
用于换机、重装或定期备份。导入会覆盖现有数据,请先确认。
</p>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">配置</p>
<p className="mt-0.5 text-xs text-muted-foreground">仅设置、密钥、热词与提示词,文件小、便于日常备份</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void handleExportConfig()} disabled={isBusy}>
{busyAction === 'exportConfig' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{busyAction === 'exportConfig' ? '正在导出' : '导出'}
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void handleImport('config')} disabled={isBusy}>
{busyAction === 'importConfig' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{busyAction === 'importConfig' ? '正在导入' : '导入'}
</Button>
</div>
</div>
{configPath && (
<SavedPath label="已保存到" path={configPath} success onOpen={() => revealFile(configPath)} />
)}
</div>
<div className="mt-4 border-t border-border pt-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">全部数据</p>
<p className="mt-0.5 text-xs text-muted-foreground">在配置基础上,额外包含历史记录与录音,适合完整迁移</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void handleExportFull()} disabled={isBusy}>
{busyAction === 'exportFull' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{busyAction === 'exportFull' ? '正在打包' : '导出'}
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void handleImport('full')} disabled={isBusy}>
{busyAction === 'importFull' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{busyAction === 'importFull' ? '正在导入' : '导入'}
</Button>
</div>
</div>
{fullExportRunning && fullProgress && <ExportProgress progress={fullProgress} />}
{fullProgress?.status === 'completed' && fullPath && (
<SavedPath label="已保存到" path={fullPath} success onOpen={() => revealFile(fullPath)} />
)}
{fullProgressError && (
<div className="mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
导出失败:{fullProgressError}
</div>
)}
</div>
</CardContent>
</Card>
)
}
@@ -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 (
<div className="mx-auto max-w-4xl p-8">
<h1 className="mb-6 text-2xl font-bold">设置</h1>
@@ -248,52 +214,7 @@ export default function GeneralSettingsPage() {
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">数据导出</h2>
<p className="mt-1 text-sm text-muted-foreground">可导出当前设置,或一键打包导出历史、收藏、热词和设置。</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void handleExportSettings()}>
<Download className="mr-1 h-4 w-4" />导出设置
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => void handleExportAll()} disabled={exporting}>
<Download className="mr-1 h-4 w-4" />{exporting ? '导出中...' : '导出全部(含音频)'}
</Button>
</div>
</div>
{exporting && exportProgress && (
<div className="mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` }}
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{exportProgress.current} / {exportProgress.total} 文件
</p>
</div>
)}
{exportResult && !exportResult.canceled && exportResult.filePath && (
<div className="mt-3 flex items-center gap-2 text-xs text-success">
<Check className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">已保存到 {exportResult.filePath}</span>
<button
onClick={handleRevealExport}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</div>
)}
{exportResult?.canceled && (
<p className="mt-3 text-xs text-muted-foreground">已取消导出。</p>
)}
</CardContent>
</Card>
<BackupSection />
</div>
</div>
)
@@ -10,14 +10,9 @@ export interface ReleaseHighlights {
}
export const RELEASE_HIGHLIGHTS: ReleaseHighlights = {
version: '0.1.1',
version: '0.1.2',
items: [
'修复悬浮窗被其他置顶程序(如播放器、会议共享工具条)遮挡的问题,现在稳定显示在最上层',
'「按住说话」「免提模式」新增支持绑定鼠标中键',
'历史记录搜索:命中的关键词现在会高亮显示,更容易定位',
'数字规范化默认开启,并修复「九点三十二分」等时间被误当小数的问题',
'优化热词管理:过多时自动折叠、给出温和提示,并用表格说明各模型的热词支持',
'优化低音量提醒与「未检测到有效声音」提示,减少打扰、更清晰',
'键盘快捷键设置新增使用说明(ⓘ)',
'修复长时间后台运行后偶发「一直处理中、超时无结果」的问题:后台连接更稳定,不再被误判空闲而断开',
'识别超时不再丢录音:即使这次超时没结果,录音也会保存到历史,可随时「重新识别」',
],
}
+84
View File
@@ -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<string> {
return invoke<string>('get_backup_directory')
}
export function onBackupExportProgress(
handler: (progress: BackupExportProgress) => void,
): Promise<UnlistenFn> {
return listen<BackupExportProgress>('backup-export-progress', (event) => handler(event.payload))
}
/** 导出配置(设置 / 供应商与密钥 / 热词 / Prompt)为 JSON。 */
export async function exportConfigFile(): Promise<ExportResult> {
const path = await invoke<string>('export_config')
return { canceled: false, filePath: path }
}
/** 导出全部数据(配置 + 历史 + 录音)为 zip,直接写入默认备份目录。 */
export async function exportFullFile(): Promise<ExportResult> {
const path = await invoke<string>('export_full')
return { canceled: false, filePath: path }
}
/** 导入结果:done = 已导入(即将重启);canceled = 用户取消。 */
export type ImportOutcome = 'done' | 'canceled'
async function pickConfirmImport(kind: 'config' | 'full'): Promise<ImportOutcome> {
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')
@@ -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)
}