把 4857 行的单文件 commands.rs 按单一职责拆成 commands/ 下 16 个域模块
(settings / credentials / providers / history / dictionary / dictation /
style_packs / permissions_cmds / qa / hotkeys / local_asr / foundry_asr /
sherpa_asr / marketplace / github_oauth / misc)。纯机械式、行为保持的剪切 +
可见性提升,未改动任何 #[tauri::command] 函数体。
- commands.rs → commands/mod.rs:内含 `mod <domain>;` + `pub use <domain>::*;`,
glob 重导出每个域模块。`commands::<name>` 路径全部不变 → lib.rs 的
generate_handler! 清单(346-492)与所有类型/helper 引用零改动。
#[tauri::command] 生成的 __cmd__<name> 伴生项随 glob 一并重导出。
- 跨域共享项提到 mod.rs 并 pub(crate):is_valid_session_id /
is_valid_local_pack_id、open_path_in_file_manager(3 个 cfg 变体)、
共享状态类型(MicrophoneMonitorState / TrayMicrophoneMenuState 等)、
常用 crate 级 import 以 `pub(crate) use` 重导出供子模块 `use super::*` 取用。
- MARKETPLACE_BASE_URL 提为 pub(crate)(misc::check_network 引用);
hotkeys.rs 的 reject_* 整套 + sync_dictation_hotkey_legacy_fields 保留在
hotkeys.rs 但提 pub(crate)(被 settings::persist_settings、qa::set_qa_hotkey 引用);
style_packs.rs 的 activate_builtin_style_mode 等保留并 pub(crate)(lib.rs 引用)。
- 测试:保留单个 tests mod 在 mod.rs,把 `use super::{...}` 改为 per-module
路径,被测 helper 提为 pub(crate)。不改任何测试逻辑。
- sherpa_onnx_asr_* 整组 #[cfg(windows)],非 Windows 平台 glob 重导出无人引用,
对该 `pub use sherpa_asr::*` 加 #[allow(unused_imports)]。
验证:cargo check 0 error;cargo test --lib 417 passed(基线不回归);
npm run build 通过;capsule Spaces / hotkey-injection 契约测试通过。
命令面 127 个 #[tauri::command] 与原文件逐一对齐,无新增/缺失/重复。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
413 lines
16 KiB
Rust
413 lines
16 KiB
Rust
use super::*;
|
||
|
||
#[tauri::command]
|
||
pub fn get_settings(coord: CoordinatorState<'_>) -> UserPreferences {
|
||
coord.prefs().get()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_default_style_system_prompts() -> StyleSystemPrompts {
|
||
StyleSystemPrompts::default()
|
||
}
|
||
|
||
pub(crate) trait SettingsWriter {
|
||
fn read_settings(&self) -> UserPreferences;
|
||
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>;
|
||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String>;
|
||
fn refresh_dictation_hotkey(&self);
|
||
fn refresh_qa_hotkey(&self);
|
||
fn refresh_combo_hotkey(&self);
|
||
fn refresh_translation_hotkey(&self);
|
||
fn refresh_switch_style_hotkey(&self);
|
||
fn refresh_open_app_hotkey(&self);
|
||
fn refresh_coding_agent_hotkey(&self);
|
||
}
|
||
|
||
impl SettingsWriter for Coordinator {
|
||
fn read_settings(&self) -> UserPreferences {
|
||
self.prefs().get()
|
||
}
|
||
|
||
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
|
||
self.prefs().set(prefs).map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
|
||
self.sync_active_asr_provider_to_vault(provider)
|
||
}
|
||
|
||
fn refresh_dictation_hotkey(&self) {
|
||
self.update_hotkey_binding();
|
||
}
|
||
|
||
fn refresh_qa_hotkey(&self) {
|
||
self.update_qa_hotkey_binding();
|
||
}
|
||
|
||
fn refresh_combo_hotkey(&self) {
|
||
self.update_combo_hotkey_binding();
|
||
}
|
||
|
||
fn refresh_translation_hotkey(&self) {
|
||
self.update_translation_hotkey_binding();
|
||
}
|
||
|
||
fn refresh_switch_style_hotkey(&self) {
|
||
self.update_switch_style_hotkey_binding();
|
||
}
|
||
|
||
fn refresh_open_app_hotkey(&self) {
|
||
self.update_open_app_hotkey_binding();
|
||
}
|
||
|
||
fn refresh_coding_agent_hotkey(&self) {
|
||
self.update_coding_agent_hotkey_binding();
|
||
}
|
||
}
|
||
|
||
impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
|
||
fn read_settings(&self) -> UserPreferences {
|
||
(**self).read_settings()
|
||
}
|
||
|
||
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
|
||
(**self).write_settings(prefs)
|
||
}
|
||
|
||
fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> {
|
||
(**self).sync_active_asr_provider(provider)
|
||
}
|
||
|
||
fn refresh_dictation_hotkey(&self) {
|
||
(**self).refresh_dictation_hotkey();
|
||
}
|
||
|
||
fn refresh_qa_hotkey(&self) {
|
||
(**self).refresh_qa_hotkey();
|
||
}
|
||
|
||
fn refresh_combo_hotkey(&self) {
|
||
(**self).refresh_combo_hotkey();
|
||
}
|
||
|
||
fn refresh_translation_hotkey(&self) {
|
||
(**self).refresh_translation_hotkey();
|
||
}
|
||
|
||
fn refresh_switch_style_hotkey(&self) {
|
||
(**self).refresh_switch_style_hotkey();
|
||
}
|
||
|
||
fn refresh_open_app_hotkey(&self) {
|
||
(**self).refresh_open_app_hotkey();
|
||
}
|
||
|
||
fn refresh_coding_agent_hotkey(&self) {
|
||
(**self).refresh_coding_agent_hotkey();
|
||
}
|
||
}
|
||
|
||
pub(crate) fn persist_settings<T: SettingsWriter>(
|
||
coord: &T,
|
||
mut prefs: UserPreferences,
|
||
) -> Result<(), String> {
|
||
let mut previous = coord.read_settings();
|
||
sync_dictation_hotkey_legacy_fields(&mut previous);
|
||
sync_dictation_hotkey_legacy_fields(&mut prefs);
|
||
reject_hotkey_collisions(&prefs)?;
|
||
let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey;
|
||
let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode;
|
||
let qa_changed = previous.qa_hotkey != prefs.qa_hotkey;
|
||
let translation_changed = previous.translation_hotkey != prefs.translation_hotkey;
|
||
let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey;
|
||
let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey;
|
||
let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled
|
||
|| previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey;
|
||
let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider;
|
||
let active_asr_provider = prefs.active_asr_provider.clone();
|
||
if active_asr_provider_changed {
|
||
coord.sync_active_asr_provider(&active_asr_provider)?;
|
||
}
|
||
if let Err(error) = coord.write_settings(prefs.clone()) {
|
||
if active_asr_provider_changed {
|
||
if let Err(rollback_error) =
|
||
coord.sync_active_asr_provider(&previous.active_asr_provider)
|
||
{
|
||
coord.write_settings(prefs).map_err(|roll_forward_error| {
|
||
format!(
|
||
"{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}"
|
||
)
|
||
})?;
|
||
} else {
|
||
return Err(error);
|
||
}
|
||
} else {
|
||
return Err(error);
|
||
}
|
||
}
|
||
if dictation_shortcut_changed || dictation_mode_changed {
|
||
coord.refresh_dictation_hotkey();
|
||
}
|
||
if dictation_shortcut_changed {
|
||
coord.refresh_combo_hotkey();
|
||
}
|
||
if qa_changed {
|
||
coord.refresh_qa_hotkey();
|
||
}
|
||
if translation_changed {
|
||
coord.refresh_translation_hotkey();
|
||
}
|
||
if switch_style_changed {
|
||
coord.refresh_switch_style_hotkey();
|
||
}
|
||
if open_app_changed {
|
||
coord.refresh_open_app_hotkey();
|
||
}
|
||
if coding_agent_changed {
|
||
coord.refresh_coding_agent_hotkey();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_settings(
|
||
coord: CoordinatorState<'_>,
|
||
app: AppHandle,
|
||
tray_microphones: State<'_, TrayMicrophoneMenuState>,
|
||
mut prefs: UserPreferences,
|
||
) -> Result<(), String> {
|
||
let packs = coord.style_packs().list().map_err(|e| e.to_string())?;
|
||
sync_style_pack_preferences(&mut prefs, &packs);
|
||
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
|
||
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
||
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
||
persist_settings(&*coord, prefs.clone())?;
|
||
// refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。
|
||
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
|
||
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
|
||
// 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。
|
||
let app_for_main = app.clone();
|
||
let prefs_for_main = prefs.clone();
|
||
let _ = app.run_on_main_thread(move || {
|
||
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
|
||
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
|
||
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
|
||
sync_tray_microphone_selection(
|
||
&tray_state.lock(),
|
||
&prefs_for_main.microphone_device_name,
|
||
);
|
||
}
|
||
});
|
||
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
|
||
// 但函数签名保留 State 入参,以便 Tauri 在调用前注入。
|
||
let _ = tray_microphones;
|
||
let _ = app.emit("prefs:changed", &prefs);
|
||
Ok(())
|
||
}
|
||
|
||
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
|
||
//
|
||
// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化
|
||
// 跟其他 prefs 写入一致,且写完后 emit "prefs:changed",让前端跨 webview 同步。
|
||
//
|
||
// 更新:plugin-updater 2.10.1 的 Builder 现在暴露 .endpoints() runtime API(CLAUDE.md
|
||
// 当年记的"不支持"已不成立)。本节配合 `app_check_update_with_channel` 命令实现
|
||
// Beta auto-update:Stable 渠道 → 走 tauri.conf 的默认 endpoints;Beta 渠道 →
|
||
// fetch_latest_beta_release 拿最新 prerelease tag → 拼成 -beta manifest URL →
|
||
// builder.endpoints(vec![url]).build().check()。Stable 用户绝对不会撞到 Beta 包
|
||
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
|
||
// Release assets 里物理分离)。
|
||
|
||
#[tauri::command]
|
||
pub fn get_update_channel(coord: CoordinatorState<'_>) -> UpdateChannel {
|
||
coord.prefs().get().update_channel
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn set_update_channel(
|
||
coord: CoordinatorState<'_>,
|
||
app: AppHandle,
|
||
channel: UpdateChannel,
|
||
) -> Result<(), String> {
|
||
let mut prefs = coord.prefs().get();
|
||
if prefs.update_channel == channel {
|
||
return Ok(());
|
||
}
|
||
prefs.update_channel = channel;
|
||
persist_settings(&*coord, prefs.clone())?;
|
||
let _ = app.emit("prefs:changed", &prefs);
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct LatestBetaRelease {
|
||
pub tag_name: String,
|
||
pub html_url: String,
|
||
pub published_at: String,
|
||
}
|
||
|
||
/// 拉 GitHub Releases atom feed 找最新 Beta release(tag 以 `-beta-tauri` 结尾)。
|
||
///
|
||
/// 历史:之前用 `api.github.com/repos/.../releases` REST 端点,**未认证 60 req/h/IP**,
|
||
/// 多人多次切 Beta toggle 很容易撞 403 rate limit(用户报"获取 Beta 版本信息失败"
|
||
/// 即是这个)。换成 `releases.atom` 后是公开页面 + CDN cache,没有同等 rate 限制。
|
||
/// Atom feed 不显式标 prerelease,但项目约定 tag 后缀 `-beta-tauri` 必为 Beta,
|
||
/// 所以只用 tag 后缀过滤就够了。
|
||
///
|
||
/// 返回 `Ok(None)` = 当前没发过 Beta 版;`Err(String)` = 网络/解析故障。
|
||
#[tauri::command]
|
||
pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, String> {
|
||
let resp = net::send_with_retry(|| {
|
||
net::http()
|
||
.get("https://github.com/appergb/openless/releases.atom")
|
||
.timeout(std::time::Duration::from_secs(15))
|
||
})
|
||
.await
|
||
.map_err(|e| format!("fetch releases.atom: {e}"))?;
|
||
if !resp.status().is_success() {
|
||
return Err(format!("releases.atom status {}", resp.status()));
|
||
}
|
||
let body = resp
|
||
.text()
|
||
.await
|
||
.map_err(|e| format!("read atom body: {e}"))?;
|
||
Ok(parse_latest_beta_from_atom(&body))
|
||
}
|
||
|
||
/// 简单字符串解析 atom feed,避免引 XML 库。每个 `<entry>...</entry>` 内含一行
|
||
/// `<link rel="alternate" type="text/html" href=".../releases/tag/<tag>"/>`,
|
||
/// 用 `/releases/tag/` 这个唯一锚点抓 tag。
|
||
pub(crate) fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
|
||
for entry in body.split("<entry>").skip(1) {
|
||
let entry_body = entry
|
||
.split_once("</entry>")
|
||
.map(|(b, _)| b)
|
||
.unwrap_or(entry);
|
||
let needle = "/releases/tag/";
|
||
let tag_start = match entry_body.find(needle) {
|
||
Some(i) => i + needle.len(),
|
||
None => continue,
|
||
};
|
||
let tag_after = &entry_body[tag_start..];
|
||
let tag_end = tag_after
|
||
.find(|c: char| c == '"' || c == '<' || c == ' ' || c == '/')
|
||
.unwrap_or(tag_after.len());
|
||
let tag_name = tag_after[..tag_end].to_string();
|
||
if !tag_name.ends_with("-beta-tauri") {
|
||
continue;
|
||
}
|
||
let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}");
|
||
let published_at =
|
||
extract_between(entry_body, "<updated>", "</updated>").unwrap_or_default();
|
||
return Some(LatestBetaRelease {
|
||
tag_name,
|
||
html_url,
|
||
published_at,
|
||
});
|
||
}
|
||
None
|
||
}
|
||
|
||
fn extract_between(haystack: &str, open: &str, close: &str) -> Option<String> {
|
||
let start = haystack.find(open)? + open.len();
|
||
let end = haystack[start..].find(close)?;
|
||
Some(haystack[start..start + end].to_string())
|
||
}
|
||
|
||
// ─────────────────────── Channel-aware updater check ────────────────────────
|
||
//
|
||
// 替换前端原来直接 import('@tauri-apps/plugin-updater').check() 的路径:
|
||
// - Stable 渠道:builder 不动 endpoints,沿用 tauri.conf 配的 stable manifest URL。
|
||
// - Beta 渠道:先 fetch_latest_beta_release 拿最新 prerelease tag,拼成 -beta manifest
|
||
// URL(同时给一对 mirror + direct),再 builder.endpoints(vec![url])?.build()?.check()。
|
||
//
|
||
// 返回的 Metadata 形状与 plugin-updater 的 JS UpdateMetadata 完全一致(rid +
|
||
// currentVersion 等驼峰字段),前端可以直接 `new Update(metadata)` 复用 plugin
|
||
// 的 download / install / close 实现,无需我们自己写下载和签名校验。
|
||
//
|
||
// 物理隔离:Beta tag 推出来的 manifest 文件名带 `-beta` 后缀(参见 release-tauri.yml
|
||
// 第 382 行注释),跟 Stable 的 `latest-{tgt}-{arch}.json` 在 GitHub Release assets
|
||
// 里是分开的两份文件 —— 即使代码逻辑写错把 Beta URL 传给 Stable 用户,HTTP 也是
|
||
// 直接 404,绝不会拿到错档。
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct AppUpdateMetadata {
|
||
pub rid: tauri::ResourceId,
|
||
pub current_version: String,
|
||
pub version: String,
|
||
pub date: Option<String>,
|
||
pub body: Option<String>,
|
||
/// 原始 manifest JSON——`new Update(metadata)` 在 JS 那边会校验它存在;
|
||
/// 我们透传 plugin 自己 check 时拿到的字段。
|
||
pub raw_json: serde_json::Value,
|
||
}
|
||
|
||
/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。
|
||
/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta);
|
||
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
|
||
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
|
||
#[tauri::command]
|
||
pub async fn app_check_update_with_channel<R: tauri::Runtime>(
|
||
coord: CoordinatorState<'_>,
|
||
webview: tauri::Webview<R>,
|
||
timeout_ms: Option<u64>,
|
||
channel: Option<UpdateChannel>,
|
||
) -> Result<Option<AppUpdateMetadata>, String> {
|
||
use tauri_plugin_updater::UpdaterExt;
|
||
|
||
let channel = channel.unwrap_or_else(|| coord.prefs().get().update_channel);
|
||
let mut builder = webview.updater_builder();
|
||
if let Some(ms) = timeout_ms {
|
||
builder = builder.timeout(std::time::Duration::from_millis(ms));
|
||
}
|
||
if matches!(channel, UpdateChannel::Beta) {
|
||
let urls = resolve_beta_manifest_endpoints().await?;
|
||
builder = builder
|
||
.endpoints(urls)
|
||
.map_err(|e| format!("set beta endpoints: {e}"))?;
|
||
}
|
||
let updater = builder.build().map_err(|e| format!("build updater: {e}"))?;
|
||
let update = updater
|
||
.check()
|
||
.await
|
||
.map_err(|e| format!("check update failed: {e}"))?;
|
||
|
||
let Some(update) = update else {
|
||
return Ok(None);
|
||
};
|
||
// date 字段透传需要引 time crate;前端 AutoUpdate.tsx 实际并不用 date,所以这里
|
||
// 直接置 None,避免拉一个新 dep 进 src-tauri/Cargo.toml。
|
||
let metadata = AppUpdateMetadata {
|
||
current_version: update.current_version.clone(),
|
||
version: update.version.clone(),
|
||
date: None,
|
||
body: update.body.clone(),
|
||
raw_json: update.raw_json.clone(),
|
||
rid: webview.resources_table().add(update),
|
||
};
|
||
Ok(Some(metadata))
|
||
}
|
||
|
||
/// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。
|
||
/// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable
|
||
/// endpoints 一致,让国内访问优先打到 CDN。
|
||
async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
|
||
let Some(latest) = fetch_latest_beta_release().await? else {
|
||
return Err("尚未发布过 Beta 版本".to_string());
|
||
};
|
||
let tag = latest.tag_name;
|
||
// {{target}} / {{arch}} 占位符由 plugin 在 check 时替换。Rust raw string 用 r#""#
|
||
// 不需要转义双花括号,比 format! 干净。
|
||
let mirror = format!(
|
||
"https://fastgit.cc/https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta-mirror.json"
|
||
);
|
||
let direct = format!(
|
||
"https://github.com/appergb/openless/releases/download/{tag}/latest-{{{{target}}}}-{{{{arch}}}}-beta.json"
|
||
);
|
||
let mirror_url = url::Url::parse(&mirror).map_err(|e| format!("parse beta mirror url: {e}"))?;
|
||
let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?;
|
||
Ok(vec![mirror_url, direct_url])
|
||
}
|