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>, + tx: Sender, +} + +// global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window +// handle 等 `*mut c_void`,crate 没标 Send/Sync。与 qa_hotkey.rs 同理。 +unsafe impl Send for Inner {} +unsafe impl Sync for Inner {} + +impl ComboHotkeyMonitor { + /// 启动监听并注册一个组合键。`tx` 在每次按下/松开边沿收到事件。 + /// + /// **注意**:`global-hotkey` crate 在 macOS 要求 manager 在主线程构造。 + /// 调用方需要确保从主线程触发。 + pub fn start( + binding: ShortcutBinding, + tx: Sender, + ) -> Result { + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; + + let hotkey = parse_binding(&binding)?; + let (registered, rx) = runtime + .register(hotkey) + .map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?; + + // runtime 已按 hotkey id 分发;这里保留 id 检查作为防线, + // 避免未来误接回进程级事件流后串到其他快捷键。 + let hotkey_id = registered.hotkey().id(); + let tx_for_thread = tx.clone(); + std::thread::Builder::new() + .name("openless-combo-hotkey-forward".into()) + .spawn(move || forward_loop(hotkey_id, rx, tx_for_thread)) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + + Ok(Self { + inner: Arc::new(Inner { + registered: Mutex::new(Some(registered)), + tx, + }), + }) + } + + /// 替换当前注册的组合键(用户在设置里改了组合键时)。 + pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), ComboHotkeyError> { + let next = parse_binding(&binding)?; + let mut current = self.inner.registered.lock(); + if let Some(prev) = current.as_ref() { + if prev.hotkey() == next { + return Ok(()); + } + } + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| ComboHotkeyError::ManagerInitFailed(e.to_string()))?; + let (registered, rx) = runtime + .register(next) + .map_err(|e| ComboHotkeyError::RegisterFailed(e.to_string()))?; + let hotkey_id = registered.hotkey().id(); + std::thread::Builder::new() + .name("openless-combo-hotkey-forward".into()) + .spawn({ + let tx = self.inner.tx.clone(); + move || forward_loop(hotkey_id, rx, tx) + }) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + *current = Some(registered); + Ok(()) + } +} + +impl Drop for ComboHotkeyMonitor { + fn drop(&mut self) { + self.inner.registered.lock().take(); + } +} + +fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender) { + while let Ok(event) = rx.recv() { + if event.id() != hotkey_id { + continue; + } + let combo_event = match event.state() { + HotKeyState::Pressed => ComboHotkeyEvent::Pressed, + HotKeyState::Released => ComboHotkeyEvent::Released, + }; + if let Err(e) = tx.send(combo_event) { + log::warn!("[combo-hotkey] 事件投递失败: {e}"); + break; + } + } + log::info!("[combo-hotkey] 转发线程退出"); +} + +/// 测试一个组合键是否可以注册(不实际注册,仅验证格式)。 +pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ComboHotkeyError> { + parse_binding(binding)?; + Ok(()) +} + +fn parse_binding( + binding: &ShortcutBinding, +) -> Result { + parse_global_hotkey(binding).map_err(|e| match e { + ShortcutBindingError::UnsupportedModifier(m) => ComboHotkeyError::UnsupportedModifier(m), + ShortcutBindingError::UnsupportedKey(k) => ComboHotkeyError::UnsupportedKey(k), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use global_hotkey::hotkey::{Code, Modifiers}; + + #[test] + fn parse_cmd_shift_d() { + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let parsed = parse_binding(&binding).expect("binding parses"); + assert!(parsed.mods.contains(Modifiers::SUPER)); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::KeyD); + } + + #[test] + fn parse_ctrl_shift_space() { + let binding = ShortcutBinding { + primary: "Space".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + let parsed = parse_binding(&binding).expect("binding parses"); + assert!(parsed.mods.contains(Modifiers::CONTROL)); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::Space); + } + + #[test] + fn unsupported_modifier_rejected() { + let binding = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["hyper".into()], + }; + assert!(matches!( + parse_binding(&binding), + Err(ComboHotkeyError::UnsupportedModifier(_)) + )); + } + + #[test] + fn empty_primary_rejected() { + let binding = ShortcutBinding { + primary: "".into(), + modifiers: vec!["cmd".into()], + }; + assert!(matches!( + parse_binding(&binding), + Err(ComboHotkeyError::UnsupportedKey(_)) + )); + } + + #[test] + fn bare_shift_is_rejected_for_combo_hotkey() { + let binding = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + assert!(matches!( + validate_binding(&binding), + Err(ComboHotkeyError::UnsupportedKey(_)) + )); + } + + #[test] + fn legacy_modifier_only_is_rejected_for_combo_hotkey() { + let binding = ShortcutBinding { + primary: "RightOption".into(), + modifiers: vec![], + }; + assert!(matches!( + validate_binding(&binding), + Err(ComboHotkeyError::UnsupportedKey(_)) + )); + } + + #[test] + fn forward_loop_ignores_unrelated_hotkey_ids() { + let (event_tx, event_rx) = std::sync::mpsc::channel(); + let (out_tx, out_rx) = std::sync::mpsc::channel(); + + event_tx + .send(GlobalHotKeyEvent { + id: 7, + state: HotKeyState::Pressed, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 8, + state: HotKeyState::Released, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 8, + state: HotKeyState::Pressed, + }) + .unwrap(); + drop(event_tx); + + forward_loop(8, event_rx, out_tx); + + assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released)); + assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed)); + assert!(out_rx.try_recv().is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 93cc6cdd..e80ef597 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -3,21 +3,43 @@ use std::sync::Arc; use std::time::Duration; +use parking_lot::Mutex; use serde::Serialize; use serde_json::Value; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State, Window}; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; +use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ - ChineseScriptPreference, CredentialsStatus, DictationSession, DictionaryEntry, - HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, QaHotkeyBinding, + ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, + HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; +pub type MicrophoneMonitorState = Mutex>; +pub type TrayMicrophoneMenuState = Mutex>; + +pub struct TrayMicrophoneMenuItem { + pub id: String, + pub device_name: String, + pub item: tauri::menu::CheckMenuItem, +} + +pub fn sync_tray_microphone_selection(items: &[TrayMicrophoneMenuItem], device_name: &str) { + for item in items { + let _ = item.item.set_checked(item.device_name == device_name); + } +} + +struct LevelProbeConsumer; + +impl AudioConsumer for LevelProbeConsumer { + fn consume_pcm_chunk(&self, _pcm: &[u8]) {} +} // ─────────────────────────── settings + credentials ─────────────────────────── @@ -30,6 +52,10 @@ trait SettingsWriter { fn write_settings(&self, prefs: UserPreferences) -> 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); } impl SettingsWriter for Coordinator { @@ -44,26 +70,67 @@ impl SettingsWriter for Coordinator { 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(); + } } -impl SettingsWriter for Arc { +impl SettingsWriter for Arc { fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { - self.prefs().set(prefs).map_err(|e| e.to_string()) + (**self).write_settings(prefs) } fn refresh_dictation_hotkey(&self) { - self.update_hotkey_binding(); + (**self).refresh_dictation_hotkey(); } fn refresh_qa_hotkey(&self) { - self.update_qa_hotkey_binding(); + (**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 persist_settings(coord: &T, prefs: UserPreferences) -> Result<(), String> { +fn persist_settings( + coord: &T, + mut prefs: UserPreferences, +) -> Result<(), String> { + sync_dictation_hotkey_legacy_fields(&mut prefs); + reject_hotkey_collisions(&prefs)?; coord.write_settings(prefs)?; coord.refresh_dictation_hotkey(); coord.refresh_qa_hotkey(); + coord.refresh_combo_hotkey(); + coord.refresh_translation_hotkey(); + coord.refresh_switch_style_hotkey(); + coord.refresh_open_app_hotkey(); Ok(()) } @@ -71,12 +138,17 @@ fn persist_settings(coord: &T, prefs: UserPreferences) -> Res pub fn set_settings( coord: CoordinatorState<'_>, app: AppHandle, + tray_microphones: State<'_, TrayMicrophoneMenuState>, prefs: UserPreferences, ) -> Result<(), String> { // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 persist_settings(&*coord, prefs.clone())?; + if let Err(err) = crate::refresh_tray_microphone_menu(&app) { + log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); + sync_tray_microphone_selection(&tray_microphones.lock(), &prefs.microphone_device_name); + } let _ = app.emit("prefs:changed", &prefs); Ok(()) } @@ -91,11 +163,65 @@ pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { coord.hotkey_capability() } +#[tauri::command] +pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { + coord.set_shortcut_recording_active(active); +} + #[tauri::command] pub fn get_windows_ime_status() -> WindowsImeStatus { crate::windows_ime_profile::get_windows_ime_status() } +#[tauri::command] +pub fn list_microphone_devices() -> Result, String> { + crate::recorder::list_input_devices().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn start_microphone_level_monitor( + app: AppHandle, + device_name: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + if let Some(existing) = state.lock().take() { + existing.stop(); + } + + let selected = device_name.trim().to_string(); + let microphone_device_name = if selected.is_empty() { + None + } else { + Some(selected) + }; + let consumer: Arc = Arc::new(LevelProbeConsumer); + let level_app = app.clone(); + let level_handler: Arc = Arc::new(move |level| { + let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level })); + }); + let (recorder, _runtime_errors) = + Recorder::start(microphone_device_name, consumer, level_handler) + .map_err(|e| e.to_string())?; + *state.lock() = Some(recorder); + Ok(()) + }) + .await + .map_err(|e| format!("start microphone monitor task failed: {e}"))? +} + +#[tauri::command] +pub async fn stop_microphone_level_monitor(app: AppHandle) { + let _ = tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let recorder = state.lock().take(); + if let Some(recorder) = recorder { + recorder.stop(); + } + }) + .await; +} + #[tauri::command] pub fn get_credentials() -> CredentialsStatus { let snap = CredentialsVault::snapshot(); @@ -143,7 +269,8 @@ fn configured(field: &Option) -> bool { } #[tauri::command] -pub fn set_credential(account: String, value: String) -> Result<(), String> { +pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> { + ensure_main_window(&window)?; let acc = parse_account(&account)?; if value.is_empty() { CredentialsVault::remove(acc).map_err(|e| e.to_string()) @@ -153,7 +280,10 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> { } #[tauri::command] -pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> { +pub fn set_active_asr_provider( + coord: CoordinatorState<'_>, + provider: String, +) -> Result<(), String> { CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?; if provider == crate::asr::local::PROVIDER_ID { // 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。 @@ -173,13 +303,22 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> { } /// 读出某个账号的实际值(用于设置页预填表单)。 -/// 与 Swift `CredentialsVault.get` 同语义,先 Keychain,缺则回落 ~/.openless/credentials.json。 +/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。 #[tauri::command] -pub fn read_credential(account: String) -> Result, String> { +pub fn read_credential(window: Window, account: String) -> Result, String> { + ensure_main_window(&window)?; let acc = parse_account(&account)?; CredentialsVault::get(acc).map_err(|e| e.to_string()) } +fn ensure_main_window(window: &Window) -> Result<(), String> { + if window.label() == "main" { + Ok(()) + } else { + Err("credential access is only allowed from the main window".to_string()) + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ProviderCheckResult { @@ -718,9 +857,24 @@ pub fn get_qa_hotkey_label(coord: CoordinatorState<'_>) -> String { /// 传入 `None` 形式的字段不在这里支持——前端用 `binding == null` 时调下面的 /// "disable" 写法(写 prefs.qa_hotkey = None)即可。 #[tauri::command] -pub fn set_qa_hotkey(coord: CoordinatorState<'_>, binding: QaHotkeyBinding) -> Result<(), String> { +pub fn set_qa_hotkey( + coord: CoordinatorState<'_>, + binding: Option, +) -> Result<(), String> { + if let Some(binding) = binding.as_ref() { + crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Err("Shift 单键目前只能用于翻译快捷键".into()); + } + } let mut prefs = coord.prefs().get(); - prefs.qa_hotkey = Some(binding); + if let Some(binding) = binding.as_ref() { + reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, binding)?; + reject_qa_translation_hotkey_overlap(binding, &prefs.translation_hotkey)?; + reject_qa_switch_style_hotkey_overlap(binding, &prefs.switch_style_hotkey)?; + reject_qa_open_app_hotkey_overlap(binding, &prefs.open_app_hotkey)?; + } + prefs.qa_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; coord.update_qa_hotkey_binding(); Ok(()) @@ -738,6 +892,293 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { coord.qa_window_pin(pinned); } +// ─────────────────────────── 自定义组合键 ─────────────────────────── + +/// 测试一个组合键是否可以注册(验证格式,不实际注册)。 +#[tauri::command] +pub fn validate_shortcut_binding(binding: ShortcutBinding) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_dictation_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + reject_bare_shift_dictation_shortcut(&binding)?; + let mut prefs = coord.prefs().get(); + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_dictation_qa_hotkey_overlap(&binding, qa_hotkey)?; + } + reject_dictation_translation_hotkey_overlap(&binding, &prefs.translation_hotkey)?; + reject_dictation_switch_style_hotkey_overlap(&binding, &prefs.switch_style_hotkey)?; + reject_dictation_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?; + prefs.dictation_hotkey = binding; + sync_dictation_hotkey_legacy_fields(&mut prefs); + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_hotkey_binding(); + coord.update_combo_hotkey_binding(); + Ok(()) +} + +#[tauri::command] +pub fn set_translation_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + let previous = coord.prefs().get(); + reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?; + if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() { + reject_qa_translation_hotkey_overlap(qa_hotkey, &binding)?; + } + reject_translation_switch_style_hotkey_overlap(&binding, &previous.switch_style_hotkey)?; + reject_translation_open_app_hotkey_overlap(&binding, &previous.open_app_hotkey)?; + let mut prefs = previous.clone(); + prefs.translation_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + if let Err(e) = coord.try_update_translation_hotkey_binding() { + if let Err(rollback_err) = coord.prefs().set(previous) { + log::warn!("[commands] 回滚翻译快捷键失败: {rollback_err}"); + } + coord.update_translation_hotkey_binding(); + return Err(e); + } + Ok(()) +} + +#[tauri::command] +pub fn set_switch_style_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + reject_modifier_only_action_shortcut(&binding)?; + let mut prefs = coord.prefs().get(); + reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, &binding)?; + reject_translation_switch_style_hotkey_overlap(&prefs.translation_hotkey, &binding)?; + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_qa_switch_style_hotkey_overlap(qa_hotkey, &binding)?; + } + reject_switch_style_open_app_hotkey_overlap(&binding, &prefs.open_app_hotkey)?; + prefs.switch_style_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_switch_style_hotkey_binding(); + Ok(()) +} + +#[tauri::command] +pub fn set_open_app_hotkey( + coord: CoordinatorState<'_>, + binding: ShortcutBinding, +) -> Result<(), String> { + crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?; + reject_modifier_only_action_shortcut(&binding)?; + let mut prefs = coord.prefs().get(); + reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &binding)?; + reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &binding)?; + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_qa_open_app_hotkey_overlap(qa_hotkey, &binding)?; + } + reject_switch_style_open_app_hotkey_overlap(&prefs.switch_style_hotkey, &binding)?; + prefs.open_app_hotkey = binding; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_open_app_hotkey_binding(); + Ok(()) +} + +fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> { + if binding.modifiers.is_empty() + && (binding.primary.eq_ignore_ascii_case("shift") + || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) + { + return Err("该快捷键需要使用组合键或非修饰主键".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn validate_combo_hotkey(binding: ComboBinding) -> Result<(), String> { + let shortcut = ShortcutBinding { + primary: binding.primary, + modifiers: binding.modifiers, + }; + reject_bare_shift_dictation_shortcut(&shortcut)?; + crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string()) +} + +/// 设置自定义录音组合键并热更新 monitor。 +#[tauri::command] +pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + let shortcut = ShortcutBinding { + primary: binding.primary.clone(), + modifiers: binding.modifiers.clone(), + }; + reject_bare_shift_dictation_shortcut(&shortcut)?; + crate::combo_hotkey::validate_binding(&shortcut).map_err(|e| e.to_string())?; + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_dictation_qa_hotkey_overlap(&shortcut, qa_hotkey)?; + } + reject_dictation_translation_hotkey_overlap(&shortcut, &prefs.translation_hotkey)?; + reject_dictation_switch_style_hotkey_overlap(&shortcut, &prefs.switch_style_hotkey)?; + reject_dictation_open_app_hotkey_overlap(&shortcut, &prefs.open_app_hotkey)?; + prefs.custom_combo_hotkey = Some(binding); + prefs.dictation_hotkey = shortcut; + sync_dictation_hotkey_legacy_fields(&mut prefs); + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_hotkey_binding(); + coord.update_combo_hotkey_binding(); + Ok(()) +} + +fn reject_bare_shift_dictation_shortcut(binding: &ShortcutBinding) -> Result<(), String> { + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Err("Shift 单键目前只能用于翻译快捷键".into()); + } + Ok(()) +} + +fn sync_dictation_hotkey_legacy_fields(prefs: &mut UserPreferences) { + if let Some(trigger) = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) + { + prefs.hotkey.trigger = trigger; + prefs.custom_combo_hotkey = None; + return; + } + prefs.hotkey.trigger = crate::types::HotkeyTrigger::Custom; + prefs.custom_combo_hotkey = if prefs.dictation_hotkey.primary.trim().is_empty() { + None + } else { + Some(ComboBinding { + primary: prefs.dictation_hotkey.primary.clone(), + modifiers: prefs.dictation_hotkey.modifiers.clone(), + }) + }; +} + +fn reject_dictation_qa_hotkey_overlap( + dictation: &ShortcutBinding, + qa: &ShortcutBinding, +) -> Result<(), String> { + if shortcut_bindings_overlap(dictation, qa) { + return Err("QA 快捷键不能和听写快捷键相同".into()); + } + Ok(()) +} + +fn reject_hotkey_overlap( + left: &ShortcutBinding, + right: &ShortcutBinding, + message: &'static str, +) -> Result<(), String> { + if shortcut_bindings_overlap(left, right) { + return Err(message.into()); + } + Ok(()) +} + +fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> { + if let Some(qa_hotkey) = prefs.qa_hotkey.as_ref() { + reject_dictation_qa_hotkey_overlap(&prefs.dictation_hotkey, qa_hotkey)?; + reject_qa_translation_hotkey_overlap(qa_hotkey, &prefs.translation_hotkey)?; + reject_qa_switch_style_hotkey_overlap(qa_hotkey, &prefs.switch_style_hotkey)?; + reject_qa_open_app_hotkey_overlap(qa_hotkey, &prefs.open_app_hotkey)?; + } + reject_dictation_translation_hotkey_overlap(&prefs.dictation_hotkey, &prefs.translation_hotkey)?; + reject_dictation_switch_style_hotkey_overlap(&prefs.dictation_hotkey, &prefs.switch_style_hotkey)?; + reject_dictation_open_app_hotkey_overlap(&prefs.dictation_hotkey, &prefs.open_app_hotkey)?; + reject_translation_switch_style_hotkey_overlap( + &prefs.translation_hotkey, + &prefs.switch_style_hotkey, + )?; + reject_translation_open_app_hotkey_overlap(&prefs.translation_hotkey, &prefs.open_app_hotkey)?; + reject_switch_style_open_app_hotkey_overlap(&prefs.switch_style_hotkey, &prefs.open_app_hotkey)?; + Ok(()) +} + +fn reject_dictation_translation_hotkey_overlap( + dictation: &ShortcutBinding, + translation: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(dictation, translation, "翻译快捷键不能和听写快捷键相同") +} + +fn reject_dictation_switch_style_hotkey_overlap( + dictation: &ShortcutBinding, + switch_style: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(dictation, switch_style, "切换风格快捷键不能和听写快捷键相同") +} + +fn reject_dictation_open_app_hotkey_overlap( + dictation: &ShortcutBinding, + open_app: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(dictation, open_app, "打开应用快捷键不能和听写快捷键相同") +} + +fn reject_qa_translation_hotkey_overlap( + qa: &ShortcutBinding, + translation: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(qa, translation, "翻译快捷键不能和 QA 快捷键相同") +} + +fn reject_qa_switch_style_hotkey_overlap( + qa: &ShortcutBinding, + switch_style: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(qa, switch_style, "切换风格快捷键不能和 QA 快捷键相同") +} + +fn reject_qa_open_app_hotkey_overlap( + qa: &ShortcutBinding, + open_app: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(qa, open_app, "打开应用快捷键不能和 QA 快捷键相同") +} + +fn reject_translation_switch_style_hotkey_overlap( + translation: &ShortcutBinding, + switch_style: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(translation, switch_style, "切换风格快捷键不能和翻译快捷键相同") +} + +fn reject_translation_open_app_hotkey_overlap( + translation: &ShortcutBinding, + open_app: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(translation, open_app, "打开应用快捷键不能和翻译快捷键相同") +} + +fn reject_switch_style_open_app_hotkey_overlap( + switch_style: &ShortcutBinding, + open_app: &ShortcutBinding, +) -> Result<(), String> { + reject_hotkey_overlap(switch_style, open_app, "打开应用快捷键不能和切换风格快捷键相同") +} + +fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool { + let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left); + let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right); + match (left_legacy, right_legacy) { + (Some(left), Some(right)) => left == right, + (Some(_), None) | (None, Some(_)) => false, + (None, None) => { + let Ok(left) = crate::shortcut_binding::parse_global_hotkey(left) else { + return false; + }; + let Ok(right) = crate::shortcut_binding::parse_global_hotkey(right) else { + return false; + }; + left == right + } + } +} + // ─────────────────────────── local ASR (Qwen3-ASR) ─────────────────────────── use crate::asr::local::{ @@ -767,7 +1208,10 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { } #[tauri::command] -pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { +pub fn local_asr_set_active_model( + coord: CoordinatorState<'_>, + model_id: String, +) -> Result<(), String> { if ModelId::from_str(&model_id).is_none() { return Err(format!("unknown model id: {model_id}")); } @@ -914,7 +1358,7 @@ mod tests { }; use crate::persistence::CredentialsSnapshot; use crate::types::{ - HotkeyBinding, HotkeyMode, HotkeyTrigger, QaHotkeyBinding, UserPreferences, + ComboBinding, HotkeyBinding, HotkeyMode, HotkeyTrigger, ShortcutBinding, UserPreferences, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -926,6 +1370,7 @@ mod tests { saved: Mutex>, dictation_refreshes: Mutex, qa_refreshes: Mutex, + combo_refreshes: Mutex, } fn snapshot() -> CredentialsSnapshot { @@ -1000,6 +1445,14 @@ mod tests { fn refresh_qa_hotkey(&self) { *self.qa_refreshes.lock().unwrap() += 1; } + + fn refresh_combo_hotkey(&self) { + *self.combo_refreshes.lock().unwrap() += 1; + } + + fn refresh_translation_hotkey(&self) {} + fn refresh_switch_style_hotkey(&self) {} + fn refresh_open_app_hotkey(&self) {} } #[test] @@ -1054,7 +1507,7 @@ mod tests { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, }, - qa_hotkey: Some(QaHotkeyBinding { + qa_hotkey: Some(ShortcutBinding { primary: ";".to_string(), modifiers: vec!["ctrl".to_string(), "shift".to_string()], }), @@ -1069,7 +1522,7 @@ mod tests { .unwrap() .clone() .expect("settings saved"); - assert_eq!(saved.hotkey.trigger, prefs.hotkey.trigger); + assert_eq!(saved.hotkey.trigger, HotkeyTrigger::RightOption); assert_eq!(saved.hotkey.mode, prefs.hotkey.mode); assert_eq!( saved.qa_hotkey.unwrap().primary, @@ -1077,6 +1530,253 @@ mod tests { ); assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 1); assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1); + assert_eq!(*writer.combo_refreshes.lock().unwrap(), 1); + } + + #[test] + fn sync_dictation_hotkey_sets_modifier_trigger_and_clears_combo() { + let mut prefs = UserPreferences { + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::Custom, + mode: HotkeyMode::Toggle, + }, + custom_combo_hotkey: Some(ComboBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }), + dictation_hotkey: ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }, + ..Default::default() + }; + + super::sync_dictation_hotkey_legacy_fields(&mut prefs); + + assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::RightControl); + assert!(prefs.custom_combo_hotkey.is_none()); + } + + #[test] + fn sync_dictation_hotkey_sets_custom_trigger_and_combo_binding() { + let mut prefs = UserPreferences { + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + }, + dictation_hotkey: ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }, + ..Default::default() + }; + + super::sync_dictation_hotkey_legacy_fields(&mut prefs); + + assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom); + let combo = prefs.custom_combo_hotkey.expect("combo binding saved"); + assert_eq!(combo.primary, "D"); + assert_eq!( + combo.modifiers, + vec!["cmd".to_string(), "shift".to_string()] + ); + } + + #[test] + fn sync_dictation_hotkey_clears_empty_custom_binding() { + let mut prefs = UserPreferences { + hotkey: HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + }, + custom_combo_hotkey: Some(ComboBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }), + dictation_hotkey: ShortcutBinding { + primary: " ".into(), + modifiers: vec!["cmd".into()], + }, + ..Default::default() + }; + + super::sync_dictation_hotkey_legacy_fields(&mut prefs); + + assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom); + assert!(prefs.custom_combo_hotkey.is_none()); + } + + #[test] + fn validate_combo_hotkey_rejects_bare_shift() { + let result = super::validate_combo_hotkey(ComboBinding { + primary: "Shift".into(), + modifiers: vec![], + }); + + assert!(result.is_err()); + } + + #[test] + fn combo_hotkey_bare_shift_rejection_matches_dictation_setter() { + let binding = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + + assert_eq!( + super::reject_bare_shift_dictation_shortcut(&binding), + Err("Shift 单键目前只能用于翻译快捷键".into()) + ); + } + + #[test] + fn dictation_qa_overlap_rejects_same_modifier_only_binding() { + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + + assert_eq!( + super::reject_dictation_qa_hotkey_overlap(&binding, &binding), + Err("QA 快捷键不能和听写快捷键相同".into()) + ); + } + + #[test] + fn dictation_qa_overlap_rejects_same_combo_binding() { + let dictation = ShortcutBinding { + primary: ";".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + let qa = ShortcutBinding { + primary: ";".into(), + modifiers: vec!["control".into(), "shift".into()], + }; + + assert_eq!( + super::reject_dictation_qa_hotkey_overlap(&dictation, &qa), + Err("QA 快捷键不能和听写快捷键相同".into()) + ); + } + + #[test] + fn dictation_qa_overlap_allows_distinct_bindings() { + let dictation = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + let qa = ShortcutBinding { + primary: ";".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + + assert!(super::reject_dictation_qa_hotkey_overlap(&dictation, &qa).is_ok()); + } + + #[test] + fn dictation_translation_overlap_rejects_same_modifier_only_binding() { + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + + assert_eq!( + super::reject_dictation_translation_hotkey_overlap(&binding, &binding), + Err("翻译快捷键不能和听写快捷键相同".into()) + ); + } + + #[test] + fn dictation_translation_overlap_rejects_same_combo_binding() { + let dictation = ShortcutBinding { + primary: "T".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + let translation = ShortcutBinding { + primary: "T".into(), + modifiers: vec!["control".into(), "shift".into()], + }; + + assert_eq!( + super::reject_dictation_translation_hotkey_overlap(&dictation, &translation), + Err("翻译快捷键不能和听写快捷键相同".into()) + ); + } + + #[test] + fn dictation_translation_overlap_allows_distinct_bindings() { + let dictation = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + let translation = ShortcutBinding { + primary: "Shift".into(), + modifiers: vec![], + }; + + assert!( + super::reject_dictation_translation_hotkey_overlap(&dictation, &translation).is_ok() + ); + } + + #[test] + fn persist_settings_rejects_dictation_translation_overlap() { + let writer = FakeSettingsWriter::default(); + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + let prefs = UserPreferences { + dictation_hotkey: binding.clone(), + translation_hotkey: binding, + ..Default::default() + }; + + assert_eq!( + persist_settings(&writer, prefs), + Err("翻译快捷键不能和听写快捷键相同".into()) + ); + assert!(writer.saved.lock().unwrap().is_none()); + } + + #[test] + fn persist_settings_rejects_translation_switch_style_overlap() { + let writer = FakeSettingsWriter::default(); + let binding = ShortcutBinding { + primary: "T".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let prefs = UserPreferences { + translation_hotkey: binding.clone(), + switch_style_hotkey: binding, + ..Default::default() + }; + + assert_eq!( + persist_settings(&writer, prefs), + Err("切换风格快捷键不能和翻译快捷键相同".into()) + ); + assert!(writer.saved.lock().unwrap().is_none()); + } + + #[test] + fn persist_settings_rejects_switch_style_open_app_overlap() { + let writer = FakeSettingsWriter::default(); + let binding = ShortcutBinding { + primary: "K".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let prefs = UserPreferences { + switch_style_hotkey: binding.clone(), + open_app_hotkey: binding, + ..Default::default() + }; + + assert_eq!( + persist_settings(&writer, prefs), + Err("打开应用快捷键不能和切换风格快捷键相同".into()) + ); + assert!(writer.saved.lock().unwrap().is_none()); } #[tokio::test] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 549ac465..82dda2a4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -20,6 +20,7 @@ use crate::asr::{ DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; +use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent, ComboHotkeyMonitor}; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ @@ -150,6 +151,13 @@ struct Inner { hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, + shortcut_recording_active: AtomicBool, + /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 + /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 + combo_hotkey: Mutex>, + translation_hotkey: Mutex>, + switch_style_hotkey: Mutex>, + open_app_hotkey: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language @@ -180,6 +188,12 @@ enum QaPhase { Processing, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActionHotkeyKind { + SwitchStyle, + OpenApp, +} + struct QaSessionState { phase: QaPhase, cancelled: bool, @@ -246,6 +260,11 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + shortcut_recording_active: AtomicBool::new(false), + combo_hotkey: Mutex::new(None), + translation_hotkey: Mutex::new(None), + switch_style_hotkey: Mutex::new(None), + open_app_hotkey: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), @@ -267,12 +286,16 @@ impl Coordinator { let inner = Arc::clone(&self.inner); tauri::async_runtime::spawn(async move { let prefs = inner.prefs.get(); - let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) { - Some(m) => m, - None => return, - }; + let model_id = + match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) { + Some(m) => m, + None => return, + }; if !crate::asr::local::models::is_downloaded(model_id) { - log::info!("[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str()); + log::info!( + "[coord] local ASR preload skipped: model {} not downloaded", + model_id.as_str() + ); return; } let dir = match crate::asr::local::models::model_dir(model_id) { @@ -349,6 +372,106 @@ impl Coordinator { } } + /// 启动自定义组合键监听器。当 `prefs.hotkey.trigger == Custom` 时, + /// 代替 modifier-only 的 hotkey monitor。 + pub fn start_combo_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-supervisor".into()) + .spawn(move || combo_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_combo_hotkey_listener(&self) { + take_combo_hotkey_on_main_thread(&self.inner); + } + + pub fn start_translation_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-supervisor".into()) + .spawn(move || translation_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_translation_hotkey_listener(&self) { + take_translation_hotkey_on_main_thread(&self.inner); + } + + pub fn start_switch_style_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-switch-style-hotkey-supervisor".into()) + .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::SwitchStyle)) + .ok(); + } + + pub fn stop_switch_style_hotkey_listener(&self) { + take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::SwitchStyle); + } + + pub fn start_open_app_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-open-app-hotkey-supervisor".into()) + .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::OpenApp)) + .ok(); + } + + pub fn stop_open_app_hotkey_listener(&self) { + take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp); + } + + /// 用户在设置里改了自定义组合键时调用。 + pub fn update_combo_hotkey_binding(&self) { + let prefs = self.inner.prefs.get(); + if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { + // 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。 + take_combo_hotkey_on_main_thread(&self.inner); + log::info!("[coord] combo hotkey 已关闭(modifier-only)"); + return; + } + let binding = prefs.dictation_hotkey.clone(); + if is_unconfigured_shortcut(&binding) { + // Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。 + take_combo_hotkey_on_main_thread(&self.inner); + log::info!("[coord] combo hotkey 已关闭(无绑定)"); + return; + }; + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过"); + return; + }; + let inner_clone = Arc::clone(&self.inner); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = inner_clone.combo_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding_for_main.clone()) { + log::warn!("[coord] update combo hotkey binding 失败: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding_for_main, tx) { + Ok(monitor) => { + *inner_clone.combo_hotkey.lock() = Some(monitor); + log::info!( + "[coord] combo hotkey listener installed on main thread (via update)" + ); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => { + log::warn!("[coord] update combo hotkey binding 失败: {e}"); + } + } + }); + } + /// 用户在设置里改了 QA 组合键时调用。先持久化(由 prefs.set 完成), /// 然后通知活着的 monitor 重新注册;monitor 不存在时 supervisor 会自然 /// 在下一次循环里读到新的 prefs。 @@ -367,8 +490,24 @@ impl Coordinator { self.inner.qa_hotkey.lock().take(); } log::info!("[coord] QA hotkey 已关闭"); + self.update_modifier_shortcut_bindings(); return; }; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + let app = self.inner.app.lock().clone(); + if let Some(app) = app { + let inner_clone = Arc::clone(&self.inner); + let _ = app.run_on_main_thread(move || { + inner_clone.qa_hotkey.lock().take(); + }); + } else { + self.inner.qa_hotkey.lock().take(); + } + self.update_modifier_shortcut_bindings(); + log::info!("[coord] QA hotkey uses modifier-only listener"); + return; + } + self.update_modifier_shortcut_bindings(); // global-hotkey crate 的 manager.register/unregister 必须主线程跑。 // 没在主线程会让 Carbon 句柄注册看似成功但事件不派发。 let app = self.inner.app.lock().clone(); @@ -406,6 +545,84 @@ impl Coordinator { }); } + pub fn update_translation_hotkey_binding(&self) { + if let Err(e) = self.try_update_translation_hotkey_binding() { + log::warn!("[coord] update translation hotkey binding 失败: {e}"); + } + } + + pub fn try_update_translation_hotkey_binding(&self) -> Result<(), String> { + let prefs = self.inner.prefs.get(); + if is_builtin_translation_shift(&prefs.translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_some() + { + take_translation_hotkey_on_main_thread(&self.inner); + self.update_modifier_shortcut_bindings(); + log::info!("[coord] translation hotkey uses modifier-only listener"); + return Ok(()); + } + self.update_modifier_shortcut_bindings(); + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + return Err("AppHandle 未 bind,无法注册翻译快捷键".into()); + }; + let inner_clone = Arc::clone(&self.inner); + let binding_for_main = prefs.translation_hotkey.clone(); + let (result_tx, result_rx) = mpsc::sync_channel::>(1); + let _ = app.run_on_main_thread(move || { + let result = update_translation_hotkey_on_main_thread(inner_clone, binding_for_main); + let _ = result_tx.send(result.map_err(|e| e.to_string())); + }); + match result_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(result) => result, + Err(_) => Err("注册翻译快捷键超时".into()), + } + } + + pub fn update_switch_style_hotkey_binding(&self) { + self.update_action_hotkey_binding(ActionHotkeyKind::SwitchStyle); + } + + pub fn update_open_app_hotkey_binding(&self) { + self.update_action_hotkey_binding(ActionHotkeyKind::OpenApp); + } + + fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { + let binding = action_hotkey_binding(&self.inner, kind); + if is_modifier_only_shortcut(&binding) { + take_action_hotkey_on_main_thread(&self.inner, kind); + log::warn!("[coord] action hotkey {kind:?} 使用了不支持的 modifier-only 绑定,已关闭"); + return; + } + + let app = self.inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] update action hotkey binding: AppHandle 未 bind,跳过"); + return; + }; + let inner_clone = Arc::clone(&self.inner); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = action_hotkey_slot(&inner_clone, kind).lock().as_ref() { + if let Err(e) = monitor.update_binding(binding.clone()) { + log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + *action_hotkey_slot(&inner_clone, kind).lock() = Some(monitor); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(bridge_inner, rx, kind)) + .ok(); + } + Err(e) => log::warn!("[coord] update action hotkey {kind:?} binding 失败: {e}"), + } + }); + } + /// 给前端 Settings 渲染当前 QA 快捷键 label(如 "Cmd+Shift+;")。 /// `qa_hotkey == None` 时返回空串,UI 据此显示「未启用」。 pub fn qa_hotkey_label(&self) -> String { @@ -441,8 +658,59 @@ impl Coordinator { } pub fn update_hotkey_binding(&self) { + let prefs = self.inner.prefs.get(); + let dictation_trigger = + crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey); + let binding = crate::types::HotkeyBinding { + trigger: dictation_trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), + mode: prefs.hotkey.mode, + }; + if dictation_trigger.is_some() { + take_combo_hotkey_on_main_thread(&self.inner); + } else { + self.update_combo_hotkey_binding(); + } + self.ensure_modifier_hotkey_monitor(binding); + self.update_modifier_shortcut_bindings(); + } + + fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { + if let Some(monitor) = self.inner.hotkey.lock().as_ref() { + monitor.update_binding(binding); + return; + } + let (tx, rx) = mpsc::channel::(); + match HotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + let adapter = monitor.kind(); + *self.inner.hotkey.lock() = Some(monitor); + *self.inner.hotkey_status.lock() = HotkeyStatus { + adapter, + state: HotkeyStatusState::Installed, + message: Some(format!("{} 已安装", adapter.display_name())), + last_error: None, + }; + let inner_clone = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-hotkey-bridge".into()) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) + .ok(); + } + Err(e) => { + *self.inner.hotkey_status.lock() = HotkeyStatus { + adapter: HotkeyMonitor::capability().adapter, + state: HotkeyStatusState::Failed, + message: Some(e.message.clone()), + last_error: Some(e), + }; + } + } + } + + pub fn update_modifier_shortcut_bindings(&self) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { - monitor.update_binding(self.inner.prefs.get().hotkey); + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); } } @@ -470,6 +738,16 @@ impl Coordinator { cancel_session(&self.inner); } + pub fn set_shortcut_recording_active(&self, active: bool) { + self.inner + .shortcut_recording_active + .store(active, Ordering::SeqCst); + if active { + reset_shortcut_held_state(&self.inner); + } + log::info!("[coord] shortcut recording active={active}"); + } + pub async fn handle_window_hotkey_event( &self, event_type: String, @@ -518,6 +796,8 @@ fn hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; let capability = HotkeyMonitor::capability(); loop { + let prefs = inner.prefs.get(); + if inner.hotkey.lock().is_some() { return; } @@ -528,11 +808,20 @@ fn hotkey_supervisor_loop(inner: Arc) { last_error: None, }; let (tx, rx) = mpsc::channel::(); - let binding = inner.prefs.get().hotkey; + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) + .unwrap_or(crate::types::HotkeyTrigger::Custom); + let binding = crate::types::HotkeyBinding { + trigger, + mode: prefs.hotkey.mode, + }; match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); *inner.hotkey.lock() = Some(monitor); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } *inner.hotkey_status.lock() = HotkeyStatus { adapter, state: HotkeyStatusState::Installed, @@ -585,6 +874,15 @@ fn qa_hotkey_supervisor_loop(inner: Arc) { continue; } }; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + inner.qa_hotkey.lock().take(); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } if inner.qa_hotkey.lock().is_some() { // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 @@ -657,6 +955,9 @@ fn qa_hotkey_supervisor_loop(inner: Arc) { fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } let inner_cloned = Arc::clone(&inner); match evt { QaHotkeyEvent::Pressed => { @@ -666,6 +967,450 @@ fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } } +// ─────────────────────────── combo hotkey supervisor ─────────────────────────── + +fn combo_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + // 读当前 prefs + let prefs = inner.prefs.get(); + if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { + // 不是 Custom → 睡着等 prefs 改动 + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let binding = prefs.dictation_hotkey.clone(); + if is_unconfigured_shortcut(&binding) { + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.combo_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.combo_hotkey.lock() = Some(monitor); + log::info!( + "[coord] combo hotkey listener installed on main thread (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + ComboHotkeyEvent::Pressed => { + async_runtime::spawn(async move { handle_pressed_edge(&inner_cloned).await }); + } + ComboHotkeyEvent::Released => { + async_runtime::spawn(async move { handle_released_edge(&inner_cloned).await }); + } + } + } +} + +fn translation_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + let binding = inner.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&binding) + || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() + { + take_translation_hotkey_on_main_thread(&inner); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.translation_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.translation_hotkey.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn update_translation_hotkey_on_main_thread( + inner: Arc, + binding: crate::types::ShortcutBinding, +) -> Result<(), ComboHotkeyError> { + if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { + return monitor.update_binding(binding); + } + let (tx, rx) = mpsc::channel::(); + let monitor = ComboHotkeyMonitor::start(binding, tx)?; + *inner.translation_hotkey.lock() = Some(monitor); + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?; + Ok(()) +} + +fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + mark_translation_modifier_seen(&inner); + } + } +} + +fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { + let mut attempts: u32 = 0; + loop { + let binding = action_hotkey_binding(&inner, kind); + if is_modifier_only_shortcut(&binding) { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if action_hotkey_slot(&inner, kind).lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *action_hotkey_slot(&inner, kind).lock() = Some(monitor); + log::info!( + "[coord] action hotkey {kind:?} listener installed after {} attempt(s)", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn action_hotkey_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, + kind: ActionHotkeyKind, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + handle_action_hotkey_pressed(&inner, kind); + } + } +} + +fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { + match kind { + ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), + ActionHotkeyKind::OpenApp => { + if let Some(app) = inner.app.lock().clone() { + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + crate::show_main_window(&app_for_main); + }); + } + } + } +} + +fn switch_to_previous_style(inner: &Arc) { + let mut prefs = inner.prefs.get(); + let order = [ + PolishMode::Raw, + PolishMode::Light, + PolishMode::Structured, + PolishMode::Formal, + ]; + let enabled: Vec = order + .into_iter() + .filter(|mode| prefs.enabled_modes.contains(mode)) + .collect(); + if enabled.len() <= 1 { + log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); + return; + } + let current_index = enabled + .iter() + .position(|mode| *mode == prefs.default_mode) + .unwrap_or(0); + let next_index = if current_index == 0 { + enabled.len() - 1 + } else { + current_index - 1 + }; + prefs.default_mode = enabled[next_index]; + if let Err(e) = inner.prefs.set(prefs.clone()) { + log::warn!("[coord] switch style hotkey 保存失败: {e}"); + } else { + log::info!( + "[coord] switch style hotkey changed default mode to {}", + prefs.default_mode.display_name() + ); + } +} + +fn take_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.combo_hotkey.lock().take(); + }); + } else { + inner.combo_hotkey.lock().take(); + } +} + +fn take_translation_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.translation_hotkey.lock().take(); + }); + } else { + inner.translation_hotkey.lock().take(); + } +} + +fn take_action_hotkey_on_main_thread(inner: &Arc, kind: ActionHotkeyKind) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + action_hotkey_slot(&inner, kind).lock().take(); + }); + } else { + action_hotkey_slot(inner, kind).lock().take(); + } +} + +fn action_hotkey_slot( + inner: &Arc, + kind: ActionHotkeyKind, +) -> &Mutex> { + match kind { + ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, + ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, + } +} + +fn action_hotkey_binding( + inner: &Arc, + kind: ActionHotkeyKind, +) -> crate::types::ShortcutBinding { + let prefs = inner.prefs.get(); + match kind { + ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, + ActionHotkeyKind::OpenApp => prefs.open_app_hotkey, + } +} + +fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() + && (binding.primary.eq_ignore_ascii_case("shift") + || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) +} + +fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.primary.trim().is_empty() +} + +fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str { + match kind { + ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", + ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", + } +} + +fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") +} + +fn modifier_shortcut_triggers( + inner: &Arc, +) -> ( + Option, + Option, +) { + let prefs = inner.prefs.get(); + let qa_trigger = prefs + .qa_hotkey + .as_ref() + .and_then(crate::shortcut_binding::legacy_modifier_trigger); + let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) { + None + } else { + crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey) + }; + (qa_trigger, translation_trigger) +} + +fn mark_translation_modifier_seen(inner: &Arc) { + let phase = inner.state.lock().phase; + if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] translation modifier seen during {phase:?}"); + } +} + async fn handle_qa_hotkey_pressed(inner: &Arc) { // QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。 // 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。 @@ -744,6 +1489,9 @@ fn close_qa_panel(inner: &Arc) { fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } let inner_cloned = Arc::clone(&inner); match evt { HotkeyEvent::Pressed => { @@ -756,19 +1504,59 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { cancel_session(&inner_cloned); } HotkeyEvent::TranslationModifierPressed => { - // 仅在 Starting / Listening 阶段把 Shift 边沿计入"翻译模式触发"。 - // Idle 阶段按 Shift 不应该影响下一段录音;Processing/Inserting 已经过了 - // 决定走哪条管线的检查点,再 set 也没意义。 - let phase = inner_cloned.state.lock().phase; - if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { - inner_cloned - .translation_modifier_seen - .store(true, Ordering::SeqCst); - log::info!("[coord] translation modifier seen during {phase:?}"); + let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) + .is_some() + { + mark_translation_modifier_seen(&inner_cloned); + } + } + HotkeyEvent::QaShortcutPressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } + } + } +} + +fn reset_shortcut_held_state(inner: &Arc) { + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + monitor.reset_held_state(); + } + let prefs = inner.prefs.get(); + if let Some(binding) = prefs.qa_hotkey.as_ref() { + if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() { + if let Some(monitor) = inner.qa_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding.clone()) { + log::warn!("[coord] reset QA hotkey latch failed: {e}"); } } } } + if !is_builtin_translation_shift(&prefs.translation_hotkey) + && crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none() + { + if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) { + log::warn!("[coord] reset translation hotkey latch failed: {e}"); + } + } + } + if !is_modifier_only_shortcut(&prefs.switch_style_hotkey) { + if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(prefs.switch_style_hotkey.clone()) { + log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); + } + } + } + if !is_modifier_only_shortcut(&prefs.open_app_hotkey) { + if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(prefs.open_app_hotkey.clone()) { + log::warn!("[coord] reset open-app hotkey latch failed: {e}"); + } + } + } } async fn handle_pressed_edge(inner: &Arc) { @@ -889,6 +1677,27 @@ fn store_recorder_for_session(inner: &Arc, session_id: u64, recorder: Rec *inner.recorder.lock() = Some(SessionResource::new(session_id, recorder)); } +fn selected_microphone_device_name(inner: &Arc) -> Option { + let name = inner.prefs.get().microphone_device_name.trim().to_string(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +fn stop_microphone_preview_monitor(inner: &Arc, owner: &str) { + let Some(app) = inner.app.lock().as_ref().cloned() else { + return; + }; + let state = app.state::(); + let recorder = state.lock().take(); + if let Some(recorder) = recorder { + log::info!("[recorder] stopping microphone preview monitor before {owner}"); + recorder.stop(); + } +} + fn acquire_recording_mute(inner: &Arc, owner: &str) { if !inner.prefs.get().mute_during_recording { return; @@ -974,6 +1783,9 @@ async fn handle_window_hotkey_event( code: String, repeat: bool, ) -> Result<(), String> { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + return Ok(()); + } if event_type == "keydown" && key == "Escape" { // Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation); // 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗 @@ -1007,7 +1819,11 @@ async fn handle_window_hotkey_event( return Ok(()); } - let trigger = inner.prefs.get().hotkey.trigger; + let Some(trigger) = + crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey) + else { + return Ok(()); + }; if !window_key_matches_trigger(trigger, &key, &code) { return Ok(()); } @@ -1049,6 +1865,8 @@ fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, c HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + // Custom 走 global-hotkey crate,不走 window hotkey fallback + HotkeyTrigger::Custom => false, } } @@ -1125,8 +1943,10 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { return Err(message); } - emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); - + // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 + // Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。 + // 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init + // 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。 let active_asr = CredentialsVault::get_active_asr(); #[cfg(target_os = "macos")] @@ -1294,11 +2114,33 @@ fn start_recorder_for_starting( ); }); + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "dictation recorder"); acquire_recording_mute(inner, "dictation"); - match Recorder::start(consumer, level_handler) { + match Recorder::start(microphone_device_name, consumer, level_handler) { Ok((rec, runtime_errors)) => { store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); + // ★ 录音器实际启动后再发 Recording capsule —— 避免用户「看到录音条但 + // mic 还没开」的 50-200ms 窗口里开口讲话被吞(三条 ASR 路径共享)。 + // ASR 连接慢的间隙由 DeferredAsrBridge 缓存 PCM,按顺序后送,不丢字。 + // + // 竞态保护:必须在 stop_recorder_if_pending_start_stop 之前 emit, + // 并且仅当 recorder 真的会继续运行(phase 仍是 Starting、无待处理的 + // stop / cancel)时才 emit。否则用户在 cpal init 期间松开热键时, + // stop / cancel 路径可能已经发出 Transcribing / Cancelled,本行 + // 再无条件覆盖回 Recording 会让 UI 短暂闪烁错误状态(短按尤其明显)。 + // Codex review (PR #289 P2) 指出。 + let should_emit_recording = { + let state = inner.state.lock(); + state.session_id == session_id + && state.phase == SessionPhase::Starting + && !state.pending_stop + && !state.cancelled + }; + if should_emit_recording { + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + } stop_recorder_if_pending_start_stop(inner); log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); } @@ -2212,7 +3054,9 @@ fn schedule_local_asr_release(inner: &Arc) { } #[cfg(target_os = "macos")] -async fn build_local_qwen3(inner: &Arc) -> anyhow::Result> { +async fn build_local_qwen3( + inner: &Arc, +) -> anyhow::Result> { let prefs = inner.prefs.get(); let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) .ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?; @@ -2562,8 +3406,10 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { ); }); + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "QA recorder"); acquire_recording_mute(inner, "qa"); - match Recorder::start(consumer, level_handler) { + match Recorder::start(microphone_device_name, consumer, level_handler) { Ok((rec, runtime_errors)) => { *inner.qa_recorder.lock() = Some(rec); // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 @@ -3154,6 +4000,19 @@ mod tests { assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } + #[test] + fn enabling_shortcut_recording_clears_dictation_hold_latch() { + let coordinator = Coordinator::new(); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + coordinator.set_shortcut_recording_active(true); + + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + #[test] fn window_hotkey_fallback_is_disabled_when_no_explicit_fallback_is_advertised() { assert_eq!( diff --git a/openless-all/app/src-tauri/src/global_hotkey_runtime.rs b/openless-all/app/src-tauri/src/global_hotkey_runtime.rs new file mode 100644 index 00000000..b04516a3 --- /dev/null +++ b/openless-all/app/src-tauri/src/global_hotkey_runtime.rs @@ -0,0 +1,107 @@ +//! Shared `global-hotkey` runtime. +//! +//! `global-hotkey` installs a process-level Carbon event handler on macOS and +//! exposes one process-level event receiver. OpenLess has two logical users of +//! that crate (QA and custom dictation combos), so they must share one manager +//! and one dispatcher instead of racing on `GlobalHotKeyEvent::receiver()`. + +use std::collections::HashMap; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::time::Duration; + +use global_hotkey::hotkey::HotKey; +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager}; +use once_cell::sync::OnceCell; +use parking_lot::Mutex; + +static RUNTIME: OnceCell> = OnceCell::new(); + +pub struct GlobalHotkeyRuntime { + manager: GlobalHotKeyManager, + routes: Mutex>>, +} + +// global-hotkey 0.6 does not mark its manager Send/Sync on all platforms even +// though it wraps OS-level handles. Coordinator stores monitors across threads, +// matching the existing qa/combo monitor safety model. +unsafe impl Send for GlobalHotkeyRuntime {} +unsafe impl Sync for GlobalHotkeyRuntime {} + +pub struct RegisteredHotkey { + runtime: Arc, + hotkey: HotKey, +} + +impl GlobalHotkeyRuntime { + pub fn shared() -> Result, String> { + RUNTIME + .get_or_try_init(|| { + let manager = GlobalHotKeyManager::new().map_err(|e| e.to_string())?; + let runtime = Arc::new(Self { + manager, + routes: Mutex::new(HashMap::new()), + }); + start_dispatcher(Arc::clone(&runtime)); + Ok(runtime) + }) + .cloned() + } + + pub fn register( + self: &Arc, + hotkey: HotKey, + ) -> Result<(RegisteredHotkey, Receiver), String> { + self.manager.register(hotkey).map_err(|e| e.to_string())?; + let (tx, rx) = mpsc::channel(); + self.routes.lock().insert(hotkey.id(), tx); + Ok(( + RegisteredHotkey { + runtime: Arc::clone(self), + hotkey, + }, + rx, + )) + } + + fn unregister(&self, hotkey: HotKey) { + self.routes.lock().remove(&hotkey.id()); + if let Err(e) = self.manager.unregister(hotkey) { + log::warn!("[global-hotkey] unregister 失败: {e}"); + } + } + + fn dispatch(&self, event: GlobalHotKeyEvent) { + let tx = self.routes.lock().get(&event.id()).cloned(); + if let Some(tx) = tx { + let _ = tx.send(event); + } + } +} + +impl Drop for RegisteredHotkey { + fn drop(&mut self) { + self.runtime.unregister(self.hotkey); + } +} + +impl RegisteredHotkey { + pub fn hotkey(&self) -> HotKey { + self.hotkey + } +} + +fn start_dispatcher(runtime: Arc) { + std::thread::Builder::new() + .name("openless-global-hotkey-dispatch".into()) + .spawn(move || { + let receiver = GlobalHotKeyEvent::receiver(); + loop { + match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(event) => runtime.dispatch(event), + Err(_) => continue, + } + } + }) + .expect("spawn global hotkey dispatcher"); +} diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 1926c0c5..0692c3e1 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -17,6 +17,7 @@ use std::time::Duration; use parking_lot::RwLock; +use crate::types::HotkeyTrigger; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError}; #[derive(Clone, Copy, Debug)] @@ -27,11 +28,44 @@ pub enum HotkeyEvent { /// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生; /// 上层据此切换到翻译输出管线。详见 issue #4。 TranslationModifierPressed, + QaShortcutPressed, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::Ordering; + + #[test] + fn reset_shared_held_state_clears_all_shortcut_latches() { + let shared = Shared { + binding: RwLock::new(HotkeyBinding::default()), + trigger_held: AtomicBool::new(true), + qa_trigger: RwLock::new(None), + qa_trigger_held: AtomicBool::new(true), + translation_trigger: RwLock::new(None), + translation_trigger_held: AtomicBool::new(true), + translation_modifier_held: AtomicBool::new(true), + }; + + reset_shared_held_state(&shared); + + assert!(!shared.trigger_held.load(Ordering::SeqCst)); + assert!(!shared.qa_trigger_held.load(Ordering::SeqCst)); + assert!(!shared.translation_trigger_held.load(Ordering::SeqCst)); + assert!(!shared.translation_modifier_held.load(Ordering::SeqCst)); + } } pub trait HotkeyAdapter: Send + Sync { fn kind(&self) -> HotkeyAdapterKind; fn update_binding(&self, binding: HotkeyBinding); + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ); + fn reset_held_state(&self); fn shutdown(&self) {} } @@ -39,6 +73,10 @@ struct Shared { binding: RwLock, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 trigger_held: AtomicBool, + qa_trigger: RwLock>, + qa_trigger_held: AtomicBool, + translation_trigger: RwLock>, + translation_trigger_held: AtomicBool, /// Shift(翻译修饰键)当前是否按住。用于在 FLAGS_CHANGED 上识别 down 边沿 /// (只在 false → true 时往上层发 TranslationModifierPressed)。详见 issue #4。 translation_modifier_held: AtomicBool, @@ -65,10 +103,23 @@ impl HotkeyMonitor { self.adapter.update_binding(binding); } + pub fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + self.adapter + .update_modifier_shortcuts(qa_trigger, translation_trigger); + } + pub fn kind(&self) -> HotkeyAdapterKind { self.adapter.kind() } + pub fn reset_held_state(&self) { + self.adapter.reset_held_state(); + } + pub fn capability() -> HotkeyCapability { HotkeyCapability::current() } @@ -114,6 +165,10 @@ where let shared = Arc::new(Shared { binding: RwLock::new(binding), trigger_held: AtomicBool::new(false), + qa_trigger: RwLock::new(None), + qa_trigger_held: AtomicBool::new(false), + translation_trigger: RwLock::new(None), + translation_trigger_held: AtomicBool::new(false), translation_modifier_held: AtomicBool::new(false), }); @@ -138,6 +193,36 @@ fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { .store(false, std::sync::atomic::Ordering::SeqCst); } +fn update_shared_modifier_shortcuts( + shared: &Shared, + qa_trigger: Option, + translation_trigger: Option, +) { + *shared.qa_trigger.write() = qa_trigger; + *shared.translation_trigger.write() = translation_trigger; + shared + .qa_trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); + shared + .translation_trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); +} + +fn reset_shared_held_state(shared: &Shared) { + shared + .trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); + shared + .qa_trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); + shared + .translation_trigger_held + .store(false, std::sync::atomic::Ordering::SeqCst); + shared + .translation_modifier_held + .store(false, std::sync::atomic::Ordering::SeqCst); +} + // ─────────────────────────── macOS implementation ─────────────────────────── #[cfg(target_os = "macos")] @@ -148,8 +233,9 @@ mod platform { use std::sync::Arc; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + install_error, reset_shared_held_state, send_or_log, start_listener_thread, + update_shared_binding, update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, + Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -182,6 +268,18 @@ mod platform { fn update_binding(&self, binding: HotkeyBinding) { update_shared_binding(&self.shared, binding); } + + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); + } + + fn reset_held_state(&self) { + reset_shared_held_state(&self.shared); + } } // ── Raw CG/CF FFI ────────────────────────────────────────────────────── @@ -360,7 +458,27 @@ mod platform { } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + handle_optional_modifier_trigger( + ctx, + keycode, + flags, + *ctx.shared.qa_trigger.read(), + &ctx.shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + ctx, + keycode, + flags, + *ctx.shared.translation_trigger.read(), + &ctx.shared.translation_trigger_held, + HotkeyEvent::TranslationModifierPressed, + ); + let trigger = ctx.shared.binding.read().trigger; + if trigger == HotkeyTrigger::Custom { + return; + } let expected_keycode = trigger_to_keycode(trigger); if keycode != expected_keycode { return; @@ -378,6 +496,30 @@ mod platform { } } + fn handle_optional_modifier_trigger( + ctx: &CallbackContext, + keycode: i64, + flags: CgEventFlags, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + event: HotkeyEvent, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger == HotkeyTrigger::Custom || keycode != trigger_to_keycode(trigger) { + return; + } + let active = (flags & trigger_to_flag_mask(trigger)) != 0; + let was_held = held.load(Ordering::SeqCst); + if active && !was_held { + held.store(true, Ordering::SeqCst); + send_or_log(&ctx.tx, event); + } else if !active && was_held { + held.store(false, Ordering::SeqCst); + } + } + fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == ESC_KEYCODE { @@ -393,6 +535,7 @@ mod platform { HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61, HotkeyTrigger::RightCommand => 54, HotkeyTrigger::Fn => 63, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } @@ -404,6 +547,7 @@ mod platform { FLAG_MASK_ALTERNATE } HotkeyTrigger::Fn => FLAG_MASK_SECONDARY_FN, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } } @@ -426,8 +570,9 @@ mod platform { }; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + install_error, reset_shared_held_state, send_or_log, start_listener_thread, + update_shared_binding, update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, + Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -480,6 +625,18 @@ mod platform { update_shared_binding(&self.shared, binding); } + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); + } + + fn reset_held_state(&self) { + reset_shared_held_state(&self.shared); + } + fn shutdown(&self) { unsafe { if let Err(err) = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) @@ -606,7 +763,27 @@ mod platform { return false; } + handle_optional_modifier_trigger( + ctx, + vk_code, + message, + *ctx.shared.qa_trigger.read(), + &ctx.shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_trigger( + ctx, + vk_code, + message, + *ctx.shared.translation_trigger.read(), + &ctx.shared.translation_trigger_held, + HotkeyEvent::TranslationModifierPressed, + ); + let trigger = ctx.shared.binding.read().trigger; + if trigger == HotkeyTrigger::Custom { + return false; + } if vk_code != trigger_to_vk_code(trigger) { return false; } @@ -631,6 +808,34 @@ mod platform { true } + fn handle_optional_modifier_trigger( + ctx: &CallbackContext, + vk_code: u32, + message: usize, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + event: HotkeyEvent, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger == HotkeyTrigger::Custom || vk_code != trigger_to_vk_code(trigger) { + return; + } + match message { + WM_KEYDOWN | WM_SYSKEYDOWN => { + let was_held = held.swap(true, Ordering::SeqCst); + if !was_held { + send_or_log(&ctx.tx, event); + } + } + WM_KEYUP | WM_SYSKEYUP => { + held.store(false, Ordering::SeqCst); + } + _ => {} + } + } + fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 { // Windows only gives us a small set of modifier virtual keys that can be // used as reliable modifier-only global triggers, so the cross-platform @@ -645,6 +850,7 @@ mod platform { HotkeyTrigger::RightCommand => VK_RWIN, HotkeyTrigger::LeftOption => VK_RMENU, HotkeyTrigger::Fn => VK_RCONTROL, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } @@ -665,8 +871,8 @@ mod platform { use rdev::{listen, Event, EventType, Key}; use super::{ - install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, - Shared, StartupTx, + install_error, reset_shared_held_state, start_listener_thread, update_shared_binding, + update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; @@ -705,6 +911,18 @@ mod platform { fn update_binding(&self, binding: HotkeyBinding) { update_shared_binding(&self.shared, binding); } + + fn update_modifier_shortcuts( + &self, + qa_trigger: Option, + translation_trigger: Option, + ) { + update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); + } + + fn reset_held_state(&self) { + reset_shared_held_state(&self.shared); + } } fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { @@ -750,6 +968,25 @@ mod platform { } return; } + handle_optional_modifier_press( + shared, + tx, + key, + *shared.qa_trigger.read(), + &shared.qa_trigger_held, + HotkeyEvent::QaShortcutPressed, + ); + handle_optional_modifier_press( + shared, + tx, + key, + *shared.translation_trigger.read(), + &shared.translation_trigger_held, + HotkeyEvent::TranslationModifierPressed, + ); + if trigger == HotkeyTrigger::Custom { + return; + } if key == trigger_to_rdev_key(trigger) { let was_held = shared.trigger_held.swap(true, Ordering::SeqCst); if !was_held { @@ -764,6 +1001,21 @@ mod platform { .store(false, Ordering::SeqCst); return; } + handle_optional_modifier_release( + shared, + key, + *shared.qa_trigger.read(), + &shared.qa_trigger_held, + ); + handle_optional_modifier_release( + shared, + key, + *shared.translation_trigger.read(), + &shared.translation_trigger_held, + ); + if trigger == HotkeyTrigger::Custom { + return; + } if key == trigger_to_rdev_key(trigger) { let was_held = shared.trigger_held.swap(false, Ordering::SeqCst); if was_held { @@ -775,6 +1027,40 @@ mod platform { } } + fn handle_optional_modifier_press( + shared: &Shared, + tx: &Sender, + key: Key, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + event: HotkeyEvent, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger == HotkeyTrigger::Custom || key != trigger_to_rdev_key(trigger) { + return; + } + let was_held = held.swap(true, Ordering::SeqCst); + if !was_held { + let _ = tx.send(event); + } + } + + fn handle_optional_modifier_release( + _shared: &Shared, + key: Key, + trigger: Option, + held: &std::sync::atomic::AtomicBool, + ) { + let Some(trigger) = trigger else { + return; + }; + if trigger != HotkeyTrigger::Custom && key == trigger_to_rdev_key(trigger) { + held.store(false, Ordering::SeqCst); + } + } + fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key { match trigger { HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr, @@ -783,6 +1069,7 @@ mod platform { HotkeyTrigger::LeftControl => Key::ControlLeft, HotkeyTrigger::RightCommand => Key::MetaRight, HotkeyTrigger::Fn => Key::Function, + HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), } } } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 95afe7d0..3c93b55c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -12,8 +12,10 @@ mod asr; mod audio_mute; +mod combo_hotkey; mod commands; mod coordinator; +mod global_hotkey_runtime; mod hotkey; mod insertion; mod permissions; @@ -22,6 +24,7 @@ mod polish; mod qa_hotkey; mod recorder; mod selection; +mod shortcut_binding; mod types; mod windows_ime_ipc; mod windows_ime_profile; @@ -32,13 +35,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::sync::mpsc; use std::sync::Arc; -#[cfg(target_os = "macos")] use std::time::Duration; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); -use tauri::menu::{MenuBuilder, MenuItemBuilder}; +static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false); +use tauri::menu::{ + CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder, +}; use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Runtime}; @@ -72,6 +77,8 @@ pub fn run() { )) .manage(coordinator.clone()) .manage(local_asr_download_manager.clone()) + .manage(commands::MicrophoneMonitorState::new(None)) + .manage(commands::TrayMicrophoneMenuState::new(Vec::new())) .setup(move |app| { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 @@ -148,33 +155,45 @@ pub fn run() { // 菜单栏图标 — 与 Swift `MenuBarController` 同语义: // 左键点 → 显示/聚焦主窗口;菜单含「显示主窗口」「退出」。 - let toggle = MenuItemBuilder::with_id("toggle", "显示主窗口").build(app)?; - let quit = MenuItemBuilder::with_id("quit", "退出 OpenLess").build(app)?; - let menu = MenuBuilder::new(app).items(&[&toggle, &quit]).build()?; + let tray_menu = build_tray_menu(app, &coordinator)?; + let menu = tray_menu.menu; // 与 Swift `StatusBarIcon.swift` 行为一致:用全彩 AppIcon,**不**走 template 模式 // (走 template 会被 macOS 染成单色 → 看起来像个黑方块)。 if let Some(icon) = app.default_window_icon() { + { + let state = app.state::(); + *state.lock() = tray_menu.microphone_items; + } let _tray = TrayIconBuilder::with_id("main-tray") .icon(icon.clone()) .icon_as_template(false) .menu(&menu) .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { + .on_menu_event(move |app, event| match event.id.as_ref() { "toggle" => show_main_window(app), "quit" => app.exit(0), - _ => {} + id => handle_microphone_tray_menu_event( + app, + id, + ), }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - .. - } = event - { - show_main_window(tray.app_handle()); + .on_tray_icon_event(move |tray, event| { + match event { + TrayIconEvent::Enter { .. } => { + if let Err(err) = refresh_tray_microphone_menu(tray.app_handle()) { + log::warn!("[tray] refresh microphone menu on hover failed: {err}"); + } + } + TrayIconEvent::Click { + button: MouseButton::Left, + .. + } => show_main_window(tray.app_handle()), + _ => {} } }) .build(app)?; + start_tray_microphone_watcher(app.handle().clone()); } else { log::warn!("[startup] default window icon missing; tray icon disabled"); } @@ -183,8 +202,8 @@ pub fn run() { let app_handle = app.handle().clone(); coordinator.bind_app(app_handle); coordinator.start_hotkey_listener(); - // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 - coordinator.start_qa_hotkey_listener(); + // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). + // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { show_main_window(app.handle()); } @@ -196,7 +215,11 @@ pub fn run() { commands::set_settings, commands::get_hotkey_status, commands::get_hotkey_capability, + commands::set_shortcut_recording_active, commands::get_windows_ime_status, + commands::list_microphone_devices, + commands::start_microphone_level_monitor, + commands::stop_microphone_level_monitor, commands::get_credentials, commands::set_credential, commands::list_history, @@ -228,8 +251,15 @@ pub fn run() { commands::set_active_llm_provider, commands::get_qa_hotkey_label, commands::set_qa_hotkey, + commands::validate_shortcut_binding, + commands::set_dictation_hotkey, + commands::set_translation_hotkey, + commands::set_switch_style_hotkey, + commands::set_open_app_hotkey, commands::qa_window_dismiss, commands::qa_window_pin, + commands::validate_combo_hotkey, + commands::set_combo_hotkey, commands::validate_provider_credentials, commands::list_provider_models, commands::local_asr_get_settings, @@ -251,6 +281,16 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| match event { + RunEvent::Ready => { + let coordinator = app.state::>(); + // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 + coordinator.start_qa_hotkey_listener(); + // 启动自定义组合键监听器。当 trigger == Custom 时替代 modifier-only 监听器。 + coordinator.start_combo_hotkey_listener(); + coordinator.start_translation_hotkey_listener(); + coordinator.start_switch_style_hotkey_listener(); + coordinator.start_open_app_hotkey_listener(); + } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), RunEvent::WindowEvent { label, event, .. } => { @@ -272,14 +312,181 @@ pub fn run() { } } RunEvent::Exit => { + TRAY_MICROPHONE_WATCHER_STOPPING.store(true, Ordering::Relaxed); let coordinator = app.state::>(); coordinator.stop_hotkey_listener(); coordinator.stop_qa_hotkey_listener(); + coordinator.stop_combo_hotkey_listener(); + coordinator.stop_translation_hotkey_listener(); + coordinator.stop_switch_style_hotkey_listener(); + coordinator.stop_open_app_hotkey_listener(); } _ => {} }); } +struct MicrophoneTrayMenu { + submenu: Submenu, + items: Vec, +} + +struct TrayMenu { + menu: Menu, + microphone_items: Vec, +} + +fn build_tray_menu>( + app: &M, + coordinator: &Arc, +) -> tauri::Result { + let toggle = MenuItemBuilder::with_id("toggle", "显示主窗口").build(app)?; + let microphone_menu = build_microphone_tray_menu(app, coordinator)?; + let quit = MenuItemBuilder::with_id("quit", "退出 OpenLess").build(app)?; + let menu = MenuBuilder::new(app) + .items(&[&toggle, µphone_menu.submenu, &quit]) + .build()?; + Ok(TrayMenu { + menu, + microphone_items: microphone_menu.items, + }) +} + +fn build_microphone_tray_menu>( + app: &M, + coordinator: &Arc, +) -> tauri::Result { + let selected = coordinator.prefs().get().microphone_device_name; + let mut items = Vec::new(); + let mut submenu = SubmenuBuilder::with_id(app, "microphone", "选择麦克风"); + let devices = match recorder::list_input_devices() { + Ok(devices) => devices, + Err(err) => { + log::warn!("[tray] list microphone devices failed: {err}"); + Vec::new() + } + }; + let selected_available = selected.trim().is_empty() + || devices.iter().any(|device| device.name == selected); + + let default_item = CheckMenuItemBuilder::with_id("mic-default", "系统默认麦克风") + .checked(selected.trim().is_empty() || !selected_available) + .build(app)?; + submenu = submenu.item(&default_item); + items.push(commands::TrayMicrophoneMenuItem { + id: "mic-default".to_string(), + device_name: String::new(), + item: default_item, + }); + + if devices.is_empty() { + let empty = MenuItemBuilder::with_id("mic-empty", "未发现麦克风") + .enabled(false) + .build(app)?; + submenu = submenu.item(&empty); + } else { + for (index, device) in devices.into_iter().enumerate() { + let id = format!("mic-device-{index}"); + let label = if device.is_default { + format!("{}(系统默认)", device.name) + } else { + device.name.clone() + }; + let item = CheckMenuItemBuilder::with_id(&id, label) + .checked(selected == device.name) + .build(app)?; + submenu = submenu.item(&item); + items.push(commands::TrayMicrophoneMenuItem { + id, + device_name: device.name, + item, + }); + } + } + + Ok(MicrophoneTrayMenu { + submenu: submenu.build()?, + items, + }) +} + +pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> { + let coordinator = app.state::>(); + let tray_menu = build_tray_menu(app, &coordinator)?; + if let Some(tray) = app.tray_by_id("main-tray") { + tray.set_menu(Some(tray_menu.menu))?; + } + let state = app.state::(); + *state.lock() = tray_menu.microphone_items; + Ok(()) +} + +fn microphone_device_signature() -> Option> { + match recorder::list_input_devices() { + Ok(devices) => Some( + devices + .into_iter() + .map(|device| (device.name, device.is_default)) + .collect(), + ), + Err(err) => { + log::warn!("[tray] watch microphone devices failed: {err}"); + None + } + } +} + +fn start_tray_microphone_watcher(app: AppHandle) { + TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed); + if let Err(err) = std::thread::Builder::new() + .name("openless-tray-mic-watch".into()) + .spawn(move || { + let mut last_signature = microphone_device_signature(); + while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1500)); + if TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + break; + } + let signature = microphone_device_signature(); + if signature == last_signature { + continue; + } + last_signature = signature; + let app = app.clone(); + let refresh_app = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = refresh_tray_microphone_menu(&refresh_app) { + log::warn!("[tray] refresh microphone menu after device change failed: {err}"); + } + let _ = refresh_app.emit("microphone:devices-changed", serde_json::json!({})); + }); + } + }) { + log::warn!("[tray] start microphone watcher failed: {err}"); + } +} + +fn handle_microphone_tray_menu_event( + app: &AppHandle, + id: &str, +) { + let tray_items = app.state::(); + let items = tray_items.lock(); + let Some(selected) = items.iter().find(|item| item.id == id) else { + return; + }; + + let coord = app.state::>(); + let mut prefs = coord.prefs().get(); + prefs.microphone_device_name = selected.device_name.clone(); + if let Err(err) = coord.prefs().set(prefs.clone()) { + log::warn!("[tray] save microphone preference failed: {err}"); + return; + } + let _ = app.emit("prefs:changed", &prefs); + + commands::sync_tray_microphone_selection(&items, &selected.device_name); +} + #[cfg(target_os = "windows")] fn apply_windows_rounded_frame(window: &tauri::WebviewWindow) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -770,8 +977,12 @@ struct CapsuleWindowBounds { fn capsule_window_bounds(translation_active: bool) -> CapsuleWindowBounds { #[cfg(target_os = "windows")] { + const WINDOWS_CAPSULE_PILL_WIDTH: f64 = 196.0; + const WINDOWS_CAPSULE_SIDE_INSET: f64 = 12.0; CapsuleWindowBounds { - width: 220.0, + // Keep the existing Windows hitbox width, but express it as + // pill width (196) + symmetric 12px side insets for shadow room. + width: WINDOWS_CAPSULE_PILL_WIDTH + WINDOWS_CAPSULE_SIDE_INSET * 2.0, height: if translation_active { 118.0 } else { 84.0 }, bottom_inset: 12.0, } diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index f5951e97..a4b41ac1 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1,16 +1,15 @@ //! Local persistence: history JSON, user preferences JSON, vocab JSON, and -//! Keychain-backed credentials vault. +//! platform-backed credentials vault. //! //! Storage roots: //! - macOS: `~/Library/Application Support/OpenLess` //! - Windows: `%APPDATA%\OpenLess` //! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess` //! -//! Divergence from Swift: the Swift `CredentialsVault` falls back to a JSON -//! file (`~/.openless/credentials.json`) when Keychain is unavailable. The -//! Rust port intentionally does NOT replicate that fallback — we rely solely -//! on the platform keyring. The macOS service name (`com.openless.app`) is -//! preserved so existing Keychain entries from the Swift app remain readable. +//! Credential storage policy: provider credentials are stored in the OS +//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring). +//! A legacy plaintext JSON file is read once as a migration source and removed +//! after a successful vault write; new writes never persist plaintext secrets. use std::fs; use std::path::{Path, PathBuf}; @@ -32,12 +31,16 @@ const PREFERENCES_FILE: &str = "preferences.json"; const VOCAB_FILE: &str = "dictionary.json"; const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; -/// Swift 老 `CredentialsVault` 的 JSON 备用路径。 -/// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件, -/// 让用户在 Swift 版填过的凭据无需重输。 +/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。 const LEGACY_CREDS_DIR: &str = ".openless"; const LEGACY_CREDS_FILE: &str = "credentials.json"; +const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; +const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; +// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores +// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. +const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; + static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); fn credentials_lock() -> &'static Mutex<()> { @@ -115,12 +118,11 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul .with_context(|| format!("decode failed: {}", path.display())) } -// ───────────────────────── credentials JSON store ───────────────────────── +// ───────────────────────── credentials vault ───────────────────────── // -// 与 Swift `Sources/OpenLessPersistence/CredentialsVault.swift` 同源——纯 JSON 文件, -// 路径 `~/.openless/credentials.json`,权限 0600。**故意不用 Keychain**: -// ad-hoc 签名每次构建 hash 都变,Keychain ACL 失效后会触发逐账号弹框;用户已明确 -// 选择"直接写本地文件"。 +// 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider +// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload;payload 会按平台 +// 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。 // // v1 schema: // { @@ -262,56 +264,311 @@ fn credentials_path() -> Result { } } -fn ensure_credentials_dir(path: &Path) -> Result<()> { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir).with_context(|| format!("create dir {} failed", dir.display()))?; - // 0700 on parent so other users can't peek - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); +fn keyring_entry() -> Result { + keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) +} + +fn keyring_entry_for(account: &str) -> Result { + keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) + .context("open system credential vault") +} + +fn clean_credentials(root: &CredsRoot) -> CredsRoot { + let mut cleaned = root.clone(); + cleaned.providers.asr.retain(|_, v| !v.is_empty()); + cleaned.providers.llm.retain(|_, v| !v.is_empty()); + cleaned +} + +fn read_legacy_credentials_file(path: &Path) -> Option { + if !path.exists() { + return None; + } + let bytes = match fs::read(path) { + Ok(b) => b, + Err(e) => { + log::warn!("[vault] read legacy {} failed: {}", path.display(), e); + return None; + } + }; + match serde_json::from_slice::(&bytes) { + Ok(root) => Some(root), + Err(e) => { + log::warn!("[vault] parse legacy {} failed: {}", path.display(), e); + None } } - Ok(()) } -fn load_credentials() -> CredsRoot { - let path = match credentials_path() { - Ok(p) => p, - Err(_) => return CredsRoot::default(), +fn remove_legacy_credentials_file() -> Result<()> { + let Ok(path) = credentials_path() else { + return Ok(()); }; - if !path.exists() { - return CredsRoot::default(); + if path.exists() { + fs::remove_file(&path) + .with_context(|| format!("remove legacy credentials file {}", path.display()))?; } - let bytes = match fs::read(&path) { - Ok(b) => b, + Ok(()) +} + +fn remove_legacy_credentials_file_best_effort() { + if let Err(e) = remove_legacy_credentials_file() { + log::warn!("[vault] remove legacy credentials file failed: {e}"); + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct CredsChunkManifest { + openless_credentials_storage: String, + version: u32, + /// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀, + /// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。 + /// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。 + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, + chunks: usize, +} + +/// 旧版(generation=Some):`credentials.v1.chunk..{index}` +/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效 +fn chunk_account(generation: Option<&str>, index: usize) -> String { + match generation { + Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"), + None => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}"), + } +} + +fn chunk_json_payload(json: &str) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_units = 0usize; + for ch in json.chars() { + let units = ch.len_utf16(); + if !current.is_empty() && current_units + units > KEYRING_CHUNK_MAX_UTF16_UNITS { + chunks.push(std::mem::take(&mut current)); + current_units = 0; + } + current.push(ch); + current_units += units; + } + if !current.is_empty() || json.is_empty() { + chunks.push(current); + } + chunks +} + +fn read_chunk_manifest(json: &str) -> Option { + let manifest = serde_json::from_str::(json).ok()?; + if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 { + Some(manifest) + } else { + None + } +} + +fn get_keyring_password(account: &str) -> Result> { + match keyring_entry_for(account)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), Err(e) => { - log::warn!("[vault] read {} failed: {}", path.display(), e); - return CredsRoot::default(); + Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}")) } + } +} + +fn delete_keyring_password(account: &str) { + match keyring_entry_for(account).and_then(|entry| { + entry + .delete_credential() + .with_context(|| format!("delete system credential vault {account}")) + }) { + Ok(()) | Err(_) => {} + } +} + +fn load_keyring_credentials() -> Result> { + let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { + return Ok(None); }; - serde_json::from_slice::(&bytes).unwrap_or_else(|e| { - log::warn!("[vault] parse {} failed: {}", path.display(), e); - CredsRoot::default() - }) + + let manifest = read_chunk_manifest(&json_or_manifest) + .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; + let mut json = String::new(); + for index in 0..manifest.chunks { + let account = chunk_account(manifest.generation.as_deref(), index); + let chunk = get_keyring_password(&account)? + .ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?; + json.push_str(&chunk); + } + + serde_json::from_str::(&json) + .map(Some) + .context("decode system credential vault payload") +} + +fn load_legacy_keyring_credentials() -> CredsRoot { + match load_legacy_keyring_credentials_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] read legacy vault credentials failed: {e}"); + CredsRoot::default() + } + } +} + +fn load_legacy_keyring_credentials_for_update() -> Result { + let mut root = CredsRoot::default(); + for account in CredentialAccount::all() { + let legacy_account = account.keyring_account(); + match get_keyring_password(legacy_account) { + Ok(Some(value)) => write_account(&mut root, *account, Some(value)), + Ok(None) => {} + Err(e) => return Err(e.context(format!("read legacy vault {legacy_account}"))), + } + } + Ok(clean_credentials(&root)) +} + +fn remove_legacy_keyring_credentials() { + for account in CredentialAccount::all() { + delete_keyring_password(account.keyring_account()); + } +} + +fn load_legacy_credentials() -> Option { + credentials_path() + .ok() + .and_then(|p| read_legacy_credentials_file(&p)) +} + +fn legacy_vault_has_credentials(root: &CredsRoot) -> bool { + !root.providers.asr.is_empty() || !root.providers.llm.is_empty() +} + +fn load_legacy_sources_without_migration() -> CredsRoot { + if let Some(legacy) = load_legacy_credentials() { + return legacy; + } + + let legacy_vault = load_legacy_keyring_credentials(); + if legacy_vault_has_credentials(&legacy_vault) { + return legacy_vault; + } + + CredsRoot::default() +} + +fn migrate_legacy_sources() -> CredsRoot { + match migrate_legacy_sources_for_update() { + Ok(root) => root, + Err(e) => { + log::warn!("[vault] legacy credential migration failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn migrate_legacy_sources_for_update() -> Result { + if let Some(legacy) = load_legacy_credentials() { + save_credentials(&legacy)?; + remove_legacy_keyring_credentials(); + return Ok(legacy); + } + + let legacy_vault = load_legacy_keyring_credentials_for_update()?; + if legacy_vault_has_credentials(&legacy_vault) { + save_credentials(&legacy_vault)?; + remove_legacy_keyring_credentials(); + return Ok(legacy_vault); + } + + Ok(CredsRoot::default()) +} + +fn load_credentials() -> CredsRoot { + match load_keyring_credentials() { + Ok(Some(root)) => { + // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对 9 个 + // 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain + // 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经 + // 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑, + // 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext + // JSON)不需要 ACL,可继续 best-effort 删除。 + remove_legacy_credentials_file_best_effort(); + root + } + Ok(None) => migrate_legacy_sources(), + Err(e) => { + log::warn!("[vault] system credential read failed: {e}"); + load_legacy_sources_without_migration() + } + } +} + +fn load_credentials_for_update() -> Result { + match load_keyring_credentials() { + Ok(Some(root)) => { + // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring + // entries,避免反复触发 macOS Keychain ACL 弹窗。 + remove_legacy_credentials_file_best_effort(); + Ok(root) + } + Ok(None) => migrate_legacy_sources_for_update(), + Err(e) => Err(e), + } } fn save_credentials(root: &CredsRoot) -> Result<()> { - let path = credentials_path()?; - ensure_credentials_dir(&path)?; - // 写盘前过滤掉空 entry,保持 JSON 干净(mirrors Swift cleanedSchema)。 - let mut cleaned = root.clone(); - cleaned.providers.asr.retain(|_, v| !v.is_empty()); - cleaned.providers.llm.retain(|_, v| !v.is_empty()); - let json = serde_json::to_vec_pretty(&cleaned).context("encode credentials failed")?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, &json).with_context(|| format!("write {} failed", tmp.display()))?; - fs::rename(&tmp, &path).with_context(|| format!("rename to {} failed", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + .ok() + .flatten() + .and_then(|value| read_chunk_manifest(&value)); + let chunks = chunk_json_payload(&json); + + // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 + // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 + // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation + // 设计的回退)。 + for (index, chunk) in chunks.iter().enumerate() { + let account = chunk_account(None, index); + keyring_entry_for(&account)? + .set_password(chunk) + .with_context(|| format!("write system credential vault chunk {index}"))?; } + + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".to_string(), + version: 1, + generation: None, + chunks: chunks.len(), + }; + let manifest_json = + serde_json::to_string(&manifest).context("encode credential manifest failed")?; + keyring_entry()? + .set_password(&manifest_json) + .context("write system credential vault manifest")?; + + // 清理旧 chunks: + // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) + // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx + if let Some(previous) = previous_manifest { + match previous.generation.as_deref() { + Some(prev_gen) => { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(Some(prev_gen), index)); + } + } + None => { + for index in chunks.len()..previous.chunks { + delete_keyring_password(&chunk_account(None, index)); + } + } + } + } + + remove_legacy_credentials_file_best_effort(); Ok(()) } @@ -450,7 +707,14 @@ impl PreferencesStore { ensure_dir(&dir)?; let path = dir.join(PREFERENCES_FILE); let prefs = if path.exists() { - read_or_default::(&path).unwrap_or_default() + read_or_default::(&path).unwrap_or_else(|e| { + log::warn!( + "[prefs] load {} failed, using defaults: {}", + path.display(), + e + ); + UserPreferences::default() + }) } else { UserPreferences::default() }; @@ -682,12 +946,11 @@ pub struct CredentialsSnapshot { pub ark_endpoint: Option, } -/// 凭据存储——纯 JSON 文件,**不**走 Keychain。详见文件头部注释。 +/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。 pub struct CredentialsVault; impl CredentialsVault { - /// 历史保留:Swift 时代以此名作为 Keychain service。Rust 不再使用 Keychain, - /// 但暴露此常量给可能仍依赖它的代码点。 + /// 系统凭据库 service name;macOS 下对应 Keychain service。 pub const SERVICE_NAME: &'static str = "com.openless.app"; pub fn get(account: CredentialAccount) -> Result> { @@ -697,7 +960,7 @@ impl CredentialsVault { pub fn set(account: CredentialAccount, value: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; let v = if value.is_empty() { None } else { @@ -709,7 +972,7 @@ impl CredentialsVault { pub fn remove(account: CredentialAccount) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; write_account(&mut root, account, None); save_credentials(&root) } @@ -721,14 +984,14 @@ impl CredentialsVault { pub fn set_active_asr_provider(id: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; root.active.asr = id.to_string(); save_credentials(&root) } pub fn set_active_llm_provider(id: &str) -> Result<()> { let _guard = credentials_lock().lock(); - let mut root = load_credentials(); + let mut root = load_credentials_for_update()?; root.active.llm = id.to_string(); save_credentials(&root) } @@ -757,14 +1020,33 @@ impl CredentialsVault { #[cfg(test)] mod tests { - use super::{list_vocab_presets, save_vocab_presets}; + use super::{ + chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS, + }; use crate::types::{VocabPreset, VocabPresetStore}; use std::fs; use std::path::PathBuf; + #[test] + fn credential_payload_chunks_stay_under_windows_blob_limit() { + let payload = format!( + "{}{}{}", + "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25), + "😀".repeat(20), + "b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25) + ); + let chunks = chunk_json_payload(&payload); + assert!(chunks.len() > 1); + assert_eq!(chunks.concat(), payload); + assert!(chunks + .iter() + .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); + } + #[test] fn vocab_presets_roundtrip_json_file() { - let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&tmp).expect("create temp dir"); // Linux path helper uses XDG_DATA_HOME first. unsafe { @@ -783,7 +1065,10 @@ mod tests { let loaded = list_vocab_presets().expect("list presets"); assert_eq!(loaded.custom.len(), 1); assert_eq!(loaded.custom[0].id, "test"); - assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]); + assert_eq!( + loaded.custom[0].phrases, + vec!["PR".to_string(), "CI".to_string()] + ); assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); let _ = fs::remove_dir_all(&tmp); } diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index 4740a012..28635587 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -7,16 +7,17 @@ //! 仅产出 `QaHotkeyEvent::Pressed` 边沿事件;toggle / 录音生命周期由 //! coordinator 解释(第一次按 → 开始问答;第二次按 → 结束)。 //! -//! 模块依赖:仅 `types`,与 CLAUDE.md "Rust 模块依赖只通过 types.rs 跨模块" 一致。 +//! 通过 `global_hotkey_runtime` 共享进程级 manager / event receiver。 -use std::sync::mpsc::Sender; +use std::sync::mpsc::{Receiver, Sender}; use std::sync::Arc; -use global_hotkey::hotkey::{Code, HotKey, Modifiers}; -use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; use parking_lot::Mutex; -use crate::types::QaHotkeyBinding; +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 QaHotkeyEvent { @@ -44,13 +45,9 @@ pub struct QaHotkeyMonitor { } struct Inner { - manager: GlobalHotKeyManager, /// 当前注册的 hotkey 句柄;用于 unregister。 - registered: Mutex>, - /// 事件转发线程接收 global-hotkey crate 的全局 channel,再过滤 id 后转发到 tx。 - forward_alive: Arc, - /// 当前关心的 hotkey id(filter 用)。 - active_id: Arc, + registered: Mutex>, + tx: Sender, } // global-hotkey 0.6 的 GlobalHotKeyManager 在 Windows 内部持有 HHOOK / window @@ -70,93 +67,71 @@ impl QaHotkeyMonitor { /// `AppHandle::run_on_main_thread` 跳到主线程后再 spawn 这个 monitor)。 /// 本函数不强制断言主线程——单元 / 集成测试也跑不到 manager 创建那一行。 pub fn start( - binding: QaHotkeyBinding, + binding: ShortcutBinding, tx: Sender, ) -> Result { - let manager = GlobalHotKeyManager::new() + let runtime = GlobalHotkeyRuntime::shared() .map_err(|e| QaHotkeyError::ManagerInitFailed(e.to_string()))?; let hotkey = parse_binding(&binding)?; - manager + let (registered, rx) = runtime .register(hotkey) .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; - let active_id = Arc::new(std::sync::atomic::AtomicU32::new(hotkey.id())); - let forward_alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); - - // 启动转发线程:消费 global-hotkey 的进程级 channel,filter id 后投递到上层 tx。 - // global-hotkey 用 crossbeam_channel,自带超时 recv,便于优雅退出。 - let alive_for_thread = Arc::clone(&forward_alive); - let id_for_thread = Arc::clone(&active_id); + // 启动转发线程:runtime 已按 hotkey id 分发;这里保留 id 检查作为防线, + // 避免未来误接回进程级事件流后串到其他快捷键。 + let hotkey_id = registered.hotkey().id(); + let tx_for_thread = tx.clone(); std::thread::Builder::new() .name("openless-qa-hotkey-forward".into()) - .spawn(move || forward_loop(alive_for_thread, id_for_thread, tx)) + .spawn(move || forward_loop(hotkey_id, rx, tx_for_thread)) .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; Ok(Self { inner: Arc::new(Inner { - manager, - registered: Mutex::new(Some(hotkey)), - forward_alive, - active_id, + registered: Mutex::new(Some(registered)), + tx, }), }) } /// 替换当前注册的 hotkey(用户在设置里改了组合键时)。 - pub fn update_binding(&self, binding: QaHotkeyBinding) -> Result<(), QaHotkeyError> { + pub fn update_binding(&self, binding: ShortcutBinding) -> Result<(), QaHotkeyError> { let next = parse_binding(&binding)?; let mut current = self.inner.registered.lock(); - if let Some(prev) = current.take() { - if prev == next { - *current = Some(prev); + if let Some(prev) = current.as_ref() { + if prev.hotkey() == next { return Ok(()); } - if let Err(e) = self.inner.manager.unregister(prev) { - log::warn!("[qa-hotkey] unregister 旧绑定失败: {e}"); - } } - self.inner - .manager + let runtime = GlobalHotkeyRuntime::shared() + .map_err(|e| QaHotkeyError::ManagerInitFailed(e.to_string()))?; + let (registered, rx) = runtime .register(next) .map_err(|e| QaHotkeyError::RegisterFailed(e.to_string()))?; - *current = Some(next); - self.inner - .active_id - .store(next.id(), std::sync::atomic::Ordering::SeqCst); + let hotkey_id = registered.hotkey().id(); + // Keep event forwarding alive for the replacement registration. + std::thread::Builder::new() + .name("openless-qa-hotkey-forward".into()) + .spawn({ + let tx = self.inner.tx.clone(); + move || forward_loop(hotkey_id, rx, tx) + }) + .map_err(|e| QaHotkeyError::RegisterFailed(format!("spawn forward thread: {e}")))?; + *current = Some(registered); Ok(()) } } impl Drop for QaHotkeyMonitor { fn drop(&mut self) { - // 通知转发线程退出;超时 recv 后自然结束。 - self.inner - .forward_alive - .store(false, std::sync::atomic::Ordering::SeqCst); - if let Some(prev) = self.inner.registered.lock().take() { - if let Err(e) = self.inner.manager.unregister(prev) { - log::warn!("[qa-hotkey] drop 时 unregister 失败: {e}"); - } - } + self.inner.registered.lock().take(); } } -fn forward_loop( - alive: Arc, - active_id: Arc, - tx: Sender, -) { - // global-hotkey crate 用 crossbeam_channel;其 receiver 没暴露 RecvTimeoutError 给外部, - // 所以不区分 timeout vs disconnect,统一 250ms tick 重新 check alive 标志。 - let receiver = GlobalHotKeyEvent::receiver(); - while alive.load(std::sync::atomic::Ordering::SeqCst) { - let event = match receiver.recv_timeout(std::time::Duration::from_millis(250)) { - Ok(e) => e, - Err(_) => continue, - }; - let want = active_id.load(std::sync::atomic::Ordering::SeqCst); - if event.id() != want { +fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender) { + while let Ok(event) = rx.recv() { + if event.id() != hotkey_id { continue; } if !matches!(event.state(), HotKeyState::Pressed) { @@ -170,146 +145,23 @@ fn forward_loop( log::info!("[qa-hotkey] 转发线程退出"); } -fn parse_binding(binding: &QaHotkeyBinding) -> Result { - let mut mods = Modifiers::empty(); - for raw in &binding.modifiers { - let tag = normalize_modifier_tag(raw); - let bit = match tag.as_str() { - "cmd" | "command" | "super" | "meta" | "win" => Modifiers::SUPER, - "ctrl" | "control" => Modifiers::CONTROL, - "alt" | "option" | "opt" => Modifiers::ALT, - "shift" => Modifiers::SHIFT, - other => return Err(QaHotkeyError::UnsupportedModifier(other.to_string())), - }; - mods |= bit; - } - let code = parse_primary(&binding.primary)?; - Ok(HotKey::new(Some(mods), code)) -} - -fn normalize_modifier_tag(raw: &str) -> String { - let tag = raw.trim().to_ascii_lowercase(); - #[cfg(target_os = "windows")] - { - if matches!(tag.as_str(), "cmd" | "command") { - return "ctrl".to_string(); - } - } - tag -} - -/// 把用户配置的主键字符串解析成 keyboard_types::Code。 -/// 支持单字符(字母 / 数字 / 符号)+ 常见命名键(F1..F12 / Enter / Tab / Escape / Space)。 -fn parse_primary(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(QaHotkeyError::UnsupportedKey("(空)".into())); - } - // 单字符 - if trimmed.chars().count() == 1 { - let ch = trimmed.chars().next().unwrap(); - if let Some(code) = char_to_code(ch) { - return Ok(code); - } - } - // 命名键 - let upper = trimmed.to_ascii_uppercase(); - let named = match upper.as_str() { - "ENTER" | "RETURN" => Code::Enter, - "TAB" => Code::Tab, - "ESC" | "ESCAPE" => Code::Escape, - "SPACE" => Code::Space, - "BACKSPACE" => Code::Backspace, - "DELETE" | "DEL" => Code::Delete, - "HOME" => Code::Home, - "END" => Code::End, - "PAGEUP" => Code::PageUp, - "PAGEDOWN" => Code::PageDown, - "ARROWUP" | "UP" => Code::ArrowUp, - "ARROWDOWN" | "DOWN" => Code::ArrowDown, - "ARROWLEFT" | "LEFT" => Code::ArrowLeft, - "ARROWRIGHT" | "RIGHT" => Code::ArrowRight, - "F1" => Code::F1, - "F2" => Code::F2, - "F3" => Code::F3, - "F4" => Code::F4, - "F5" => Code::F5, - "F6" => Code::F6, - "F7" => Code::F7, - "F8" => Code::F8, - "F9" => Code::F9, - "F10" => Code::F10, - "F11" => Code::F11, - "F12" => Code::F12, - _ => return Err(QaHotkeyError::UnsupportedKey(trimmed.to_string())), - }; - Ok(named) -} - -fn char_to_code(ch: char) -> Option { - let c = ch.to_ascii_uppercase(); - let code = match c { - 'A' => Code::KeyA, - 'B' => Code::KeyB, - 'C' => Code::KeyC, - 'D' => Code::KeyD, - 'E' => Code::KeyE, - 'F' => Code::KeyF, - 'G' => Code::KeyG, - 'H' => Code::KeyH, - 'I' => Code::KeyI, - 'J' => Code::KeyJ, - 'K' => Code::KeyK, - 'L' => Code::KeyL, - 'M' => Code::KeyM, - 'N' => Code::KeyN, - 'O' => Code::KeyO, - 'P' => Code::KeyP, - 'Q' => Code::KeyQ, - 'R' => Code::KeyR, - 'S' => Code::KeyS, - 'T' => Code::KeyT, - 'U' => Code::KeyU, - 'V' => Code::KeyV, - 'W' => Code::KeyW, - 'X' => Code::KeyX, - 'Y' => Code::KeyY, - 'Z' => Code::KeyZ, - '0' => Code::Digit0, - '1' => Code::Digit1, - '2' => Code::Digit2, - '3' => Code::Digit3, - '4' => Code::Digit4, - '5' => Code::Digit5, - '6' => Code::Digit6, - '7' => Code::Digit7, - '8' => Code::Digit8, - '9' => Code::Digit9, - ';' => Code::Semicolon, - ':' => Code::Semicolon, - ',' => Code::Comma, - '.' => Code::Period, - '/' => Code::Slash, - '\\' => Code::Backslash, - '[' => Code::BracketLeft, - ']' => Code::BracketRight, - '\'' => Code::Quote, - '`' => Code::Backquote, - '-' => Code::Minus, - '=' => Code::Equal, - ' ' => Code::Space, - _ => return None, - }; - Some(code) +fn parse_binding( + binding: &ShortcutBinding, +) -> Result { + parse_global_hotkey(binding).map_err(|e| match e { + ShortcutBindingError::UnsupportedModifier(m) => QaHotkeyError::UnsupportedModifier(m), + ShortcutBindingError::UnsupportedKey(k) => QaHotkeyError::UnsupportedKey(k), + }) } #[cfg(test)] mod tests { use super::*; + use global_hotkey::hotkey::{Code, Modifiers}; #[test] fn parse_default_binding() { - let binding = QaHotkeyBinding::default(); + let binding = ShortcutBinding::default_qa(); let parsed = parse_binding(&binding).expect("default binding parses"); assert!(parsed.mods.contains(Modifiers::SHIFT)); assert_eq!(parsed.key, Code::Semicolon); @@ -317,7 +169,7 @@ mod tests { #[test] fn parse_letter_binding() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: "k".into(), modifiers: vec!["cmd".into(), "alt".into()], }; @@ -329,7 +181,7 @@ mod tests { #[test] fn unsupported_modifier_rejected() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: ";".into(), modifiers: vec!["hyper".into()], }; @@ -341,7 +193,7 @@ mod tests { #[test] fn empty_primary_rejected() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: "".into(), modifiers: vec!["cmd".into()], }; @@ -353,7 +205,7 @@ mod tests { #[test] fn cmd_modifier_normalizes_per_platform() { - let binding = QaHotkeyBinding { + let binding = ShortcutBinding { primary: ";".into(), modifiers: vec!["cmd".into(), "shift".into()], }; @@ -370,4 +222,35 @@ mod tests { assert!(parsed.mods.contains(Modifiers::SUPER)); } } + + #[test] + fn forward_loop_ignores_unrelated_hotkey_ids() { + let (event_tx, event_rx) = std::sync::mpsc::channel(); + let (out_tx, out_rx) = std::sync::mpsc::channel(); + + event_tx + .send(GlobalHotKeyEvent { + id: 41, + state: HotKeyState::Pressed, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 42, + state: HotKeyState::Released, + }) + .unwrap(); + event_tx + .send(GlobalHotKeyEvent { + id: 42, + state: HotKeyState::Pressed, + }) + .unwrap(); + drop(event_tx); + + forward_loop(42, event_rx, out_tx); + + assert!(matches!(out_rx.recv().unwrap(), QaHotkeyEvent::Pressed)); + assert!(out_rx.try_recv().is_err()); + } } diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 4057abdb..35b44788 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -18,6 +18,7 @@ use std::thread::{self, JoinHandle}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{SampleFormat, StreamConfig}; use parking_lot::Mutex; +use serde::Serialize; use thiserror::Error; /// 目标采样率(与 Swift 端常量一致;不要改)。 @@ -34,6 +35,13 @@ pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MicrophoneDevice { + pub name: String, + pub is_default: bool, +} + /// 采集器错误。 #[derive(Debug, Error)] pub enum RecorderError { @@ -55,6 +63,7 @@ impl Recorder { /// /// 实际的 cpal Stream 在独立线程里构造、播放、最终析构——因为它 `!Send`。 pub fn start( + microphone_device_name: Option, consumer: Arc, level_handler: Arc, ) -> Result<(Self, Receiver), RecorderError> { @@ -69,6 +78,7 @@ impl Recorder { .name("openless-recorder".into()) .spawn(move || { run_audio_thread( + microphone_device_name, consumer, level_handler, stop_for_thread, @@ -107,15 +117,47 @@ impl Recorder { } } +pub fn list_input_devices() -> Result, RecorderError> { + let host = cpal::default_host(); + let default_name = host + .default_input_device() + .and_then(|device| device.name().ok()); + let devices = host + .input_devices() + .map_err(|e| RecorderError::EngineFailed(format!("input_devices: {e}")))?; + + let mut result = Vec::new(); + for device in devices { + let name = match device.name() { + Ok(name) => name, + Err(err) => { + log::warn!("[recorder] failed to read input device name: {err}"); + continue; + } + }; + result.push(MicrophoneDevice { + is_default: default_name.as_deref() == Some(name.as_str()), + name, + }); + } + Ok(result) +} + /// 音频线程主体:构造 Stream → 通过 startup_tx 报告 → 循环到 stop_flag。 fn run_audio_thread( + microphone_device_name: Option, consumer: Arc, level_handler: Arc, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, ) { - let (stream, state) = match build_input_stream(consumer, level_handler, runtime_error_tx.clone()) { + let (stream, state) = match build_input_stream( + microphone_device_name, + consumer, + level_handler, + runtime_error_tx.clone(), + ) { Ok(s) => s, Err(err) => { // 启动失败:通知主线程后即退出。 @@ -133,9 +175,9 @@ fn run_audio_thread( let _ = startup_tx.send(Ok(())); // 启动 liveness watchdog 线程:检测录音回调是否静默停止 - const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次 - const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常 - const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调 + const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次 + const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常 + const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调 let stop_flag_for_watchdog = Arc::clone(&stop_flag); let state_for_watchdog = Arc::clone(&state); @@ -160,10 +202,11 @@ fn run_audio_thread( "[recorder] watchdog: 录音回调已停止 {} 秒,触发错误恢复", elapsed.as_secs() ); - let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( - format!("录音回调静默停止 {} 秒", elapsed.as_secs()) - )); - break; // 只报告一次 + let _ = + runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( + format!("录音回调静默停止 {} 秒", elapsed.as_secs()), + )); + break; // 只报告一次 } } None => { @@ -174,10 +217,11 @@ fn run_audio_thread( "[recorder] watchdog: {} 秒内未收到首次回调,触发错误恢复", elapsed.as_secs() ); - let _ = runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( - format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs()) - )); - break; // 只报告一次 + let _ = + runtime_error_tx_for_watchdog.send(RecorderError::EngineFailed( + format!("录音启动后 {} 秒内未收到回调", elapsed.as_secs()), + )); + break; // 只报告一次 } } } @@ -201,14 +245,13 @@ fn run_audio_thread( /// 选默认输入设备 + 默认配置 + 构造 Stream。 fn build_input_stream( + microphone_device_name: Option, consumer: Arc, level_handler: Arc, runtime_error_tx: Sender, ) -> Result<(cpal::Stream, Arc), RecorderError> { let host = cpal::default_host(); - let device = host - .default_input_device() - .ok_or_else(|| RecorderError::EngineFailed("no default input device".into()))?; + let device = select_input_device(&host, microphone_device_name.as_deref())?; let supported = device .default_input_config() @@ -220,7 +263,8 @@ fn build_input_stream( let channels = config.channels as usize; log::info!( - "[recorder] inputFormat sampleRate={} channels={} fmt={:?}", + "[recorder] inputDevice={} inputFormat sampleRate={} channels={} fmt={:?}", + device.name().unwrap_or_else(|_| "".into()), input_sr, channels, sample_format @@ -241,6 +285,31 @@ fn build_input_stream( Ok((stream, state)) } +fn select_input_device( + host: &cpal::Host, + microphone_device_name: Option<&str>, +) -> Result { + let preferred = microphone_device_name + .map(str::trim) + .filter(|name| !name.is_empty()); + if let Some(preferred) = preferred { + let devices = host + .input_devices() + .map_err(|e| RecorderError::EngineFailed(format!("input_devices: {e}")))?; + for device in devices { + if device.name().ok().as_deref() == Some(preferred) { + return Ok(device); + } + } + log::warn!( + "[recorder] preferred input device not found; falling back to default: {preferred}" + ); + } + + host.default_input_device() + .ok_or_else(|| RecorderError::EngineFailed("no default input device".into())) +} + /// 启动期 default_input_config 失败:依靠错误字符串关键字粗判权限问题。 /// cpal 在 macOS 没拿到 mic 授权时通常返回 `BackendSpecific`,我们尽力识别。 fn classify_default_config_err(msg: String) -> RecorderError { diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs new file mode 100644 index 00000000..731f8532 --- /dev/null +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -0,0 +1,253 @@ +//! Shared parsing/validation for user-configurable shortcut bindings. + +use global_hotkey::hotkey::{Code, HotKey, Modifiers}; + +use crate::types::{HotkeyTrigger, ShortcutBinding}; + +#[derive(Debug, thiserror::Error)] +pub enum ShortcutBindingError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), +} + +pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + if legacy_modifier_trigger(binding).is_some() { + return Ok(()); + } + if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { + return Ok(()); + } + parse_global_hotkey(binding)?; + Ok(()) +} + +pub fn parse_global_hotkey(binding: &ShortcutBinding) -> Result { + let mut mods = Modifiers::empty(); + for raw in &binding.modifiers { + let tag = normalize_modifier_tag(raw); + let bit = match tag.as_str() { + "cmd" | "command" | "super" | "meta" | "win" => Modifiers::SUPER, + "ctrl" | "control" => Modifiers::CONTROL, + "alt" | "option" | "opt" => Modifiers::ALT, + "shift" => Modifiers::SHIFT, + other => return Err(ShortcutBindingError::UnsupportedModifier(other.to_string())), + }; + mods |= bit; + } + let code = parse_primary(&binding.primary)?; + let mods = if mods.is_empty() { None } else { Some(mods) }; + Ok(HotKey::new(mods, code)) +} + +pub fn legacy_modifier_trigger(binding: &ShortcutBinding) -> Option { + if !binding.modifiers.is_empty() { + return None; + } + match normalize_primary(&binding.primary).as_str() { + "rightoption" | "rightalt" => Some(HotkeyTrigger::RightOption), + "leftoption" | "leftalt" => Some(HotkeyTrigger::LeftOption), + "rightcontrol" | "rightctrl" => Some(HotkeyTrigger::RightControl), + "leftcontrol" | "leftctrl" => Some(HotkeyTrigger::LeftControl), + "rightcommand" | "rightcmd" | "rightsuper" | "rightmeta" => { + Some(HotkeyTrigger::RightCommand) + } + "fn" | "function" => Some(HotkeyTrigger::Fn), + _ => None, + } +} + +pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { + let primary = match trigger { + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "RightOption", + HotkeyTrigger::LeftOption => "LeftOption", + HotkeyTrigger::RightControl => "RightControl", + HotkeyTrigger::LeftControl => "LeftControl", + HotkeyTrigger::RightCommand => "RightCommand", + HotkeyTrigger::Fn => "Fn", + HotkeyTrigger::Custom => "RightOption", + }; + ShortcutBinding { + primary: primary.into(), + modifiers: Vec::new(), + } +} + +fn normalize_modifier_tag(raw: &str) -> String { + let tag = raw.trim().to_ascii_lowercase(); + #[cfg(target_os = "windows")] + { + if matches!(tag.as_str(), "cmd" | "command") { + return "ctrl".to_string(); + } + } + tag +} + +fn normalize_primary(raw: &str) -> String { + raw.trim() + .chars() + .filter(|c| !matches!(c, ' ' | '-' | '_')) + .collect::() + .to_ascii_lowercase() +} + +fn parse_primary(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ShortcutBindingError::UnsupportedKey("(空)".into())); + } + if trimmed.chars().count() == 1 { + let ch = trimmed.chars().next().unwrap(); + if let Some(code) = char_to_code(ch) { + return Ok(code); + } + } + let upper = trimmed.to_ascii_uppercase(); + let named = match upper.as_str() { + "ENTER" | "RETURN" => Code::Enter, + "TAB" => Code::Tab, + "ESC" | "ESCAPE" => Code::Escape, + "SPACE" => Code::Space, + "BACKSPACE" => Code::Backspace, + "DELETE" | "DEL" => Code::Delete, + "HOME" => Code::Home, + "END" => Code::End, + "PAGEUP" => Code::PageUp, + "PAGEDOWN" => Code::PageDown, + "ARROWUP" | "UP" => Code::ArrowUp, + "ARROWDOWN" | "DOWN" => Code::ArrowDown, + "ARROWLEFT" | "LEFT" => Code::ArrowLeft, + "ARROWRIGHT" | "RIGHT" => Code::ArrowRight, + "F1" => Code::F1, + "F2" => Code::F2, + "F3" => Code::F3, + "F4" => Code::F4, + "F5" => Code::F5, + "F6" => Code::F6, + "F7" => Code::F7, + "F8" => Code::F8, + "F9" => Code::F9, + "F10" => Code::F10, + "F11" => Code::F11, + "F12" => Code::F12, + _ => return Err(ShortcutBindingError::UnsupportedKey(trimmed.to_string())), + }; + Ok(named) +} + +fn char_to_code(ch: char) -> Option { + let c = ch.to_ascii_uppercase(); + let code = match c { + 'A' => Code::KeyA, + 'B' => Code::KeyB, + 'C' => Code::KeyC, + 'D' => Code::KeyD, + 'E' => Code::KeyE, + 'F' => Code::KeyF, + 'G' => Code::KeyG, + 'H' => Code::KeyH, + 'I' => Code::KeyI, + 'J' => Code::KeyJ, + 'K' => Code::KeyK, + 'L' => Code::KeyL, + 'M' => Code::KeyM, + 'N' => Code::KeyN, + 'O' => Code::KeyO, + 'P' => Code::KeyP, + 'Q' => Code::KeyQ, + 'R' => Code::KeyR, + 'S' => Code::KeyS, + 'T' => Code::KeyT, + 'U' => Code::KeyU, + 'V' => Code::KeyV, + 'W' => Code::KeyW, + 'X' => Code::KeyX, + 'Y' => Code::KeyY, + 'Z' => Code::KeyZ, + '0' | ')' => Code::Digit0, + '1' | '!' => Code::Digit1, + '2' | '@' => Code::Digit2, + '3' | '#' => Code::Digit3, + '4' | '$' => Code::Digit4, + '5' | '%' => Code::Digit5, + '6' | '^' => Code::Digit6, + '7' | '&' => Code::Digit7, + '8' | '*' => Code::Digit8, + '9' | '(' => Code::Digit9, + ';' | ':' => Code::Semicolon, + ',' | '<' => Code::Comma, + '.' | '>' => Code::Period, + '/' | '?' => Code::Slash, + '\\' | '|' => Code::Backslash, + '[' | '{' => Code::BracketLeft, + ']' | '}' => Code::BracketRight, + '\'' | '"' => Code::Quote, + '`' | '~' => Code::Backquote, + '-' | '_' => Code::Minus, + '=' | '+' => Code::Equal, + ' ' => Code::Space, + _ => return None, + }; + Some(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_combo_and_single_key() { + let combo = ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }; + let parsed = parse_global_hotkey(&combo).expect("combo parses"); + assert!(parsed.mods.contains(Modifiers::SUPER)); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + assert_eq!(parsed.key, Code::KeyD); + + let single = ShortcutBinding { + primary: "F8".into(), + modifiers: vec![], + }; + let parsed = parse_global_hotkey(&single).expect("single key parses"); + assert!(parsed.mods.is_empty()); + assert_eq!(parsed.key, Code::F8); + } + + #[test] + fn detects_legacy_modifier_only() { + let binding = ShortcutBinding { + primary: "RightControl".into(), + modifiers: vec![], + }; + assert_eq!( + legacy_modifier_trigger(&binding), + Some(HotkeyTrigger::RightControl) + ); + } + + #[test] + fn accepts_shifted_printable_aliases() { + let cases = [ + ("?", Code::Slash), + ("!", Code::Digit1), + (":", Code::Semicolon), + ("+", Code::Equal), + ("_", Code::Minus), + ("{", Code::BracketLeft), + ("|", Code::Backslash), + ]; + for (primary, expected) in cases { + let binding = ShortcutBinding { + primary: primary.into(), + modifiers: vec!["shift".into()], + }; + let parsed = parse_global_hotkey(&binding).expect("shifted printable parses"); + assert_eq!(parsed.key, expected); + assert!(parsed.mods.contains(Modifiers::SHIFT)); + } + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c1266e24..f6c0e83d 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -109,10 +109,11 @@ fn default_true() -> bool { true } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct UserPreferences { pub hotkey: HotkeyBinding, + pub dictation_hotkey: ShortcutBinding, pub default_mode: PolishMode, pub enabled_modes: Vec, pub launch_at_login: bool, @@ -120,6 +121,9 @@ pub struct UserPreferences { /// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 #[serde(default)] pub mute_during_recording: bool, + /// 录音输入设备名称。空字符串 = 使用系统默认麦克风。 + #[serde(default)] + pub microphone_device_name: String, pub active_asr_provider: String, // "volcengine" | "apple-speech" | ... pub active_llm_provider: String, // "ark" | "openai" | ... /// Windows/Linux 粘贴成功后是否恢复用户原剪贴板。默认 true 跟历史行为一致; @@ -154,11 +158,22 @@ pub struct UserPreferences { /// coordinator 用 global-hotkey crate 注册组合键(modifier + 主键)。 /// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。 #[serde(default = "default_qa_hotkey")] - pub qa_hotkey: Option, + pub qa_hotkey: Option, /// 是否把每次 QA 会话写进 history.json。默认 false:QA 默认临时不留痕。 /// 详见 issue #118。 #[serde(default)] pub qa_save_history: bool, + /// 自定义录音组合键。当 `hotkey.trigger == Custom` 时,coordinator 用 + /// `global-hotkey` crate 注册此组合键(支持 Toggle + Hold 模式)。 + /// `None` 且 trigger == Custom 表示用户选了自定义但还没录制。 + #[serde(default)] + pub custom_combo_hotkey: Option, + #[serde(default = "default_translation_hotkey")] + pub translation_hotkey: ShortcutBinding, + #[serde(default = "default_switch_style_hotkey")] + pub switch_style_hotkey: ShortcutBinding, + #[serde(default = "default_open_app_hotkey")] + pub open_app_hotkey: ShortcutBinding, /// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。 /// 仅在 active_asr_provider == "local-qwen3" 时有意义。 #[serde(default = "default_local_asr_model")] @@ -185,8 +200,174 @@ fn default_local_asr_keep_loaded_secs() -> u32 { 300 } -fn default_qa_hotkey() -> Option { - Some(QaHotkeyBinding::default()) +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct UserPreferencesWire { + hotkey: HotkeyBinding, + dictation_hotkey: Option, + default_mode: PolishMode, + enabled_modes: Vec, + launch_at_login: bool, + show_capsule: bool, + #[serde(default)] + mute_during_recording: bool, + #[serde(default)] + microphone_device_name: String, + active_asr_provider: String, + active_llm_provider: String, + restore_clipboard_after_paste: bool, + allow_non_tsf_insertion_fallback: bool, + working_languages: Vec, + translation_target_language: String, + chinese_script_preference: ChineseScriptPreference, + #[serde(default)] + output_language_preference: OutputLanguagePreference, + qa_hotkey: Option, + qa_save_history: bool, + custom_combo_hotkey: Option, + translation_hotkey: Option, + switch_style_hotkey: Option, + open_app_hotkey: Option, + #[serde(default = "default_local_asr_model")] + local_asr_active_model: String, + #[serde(default = "default_local_asr_mirror")] + local_asr_mirror: String, + #[serde(default = "default_local_asr_keep_loaded_secs")] + local_asr_keep_loaded_secs: u32, +} + +impl Default for UserPreferencesWire { + fn default() -> Self { + let prefs = UserPreferences::default(); + Self { + hotkey: prefs.hotkey, + dictation_hotkey: None, + default_mode: prefs.default_mode, + enabled_modes: prefs.enabled_modes, + launch_at_login: prefs.launch_at_login, + show_capsule: prefs.show_capsule, + mute_during_recording: prefs.mute_during_recording, + microphone_device_name: prefs.microphone_device_name, + active_asr_provider: prefs.active_asr_provider, + active_llm_provider: prefs.active_llm_provider, + restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, + allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, + working_languages: prefs.working_languages, + translation_target_language: prefs.translation_target_language, + chinese_script_preference: prefs.chinese_script_preference, + output_language_preference: prefs.output_language_preference, + qa_hotkey: prefs.qa_hotkey, + qa_save_history: prefs.qa_save_history, + custom_combo_hotkey: prefs.custom_combo_hotkey, + translation_hotkey: None, + switch_style_hotkey: None, + open_app_hotkey: None, + local_asr_active_model: prefs.local_asr_active_model, + local_asr_mirror: prefs.local_asr_mirror, + local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs, + } + } +} + +impl<'de> Deserialize<'de> for UserPreferences { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = UserPreferencesWire::deserialize(deserializer)?; + let dictation_hotkey = match wire.dictation_hotkey { + Some(binding) => binding, + None => default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) + .map_err(serde::de::Error::custom)?, + }; + Ok(Self { + hotkey: wire.hotkey, + dictation_hotkey, + default_mode: wire.default_mode, + enabled_modes: wire.enabled_modes, + launch_at_login: wire.launch_at_login, + show_capsule: wire.show_capsule, + mute_during_recording: wire.mute_during_recording, + microphone_device_name: wire.microphone_device_name, + active_asr_provider: wire.active_asr_provider, + active_llm_provider: wire.active_llm_provider, + restore_clipboard_after_paste: wire.restore_clipboard_after_paste, + allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, + working_languages: wire.working_languages, + translation_target_language: wire.translation_target_language, + chinese_script_preference: wire.chinese_script_preference, + output_language_preference: wire.output_language_preference, + qa_hotkey: wire.qa_hotkey, + qa_save_history: wire.qa_save_history, + custom_combo_hotkey: wire.custom_combo_hotkey, + translation_hotkey: wire + .translation_hotkey + .unwrap_or_else(default_translation_hotkey), + switch_style_hotkey: wire + .switch_style_hotkey + .unwrap_or_else(default_switch_style_hotkey), + open_app_hotkey: wire.open_app_hotkey.unwrap_or_else(default_open_app_hotkey), + local_asr_active_model: wire.local_asr_active_model, + local_asr_mirror: wire.local_asr_mirror, + local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs, + }) + } +} + +fn default_qa_hotkey() -> Option { + Some(ShortcutBinding::default_qa()) +} + +fn default_translation_hotkey() -> ShortcutBinding { + ShortcutBinding { + primary: "Shift".into(), + modifiers: Vec::new(), + } +} + +fn default_switch_style_hotkey() -> ShortcutBinding { + ShortcutBinding { + primary: "S".into(), + modifiers: default_app_shortcut_modifiers(), + } +} + +fn default_open_app_hotkey() -> ShortcutBinding { + ShortcutBinding { + primary: "O".into(), + modifiers: default_app_shortcut_modifiers(), + } +} + +fn default_app_shortcut_modifiers() -> Vec { + #[cfg(target_os = "macos")] + { + vec!["cmd".into(), "shift".into()] + } + #[cfg(not(target_os = "macos"))] + { + vec!["ctrl".into(), "shift".into()] + } +} + +fn default_dictation_hotkey_from_legacy( + hotkey: &HotkeyBinding, + custom_combo_hotkey: &Option, +) -> Result { + if hotkey.trigger == HotkeyTrigger::Custom { + if let Some(combo) = custom_combo_hotkey { + return Ok(ShortcutBinding { + primary: combo.primary.clone(), + modifiers: combo.modifiers.clone(), + }); + } + return Err( + "hotkey.trigger is custom but dictationHotkey/customComboHotkey is missing".into(), + ); + } + Ok(crate::shortcut_binding::binding_from_legacy_trigger( + hotkey.trigger, + )) } fn default_working_languages() -> Vec { @@ -197,6 +378,11 @@ impl Default for UserPreferences { fn default() -> Self { Self { hotkey: HotkeyBinding::default(), + dictation_hotkey: default_dictation_hotkey_from_legacy( + &HotkeyBinding::default(), + &None, + ) + .expect("default legacy hotkey is not custom"), default_mode: PolishMode::Light, enabled_modes: vec![ PolishMode::Raw, @@ -207,6 +393,7 @@ impl Default for UserPreferences { launch_at_login: false, show_capsule: true, mute_during_recording: false, + microphone_device_name: String::new(), active_asr_provider: "volcengine".into(), active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, @@ -217,6 +404,10 @@ impl Default for UserPreferences { output_language_preference: OutputLanguagePreference::Auto, qa_hotkey: default_qa_hotkey(), qa_save_history: false, + custom_combo_hotkey: None, + translation_hotkey: default_translation_hotkey(), + switch_style_hotkey: default_switch_style_hotkey(), + open_app_hotkey: default_open_app_hotkey(), local_asr_active_model: default_local_asr_model(), local_asr_mirror: default_local_asr_mirror(), local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), @@ -224,6 +415,44 @@ impl Default for UserPreferences { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutBinding { + pub primary: String, + pub modifiers: Vec, +} + +impl ShortcutBinding { + pub fn default_qa() -> Self { + #[cfg(target_os = "macos")] + { + Self { + primary: ";".into(), + modifiers: vec!["cmd".into(), "shift".into()], + } + } + #[cfg(not(target_os = "macos"))] + { + Self { + primary: ";".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + } + } + } + + pub fn display_label(&self) -> String { + let mut parts: Vec = Vec::new(); + let modifier_order = ["cmd", "ctrl", "alt", "shift", "super"]; + for tag in modifier_order { + if self.modifiers.iter().any(|m| m.eq_ignore_ascii_case(tag)) { + parts.push(modifier_display(tag).to_string()); + } + } + parts.push(display_primary(&self.primary)); + parts.join("+") + } +} + /// 划词语音问答的全局快捷键绑定。原生名字符串: /// - `primary`:主键(如 `";"`、`"."`、`"A"`、`"F1"`)。 /// - `modifiers`:修饰键集合,元素来自 `{"cmd","ctrl","alt","shift","super"}`。 @@ -257,7 +486,7 @@ impl Default for QaHotkeyBinding { } impl QaHotkeyBinding { - /// 渲染成给前端展示的可读标签(macOS 用 `Cmd`,其他平台用 `Ctrl`)。 + /// 渲染成给前端展示的可读标签。 /// 顺序与人类阅读习惯一致:`Cmd+Shift+;`、`Ctrl+Alt+Shift+.`。 pub fn display_label(&self) -> String { let mut parts: Vec = Vec::new(); @@ -274,9 +503,46 @@ impl QaHotkeyBinding { } } +/// 录音快捷键的自定义组合键绑定。结构与 `QaHotkeyBinding` 相同: +/// - `primary`:主键(如 `"D"`、`"Space"`、`"F1"`)。 +/// - `modifiers`:修饰键集合,元素来自 `{"cmd","ctrl","alt","shift","super"}`。 +/// +/// 当 `HotkeyBinding.trigger == Custom` 时,coordinator 用 `global-hotkey` crate +/// 注册此组合键,而非 modifier-only 的 CGEventTap / WH_KEYBOARD_LL。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ComboBinding { + pub primary: String, + pub modifiers: Vec, +} + +impl ComboBinding { + /// 渲染成给前端展示的可读标签。复用 QaHotkeyBinding 的格式化逻辑。 + pub fn display_label(&self) -> String { + let qa = QaHotkeyBinding { + primary: self.primary.clone(), + modifiers: self.modifiers.clone(), + }; + qa.display_label() + } +} + fn modifier_display(tag: &str) -> &'static str { match tag { - "cmd" => "Cmd", + "cmd" => { + #[cfg(target_os = "macos")] + { + "Cmd" + } + #[cfg(target_os = "windows")] + { + "Ctrl" + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + "Super" + } + } "ctrl" => "Ctrl", "alt" => { #[cfg(target_os = "macos")] @@ -319,6 +585,7 @@ pub enum HotkeyTrigger { RightCommand, Fn, RightAlt, // Windows synonym for RightOption + Custom, } impl HotkeyTrigger { @@ -331,6 +598,7 @@ impl HotkeyTrigger { HotkeyTrigger::RightCommand => "右 Command", HotkeyTrigger::Fn => "Fn (地球键)", HotkeyTrigger::RightAlt => "右 Alt", + HotkeyTrigger::Custom => "自定义组合键", } } } @@ -361,7 +629,7 @@ impl HotkeyAdapterKind { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(default, rename_all = "camelCase")] +#[serde(rename_all = "camelCase")] pub struct HotkeyBinding { pub trigger: HotkeyTrigger, pub mode: HotkeyMode, @@ -392,6 +660,7 @@ impl HotkeyCapability { HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, HotkeyTrigger::Fn, + HotkeyTrigger::Custom, ], requires_accessibility_permission: true, supports_modifier_only_trigger: true, @@ -410,6 +679,7 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::Custom, ], requires_accessibility_permission: false, supports_modifier_only_trigger: true, @@ -430,6 +700,7 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, + HotkeyTrigger::Custom, ], requires_accessibility_permission: false, supports_modifier_only_trigger: true, @@ -598,4 +869,43 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + + #[test] + fn legacy_custom_hotkey_without_custom_binding_is_rejected() { + let result = serde_json::from_str::( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" } + }"#, + ); + + assert!(result.is_err()); + } + + #[test] + fn legacy_custom_hotkey_uses_custom_combo_binding() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" }, + "customComboHotkey": { "primary": "D", "modifiers": ["cmd", "shift"] } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.dictation_hotkey.primary, "D"); + assert_eq!(prefs.dictation_hotkey.modifiers, vec!["cmd", "shift"]); + } + + #[test] + fn custom_hotkey_with_dictation_hotkey_preserves_dictation_binding() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "hotkey": { "trigger": "custom", "mode": "toggle" }, + "dictationHotkey": { "primary": "Space", "modifiers": ["ctrl"] } + }"#, + ) + .unwrap(); + + assert_eq!(prefs.dictation_hotkey.primary, "Space"); + assert_eq!(prefs.dictation_hotkey.modifiers, vec!["ctrl"]); + } } diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index b5585bf0..79f6ae5d 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -392,10 +392,7 @@ mod tests { #[test] fn submit_timeout_covers_native_async_commit_path() { - assert!( - IME_SUBMIT_TIMEOUT - > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS) - ); + assert!(IME_SUBMIT_TIMEOUT > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS)); } #[test] diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 43ad54ed..b733be31 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "1.2.20", + "version": "1.2.21", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index b1baac39..d047bf72 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -147,18 +147,38 @@ function isWindowHotkeyCandidate(event: KeyboardEvent): boolean { } function StartupShell() { + // 用透明背景:main window 是 transparent + macOSPrivateApi(NSVisualEffectView 磨砂)。 + // 之前用 linear-gradient(rgba(245,245,247,0.96)...) 会盖过 macOS vibrancy,启动时 + // 长时间在 'checking' phase(凭据迁移 / 权限 probe 慢)会让窗口看起来「左侧白屏 + + // 右侧磨砂」割裂。现在背景全透明,让磨砂统一展开,提示文字 + icon 用一个轻量 + // pill 卡片承载,跟 capsule 视觉一致。 return (
-
+
OpenLess 正在启动
diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index da5d5480..848b9a9b 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -246,6 +246,7 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: padding: '0 8px', width: metrics.width, height: metrics.height, + boxSizing: metrics.boxSizing, borderRadius: 999, background: 'rgba(255, 255, 255, 0.62)', backdropFilter: useBackdrop ? 'blur(28px) saturate(180%)' : 'none', @@ -327,7 +328,10 @@ export function Capsule() { position: 'relative', display: 'flex', alignItems: 'center', - justifyContent: os === 'win' ? 'flex-end' : 'center', + justifyContent: 'center', + paddingLeft: hostMetrics.horizontalInset, + paddingRight: hostMetrics.horizontalInset, + boxSizing: hostMetrics.boxSizing, paddingTop: os === 'win' ? Math.max(0, hostMetrics.height - metrics.height - hostMetrics.bottomInset) : 0, @@ -345,7 +349,7 @@ export function Capsule() { position: 'absolute', left: '50%', // macOS / Linux:胶囊窗口 220×110、pill 居中,badge 锚到 pill 中线上方 21+8。 - // Windows:pill 不居中(带 12pt 阴影 inset),用 hostMetrics 量到底部 inset + pill 高 + gap。 + // Windows:host 比 pill 多出左右 12px / 底部 12px 的阴影空间,pill 仍保持居中。 bottom: os === 'win' ? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px` : 'calc(50% + 21px + 8px)', diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 23f965aa..94ed25a2 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -23,6 +23,7 @@ import { HOTKEY_MODE_MIGRATION_DEFERRED_KEY, shouldShowHotkeyModeMigrationPrompt, } from '../lib/hotkeyMigration'; +import { formatComboLabel } from '../lib/hotkey'; import { applyFontScale, readFontScale } from '../lib/fontScale'; import { getCredentials, openExternal } from '../lib/ipc'; import { @@ -30,6 +31,7 @@ import { shouldShowProviderSetupPrompt, } from '../lib/providerSetup'; import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings'; +import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; interface NavItem { @@ -74,6 +76,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); const [helpPopoverOpen, setHelpPopoverOpen] = useState(false); + const { prefs } = useHotkeySettings(); // tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。 // displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。 @@ -161,6 +164,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia setSettingsOpen(true); }; + // ⌘, 打开设置页面 + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.metaKey && e.key === ',') { + e.preventDefault(); + openSettings(); + } + }; + window.addEventListener('keydown', onKeyDown, true); + return () => window.removeEventListener('keydown', onKeyDown, true); + }, []); + const openProviderSettings = () => { rememberProviderPrompt(); openSettings('providers'); @@ -241,6 +256,21 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
+ {/* shortcut hint — 不要 dashed 边框,否则会切断"整片磨砂玻璃"的视觉 */} +
+
{t('shell.shortcutLabel')}
+
+ {prefs ? formatComboLabel(prefs.dictationHotkey) : ''} + {t('shell.shortcutHint')} +
+
+ {/* BETA 区域 — 去掉描边和实色背景,让它和底部 footer 一起浮在磨砂玻璃上 */}
{t('shell.betaTag')}
diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx new file mode 100644 index 00000000..e14da7c5 --- /dev/null +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { currentPlatform, formatComboLabel } from '../lib/hotkey'; +import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; +import type { ShortcutBinding } from '../lib/types'; + +export function ShortcutRecorder({ + value, + onSave, + alignRecordButton = false, +}: { + value: ShortcutBinding; + onSave: (binding: ShortcutBinding) => Promise; + alignRecordButton?: boolean; +}) { + const { t } = useTranslation(); + const [recording, setRecording] = useState(false); + const [error, setError] = useState(null); + const pendingModifier = useRef(null); + const pendingTimer = useRef(null); + + const clearPendingModifier = () => { + if (pendingTimer.current !== null) { + window.clearTimeout(pendingTimer.current); + pendingTimer.current = null; + } + pendingModifier.current = null; + }; + + useEffect(() => () => { + clearPendingModifier(); + void setShortcutRecordingActive(false); + }, []); + + useEffect(() => { + void setShortcutRecordingActive(recording); + return () => { + if (recording) void setShortcutRecordingActive(false); + }; + }, [recording]); + + const finish = async (binding: ShortcutBinding) => { + try { + await validateShortcutBinding(binding); + await onSave(binding); + clearPendingModifier(); + setRecording(false); + setError(null); + } catch { + setError(t('settings.recording.comboConflict')); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!recording) return; + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + setRecording(false); + setError(null); + clearPendingModifier(); + return; + } + if (isModifierKey(e.key)) { + const primary = modifierPrimaryFromCode(e.code, e.key); + if (!primary || pendingModifier.current?.primary === primary) return; + clearPendingModifier(); + const binding = { primary, modifiers: [] }; + pendingModifier.current = binding; + pendingTimer.current = window.setTimeout(() => { + if (pendingModifier.current?.primary === primary) { + void finish(binding); + } + }, 650); + return; + } + clearPendingModifier(); + const primary = primaryFromKeyboardEvent(e); + if (primary) void finish({ primary, modifiers: modifiersFromKeyboardEvent(e) }); + }; + + const onKeyUp = (e: KeyboardEvent) => { + if (!recording || !isModifierKey(e.key)) return; + e.preventDefault(); + e.stopPropagation(); + const primary = modifierPrimaryFromCode(e.code, e.key); + if (primary && pendingModifier.current?.primary === primary) { + const binding = pendingModifier.current; + clearPendingModifier(); + void finish(binding); + } + }; + + const rootStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + width: alignRecordButton ? '100%' : undefined, + }; + const recorderRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + width: alignRecordButton ? '100%' : undefined, + }; + const recordButtonStyle: CSSProperties = { + fontSize: 12, + padding: '5px 14px', + background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-blue)', + color: recording ? 'var(--ol-blue)' : '#fff', + border: 0, + borderRadius: 6, + fontFamily: 'inherit', + fontWeight: 500, + cursor: recording ? 'default' : 'pointer', + marginLeft: alignRecordButton ? 'auto' : undefined, + }; + + return ( +
+
+ + {formatComboLabel(value)} + + +
+ {recording && ( +
el?.focus()} + > + {t('settings.recording.comboRecordHint')} +
Esc 取消
+
+ )} + {error &&
{error}
} +
+ ); +} + +function modifiersFromKeyboardEvent(e: KeyboardEvent): string[] { + const modifiers: string[] = []; + if (e.metaKey && e.key !== 'Meta') modifiers.push(currentPlatform().isMac ? 'cmd' : 'super'); + if (e.ctrlKey && e.key !== 'Control') modifiers.push('ctrl'); + if (e.altKey && e.key !== 'Alt') modifiers.push('alt'); + if (e.shiftKey && e.key !== 'Shift') modifiers.push('shift'); + return modifiers; +} + +function isModifierKey(key: string): boolean { + return key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta'; +} + +function modifierPrimaryFromCode(code: string, key: string): string { + if (key === 'Shift') return 'Shift'; + if (code === 'ControlRight') return 'RightControl'; + if (code === 'ControlLeft') return 'LeftControl'; + if (code === 'AltRight') return 'RightOption'; + if (code === 'AltLeft') return 'LeftOption'; + if (code === 'MetaRight' || code === 'MetaLeft') return 'RightCommand'; + return ''; +} + +function primaryFromKeyboardEvent(e: KeyboardEvent): string { + const printable = primaryFromPrintableCode(e.code); + if (printable) return printable; + if (e.key.length === 1) return e.key; + const codeToName: Record = { + Space: 'Space', + Enter: 'Enter', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + ArrowUp: 'ArrowUp', + ArrowDown: 'ArrowDown', + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + }; + if (/^F\d{1,2}$/.test(e.key)) return e.key; + return codeToName[e.code] || e.key; +} + +function primaryFromPrintableCode(code: string): string { + if (/^Key[A-Z]$/.test(code)) return code.slice(3); + if (/^Digit[0-9]$/.test(code)) return code.slice(5); + const codeToPrimary: Record = { + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/', + IntlBackslash: '\\', + }; + return codeToPrimary[code] || ''; +} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index c55ee618..0c22e0c4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -273,13 +273,30 @@ export const en: typeof zhCN = { modeToggle: 'Toggle', modeHold: 'Push-to-talk', migrationNoticeTitle: 'Default recording mode is now Toggle', - migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', + migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', + microphoneLabel: 'Preferred microphone', + microphoneDesc: 'Choose the preferred input device. If it is temporarily unavailable, OpenLess uses the system default microphone and switches back when it reconnects.', + microphoneDefault: 'System default microphone', + microphoneDefaultDesc: 'Use the system default input device', + microphoneSystemDefault: 'system default', + microphoneUnavailable: 'unavailable', + microphoneLoadError: 'Failed to load microphones: {{message}}', + microphoneDialogTitle: 'Microphone', + microphoneDialogDesc: 'Choose a microphone that can pick up your voice. If the meter does not move, try another microphone.', + microphoneMonitorError: 'Failed to monitor input level: {{message}}', capsuleLabel: 'Recording capsule', capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', muteDuringRecordingLabel: 'Mute while recording', muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + comboRecordLabel: 'Record shortcut', + comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.', + comboRecordBtn: 'Record shortcut', + comboRecordHint: 'Press your shortcut combination\u2026', + comboRecorded: 'Recorded', + comboClear: 'Clear', + comboConflict: 'This shortcut combination is not available', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows only: if direct TSF insertion fails, allow Unicode SendInput, shortcut paste, or WM_PASTE. Turn off to verify that insertion is really coming from TSF.', startupAtBoot: 'Launch at login', @@ -291,6 +308,7 @@ export const en: typeof zhCN = { llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.', providerLabel: 'Provider', llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.', + credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.', asrProviderDesc: 'Switching providers automatically loads the matching credentials.', asrTitle: 'ASR (transcription)', asrDesc: 'Used to turn speech into text in real time.', @@ -524,6 +542,7 @@ export const en: typeof zhCN = { rightCommand: 'Right Command', fn: 'Fn (Globe key)', rightAlt: 'Right Alt', + custom: 'Custom combination\u2026', }, fallback: 'Global hotkey', modeHoldSuffix: ' (push-to-talk)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c98989f6..45c8f8f8 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -276,12 +276,29 @@ export const ja: typeof zhCN = { modeHold: '押し続けて話す', migrationNoticeTitle: 'デフォルトがトグル式に変更されました', migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', + microphoneLabel: '優先マイク', + microphoneDesc: '優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。', + microphoneDefault: 'システムのデフォルトマイク', + microphoneDefaultDesc: 'システムのデフォルト入力デバイスを使用', + microphoneSystemDefault: 'システムデフォルト', + microphoneUnavailable: '利用不可', + microphoneLoadError: 'マイクの読み込みに失敗:{{message}}', + microphoneDialogTitle: 'マイク', + microphoneDialogDesc: '声を拾えるマイクを選択してください。メーターが動かない場合は別のマイクを試してください。', + microphoneMonitorError: '入力レベルの監視に失敗:{{message}}', capsuleLabel: '録音カプセル', capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。', muteDuringRecordingLabel: '録音中はミュート', muteDuringRecordingDesc: '音声入力開始時にシステム出力を一時的にミュートし、停止/キャンセル/エラー後に元のミュート状態を復元。スピーカーの音がマイクに入らないようにします。', restoreClipboardLabel: '入力後にクリップボードを復元', restoreClipboardDesc: 'Windows / Linux のみ:ペースト成功後に元のクリップボード内容を復元(既定 ON)。OFF にするとディクテーションテキストがクリップボードに残り、ペーストが効かなかった場合に手動で Ctrl+V できます。詳細は issue #111。', + comboRecordLabel: 'ショートカットを記録', + comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。', + comboRecordBtn: 'ショートカットを記録', + comboRecordHint: 'ショートカットの組み合わせを押してください…', + comboRecorded: '記録済み', + comboClear: 'クリア', + comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows のみ:TSF による直接入力が失敗した場合、Unicode SendInput / ショートカットペースト / WM_PASTE への切り替えを許可。OFF にすると TSF 入力が実際に使われているか検証できます。', startupAtBoot: '起動時に自動起動', @@ -293,6 +310,7 @@ export const ja: typeof zhCN = { llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。', providerLabel: 'サプライヤー', llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。', + credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。', asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。', asrTitle: 'ASR 音声(転写)', asrDesc: '口述をリアルタイムでテキストに転写。', @@ -526,6 +544,7 @@ export const ja: typeof zhCN = { rightCommand: '右 Command', fn: 'Fn (地球キー)', rightAlt: '右 Alt', + custom: 'カスタム', }, fallback: 'グローバルショートカット', modeHoldSuffix: '(押し続けて話す)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ac669fc1..b7003c37 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -276,12 +276,29 @@ export const ko: typeof zhCN = { modeHold: '눌러서 말하기', migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', + microphoneLabel: '기본 선택 마이크', + microphoneDesc: '우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.', + microphoneDefault: '시스템 기본 마이크', + microphoneDefaultDesc: '시스템 기본 입력 장치 사용', + microphoneSystemDefault: '시스템 기본값', + microphoneUnavailable: '사용할 수 없음', + microphoneLoadError: '마이크 로드 실패: {{message}}', + microphoneDialogTitle: '마이크', + microphoneDialogDesc: '목소리를 받을 수 있는 마이크를 선택하세요. 미터가 움직이지 않으면 다른 마이크를 시도하세요.', + microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}', capsuleLabel: '녹음 캡슐', capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.', muteDuringRecordingLabel: '녹음 중 음소거', muteDuringRecordingDesc: '음성 입력 시작 시 시스템 출력을 일시적으로 음소거하고, 정지/취소/오류 후 원래 음소거 상태를 복원합니다. 스피커 소리가 마이크에 들어가지 않도록 합니다.', restoreClipboardLabel: '입력 후 클립보드 복원', restoreClipboardDesc: 'Windows / Linux 만: 붙여넣기 성공 후 원래 클립보드 내용을 복원합니다(기본 ON). OFF 시 받아쓰기 텍스트가 클립보드에 남아 붙여넣기가 실패한 경우 수동으로 Ctrl+V 할 수 있습니다. issue #111 참조.', + comboRecordLabel: '단축키 녹화', + comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.', + comboRecordBtn: '단축키 녹화', + comboRecordHint: '단축키 조합을 눌러 주세요…', + comboRecorded: '녹화됨', + comboClear: '지우기', + comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows 만: TSF 직접 입력이 실패할 경우 Unicode SendInput / 단축키 붙여넣기 / WM_PASTE 로 전환을 허용합니다. OFF 시 실제로 TSF 입력이 사용되는지 검증할 수 있습니다.', startupAtBoot: '부팅 시 자동 시작', @@ -293,6 +310,7 @@ export const ko: typeof zhCN = { llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.', providerLabel: '공급자', llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.', + credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.', asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.', asrTitle: 'ASR 음성(전사)', asrDesc: '구술을 실시간으로 텍스트로 전사합니다.', @@ -526,6 +544,7 @@ export const ko: typeof zhCN = { rightCommand: '오른쪽 Command', fn: 'Fn (지구본 키)', rightAlt: '오른쪽 Alt', + custom: '사용자 정의', }, fallback: '전역 단축키', modeHoldSuffix: '(눌러서 말하기)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ac6798ec..97f72673 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -272,12 +272,29 @@ export const zhCN = { modeHold: '按住说话', migrationNoticeTitle: '默认已改为切换式说话', migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + microphoneLabel: '首选麦克风', + microphoneDesc: '选择优先使用的输入设备。设备暂时不可用时会使用系统默认麦克风,重新连接后自动切回首选设备。', + microphoneDefault: '系统默认麦克风', + microphoneDefaultDesc: '使用系统默认输入设备', + microphoneSystemDefault: '系统默认', + microphoneUnavailable: '不可用', + microphoneLoadError: '麦克风列表读取失败:{{message}}', + microphoneDialogTitle: '麦克风', + microphoneDialogDesc: '选择能捕捉到您声音的麦克风。如果指示条没有移动,请尝试其他麦克风。', + microphoneMonitorError: '输入电平监听失败:{{message}}', capsuleLabel: '录音胶囊', capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', muteDuringRecordingLabel: '录音时静音', muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + comboRecordLabel: '录制快捷键', + comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。', + comboRecordBtn: '录制快捷键', + comboRecordHint: '请按下快捷键组合…', + comboRecorded: '已录制', + comboClear: '清除', + comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: '仅 Windows:TSF 直接上屏失败后,允许改用 Unicode SendInput、快捷键粘贴或 WM_PASTE。关闭后可验证是否真实使用 TSF 输入。', startupAtBoot: '开机自启', @@ -289,6 +306,7 @@ export const zhCN = { llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。', providerLabel: '供应商', llmProviderDesc: '选择后将自动填入 Base URL 默认值。', + credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。', asrProviderDesc: '切换后将自动选用对应凭据。', asrTitle: 'ASR 语音(转写)', asrDesc: '用于将口述实时转写为文本。', @@ -522,6 +540,7 @@ export const zhCN = { rightCommand: '右 Command', fn: 'Fn (地球键)', rightAlt: '右 Alt', + custom: '自定义组合键…', }, fallback: '全局快捷键', modeHoldSuffix: '(按住说话)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5491af92..44d92096 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -274,6 +274,23 @@ export const zhTW: typeof zhCN = { modeHold: '按住說話', migrationNoticeTitle: '默認已改爲切換式說話', migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。', + comboRecordLabel: '錄製快捷鍵', + comboRecordDesc: '點擊後按下你想要的快捷鍵組合(如 ⌘⇧D),支援 Toggle 與 Hold 模式。', + comboRecordBtn: '錄製快捷鍵', + comboRecordHint: '請按下快捷鍵組合…', + comboRecorded: '已錄製', + comboClear: '清除', + comboConflict: '此快捷鍵組合不可用', + microphoneLabel: '首選麥克風', + microphoneDesc: '選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。', + microphoneDefault: '系統默認麥克風', + microphoneDefaultDesc: '使用系統默認輸入設備', + microphoneSystemDefault: '系統默認', + microphoneUnavailable: '不可用', + microphoneLoadError: '麥克風列表讀取失敗:{{message}}', + microphoneDialogTitle: '麥克風', + microphoneDialogDesc: '選擇能捕捉到您聲音的麥克風。如果指示條沒有移動,請嘗試其他麥克風。', + microphoneMonitorError: '輸入電平監聽失敗:{{message}}', capsuleLabel: '錄音膠囊', capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。', muteDuringRecordingLabel: '錄音時靜音', @@ -291,6 +308,7 @@ export const zhTW: typeof zhCN = { llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。', providerLabel: '供應商', llmProviderDesc: '選擇後將自動填入 Base URL 默認值。', + credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。', asrProviderDesc: '切換後將自動選用對應憑據。', asrTitle: 'ASR 語音(轉寫)', asrDesc: '用於將口述實時轉寫爲文本。', @@ -524,6 +542,7 @@ export const zhTW: typeof zhCN = { rightCommand: '右 Command', fn: 'Fn (地球鍵)', rightAlt: '右 Alt', + custom: '自訂組合…', }, fallback: '全局快捷鍵', modeHoldSuffix: '(按住說話)', diff --git a/openless-all/app/src/lib/capsuleLayout.test.ts b/openless-all/app/src/lib/capsuleLayout.test.ts index dd746b54..c4a0f6c0 100644 --- a/openless-all/app/src/lib/capsuleLayout.test.ts +++ b/openless-all/app/src/lib/capsuleLayout.test.ts @@ -1,4 +1,5 @@ import { + getCapsuleHostMetrics, getCapsuleMessageLayout, getCapsulePillMetrics, } from './capsuleLayout.ts'; @@ -13,11 +14,38 @@ const winMetrics = getCapsulePillMetrics('win'); assertEqual(winMetrics.width, 196, 'windows capsule widens pill'); assertEqual(winMetrics.height, 52, 'windows capsule increases pill height'); assertEqual(winMetrics.textWidth, 104, 'windows capsule keeps side controls clear'); +assertEqual(winMetrics.boxSizing, 'border-box', 'windows capsule pill width is an outer border-box metric'); + +const winHost = getCapsuleHostMetrics('win', false); +assertEqual(winHost.width, 220, 'windows capsule host keeps the current outer hitbox width'); +assertEqual(winHost.height, 84, 'windows capsule host keeps regular height'); +assertEqual(winHost.horizontalInset, 12, 'windows capsule host keeps symmetric shadow insets'); +assertEqual(winHost.boxSizing, 'border-box', 'windows capsule host inset is reserved inside the native width'); +assertEqual( + winHost.width, + winMetrics.width + winHost.horizontalInset * 2, + 'windows capsule host width derives from pill width plus symmetric side insets', +); +assertEqual( + winHost.width - winHost.horizontalInset * 2, + winMetrics.width, + 'windows capsule host keeps the visible pill width after reserving side insets', +); + +const winHostWithTranslation = getCapsuleHostMetrics('win', true); +assertEqual(winHostWithTranslation.width, 220, 'windows translation capsule keeps the same outer width'); +assertEqual(winHostWithTranslation.height, 118, 'windows translation capsule grows vertically only'); +assertEqual(winHostWithTranslation.horizontalInset, 12, 'windows translation capsule keeps symmetric side insets'); +assertEqual(winHostWithTranslation.boxSizing, 'border-box', 'windows translation host keeps the same inset-reserving box model'); const macMetrics = getCapsulePillMetrics('mac'); assertEqual(macMetrics.width, 176, 'mac capsule keeps existing pill width'); assertEqual(macMetrics.height, 42, 'mac capsule keeps existing pill height'); assertEqual(macMetrics.textWidth, 84, 'mac capsule keeps existing text slot'); +assertEqual(macMetrics.boxSizing, 'border-box', 'mac capsule keeps the existing border-box pill model'); + +const macHost = getCapsuleHostMetrics('mac', false); +assertEqual(macHost.boxSizing, 'border-box', 'mac capsule host keeps the existing border-box box model'); const winErrorLayout = getCapsuleMessageLayout('win', 'error'); assertEqual(winErrorLayout.lineClamp, 2, 'windows error message allows two lines'); diff --git a/openless-all/app/src/lib/capsuleLayout.ts b/openless-all/app/src/lib/capsuleLayout.ts index 269d61e0..b8f9207d 100644 --- a/openless-all/app/src/lib/capsuleLayout.ts +++ b/openless-all/app/src/lib/capsuleLayout.ts @@ -6,13 +6,16 @@ export interface CapsulePillMetrics { width: number; height: number; textWidth: number; + boxSizing: 'border-box' | 'content-box'; } export interface CapsuleHostMetrics { width: number; height: number; + horizontalInset: number; bottomInset: number; badgeGap: number; + boxSizing: 'border-box' | 'content-box'; } export interface CapsuleMessageLayout { @@ -22,10 +25,11 @@ export interface CapsuleMessageLayout { export function getCapsulePillMetrics(os: OS): CapsulePillMetrics { if (os === 'win') { - return { width: 196, height: 52, textWidth: 104 }; + // Windows metrics describe the visible outer footprint of the pill. + return { width: 196, height: 52, textWidth: 104, boxSizing: 'border-box' }; } - return { width: 176, height: 42, textWidth: 84 }; + return { width: 176, height: 42, textWidth: 84, boxSizing: 'border-box' }; } // macOS 走 1.2.11 calc 布局,不依赖 host metrics;Windows 端要更大的 host @@ -35,9 +39,25 @@ export function getCapsuleHostMetrics( translationActive: boolean, ): CapsuleHostMetrics { if (os === 'win') { - return { width: 220, height: translationActive ? 118 : 84, bottomInset: 12, badgeGap: 8 }; + const horizontalInset = 12; + const pill = getCapsulePillMetrics(os); + return { + width: pill.width + horizontalInset * 2, + height: translationActive ? 118 : 84, + horizontalInset, + bottomInset: 12, + badgeGap: 8, + boxSizing: 'border-box', + }; } - return { width: 176, height: 42, bottomInset: 0, badgeGap: 8 }; + return { + width: 176, + height: 42, + horizontalInset: 0, + bottomInset: 0, + badgeGap: 8, + boxSizing: 'border-box', + }; } export function getCapsuleMessageLayout( diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 4819d8b1..048febf2 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -1,12 +1,41 @@ import i18n from '../i18n'; -import type { HotkeyBinding, HotkeyTrigger } from './types'; +import type { ComboBinding, HotkeyBinding, HotkeyTrigger, QaHotkeyBinding, ShortcutBinding } from './types'; + +export function defaultQaShortcut(): ShortcutBinding { + return { + primary: ';', + modifiers: defaultAppShortcutModifiers(), + }; +} + +export function defaultAppShortcutModifiers(): string[] { + return currentPlatform().isMac ? ['cmd', 'shift'] : ['ctrl', 'shift']; +} export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined): string { if (!trigger) return i18n.t('hotkey.fallback'); + if (trigger === 'custom') return i18n.t('hotkey.triggers.custom'); return i18n.t(`hotkey.triggers.${trigger}`); } -export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string { +export function getHotkeyStartStopLabel( + binding: HotkeyBinding | null | undefined, + comboBinding?: ComboBinding | null, + shortcutBinding?: ShortcutBinding | null, +): string { + if (shortcutBinding) { + const suffix = binding?.mode === 'hold' + ? i18n.t('hotkey.modeHoldSuffix') + : i18n.t('hotkey.modeToggleSuffix'); + return `${formatComboLabel(shortcutBinding)}${suffix}`; + } + if (binding?.trigger === 'custom' && comboBinding) { + const combo = formatComboLabel(comboBinding); + const suffix = binding.mode === 'hold' + ? i18n.t('hotkey.modeHoldSuffix') + : i18n.t('hotkey.modeToggleSuffix'); + return `${combo}${suffix}`; + } const trigger = getHotkeyTriggerLabel(binding?.trigger); const suffix = binding?.mode === 'hold' ? i18n.t('hotkey.modeHoldSuffix') @@ -14,9 +43,115 @@ export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefine return `${trigger}${suffix}`; } -export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string { +export function getHotkeyUsageHint( + binding: HotkeyBinding | null | undefined, + comboBinding?: ComboBinding | null, + shortcutBinding?: ShortcutBinding | null, +): string { + if (shortcutBinding) { + const combo = formatComboLabel(shortcutBinding); + return binding?.mode === 'hold' + ? i18n.t('hotkey.usageHold', { trigger: combo }) + : i18n.t('hotkey.usageToggle', { trigger: combo }); + } + if (binding?.trigger === 'custom' && comboBinding) { + const combo = formatComboLabel(comboBinding); + return binding.mode === 'hold' + ? i18n.t('hotkey.usageHold', { trigger: combo }) + : i18n.t('hotkey.usageToggle', { trigger: combo }); + } const trigger = getHotkeyTriggerLabel(binding?.trigger); return binding?.mode === 'hold' ? i18n.t('hotkey.usageHold', { trigger }) : i18n.t('hotkey.usageToggle', { trigger }); } + +/** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */ +export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string { + const parts: string[] = []; + const platform = currentPlatform(); + + // 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super + const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const; + for (const tag of modifierOrder) { + if (binding.modifiers.some(m => m.toLowerCase() === tag)) { + parts.push(modifierDisplayName(tag, platform)); + } + } + + parts.push(formatPrimary(binding.primary)); + return parts.join(platform.isMac ? '' : '+'); +} + +export function currentPlatform(): { isMac: boolean; isWindows: boolean } { + const nav = typeof navigator === 'undefined' ? null : navigator; + const platform = nav?.platform || ''; + const userAgent = nav?.userAgent || ''; + return { + isMac: platform.includes('Mac') || userAgent.includes('Mac'), + isWindows: platform.includes('Win') || userAgent.includes('Windows'), + }; +} + +function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string { + if (platform.isMac) { + switch (tag) { + case 'cmd': return '\u2318'; + case 'ctrl': return '\u2303'; + case 'alt': return '\u2325'; + case 'shift': return '\u21E7'; + case 'super': return '\u2318'; + } + } else { + switch (tag) { + case 'cmd': return platform.isWindows ? 'Ctrl' : 'Super'; + case 'ctrl': return 'Ctrl'; + case 'alt': return 'Alt'; + case 'shift': return 'Shift'; + case 'super': return platform.isWindows ? 'Win' : 'Super'; + } + } + return tag; +} + +function formatPrimary(primary: string): string { + const trimmed = primary.trim(); + if (!trimmed) return '?'; + // 单字母归大写 + if (trimmed.length === 1 && /[a-zA-Z]/.test(trimmed)) { + return trimmed.toUpperCase(); + } + // 常见命名键的 macOS 符号 + const isMac = currentPlatform().isMac; + if (isMac) { + switch (trimmed.toLowerCase()) { + case 'space': return '\u2423'; + case 'enter': + case 'return': return '\u21A9'; + case 'tab': return '\u21E5'; + case 'escape': + case 'esc': return '\u238B'; + case 'backspace': return '\u232B'; + case 'delete': + case 'del': return '\u2326'; + case 'arrowup': + case 'up': return '\u2191'; + case 'arrowdown': + case 'down': return '\u2193'; + case 'arrowleft': + case 'left': return '\u2190'; + case 'arrowright': + case 'right': return '\u2192'; + } + } + switch (trimmed.toLowerCase()) { + case 'rightoption': return isMac ? 'Right ⌥' : 'Right Alt'; + case 'leftoption': return isMac ? 'Left ⌥' : 'Left Alt'; + case 'rightcontrol': return isMac ? 'Right ⌃' : 'Right Ctrl'; + case 'leftcontrol': return isMac ? 'Left ⌃' : 'Left Ctrl'; + case 'rightcommand': return isMac ? 'Right ⌘' : (currentPlatform().isWindows ? 'Right Win' : 'Right Super'); + case 'fn': return 'Fn'; + case 'shift': return isMac ? '⇧' : 'Shift'; + } + return trimmed; +} diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 77823fd5..a0aa78af 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -3,20 +3,23 @@ // the UI is still operable for visual review. import type { + ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, + MicrophoneDevice, PermissionStatus, PolishMode, QaHotkeyBinding, + ShortcutBinding, UserPreferences, WindowsImeStatus, - VocabPreset, VocabPresetStore, } from './types'; import { OL_DATA } from './mockData'; +import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel } from './hotkey'; declare global { interface Window { @@ -24,7 +27,8 @@ declare global { } } -const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; +const isTauri = + globalThis.window !== undefined && '__TAURI_INTERNALS__' in globalThis.window; export async function invokeOrMock( cmd: string, @@ -41,21 +45,27 @@ export async function invokeOrMock( // ── Mock fixtures ────────────────────────────────────────────────────── const mockSettings: UserPreferences = { hotkey: { trigger: 'rightControl', mode: 'toggle' }, + dictationHotkey: { primary: 'RightControl', modifiers: [] }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, showCapsule: true, muteDuringRecording: false, + microphoneDeviceName: '', activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', + qaHotkey: defaultQaShortcut(), chineseScriptPreference: 'auto', outputLanguagePreference: 'auto', - qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] }, qaSaveHistory: false, + customComboHotkey: null, + translationHotkey: { primary: 'Shift', modifiers: [] }, + switchStyleHotkey: { primary: 'S', modifiers: defaultAppShortcutModifiers() }, + openAppHotkey: { primary: 'O', modifiers: defaultAppShortcutModifiers() }, localAsrActiveModel: 'qwen3-asr-0.6b', localAsrMirror: 'huggingface', localAsrKeepLoadedSecs: 300, @@ -63,7 +73,7 @@ const mockSettings: UserPreferences = { const mockHotkeyCapability: HotkeyCapability = { adapter: 'windowsLowLevel', - availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand'], + availableTriggers: ['rightControl', 'rightAlt', 'leftControl', 'rightCommand', 'custom'], requiresAccessibilityPermission: false, supportsModifierOnlyTrigger: true, supportsSideSpecificModifiers: true, @@ -102,6 +112,11 @@ const mockWindowsImeStatus: WindowsImeStatus = { dllPath: null, }; +const mockMicrophoneDevices: MicrophoneDevice[] = [ + { name: 'Built-in Microphone', isDefault: true }, + { name: 'USB Microphone', isDefault: false }, +]; + const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ id: `mock-${i}`, createdAt: new Date().toISOString(), @@ -146,6 +161,18 @@ export function getWindowsImeStatus(): Promise { return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); } +export function listMicrophoneDevices(): Promise { + return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices); +} + +export function startMicrophoneLevelMonitor(deviceName: string): Promise { + return invokeOrMock('start_microphone_level_monitor', { deviceName }, () => undefined); +} + +export function stopMicrophoneLevelMonitor(): Promise { + return invokeOrMock('stop_microphone_level_monitor', undefined, () => undefined); +} + // ── Credentials ──────────────────────────────────────────────────────── export function getCredentials(): Promise { return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); @@ -296,10 +323,10 @@ export function restartApp(): Promise { // 详见 issue #118。后端会发 `qa:state` / `qa:dismiss` 事件;前端通过下面四个 // 命令查询与控制 QA 浮窗。 export function getQaHotkeyLabel(): Promise { - return invokeOrMock('get_qa_hotkey_label', undefined, () => 'Cmd+Shift+;'); + return invokeOrMock('get_qa_hotkey_label', undefined, () => formatComboLabel(defaultQaShortcut())); } -export function setQaHotkey(binding: QaHotkeyBinding): Promise { +export function setQaHotkey(binding: QaHotkeyBinding | null): Promise { return invokeOrMock('set_qa_hotkey', { binding }, () => undefined); } @@ -311,6 +338,39 @@ export function qaWindowPin(pinned: boolean): Promise { return invokeOrMock('qa_window_pin', { pinned }, () => undefined); } +// ── Combo Hotkey (自定义录音组合键) ─────────────────────────────────── +export function validateComboHotkey(binding: ComboBinding): Promise { + return invokeOrMock('validate_combo_hotkey', { binding }, () => undefined); +} + +export function setComboHotkey(binding: ComboBinding): Promise { + return invokeOrMock('set_combo_hotkey', { binding }, () => undefined); +} + +export function validateShortcutBinding(binding: ShortcutBinding): Promise { + return invokeOrMock('validate_shortcut_binding', { binding }, () => undefined); +} + +export function setDictationHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_dictation_hotkey', { binding }, () => undefined); +} + +export function setTranslationHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_translation_hotkey', { binding }, () => undefined); +} + +export function setSwitchStyleHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_switch_style_hotkey', { binding }, () => undefined); +} + +export function setOpenAppHotkey(binding: ShortcutBinding): Promise { + return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined); +} + +export function setShortcutRecordingActive(active: boolean): Promise { + return invokeOrMock('set_shortcut_recording_active', { active }, () => undefined); +} + export async function openExternal(url: string): Promise { if (!isTauri) { window.open(url, '_blank', 'noopener,noreferrer'); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 1634f571..71513411 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -48,7 +48,8 @@ export type HotkeyTrigger = | 'leftControl' | 'rightCommand' | 'fn' - | 'rightAlt'; + | 'rightAlt' + | 'custom'; export type HotkeyMode = 'toggle' | 'hold'; @@ -83,14 +84,19 @@ export interface HotkeyStatus { lastError: HotkeyInstallError | null; } -/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ -export interface QaHotkeyBinding { - /** 主键(去掉所有修饰符的字面字符),例如 ";" / "/" / "a" */ +export interface ShortcutBinding { + /** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */ primary: string; - /** 修饰符列表,元素小写:"cmd" | "shift" | "option" | "ctrl"。 */ + /** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */ modifiers: string[]; } +/** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ +export type QaHotkeyBinding = ShortcutBinding; + +/** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */ +export type ComboBinding = ShortcutBinding; + export type WindowsImeInstallState = | 'installed' | 'notInstalled' @@ -106,12 +112,15 @@ export interface WindowsImeStatus { export interface UserPreferences { hotkey: HotkeyBinding; + dictationHotkey: ShortcutBinding; defaultMode: PolishMode; enabledModes: PolishMode[]; launchAtLogin: boolean; showCapsule: boolean; /** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */ muteDuringRecording: boolean; + /** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */ + microphoneDeviceName: string; activeAsrProvider: string; activeLlmProvider: string; /** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */ @@ -130,6 +139,14 @@ export interface UserPreferences { qaHotkey: QaHotkeyBinding | null; /** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */ qaSaveHistory: boolean; + /** 自定义录音组合键。当 hotkey.trigger == 'custom' 时使用。null = 未设置。 */ + customComboHotkey: ComboBinding | null; + /** 录音中触发翻译的全局快捷键。默认 Shift。 */ + translationHotkey: ShortcutBinding; + /** 切换到上一个润色风格的全局快捷键。 */ + switchStyleHotkey: ShortcutBinding; + /** 打开 OpenLess 主窗口的全局快捷键。 */ + openAppHotkey: ShortcutBinding; /** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */ localAsrActiveModel: string; /** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */ @@ -139,6 +156,11 @@ export interface UserPreferences { localAsrKeepLoadedSecs: number; } +export interface MicrophoneDevice { + name: string; + isDefault: boolean; +} + /** Rust 通过 `qa:state` 事件下发的 payload。 * v2 (issue #118 v2):支持多轮对话,messages 数组每次由后端整段下发(单一可信源)。 * v2.1:开 `stream:true`,LLM 答案逐 chunk 通过 `answer_delta` 事件推前端边渲染。 */ diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 4ec8e88d..13bbc238 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { formatComboLabel } from '../lib/hotkey'; import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -41,7 +41,7 @@ export function History() { const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [loading, setLoading] = useState(true); - const { hotkey } = useHotkeySettings(); + const { prefs } = useHotkeySettings(); const refresh = async () => { const data = await listHistory(); @@ -130,7 +130,7 @@ export function History() { {loading &&
{t('common.loading')}
} {!loading && filtered.length === 0 && (
- {t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('history.empty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
)} {filtered.map(s => ( diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 34f320dc..946dfbfa 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { formatComboLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -52,7 +52,7 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); - const { hotkey } = useHotkeySettings(); + const { prefs } = useHotkeySettings(); useEffect(() => { listHistory().then(setHistory); @@ -102,6 +102,29 @@ export function Overview({ onOpenHistory }: OverviewProps) { kicker={t('overview.kicker')} title={t('overview.title')} desc={t('overview.desc')} + right={ +
+ + {t('overview.pressPrefix')} + {prefs ? formatComboLabel(prefs.dictationHotkey) : ''} + {t('overview.pressSuffix')} +
+ } />
@@ -149,7 +172,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{history.length === 0 && (
- {t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('overview.recentEmpty', { trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '' })}
)} {history.slice(0, 5).map(s => ( diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 98952160..db58ef18 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -9,81 +9,15 @@ import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; -import type { QaHotkeyBinding } from '../lib/types'; -import { detectOS, type OS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; - -const QA_HOTKEY_DISABLED_ID = 'disabled' as const; - -interface QaHotkeyPreset { - id: string; - binding: QaHotkeyBinding; - label: string; -} - -// macOS:用 Cmd 修饰键,跟系统其他快捷键肌肉记忆一致。 -const QA_HOTKEY_PRESETS_MAC: readonly QaHotkeyPreset[] = [ - { id: 'cmd+shift+;', label: 'Cmd+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+shift+/', label: 'Cmd+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+shift+.', label: 'Cmd+Shift+.', binding: { primary: '.', modifiers: ['cmd', 'shift'] } }, - { id: 'cmd+shift+,', label: 'Cmd+Shift+,', binding: { primary: ',', modifiers: ['cmd', 'shift'] } }, -] as const; - -// Windows:用 Ctrl 修饰键(macOS Cmd 的对等键)。**不**用 Fn——Win32 的 -// `RegisterHotKey` 和 `WH_KEYBOARD_LL` 都收不到 Fn 的虚拟键码(硬件级 modifier, -// 在 OS 内核之下被吃掉),写进 preset 用户也注册不上。**不**用 Win+x—— -// 大部分 Win+x 已被系统 / Cortana 占用。 -const QA_HOTKEY_PRESETS_WIN: readonly QaHotkeyPreset[] = [ - { id: 'ctrl+shift+;', label: 'Ctrl+Shift+;', binding: { primary: ';', modifiers: ['ctrl', 'shift'] } }, - { id: 'ctrl+shift+/', label: 'Ctrl+Shift+/', binding: { primary: '/', modifiers: ['ctrl', 'shift'] } }, - { id: 'ctrl+shift+.', label: 'Ctrl+Shift+.', binding: { primary: '.', modifiers: ['ctrl', 'shift'] } }, - { id: 'ctrl+shift+,', label: 'Ctrl+Shift+,', binding: { primary: ',', modifiers: ['ctrl', 'shift'] } }, -] as const; - -// Linux:UI 展示用 Super,后端 binding 仍用 SUPER 同义词 `cmd` 透传到 global-hotkey。 -const QA_HOTKEY_PRESETS_LINUX: readonly QaHotkeyPreset[] = [ - { id: 'super+shift+;', label: 'Super+Shift+;', binding: { primary: ';', modifiers: ['cmd', 'shift'] } }, - { id: 'super+shift+/', label: 'Super+Shift+/', binding: { primary: '/', modifiers: ['cmd', 'shift'] } }, - { id: 'super+shift+.', label: 'Super+Shift+.', binding: { primary: '.', modifiers: ['cmd', 'shift'] } }, - { id: 'super+shift+,', label: 'Super+Shift+,', binding: { primary: ',', modifiers: ['cmd', 'shift'] } }, -] as const; - -function getQaHotkeyPresets(os: OS): readonly QaHotkeyPreset[] { - if (os === 'mac') return QA_HOTKEY_PRESETS_MAC; - if (os === 'linux') return QA_HOTKEY_PRESETS_LINUX; - return QA_HOTKEY_PRESETS_WIN; -} - -function normalizeQaModifier(modifier: string): string { - const tag = modifier.toLowerCase(); - if (tag === 'command' || tag === 'super' || tag === 'meta' || tag === 'win') { - return 'cmd'; - } - return tag; -} - -function bindingToPresetId( - binding: QaHotkeyBinding | null, - presets: readonly QaHotkeyPreset[], -): string { - if (!binding) return QA_HOTKEY_DISABLED_ID; - const sortedMods = [...binding.modifiers].map(normalizeQaModifier).sort(); - const match = presets.find(p => { - const pMods = [...p.binding.modifiers].map(normalizeQaModifier).sort(); - return p.binding.primary === binding.primary - && pMods.length === sortedMods.length - && pMods.every((m, i) => m === sortedMods[i]); - }); - return match ? match.id : presets[0].id; -} +import { defaultQaShortcut, formatComboLabel } from '../lib/hotkey'; +import { ShortcutRecorder } from '../components/ShortcutRecorder'; export function SelectionAsk() { const { t } = useTranslation(); - const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); - const os = detectOS(); - const qaHotkeyPresets = getQaHotkeyPresets(os); - const defaultHotkeyLabel = qaHotkeyPresets[0]?.label ?? '快捷键'; - const recordHotkeyLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const defaultQaHotkey = defaultQaShortcut(); + const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey); + const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键'; if (!prefs) { return ( @@ -103,31 +37,11 @@ export function SelectionAsk() { ); } - const onHotkeyChange = async (id: string) => { - if (id === QA_HOTKEY_DISABLED_ID) { - await savePrefs({ ...prefs, qaHotkey: null }); - return; - } - const preset = qaHotkeyPresets.find(p => p.id === id); - if (!preset) return; - // 先让后端真注册成功 → 再写盘 prefs。否则 prefs 跟实际生效的快捷键脱节, - // 会让用户陷入"UI 改了但按了没反应"的迷雾(issue #118 v1 实测过)。 - try { - await setQaHotkey(preset.binding); - } catch (error) { - console.error('[selectionAsk] failed to set qa hotkey', error); - // 后端拒绝绑定(如不支持的主键)→ 不写盘,UI 下次 render 仍显示旧值。 - return; - } - await savePrefs({ ...prefs, qaHotkey: preset.binding }); - }; - const onSaveHistoryChange = (qaSaveHistory: boolean) => savePrefs({ ...prefs, qaSaveHistory }); const enabled = prefs.qaHotkey !== null; - const currentId = bindingToPresetId(prefs.qaHotkey, qaHotkeyPresets); - const currentLabel = qaHotkeyPresets.find(p => p.id === currentId)?.label ?? defaultHotkeyLabel; + const currentLabel = prefs.qaHotkey ? formatComboLabel(prefs.qaHotkey) : defaultHotkeyLabel; return ( <> @@ -144,84 +58,39 @@ export function SelectionAsk() { {/* 1. 触发快捷键 */} -
-
{t('selectionAsk.hotkey.title')}
- - {enabled ? t('selectionAsk.statusEnabled') : t('selectionAsk.statusDisabled')} - -
-
+ { + const nextHotkey = enabled ? null : defaultQaHotkey; + await setQaHotkey(nextHotkey); + await savePrefs({ ...prefs, qaHotkey: nextHotkey }); + }} + /> +
{t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
- + {prefs.qaHotkey && ( + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> + )} {/* 2. 历史保存 */} -
{t('selectionAsk.history.title')}
-
+ onSaveHistoryChange(!prefs.qaSaveHistory)} + /> +
{t('selectionAsk.history.desc')}
- {/* 3. 使用方法 */} @@ -270,3 +139,49 @@ export function SelectionAsk() { ); } + +// 卡片标题行右侧开关:与 Style 页面顶栏的 36×20 toggle 同款,保持全局视觉一致。 +function CardHeaderToggle({ + title, + checked, + onToggle, +}: { + title: string; + checked: boolean; + onToggle: () => void; +}) { + return ( +
+
{title}
+ +
+ ); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index f7aa64c2..b667fdb7 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -2,19 +2,21 @@ // Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About) // keep their inline-style literals 1:1 with the source JSX. -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; +import { ShortcutRecorder } from '../components/ShortcutRecorder'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; -import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; +import { defaultQaShortcut } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, getHotkeyStatus, getWindowsImeStatus, isTauri, + listMicrophoneDevices, openExternal, openSystemSettings, listProviderModels, @@ -24,6 +26,13 @@ import { setActiveAsrProvider, setActiveLlmProvider, setCredential, + setDictationHotkey, + setOpenAppHotkey, + setQaHotkey, + setSwitchStyleHotkey, + setTranslationHotkey, + startMicrophoneLevelMonitor, + stopMicrophoneLevelMonitor, validateProviderCredentials, } from '../lib/ipc'; import type { @@ -31,6 +40,7 @@ import type { HotkeyMode, HotkeyStatus, HotkeyTrigger, + MicrophoneDevice, PermissionStatus, WindowsImeStatus, } from '../lib/types'; @@ -151,16 +161,17 @@ interface SettingRowProps { label: string; desc?: string; children: ReactNode; + controlWidth?: number | string; } -function SettingRow({ label, desc, children }: SettingRowProps) { +function SettingRow({ label, desc, children, controlWidth }: SettingRowProps) { return ( -
-
+
+
{label}
{desc &&
{desc}
}
-
{children}
+
{children}
); } @@ -168,6 +179,69 @@ function SettingRow({ label, desc, children }: SettingRowProps) { function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const [microphoneDevices, setMicrophoneDevices] = useState([]); + const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); + const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); + const [microphonePickerOpen, setMicrophonePickerOpen] = useState(false); + + const loadMicrophoneDevices = useCallback(async ( + signal?: { cancelled: boolean }, + options: { showLoading?: boolean } = {}, + ) => { + if (options.showLoading ?? true) { + setMicrophoneDevicesLoaded(false); + } + setMicrophoneDevicesError(null); + try { + const devices = await listMicrophoneDevices(); + if (signal?.cancelled) return; + setMicrophoneDevices(devices); + setMicrophoneDevicesLoaded(true); + } catch (err) { + console.error('[settings] list microphone devices failed', err); + if (signal?.cancelled) return; + setMicrophoneDevices([]); + setMicrophoneDevicesError(err instanceof Error ? err.message : String(err)); + setMicrophoneDevicesLoaded(true); + } + }, []); + + useEffect(() => { + const signal = { cancelled: false }; + void loadMicrophoneDevices(signal); + return () => { + signal.cancelled = true; + }; + }, [loadMicrophoneDevices]); + + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + async function listenForDeviceChanges() { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + const stopListening = await listen('microphone:devices-changed', () => { + void loadMicrophoneDevices(undefined, { showLoading: false }); + }); + if (cancelled) { + stopListening(); + return; + } + unlisten = stopListening; + } + void listenForDeviceChanges(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [loadMicrophoneDevices]); + + useEffect(() => { + if (microphonePickerOpen) { + void loadMicrophoneDevices(undefined, { showLoading: false }); + } + }, [loadMicrophoneDevices, microphonePickerOpen]); if (!prefs || !capability) { return ( @@ -177,14 +251,14 @@ function RecordingSection() { ); } - const onTriggerChange = (trigger: HotkeyTrigger) => - savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } }); const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); const onShowCapsuleChange = (showCapsule: boolean) => savePrefs({ ...prefs, showCapsule }); const onMuteDuringRecordingChange = (muteDuringRecording: boolean) => savePrefs({ ...prefs, muteDuringRecording }); + const onMicrophoneDeviceChange = (microphoneDeviceName: string) => + savePrefs({ ...prefs, microphoneDeviceName }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => @@ -197,6 +271,17 @@ function RecordingSection() { const hotkeyDesc = capability.requiresAccessibilityPermission ? t('settings.recording.hotkeyDescAcc') : t('settings.recording.hotkeyDescNoAcc'); + const preferredMicrophoneAvailable = Boolean( + prefs.microphoneDeviceName + && microphoneDevices.some(device => device.name === prefs.microphoneDeviceName), + ); + const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName + && (!microphoneDevicesLoaded || preferredMicrophoneAvailable) + ? prefs.microphoneDeviceName + : ''; + const selectedMicrophoneLabel = effectiveMicrophoneDeviceName + ? effectiveMicrophoneDeviceName + : t('settings.recording.microphoneDefault'); return ( @@ -222,19 +307,13 @@ function RecordingSection() {
)} - + />
@@ -257,6 +336,67 @@ function RecordingSection() { ))}
+ +
+ + {!microphoneDevicesLoaded && ( +
{t('common.loading')}
+ )} + {microphoneDevicesError && ( +
+ {t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })} +
+ )} +
+
+ {microphonePickerOpen && ( + setMicrophonePickerOpen(false)} + onRefresh={() => { + void loadMicrophoneDevices(); + }} + loading={!microphoneDevicesLoaded} + onSelect={(name) => { + onMicrophoneDeviceChange(name); + }} + /> + )} @@ -293,6 +433,330 @@ function RecordingSection() { ); } +function MicrophonePickerDialog({ + devices, + selectedName, + onClose, + onRefresh, + loading, + onSelect, +}: { + devices: MicrophoneDevice[]; + selectedName: string; + onClose: () => void; + onRefresh: () => void; + loading: boolean; + onSelect: (name: string) => void; +}) { + const { t } = useTranslation(); + const [pickedName, setPickedName] = useState(selectedName); + const [previewName, setPreviewName] = useState(selectedName); + const [level, setLevel] = useState(0); + const [hoveredName, setHoveredName] = useState(null); + const [pressedName, setPressedName] = useState(null); + const [monitorError, setMonitorError] = useState(null); + const monitorQueueRef = useRef>(Promise.resolve()); + + const enqueueMonitorTask = useCallback((task: () => Promise) => { + const next = monitorQueueRef.current.catch(() => undefined).then(task); + monitorQueueRef.current = next.catch(() => undefined); + return next; + }, []); + + useEffect(() => { + setPickedName(selectedName); + setPreviewName(selectedName); + }, [selectedName]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + let timer: number | undefined; + setLevel(0); + setMonitorError(null); + + async function start() { + await enqueueMonitorTask(async () => { + try { + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + const stopListening = await listen<{ level: number }>('microphone:level', event => { + setLevel(Math.max(0, Math.min(1, event.payload.level ?? 0))); + }); + if (cancelled) { + stopListening(); + return; + } + unlisten = stopListening; + await startMicrophoneLevelMonitor(previewName); + if (cancelled) { + unlisten?.(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + } + } else { + const tick = window.setInterval(() => { + setLevel(0.25 + Math.random() * 0.55); + }, 120); + if (cancelled) { + window.clearInterval(tick); + return; + } + unlisten = () => window.clearInterval(tick); + } + } catch (err) { + console.warn('[settings] microphone level monitor failed', err); + if (!cancelled) { + setMonitorError(err instanceof Error ? err.message : String(err)); + } + } + }); + } + + timer = window.setTimeout(() => { + void start(); + }, 140); + return () => { + cancelled = true; + if (timer !== undefined) { + window.clearTimeout(timer); + } + void enqueueMonitorTask(async () => { + unlisten?.(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + }); + }; + }, [enqueueMonitorTask, previewName]); + + const rows = [ + { + id: 'default', + name: '', + label: t('settings.recording.microphoneDefault'), + desc: t('settings.recording.microphoneDefaultDesc'), + isDefault: false, + }, + ...devices.map((device, index) => ({ + id: `${device.name}-${index}`, + name: device.name, + label: device.name, + desc: device.isDefault ? t('settings.recording.microphoneSystemDefault') : '', + isDefault: device.isDefault, + })), + ]; + + return ( +
+
e.stopPropagation()} + style={{ + width: 450, + maxWidth: 'calc(100vw - 48px)', + borderRadius: 16, + background: 'rgba(255,255,255,0.96)', + border: '0.5px solid rgba(0,0,0,0.12)', + boxShadow: '0 24px 70px rgba(0,0,0,0.28)', + padding: 24, + animation: 'olMicPickerPopIn 160ms cubic-bezier(.2,.8,.2,1)', + }} + > +
+
{t('settings.recording.microphoneDialogTitle')}
+
+ + +
+
+
+ {t('settings.recording.microphoneDialogDesc')} +
+ {monitorError && ( +
+ {t('settings.recording.microphoneMonitorError', { message: monitorError })} +
+ )} +
+ {rows.map(row => { + const active = pickedName === row.name; + const previewing = previewName === row.name; + const hovered = hoveredName === row.name; + const pressed = pressedName === row.name; + return ( + + ); + })} +
+ +
+
+ ); +} + +function LevelMeter({ level }: { level: number }) { + const amplified = Math.min(1, Math.max(0, level * 4.5)); + const bars = [0.25, 0.5, 0.75, 1, 0.75, 0.5]; + return ( + + {bars.map((weight, index) => { + const intensity = Math.min(1, amplified * (0.85 + weight * 0.35)); + const height = 6 + intensity * (20 * weight); + return ( + 0.08 ? 'var(--ol-blue)' : 'rgba(0,0,0,0.10)', + opacity: 0.35 + intensity * 0.65, + transition: 'height 70ms linear, opacity 90ms ease, background 120ms ease', + }} + /> + ); + })} + + ); +} + // 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / // windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 function AutostartRow() { @@ -508,6 +972,9 @@ function ProvidersSection() { return ( <> +
+ {t('settings.providers.credentialStorageNotice')} +
{t('settings.providers.llmTitle')}
@@ -910,9 +1377,9 @@ const iconBtnStyle: CSSProperties = { function ShortcutsSection() { const { t } = useTranslation(); - const { hotkey, capability } = useHotkeySettings(); + const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings(); - if (!hotkey || !capability) { + if (!prefs || !hotkey || !capability) { return (
{t('common.loading')}
@@ -923,19 +1390,83 @@ function ShortcutsSection() { const desc = capability.requiresAccessibilityPermission ? t('settings.shortcuts.descAcc') : t('settings.shortcuts.descNoAcc'); - const notSupported = t('settings.shortcuts.notSupported'); - const rows: Array<[string, string]> = [ - [t('settings.shortcuts.startStop'), getHotkeyStartStopLabel(hotkey)], + const readonlyRows: Array<[string, string]> = [ [t('settings.shortcuts.cancel'), 'Esc'], [t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')], - [t('settings.shortcuts.switchStyle'), capability.requiresAccessibilityPermission ? '⌘ ⇧ S' : notSupported], - [t('settings.shortcuts.openApp'), capability.requiresAccessibilityPermission ? '⌘ ⇧ O' : notSupported], ]; return (
{t('settings.shortcuts.title')}
{desc}
- {rows.map(([k, v]) => ( + +
+ { + await setDictationHotkey(binding); + await savePrefs({ ...prefs, dictationHotkey: binding }); + }} + /> +
+ {hotkey.mode === 'hold' ? t('hotkey.modeHoldSuffix') : t('hotkey.modeToggleSuffix')} +
+
+
+ + { + await setTranslationHotkey(binding); + await savePrefs({ ...prefs, translationHotkey: binding }); + }} + /> + + + {prefs.qaHotkey ? ( + { + await setQaHotkey(binding); + await savePrefs({ ...prefs, qaHotkey: binding }); + }} + /> + ) : ( + + )} + + + { + await setSwitchStyleHotkey(binding); + await savePrefs({ ...prefs, switchStyleHotkey: binding }); + }} + /> + + + { + await setOpenAppHotkey(binding); + await savePrefs({ ...prefs, openAppHotkey: binding }); + }} + /> + + {readonlyRows.map(([k, v]) => ( savePrefs({ ...prefs, translationTargetLanguage }); - const triggerLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const triggerLabel = formatComboLabel(prefs.dictationHotkey); + const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey); const enabled = prefs.translationTargetLanguage.trim() !== ''; return ( @@ -132,13 +135,27 @@ export function Translation() {
+ +
{t('translation.hotkey.title', 'Translation shortcut')}
+
+ {t('translation.hotkey.desc', 'Press this during recording to switch the current dictation into translation mode.')} +
+ { + await setTranslationHotkey(binding); + await savePrefs({ ...prefs, translationHotkey: binding }); + }} + /> +
+ {/* 3. 使用方法 */}
{t('translation.howto.title')}
  1. {t('translation.howto.step1', { trigger: triggerLabel })}
  2. {t('translation.howto.step2', { trigger: triggerLabel })}
  3. -
  4. {t('translation.howto.step3')}
  5. +
  6. {t('translation.howto.step3', { shortcut: translationHotkeyLabel })}
  7. {t('translation.howto.step4')}
  8. {t('translation.howto.step5')}
diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 4d3c21b0..15f5a66e 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -8,7 +8,7 @@ import { useState, type ReactNode, } from 'react'; -import { getHotkeyCapability, getSettings, setSettings } from '../lib/ipc'; +import { getHotkeyCapability, getSettings, isTauri, setSettings } from '../lib/ipc'; import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types'; import i18n, { outputPrefsForLocale, type SupportedLocale } from '../i18n'; @@ -56,6 +56,34 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { void refresh(); }, [refresh]); + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('prefs:changed', event => { + const nextPrefs = event.payload; + if (!nextPrefs) return; + latestPrefsRef.current = nextPrefs; + setPrefs(nextPrefs); + }); + if (cancelled) { + handle(); + } else { + unlisten = handle; + } + } catch (error) { + console.warn('[settings] prefs:changed listener setup failed', error); + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + useEffect(() => { latestPrefsRef.current = prefs; }, [prefs]); diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index 2bbe9feb..956cd1fa 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -75,6 +75,12 @@ WIN32;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) true stdcpp17 + + MultiThreadedDebug Windows @@ -89,6 +95,7 @@ WIN32;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) true stdcpp17 + MultiThreadedDebug Windows @@ -105,6 +112,7 @@ WIN32;NDEBUG;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) true stdcpp17 + MultiThreaded Windows @@ -123,6 +131,7 @@ WIN32;NDEBUG;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) true stdcpp17 + MultiThreaded Windows diff --git a/openless-all/app/windows-ime/src/dllmain.cpp b/openless-all/app/windows-ime/src/dllmain.cpp index 84476ba2..624c79f7 100644 --- a/openless-all/app/windows-ime/src/dllmain.cpp +++ b/openless-all/app/windows-ime/src/dllmain.cpp @@ -13,9 +13,13 @@ LONG g_object_count = 0; BOOL APIENTRY DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { UNREFERENCED_PARAMETER(reserved); + // 不调用 DisableThreadLibraryCalls:DLL 现在用 /MT 静态链接 CRT,CRT 需要 + // DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知做 per-thread TLS 初始化与清理。 + // 在 host 进程(如 QQ / Office)切输入法新建 input thread 时禁用通知,会让 + // 静态 CRT 的 thread-local 资源泄漏 / 行为不稳定,反而把这次想修的崩溃问题 + // 重新引回来。详见 Microsoft 文档 DisableThreadLibraryCalls 备注。 if (reason == DLL_PROCESS_ATTACH) { g_module = instance; - DisableThreadLibraryCalls(instance); } return TRUE; diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 00000000..45d86ed4 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# 同步更新 OpenLess 四处版本号。 +# 用法: +# ./scripts/bump-version.sh 1.2.21 +# +# 改的位置(CLAUDE.md 强调必须同时改,否则 release-tauri.yml 失败): +# - openless-all/app/package.json "version": "X.Y.Z" +# - openless-all/app/package-lock.json 根包 version + 嵌套引用 +# - openless-all/app/src-tauri/tauri.conf.json "version": "X.Y.Z" +# - openless-all/app/src-tauri/Cargo.toml version = "X.Y.Z" (顶层) +# - openless-all/app/src-tauri/Cargo.lock 通过 cargo update -p openless 同步 +# +# CI 的 cross-platform 任务最后一步会校验四个文件版本号一致;漏改一处直接 fail。 + +set -euo pipefail + +if [ "${1:-}" = "" ]; then + echo "用法: $0 " >&2 + echo "例: $0 1.2.21" >&2 + exit 1 +fi + +NEW="$1" + +if ! [[ "$NEW" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "错误:版本号必须是 X.Y.Z 数字格式 (拿到 '$NEW')" >&2 + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP="$REPO_ROOT/openless-all/app" + +PKG_JSON="$APP/package.json" +PKG_LOCK="$APP/package-lock.json" +TAURI_CONF="$APP/src-tauri/tauri.conf.json" +CARGO_TOML="$APP/src-tauri/Cargo.toml" +CARGO_LOCK="$APP/src-tauri/Cargo.lock" + +for f in "$PKG_JSON" "$PKG_LOCK" "$TAURI_CONF" "$CARGO_TOML" "$CARGO_LOCK"; do + if [ ! -f "$f" ]; then + echo "错误:找不到 $f" >&2 + exit 1 + fi +done + +# package.json + package-lock.json:npm version 一行同步两个,且不打 git tag。 +# --allow-same-version 让脚本可重复运行(实际 release flow 不会,但 dry-run 友好)。 +echo "▶ 升 package.json + package-lock.json → $NEW" +( cd "$APP" && npm version "$NEW" --no-git-tag-version --allow-same-version > /dev/null ) + +# tauri.conf.json:BSD sed 与 GNU sed 都支持 -E + -i.bak 后缀;不用行号范围地址。 +echo "▶ 升 tauri.conf.json → $NEW" +sed -E -i.bak \ + "s/\"version\":[[:space:]]*\"[0-9]+\.[0-9]+\.[0-9]+\"/\"version\": \"$NEW\"/" \ + "$TAURI_CONF" +rm "$TAURI_CONF.bak" + +# Cargo.toml:用 awk 替换文件里第一个 version = "X.Y.Z" 行(顶层 [package].version)。 +# 不用 GNU sed 的 `0,/.../` 行号范围地址(macOS BSD sed 不支持)。 +echo "▶ 升 Cargo.toml → $NEW" +awk -v new="$NEW" ' + !done && /^version = "[0-9]+\.[0-9]+\.[0-9]+"$/ { + sub(/"[0-9]+\.[0-9]+\.[0-9]+"/, "\"" new "\"") + done = 1 + } + { print } +' "$CARGO_TOML" > "$CARGO_TOML.tmp" +mv "$CARGO_TOML.tmp" "$CARGO_TOML" + +# Cargo.lock:cargo update 显式同步 openless package;失败要立刻退出,不能吞错。 +echo "▶ 同步 Cargo.lock" +( cd "$APP/src-tauri" && cargo update -p openless 2>&1 | tail -5 ) + +# 校验五处一致(package.json / package-lock.json / tauri.conf.json / Cargo.toml / Cargo.lock) +echo +echo "===== 验证版本一致性 =====" +PKG=$(node -p "require('$PKG_JSON').version") +LOCK_ROOT=$(node -p "require('$PKG_LOCK').version") +LOCK_NESTED=$(node -p "require('$PKG_LOCK').packages[''].version") +TAU=$(node -p "require('$TAURI_CONF').version") +CRG=$(grep -E '^version = ' "$CARGO_TOML" | head -1 | sed -E 's/^version = "(.+)"$/\1/') +CARGO_LOCK_VER=$(awk '/^name = "openless"$/{getline; if (match($0, /version = "([0-9.]+)"/, a)) {print a[1]; exit}}' "$CARGO_LOCK" 2>/dev/null \ + || awk 'BEGIN{found=0} /^name = "openless"$/{found=1; next} found && /^version = /{gsub(/"/,""); print $3; exit}' "$CARGO_LOCK") + +printf '%-22s %s\n' 'package.json:' "$PKG" +printf '%-22s %s\n' 'package-lock root:' "$LOCK_ROOT" +printf '%-22s %s\n' 'package-lock nested:' "$LOCK_NESTED" +printf '%-22s %s\n' 'tauri.conf.json:' "$TAU" +printf '%-22s %s\n' 'Cargo.toml:' "$CRG" +printf '%-22s %s\n' 'Cargo.lock (openless):' "$CARGO_LOCK_VER" + +mismatch=0 +for v in "$LOCK_ROOT" "$LOCK_NESTED" "$TAU" "$CRG" "$CARGO_LOCK_VER"; do + if [ "$v" != "$NEW" ]; then mismatch=1; fi +done + +if [ "$mismatch" -ne 0 ] || [ "$PKG" != "$NEW" ]; then + echo + echo "::error::版本号未对齐 — 请检查脚本输出" >&2 + exit 1 +fi + +echo +echo "✓ 全部一致:$NEW" +echo +echo "下一步建议:" +echo " git add $PKG_JSON $PKG_LOCK $TAURI_CONF $CARGO_TOML $CARGO_LOCK" +echo " git commit -m 'chore(release): $NEW'" +echo " git push" +echo " git tag v$NEW-tauri && git push origin v$NEW-tauri"