diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 53d5fa08..345036d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,11 @@
name: CI
+# 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责
+# 在合并前快速验证两件事:
+# 1. 前端 typecheck + vite bundle 通过(tsc + vite build,捕获跨 locale 类型 drift)
+# 2. Rust 后端在 macOS / Windows 都能 cargo check 过(捕获 cfg 漏分支 / 平台 API 误用)
+# 跑 build-mac.sh / windows-package-msvc.ps1 太重;只跑轻量 cargo check + vite build。
+
on:
push:
branches: [main, dev]
@@ -7,9 +13,20 @@ on:
branches: [main, dev]
jobs:
- windows-tauri:
- name: Windows Tauri checks
- runs-on: windows-latest
+ cross-platform:
+ name: ${{ matrix.label }} checks
+ strategy:
+ # 一个平台挂掉不阻塞其他平台拿到验证结果。
+ fail-fast: false
+ matrix:
+ include:
+ - os: macos-latest
+ label: macOS
+ preflight: false
+ - os: windows-latest
+ label: Windows
+ preflight: true
+ runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: openless-all/app
@@ -32,10 +49,12 @@ jobs:
run: npm ci
- name: Check Windows prerequisites
+ if: matrix.preflight
shell: pwsh
run: ./scripts/windows-preflight.ps1 -Toolchain msvc
- name: Check PowerShell scripts
+ if: matrix.preflight
shell: pwsh
run: |
foreach ($script in @("./scripts/windows-preflight.ps1", "./scripts/windows-build-gnu.ps1", "./scripts/windows-runtime-smoke.ps1")) {
@@ -47,8 +66,38 @@ jobs:
}
}
- - name: Build frontend
+ - name: Build frontend (tsc + vite)
run: npm run build
- - name: Check Tauri backend
+ - name: Check Tauri backend (cargo check)
run: cargo check --manifest-path src-tauri/Cargo.toml
+
+ - name: Verify version sync across all 5 files
+ # 两个平台都跑这个校验:Windows runner 自带 git-bash,跨 shell 表现一致。
+ # 一旦版本号 drift 立刻 fail,避免发版时再发现漏改。
+ # 校验 5 处:package.json / package-lock.json (root + nested) /
+ # tauri.conf.json / Cargo.toml / Cargo.lock 的 [openless] 包。
+ shell: bash
+ run: |
+ PKG=$(node -p "require('./package.json').version")
+ LOCK_ROOT=$(node -p "require('./package-lock.json').version")
+ LOCK_NESTED=$(node -p "require('./package-lock.json').packages[''].version")
+ TAU=$(node -p "require('./src-tauri/tauri.conf.json').version")
+ CRG=$(grep -E '^version = ' src-tauri/Cargo.toml | head -1 | sed -E 's/^version = "(.+)"$/\1/')
+ # Cargo.lock:找 [openless] 包紧跟的 version 行
+ CARGO_LOCK_VER=$(awk 'BEGIN{found=0} /^name = "openless"$/{found=1; next} found && /^version = /{gsub(/"/,""); print $3; exit}' src-tauri/Cargo.lock)
+ echo "package.json = $PKG"
+ echo "package-lock root = $LOCK_ROOT"
+ echo "package-lock nested = $LOCK_NESTED"
+ echo "tauri.conf.json = $TAU"
+ echo "Cargo.toml = $CRG"
+ echo "Cargo.lock openless = $CARGO_LOCK_VER"
+ mismatch=0
+ for v in "$LOCK_ROOT" "$LOCK_NESTED" "$TAU" "$CRG" "$CARGO_LOCK_VER"; do
+ if [ "$v" != "$PKG" ]; then mismatch=1; fi
+ done
+ if [ "$mismatch" -ne 0 ]; then
+ echo "::error::版本号未对齐 — 请用 scripts/bump-version.sh 同步更新"
+ exit 1
+ fi
+ echo "[ok] 全部 5 处版本号一致:$PKG"
diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml
index 858bdba9..9455978b 100644
--- a/.github/workflows/release-tauri.yml
+++ b/.github/workflows/release-tauri.yml
@@ -32,10 +32,15 @@ jobs:
rust-target: aarch64-apple-darwin
updater-target: darwin
updater-arch: aarch64
- - platform: macos-13
- rust-target: x86_64-apple-darwin
- updater-target: darwin
- updater-arch: x86_64
+ # 暂时搁置 Intel mac 构建:GitHub Actions 把 macos-13 标记为 deprecated
+ # runner,pool 容量紧张到每次 dispatch 都 queue 1-2h 拿不到分配,多次
+ # release 都因这个 job 卡住整个 run 的 conclusion。等 GH 把 macOS x86_64
+ # 迁移到非 deprecated runner(macos-14 / macos-15-large)或 macos-13
+ # pool 缓解后再恢复。Apple Silicon dmg 在 Intel mac 上跑 Rosetta 仍可用。
+ # - platform: macos-13
+ # rust-target: x86_64-apple-darwin
+ # updater-target: darwin
+ # updater-arch: x86_64
- platform: windows-latest
rust-target: x86_64-pc-windows-msvc
updater-target: windows
diff --git a/.gitignore b/.gitignore
index a2d5ad09..772b2eda 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,9 +22,18 @@ SC/
apps/
promo/
promo-openless/
+promo-openless-v2/
docs/old-promo/
.worktrees/
+# 派生产物(兜底):项目曾出现 promo-openless-v2/node_modules 等遗漏,
+# 这里全局通配,避免某子目录漏配 .gitignore 时把 build artifact 推进 PR。
+node_modules/
+dist/
+target/
+.cargo/registry/
+.cargo/git/
+
# Windows TSF IME local build outputs
openless-all/app/windows-ime/OpenLessIme/
openless-all/app/windows-ime/x64/
diff --git a/AGENTS.md b/AGENTS.md
index aebd3f39..415d0cce 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
-persistence.rs History/preferences/vocab JSON + Keychain credentials
+persistence.rs History/preferences/vocab JSON + platform credential vault
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
permissions.rs TCC checks (Accessibility / Microphone)
@@ -91,9 +91,9 @@ Invariants:
### Permissions, credentials, on-disk state
-- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant.
+- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant.
- **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs.
-- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys.
+- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports.
- **Per-user data**:
- macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data).
- Windows: `%APPDATA%\OpenLess\`
diff --git a/CLAUDE.md b/CLAUDE.md
index 2a1400fa..d8a70984 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
-persistence.rs History/preferences/vocab JSON + Keychain credentials
+persistence.rs History/preferences/vocab JSON + platform credential vault
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
permissions.rs TCC checks (Accessibility / Microphone)
@@ -91,9 +91,9 @@ Invariants:
### Permissions, credentials, on-disk state
-- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant.
+- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant.
- **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs.
-- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys.
+- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports.
- **Per-user data**:
- macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data).
- Windows: `%APPDATA%\OpenLess\`
diff --git a/README.md b/README.md
index 182a1838..de83b297 100644
--- a/README.md
+++ b/README.md
@@ -195,9 +195,14 @@ Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\
## Credentials
-Credentials live in the local Keychain (service = `com.openless.app`). A plaintext JSON file at `~/.openless/credentials.json` (mode 0600, dir 0700) is kept as a dev-mode fallback when Keychain is unavailable.
+Credentials live in the OS credential vault (service = `com.openless.app`): macOS Keychain, Windows Credential Manager, or Linux keyring. A legacy plaintext JSON file is read only as a migration source and removed after a successful vault write:
-The repository contains no API keys, tokens, or private endpoints.
+```text
+macOS / Linux: ~/.openless/credentials.json
+Windows: %APPDATA%\OpenLess\credentials.json
+```
+
+New credential writes do not persist plaintext secrets. The repository contains no API keys, tokens, or private endpoints.
You'll need:
@@ -247,7 +252,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback
asr/ Volcengine streaming ASR (WebSocket) + Whisper HTTP
polish.rs OpenAI-compatible chat-completions (Ark / DeepSeek / etc.)
insertion.rs AX focused-element → clipboard + Cmd+V → copy-only fallback
-persistence.rs History / preferences / vocab JSON + Keychain credentials
+persistence.rs History / preferences / vocab JSON + platform credential vault
permissions.rs TCC checks (Accessibility / Microphone)
coordinator.rs State machine: Idle → Starting → Listening → Processing
commands.rs Tauri IPC surface
diff --git a/README.zh.md b/README.zh.md
index d3cb93de..eea10e60 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -198,13 +198,14 @@ npm run build
## 凭据
-凭据保存在本机 Keychain(service = `com.openless.app`)。开发期同时维护一份明文 JSON 兜底,用于在 Keychain 不可用时回退:
+凭据保存在系统凭据库(service = `com.openless.app`):macOS Keychain、Windows Credential Manager 或 Linux keyring。旧版明文 JSON 只作为迁移来源读取,成功写入系统凭据库后会被删除:
```text
-~/.openless/credentials.json # 0600,目录 0700
+macOS / Linux: ~/.openless/credentials.json
+Windows: %APPDATA%\OpenLess\credentials.json
```
-仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。
+新的凭据写入不会继续保存明文 secrets。仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。
需要配置的字段:
@@ -254,7 +255,7 @@ recorder.rs 麦克风 → 16 kHz 单声道 Int16 PCM,RMS 回调
asr/ 火山引擎流式 ASR(WebSocket)+ Whisper HTTP
polish.rs OpenAI 兼容 chat-completions(Ark / DeepSeek 等)
insertion.rs AX focused-element → 剪贴板 + Cmd+V → 仅复制兜底
-persistence.rs 历史记录 / 偏好设置 / 词典 JSON + Keychain 凭据
+persistence.rs 历史记录 / 偏好设置 / 词典 JSON + 系统凭据库
permissions.rs TCC 权限检查(辅助功能 / 麦克风)
coordinator.rs 状态机:Idle → Starting → Listening → Processing
commands.rs Tauri IPC 接口
diff --git a/docs/audit-2026-05-06.md b/docs/audit-2026-05-06.md
new file mode 100644
index 00000000..d791d879
--- /dev/null
+++ b/docs/audit-2026-05-06.md
@@ -0,0 +1,293 @@
+# OpenLess 系统化工程审计报告
+
+> 审计日期:2026-05-06
+> 项目版本:1.2.20
+> 审计范围:`openless-all/app/` 主项目(Rust 后端 + React 前端)
+
+---
+
+## 一、后端检查与优化
+
+### 1.1 架构总评
+
+后端整体架构清晰,遵循「单 Coordinator 状态机 + 独立叶子模块」的分层设计。模块之间只通过 `types.rs` 共享类型,跨模块调用全部收敛到 `coordinator.rs`,与 CLAUDE.md 约定的架构一致。代码质量整体较高:大量使用 `thiserror` / `anyhow` 进行错误处理,关键路径有 `#[cfg(test)]` 单元测试覆盖,热路径有状态竞态保护。
+
+**模块清单**:`asr/`(火山引擎流式 + Whisper HTTP + 本地 Qwen3-ASR)、`polish.rs`(OpenAI-compatible LLM)、`hotkey.rs`(macOS CGEventTap / Windows WH_KEYBOARD_LL / Linux rdev)、`recorder.rs`(cpal 音频采集)、`insertion.rs`(跨平台文本插入)、`persistence.rs`(JSON 文件 + OS 凭据库)、`permissions.rs`(TCC 权限)、`selection.rs`(划词捕获)。
+
+### 1.2 值得优化的后端问题
+
+#### 问题 A:`coordinator.rs` 过于臃肿(严重程度:中)
+
+`coordinator.rs` 当前 **3842 行**,包含了 dictation 状态机、QA 状态机、双 hotkey supervisor 循环、recorder 错误监控、Windows IME 会话管理、capsule 事件发射、录音 mute 管理、本地 ASR 预加载/释放等全部胶水逻辑。单一文件内职责过多。
+
+**建议**:按子状态机拆分为多个 coordinator 子模块:
+- `coordinator/dictation.rs` — 主听写 session 生命周期
+- `coordinator/qa.rs` — QA 划词追问 session 生命周期
+- `coordinator/resources.rs` — recorder / ASR / mute 等资源管理
+
+#### 问题 B:`commands.rs` 包含过多业务逻辑(严重程度:中)
+
+`commands.rs` 中有大量本不属于「IPC 薄层」的业务逻辑,例如:
+- WAV 静音文件编码(`encode_wav_16k_mono_silence`)
+- ASR 端点 URL 拼接(`asr_transcriptions_url`)
+- 模型列表 JSON 解析(`parse_model_ids`)
+- LLM/ASR provider 连接验证(`validate_llm_provider` / `validate_asr_provider`)
+
+这些应该下沉到对应的叶子模块(`asr/` 或 `polish.rs`),`commands.rs` 只做参数接收和类型转换。
+
+#### 问题 C:平台条件编译代码分散(严重程度:低)
+
+`lib.rs`、`coordinator.rs`、`insertion.rs` 中大量 `#[cfg(target_os = "macos")]` / `#[cfg(target_os = "windows")]` 块散落在主流程代码中。虽然不是编译期问题,但降低了可读性。建议将平台适配代码集中到 `platform/` 子模块,用 trait 抽象。
+
+#### 问题 D:本地 ASR 引擎缓存释放策略依赖时间阈值(严重程度:低)
+
+`LocalAsrCache` 的释放依赖 `local_asr_keep_loaded_secs` 定时器(默认 300 秒)。如果用户在 5 分钟内未再次使用,引擎释放。但在 Windows 上本地 ASR 引擎根本不可用(仅在 macOS 编译),相关代码却仍在 coordinator 中占据逻辑分支。建议将平台不可用的功能路径在编译期完全消除,而非运行时静默跳过。
+
+#### 问题 E:Volcengine 凭据与通用 Provider 凭据同时存在(严重程度:低)
+
+系统中同时维护了 Volcengine 专用凭据字段(`volcengine_app_key` / `volcengine_access_key` / `volcengine_resource_id`)和通用 Provider 凭据路径(`asr_api_key` / `asr_endpoint`),导致 `get_credentials` 返回的 `CredentialsStatus` 需要同时维护 `volcengineConfigured` 和 `asrConfigured` 两个字段。历史迁移可理解,但长期维护增加复杂度。
+
+### 1.3 后端与其他应用混杂检查
+
+经检查,`openless-all/app/` 是纯净的单一 Tauri 项目,未发现与其他应用混杂的代码。但仓库根目录存在以下与主项目无关的目录:
+
+| 目录 | 内容 | 建议 |
+|------|------|------|
+| `promo-openless/` | Remotion 宣传视频项目 | 移至独立仓库或 `marketing/` 子目录 |
+| `promo-openless-v2/` | Remotion 宣传视频 v2 | 同上 |
+| `SC/` | 录屏素材文件(.mov / .mp4) | 建议移出仓库或用 Git LFS |
+| `docs/` | 开发调研文档 | 保留,但与主项目解耦 |
+
+当前这些目录虽然在 git 仓库中,但不会参与 Tauri 构建,不会导致臃肿或冲突。**与用户提到的「此前与其他软件冲突或臃肿问题」对比,当前架构没有重复该问题。**
+
+---
+
+## 二、前端检查
+
+### 2.1 UI Bug 分析
+
+#### Bug 1:Tab 切换动画的双重渲染竞态(严重程度:中)
+
+`FloatingShell.tsx` 的 tab 切换使用 `displayTab` / `tabPhase` 机制:旧页先播 `ol-page-fadeout`(180ms),之后切 `displayTab` 并播 `ol-page-slide`。但 `key={displayTab}` 会让 React 在 `displayTab` 改变时**卸载旧组件树并挂载新组件树**。问题:
+
+- 如果用户在 180ms 内快速切换两次 tab,第一次的 timeout 触发时 `displayTab` 已被第二次覆盖,会看到页面闪变
+- 旧页的 `useEffect` cleanup 和新页的 `useEffect` 在 180ms 内交错执行,若两者都触发了 IPC 调用,会产生竞态
+
+**修复建议**:使用 `useTransition` 或 CSS `animationend` 事件代替固定 `setTimeout`,确保动画结束后再切 DOM。
+
+#### Bug 2:Capsule 窗口 `state === 'idle'` 时返回空 div(严重程度:低)
+
+`Capsule.tsx` 的 `if (state === 'idle') { return
; }`。问题:胶囊窗口尺寸由 Rust 端 `position_capsule_bottom_center` 设定(220×110),但 React 返回 0×0 的 div 时,Tauri webview 的 CSS 尺寸与窗口尺寸不一致。在 Windows 上可能导致透明区域响应鼠标事件(mouse event 穿透到下层窗口)。
+
+#### Bug 3:QA 浮窗滚动容器缺少 `overflow-anchor`(严重程度:低)
+
+`QaPanel.tsx` 的流式答案到达时,用 `scrollRef.current.scrollTop = scrollRef.current.scrollHeight` 手动滚到底。如果用户在流式过程中手动向上滚动查看前面的消息,新 chunk 到达时会强制跳回底部,打断阅读。应该加入「用户是否主动滚离底部」的检测(类似聊天的 scroll-to-bottom 按钮逻辑)。
+
+#### Bug 4:`dangerouslySetInnerHTML` 的 XSS 表面(严重程度:低)
+
+`QaPanel.tsx` 和 `StreamingAssistantBubble` 使用 `dangerouslySetInnerHTML` 渲染 Markdown。虽然 `renderQaMarkdown` 使用 `marked` 库且配置了 sanitize,但在流式场景下不完整的 Markdown 可能导致 HTML 结构断裂(如未闭合的 `` 块)。当前有 fallback 到 `renderQaPlainText`,但错误边界不覆盖 dangerouslySetInnerHTML 渲染错误。
+
+### 2.2 动效与交互流畅度
+
+**当前状态良好**:
+- 胶囊波形的 `AudioBars` 使用 `cubic-bezier(0.22, 1, 0.36, 1)` 缓动曲线,过渡平滑
+- 所有 transition 使用 CSS 变量 `var(--ol-motion-*)` 统一缓动
+- `willChange` 属性在动画元素上正确设置(Capsule 的 `transform, box-shadow`)
+
+**可优化的点**:
+
+1. **音频电平更新频率**:`LEVEL_EMIT_MIN_INTERVAL_MS = 33`(~30Hz),配合 CSS 0.18s transition 效果尚可。但如果窗口失去焦点时 `requestAnimationFrame` 降频,可能出现电平条「冻住」的观感。建议在 coordinator 侧用 `setInterval` 兜底。
+
+2. **QA 浮窗出场动画缺失**:`QaPanel` 关闭时直接 `hide()`,没有退场动画。可以加一个 `qa:fadeout` 事件让前端先播动画,100ms 后再由 Rust 端 actual hide。
+
+3. **SettingsModal 无入场动画**:Settings 弹窗使用 `animation: 'ol-prompt-pop 0.26s var(--ol-motion-spring)'`,但关闭时瞬间消失。
+
+---
+
+## 三、项目工程化与功能完善
+
+### 3.1 工程化水平评估
+
+#### 优点
+
+- **模块化清晰**:Rust 端严格遵循「叶子模块只依赖 types,胶水只写在 coordinator」的约定
+- **错误处理完备**:关键路径全部使用 `Result`,无 `unwrap()` 裸奔
+- **测试覆盖**:`commands.rs` 和 `polish.rs` 有单元测试,`persistence.rs` 有集成测试
+- **类型安全 IPC**:前后端类型通过 `types.rs` ↔ `types.ts` 镜像定义,序列化字段名一致
+- **Mock 支持**:前端 `invokeOrMock` 允许在浏览器中脱离 Tauri 环境开发
+- **i18n 国际化**:支持 zh-CN、en、ja、ko、zh-TW 五种语言
+- **自动更新**:Tauri updater 插件集成完整
+
+#### 可改善
+
+1. **缺少 CI 质量门禁**:当前只有 `release-tauri.yml` 构建流水线,没有 lint / test / typecheck 门禁(虽然有 Rust `cargo check` 和前端 `tsc` 命令,但未在 CI 强制)。
+
+2. **缺少 E2E 测试**:没有端到端测试(如 Playwright + Tauri driver),无法验证「按热键 → 录音 → 插入」的完整链路。
+
+3. **`.gitignore` 不完整**:`node_modules/` 出现在多个子目录(`promo-openless/`、`promo-openless-v2/`),但根 `.gitignore` 未统一管理。
+
+4. **版本号同步风险**:CLAUDE.md 指出需要同时更新 `package.json`、`tauri.conf.json`、`Cargo.toml` 三处的版本号,容易遗漏。建议用脚本或 workspace 版本管理。
+
+### 3.2 功能完整性
+
+当前功能矩阵:
+
+| 功能 | macOS | Windows | 备注 |
+|------|-------|---------|------|
+| 全局热键听写 | ✅ | ✅ | macOS CGEventTap / Windows WH_KEYBOARD_LL |
+| 火山引擎流式 ASR | ✅ | ✅ | |
+| Whisper HTTP ASR | ✅ | ✅ | |
+| 本地 Qwen3-ASR | ✅ | ❌ | 仅 macOS 编译 |
+| LLM 润色(四种模式) | ✅ | ✅ | |
+| LLM 翻译模式 | ✅ | ✅ | Shift 修饰键触发 |
+| 划词 QA 问答 | ✅ | ✅ | 双 hotkey 架构 |
+| 热词词典 | ✅ | ✅ | |
+| 历史记录 | ✅ | ✅ | |
+| 开机自启 | ✅ | ✅ | |
+| 自动更新 | ✅ | ✅ | |
+| 录音时系统静音 | ✅ | ✅ | |
+| Windows IME 直写 | N/A | ✅ | C++ TSF 模块 |
+| 系统托盘图标 | ✅ | ✅ | |
+
+---
+
+## 四、多端逻辑与体验一致性
+
+### 4.1 平台差异对比
+
+| 维度 | macOS | Windows | 一致性 |
+|------|-------|---------|--------|
+| Capsule 物理尺寸 | 220×110,visual height 96 | 220×84(118),visual height 52 | ⚠️ 不一致 |
+| 插入策略 | AX 直写(通过 Accessibility API) | enigo 模拟粘贴 / TSF | 不同,但策略合理 |
+| 窗口圆角 | 系统原生圆角 | 手动 CreateRoundRectRgn(18px) | ⚠️ 视觉差异 |
+| 窗口背景 | NSVisualEffectView 磨砂 | Mica + 自定义渐变 | ⚠️ 视觉差异 |
+| 默认热键 | 右 Option(Toggle) | 右 Control(Toggle) | 不一致,但符合平台惯例 |
+| QA 默认热键 | Cmd+Shift+; | Ctrl+Shift+; | 符合平台惯例 ✅ |
+| 启动权限检查 | 阻塞式弹窗检查 | 异步轮询(最多 10s) | 合理差异 |
+| 窗口控制按钮 | 系统红黄绿 | 自绘最小化/最大化/关闭 | 合理差异 |
+
+### 4.2 逻辑完备性
+
+- **翻译模式**:两端均通过 Shift 修饰键触发,但 macOS 用 `flagsChanged` 事件,Windows 用 `WH_KEYBOARD_LL` 的 Shift 边沿。逻辑层在 coordinator 中以 `translation_modifier_seen` flag 统一,完备。
+- **QA 浮窗拖动**:macOS 用 `movableByWindowBackground`,Windows 用 `data-tauri-drag-region`。两端都能拖,但 macOS 整窗口可拖,Windows 仅 toolbar 区域可拖。
+- **粘贴后剪贴板恢复**:Windows/Linux 支持(`restore_clipboard_after_paste`),macOS 走 AX 直写不涉及剪贴板。完备。
+- **降级兜底**:插入失败 → 文本留在剪贴板,用户可手动粘贴。两端一致。
+
+### 4.3 需要关注的多端差异
+
+1. **Windows Capsule 翻译模式高度变化**:翻译模式激活时 capsule 窗口从 84→118,macOS 保持 110 不变。设计合理但视觉差异可能困扰跨平台用户。
+
+2. **Windows WindowChrome 自绘标题栏**:`WindowChrome.tsx` 的 `WinTitleBar` 绘制的关闭按钮走 `getCurrentWindow().close()`,但 Rust 端 `RunEvent::WindowEvent::CloseRequested` 只对 `label == "main"` 做 `prevent_close` + `hide`,其他窗口(capsule / qa)的关闭行为不一致。
+
+3. **capsule 窗口 `skipTaskbar`**:两端都设了,正确。
+
+---
+
+## 五、UI 与后端接口映射校验
+
+### 5.1 前后端命令对照
+
+经逐一核对 `invoke_handler!` 宏(`lib.rs`)与 `ipc.ts` 的函数导出,**所有后端命令在前端都有对应的 TypeScript wrapper**:
+
+| 后端命令 | 前端函数 | 状态 |
+|----------|----------|------|
+| `get_settings` | `getSettings()` | ✅ |
+| `set_settings` | `setSettings(prefs)` | ✅ |
+| `get_hotkey_status` | `getHotkeyStatus()` | ✅ |
+| `get_hotkey_capability` | `getHotkeyCapability()` | ✅ |
+| `get_windows_ime_status` | `getWindowsImeStatus()` | ✅ |
+| `get_credentials` | `getCredentials()` | ✅ |
+| `set_credential` | `setCredential(account, value)` | ✅ |
+| `read_credential` | `readCredential(account)` | ✅ |
+| `set_active_asr_provider` | `setActiveAsrProvider(provider)` | ✅ |
+| `set_active_llm_provider` | `setActiveLlmProvider(provider)` | ✅ |
+| `validate_provider_credentials` | `validateProviderCredentials(kind)` | ✅ |
+| `list_provider_models` | `listProviderModels(kind)` | ✅ |
+| `list_history` | `listHistory()` | ✅ |
+| `delete_history_entry` | `deleteHistoryEntry(id)` | ✅ |
+| `clear_history` | `clearHistory()` | ✅ |
+| `list_vocab` | `listVocab()` | ✅ |
+| `add_vocab` | `addVocab(phrase, note)` | ✅ |
+| `remove_vocab` | `removeVocab(id)` | ✅ |
+| `set_vocab_enabled` | `setVocabEnabled(id, enabled)` | ✅ |
+| `list_vocab_presets` | `listVocabPresets()` | ✅ |
+| `save_vocab_presets` | `saveVocabPresets(store)` | ✅ |
+| `start_dictation` | `startDictation()` | ✅ |
+| `stop_dictation` | `stopDictation()` | ✅ |
+| `cancel_dictation` | `cancelDictation()` | ✅ |
+| `handle_window_hotkey_event` | `handleWindowHotkeyEvent(...)` | ✅ |
+| `inject_hotkey_click_for_dev` | N/A(debug only) | ✅ |
+| `repolish` | `repolish(rawText, mode)` | ✅ |
+| `set_default_polish_mode` | `setDefaultPolishMode(mode)` | ✅ |
+| `set_style_enabled` | `setStyleEnabled(mode, enabled)` | ✅ |
+| `check_accessibility_permission` | `checkAccessibilityPermission()` | ✅ |
+| `request_accessibility_permission` | `requestAccessibilityPermission()` | ✅ |
+| `check_microphone_permission` | `checkMicrophonePermission()` | ✅ |
+| `request_microphone_permission` | `requestMicrophonePermission()` | ✅ |
+| `open_system_settings` | `openSystemSettings(pane)` | ✅ |
+| `trigger_microphone_prompt` | `triggerMicrophonePrompt()` | ✅ |
+| `export_error_log` | `exportErrorLog(name)` | ✅ |
+| `restart_app` | `restartApp()` | ✅ |
+| `get_qa_hotkey_label` | `getQaHotkeyLabel()` | ✅ |
+| `set_qa_hotkey` | `setQaHotkey(binding)` | ✅ |
+| `qa_window_dismiss` | `qaWindowDismiss()` | ✅ |
+| `qa_window_pin` | `qaWindowPin(pinned)` | ✅ |
+| `local_asr_*` (15 个命令) | 对应的 15 个函数 | ✅ |
+
+**结论**:UI 与后端接口完全 1:1 对应,无遗漏。
+
+### 5.2 事件订阅对照
+
+| 事件名 | 发射端 | 订阅端 | 状态 |
+|--------|--------|--------|------|
+| `capsule:state` | coordinator | Capsule.tsx | ✅ |
+| `qa:state` | coordinator | QaPanel.tsx | ✅ |
+| `qa:dismiss` | coordinator | QaPanel.tsx | ✅ |
+| `qa:level` | coordinator | QaPanel.tsx | ✅ |
+| `prefs:changed` | commands::set_settings | QaPanel.tsx | ✅ |
+| `local-asr:download-progress` | DownloadManager | LocalAsr.tsx | ✅ |
+
+---
+
+## 六、改进建议汇总
+
+### 立即修复(P0)
+
+无。当前版本功能完整,无明显崩溃或数据丢失风险。
+
+### 短期优化(P1 — 建议在下一版迭代中处理)
+
+| # | 问题 | 位置 | 工作量 |
+|---|------|------|--------|
+| 1 | coordinator.rs 拆分 | 后端 | 2-3h |
+| 2 | commands.rs 业务逻辑下沉 | 后端 | 1-2h |
+| 3 | Tab 切换动画竞态修复 | FloatingShell.tsx | 1h |
+| 4 | QA 流式滚动打断问题 | QaPanel.tsx | 1h |
+| 5 | SettingsModal / QaPanel 退场动画 | 前端 | 1h |
+
+### 中长期改善(P2)
+
+| # | 问题 | 建议 |
+|---|------|------|
+| 1 | 缺少 CI lint/test 门禁 | 添加 GitHub Actions workflow:`cargo clippy` + `cargo test` + `npx tsc --noEmit` |
+| 2 | 缺少 E2E 测试 | 引入 Playwright + Tauri driver 测试核心链路 |
+| 3 | 平台代码分散 | 创建 `src-tauri/src/platform/` 模块,用 trait 抽象平台差异 |
+| 4 | 版本号同步 | 用 workspace Cargo.toml + 构建脚本自动同步三处版本号 |
+| 5 | 仓库清理 | `promo-openless/`、`SC/` 移至独立仓库或 Git LFS |
+| 6 | Windows Capsule 尺寸与 macOS 视觉差异 | 文档化或统一 visual height |
+
+---
+
+## 七、总结
+
+OpenLess 1.2.20 的代码质量在同类开源项目中属于**中上水平**。架构设计清晰(单 Coordinator + 叶子模块),错误处理完备,前后端类型安全,IPC 接口 1:1 映射无遗漏。
+
+核心问题集中在:
+1. **coordinator.rs 的单一文件过大**(3842 行),需要拆分子状态机
+2. **commands.rs 的业务逻辑应下沉**到叶子模块
+3. **前端 Tab 切换的竞态**可能导致动画异常
+4. **QA 浮窗的流式滚动打断用户体验**
+
+没有发现「与其他应用混杂」或「耦合臃肿」的问题——代码遵循了严格的模块隔离约定。多平台覆盖完整,macOS 和 Windows 的核心行为一致,仅在视觉尺寸、窗口装饰等平台原生差异上有所不同。
+
+**项目整体健康。建议在下个迭代中优先处理 coordinator 拆分和前端动画竞态修复。**
diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json
index dc916113..b67e1e59 100644
--- a/openless-all/app/package-lock.json
+++ b/openless-all/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openless-app",
- "version": "1.2.19",
+ "version": "1.2.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openless-app",
- "version": "1.2.19",
+ "version": "1.2.21",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
diff --git a/openless-all/app/package.json b/openless-all/app/package.json
index 1c6f34d5..9e10d979 100644
--- a/openless-all/app/package.json
+++ b/openless-all/app/package.json
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
- "version": "1.2.20",
+ "version": "1.2.21",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1
index 1c11ca18..4c92d1e4 100644
--- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1
+++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1
@@ -597,7 +597,7 @@ if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -o
throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials."
}
if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) {
- Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault."
+ Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault."
}
$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log"
diff --git a/openless-all/app/scripts/windows-runtime-smoke.ps1 b/openless-all/app/scripts/windows-runtime-smoke.ps1
index 237b153b..053dcc19 100644
--- a/openless-all/app/scripts/windows-runtime-smoke.ps1
+++ b/openless-all/app/scripts/windows-runtime-smoke.ps1
@@ -71,7 +71,7 @@ if (-not $credentialStatus.ArkConfigured) {
Write-Host "[warn] Ark LLM credentials are not configured; polishing will fall back or fail depending on mode."
}
if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) {
- throw "Real regression requires configured Volcengine ASR and Ark LLM credentials."
+ Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault."
}
Write-Host ""
diff --git a/openless-all/app/scripts/windows-smoke-suite.ps1 b/openless-all/app/scripts/windows-smoke-suite.ps1
index 55797fed..5efc5492 100644
--- a/openless-all/app/scripts/windows-smoke-suite.ps1
+++ b/openless-all/app/scripts/windows-smoke-suite.ps1
@@ -90,7 +90,6 @@ try {
Invoke-Step "Runtime smoke" {
Invoke-Script (Join-Path $PSScriptRoot "windows-runtime-smoke.ps1") @{
ExePath = $ExePath
- RequireCredentials = $true
}
}
}
diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs
index 873afdbb..4d9ddb61 100644
--- a/openless-all/app/scripts/windows-ui-config.test.mjs
+++ b/openless-all/app/scripts/windows-ui-config.test.mjs
@@ -55,7 +55,7 @@ if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) {
assertMatch(
windowChromeTsx,
- /const MAC_TITLEBAR_HEIGHT = 30;/,
+ /const MAC_TITLEBAR_HEIGHT = 28;/,
'macOS titlebar spacer should stay visually compact around the native traffic lights',
);
assertMatch(
@@ -108,7 +108,7 @@ if (!/export function getCapsuleHostMetrics\(\s*os: OS,\s*translationActive: boo
throw new Error('capsule layout should define explicit host metrics separate from the visible pill metrics');
}
-if (!/if \(os === 'win'\)\s*\{[\s\S]*?width: 220,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?bottomInset: 12,[\s\S]*?badgeGap: 8[\s\S]*?\}/.test(capsuleLayoutTs)) {
+if (!/if \(os === 'win'\)\s*\{[\s\S]*?const horizontalInset = 12;[\s\S]*?const pill = getCapsulePillMetrics\(os\);[\s\S]*?width: pill\.width \+ horizontalInset \* 2,[\s\S]*?height: translationActive \? 118 : 84,[\s\S]*?horizontalInset,[\s\S]*?bottomInset: 12,[\s\S]*?badgeGap: 8,[\s\S]*?boxSizing: 'border-box',[\s\S]*?\}/.test(capsuleLayoutTs)) {
throw new Error('windows capsule host metrics should leave room for shadow and badge geometry');
}
@@ -116,19 +116,23 @@ if (!/const hostMetrics = getCapsuleHostMetrics\(os,\s*translation\);/.test(caps
throw new Error('capsule should derive host metrics from the shared layout contract');
}
-if (!/justifyContent:\s*os === 'win' \? 'flex-end' : 'center'/.test(capsuleTsx)) {
- throw new Error('windows capsule host should anchor the pill to the bottom instead of centering it inside the larger native host window');
+if (!/return\s*\(\s* bool {
return false;
}
let lower = path.to_ascii_lowercase();
- if lower.ends_with(".md") || lower.ends_with(".png") || lower.ends_with(".jpg")
- || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg")
+ if lower.ends_with(".md")
+ || lower.ends_with(".png")
+ || lower.ends_with(".jpg")
+ || lower.ends_with(".jpeg")
+ || lower.ends_with(".gif")
+ || lower.ends_with(".svg")
{
return false;
}
let ext = lower.rsplit('.').next().unwrap_or("");
- matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken")
+ matches!(
+ ext,
+ "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken"
+ )
}
#[derive(Debug, Clone, Serialize)]
@@ -205,7 +212,10 @@ impl DownloadManager {
flag.store(true, Ordering::SeqCst);
log::info!("[local-asr] cancel requested for {}", model_id.as_str());
} else {
- log::info!("[local-asr] cancel requested for {} but no active download", model_id.as_str());
+ log::info!(
+ "[local-asr] cancel requested for {} but no active download",
+ model_id.as_str()
+ );
}
}
@@ -289,9 +299,8 @@ async fn run_download(
}
}
- let in_flight_bytes: Arc
> = Arc::new(
- info.files.iter().map(|_| AtomicU64::new(0)).collect()
- );
+ let in_flight_bytes: Arc> =
+ Arc::new(info.files.iter().map(|_| AtomicU64::new(0)).collect());
let already_done_bytes: u64 = info
.files
.iter()
@@ -480,7 +489,13 @@ async fn download_one(
// 远端文件 ≤ 一个 chunk 大小:直接单 chunk,不走 sparse + idx
if total_size <= CHUNK_SIZE {
let result = chunk_with_retry(
- client, url, &partial, 0, total_size - 1, &cancel, &on_progress,
+ client,
+ url,
+ &partial,
+ 0,
+ total_size - 1,
+ &cancel,
+ &on_progress,
)
.await;
if cancel.load(Ordering::SeqCst) {
@@ -499,7 +514,8 @@ async fn download_one(
let done_set = read_idx(&idx_path);
// 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节)
- if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size {
+ if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size
+ {
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
@@ -630,7 +646,10 @@ fn read_idx(path: &Path) -> HashSet {
fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> {
use std::io::Write;
- let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
+ let mut f = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(path)?;
writeln!(f, "{idx}")
}
@@ -657,7 +676,17 @@ async fn chunk_with_retry(
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
- match try_download_range_append(client, url, partial, range_start, range_end, cancel, on_progress).await {
+ match try_download_range_append(
+ client,
+ url,
+ partial,
+ range_start,
+ range_end,
+ cancel,
+ on_progress,
+ )
+ .await
+ {
Ok(()) => return Ok(()),
Err(e) => {
let msg = format!("{e:#}");
@@ -673,7 +702,8 @@ async fn chunk_with_retry(
}
}
}
- Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
+ Err(last_err
+ .unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
}
async fn try_download_range_append(
@@ -687,12 +717,19 @@ async fn try_download_range_append(
) -> Result<()> {
let mut req = client.get(url);
req = req.header("Range", format!("bytes={range_start}-{range_end}"));
- let resp = req.send().await.with_context(|| format!("HTTP GET {url} failed"))?;
+ let resp = req
+ .send()
+ .await
+ .with_context(|| format!("HTTP GET {url} failed"))?;
let status = resp.status();
if status.as_u16() != 200 && status.as_u16() != 206 {
anyhow::bail!("HTTP {status} for {url}");
}
- let effective_start = if status.as_u16() == 200 { 0 } else { range_start };
+ let effective_start = if status.as_u16() == 200 {
+ 0
+ } else {
+ range_start
+ };
// 截断 partial 到本次 attempt 的起点,再 seek 写入。
// 老 append 实现的 bug:若上一次 attempt 已写了部分字节后失败,retry 拿到的还是
@@ -744,7 +781,18 @@ async fn chunk_with_retry_seek(
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
- match try_download_range_seek(client, url, partial, range_start, range_end, cancel, bytes_in_file, on_progress).await {
+ match try_download_range_seek(
+ client,
+ url,
+ partial,
+ range_start,
+ range_end,
+ cancel,
+ bytes_in_file,
+ on_progress,
+ )
+ .await
+ {
Ok(()) => return Ok(()),
Err(e) => {
let msg = format!("{e:#}");
@@ -760,7 +808,11 @@ async fn chunk_with_retry_seek(
}
}
}
- Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts")))
+ Err(last_err.unwrap_or_else(|| {
+ anyhow::anyhow!(
+ "chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts"
+ )
+ }))
}
async fn try_download_range_seek(
@@ -806,7 +858,8 @@ async fn try_download_range_seek(
}
let bytes = chunk.context("read stream chunk failed")?;
file.write_all(&bytes).await.context("write chunk failed")?;
- let new_total = bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
+ let new_total =
+ bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
on_progress(new_total);
}
file.flush().await.ok();
diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs
index 4fe0bd3f..5f9b0b78 100644
--- a/openless-all/app/src-tauri/src/asr/local/local_provider.rs
+++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs
@@ -68,10 +68,11 @@ impl LocalQwenAsr {
// 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调,
// 但保持一致更稳)。
let engine = Arc::clone(&self.engine);
- let text = tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32))
- .await
- .context("transcribe spawn_blocking join 失败")?
- .context("qwen_transcribe_stream 失败")?;
+ let text =
+ tauri::async_runtime::spawn_blocking(move || engine.transcribe_stream(&samples_f32))
+ .await
+ .context("transcribe spawn_blocking join 失败")?
+ .context("qwen_transcribe_stream 失败")?;
// 解绑回调,避免 idle 期 C 端任何后续触发。
self.engine.set_token_handler::(None);
diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs
index 1aca579f..52fd1051 100644
--- a/openless-all/app/src-tauri/src/asr/local/mod.rs
+++ b/openless-all/app/src-tauri/src/asr/local/mod.rs
@@ -16,10 +16,10 @@ mod qwen_engine;
#[cfg(target_os = "macos")]
mod qwen_ffi;
-#[cfg(target_os = "macos")]
-pub use qwen_engine::QwenAsrEngine;
#[cfg(target_os = "macos")]
pub use local_provider::LocalQwenAsr;
+#[cfg(target_os = "macos")]
+pub use qwen_engine::QwenAsrEngine;
pub use download::{DownloadManager, Mirror};
pub use models::{ModelId, ModelStatus};
diff --git a/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs b/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs
index 0b298fd8..a6f3242e 100644
--- a/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs
+++ b/openless-all/app/src-tauri/src/asr/local/qwen_engine.rs
@@ -79,9 +79,8 @@ impl QwenAsrEngine {
/// 批式转写:一次性给完整音频(mono f32 16kHz)。
pub fn transcribe_audio(&self, samples: &[f32]) -> Result {
// SAFETY: samples 在调用期间存活;返回是 C `malloc` 出的字符串。
- let raw = unsafe {
- qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32)
- };
+ let raw =
+ unsafe { qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32) };
if raw.is_null() {
anyhow::bail!("qwen_transcribe_audio 返回 NULL");
}
@@ -95,9 +94,8 @@ impl QwenAsrEngine {
/// 流式转写:内部按 2s chunk 切片,token 通过 `set_token_handler` 注册的
/// 回调实时吐出;返回值是最终完整文本。
pub fn transcribe_stream(&self, samples: &[f32]) -> Result {
- let raw = unsafe {
- qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32)
- };
+ let raw =
+ unsafe { qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32) };
if raw.is_null() {
anyhow::bail!("qwen_transcribe_stream 返回 NULL");
}
diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs
index c7e3ec10..07759394 100644
--- a/openless-all/app/src-tauri/src/asr/local/test_run.rs
+++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs
@@ -50,21 +50,18 @@ pub async fn run_test(model_id: ModelId) -> Result {
// qwen_load 是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。
let load_start = Instant::now();
let dir_for_blocking = dir.clone();
- let engine = tauri::async_runtime::spawn_blocking(move || {
- load_engine(&dir_for_blocking)
- })
- .await
- .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
+ let engine = tauri::async_runtime::spawn_blocking(move || load_engine(&dir_for_blocking))
+ .await
+ .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let load_ms = load_start.elapsed().as_millis() as u64;
// transcribe_audio 也是阻塞 + 重活,同样扔到 blocking pool。
let trans_start = Instant::now();
let engine_clone = Arc::clone(&engine);
- let text = tauri::async_runtime::spawn_blocking(move || {
- engine_clone.transcribe_audio(&samples)
- })
- .await
- .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
+ let text =
+ tauri::async_runtime::spawn_blocking(move || engine_clone.transcribe_audio(&samples))
+ .await
+ .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
Ok(TestResult {
@@ -113,13 +110,17 @@ fn decode_wav_16k_mono(bytes: &[u8]) -> Result> {
if body_start + 16 > bytes.len() {
anyhow::bail!("fmt chunk 越界");
}
- let format = u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap());
+ let format =
+ u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap());
if format != 1 {
anyhow::bail!("只支持 PCM(format=1),当前 format={format}");
}
- channels = u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap());
- sample_rate = u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap());
- bits_per_sample = u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap());
+ channels =
+ u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap());
+ sample_rate =
+ u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap());
+ bits_per_sample =
+ u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap());
}
b"data" => {
data_offset = body_start;
diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs
new file mode 100644
index 00000000..1e69681b
--- /dev/null
+++ b/openless-all/app/src-tauri/src/combo_hotkey.rs
@@ -0,0 +1,267 @@
+//! 录音快捷键的自定义组合键监听器。
+//!
+//! 与 `hotkey.rs`(modifier-only 听写热键)平行——当用户选择自定义组合键
+//! (如 `Cmd+Shift+D`)时,用 `global-hotkey` crate 注册。
+//!
+//! 与 `qa_hotkey.rs` 的关键区别:**同时产出 Pressed 和 Released 边沿事件**,
+//! 以支持 Hold(按住说话)模式。`global-hotkey` crate 的 `HotKeyState::Released`
+//! 在 macOS (Carbon) 和 Windows 上均可用于检测松开。
+//!
+//! 通过 `global_hotkey_runtime` 与 QA 快捷键共享进程级 manager / event receiver。
+
+use std::sync::mpsc::{Receiver, Sender};
+use std::sync::Arc;
+
+use global_hotkey::{GlobalHotKeyEvent, HotKeyState};
+use parking_lot::Mutex;
+
+use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey};
+use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError};
+use crate::types::ShortcutBinding;
+
+#[derive(Debug, Clone, Copy)]
+pub enum ComboHotkeyEvent {
+ /// 用户按下了配置的组合键。
+ Pressed,
+ /// 用户松开了配置的组合键(用于 Hold 模式结束录音)。
+ Released,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum ComboHotkeyError {
+ #[error("不支持的修饰键: {0}")]
+ UnsupportedModifier(String),
+ #[error("不支持的主键: {0}")]
+ UnsupportedKey(String),
+ #[error("注册全局快捷键失败: {0}")]
+ RegisterFailed(String),
+ #[error("初始化全局快捷键管理器失败: {0}")]
+ ManagerInitFailed(String),
+}
+
+/// 自定义组合键全局快捷键监听器。`Drop` 时反注册。
+///
+/// 内部用 `global-hotkey` crate;事件转发线程持有一个共享的 `Sender`。
+/// 与 `QaHotkeyMonitor` 的区别:转发 Pressed **和** Released 事件。
+pub struct ComboHotkeyMonitor {
+ inner: Arc,
+}
+
+struct Inner {
+ registered: Mutex