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
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
157 changes: 147 additions & 10 deletions openless-all/app/src-tauri/src/commands/history.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use tauri_plugin_dialog::{DialogExt, FilePath};

#[tauri::command]
pub fn list_history(coord: CoordinatorState<'_>) -> Result<Vec<DictationSession>, String> {
Expand Down Expand Up @@ -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)。
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions openless-all/app/src-tauri/src/mobile_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const ja: typeof zhCN = {
},
},
history: {
exportError: '録音のエクスポートに失敗しました。もう一度お試しください。',
kicker: 'HISTORY',
title: '履歴',
desc: 'ローカルに保存された認識記録。',
Expand All @@ -349,6 +350,7 @@ export const ja: typeof zhCN = {
copyFailed: 'コピーに失敗:{{err}}',
playRecording: '録音を再生',
audioLoading: '読み込み中…',
audioDecodeFailed: '音声デコード失敗:{{err}}',
exportRecording: '録音をエクスポート',
exportFailed: 'エクスポート失敗:{{err}}',
retranscribe: '再認識',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const ko: typeof zhCN = {
},
},
history: {
exportError: '녹음을 내보내지 못했습니다. 다시 시도해 주세요.',
kicker: 'HISTORY',
title: '기록',
desc: '로컬에 저장된 인식 기록.',
Expand All @@ -349,6 +350,7 @@ export const ko: typeof zhCN = {
copyFailed: '복사 실패: {{err}}',
playRecording: '녹음 재생',
audioLoading: '로딩 중…',
audioDecodeFailed: '오디오 디코딩 실패: {{err}}',
exportRecording: '녹음 내보내기',
exportFailed: '내보내기 실패: {{err}}',
retranscribe: '다시 인식',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export const zhCN = {
},
},
history: {
exportError: '导出录音失败,请重试。',
kicker: 'HISTORY',
title: '历史记录',
desc: '本机保存的识别记录。',
Expand All @@ -345,6 +346,7 @@ export const zhCN = {
copyFailed: '复制失败:{{err}}',
playRecording: '播放录音',
audioLoading: '加载中…',
audioDecodeFailed: '音频解码失败:{{err}}',
exportRecording: '导出录音',
exportFailed: '导出失败:{{err}}',
retranscribe: '重新转录',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export const zhTW: typeof zhCN = {
},
},
history: {
exportError: '匯出錄音失敗,請重試。',
kicker: 'HISTORY',
title: '歷史記錄',
desc: '本機保存的識別記錄。',
Expand All @@ -347,6 +348,7 @@ export const zhTW: typeof zhCN = {
copyFailed: '複製失敗:{{err}}',
playRecording: '播放錄音',
audioLoading: '載入中…',
audioDecodeFailed: '音訊解碼失敗:{{err}}',
exportRecording: '匯出錄音',
exportFailed: '匯出失敗:{{err}}',
retranscribe: '重新轉錄',
Expand Down
17 changes: 6 additions & 11 deletions openless-all/app/src/lib/ipc/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
Loading
Loading