Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,8 +436,8 @@ pub async fn start_microphone_level_monitor(
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = Arc::new(move |level| {
let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level }));
});
let (recorder, _runtime_errors) =
Recorder::start(microphone_device_name, consumer, level_handler)
let (recorder, _runtime_errors, _archive_active) =
Recorder::start(microphone_device_name, consumer, level_handler, None)
.map_err(|e| e.to_string())?;
*state.lock() = Some(recorder);
Ok(())
Expand Down Expand Up @@ -1115,6 +1115,59 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> {
coord.history().clear().map_err(|e| e.to_string())
}

/// 读取某次会话的原始麦克风 wav 字节流。仅当用户开过
/// `prefs.record_audio_for_debug` 并且这条 session 是开关打开后录的,才会有文件。
/// 文件名规约:`<data_dir>/recordings/<session_id>.wav`,与 DictationSession.id 同名。
///
/// 路径校验:session_id **必须**严格匹配 UUID-v4 字面(36 字符 = 8-4-4-4-12 + 4 个 `-`,
/// 内容仅 ASCII 十六进制 + `-`)。白名单胜过黑名单——绝对路径前缀、Windows ADS、
/// 百分号编码、NUL 字节都不在合法字符集里,挡掉所有 Path::join 越界的可能。
/// session_id 在仓库内由 `Uuid::new_v4()` 生成 (`dictation.rs:1531`),前端只会回传
/// 自己列出的合法 id,但 IPC = boundary,按 boundary 规则严格校验。
///
/// async fs:单条 5 分钟 wav 约 9.6MB,同步 `std::fs::read` 会阻塞 Tauri IPC 主循环。
/// 改 `tokio::fs::read` 后让出线程给其它 IPC。
#[tauri::command]
pub async fn read_audio_recording(session_id: String) -> Result<Vec<u8>, String> {
if !is_valid_session_id(&session_id) {
return Err("invalid session id".into());
}
let path =
crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?;
if !path.exists() {
return Err("recording not found".into());
}
// TOCTOU 兜底:exists() 通过到 read 之间文件可能被 prune(条数 cap / retention
// 清理 / 用户手动删)。把 NotFound 标准化成跟 exists() 失败同样的错误字符串,
// 前端单条 'recording not found' catch 就能稳定隐藏按钮,不依赖本地化 OS 错误。
tokio::fs::read(&path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"recording not found".into()
} else {
format!("read wav failed: {e}")
}
})
}

/// UUID-v4 字面校验:36 字符 + 5 段 `-` 分隔(8-4-4-4-12)+ 仅 ASCII 十六进制。
fn is_valid_session_id(s: &str) -> bool {
if s.len() != 36 {
return false;
}
let bytes = s.as_bytes();
for (i, b) in bytes.iter().enumerate() {
let is_dash_position = matches!(i, 8 | 13 | 18 | 23);
if is_dash_position {
if *b != b'-' {
return false;
}
} else if !b.is_ascii_hexdigit() {
return false;
}
}
true
}

// ─────────────────────────── vocab ───────────────────────────

#[tauri::command]
Expand Down Expand Up @@ -2225,9 +2278,9 @@ mod tests {
use super::{
active_asr_is_keyless_for_validation, active_foundry_model_from_prefs,
asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models,
is_gemini_base_url, llm_configured_for_provider, local_asr_release_plan_for_provider,
models_url, normalize_foundry_language_hint, parse_gemini_model_ids,
parse_latest_beta_from_atom, parse_model_ids, persist_settings,
is_gemini_base_url, is_valid_session_id, llm_configured_for_provider,
local_asr_release_plan_for_provider, models_url, normalize_foundry_language_hint,
parse_gemini_model_ids, parse_latest_beta_from_atom, parse_model_ids, persist_settings,
release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig,
SettingsWriter,
};
Expand Down Expand Up @@ -2942,4 +2995,32 @@ mod tests {
assert_eq!(models, vec!["m1".to_string(), "m2".to_string()]);
server.join().unwrap();
}

#[test]
fn is_valid_session_id_accepts_canonical_uuid_v4() {
// canonical UUID-v4 字面:8-4-4-4-12,全小写、全大写、混合都接受。
assert!(is_valid_session_id("550e8400-e29b-41d4-a716-446655440000"));
assert!(is_valid_session_id("550E8400-E29B-41D4-A716-446655440000"));
assert!(is_valid_session_id("Abc12345-6789-abcd-EF01-234567890abc"));
}

#[test]
fn is_valid_session_id_rejects_path_traversal_and_garbage() {
assert!(!is_valid_session_id(""));
assert!(!is_valid_session_id("../../etc/passwd"));
assert!(!is_valid_session_id("..\\..\\windows\\system32"));
// 长度对但含 `/`:dash 位置错或非 hex 字符都不通过
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544/000"));
assert!(!is_valid_session_id("550e8400_e29b_41d4_a716_446655440000")); // 用 _ 代 -
// 非 hex 字符
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000g"));
// 长度不对(35 / 37)
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544000"));
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-4466554400000"));
// NUL 字节
assert!(!is_valid_session_id("550e8400-e29b-41d4-a716-44665544\x00000"));
// 百分号编码与绝对路径
assert!(!is_valid_session_id("%2e%2e/recordings/x"));
assert!(!is_valid_session_id("/Users/attacker/secret.wav"));
}
}
29 changes: 23 additions & 6 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ struct Inner {
#[cfg(target_os = "windows")]
foundry_local_runtime: Arc<FoundryLocalRuntime>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
/// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。
/// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取,
/// 决定 DictationSession.has_audio_recording 字段。比单纯读 prefs.record_audio_for_debug
/// 更准确:用户开了开关但路径无法创建(权限 / 磁盘满)也算 false。
audio_archive_active: AtomicBool,
recording_mute: Mutex<SharedRecordingMuteState>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
Expand Down Expand Up @@ -203,6 +208,7 @@ impl Coordinator {
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
recorder: Mutex::new(None),
audio_archive_active: AtomicBool::new(false),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
Expand Down Expand Up @@ -252,6 +258,7 @@ impl Coordinator {
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
recorder: Mutex::new(None),
audio_archive_active: AtomicBool::new(false),
recording_mute: Mutex::new(SharedRecordingMuteState::new()),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
Expand Down Expand Up @@ -2584,8 +2591,15 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "QA recorder");
acquire_recording_mute(inner, "qa").await;
match Recorder::start(microphone_device_name, consumer, level_handler) {
Ok((rec, runtime_errors)) => {
// QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。
// 调试 QA 麦克风请用主听写路径。
match Recorder::start(microphone_device_name, consumer, level_handler, None) {
Ok((rec, runtime_errors, archive_active)) => {
// QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation
// 接力时读到上一个 QA session 的过期值。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
*inner.qa_recorder.lock() = Some(rec);
// QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时
// 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。
Expand Down Expand Up @@ -2853,11 +2867,14 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
error_code: Some("qaSession".to_string()),
duration_ms: Some(raw.duration_ms),
dictionary_entry_count: None,
has_audio_recording: None,
};
if let Err(e) = inner
.history
.append_with_retention(session, inner.prefs.get().history_retention_days)
{
let prefs_snapshot = inner.prefs.get();
if let Err(e) = inner.history.append_with_retention(
session,
prefs_snapshot.history_retention_days,
prefs_snapshot.history_max_entries,
) {
log::error!("[coord] QA history append failed: {e}");
}
}
Expand Down
58 changes: 47 additions & 11 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,8 +785,31 @@ pub(super) async fn start_recorder_for_starting(
let microphone_device_name = selected_microphone_device_name(inner);
stop_microphone_preview_monitor(inner, "dictation recorder");
acquire_recording_mute(inner, "dictation").await;
match Recorder::start(microphone_device_name, consumer, level_handler) {
Ok((rec, runtime_errors)) => {
let audio_archive_path = if inner.prefs.get().record_audio_for_debug {
// 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见
// 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。
// 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。
let prefs = inner.prefs.get();
let _ = crate::persistence::prune_recordings(
prefs.history_retention_days,
prefs.audio_recording_max_entries,
);
crate::persistence::recording_path_for_session(&session_id.to_string()).ok()
} else {
None
};
match Recorder::start(
microphone_device_name,
consumer,
level_handler,
audio_archive_path,
) {
Ok((rec, runtime_errors, archive_active)) => {
// 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript
// 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。
inner
.audio_archive_active
.store(archive_active, std::sync::atomic::Ordering::Relaxed);
store_recorder_for_session(inner, session_id, rec);
spawn_recorder_error_monitor(inner, runtime_errors);
// 不在这里 emit Recording capsule。
Expand Down Expand Up @@ -1230,11 +1253,17 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
error_code: Some("emptyTranscript".to_string()),
duration_ms: Some(raw.duration_ms),
dictionary_entry_count: Some(enabled_phrases(inner).len() as u32),
// empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想
// 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent
// "Missing Audio" 反馈。
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
};
if let Err(e) = inner
.history
.append_with_retention(session, inner.prefs.get().history_retention_days)
{
let prefs_snapshot = inner.prefs.get();
if let Err(e) = inner.history.append_with_retention(
session,
prefs_snapshot.history_retention_days,
prefs_snapshot.history_max_entries,
) {
log::error!("[coord] history append failed: {e}");
}
emit_capsule(
Expand Down Expand Up @@ -1507,8 +1536,11 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
.map(str::to_string);
let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired");

let history_session_id = Uuid::new_v4().to_string();
// 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `<session_id>.wav`
// 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。
let history_session_id = current_session_id.to_string();
let history_created_at = Utc::now().to_rfc3339();
let prefs_snapshot = inner.prefs.get();
let session = DictationSession {
id: history_session_id.clone(),
created_at: history_created_at.clone(),
Expand All @@ -1523,11 +1555,15 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
// 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次),
// 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。
dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32),
// 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关——
// 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。
has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)),
};
if let Err(e) = inner
.history
.append_with_retention(session, inner.prefs.get().history_retention_days)
{
if let Err(e) = inner.history.append_with_retention(
session,
prefs_snapshot.history_retention_days,
prefs_snapshot.history_max_entries,
) {
log::error!("[coord] history append failed: {e}");
}
let done_message = if tsf_required_insert_failed {
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ pub fn run() {
commands::list_history,
commands::delete_history_entry,
commands::clear_history,
commands::read_audio_recording,
commands::list_vocab,
commands::add_vocab,
commands::remove_vocab,
Expand Down
Loading
Loading