fix(history): 音频播放改用 Blob URL + 导出走 Tauri dialog save (#841)

* fix(history): 音频播放改用 Blob URL + 导出走 Tauri dialog save

* fix(history): 给 export_audio_recording 加最小目标路径校验

* fix(history): export 对话框移到后端,消除 IPC 路径注入风险

* fix(history): 将阻塞 dialog 调用移入 spawn_blocking 避免阻塞异步线程池

* fix(history): support mobile export destinations and blob cleanup

* fix(history): use mobile fs handles for recording export

* fix(build): sync mobile fs dependency lockfile

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
aeoformandChris233 authored and GitHub committed 2026-07-20 15:51:41 +08:00
1 parent 75918e0338
commit a1fe38730d
13 files changed
+263 -52

No files matched your search

+1
View File
@@ -3965,6 +3965,7 @@ dependencies = [
"tauri-nspanel",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
+3
View File
@@ -59,6 +59,9 @@ ferrous-opencc = "0.4"
# Audio capture — shared across desktop and mobile.
cpal = "0.15"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-fs = "2.5.1"
# Desktop-only plugins, hotkey/insertion helpers, and tray-icon (not built for mobile).
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
reqwest = { version = "0.12", default-features = false, features = ["native-tls"] }
@@ -1,4 +1,5 @@
use super::*;
use tauri_plugin_dialog::{DialogExt, FilePath};
#[tauri::command]
pub fn list_history(coord: CoordinatorState<'_>) -> Result<Vec<DictationSession>, String> {
@@ -37,28 +38,164 @@ pub fn get_activity_stats(coord: CoordinatorState<'_>) -> Vec<ActivityDay> {
/// 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。
/// 读取录音文件的 data URL(base64),前端 `<audio>` 直接 `src={url}` 播放。
///
/// 之前的实现返回 `Vec<u8>`,Tauri IPC 将其 JSON 序列化为 number 数组(~460 KB),
/// 在 WebKit/Wry 中 `<audio>` 解析这个 Blob 有时会失败(表现为时长 0、导出无反应)。
/// 改用 base64 data URL 后:
/// - IPC payload 只增大 33%(150 KB),仍在安全范围内
/// - 前端不需要 `ArrayBuffer → Blob → createObjectURL` 的复杂链路
/// - 导出按钮直接把 data URL 设为 `<a>.href` 即可触发浏览器下载
#[tauri::command]
pub async fn read_audio_recording(session_id: String) -> Result<Vec<u8>, String> {
pub async fn read_audio_recording(session_id: String) -> Result<String, 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| {
let data = 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}")
}
})?;
log::info!("[history] read_audio_recording id={session_id} bytes={} head={:?}", data.len(), &data.get(..16));
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
let data_url = format!("data:audio/wav;base64,{b64}");
log::info!("[history] read_audio_recording data_url_len={}", data_url.len());
Ok(data_url)
}
/// 把已归档录音 wav 导出到用户选定的路径。
///
/// 后端直接调系统文件保存对话框,路径不经 IPC 传递,无法被篡改或注入。
/// 对话框调用在 spawn_blocking 中执行,避免阻塞 Tauri 异步线程池。
#[tauri::command]
pub async fn export_audio_recording(
app: tauri::AppHandle,
session_id: String,
) -> Result<String, String> {
if !is_valid_session_id(&session_id) {
return Err("invalid session id".into());
}
tokio::task::spawn_blocking(move || -> Result<String, String> {
let file_path = app
.dialog()
.file()
.add_filter("WAV audio", &["wav"])
.set_file_name(format!("openless-recording-{session_id}.wav"))
.blocking_save_file();
let Some(file_path) = file_path else {
return Err("user cancelled".into());
};
let src = crate::persistence::recording_path_for_session(&session_id)
.map_err(|e| e.to_string())?;
export_recording_to_destination(&app, file_path, &src)
})
.await
.map_err(|e| format!("internal error: {e}"))?
}
fn export_recording_to_destination(
app: &tauri::AppHandle,
file_path: FilePath,
source: &std::path::Path,
) -> Result<String, String> {
#[cfg(target_os = "android")]
if let FilePath::Url(url) = &file_path {
if url.scheme() == "content" {
copy_recording_to_mobile_url(app, &file_path, source)?;
return Ok(url.to_string());
}
}
#[cfg(target_os = "ios")]
if let FilePath::Url(url) = &file_path {
if url.scheme() == "file" {
copy_recording_to_mobile_url(app, &file_path, source)?;
return Ok(url.to_string());
}
}
let destination = file_path
.into_path()
.map_err(export_recording_failed)?;
copy_recording_to_path(source, &destination)?;
Ok(destination.to_string_lossy().into_owned())
}
const RECORDING_EXPORT_FAILED: &str = "recording export failed";
fn open_recording_source(source: &std::path::Path) -> Result<std::fs::File, String> {
std::fs::File::open(source).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
"recording not found".to_string()
} else {
export_recording_failed(error)
}
})
}
fn export_recording_failed(error: impl std::fmt::Display) -> String {
log::error!("[history] audio recording export failed: {error}");
RECORDING_EXPORT_FAILED.to_string()
}
fn copy_recording_to_path(
source: &std::path::Path,
destination: &std::path::Path,
) -> Result<(), String> {
let mut source_file = open_recording_source(source)?;
let mut destination_file = std::fs::File::create(destination).map_err(export_recording_failed)?;
std::io::copy(&mut source_file, &mut destination_file)
.map(|_| ())
.map_err(export_recording_failed)
}
#[cfg(any(target_os = "android", target_os = "ios"))]
fn copy_recording_to_mobile_url(
app: &tauri::AppHandle,
destination: &FilePath,
source: &std::path::Path,
) -> Result<(), String> {
use tauri_plugin_fs::{FsExt, OpenOptions};
let mut source_file = open_recording_source(source)?;
let mut options = OpenOptions::new();
options.write(true).truncate(true).create(true);
let mut destination_file = match app.fs().open(destination.clone(), options) {
Ok(file) => file,
Err(error) => {
#[cfg(target_os = "ios")]
let _ = app.fs().stop_accessing_security_scoped_resource(destination.clone());
return Err(export_recording_failed(error));
}
};
let copy_result = std::io::copy(&mut source_file, &mut destination_file)
.map(|_| ())
.map_err(export_recording_failed);
#[cfg(target_os = "ios")]
let stop_result = app
.fs()
.stop_accessing_security_scoped_resource(destination.clone())
.map_err(export_recording_failed);
if let Err(error) = copy_result {
#[cfg(target_os = "ios")]
let _ = stop_result;
return Err(error);
}
#[cfg(target_os = "ios")]
stop_result?;
Ok(())
}
/// 对一条「转录失败」历史条目的归档录音用**当前** ASR provider 重新转录(issue #613)。
+2
View File
@@ -177,6 +177,7 @@ macro_rules! app_invoke_handler_desktop {
commands::clear_history,
commands::get_activity_stats,
commands::read_audio_recording,
commands::export_audio_recording,
commands::retranscribe_recording,
commands::marketplace_list,
commands::marketplace_detail,
@@ -349,6 +350,7 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::clear_history,
$crate::commands::get_activity_stats,
$crate::commands::read_audio_recording,
$crate::commands::export_audio_recording,
$crate::commands::retranscribe_recording,
$crate::commands::marketplace_list,
$crate::commands::marketplace_detail,
@@ -9,9 +9,13 @@ use crate::coordinator::Coordinator;
pub fn run() {
let coordinator = Arc::new(Coordinator::new());
tauri::Builder::default()
let builder = tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_dialog::init());
#[cfg(any(target_os = "android", target_os = "ios"))]
let builder = builder.plugin(tauri_plugin_fs::init());
builder
.manage(coordinator.clone())
.setup(move |app| {
crate::init_file_logger();
@@ -56,6 +56,7 @@
"font-src": "'self' https://fonts.gstatic.com",
"img-src": "'self' asset: http://asset.localhost blob: data: https://github.com https://avatars.githubusercontent.com",
"connect-src": "'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420",
"media-src": "'self' data: asset: http://asset.localhost blob:",
"object-src": "'none'",
"base-uri": "'none'",
"form-action": "'none'",
+2
View File
@@ -332,6 +332,7 @@ export const en: typeof zhCN = {
},
},
history: {
exportError: 'Failed to export the recording. Please try again.',
kicker: 'HISTORY',
title: 'History',
desc: 'Locally stored transcripts.',
@@ -347,6 +348,7 @@ export const en: typeof zhCN = {
copyFailed: 'Failed to copy: {{err}}',
playRecording: 'Play recording',
audioLoading: 'Loading…',
audioDecodeFailed: 'Audio decode failed: {{err}}',
exportRecording: 'Export recording',
exportFailed: 'Failed to export: {{err}}',
retranscribe: 'Retranscribe',
+2
View File
@@ -334,6 +334,7 @@ export const ja: typeof zhCN = {
},
},
history: {
exportError: '録音のエクスポートに失敗しました。もう一度お試しください。',
kicker: 'HISTORY',
title: '履歴',
desc: 'ローカルに保存された認識記録。',
@@ -349,6 +350,7 @@ export const ja: typeof zhCN = {
copyFailed: 'コピーに失敗:{{err}}',
playRecording: '録音を再生',
audioLoading: '読み込み中…',
audioDecodeFailed: '音声デコード失敗:{{err}}',
exportRecording: '録音をエクスポート',
exportFailed: 'エクスポート失敗:{{err}}',
retranscribe: '再認識',
+2
View File
@@ -334,6 +334,7 @@ export const ko: typeof zhCN = {
},
},
history: {
exportError: '녹음을 내보내지 못했습니다. 다시 시도해 주세요.',
kicker: 'HISTORY',
title: '기록',
desc: '로컬에 저장된 인식 기록.',
@@ -349,6 +350,7 @@ export const ko: typeof zhCN = {
copyFailed: '복사 실패: {{err}}',
playRecording: '녹음 재생',
audioLoading: '로딩 중…',
audioDecodeFailed: '오디오 디코딩 실패: {{err}}',
exportRecording: '녹음 내보내기',
exportFailed: '내보내기 실패: {{err}}',
retranscribe: '다시 인식',
+2
View File
@@ -330,6 +330,7 @@ export const zhCN = {
},
},
history: {
exportError: '导出录音失败,请重试。',
kicker: 'HISTORY',
title: '历史记录',
desc: '本机保存的识别记录。',
@@ -345,6 +346,7 @@ export const zhCN = {
copyFailed: '复制失败:{{err}}',
playRecording: '播放录音',
audioLoading: '加载中…',
audioDecodeFailed: '音频解码失败:{{err}}',
exportRecording: '导出录音',
exportFailed: '导出失败:{{err}}',
retranscribe: '重新转录',
+2
View File
@@ -332,6 +332,7 @@ export const zhTW: typeof zhCN = {
},
},
history: {
exportError: '匯出錄音失敗,請重試。',
kicker: 'HISTORY',
title: '歷史記錄',
desc: '本機保存的識別記錄。',
@@ -347,6 +348,7 @@ export const zhTW: typeof zhCN = {
copyFailed: '複製失敗:{{err}}',
playRecording: '播放錄音',
audioLoading: '載入中…',
audioDecodeFailed: '音訊解碼失敗:{{err}}',
exportRecording: '匯出錄音',
exportFailed: '匯出失敗:{{err}}',
retranscribe: '重新轉錄',
+6 -11
View File
@@ -19,20 +19,15 @@ export function clearHistory(): Promise<void> {
return invokeOrMock("clear_history", undefined, () => undefined)
}
/** 读取某次会话的原始麦克风 wav 字节流。仅当 prefs.recordAudioForDebug 当时打开
* 并且文件没被 retention 清理掉时才有内容;其他情况后端会返回 "recording not found" 错。
* 调用方应仅在 session.hasAudioRecording === true 时触发,避免无效 IPC。 */
export function readAudioRecording(sessionId: string): Promise<Uint8Array> {
/** 读取某次会话的原始麦克风 WAV 的 data URL(base64)。
* 仅当 session.hasAudioRecording === true 时调用,避免无效 IPC。
* 返回 `data:audio/wav;base64,...` 格式,前端 `<audio>` 和导出按钮直接使用。 */
export function readAudioRecording(sessionId: string): Promise<string> {
return invokeOrMock(
"read_audio_recording",
{ sessionId },
() => new Uint8Array(),
).then((value) => {
// Tauri 默认把 Vec<u8> 序列化为 number[],前端拿到的是普通数组;统一转 Uint8Array。
if (value instanceof Uint8Array) return value
if (Array.isArray(value)) return new Uint8Array(value as number[])
return new Uint8Array(value as ArrayBuffer)
})
() => "data:audio/wav;base64,",
)
}
/** 用当前 ASR provider 对一条「转录失败」历史条目的归档录音重新转录(issue #613)。
+87 -29
View File
@@ -7,7 +7,7 @@ import { Icon } from '../components/Icon';
import { Tooltip } from '../components/Tooltip';
import { detectOS } from '../components/WindowChrome';
import { formatComboLabel } from '../lib/hotkey';
import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeRecording } from '../lib/ipc';
import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeRecording, isTauri } from '../lib/ipc';
import { useMobileLayout } from '../lib/useMobileLayout';
import type { DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
@@ -201,24 +201,32 @@ export function History() {
const onExportAudio = async () => {
if (!item || !item.hasAudioRecording) return;
try {
const bytes = await readAudioRecording(item.id);
if (bytes.byteLength === 0) throw new Error('empty recording');
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const blob = new Blob([buffer], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `openless-recording-${item.id}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 浏览器异步触发下载,立刻 revoke 偶尔被中断;延后 60s 兜底。
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
// Wry/WebKit 中 data URL 的 <a download> 可能不触发保存对话框,后端直接调系统对话框
if (isTauri) {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('export_audio_recording', { sessionId: item.id });
} else {
const dataUrl = await readAudioRecording(item.id);
if (!dataUrl || dataUrl === 'data:audio/wav;base64,') throw new Error('empty recording');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `openless-recording-${item.id}.wav`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
setActionError(null);
} catch (error) {
console.error('[history] failed to export recording', error);
const msg = errorMessage(error);
if (isUserCancelled(msg)) {
setActionError(null);
return;
}
if (msg === 'recording export failed') {
setActionError(t('history.exportError'));
return;
}
// wav 已被 retention / 条数 cap 清理:把按钮隐藏,不显示错误(用户没干错事)。
if (msg.includes('recording not found') || msg.includes('not found')) {
markAudioMissing(item.id);
@@ -499,6 +507,14 @@ function errorMessage(error: unknown): string {
return String(error);
}
function isUserCancelled(message: string): boolean {
const normalized = message.trim().toLowerCase();
return normalized === 'cancelled'
|| normalized === 'canceled'
|| normalized === 'user cancelled'
|| normalized === 'user canceled';
}
/** 当 session.hasAudioRecording 为 true 时渲染:一个加载按钮 + 拿到字节后切换为
* 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。
* `onMissing` 在后端返回 'recording not found'(wav 已被 prune)时触发,让父组件
@@ -511,33 +527,59 @@ function AudioRecordingPlayer({
onMissing?: () => void;
}) {
const { t } = useTranslation();
const [url, setUrl] = useState<string | null>(null);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [errorText, setErrorText] = useState<string | null>(null);
const mountedRef = useRef(true);
const blobUrlRef = useRef<string | null>(null);
// 组件 unmount 时释放 Blob URL,避免内存泄漏。
useEffect(() => {
mountedRef.current = true;
return () => {
if (url) URL.revokeObjectURL(url);
mountedRef.current = false;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [url]);
}, []);
const clearBlobUrl = () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
setBlobUrl(null);
};
const load = async () => {
setStatus('loading');
setErrorText(null);
try {
const bytes = await readAudioRecording(sessionId);
if (bytes.byteLength === 0) throw new Error('empty recording');
// typed array 在严格 TS lib 下不直接是 BlobPart;构造独立 ArrayBuffer 后 cast。
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const blob = new Blob([buffer], { type: 'audio/wav' });
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
const dataUrl = await readAudioRecording(sessionId);
if (!mountedRef.current) return;
if (!dataUrl || dataUrl === 'data:audio/wav;base64,') throw new Error('empty recording');
// WebKitGTK <audio> 对 data: URL 解码不稳定(时长 0 / 播不动),
// 把 base64 解码为二进制再封装成 Blob URL,在 WebKit 里远更可靠。
const comma = dataUrl.indexOf(',');
const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : '';
if (!b64) throw new Error('empty recording');
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const blob = new Blob([bin], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
if (!mountedRef.current) {
URL.revokeObjectURL(url);
return;
}
if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = url;
setBlobUrl(url);
setStatus('ready');
} catch (error) {
if (!mountedRef.current) return;
console.error('[history] load recording failed', error);
const msg = errorMessage(error);
// 文件被清理:通知父组件隐藏按钮组,自身不显示 error UI(用户没干错事)。
if (msg.includes('recording not found') || msg.includes('not found')) {
onMissing?.();
return;
@@ -547,10 +589,26 @@ function AudioRecordingPlayer({
}
};
if (status === 'ready' && url) {
if (status === 'ready' && blobUrl) {
return (
<div style={{ marginBottom: 14 }}>
<audio src={url} controls preload="auto" autoPlay style={{ width: '100%' }} />
<audio
src={blobUrl}
controls
preload="auto"
autoPlay
style={{ width: '100%' }}
onError={(e) => {
if (!mountedRef.current) return;
const a = e.currentTarget;
const code = a.error?.code ?? -1;
const detail = a.error?.message ?? `${code}`;
console.error('[history] <audio> decode/play failed', { code, detail });
clearBlobUrl();
setStatus('error');
setErrorText(t('history.audioDecodeFailed', { err: detail }));
}}
/>
</div>
);
}