Issue #295 calls out that core Rust paths have been too dependent on manual app runs. Keep the change narrow by covering existing pure state/audio/clipboard helpers instead of refactoring coordinator or touching hardware-facing code, then wire the existing lib test suite into the lightweight CI lane. Constraint: Global hotkey, recorder, and insertion integration still require real OS permissions and devices. Rejected: Split coordinator before adding tests | broader refactor would raise review risk and is tracked separately. Confidence: high Scope-risk: narrow Directive: Keep Rust CI on --lib unless integration tests get explicit OS fixtures. Tested: npm run build Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib Tested: cargo check --manifest-path src-tauri/Cargo.toml Not-tested: Live microphone, global hotkey, clipboard, and insertion device paths. Related: #295
13 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.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)
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)
# 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.appopenless-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 backend unit tests are run with cargo test --manifest-path src-tauri/Cargo.toml --lib; hardware / OS-integration behavior is still 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 + platform credential vault
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.
BufferingAudioConsumerqueues PCM until the WebSocket is ready, then drains. Recorder always pushes to it; ASR is attached afteropenSessionresolves.- 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.appis hard-coded inopenless-all/app/src-tauri/tauri.conf.jsonandCredentialsVault.serviceName. Changing it breaks system credential vault lookups and every existing TCC grant. - TCC: Microphone + Accessibility + AppleEvents.
NSMicrophoneUsageDescription/NSAccessibilityUsageDescription/NSAppleEventsUsageDescriptionlive inopenless-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 the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service
com.openless.app. The legacy plaintext JSON (~/.openless/credentials.jsonon macOS/Linux,%APPDATA%\OpenLess\credentials.jsonon 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 renamedictionary.jsontovocab.json(drops user data). - Windows:
%APPDATA%\OpenLess\ - Linux:
$XDG_DATA_HOME/OpenLess
- macOS:
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 both version fields: openless-all/app/package.json and openless-all/app/src-tauri/tauri.conf.json (and Cargo.toml).
Branch & release-channel workflow
Two-channel branching. Branch name = release channel.
beta— Beta channel (开发版). Default branch, integration buffer. All PRs targetbeta(nevermain). Beta builds may exist but are not pushed to general users — only opt-in users on the Beta channel see them.main— Stable channel (正式版). Always-releasable. Updated only bybeta → mainmerges performed by maintainers after a two-platform smoke build. Release tagsv<version>-tauriare pushed onmainand triggerrelease-tauri.yml(tag-driven; unaffected by branch renames).
Per-PR contract:
- Run the change locally on your target platform before opening the PR (build green + manual verification of the affected feature).
pr-agent.ymlruns one AI review pass per PR — treat it as advisory, do not iterate on it.- Keep AI rework rounds tight (1–2). If a fix resists, escalate to a human or restart with fresh context.
ci.ymlruns on push/PR for bothmainandbeta; no extra wiring needed when adding new branches offbeta.
For maintainers:
- Merge
beta → mainonly after the two-platform (macOS + Windows) smoke build passes. Beta work must not leak to Stable — that gate exists for a reason. - Tag
v<version>-taurionmain, not onbeta. The release workflow keys off the tag, but tagging onmainkeeps the release commit linear with the always-releasable line. - Avoid direct pushes to
mainoutside thebeta → mainmerge — it bypasses the smoke-test gate.
Channel distribution (manual-download opt-in):
- Tag convention.
v<v>-tauri→ Stable release (GitHubprerelease=false, manifestlatest-{tgt}-{arch}.json).v<v>-beta-tauri→ Beta release (GitHubprerelease=true, manifestlatest-{tgt}-{arch}-beta.json). The two manifest filenames never overlap, so the in-app updater endpoint (which is fixed at compile time to the no-suffix file) cannot pick up Beta releases. This is the physical isolation that guarantees Beta does not leak to Stable users. - Why not auto-update for Beta.
tauri-plugin-updater2.10'sBuilderdoes not exposeendpoints()— endpoints are only readable fromtauri.conf.jsonat build time and cannot be swapped at runtime. Rather than fork the plugin or write a custom updater (~500 lines, high risk), Beta opt-in is implemented as a manual-download flow: Settings → About has a "Join Beta channel" toggle that, when on, callsfetch_latest_beta_release(GitHub Releases API), shows the latest pre-release tag, and routes the user to the GitHub release page to download manually. No installer signing/install path needs to be re-implemented. - Where the wiring lives. Pref field:
UserPreferences::update_channel(types.rs). IPC:get_update_channel/set_update_channel/fetch_latest_beta_release(commands.rs). UI:BetaChannelControlinsideAboutMini(SettingsModal.tsx). i18n:settings.about.betaChannel*keys.
Release verification checklist (run after every tag push)
Run after pushing either a v*-tauri or v*-beta-tauri tag, before announcing the release:
- GitHub Release page matches expectation:
- Stable tag: not marked
Pre-release, in thereleases/latestredirect. - Beta tag: marked
Pre-release, not the target ofreleases/latest.
- Stable tag: not marked
- Release assets are channel-correct:
- Stable tag includes
latest-{darwin,windows,linux}-{aarch64,x86_64}.json+ their-mirror.jsonsiblings, without-betasuffix. - Beta tag includes
latest-{tgt}-{arch}-beta.json+-beta-mirror.json, without the no-suffix variant.
- Stable tag includes
- Stable user flow. Install a Stable build, click
Settings → About → Check for updates. After a Stable release: should offer the new version. After a Beta release only: should report "up to date" (Beta must not appear). - Beta user flow. In the same Stable build, toggle on
Join Beta channel. The latest Beta tag should appear (or "no Beta released yet"). Clicking the download button should open the corresponding GitHub release page. - Updater endpoint sanity.
curl -fsSL https://github.com/appergb/openless/releases/latest/download/latest-darwin-aarch64.jsonreturns the Stable manifest (version field matches the latest Stable tag). It should never return a Beta version, regardless of which tag was pushed most recently.
If any step fails, do not announce the release; investigate release-tauri.yml channel detection (endsWith(github.ref_name, '-beta-tauri')) and the OPENLESS_RELEASE_CHANNEL env propagation in the run logs.
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.tsis source of truth. - macOS hotkey monitor must use native
CGEventTap, neverrdev.rdevsynchronously callsTSMGetInputSourcePropertyfrom non-main threads, which macOS 14+ aborts viadispatch_assert_queue_fail→ SIGTRAP. macOS uses CGEventTap;rdevis only used on Linux/Windows. - Don't
NSApp.activateon the dictation path — it steals focus and breaks insertion. Only callset_activation_policy(Regular)+activateIgnoringOtherAppsfromshow_main_window/ mic-permission prompts, never fromstart_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 incoordinator.rs, not in the leaf modules.
Adding a new module
- Add a
<name>.rs(or directory) underopenless-all/app/src-tauri/src/, importing only fromtypes. - Register it in
lib.rs(mod <name>;). - Wire it into
coordinator.rsand expose any frontend-callable surface viacommands.rs+invoke_handler!. - Add the matching TS wrapper in
openless-all/app/src/lib/ipc.ts(with a mock branch for browser dev).