修复悬浮窗创建失败并增加分级诊断日志;新增本地备份/恢复;版本 0.1.2
- 悬浮窗:WebView2 浏览器参数统一到 main.rs 全局常量,移除主窗专属 additionalBrowserArgs,并加启动一致性护栏 - 悬浮窗:创建链路改为分级日志(create dispatched / page load / renderer ready / render ack)与失败分类 - 新增本地备份与恢复功能 - 版本号 bump 到 0.1.2
This commit is contained in:
1 parent
77bfe8b74f
commit
fc1eb4b0e7
16 files changed
+1271
-123
No files matched your search
@@ -9,6 +9,17 @@
|
||||
|
||||
---
|
||||
|
||||
## [0.1.2] - 未发布
|
||||
|
||||
### 优化
|
||||
- 优化「备份与迁移」:导出不再弹出保存位置对话框,「配置」与「全部数据」都直接保存到默认下载目录,导出完成后就地显示文件路径与「打开所在文件夹」按钮,和其他下载体验保持一致。同时优化了页面文案,更清楚地说明两种备份分别包含什么、适合什么场景(「全部数据」在「配置」基础上额外包含历史记录与录音)。
|
||||
|
||||
### 修复
|
||||
- 修复长时间后台运行后偶发「一直显示处理中、最后超时且无结果」的问题:SayIt 主窗口常驻后台隐藏时,系统会节流其心跳定时器,导致与服务器的连接被判为空闲而每隔约 5 分钟被回收;若录音恰好在连接被回收的瞬间开始,本次识别就会丢失并超时。现已让后台定时器不再被节流,保持心跳正常、连接稳定。
|
||||
- 识别超时时不再丢失录音:即使这次识别超时没有结果,录音也会保存到历史记录中,可随时点「重新识别」重试。
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] - 2026-07-22
|
||||
|
||||
### 新增
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
Generated
+1
-1
@@ -4024,7 +4024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sayit"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bzip2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sayit"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
authors = ["Liu Qianglong", "Claude"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod audio;
|
||||
pub mod audio_mute;
|
||||
pub mod backup;
|
||||
pub mod diagnostics;
|
||||
pub mod export;
|
||||
pub mod paste;
|
||||
|
||||
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,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: [
|
||||
'修复悬浮窗被其他置顶程序(如播放器、会议共享工具条)遮挡的问题,现在稳定显示在最上层',
|
||||
'「按住说话」「免提模式」新增支持绑定鼠标中键',
|
||||
'历史记录搜索:命中的关键词现在会高亮显示,更容易定位',
|
||||
'数字规范化默认开启,并修复「九点三十二分」等时间被误当小数的问题',
|
||||
'优化热词管理:过多时自动折叠、给出温和提示,并用表格说明各模型的热词支持',
|
||||
'优化低音量提醒与「未检测到有效声音」提示,减少打扰、更清晰',
|
||||
'键盘快捷键设置新增使用说明(ⓘ)',
|
||||
'修复长时间后台运行后偶发「一直处理中、超时无结果」的问题:后台连接更稳定,不再被误判空闲而断开',
|
||||
'识别超时不再丢录音:即使这次超时没结果,录音也会保存到历史,可随时「重新识别」',
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user