Files
openless/AGENTS.md
T
baiqing 003b713d52 docs(agents): 写入 Windows CI 红线,避免下一个 agent 重复踩四颗雷
修复迭代里翻过四个不可合并的雷:
1. light.exe 调用要 -sice:ICE80(IME wxs 的 32-bit Component 落 64-bit Directory)
2. tauri build 必须拆 NSIS+MSI 两轮(Tauri 签名是 post-bundle 钩子)
3. Build (Windows) shell 必须 bash 不是 pwsh(pwsh 吃 JSON 引号)
4. Repair 假设 candle 已跑出 wixobj

把每条的 ✗ 反例写出来,附上 'CI 失败先看历史日志' 的固定动作和
'保持 bump+tag 流程' 的偏好声明,下一个 agent 就不需要从头复盘。
2026-05-05 07:25:03 +08:00

170 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project
OpenLess is a menu-bar/tray voice-input layer. Hold or toggle a global hotkey, speak, and the dictated text is polished and inserted at the current cursor in any app. Product principles, state machine, and module list live in `docs/openless-development.md` and `docs/openless-overall-logic.md` — read those before changing product behavior.
The active codebase lives at `openless-all/app/` and is **Tauri 2 + Rust backend + React/TS frontend**, targeting macOS 12+ and Windows. The legacy Swift implementation (Sources/, Tests/, Package.swift, appcast.xml, Sparkle pipeline) was removed in commit `34d2823`; do not resurrect it.
UI must match `openless-all/design_handoff_openless/*.jsx` pixel-for-pixel; the JSX is reference-only, never imported.
## Build, Run, Test
### Tauri (current — start here)
```bash
cd "openless-all/app"
npm ci
# Dev: vite at :1420 + tauri shell
npm run tauri dev
# Build .app (+ DMG) — use this script, not `tauri build` directly,
# because it threads Apple signing env vars and validates Info.plist.
./scripts/build-mac.sh # build, sign, install to /Applications, reset TCC
INSTALL=0 ./scripts/build-mac.sh # build only
# Frontend-only TS check
npm run build # = tsc && vite build
# Rust type-check without full compile
cargo check --manifest-path src-tauri/Cargo.toml
```
### Windows (cross-check only — no macOS runner in CI)
```powershell
# Preflight: verify toolchain
.\scripts\windows-preflight.ps1
# Build (requires Windows host or cross-compile target)
.\scripts\windows-build-gnu.ps1
```
Generated artifacts:
- `openless-all/app/src-tauri/target/release/bundle/macos/OpenLess.app`
- `openless-all/app/src-tauri/target/release/bundle/dmg/OpenLess_<version>_aarch64.dmg`
Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\Logs\openless.log` (Windows).
There is no test runner wired in for the frontend. `src/lib/providerSetup.test.ts` is a hand-rolled assertion script — run with `npx tsx src/lib/providerSetup.test.ts` if you need it. Rust side has no `cargo test` targets yet; behavior is verified by running the app.
## Architecture
`coordinator::Coordinator` is the **single owner of session state**. Hotkey edges drive a small phase enum (`Idle → Starting → Listening → Processing`); recorder, ASR, polish, insertion, and history are wired here and nowhere else. Library/module code never calls across modules — they each depend only on shared types.
```
Rust (openless-all/app/src-tauri/src) Purpose
────────────────────────────────────── ────────────────────────────────
types.rs Pure value types: DictationSession, PolishMode, HotkeyBinding, errors
hotkey.rs Global hotkey monitor (modifier-key edges)
recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback
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
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
permissions.rs TCC checks (Accessibility / Microphone)
Frontend (openless-all/app/src)
src/components/Capsule.tsx Capsule view + state enum
src/ (React) Main window UI: Overview / History / Vocab / Style / Settings
src/i18n/ react-i18next init + zh-CN / en resources
src/pages/_atoms.tsx Recoil atoms — global frontend state
src/state/HotkeySettingsContext.tsx HotkeySettings React context (capability + binding from backend)
```
### Dictation pipeline
```
hotkey edge (1st) → beginSession: Recorder.start → ASR.openSession → BufferingAudioConsumer.attach
hotkey edge (2nd) → endSession: Recorder.stop → ASR.sendLastFrame → awaitFinal → Polish → Insert → History.save
.cancelled → ASR.cancel, Recorder.stop, capsule .cancelled
```
Invariants:
- **Polish/ASR fallbacks are silent.** Missing Ark creds → insert raw transcript. Missing Volcengine creds → mock pipeline copies a placeholder. The contract is *"the user's words don't get lost"* — don't add hard errors here.
- **`BufferingAudioConsumer`** queues PCM until the WebSocket is ready, then drains. Recorder always pushes to it; ASR is attached after `openSession` resolves.
- **Hotkey is toggle-only**, not press-and-hold. The monitor yields one edge per modifier-key keydown; the coordinator interprets odd/even.
### 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.
- **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.
- **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\`
- Linux: `$XDG_DATA_HOME/OpenLess`
### Release pipeline
Push a `v*-tauri` tag → `.github/workflows/release-tauri.yml` builds macOS arm64 `.dmg` and Windows x64 `.msi`. macOS Developer ID signing + notarization runs only when `APPLE_CERTIFICATE` / `APPLE_CERTIFICATE_PASSWORD` / `APPLE_ID` / `APPLE_PASSWORD` / `APPLE_TEAM_ID` secrets are set; otherwise it falls back to ad-hoc signing with a CI warning.
When bumping versions, update **all** version fields: `openless-all/app/package.json`, `openless-all/app/package-lock.json`, `openless-all/app/src-tauri/tauri.conf.json`, `openless-all/app/src-tauri/Cargo.toml`, `openless-all/app/src-tauri/Cargo.lock`. 漏一个就会 mismatch。
#### Windows CI 红线(不要踩同一颗雷两次)
Windows release 链路修过四颗雷,每一颗的 fix 都是不可合并的——"顺手统一" 一次就回归一次。改 `.github/workflows/release-tauri.yml` 的 Windows 段或 `windows-package-msvc.ps1` 之前必读:
1. **手动 light.exe 调用必须带 `-sice:ICE80`**
`wix/openless-ime.wxs` 把 x64 + x86 OpenLessIme.dll 都装进 `INSTALLDIR\windows-ime\`。32-bit Component 落 64-bit Directory 触发 ICE80 (LGHT0204),但 DLL 是绝对路径、不依赖 SysWOW64 重定向,按 Microsoft 文档是合法用法。Tauri 2 没暴露 light 透传参数,所以 *它自己* 的 light 调用必失败;CI workflow 的 "Repair Windows MSI" 步骤和 `windows-package-msvc.ps1::Repair-TauriMsiBundle` 用 `-sice:ICE80` 重链兜底。
- ✗ 不要去 "修" wxs 让 x86 落到 32-bit Directory(要么改 install 路径破坏 IME 注册,要么拆独立 32-bit MSI 是架构变更)。
- ✗ 不要从 Repair 调用里拿掉 `-sice:ICE80`。
2. **Windows `tauri build` 必须拆两轮 invoke,NSIS 先 / MSI 后**
```bash
tauri build --bundles nsis ... # Pass 1: 必成功(updater 硬依赖)
tauri build --bundles msi ... # Pass 2: 允许失败由 Repair 兜底
```
Tauri 2 的 updater 签名 (`.exe.sig`) 是 *post-bundle 钩子*——单次 `tauri build` 内任何 bundler 失败,**所有** bundle 的 signature 都跳过。MSI 必踩 ICE80(见 #1),所以单 pass 拿不到 NSIS 的 `.exe.sig`,`write-updater-manifest.mjs` 必报 "Missing updater signature"。
- ✗ 不要合并回 `--bundles nsis,msi` 单 pass。
- ✗ 不要移除 NSIS pass 的 `if [ "$nsis_exit" -ne 0 ]` fail-fast。
- ✗ 不要省略 `--bundles` 走默认 `targets: "all"`——Tauri 字母序 msi→nsis,MSI 一挂 NSIS 永不跑。
3. **Windows tauri build step 的 shell 必须 `bash`,不是 `pwsh`**
`pwsh` 调外部命令会吃掉 `'{"bundle":...}'` 的内部双引号,tauri 收到 `{bundle:...}` 当作无效 JSON 拒绝执行、连 candle 都不会跑。1.2.15 翻过一次。
- ✗ 不要因 "Windows 默认 pwsh 更顺" 而改回去。
4. **Repair 假设 candle 已跑出 wixobj**
Repair 步骤兜的是 *light 阶段* 失败。如果 Pass 2 在 candle 之前就挂(比如 JSON 引号问题、wxs 语法错),Repair 会以 "Required WiX object missing" 死掉——别去 "加强" Repair,先去修上游为什么 candle 没跑。
#### 修 Windows CI 之前的固定动作
不看历史日志就盲改 workflow 是这一段坑反复刷新的根因。每次 Windows job 失败按这个顺序:
1. `gh run view <id> --json jobs -q '.jobs[]|select(.name|contains("windows"))|.databaseId'` 拿 job id。
2. `gh api repos/appergb/openless/actions/jobs/<job-id>/logs > /tmp/win.log` 抓全日志。
3. `grep -n "ICE\|light\|makensis\|Bundling\|Running\|Tauri\|Error\|exit code" /tmp/win.log` 找事件序列。
4. `git show v1.2.13-tauri:.github/workflows/release-tauri.yml` 对比最后一个 known-good 版本——v1.2.13 是 IME wxs 加入前最后一次成功的 Windows release。
5. 实质 diff 锁定后再动 workflow / wxs / 脚本。
#### 发版流程(保持现状,不要改)
修 Windows CI 时按这个流程迭代:
1. 改 workflow / wxs / 脚本,提交到 main。
2. bump 五处版本号(见上)。
3. `git tag v<X.Y.Z>-tauri && git push origin v<X.Y.Z>-tauri` → CI 跑 → action-gh-release 自动发版。
`release-tauri.yml` 触发条件只有 `tags: [v*-tauri]` + `workflow_dispatch`。release publish 步骤 gated on tag,所以 dispatch run 跑了不发版。
- ✗ 不要把流程改成 "push main 自动跑 CI 验证再 tag"——已经讨论过否决了,现状的 bump+tag 流程是用户偏好。
- ✗ 不要 `--amend` 已 push 的 tag 或 force-push。失败的 tag 留着、bump 一个新版本号继续。
## Repo conventions
- **Comments, log messages, user-facing strings, and most docs are in Simplified Chinese.** UI strings additionally route through `react-i18next` (`src/i18n/{zh-CN,en}.ts`) so we ship English alongside; `zh-CN.ts` is source of truth.
- **macOS hotkey monitor must use native `CGEventTap`, never `rdev`.** `rdev` synchronously calls `TSMGetInputSourceProperty` from non-main threads, which macOS 14+ aborts via `dispatch_assert_queue_fail` → SIGTRAP. macOS uses CGEventTap; `rdev` is only used on Linux/Windows.
- **Don't `NSApp.activate` on the dictation path** — it steals focus and breaks insertion. Only call `set_activation_policy(Regular)` + `activateIgnoringOtherApps` from `show_main_window` / mic-permission prompts, never from `start_dictation`.
- Rust modules wrap shared mutable state with `Arc<Mutex<...>>` (parking_lot). Keep that locking discipline when adding fields.
- Rust modules depend only on `types.rs`. New cross-module wiring goes in `coordinator.rs`, not in the leaf modules.
### Adding a new module
1. Add a `<name>.rs` (or directory) under `openless-all/app/src-tauri/src/`, importing only from `types`.
2. Register it in `lib.rs` (`mod <name>;`).
3. Wire it into `coordinator.rs` and expose any frontend-callable surface via `commands.rs` + `invoke_handler!`.
4. Add the matching TS wrapper in `openless-all/app/src/lib/ipc.ts` (with a mock branch for browser dev).