6.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project
OpenLess is a native macOS menu-bar voice-input layer (SwiftPM, macOS 15+, Swift 5.9). User holds/toggles a global hotkey, speaks, and the dictated text is polished and inserted at the current cursor in any app. The 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.
Build, Run, Test
The app must run as a bundled .app to receive TCC entitlements (Accessibility, Microphone, AppleEvents). Running swift run directly will leave permission prompts broken.
# Library/test build (no bundle, fastest)
swift build
swift test
swift test --filter OpenLessCoreTests.PolishModeTests/<method> # single test
# Full app: builds release, assembles build/OpenLess.app, ad-hoc codesigns,
# kills the running instance, and resets TCC by default
./scripts/build-app.sh
# Keep existing TCC approvals across rebuilds
RESET_TCC=0 ./scripts/build-app.sh
# Launch
open build/OpenLess.app
# Useful launch args (handled in AppDelegate.runLaunchActions)
open build/OpenLess.app --args --open-settings
open build/OpenLess.app --args --start-recording
# Live logs
tail -f ~/Library/Logs/OpenLess/OpenLess.log
After a fresh build with RESET_TCC=1 (default), macOS will re-prompt for Accessibility + Microphone. The app must be fully quit and relaunched after granting Accessibility before the global hotkey tap works.
Architecture
SwiftPM workspace with one executable (OpenLessApp) and seven libraries. Every library depends only on OpenLessCore — there are no library-to-library deps. OpenLessApp is the only place that wires them together (DictationCoordinator).
OpenLessCore // Pure value types: DictationSession, PolishMode, HotkeyBinding,
// AudioConsumer protocol, RawTranscript/FinalText, errors.
// No AppKit. Every other target imports this.
OpenLessHotkey // CGEventTap-based modifier-key monitor. Emits .toggled / .cancelled
// on an AsyncStream. Requires Accessibility permission.
OpenLessRecorder // AVAudioEngine → 16 kHz mono Int16 PCM, RMS level callback,
// pushes Data chunks into an AudioConsumer.
OpenLessASR // Volcengine streaming ASR over WebSocket (binary framing in
// VolcengineFrame). Implements AudioConsumer so the recorder
// feeds it directly.
OpenLessPolish // Doubao (Ark) chat-completions client. Mode-driven prompts in
// PolishPrompts.
OpenLessInsertion // TextInserter: AX focused-element write first, then clipboard
// + simulated Cmd+V, then copy-only fallback.
OpenLessPersistence // CredentialsVault (Keychain), HistoryStore (JSON in Application
// Support), UserPreferences (UserDefaults).
OpenLessUI // Capsule SwiftUI view + state enum (no window plumbing).
OpenLessApp // AppDelegate, MenuBarController, SettingsWindowController,
// CapsuleWindowController, Log, BufferingAudioConsumer,
// DictationCoordinator (the state machine).
Dictation pipeline
DictationCoordinator (Sources/OpenLessApp/DictationCoordinator.swift) is the single owner of session state. It is @MainActor and uses a one-bit inSession toggle driven by HotkeyMonitor.events:
.toggled (1st) → beginSession:
Recorder.start (mic, level callback → capsule)
VolcengineStreamingASR.openSession (WebSocket)
BufferingAudioConsumer.attach(asr) // any pre-connect audio is replayed
.toggled (2nd) → endSession:
Recorder.stop, ASR.sendLastFrame, awaitFinalResult
DoubaoPolishClient.polish(raw, mode)
TextInserter.insert(final) // .inserted | .copiedFallback
HistoryStore.save(DictationSession)
CapsuleWindowController hides after delay
.cancelled → ASR.cancel, Recorder.stop, capsule .cancelled
Important behaviors:
- Polish/ASR fallbacks are silent: if Ark creds are missing or polish fails, the raw transcript is inserted; if Volcengine creds are missing, a mock pipeline runs and copies a placeholder. Don't add hard errors here — the contract is "user's words don't get lost."
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
.toggledper modifier-key keydown; the coordinator interprets odd/even.
Permissions, credentials, on-disk state
- TCC:
Info.plistdeclaresNSMicrophoneUsageDescriptionandNSAppleEventsUsageDescription. Accessibility is requested at first run viaAXIsProcessTrustedWithOptions.LSUIElement = true(menu-bar only, no Dock icon). - Bundle ID is
com.openless.app(hard-coded inscripts/build-app.shandCredentialsVault.serviceName). Changing it breaks Keychain lookups and all granted TCC entries. - Credentials live in Keychain under accounts defined in
CredentialAccount(volcengine.app_key,volcengine.access_key,volcengine.resource_id,ark.api_key,ark.model_id,ark.endpoint). Set them via the Settings window — never hard-code. - History:
~/Library/Application Support/OpenLess/history.json(JSON array, capped at 200 entries). - Logs:
~/Library/Logs/OpenLess/OpenLess.log. UseLog.writefromOpenLessApp; libraries don't log directly.
Adding a new module
- Add target in
Package.swiftunderSources/OpenLess<Name>, depending only onOpenLessCore. - Add it to
OpenLessApp's dependency list. - Wire it in
DictationCoordinator— do not call across libraries. - If it has pure logic worth testing, add a
Tests/OpenLess<Name>Teststarget alongsideOpenLessCoreTests.
Conventions specific to this repo
- Comments, log messages, user-facing strings, and most doc files are in Simplified Chinese. Match that when editing existing strings; new internal types/APIs stay in English.
- All public API surface in libraries is
Sendable; concurrency uses@MainActorfor UI/coordinator code and@unchecked Sendablewith explicit locks for audio/ASR classes that bridge C APIs (Recorder,VolcengineStreamingASR,TextInserter). - The capsule window is
LSUIElement-friendly: never callNSApp.activatefrom the dictation path (it steals focus and breaks insertion). The only exception is the mic-permission prompt inbeginSession. - When bumping
APP_VERSION/BUILD_NUMBERinscripts/build-app.sh, do both — they go straight intoInfo.plistasCFBundleShortVersionString/CFBundleVersion.