Files
openless/openless-all/app/src-tauri/src/commands/settings.rs
T
吕柏青andClaude Opus 4.8 72f9ced533 refactor(commands): commands.rs 拆为 commands/ 域模块(glob 重导出,lib.rs 零改)
把 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>
2026-06-07 13:05:53 +08:00

413 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}