diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d83ad2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test + + - name: Build + run: bun run build + + - name: Check whitespace + run: git diff --check origin/main...HEAD + + - name: Fail on tracked changes + run: git diff --exit-code -- diff --git a/.gitignore b/.gitignore index b1aa56c..588723d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,6 @@ trial/ docs/ issues/ prd/ +.wayfinder/ COMPARISON_REPORT.md VAANI_IMPROVEMENT_PLAN.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e807c..af6948e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 1.2.0 - 2026-08-12 + +### Added + +- Added build identifiers and structured dictation traces for transcription quality, provider attempts, insertion attempts, and verification outcomes. +- Added a pull-request CI workflow for frozen installs, type checking, unit tests, packaging, whitespace validation, and tracked-file cleanliness on macOS. +- Added startup checks for Microphone and Accessibility access, with non-dismissible guidance when either permission is missing and recording blocked from the tray and hotkeys until access is granted. +- Added opt-in engine support for fuzzy dictionary matching, bare spoken snippet triggers, and per-app snippet scope. These options are not yet configurable in the UI. + +### Changed + +- Moved provider API keys to macOS Keychain, with startup migration of legacy keys and secret-free provider metadata retained in settings. +- Made long-recording transcription silence-aware, with overlapping chunks, model escalation, and timeout scaling based on chunk count. +- Applied dictionary corrections before transcript formatting so formatters receive the intended spelling. +- Preserved credential metadata during settings updates and dictionary rule metadata during unrelated edits. + +### Fixed + +- Hardened IPC validation and authorization, Keychain key updates and deletion, packaged native-addon loading, media permissions, window navigation, and local JSON file permissions. +- Fixed insertion verification so only a literal occurrence-count increase over a readable pre-insertion baseline passes, including fallback targets and partial-suffix repair. +- Excluded unreadable pre-insertion baselines from insertion acceptance denominators and per-app buckets. +- Reported denied microphone access as a permission failure instead of misclassifying it as no speech. +- Scaled long-recording deadlines and classified transcription deadline failures as timeouts. + +### Validation + +- `bun run test -- tests/unit/dictation.test.ts tests/unit/insertionAcceptance.test.ts tests/unit/dictationTraceStore.test.ts` passed. +- Focused permission, hotkey, dictation, insertion, and trace tests passed: `bun run test -- tests/unit/permissionGuard.test.ts tests/unit/hotkeys.test.ts tests/unit/dictation.test.ts tests/unit/insertionAcceptance.test.ts tests/unit/dictationTraceStore.test.ts`. +- `bun run typecheck` passed. + ## 1.1.0 - 2026-06-12 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 07a27ac..606f7fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,8 @@ Premium macOS voice dictation app — Electron Forge + Vite + React + TypeScript - `bun run build` — package app locally - `bun run make` — create platform artifacts under `out/make/` - `bun run typecheck` — TypeScript check (no emit) -- `bun test` — Vitest unit tests (`tests/**/*.test.ts`) +- `bun run test` — Vitest unit tests (`tests/**/*.test.ts`). NOT `bun test` — that invokes Bun's own + runner instead of Vitest and reports ~49 bogus failures plus unhandled errors. ## Architecture (read-only summary — do NOT re-read source files for this) - `src/main/` — Electron main process (dictation, injection, tray, overlay, stores, native bridge) diff --git a/README.md b/README.md index 3845646..3fa1d51 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ The built app and DMG will be in `out/make/`. 2. Open Vaani → Settings → paste your key(s) 3. Or skip cloud entirely — select **Local (whisper.cpp)** for offline transcription +Provider API keys are stored in macOS Keychain. Keys left in legacy settings are migrated to Keychain and removed from the settings file on startup. + ### 2. Accessibility Permission On first launch Vaani will prompt for Accessibility access: @@ -79,6 +81,8 @@ This is required for global hotkeys and text injection. Vaani requests microphone access on first use. Click **Allow**. +On every startup, Vaani checks both Microphone and Accessibility access and guides you to **System Settings** if either permission is missing. Rebuilt ad-hoc apps may need these permissions granted again. + ## Usage ### Dictation @@ -95,6 +99,8 @@ Press `Ctrl+Cmd+V` to re-insert your most recent dictation. Type `/` followed by a snippet name while dictating to expand it. +Phase 3 also supports opt-in fuzzy dictionary matching, bare spoken snippet triggers, and per-app snippet scope in the engine. These advanced options are not yet configurable in the UI. + ### Tips - Speak at a normal pace; no need to slow down @@ -145,7 +151,7 @@ src/ ├── main/ # Electron main process │ ├── providers/ # Multi-provider STT + LLM engine (groq, openai, deepgram, anthropic, local, openai-compatible) │ ├── injection/ # AX + clipboard + keystroke injection (5 strategies, per-app policies) -│ ├── store/ # Settings & history (JSON, ~/.vaani/) +│ ├── store/ # Keychain credentials plus local settings, history, and traces │ ├── native/ # C++/Obj-C native addons (hotkey, injection, audio, whisper) │ └── text/ # Cleanup and formatting ├── renderer/ # React UI (pages, components, hooks, overlay) @@ -174,7 +180,8 @@ bun run typecheck # TypeScript check - Audio is never stored locally or on any server - Cloud transcription sends audio to your selected provider's API; their privacy policies apply - Local whisper.cpp mode keeps all audio on-device -- Settings and history are stored locally in `~/.vaani/` +- Provider API keys are stored in macOS Keychain; legacy settings keys migrate there on startup +- Non-secret settings, history, and dictation traces are stored locally in `~/.vaani/` - No telemetry or analytics ## Known Limitations @@ -184,12 +191,10 @@ bun run typecheck # TypeScript check - Very short phrases (< 3 words) may not inject reliably in some apps - **Stale state after extended uptime** — App may become unresponsive after ~16 hours of continuous use. Restarting Vaani resolves this. Auto-recovery watchdog added in v1.0.4; root cause investigation ongoing. - **Capsule overlay** — The recording overlay (bottom-center pill) may occasionally not appear when dictation starts. It typically reappears on the next attempt. Visibility retry logic added in v1.0.4. -- API keys are stored in plain JSON on disk. Keychain integration is planned for v1.1. - Notarization requires Apple Developer credentials. See installation workaround below. -## Roadmap (v1.1+) +## Roadmap -- **macOS Keychain integration** — Secure API key storage replacing plain JSON - **Persistent stale state fix** — Root cause investigation and fix for long-uptime unresponsiveness - **Capsule reliability** — Eliminate intermittent overlay non-appearance - **Improved offline support** — Smarter offline/online switching without user intervention diff --git a/package.json b/package.json index 7898f9e..777a8ac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vaani", "productName": "Vaani", - "version": "1.1.3", + "version": "1.2.0", "description": "Premium macOS voice dictation powered by Groq Whisper.", "main": ".vite/build/main.js", "gypfile": true, diff --git a/plans/006-ship-readiness-text-pipeline-phase3.md b/plans/006-ship-readiness-text-pipeline-phase3.md new file mode 100644 index 0000000..ab17af0 --- /dev/null +++ b/plans/006-ship-readiness-text-pipeline-phase3.md @@ -0,0 +1,421 @@ +# Plan 006: Make `feat/text-pipeline-phase3` shippable + +> **Executor instructions**: Follow this plan step by step. Run every verification +> command and confirm the expected result before moving to the next step. If +> anything in the "STOP conditions" section occurs, stop and report; do not +> improvise. When done, update the status row for this plan in `plans/README.md`. +> +> **Drift check (run first)**: +> `git diff --stat 279da30..HEAD -- src/main/ipc.ts src/main/dictation.ts src/main/transcription.ts src/renderer/components/SettingsModal.tsx src/renderer/context/vaani-ui.tsx tests/unit/transcriptionChain.test.ts` +> If any in-scope file changed since this plan was written, compare the "Current +> state" excerpts against live code before proceeding; on a mismatch, treat it as a +> STOP condition. + +## Status + +- **Priority**: P0 (release blocker set) +- **Effort**: L +- **Risk**: MED +- **Depends on**: none +- **Category**: bug / release readiness +- **Planned at**: commit `279da30`, branch `feat/text-pipeline-phase3`, 2026-08-07 + +## Why this matters + +The branch is 10 commits ahead of `main` (`1acddb9`) and carries the security audit, +silence-aware chunking, and Phase 3 text-pipeline work. It cannot ship as-is: + +- A clean checkout of the committed tree **fails 2 tests** — CI would be red on the + first push. +- Two committed data-loss bugs (API keys, dictionary metadata) affect real user + state, not just internals. +- Long recordings — the headline capability of the chunking commit — can time out. +- The working tree holds 11 modified tracked files plus 5 untracked files, and + tracked code imports untracked modules, so a partial commit breaks the build. + +## Verification baseline (already established) + +| Check | Result | +|---|---| +| Worktree `bun run test` | 360/360 pass | +| Worktree `bun run typecheck` | pass | +| Worktree `bun run build` (native + package) | pass | +| `git diff --check main` | clean | +| **Clean committed HEAD `bun run test`** | **2 failed, 342 passed** | +| Clean committed HEAD `bun run typecheck` | pass | + +Reproduce the committed-tree gate with: + +```bash +tmp=$(mktemp -d); git archive HEAD | tar -x -C "$tmp" +ln -s "$PWD/node_modules" "$tmp/node_modules" +bun run --cwd "$tmp" test && bun run --cwd "$tmp" typecheck +``` + +This snapshot check is the single most important gate in this plan: the worktree +passing tells us nothing about what a PR would run. + +--- + +## Step 1 — Land the working tree atomically (P0, unblocks everything) + +**Problem.** `src/main/dictation.ts:46-47` and `vite.main.config.ts:4` import +`@shared/buildIdentifier` and `@shared/insertionAcceptance`, both untracked. A +tracked-only commit (`git commit -am`) produces unresolved-module build failures. + +**Current state.** + +- Modified tracked: `CLAUDE.md`, `src/main/dictation.ts`, + `src/main/dictationTraceSnapshot.ts`, `src/main/store/dictationTrace.ts`, + `src/shared/types.ts`, `vite.main.config.ts`, 5 test files. +- Untracked: `src/shared/buildIdentifier.ts`, `src/shared/insertionAcceptance.ts`, + `tests/unit/buildIdentifier.test.ts`, `tests/unit/insertionAcceptance.test.ts`, + `scripts/ghostty-trial.mjs`. + +**Actions.** + +1. Split into two commits, each self-consistent: + - **Commit A — test corrections only**: `tests/unit/transcriptionChain.test.ts` + (3-attempt and 28s-chunk expectations). This alone makes the committed tree + green and is the fix for Step 2. + - **Commit B — build identifier + insertion acceptance**: the two `src/shared/` + modules, their two tests, `src/main/dictation.ts`, + `src/main/dictationTraceSnapshot.ts`, `src/main/store/dictationTrace.ts`, + `src/shared/types.ts`, `vite.main.config.ts`, plus the remaining test edits. +2. Decide on `scripts/ghostty-trial.mjs`. It writes results to `.wayfinder/research/`, + which commit `b4ab696` deliberately keeps out of the public repo. Either + (a) retarget its `RESULTS_PATH` to a repo-visible or `/tmp` location and commit it + as a documented manual harness, or (b) leave it untracked. Do not commit it with + the `.wayfinder` path. +3. `CLAUDE.md` change is documentation — fold into Commit B. + +**Verification.** After both commits, the snapshot check above must exit 0. + +**STOP condition.** If splitting produces a commit whose snapshot check fails, do not +push; re-stage until each commit is independently green. + +--- + +## Step 2 — Fix the committed test failures (P0) + +**Problem.** The committed tree asserts behavior the committed implementation does +not have. + +**Current state.** + +```ts +// tests/unit/transcriptionChain.test.ts:220 (committed) +expect(groqTranscribe).toHaveBeenCalledTimes(2); +// tests/unit/transcriptionChain.test.ts:378 (committed) +).toEqual([30, 30, 30, 30, 30, 20]); +``` + +`buildTranscriptionAttempts` (`src/main/transcription.ts:298`) yields original clip + +retry clip + stronger-model attempt = 3 calls. `snapChunkEndToSilence` +(`src/main/transcription.ts:334`) snaps boundaries, producing 28s chunks for the +fixture. + +**Actions.** The corrected expectations already exist uncommitted (3 calls, `[28,…]`, +renamed test titles). Land them via Commit A in Step 1. + +**Decision required before accepting the numbers as correct**: confirm 3 STT calls per +suspicious single-provider transcript is intended. It triples cost and latency on the +worst-quality audio. If not intended, fix `buildTranscriptionAttempts` instead of the +test and keep the 2-call expectation. + +**Verification.** `bun run test` green in the snapshot check. + +--- + +## Step 3 — Stop API-key saves from deleting other providers' keys (P0, data loss) + +**Problem.** Editing one provider's key silently deletes every other provider's key +from the keychain. + +**Chain.** + +```ts +// src/main/ipc.ts:303-321 — every provider is returned with key: '' +mapped = providerApiKeys.map(pk => ({ providerId: pk.providerId, key: '', hasKey: ... })) +``` + +```tsx +// src/renderer/components/SettingsModal.tsx:245-250 — resubmits the whole array +const next = existing >= 0 ? current.map(...) : [...current, { providerId, key }] +void updateSettings({ providerApiKeys: next }) +``` + +```ts +// src/main/ipc.ts:444-450 — every entry is written, including the redacted '' +if (typeof pk.key === "string") await credentials.set(pk.providerId, pk.key); +``` + +```ts +// src/main/store/credentials.ts:84-92 — empty value means delete +if (!trimmed) { await this.delete(key); return; } +``` + +Result: save the OpenAI key → Groq, Deepgram, Anthropic keys are deleted. + +**Actions (pick one; option A recommended).** + +- **A — dedicated per-provider IPC mutation.** Add `SetProviderApiKey` / + `ClearProviderApiKey` channels. Renderer stops round-tripping the key array + entirely; `providerApiKeys` in settings becomes presence-only metadata. Clearing is + then an explicit user action, never an inferred one. +- **B — sentinel for "unchanged".** Keep the array round-trip but have + `buildRendererApiKeys` emit a sentinel (e.g. `key: null` / omitted) for stored keys, + and treat only an explicit empty string submitted from a touched field as a delete. + Cheaper, but leaves the fragile shape in place. + +**Tests to add** in `tests/unit/ipcSecurity.test.ts` (or a new `providerKeys.test.ts`): + +1. Three providers have keys; update only one → other two survive in the credentials + store. +2. Explicit clear of one provider deletes exactly that one. +3. Renderer-facing settings never expose key material (`key: ''`, `hasKey` correct). + +--- + +## Step 4 — Stop dictionary edits from destroying Phase 3 metadata (P0, data loss) + +**Problem.** Every settings update that carries `customCorrections` rewrites each +entry to three fields. + +```ts +// src/main/ipc.ts:288-301 +return [{ spoken, written, source: "manual" }]; +``` + +`CustomCorrection` (`src/shared/types.ts:210-220`) also has `enabled`, +`caseSensitive`, `wholeWord`, `fuzzy`, `hitCount`, `lastUsedAt`. All are dropped, and +`source: "auto-suggested"` is rewritten to `"manual"`. + +Consequences, all in shipped code paths: + +- `fuzzy` is read at `src/main/text/cleanup.ts:354` → fuzzy matching silently turns + off after any dictionary edit. +- `enabled: false` is read at `src/main/text/cleanup.ts:379` → disabled rules + silently re-enable. +- Provenance loss breaks "purge auto-suggested corrections". +- The renderer compounds it: `src/renderer/context/vaani-ui.tsx:230` also forces + `source: "manual"` on edit of an existing rule. + +**Actions.** + +1. Rewrite `sanitizeManualCustomCorrections` to validate-and-preserve: keep every + known optional field when present and well-typed; bound `hitCount`; validate + `lastUsedAt` as ISO; preserve existing `source`, defaulting to `"manual"` only for + entries that are new. +2. Rename it (e.g. `sanitizeCustomCorrections`) — it is no longer manual-only. +3. Fix `src/renderer/context/vaani-ui.tsx:230` to preserve `source` on update of an + existing entry. + +**Tests to add** in `tests/unit/ipcSecurity.test.ts`: + +1. Round-trip a correction with `fuzzy`, `enabled: false`, `hitCount`, `lastUsedAt`, + `source: "auto-suggested"` → all preserved. +2. Oversized/malformed fields still rejected (existing safety intact). +3. New manual entry without `source` still gets `source: "manual"`. + +--- + +## Step 5 — Give chunked long recordings a workable timeout (P1) + +**Problem.** Chunked transcription is sequential but the whole chain shares one 30s +deadline. + +```ts +// src/main/dictation.ts:50 +const TRANSCRIPTION_TIMEOUT_MS = 30_000; +// src/main/transcription.ts:290-293 — sequential per-chunk awaits +for (const [index, chunk] of chunks.entries()) { results.push(await provider.transcribe(chunk, options)); } +``` + +A 3-minute dictation is ~7 chunks; a 10-minute one is ~22. Cumulative latency passes +30s routinely → the user sees "Transcription timed out" while in-flight requests keep +burning provider quota, uncancelled. + +**Actions.** + +1. Replace the fixed deadline with a budget scaled to work: + `base + perChunk × chunkCount`, with an absolute ceiling. Chunk count is knowable + before the call from `clip.durationSeconds` and `MAX_SINGLE_STT_CLIP_SECONDS`. +2. Prefer a per-attempt deadline inside `transcribePossiblyChunked` over one outer + race, so a single wedged chunk fails fast instead of consuming the whole budget. +3. Pass an `AbortSignal` through `TranscriptionProvider.transcribe` so a timeout stops + subsequent chunk requests. If providers can't take a signal without a wider + refactor, at minimum add a `cancelled` check between chunk iterations — cheap, and + it stops the quota bleed for remaining chunks. +4. Same treatment for the demo path at `src/main/dictation.ts:617`. + +**Tests to add** in `tests/unit/transcriptionChain.test.ts` / +`tests/unit/dictation.test.ts`: + +1. A long clip whose per-chunk latency sums past 30s completes successfully. +2. On timeout, no further chunk requests are issued. + +--- + +## Step 6 — Make insertion verification compare against the baseline (P1, metric integrity) + +**Problem.** Verification accepts any occurrence of the expected text, including text +that was already there. + +```ts +// src/main/dictation.ts:1027 and the poll at :1075 +if (currentValue.includes(expectedText)) return { readable: true, passed: true, ... }; +``` + +The `baseline` parameter is only consulted later, via `extractInsertedFragment`. So +re-dictating the same sentence into a field that already contains it passes instantly +even if nothing was inserted — and that false pass feeds the 95% acceptance gate at +`src/shared/insertionAcceptance.ts:101-106`, which is the metric intended to decide +whether insertion is healthy enough to ship. + +**Actions.** + +1. Require a *new* occurrence: compare occurrence counts of `expectedText` in + `baseline` vs `currentValue`, or verify the expected range at the insertion point. +2. When `baseline` is null (unreadable before injection), record a distinct + `reason` and exclude that trace from acceptance eligibility rather than counting it + as success. +3. Keep the polling loop (`src/main/dictation.ts:1063`) — the 50ms/2s poll is a real + improvement over the old fixed 180ms sleep and should stay. + +**Tests to add** in `tests/unit/insertionAcceptance.test.ts` / +`tests/unit/dictation.test.ts`: + +1. Field already contains the expected sentence, injection inserts nothing → + verification fails. +2. Field contains one copy, injection adds a second → passes. +3. Baseline unreadable → trace excluded from acceptance counts. + +--- + +## Step 7 — Decide the Phase 3 UI surface (P2, scope decision — needed before "shipped") + +**Problem.** Phase 3 features default off and have no UI to turn on. + +- `fuzzy` defaults off (`src/main/text/cleanup.ts:354`), and the Dictionary form + (`src/renderer/pages/Dictionary.tsx:92-107`) exposes only trigger and replacement. +- `Snippet.matchBareTrigger` and `Snippet.appProfileIds` (`src/shared/types.ts:222-227`) + are consumed at `src/main/text/cleanup.ts:416,428` but never set by + `addSnippet` (`src/renderer/context/vaani-ui.tsx:242-258`). + +So the shipped behavior is reachable only by hand-editing `~/.vaani` settings JSON. + +**Decision — Option B, engine-only.** Land Phases 0–3 as pipeline infrastructure. +Fuzzy dictionary matching, bare spoken snippet triggers, and per-app snippet scope +remain opt-in engine/settings support and are not renderer-configurable in this +release. + +The follow-up UI must support editing existing entries, warn when an ordinary-word +bare trigger could collide with normal dictation, and represent “All apps” by +omitting `appProfileIds`. It must never encode “All apps” as `appProfileIds: []`. + +--- + +## Step 8 — Chunk-overlap merge quality (P2, quality) + +**Problem.** Overlap dedup only matches runs of ≥3 words. + +```ts +// src/main/transcription.ts:405 +for (let count = maxOverlap; count >= 3; count -= 1) { +``` + +With a 2-second overlap, a boundary landing on a 1–2 word span leaves a duplicated +phrase (`"hello world hello world"`) that the single-word duplicate cleanup won't +catch. + +**Actions.** Either lower the floor to 1–2 words guarded against false positives on +common words, or align chunks on timestamps where the provider returns segment times. +Add tests for 1-word and 2-word boundary overlaps. + +Lower priority than Steps 1–6: it degrades transcript quality at chunk seams, it does +not lose user data or fail the build. + +--- + +## Step 9 — Release hygiene, then open the PR + +1. `git diff --check main` — currently clean; keep it that way. (Trailing whitespace + at `src/main/ipc.ts:387,395` and `references/design.md:3` exists in older commits + relative to `initial-scaffold`; not worth a rewrite, but do not add more.) +2. `CHANGELOG.md` — add the entry covering: IPC validation and keychain hardening, + silence-aware chunking + model escalation, dictionary-before-formatting ordering, + fuzzy matching, bare snippet triggers, build identifier in traces. +3. `README.md` — the Known Limitations list still claims API keys are plain JSON with + "Keychain integration planned for v1.1". Keychain landed on this branch + (`src/main/store/credentials.ts`, commit `92965e5`). Update it. +4. Version bump in `package.json` (currently 1.1.3, matching tag `v1.1.3` on `main`). +5. Open the PR against `main`, not `initial-scaffold`. Confirm CI runs the committed + tree and is green before requesting review. + +--- + +## Sequencing + +| Order | Step | Blocking? | Rationale | +|---|---|---|---| +| 1 | Step 1 (atomic commits) | yes | Nothing else is verifiable until the tree is committable | +| 2 | Step 2 (test failures) | yes | Red CI blocks the PR | +| 3 | Step 3 (API keys) | yes | User data loss | +| 4 | Step 4 (dictionary metadata) | yes | User data loss; blocks Step 7A | +| 5 | Step 5 (timeout) | yes | Headline feature is unreliable | +| 6 | Step 6 (acceptance metric) | yes | Ship-gate metric is unsound | +| 7 | Step 7 (UI decision) | decision | Determines release-note honesty | +| 8 | Step 8 (overlap merge) | no | Quality; can follow the release | +| 9 | Step 9 (hygiene + PR) | yes | Final gate | + +Steps 3, 4, 5, 6 are independent of each other and can run in parallel once Step 1 +lands. + +## Verification gates (all must pass before the PR) + +```bash +# 1. committed-tree snapshot — the gate that actually matters +tmp=$(mktemp -d); git archive HEAD | tar -x -C "$tmp" +ln -s "$PWD/node_modules" "$tmp/node_modules" +bun run --cwd "$tmp" test && bun run --cwd "$tmp" typecheck + +# 2. worktree +bun run test && bun run typecheck + +# 3. packaging (native module must build) +bun run build + +# 4. whitespace +git diff --check main + +# 5. no untracked file is imported by tracked code +git ls-files --others --exclude-standard +``` + +Manual checks that unit tests cannot cover: + +- Configure two provider keys, edit one, restart the app → both keys still work. +- Add a dictionary rule with fuzzy enabled (via settings JSON until Step 7 lands), + then edit an unrelated rule from the UI → fuzzy flag survives. +- Dictate 3+ minutes into TextEdit → full transcript inserted, no timeout error. + +## STOP conditions + +- Snapshot check fails after any commit → stop, do not push. +- Step 3 or 4 fix cannot preserve existing on-disk user data across an app restart → + stop and report; a migration may be needed. +- Adding an `AbortSignal` to `TranscriptionProvider` requires changing more than the + six provider files under `src/main/providers/` → stop, fall back to the + between-chunk cancellation check. +- Any step tempts a broader `DictationService` refactor → out of scope; note it for a + follow-up plan. + +## Out of scope + +- `DictationService` decomposition (`src/main/dictation.ts` is ~1200 lines on this + branch). +- Notarization / signing credentials. +- The stale-state-after-16-hours and capsule-overlay issues in README Known + Limitations. +- Reworking the 3-attempt STT escalation policy beyond the Step 2 decision. diff --git a/plans/README.md b/plans/README.md index 67afc7b..f8218bc 100644 --- a/plans/README.md +++ b/plans/README.md @@ -15,6 +15,7 @@ gates, and update the status row when done. | 003 | Make the menu bar icon open language and recent-history actions | P1 | M | — | DONE | | 004 | Add deterministic number normalization for common dictation phrases | P2 | M | — | DONE | | 005 | Make language choices provider-aware and honest | P2 | M | — | DONE | +| 006 | Make `feat/text-pipeline-phase3` shippable | P0 | L | — | TODO | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale). @@ -26,6 +27,9 @@ REJECTED (with one-line rationale). - Plan 004 and Plan 005 can run independently, but both touch user-facing text quality. Review them together before release so formatting and language behavior feel coherent. +- Plan 006 was added on 2026-08-07 from the PR-readiness review of + `feat/text-pipeline-phase3`. It is independent of 001–005 (all DONE) and blocks the + next release; run it before any further feature work on that branch. - If executor capacity is limited, run 001 first because it addresses the highest-trust data-loss-feeling bug: Vaani can save correct history while pasting stale clipboard text into the active app. diff --git a/src/main/dictation.ts b/src/main/dictation.ts index 1fbb949..d332f9a 100644 --- a/src/main/dictation.ts +++ b/src/main/dictation.ts @@ -1,9 +1,11 @@ -import { BrowserWindow } from "electron"; +import * as electron from "electron"; import { writeFile, mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; +import { createRequire } from "node:module"; import type { DictionarySuggestion } from "@shared/dictionarySuggestions"; +import type { BrowserWindow } from "electron"; import type { AudioClip, AudioQualityMetrics, @@ -35,15 +37,16 @@ import { HistoryStore } from "./store/history"; import { DictationTraceStore } from "./store/dictationTrace"; import { SettingsStore } from "./store/settings"; import { CredentialsStore } from "./store/credentials"; -import { cleanupText } from "./text/cleanup"; +import { applyDictionary, cleanupText } from "./text/cleanup"; import { detectDictionarySuggestions, isAutoLearnableDictionarySuggestion, isValidDictionarySuggestion } from "@shared/dictionarySuggestions"; -import { TranscriptionService, type FormatTranscriptTraceResult } from "./transcription"; +import { getTranscriptionTimeoutMs, TranscriptionDeadlineExceededError, TranscriptionService, type FormatTranscriptTraceResult } from "./transcription"; import { SessionTimers } from "./dictation/sessionTimers"; import { decideTranscriptInsertion, finalizeTranscriptDecision } from "./transcriptQuality"; import { mergeDictationTracePatch } from "./dictationTraceSnapshot"; +import { formatBuildIdentifier } from "@shared/buildIdentifier"; +import { evaluateInsertionAcceptance } from "@shared/insertionAcceptance"; const FINALIZATION_TIMEOUT_MS = 4_000; -const TRANSCRIPTION_TIMEOUT_MS = 30_000; const FORMATTING_TIMEOUT_MS = 20_000; const AUDIO_FRAME_TIMEOUT_MS = 1_600; const RECORDER_START_TIMEOUT_MS = 5_000; @@ -52,7 +55,21 @@ const UPTIME_LOG_INTERVAL_MS = 3_600_000; const EDIT_WATCH_INTERVAL_MS = 500; const EDIT_WATCH_TIMEOUT_MS = 60_000; const EDIT_PROMPT_IDLE_MS = 1_000; -const INSERTION_VERIFY_DELAY_MS = 180; +const INSERTION_VERIFY_POLL_INTERVAL_MS = 50; +const INSERTION_VERIFY_TIMEOUT_MS = 2_000; +const TRANSCRIPTION_TIMEOUT_MESSAGE = "Transcription timed out. Please try again."; +class TranscriptionTimeoutError extends Error { + constructor() { + super(TRANSCRIPTION_TIMEOUT_MESSAGE); + this.name = "TranscriptionTimeoutError"; + } +} +type ElectronModule = typeof import("electron") & { default?: typeof import("electron") }; +const electronModule = electron as unknown as ElectronModule; + +function getDefaultMicrophonePermission(): string { + return electron.systemPreferences.getMediaAccessStatus("microphone"); +} interface RecorderCommands { isReady: () => boolean; @@ -60,14 +77,20 @@ interface RecorderCommands { stopRecording: (sessionId: string) => boolean; } +type DictationTraceDeps = Pick + & Partial>; + interface DictationServiceDeps { transcription?: Pick & Partial>; injector?: Pick; appDetector?: Pick; + getMicrophonePermission?: () => string; recorder?: RecorderCommands; credentials?: CredentialsStore; createSessionId?: () => string; - traces?: Pick; + traces?: DictationTraceDeps; + verifierNow?: () => number; + verifierSleep?: (ms: number) => Promise; } export class DictationService { @@ -75,8 +98,11 @@ export class DictationService { private readonly transcription: Pick & Partial>; private readonly injector: Pick; private readonly appDetector: Pick; + private readonly getMicrophonePermission: () => string; private readonly createSessionId: () => string; - private readonly traces: Pick | null; + private readonly traces: DictationTraceDeps | null; + private readonly verifierNow: () => number; + private readonly verifierSleep: (ms: number) => Promise; private readonly timers = new SessionTimers(); private pendingEditPromptKey: string | null = null; private pendingEdit: { insertedText: string; correctedCandidate: string } | null = null; @@ -100,9 +126,12 @@ export class DictationService { this.transcription = deps.transcription ?? new TranscriptionService(() => this.settings.get(), deps.credentials); this.injector = deps.injector ?? new TextInjector(() => this.settings.get()); this.appDetector = deps.appDetector ?? new AppDetector(); + this.getMicrophonePermission = deps.getMicrophonePermission ?? getDefaultMicrophonePermission; this.recorder = deps.recorder ?? null; this.createSessionId = deps.createSessionId ?? (() => crypto.randomUUID()); this.traces = deps.traces ?? null; + this.verifierNow = deps.verifierNow ?? (() => performance.now()); + this.verifierSleep = deps.verifierSleep ?? delay; this.startUptimeLogging(); } @@ -240,8 +269,9 @@ export class DictationService { this.clearFinalizationTimer(); const settings = this.settings.get(); const validationClip = trimSilence(payload.clip, settings.silenceThreshold); + const rawAudio = analyzeAudioQuality(payload.clip, settings.silenceThreshold); const tracePatch: Partial = { - rawAudio: analyzeAudioQuality(payload.clip, settings.silenceThreshold), + rawAudio, trimmedAudio: analyzeAudioQuality(validationClip, settings.silenceThreshold), }; @@ -255,6 +285,12 @@ export class DictationService { } void this.patchTrace(payload.sessionId, tracePatch); + if (rawAudio.peakAmplitude === 0 && this.getMicrophonePermission() !== "granted") { + debug("dictation", "submitAudioClip: clip rejected (microphone permission is not granted)"); + this.failSession(payload.sessionId, "Microphone access is not granted. Enable it in System Settings > Privacy & Security > Microphone, then restart Vaani.", "microphone_permission_denied"); + return; + } + if (!isValidClip(validationClip, settings.minClipDuration)) { debug("dictation", "submitAudioClip: clip rejected (too short or empty)"); this.failSession(payload.sessionId, "No speech detected. Try speaking louder or closer to the microphone.", "no_speech"); @@ -274,14 +310,17 @@ export class DictationService { const appProfile = resolveAppProfile(settings.appProfiles ?? [], this.activeTarget?.appBundleId ?? null); let transcriptionTimer: ReturnType | null = null; const sttStartedAt = Date.now(); + const transcriptionTimeoutMs = getTranscriptionTimeoutMs(payload.clip.durationSeconds); + const transcriptionDeadlineAt = sttStartedAt + transcriptionTimeoutMs; const transcription = await Promise.race([ this.transcription.transcribe(payload.clip, { ...(appProfile?.language ? { languageOverride: appProfile.language } : {}), ...(appProfile?.transcriptionProvider ? { providerOverride: appProfile.transcriptionProvider } : {}), retryClip: validationClip, + deadlineAt: transcriptionDeadlineAt, rejectResult: (result: TranscriptionResult) => decideTranscriptInsertion(result.rawText, payload.clip, result.quality).action === "retry", }).finally(() => { if (transcriptionTimer) { clearTimeout(transcriptionTimer); transcriptionTimer = null; } }), - new Promise((_, reject) => { transcriptionTimer = setTimeout(() => reject(new Error("Transcription timed out. Please try again.")), TRANSCRIPTION_TIMEOUT_MS); }), + new Promise((_, reject) => { transcriptionTimer = setTimeout(() => reject(new TranscriptionTimeoutError()), transcriptionTimeoutMs); }), ]); const qualityDecision = finalizeTranscriptDecision(decideTranscriptInsertion(transcription.rawText, payload.clip, transcription.quality)); const quality = transcription.quality @@ -320,7 +359,8 @@ export class DictationService { if (qualityDecision.action === "save") { debug("dictation", `submitAudioClip: transcript saved instead of inserted (${qualityDecision.reason}): "${transcription.rawText}"`); const cleanupTrace = { correctionsApplied: [] }; - const cleanedText = cleanupText({ rawText: transcription.rawText, settings, trace: cleanupTrace }); + const correctedText = applyDictionary(transcription.rawText, settings, cleanupTrace); + const cleanedText = cleanupText({ rawText: correctedText, settings, trace: cleanupTrace, skipCorrections: true, appProfileId: appProfile?.id, placeholderResolver: resolveSnippetPlaceholder }); void this.patchTrace(payload.sessionId, { stages: { cleanedText, @@ -350,26 +390,26 @@ export class DictationService { return; } - // Format via LLM using provider system - let formattedText = transcription.rawText; - let formatTrace: FormatTranscriptTraceResult = { text: transcription.rawText, formatterUsed: "none" }; + // Apply dictionary terms before the formatter so it sees the intended spelling. + const cleanupTrace = { correctionsApplied: [] }; + const correctedText = applyDictionary(transcription.rawText, settings, cleanupTrace); + let formattedText = correctedText; + let formatTrace: FormatTranscriptTraceResult = { text: correctedText, formatterUsed: "none" }; try { let formattingTimer: ReturnType | null = null; const formattingStartedAt = Date.now(); formatTrace = await Promise.race([ - this.formatTranscriptWithTrace(transcription.rawText).finally(() => { if (formattingTimer) { clearTimeout(formattingTimer); formattingTimer = null; } }), + this.formatTranscriptWithTrace(correctedText).finally(() => { if (formattingTimer) { clearTimeout(formattingTimer); formattingTimer = null; } }), new Promise((_, reject) => { formattingTimer = setTimeout(() => reject(new Error("Formatting timed out.")), FORMATTING_TIMEOUT_MS); }), ]); formattedText = formatTrace.text; void this.patchTrace(payload.sessionId, { formattingLatencyMs: Date.now() - formattingStartedAt }); } catch { - formattedText = transcription.rawText; - formatTrace = { text: transcription.rawText, formatterUsed: "none" }; + formattedText = correctedText; + formatTrace = { text: correctedText, formatterUsed: "none" }; } - const textForCleanup = formattedText !== transcription.rawText ? formattedText : transcription.rawText; - const cleanupTrace = { correctionsApplied: [] }; - const cleanedText = cleanupText({ rawText: textForCleanup, settings, trace: cleanupTrace }); + const cleanedText = cleanupText({ rawText: formattedText, settings, trace: cleanupTrace, skipCorrections: true, appProfileId: appProfile?.id, placeholderResolver: resolveSnippetPlaceholder }); void this.patchTrace(payload.sessionId, { stages: { cleanedText, @@ -407,7 +447,9 @@ export class DictationService { selection: this.activeSelection }; - const verificationBaseline = safeFocusedValue(); + const verificationFocus = this.appDetector.getContext(); + let verificationBaseline = sameTarget(injectionTarget, verificationFocus) ? safeFocusedValue() : null; + let verificationTarget = this.activeTarget; let injection = await this.injector.inject(cleanedText, injectionTarget); const injectionAttempts: DictationTrace["injectionAttempts"] = [{ targetAppBundleId: injectionTarget.appBundleId, @@ -419,6 +461,8 @@ export class DictationService { const fallbackTarget = this.appDetector.getContext(); if (isExternalTarget(fallbackTarget) && !sameTarget(injectionTarget, fallbackTarget)) { const fallbackSelection = this.captureSelection(fallbackTarget); + const fallbackVerificationFocus = this.appDetector.getContext(); + const fallbackVerificationBaseline = sameTarget(fallbackTarget, fallbackVerificationFocus) ? safeFocusedValue() : null; injection = await this.injector.inject(cleanedText, { appBundleId: fallbackTarget.appBundleId, appName: fallbackTarget.appName, @@ -431,14 +475,18 @@ export class DictationService { success: injection.success, fallbackReason: "primary-insertion-failed", }); - if (injection.success) this.activeTarget = fallbackTarget; + if (injection.success) { + this.activeTarget = fallbackTarget; + verificationTarget = fallbackTarget; + verificationBaseline = fallbackVerificationBaseline; + } } } void this.patchTrace(payload.sessionId, { injectionAttempts }); if (!this.isCurrentSession(payload.sessionId)) return; if (injection.success) { - const verification = await this.verifyInsertion(cleanedText, verificationBaseline, this.activeTarget); + const verification = await this.verifyInsertion(cleanedText, verificationBaseline, verificationTarget); const finalAttempt = injectionAttempts[injectionAttempts.length - 1]; if (finalAttempt) finalAttempt.verification = verification; void this.patchTrace(payload.sessionId, { @@ -475,8 +523,9 @@ export class DictationService { } } catch (error) { if (!this.isCurrentSession(payload.sessionId)) return; - const message = error instanceof Error ? error.message : "Dictation failed."; - this.failSession(payload.sessionId, message, message.toLowerCase().includes("timed out") ? "timeout" : "transcription_error"); + const isTimeout = error instanceof TranscriptionDeadlineExceededError || error instanceof TranscriptionTimeoutError; + const message = isTimeout ? TRANSCRIPTION_TIMEOUT_MESSAGE : error instanceof Error ? error.message : "Dictation failed."; + this.failSession(payload.sessionId, message, isTimeout ? "timeout" : "transcription_error"); } } @@ -595,9 +644,11 @@ export class DictationService { async demoTranscribe(clip: { pcmData: number[]; sampleRate: number; durationSeconds: number; rmsFrames: number[] }): Promise { let demoTimer: ReturnType | null = null; + const transcriptionTimeoutMs = getTranscriptionTimeoutMs(clip.durationSeconds); + const transcriptionDeadlineAt = Date.now() + transcriptionTimeoutMs; const result = await Promise.race([ - this.transcription.transcribe(clip).then(r => { if (demoTimer) { clearTimeout(demoTimer); demoTimer = null; } return r; }), - new Promise((_, reject) => { demoTimer = setTimeout(() => reject(new Error("Transcription timed out. Please try again.")), TRANSCRIPTION_TIMEOUT_MS); }), + this.transcription.transcribe(clip, { deadlineAt: transcriptionDeadlineAt }).then(r => { if (demoTimer) { clearTimeout(demoTimer); demoTimer = null; } return r; }), + new Promise((_, reject) => { demoTimer = setTimeout(() => reject(new Error("Transcription timed out. Please try again.")), transcriptionTimeoutMs); }), ]); return result.rawText; } @@ -830,7 +881,7 @@ export class DictationService { this.clearAudioFrameTimer(); this.clearFinalizationTimer(); this.setState({ status: "error", sessionId, message }); - void this.finishTrace(sessionId, reason === "no_speech" || reason === "fragment" ? "rejected" : "failed", reason, message); + void this.finishTrace(sessionId, reason === "no_speech" || reason === "microphone_permission_denied" || reason === "fragment" ? "rejected" : "failed", reason, message); this.scheduleReset(ERROR_RESET_MS); } @@ -931,6 +982,7 @@ export class DictationService { id: traceId, sessionId, startedAt: new Date().toISOString(), + buildIdentifier: formatBuildIdentifier(getElectronAppVersion()), targetAppBundleId: this.activeTarget?.appBundleId ?? null, targetAppName: this.activeTarget?.appName ?? null, stages: { outcome: "started" }, @@ -969,6 +1021,16 @@ export class DictationService { stages: { ...(patch.stages ?? {}), outcome }, completedAt: new Date().toISOString(), }); + if (!this.traces?.getAll) return; + const traces = await this.safeTraceOperation("evaluateInsertionAcceptance", sessionId, () => this.traces?.getAll?.()); + if (!traces) return; + const acceptance = evaluateInsertionAcceptance(traces); + debug("dictation", `insertion acceptance status=${acceptance.status}`, { + status: acceptance.status, + aggregate: acceptance.rates.aggregate, + apps: acceptance.apps, + unknown: acceptance.unknown, + }); } private async formatTranscriptWithTrace(rawText: string): Promise { @@ -987,16 +1049,20 @@ export class DictationService { baseline: string | null, target: Pick | null ): Promise { - await delay(INSERTION_VERIFY_DELAY_MS); - if (!sameTarget(target, this.appDetector.getContext())) { + if (baseline === null) { + return { readable: false, passed: false, repaired: false, reason: "baseline-unreadable" }; + } + const baselineOccurrenceCount = countLiteralOccurrences(baseline, expectedText); + const initialPoll = await this.pollInsertionValue(expectedText, baselineOccurrenceCount, target); + if (initialPoll.reason === "not-at-target") { return { readable: false, passed: false, repaired: false, reason: "not-at-target" }; } - const currentValue = safeFocusedValue(); + const currentValue = initialPoll.value; if (currentValue === null) { return { readable: false, passed: false, repaired: false, reason: "unreadable" }; } - if (currentValue.includes(expectedText)) { + if (countLiteralOccurrences(currentValue, expectedText) > baselineOccurrenceCount) { return { readable: true, passed: true, repaired: false, reason: "expected-present" }; } @@ -1004,15 +1070,24 @@ export class DictationService { if (insertedFragment && expectedText.startsWith(insertedFragment)) { const missingSuffix = expectedText.slice(insertedFragment.length); if (missingSuffix.length > 0) { + if (!sameTarget(target, this.appDetector.getContext())) { + return { readable: false, passed: false, repaired: false, reason: "not-at-target" }; + } const repair = await this.injector.inject(missingSuffix, { appBundleId: target?.appBundleId ?? null, appName: target?.appName ?? null, selection: this.captureSelection(target), }); if (repair.success) { - await delay(INSERTION_VERIFY_DELAY_MS); - const repairedValue = safeFocusedValue(); - if (repairedValue?.includes(expectedText)) { + const repairedPoll = await this.pollInsertionValue(expectedText, baselineOccurrenceCount, target); + if (repairedPoll.reason === "not-at-target") { + return { readable: false, passed: false, repaired: false, reason: "not-at-target" }; + } + const repairedValue = repairedPoll.value; + if (repairedValue === null) { + return { readable: false, passed: false, repaired: false, reason: "unreadable" }; + } + if (countLiteralOccurrences(repairedValue, expectedText) > baselineOccurrenceCount) { return { readable: true, passed: true, repaired: true, reason: "partial-suffix-repaired" }; } } @@ -1023,6 +1098,32 @@ export class DictationService { return { readable: true, passed: false, repaired: false, reason: insertedFragment ? "partial-unsafe" : "missing" }; } + private async pollInsertionValue( + expectedText: string, + baselineOccurrenceCount: number, + target: Pick | null + ): Promise<{ value: string | null; reason?: "not-at-target" }> { + let lastReadableValue: string | null = null; + const deadline = this.verifierNow() + INSERTION_VERIFY_TIMEOUT_MS; + while (true) { + if (this.verifierNow() >= deadline) break; + if (!sameTarget(target, this.appDetector.getContext())) { + return { value: null, reason: "not-at-target" }; + } + + const currentValue = safeFocusedValue(); + if (currentValue !== null) { + lastReadableValue = currentValue; + if (countLiteralOccurrences(currentValue, expectedText) > baselineOccurrenceCount) return { value: currentValue }; + } + + const remainingMs = deadline - this.verifierNow(); + if (remainingMs <= 0) break; + await this.verifierSleep(Math.min(INSERTION_VERIFY_POLL_INTERVAL_MS, remainingMs)); + } + return { value: lastReadableValue }; + } + private async safeTraceOperation( operation: string, sessionId: string, @@ -1038,6 +1139,10 @@ export class DictationService { } } +function getElectronAppVersion(): string { + return electronModule.app?.getVersion() ?? electronModule.default?.app?.getVersion() ?? "unresolved"; +} + function redactEntryForBugReport(entry: DictationEntry | null): DictationEntry | null { return entry ? { ...entry, rawAudioPath: entry.rawAudioPath ? null : entry.rawAudioPath } : null; } @@ -1110,6 +1215,18 @@ function safeFocusedValue(): string | null { } } +function countLiteralOccurrences(value: string, expectedText: string): number { + if (expectedText.length === 0) return 0; + let count = 0; + let searchFrom = 0; + while (true) { + const index = value.indexOf(expectedText, searchFrom); + if (index === -1) return count; + count += 1; + searchFrom = index + expectedText.length; + } +} + function delay(ms: number): Promise { if (process.env.NODE_ENV === "test") return Promise.resolve(); return new Promise((resolve) => setTimeout(resolve, ms)); @@ -1202,3 +1319,11 @@ function resolveAppProfile(appProfiles: NonNullable, bu const id = bundleId.toLowerCase(); return appProfiles.find(p => p.appBundleIds.some(b => b.toLowerCase() === id)) ?? null; } + +function resolveSnippetPlaceholder(name: "date" | "time" | "clipboard"): string { + const now = new Date(); + if (name === "date") return now.toLocaleDateString(); + if (name === "time") return now.toLocaleTimeString(); + const { clipboard } = createRequire(import.meta.url)("electron") as typeof import("electron"); + return clipboard.readText(); +} diff --git a/src/main/dictationTraceSnapshot.ts b/src/main/dictationTraceSnapshot.ts index 28f5cac..497ea01 100644 --- a/src/main/dictationTraceSnapshot.ts +++ b/src/main/dictationTraceSnapshot.ts @@ -13,7 +13,6 @@ export function buildTraceStageSnapshot(snapshot: DictationStageSnapshot): Dicta const next: DictationStageSnapshot = { ...snapshot }; if (next.rawTranscript !== undefined) next.rawTranscript = truncateTraceText(next.rawTranscript); if (next.cleanedText !== undefined) next.cleanedText = truncateTraceText(next.cleanedText); - if (next.injectedText !== undefined) next.injectedText = truncateTraceText(next.injectedText); if (next.correctionsApplied) { next.correctionsApplied = next.correctionsApplied .slice(0, DICTATION_TRACE_ARRAY_LIMIT) diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index 11f7fe2..73c1793 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -41,10 +41,11 @@ export class HotkeyManager { private escapeRegistered = false; private pendingReleaseTimer: ReturnType | null = null; private suppressNextRelease = false; + private rejectedPress = false; constructor( private readonly settingsProvider: () => Settings, - private readonly onPress: () => void, + private readonly onPress: () => void | boolean, private readonly onRelease: () => void, private readonly onCancel: () => void, private readonly onPasteLatest: () => void, @@ -98,6 +99,7 @@ export class HotkeyManager { this.unregisterEscapeShortcut(); this.isToggleRecording = false; this.suppressNextRelease = false; + this.rejectedPress = false; if (this.usingNativeMonitor) { try { nativeBridge.stopHotkeyMonitor?.(); } catch (error) { console.warn("[vaani] failed to stop native hotkey monitor:", error); } @@ -139,14 +141,19 @@ export class HotkeyManager { } private handlePress(): void { + if (this.rejectedPress) return; + const settings = this.settingsProvider(); const mode: DictationMode = settings.dictationMode || "toggle"; const now = Date.now(); // Push-to-talk: start immediately on press, ignore double-press and toggle logic if (mode === "push-to-talk") { + if (this.onPress() === false) { + this.rejectedPress = true; + return; + } this.lastPressTime = now; - this.onPress(); return; } @@ -172,17 +179,28 @@ export class HotkeyManager { if (this.pendingReleaseTimer) { this.clearPendingRelease(); } + if (this.onPress() === false) { + this.rejectedPress = true; + return; + } this.lastPressTime = now; - this.onPress(); return; } // toggle-double mode: single press = push-to-talk, double press = toggle + if (this.onPress() === false) { + this.rejectedPress = true; + return; + } this.lastPressTime = now; - this.onPress(); } private handleRelease(): void { + if (this.rejectedPress) { + this.rejectedPress = false; + return; + } + const settings = this.settingsProvider(); const mode: DictationMode = settings.dictationMode || "toggle"; diff --git a/src/main/index.ts b/src/main/index.ts index 6abced6..fdfef3e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import { appendFileSync, existsSync, renameSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir, homedir } from "node:os"; import { fileURLToPath } from "node:url"; -import type { UpdateNotificationPayload } from "@shared/types"; +import type { MacOSPermissionState, PermissionStatus, UpdateNotificationPayload } from "@shared/types"; import { DictationService } from "./dictation"; import { HotkeyManager } from "./hotkeys"; import { registerIpcHandlers } from "./ipc"; @@ -18,9 +18,11 @@ import { CredentialsStore } from "./store/credentials"; import { createTray, type TrayController } from "./tray"; import { IpcChannel } from "@shared/ipc"; import { assertValidWhisperModelName } from "@shared/whisperModels"; +import { isPermissionReady } from "@shared/permissionGuard"; import { getProviderRegistry } from "./providers"; import { loadWhisperModel } from "./providers/local/whisperCpp"; import { error } from "@main/log"; +import { shouldGrantMediaPermission } from "./mediaPermissions"; const currentDir = dirname(fileURLToPath(import.meta.url)); const mutableApp = app as typeof app & { isQuitting?: boolean }; @@ -59,6 +61,29 @@ function log(label: string, data?: unknown): void { } } +function getFreshPermissionStatus(): PermissionStatus { + const microphoneStatus = systemPreferences.getMediaAccessStatus("microphone"); + const microphone = (["not-determined", "granted", "denied", "restricted"].includes(microphoneStatus) + ? microphoneStatus + : "unknown") as MacOSPermissionState; + return { + microphone, + accessibility: systemPreferences.isTrustedAccessibilityClient(false) ? "granted" : "denied", + }; +} + +function pushPermissionStatus(status: PermissionStatus): void { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) return; + mainWindow.webContents.send(IpcChannel.PermissionStatusPush, status); +} + +function warmNativeIfPermissionReady(): void { + const status = getFreshPermissionStatus(); + if (!isPermissionReady(status)) return; + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return; + recorderController?.warmNative(); +} + export function setCachedUpdateStatus(payload: UpdateNotificationPayload | null): void { cachedUpdateStatus = payload; } @@ -199,6 +224,11 @@ function createMainWindow(trayEnabled: () => boolean): BrowserWindow { } }); + win.webContents.on("will-navigate", (event) => { + event.preventDefault(); + }); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + win.webContents.on("did-start-loading", () => { log("renderer:start-loading", { rendererReady }); if (rendererReady) return; @@ -267,14 +297,7 @@ function createMainWindow(trayEnabled: () => boolean): BrowserWindow { win.on("focus", () => { log("window:focus"); - if (win.webContents && !win.webContents.isDestroyed()) { - const micStatus = systemPreferences.getMediaAccessStatus("microphone"); - const micState = (["not-determined", "granted", "denied", "restricted"].includes(micStatus) ? micStatus : "unknown") as import("@shared/types").MacOSPermissionState; - win.webContents.send(IpcChannel.PermissionStatusPush, { - microphone: micState, - accessibility: systemPreferences.isTrustedAccessibilityClient(false) ? "granted" : "denied", - }); - } + pushPermissionStatus(getFreshPermissionStatus()); }); return win; @@ -286,9 +309,9 @@ function configureRendererLifecycle(win: BrowserWindow): void { log("renderer:ready"); rendererReady = true; clearMainWindowReadyTimeout(); - if (!menuBarMode && !shouldSuppressDashboardActivation()) { - win.show(); - win.focus(); + if (!shouldSuppressDashboardActivation()) { + showMainWindow(); + warmNativeIfPermissionReady(); } else if (!menuBarMode) { log("renderer:ready-focus-suppressed"); syncAppPresentation(); @@ -305,13 +328,13 @@ function configureRendererLifecycle(win: BrowserWindow): void { }); } -function configureMediaPermissions(): void { - session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { +function configureMediaPermissions(getAllowedWebContents: () => readonly (object | null)[]): void { + session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { if (permission === "media") { const mediaTypes = (details as { mediaTypes?: string[] }).mediaTypes ?? []; - const audioOnly = mediaTypes.length === 0 || mediaTypes.every((type) => type === "audio"); - callback(audioOnly); - log("permission:media", { audioOnly, mediaTypes }); + const granted = shouldGrantMediaPermission(webContents, permission, { mediaTypes }, getAllowedWebContents()); + callback(granted); + log("permission:media", { granted, mediaTypes }); return; } callback(false); @@ -366,7 +389,6 @@ async function bootstrap(): Promise { lastDockVisible = null; let trayReady = false; - configureMediaPermissions(); mainWindow = createMainWindow(() => trayReady); configureRendererLifecycle(mainWindow); @@ -377,6 +399,10 @@ async function bootstrap(): Promise { preWarmMic: settings.get().preWarmMic, captureBackend: settings.get().captureBackend, })); + configureMediaPermissions(() => [ + mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null, + rendererRecorder.getWindow()?.webContents ?? null, + ]); overlayController.setTheme("aurora"); overlayController.setColorMode(initSettings.colorMode ?? "light"); if (initSettings.accentColor) overlayController.setAccentColor(initSettings.accentColor); @@ -420,13 +446,28 @@ async function bootstrap(): Promise { { recorder: recorderController, credentials: credentialsStore, traces } ); dictationService = dictation; - recorderController.warmNative(); + + const beginDictationIfPermitted = (source: "tray" | "hotkey"): boolean => { + const status = getFreshPermissionStatus(); + if (status.microphone === "granted" && status.accessibility === "granted") { + warmNativeIfPermissionReady(); + if (source === "hotkey") suppressDashboardActivation("hotkey"); + dictation.beginHotkeySession(); + if (source === "hotkey") preserveDockIfDashboardOpen(); + return true; + } + + log("dictation:blocked", { source, microphone: status.microphone, accessibility: status.accessibility }); + pushPermissionStatus(status); + showMainWindow(); + return false; + }; try { trayController = createTray({ openMainWindow: () => showMainWindow(), quit: () => { mutableApp.isQuitting = true; app.quit(); }, - startDictation: () => dictation.beginHotkeySession(), + startDictation: () => { void beginDictationIfPermitted("tray"); }, pasteLatest: () => { void dictation.pasteLatestEntry(); }, getRecentHistory: async () => { const entries = await history.getAll(); @@ -443,11 +484,7 @@ async function bootstrap(): Promise { hotkeyManager = new HotkeyManager( () => settings.get(), - () => { - suppressDashboardActivation("hotkey"); - dictation.beginHotkeySession(); - preserveDockIfDashboardOpen(); - }, + () => beginDictationIfPermitted("hotkey"), () => dictation.endHotkeySession(), () => dictation.cancelSession(), () => { dictation.pasteLatestEntry().catch((err) => { error("main", `paste latest failed: ${err instanceof Error ? err.message : String(err)}`); }); }, @@ -461,6 +498,7 @@ async function bootstrap(): Promise { settings, hotkeys: hotkeyManager, recorder: rendererRecorder, + overlay: overlayController, credentials: credentialsStore, onSettingsUpdated: (_updated, patch) => { if ("theme" in patch) overlayController?.setTheme("aurora"); @@ -497,7 +535,6 @@ async function bootstrap(): Promise { }); await loadWindowUrl(mainWindow); - setTimeout(() => showMainWindow(), 100); setTimeout(() => hotkeyManager?.register(), 300); // Auto-updater (only in packaged builds) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4f14548..69f1420 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,5 @@ -import { app, BrowserWindow, clipboard, ipcMain, shell, systemPreferences } from "electron"; -import { join } from "node:path"; +import { app, type BrowserWindow, clipboard, ipcMain, shell, systemPreferences } from "electron"; +import { isAbsolute, join, normalize } from "node:path"; import { homedir } from "node:os"; import { autoUpdater } from "electron-updater"; import { IpcChannel } from "@shared/ipc"; @@ -22,7 +22,8 @@ import { SettingsStore } from "./store/settings"; import { CredentialsStore, sanitizeSettingsForRenderer } from "./store/credentials"; import { HotkeyManager } from "./hotkeys"; import { nativeBridge } from "./nativeBridge"; -import { RecorderWindowController } from "./recorderWindow"; +import type { RecorderWindowController } from "./recorderWindow"; +import type { OverlayController } from "./overlay"; import { listNativeInputDevices } from "./audio/nativeCapture"; import { getProviderRegistry } from "./providers"; import { detectDictionarySuggestions } from "@shared/dictionarySuggestions"; @@ -72,19 +73,242 @@ function getPermissionStatus(): PermissionStatus { } const MAX_CUSTOM_CORRECTION_TEXT_LENGTH = 40; +const MAX_ID_LENGTH = 256; +const MAX_SHORT_TEXT_LENGTH = 512; +const MAX_TEXT_LENGTH = 100_000; +const MAX_SECRET_LENGTH = 8_192; +const MAX_LIST_LENGTH = 500; +const MAX_CUSTOM_CORRECTION_HIT_COUNT = 1_000_000; +const MAX_AUDIO_DURATION_SECONDS = 600; +const MAX_AUDIO_SAMPLES = 10_000_000; +const MAX_RMS_FRAMES = 100_000; + +type IpcSenderEvent = Electron.IpcMainEvent | Electron.IpcMainInvokeEvent; + +function isSenderAllowed(event: IpcSenderEvent, allowed: Array): boolean { + return allowed.some((window) => ( + !!window && !window.isDestroyed() && event.sender === window.webContents + )); +} + +function requireAllowedSender(event: IpcSenderEvent, allowed: Array): void { + if (!isSenderAllowed(event, allowed)) { + throw new Error("Unauthorized IPC sender"); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function isBoundedString(value: unknown, maxLength: number, allowEmpty = true): value is string { + return typeof value === "string" && value.length <= maxLength && (allowEmpty || value.trim().length > 0); +} + +function isFiniteNumberInRange(value: unknown, min: number, max: number): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max; +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === "string" && allowed.includes(value as T); +} + +function isBoundedStringArray(value: unknown, maxEntries = MAX_LIST_LENGTH, maxText = MAX_SHORT_TEXT_LENGTH): value is string[] { + return Array.isArray(value) + && value.length <= maxEntries + && value.every((entry) => isBoundedString(entry, maxText)); +} + +function isCustomCorrection(value: unknown): value is CustomCorrection { + if (!isRecord(value) || !hasOnlyKeys(value, ["spoken", "written", "source", "enabled", "caseSensitive", "wholeWord", "fuzzy", "hitCount", "lastUsedAt"])) return false; + return isBoundedString(value.spoken, MAX_CUSTOM_CORRECTION_TEXT_LENGTH, false) + && isBoundedString(value.written, MAX_CUSTOM_CORRECTION_TEXT_LENGTH, false) + && (value.source === undefined || isOneOf(value.source, ["auto-suggested", "manual"])) + && (value.enabled === undefined || typeof value.enabled === "boolean") + && (value.caseSensitive === undefined || typeof value.caseSensitive === "boolean") + && (value.wholeWord === undefined || typeof value.wholeWord === "boolean") + && (value.fuzzy === undefined || typeof value.fuzzy === "boolean") + && (value.hitCount === undefined || (Number.isInteger(value.hitCount) && isFiniteNumberInRange(value.hitCount, 0, MAX_CUSTOM_CORRECTION_HIT_COUNT))) + && (value.lastUsedAt === undefined || (isBoundedString(value.lastUsedAt, MAX_SHORT_TEXT_LENGTH, false) && !Number.isNaN(Date.parse(value.lastUsedAt)))); +} + +function isDictionarySuggestion(value: unknown): value is DictionarySuggestion { + if (!isRecord(value) || !hasOnlyKeys(value, ["spoken", "written"])) return false; + return isBoundedString(value.spoken, MAX_CUSTOM_CORRECTION_TEXT_LENGTH, false) + && isBoundedString(value.written, MAX_CUSTOM_CORRECTION_TEXT_LENGTH, false); +} + +function isDictionarySuggestions(value: unknown): value is DictionarySuggestion[] { + return Array.isArray(value) && value.length <= MAX_LIST_LENGTH && value.every(isDictionarySuggestion); +} -function sanitizeManualCustomCorrections(entries: Array>): CustomCorrection[] { +function isProviderApiKey(value: unknown): boolean { + if (!isRecord(value) || !hasOnlyKeys(value, ["providerId", "key", "hasKey"])) return false; + return isBoundedString(value.providerId, MAX_ID_LENGTH, false) + && isBoundedString(value.key, MAX_SECRET_LENGTH) + && (value.hasKey === undefined || typeof value.hasKey === "boolean"); +} + +function isSnippet(value: unknown): boolean { + if (!isRecord(value) || !hasOnlyKeys(value, ["trigger", "content", "matchBareTrigger", "appProfileIds"])) return false; + return isBoundedString(value.trigger, MAX_SHORT_TEXT_LENGTH, false) + && isBoundedString(value.content, MAX_TEXT_LENGTH, false) + && (value.matchBareTrigger === undefined || typeof value.matchBareTrigger === "boolean") + && (value.appProfileIds === undefined || isBoundedStringArray(value.appProfileIds, 100, MAX_ID_LENGTH)); +} + +function isAppProfile(value: unknown): boolean { + if (!isRecord(value) || !hasOnlyKeys(value, [ + "id", "name", "appBundleIds", "transcriptionProvider", "formattingProvider", "language", + "stylePreset", "contextAwarenessEnabled", "autoSubmit", "customPrompt", + ])) return false; + return isBoundedString(value.id, MAX_ID_LENGTH, false) + && isBoundedString(value.name, MAX_SHORT_TEXT_LENGTH, false) + && isBoundedStringArray(value.appBundleIds, 32, MAX_SHORT_TEXT_LENGTH) + && value.appBundleIds.length > 0 + && (value.transcriptionProvider === undefined || isBoundedString(value.transcriptionProvider, MAX_ID_LENGTH, false)) + && (value.formattingProvider === undefined || isBoundedString(value.formattingProvider, MAX_ID_LENGTH, false)) + && (value.language === undefined || isBoundedString(value.language, 32, false)) + && (value.stylePreset === undefined || isOneOf(value.stylePreset, ["plain", "developer", "casual", "formal", "email"])) + && (value.contextAwarenessEnabled === undefined || typeof value.contextAwarenessEnabled === "boolean") + && (value.autoSubmit === undefined || typeof value.autoSubmit === "boolean") + && (value.customPrompt === undefined || isBoundedString(value.customPrompt, MAX_TEXT_LENGTH)); +} + +const SETTINGS_VALIDATORS: { [K in keyof Required]: (value: unknown) => boolean } = { + onboardingCompleted: (value) => typeof value === "boolean", + groqApiKey: (value) => isBoundedString(value, MAX_SECRET_LENGTH), + primaryHotkey: (value) => isBoundedString(value, MAX_SHORT_TEXT_LENGTH, false), + pasteLatestHotkey: (value) => isBoundedString(value, MAX_SHORT_TEXT_LENGTH, false), + language: (value) => isBoundedString(value, 32, false), + customPrompt: (value) => value === undefined || isBoundedString(value, MAX_TEXT_LENGTH), + cleanupEnabled: (value) => typeof value === "boolean", + smartPunctuation: (value) => typeof value === "boolean", + fillerWords: (value) => isBoundedStringArray(value), + fillerWordsCustomized: (value) => value === undefined || typeof value === "boolean", + extraFillerWords: (value) => isBoundedStringArray(value), + customCorrections: (value) => Array.isArray(value) && value.length <= MAX_LIST_LENGTH && value.every(isCustomCorrection), + snippets: (value) => Array.isArray(value) && value.length <= MAX_LIST_LENGTH && value.every(isSnippet), + injectionMode: (value) => isOneOf(value, ["auto", "ax", "clipboard"]), + pasteMode: (value) => isOneOf(value, ["instant", "animated"]), + theme: (value) => value === "aurora", + colorMode: (value) => isOneOf(value, ["light", "dark"]), + accentColor: (value) => typeof value === "string" && /^#[0-9A-Fa-f]{6}$/.test(value), + launchAtLogin: (value) => typeof value === "boolean", + showInDock: (value) => typeof value === "boolean", + minClipDuration: (value) => isFiniteNumberInRange(value, 0, 60), + silenceThreshold: (value) => isFiniteNumberInRange(value, 0, 1), + capsuleBorderWidth: (value) => isFiniteNumberInRange(value, 0, 100), + capsuleBarRadius: (value) => isFiniteNumberInRange(value, 0, 100), + capsuleCornerRadius: (value) => isFiniteNumberInRange(value, 0, 100), + capsuleDesign: (value) => isOneOf(value, ["dot", "bar", "rule", "pill"]), + dictationMode: (value) => isOneOf(value, ["toggle", "push-to-talk", "toggle-double"]), + saveRecordings: (value) => typeof value === "boolean", + recordingsPath: (value) => { + if (!isBoundedString(value, 4_096)) return false; + if (value === "") return true; + const normalized = normalize(value); + return isAbsolute(value) + && isAbsolute(normalized) + && !value.split(/[\\/]+/).includes(".."); + }, + transcriptionProvider: (value) => isBoundedString(value, MAX_ID_LENGTH, false), + transcriptionModel: (value) => isBoundedString(value, MAX_ID_LENGTH), + formattingProvider: (value) => isBoundedString(value, MAX_ID_LENGTH, false), + formattingModel: (value) => isBoundedString(value, MAX_ID_LENGTH, false), + providerApiKeys: (value) => Array.isArray(value) && value.length <= 32 && value.every(isProviderApiKey), + failoverEnabled: (value) => typeof value === "boolean", + localWhisperModel: (value) => isBoundedString(value, MAX_ID_LENGTH, false), + offlineMode: (value) => isOneOf(value, ["auto", "always-offline", "always-online"]), + contextAwarenessEnabled: (value) => typeof value === "boolean", + micDeviceId: (value) => value === undefined || isBoundedString(value, MAX_SHORT_TEXT_LENGTH), + preWarmMic: (value) => typeof value === "boolean", + captureBackend: (value) => isOneOf(value, ["native", "renderer"]), + stylePreset: (value) => isOneOf(value, ["plain", "developer", "casual", "formal", "email"]), + dictionaryOnboarded: (value) => typeof value === "boolean", + snippetsOnboarded: (value) => typeof value === "boolean", + setupChecklistDismissed: (value) => typeof value === "boolean", + appProfiles: (value) => value === undefined || (Array.isArray(value) && value.length <= 100 && value.every(isAppProfile)), +}; + +function isSettingsPatch(value: unknown): value is Partial { + if (!isRecord(value)) return false; + return Object.entries(value).every(([key, entry]) => { + const validator = SETTINGS_VALIDATORS[key as keyof Settings]; + return validator !== undefined && validator(entry); + }); +} + +function isAudioClip(value: unknown): value is RecorderSubmission["clip"] { + if (!isRecord(value) || !hasOnlyKeys(value, ["pcmData", "sampleRate", "durationSeconds", "rmsFrames"])) return false; + if (!isFiniteNumberInRange(value.sampleRate, 8_000, 192_000)) return false; + if (!isFiniteNumberInRange(value.durationSeconds, 0, MAX_AUDIO_DURATION_SECONDS)) return false; + if (!Array.isArray(value.pcmData) || value.pcmData.length === 0 || value.pcmData.length > MAX_AUDIO_SAMPLES) return false; + const sampleCheckCount = Math.min(value.pcmData.length, 4_096); + const sampleCheckStep = sampleCheckCount === 1 + ? 1 + : (value.pcmData.length - 1) / (sampleCheckCount - 1); + for (let check = 0; check < sampleCheckCount; check++) { + const sampleIndex = Math.round(check * sampleCheckStep); + if (!isFiniteNumberInRange(value.pcmData[sampleIndex], -1, 1)) return false; + } + if (Math.abs(value.pcmData.length / value.sampleRate - value.durationSeconds) > 1) return false; + return Array.isArray(value.rmsFrames) + && value.rmsFrames.length <= MAX_RMS_FRAMES + && value.rmsFrames.every((frame) => isFiniteNumberInRange(frame, 0, 1)); +} + +function isRecorderSubmission(value: unknown): value is RecorderSubmission { + return isRecord(value) + && hasOnlyKeys(value, ["sessionId", "clip"]) + && isBoundedString(value.sessionId, MAX_ID_LENGTH, false) + && isAudioClip(value.clip); +} + +function isAudioVisualFrame(value: unknown): value is AudioVisualFrame { + return isRecord(value) + && hasOnlyKeys(value, ["level", "bars"]) + && isFiniteNumberInRange(value.level, 0, 1) + && Array.isArray(value.bars) + && value.bars.length > 0 + && value.bars.length <= 64 + && value.bars.every((bar) => isFiniteNumberInRange(bar, 0, 1)); +} + +function isRecorderFailure(value: unknown): value is RecorderFailure { + return isRecord(value) + && hasOnlyKeys(value, ["sessionId", "message"]) + && isBoundedString(value.sessionId, MAX_ID_LENGTH, false) + && isBoundedString(value.message, MAX_TEXT_LENGTH, false); +} + +function sanitizeCustomCorrections(entries: Array>): CustomCorrection[] { return entries.flatMap((entry) => { - if (typeof entry.spoken !== "string" || typeof entry.written !== "string") return []; + if (!isRecord(entry) + || !isBoundedString(entry.spoken, MAX_CUSTOM_CORRECTION_TEXT_LENGTH, false) + || !isBoundedString(entry.written, MAX_CUSTOM_CORRECTION_TEXT_LENGTH, false)) return []; const spoken = entry.spoken.trim(); const written = entry.written.trim(); - if (!spoken || !written) return []; - if (spoken.length > MAX_CUSTOM_CORRECTION_TEXT_LENGTH || written.length > MAX_CUSTOM_CORRECTION_TEXT_LENGTH) return []; - return [{ + const correction: CustomCorrection = { spoken, written, - source: "manual", - }]; + source: isOneOf(entry.source, ["auto-suggested", "manual"]) ? entry.source : "manual", + }; + if (typeof entry.enabled === "boolean") correction.enabled = entry.enabled; + if (typeof entry.caseSensitive === "boolean") correction.caseSensitive = entry.caseSensitive; + if (typeof entry.wholeWord === "boolean") correction.wholeWord = entry.wholeWord; + if (typeof entry.fuzzy === "boolean") correction.fuzzy = entry.fuzzy; + if (Number.isInteger(entry.hitCount) && isFiniteNumberInRange(entry.hitCount, 0, MAX_CUSTOM_CORRECTION_HIT_COUNT)) { + correction.hitCount = entry.hitCount; + } + if (isBoundedString(entry.lastUsedAt, MAX_SHORT_TEXT_LENGTH, false) && !Number.isNaN(Date.parse(entry.lastUsedAt))) { + correction.lastUsedAt = entry.lastUsedAt; + } + return [correction]; }); } @@ -121,10 +345,11 @@ export function registerIpcHandlers(opts: { settings: SettingsStore; hotkeys: HotkeyManager; recorder?: RecorderWindowController; + overlay?: OverlayController; credentials?: CredentialsStore; onSettingsUpdated?: (settings: Settings, patch: Partial) => void; }): void { - const { mainWindow, dictation, history, settings, hotkeys, recorder, credentials, onSettingsUpdated } = opts; + const { mainWindow, dictation, history, settings, hotkeys, recorder, overlay, credentials, onSettingsUpdated } = opts; let lastAccessibilityGranted = getPermissionStatus().accessibility === "granted"; let lastPermissionHotkeyRefresh = 0; @@ -149,9 +374,35 @@ export function registerIpcHandlers(opts: { mainWindow?.webContents.send(IpcChannel.UpdateNotification, payload); } - ipcMain.handle(IpcChannel.GetDictationState, () => dictation.getState()); - ipcMain.handle(IpcChannel.GetHistory, () => history.getAll()); - ipcMain.handle(IpcChannel.UpdateHistoryEntry, async (_e, id: string, cleanedText: string) => { + async function getSanitizedSettings(): Promise { + const current = settings.get(); + const sanitized = sanitizeSettingsForRenderer(current); + if (credentials) { + sanitized.providerApiKeys = await buildRendererApiKeys(current.providerApiKeys ?? [], credentials); + } + return sanitized; + } + + function syncProviderApiKeyMetadata(providerId: string, addIfMissing: boolean): void { + const current = settings.get().providerApiKeys ?? []; + const next = current.map((pk) => ({ providerId: pk.providerId, key: "" })); + if (addIfMissing && !next.some((pk) => pk.providerId === providerId)) { + next.push({ providerId, key: "" }); + } + settings.update({ providerApiKeys: next }); + } + + ipcMain.handle(IpcChannel.GetDictationState, (event) => { + requireAllowedSender(event, [mainWindow]); + return dictation.getState(); + }); + ipcMain.handle(IpcChannel.GetHistory, (event) => { + requireAllowedSender(event, [mainWindow]); + return history.getAll(); + }); + ipcMain.handle(IpcChannel.UpdateHistoryEntry, async (event, id: unknown, cleanedText: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(id, MAX_ID_LENGTH, false) || !isBoundedString(cleanedText, MAX_TEXT_LENGTH)) return undefined; const entry = await history.getById(id); const updated = await history.updateById(id, (entry) => ({ ...entry, cleanedText })); @@ -165,45 +416,56 @@ export function registerIpcHandlers(opts: { return updated; }); - ipcMain.handle(IpcChannel.ReinjectEntry, (_e, id: string) => dictation.reinjectEntry(id)); - ipcMain.handle(IpcChannel.RetryHistoryEntry, (_e, id: string) => dictation.retryEntry(id)); - ipcMain.handle(IpcChannel.GetDictationTrace, (_e, traceId: string) => dictation.getTrace(traceId)); - ipcMain.handle(IpcChannel.ExportBugReport, (_e, entryId: string) => dictation.exportBugReport(entryId, app.getVersion())); - ipcMain.handle(IpcChannel.DeleteEntry, (_e, id: string) => history.delete(id)); - ipcMain.handle(IpcChannel.ClearHistory, () => history.clear()); - ipcMain.handle(IpcChannel.CopyText, (_e, text: string) => { + ipcMain.handle(IpcChannel.ReinjectEntry, (event, id: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(id, MAX_ID_LENGTH, false)) return undefined; + return dictation.reinjectEntry(id); + }); + ipcMain.handle(IpcChannel.RetryHistoryEntry, (event, id: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(id, MAX_ID_LENGTH, false)) return undefined; + return dictation.retryEntry(id); + }); + ipcMain.handle(IpcChannel.GetDictationTrace, (event, traceId: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(traceId, MAX_ID_LENGTH, false)) return undefined; + return dictation.getTrace(traceId); + }); + ipcMain.handle(IpcChannel.ExportBugReport, (event, entryId: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(entryId, MAX_ID_LENGTH, false)) return undefined; + return dictation.exportBugReport(entryId, app.getVersion()); + }); + ipcMain.handle(IpcChannel.DeleteEntry, (event, id: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(id, MAX_ID_LENGTH, false)) return undefined; + return history.delete(id); + }); + ipcMain.handle(IpcChannel.ClearHistory, (event) => { + requireAllowedSender(event, [mainWindow]); + return history.clear(); + }); + ipcMain.handle(IpcChannel.CopyText, (event, text: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(text, MAX_TEXT_LENGTH)) return false; clipboard.writeText(text); return true; }); - ipcMain.handle(IpcChannel.GetSettings, async () => { - const s = settings.get(); - const sanitized = sanitizeSettingsForRenderer(s); - if (credentials) { - sanitized.providerApiKeys = await buildRendererApiKeys(s.providerApiKeys ?? [], credentials); - } - return sanitized; + ipcMain.handle(IpcChannel.GetSettings, (event) => { + requireAllowedSender(event, [mainWindow]); + return getSanitizedSettings(); }); - ipcMain.handle(IpcChannel.UpdateSettings, async (_e, patch: Partial) => { - const credentialPatch = patch; + ipcMain.handle(IpcChannel.UpdateSettings, async (event, patch: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isSettingsPatch(patch)) return getSanitizedSettings(); let settingsPatch: Partial = { ...patch }; - if (credentials) { - for (const pk of credentialPatch.providerApiKeys ?? []) { - if (!pk.providerId) continue; - if (pk.key) { - await credentials.set(pk.providerId, pk.key); - } - } - if (credentialPatch.groqApiKey) { - await credentials.set("groq", credentialPatch.groqApiKey); - } - if ("groqApiKey" in settingsPatch) { - settingsPatch.groqApiKey = ""; - } - if ("providerApiKeys" in settingsPatch) { - settingsPatch.providerApiKeys = (settingsPatch.providerApiKeys ?? []).map((pk) => ({ providerId: pk.providerId, key: "" })); - } + if ("groqApiKey" in settingsPatch) { + settingsPatch.groqApiKey = ""; + } + if ("providerApiKeys" in settingsPatch) { + settingsPatch.providerApiKeys = (settingsPatch.providerApiKeys ?? []).map((pk) => ({ providerId: pk.providerId, key: "" })); } if ("formattingProvider" in settingsPatch && typeof settingsPatch.formattingProvider === "string" && !("formattingModel" in settingsPatch)) { @@ -216,10 +478,10 @@ export function registerIpcHandlers(opts: { if (Array.isArray(settingsPatch.customCorrections)) { // Trust model: auto suggestions must pass consent and safety gates before // reaching settings; generic settings updates are explicit Dictionary UI - // edits, so keep them working while applying minimal shape/length sanity. + // edits, so preserve valid metadata while applying shape/length sanity. settingsPatch = { ...settingsPatch, - customCorrections: sanitizeManualCustomCorrections(settingsPatch.customCorrections), + customCorrections: sanitizeCustomCorrections(settingsPatch.customCorrections), }; } @@ -241,13 +503,18 @@ export function registerIpcHandlers(opts: { } return sanitized; }); - ipcMain.handle(IpcChannel.SetHotkeyCapture, (_e, active: boolean) => { + ipcMain.handle(IpcChannel.SetHotkeyCapture, (event, active: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (typeof active !== "boolean") return undefined; hotkeys.setCaptureActive(active); }); - ipcMain.handle(IpcChannel.ShowDictionaryPrompt, (_e, suggestions: DictionarySuggestion[]) => ( - dictation.showDictionarySuggestions(suggestions) - )); - ipcMain.handle(IpcChannel.PurgeAutoSuggestedCorrections, async () => { + ipcMain.handle(IpcChannel.ShowDictionaryPrompt, (event, suggestions: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isDictionarySuggestions(suggestions)) return undefined; + return dictation.showDictionarySuggestions(suggestions); + }); + ipcMain.handle(IpcChannel.PurgeAutoSuggestedCorrections, async (event) => { + requireAllowedSender(event, [mainWindow]); const updated = dictation.purgeAutoSuggestedCorrections(); const sanitized = sanitizeSettingsForRenderer(updated); if (credentials) { @@ -255,13 +522,25 @@ export function registerIpcHandlers(opts: { } return sanitized; }); - ipcMain.handle(IpcChannel.GetPermissionStatus, () => refreshPermissionStatus()); - ipcMain.handle(IpcChannel.ListAudioInputDevices, () => listNativeInputDevices()); - ipcMain.handle(IpcChannel.RequestMicrophonePermission, async () => { - await systemPreferences.askForMediaAccess("microphone"); + ipcMain.handle(IpcChannel.GetPermissionStatus, (event) => { + requireAllowedSender(event, [mainWindow]); + return refreshPermissionStatus(); + }); + ipcMain.handle(IpcChannel.ListAudioInputDevices, (event) => { + requireAllowedSender(event, [mainWindow]); + return listNativeInputDevices(); + }); + ipcMain.handle(IpcChannel.RequestMicrophonePermission, async (event) => { + requireAllowedSender(event, [mainWindow]); + try { + await systemPreferences.askForMediaAccess("microphone"); + } catch (error) { + throw error; + } return normalizeMediaStatus(systemPreferences.getMediaAccessStatus("microphone")); }); - ipcMain.handle(IpcChannel.RequestAccessibilityPermission, () => { + ipcMain.handle(IpcChannel.RequestAccessibilityPermission, (event) => { + requireAllowedSender(event, [mainWindow]); systemPreferences.isTrustedAccessibilityClient(true); const status = refreshPermissionStatus(); if (status.accessibility !== "granted") { @@ -269,58 +548,103 @@ export function registerIpcHandlers(opts: { } return status.accessibility; }); - ipcMain.handle(IpcChannel.OpenPermissionSettings, (_e, permission: keyof PermissionStatus) => ( - openPermissionSettings(permission) - )); - ipcMain.handle(IpcChannel.RelaunchApp, () => { + ipcMain.handle(IpcChannel.OpenPermissionSettings, (event, permission: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isOneOf(permission, ["microphone", "accessibility"])) return undefined; + return openPermissionSettings(permission); + }); + ipcMain.handle(IpcChannel.RelaunchApp, (event) => { + requireAllowedSender(event, [mainWindow]); app.relaunch(); app.quit(); }); - ipcMain.handle(IpcChannel.SubmitAudioClip, (_e, payload: RecorderSubmission) => dictation.submitAudioClip(payload)); - ipcMain.handle(IpcChannel.RecorderReady, () => { + ipcMain.handle(IpcChannel.SubmitAudioClip, (event, payload: unknown) => { + requireAllowedSender(event, [recorder?.getWindow()]); + if (!isRecorderSubmission(payload)) return undefined; + return dictation.submitAudioClip(payload); + }); + ipcMain.handle(IpcChannel.RecorderReady, (event) => { + requireAllowedSender(event, [recorder?.getWindow()]); recorder?.markReady(); dictation.reportRecorderReady(); }); - ipcMain.handle(IpcChannel.RecorderStarted, (_e, sessionId: string) => dictation.reportRecorderStarted(sessionId)); - ipcMain.handle(IpcChannel.ReportAudioFrame, (_e, frame: AudioVisualFrame) => dictation.updateAudioLevel(frame)); - ipcMain.handle(IpcChannel.RecorderFailure, (_e, payload: RecorderFailure) => dictation.handleRecorderFailure(payload)); - ipcMain.handle(IpcChannel.PrepareRecordingInput, () => nativeBridge.prepareRecordingInput?.() ?? null); - ipcMain.handle(IpcChannel.RestoreRecordingInput, (_e, deviceId: number | null) => { - if (typeof deviceId !== "number" || !Number.isFinite(deviceId)) return false; + ipcMain.handle(IpcChannel.RecorderStarted, (event, sessionId: unknown) => { + requireAllowedSender(event, [recorder?.getWindow()]); + if (!isBoundedString(sessionId, MAX_ID_LENGTH, false)) return undefined; + return dictation.reportRecorderStarted(sessionId); + }); + ipcMain.handle(IpcChannel.ReportAudioFrame, (event, frame: unknown) => { + requireAllowedSender(event, [recorder?.getWindow()]); + if (!isAudioVisualFrame(frame)) return undefined; + return dictation.updateAudioLevel(frame); + }); + ipcMain.handle(IpcChannel.RecorderFailure, (event, payload: unknown) => { + requireAllowedSender(event, [recorder?.getWindow()]); + if (!isRecorderFailure(payload)) return undefined; + return dictation.handleRecorderFailure(payload); + }); + ipcMain.handle(IpcChannel.PrepareRecordingInput, (event) => { + requireAllowedSender(event, [recorder?.getWindow()]); + return nativeBridge.prepareRecordingInput?.() ?? null; + }); + ipcMain.handle(IpcChannel.RestoreRecordingInput, (event, deviceId: unknown) => { + requireAllowedSender(event, [recorder?.getWindow()]); + if (typeof deviceId !== "number" || !Number.isSafeInteger(deviceId) || deviceId < 0 || deviceId > 0xFFFF_FFFF) return false; return nativeBridge.restoreRecordingInput?.(deviceId) ?? false; }); - ipcMain.handle(IpcChannel.GetRecorderConfig, () => ({ - micDeviceId: settings.get().micDeviceId, - preWarmMic: settings.get().preWarmMic, - captureBackend: settings.get().captureBackend, - })); + ipcMain.handle(IpcChannel.GetRecorderConfig, (event) => { + requireAllowedSender(event, [recorder?.getWindow()]); + return { + micDeviceId: settings.get().micDeviceId, + preWarmMic: settings.get().preWarmMic, + captureBackend: settings.get().captureBackend, + }; + }); // Phase 1: Provider API key testing - ipcMain.handle(IpcChannel.TestApiKey, async (_e, providerId: string, apiKey: string) => { + ipcMain.handle(IpcChannel.SetProviderApiKey, async (event, providerId: unknown, apiKey: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(providerId, MAX_ID_LENGTH, false) || !isBoundedString(apiKey, MAX_SECRET_LENGTH, false)) return undefined; + if (!credentials) return undefined; + await credentials.set(providerId, apiKey); + syncProviderApiKeyMetadata(providerId, true); + }); + + ipcMain.handle(IpcChannel.ClearProviderApiKey, async (event, providerId: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(providerId, MAX_ID_LENGTH, false)) return undefined; + if (!credentials) return undefined; + await credentials.delete(providerId); + syncProviderApiKeyMetadata(providerId, false); + }); + + ipcMain.handle(IpcChannel.TestApiKey, async (event, providerId: unknown, apiKey: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(providerId, MAX_ID_LENGTH, false) || !isBoundedString(apiKey, MAX_SECRET_LENGTH, false)) { + return { valid: false, message: "Invalid provider credentials." }; + } const registry = getProviderRegistry(); return validateSubmittedApiKey(providerId, apiKey, (id) => registry.getTranscription(id) || registry.getFormatting(id)); }); - ipcMain.handle(IpcChannel.GetProviderStatus, async () => { + ipcMain.handle(IpcChannel.GetProviderStatus, async (event) => { + requireAllowedSender(event, [mainWindow]); const registry = getProviderRegistry(); const statuses = await registry.getProviderStatus(); - const currentSettings = settings.get(); - const providerApiKeys = currentSettings.providerApiKeys ?? []; return Promise.all(statuses.map(async (s) => ({ id: s.id, name: s.name, available: s.available, - configured: providerApiKeys.some(pk => pk.providerId === s.id && pk.key) - || (s.id === "groq" && !!currentSettings.groqApiKey) - || (credentials ? !!(await credentials.get(s.id)) : false), + configured: credentials ? await credentials.has(s.id) : false, type: s.type, }))); }); // Capsule overlay: open last history entry for editing - ipcMain.on("capsule:open-last-entry", async () => { + ipcMain.on("capsule:open-last-entry", async (event) => { + if (!isSenderAllowed(event, [overlay?.getWindow()])) return; const latest = await history.getLatest(); if (latest) { dictation.navigateToHistoryEntry(latest.id); @@ -328,12 +652,15 @@ export function registerIpcHandlers(opts: { }); // Demo transcription (bypasses history and injection) - ipcMain.handle(IpcChannel.DemoTranscribe, async (_e, clip) => { + ipcMain.handle(IpcChannel.DemoTranscribe, async (event, clip: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isAudioClip(clip)) throw new Error("Invalid IPC payload"); return dictation.demoTranscribe(clip); }); // Manual update check - ipcMain.handle(IpcChannel.CheckForUpdates, async () => { + ipcMain.handle(IpcChannel.CheckForUpdates, async (event) => { + requireAllowedSender(event, [mainWindow]); try { if (app.isPackaged) { const result = await autoUpdater.checkForUpdates(); @@ -402,36 +729,49 @@ export function registerIpcHandlers(opts: { } }); - ipcMain.handle(IpcChannel.GetAppVersion, () => app.getVersion()); + ipcMain.handle(IpcChannel.GetAppVersion, (event) => { + requireAllowedSender(event, [mainWindow]); + return app.getVersion(); + }); - ipcMain.handle(IpcChannel.GetUpdateStatus, () => cachedUpdateStatus); + ipcMain.handle(IpcChannel.GetUpdateStatus, (event) => { + requireAllowedSender(event, [mainWindow]); + return cachedUpdateStatus; + }); - ipcMain.on(IpcChannel.QuitAndInstall, () => { + ipcMain.on(IpcChannel.QuitAndInstall, (event) => { + if (!isSenderAllowed(event, [mainWindow])) return; autoUpdater.quitAndInstall(); }); - ipcMain.on(IpcChannel.OpenReleasesPage, () => { + ipcMain.on(IpcChannel.OpenReleasesPage, (event) => { + if (!isSenderAllowed(event, [mainWindow])) return; void shell.openExternal("https://github.com/Onkarj012/Vaani/releases/latest"); }); // Local Whisper model management - ipcMain.handle(IpcChannel.WhisperListModels, () => { + ipcMain.handle(IpcChannel.WhisperListModels, (event) => { + requireAllowedSender(event, [mainWindow]); const modelsDir = join(homedir(), ".vaani", "models"); return listDownloadedModels(modelsDir); }); - ipcMain.handle(IpcChannel.WhisperLoadModel, (_e, modelName: string) => { + ipcMain.handle(IpcChannel.WhisperLoadModel, (event, modelName: unknown) => { + requireAllowedSender(event, [mainWindow]); + if (!isBoundedString(modelName, MAX_ID_LENGTH, false)) throw new Error("Invalid IPC payload"); assertValidWhisperModelName(modelName); const modelsDir = join(homedir(), ".vaani", "models"); const modelPath = join(modelsDir, `ggml-${modelName}.bin`); return loadWhisperModel(modelPath); }); - ipcMain.handle(IpcChannel.WhisperFreeModel, () => { + ipcMain.handle(IpcChannel.WhisperFreeModel, (event) => { + requireAllowedSender(event, [mainWindow]); freeWhisperModel(); }); - ipcMain.handle(IpcChannel.WhisperIsModelLoaded, () => { + ipcMain.handle(IpcChannel.WhisperIsModelLoaded, (event) => { + requireAllowedSender(event, [mainWindow]); return isModelLoaded(); }); } diff --git a/src/main/mediaPermissions.ts b/src/main/mediaPermissions.ts new file mode 100644 index 0000000..da78a68 --- /dev/null +++ b/src/main/mediaPermissions.ts @@ -0,0 +1,17 @@ +export interface MediaPermissionDetails { + mediaTypes?: string[]; +} + +export function shouldGrantMediaPermission( + requestingWebContents: object, + permission: string, + details: MediaPermissionDetails | undefined, + allowedWebContents: readonly (object | null | undefined)[] +): boolean { + if (permission !== "media" || !allowedWebContents.includes(requestingWebContents)) { + return false; + } + + const mediaTypes = details?.mediaTypes ?? []; + return mediaTypes.length === 0 || mediaTypes.every((type) => type === "audio"); +} diff --git a/src/main/nativeBridge.ts b/src/main/nativeBridge.ts index d45b401..ed2cbd6 100644 --- a/src/main/nativeBridge.ts +++ b/src/main/nativeBridge.ts @@ -47,12 +47,20 @@ interface NativeBridge { let cachedBridge: NativeBridge | null = null; function candidatePaths(): string[] { + // packaged app: extraResource copies to Contents/Resources/ + const packagedRootPath = join(process.resourcesPath ?? "", "vaani_native.node"); + const packagedUnpackedPath = join(process.resourcesPath ?? "", "app.asar.unpacked", ".vite", "build", "vaani_native.node"); + const packagedAppPath = join(process.resourcesPath ?? "", "app", ".vite", "build", "vaani_native.node"); + + if (app.isPackaged) { + return [packagedRootPath, packagedUnpackedPath, packagedAppPath]; + } + return [ - // packaged app: extraResource copies to Contents/Resources/ - join(process.resourcesPath ?? "", "vaani_native.node"), + packagedRootPath, join(currentDir, "vaani_native.node"), - join(process.resourcesPath ?? "", "app.asar.unpacked", ".vite", "build", "vaani_native.node"), - join(process.resourcesPath ?? "", "app", ".vite", "build", "vaani_native.node"), + packagedUnpackedPath, + packagedAppPath, join(process.cwd(), "build", "Release", "vaani_native.node"), join(currentDir, "../../build/Release/vaani_native.node"), join(currentDir, "../../../build/Release/vaani_native.node") @@ -75,6 +83,10 @@ function loadNativeAddon(): NativeBridge { } debug("native", "no native module found, using fallback bridge"); + if (app.isPackaged) { + throw new Error("Vaani native module not found in packaged resources - refusing to start with a broken/missing native bridge"); + } + return {}; } diff --git a/src/main/overlay.ts b/src/main/overlay.ts index 4ce421f..3e0be4c 100644 --- a/src/main/overlay.ts +++ b/src/main/overlay.ts @@ -40,6 +40,10 @@ export class OverlayController { private accentColor = "#FF006E"; // ── Public setters ──────────────────────────────────────────────────────── + getWindow(): BrowserWindow | null { + return this.window && !this.window.isDestroyed() ? this.window : null; + } + setColorMode(_colorMode: "light" | "dark"): void { // Overlay is always dark — no-op kept for call-site compatibility } @@ -560,6 +564,11 @@ export class OverlayController { }); this.window = win; + win.webContents.on("will-navigate", (event) => { + event.preventDefault(); + }); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); win.setAlwaysOnTop(true, "screen-saver"); win.setIgnoreMouseEvents(true, { forward: true }); diff --git a/src/main/providers/groq/groqStt.ts b/src/main/providers/groq/groqStt.ts index cf3830e..17469dc 100644 --- a/src/main/providers/groq/groqStt.ts +++ b/src/main/providers/groq/groqStt.ts @@ -18,7 +18,10 @@ export const GroqSttProvider: TranscriptionProvider = { id: "groq", name: "Groq Whisper", requiresApiKey: true, - models: [{ id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo" }], + models: [ + { id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo" }, + { id: "whisper-large-v3", name: "Whisper Large v3" }, + ], async transcribe(clip, options): Promise { debug("groq", `transcribe called: hasApiKey=${!!options.apiKey}, clipDuration=${clip.durationSeconds.toFixed(2)}s, samples=${clip.pcmData.length}`); diff --git a/src/main/recorderWindow.ts b/src/main/recorderWindow.ts index 963d7e3..2b5bfb6 100644 --- a/src/main/recorderWindow.ts +++ b/src/main/recorderWindow.ts @@ -21,6 +21,10 @@ export class RecorderWindowController { return this.ready && !!this.window && !this.window.isDestroyed(); } + getWindow(): BrowserWindow | null { + return this.window && !this.window.isDestroyed() ? this.window : null; + } + async init(): Promise { if (this.window && !this.window.isDestroyed()) { return; @@ -53,6 +57,11 @@ export class RecorderWindowController { }); this.window = win; + win.webContents.on("will-navigate", (event) => { + event.preventDefault(); + }); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + win.on("closed", () => { if (this.window === win) { this.window = null; diff --git a/src/main/store/base.ts b/src/main/store/base.ts index 5d5bd87..a505201 100644 --- a/src/main/store/base.ts +++ b/src/main/store/base.ts @@ -1,11 +1,13 @@ -import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises"; +import { readFile, writeFile, mkdir, rename, unlink, chmod } from "node:fs/promises"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; export async function readJsonFile(filePath: string, fallback: T): Promise { try { const raw = await readFile(filePath, "utf8"); - return JSON.parse(raw) as T; + const data = JSON.parse(raw) as T; + await chmod(filePath, 0o600).catch(() => undefined); + return data; } catch { return fallback; } @@ -14,12 +16,14 @@ export async function readJsonFile(filePath: string, fallback: T): Promise export async function writeJsonFile(filePath: string, data: T): Promise { const dir = dirname(filePath); await mkdir(dir, { recursive: true }); + await chmod(dir, 0o700); const tmp = join(dir, `.tmp-${randomBytes(6).toString("hex")}`); - await writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 }); try { await rename(tmp, filePath); } catch (err) { await unlink(tmp).catch(() => undefined); throw err; } + await chmod(filePath, 0o600); } diff --git a/src/main/store/dictationTrace.ts b/src/main/store/dictationTrace.ts index 1517f3b..226758d 100644 --- a/src/main/store/dictationTrace.ts +++ b/src/main/store/dictationTrace.ts @@ -83,6 +83,7 @@ function normalizeTraces(raw: unknown): DictationTrace[] { id: typeof item.id === "string" ? item.id : crypto.randomUUID(), sessionId: typeof item.sessionId === "string" ? item.sessionId : "", startedAt: typeof item.startedAt === "string" ? item.startedAt : new Date().toISOString(), + ...(typeof item.buildIdentifier === "string" ? { buildIdentifier: item.buildIdentifier } : {}), completedAt: typeof item.completedAt === "string" ? item.completedAt : undefined, hotkeyReleasedAt: typeof item.hotkeyReleasedAt === "string" ? item.hotkeyReleasedAt : undefined, targetAppBundleId: typeof item.targetAppBundleId === "string" ? item.targetAppBundleId : null, @@ -239,6 +240,7 @@ function normalizeInsertionVerification(value: unknown): NonNullable string; } export interface TextCleanupTrace { @@ -226,15 +231,17 @@ function shouldNormalizeNumberRun(normalized: string): boolean { function collapseAdjacentDuplicateWords(text: string): string { const preserveRepeats = new Set(["ha", "no", "ok", "okay", "really", "so", "very", "yes"]); - return text.replace( - /\b([\p{L}\p{N}][\p{L}\p{N}'-]{2,})([,.!?;:]?)(\s+)\1\b/giu, - (match, word: string, punctuation: string, spacing: string) => { - if (preserveRepeats.has(word.toLowerCase())) { - return match; - } - return `${word}${punctuation}${spacing}`.trimEnd(); - } - ); + let next = text; + while (true) { + const collapsed = next.replace( + /\b([\p{L}\p{N}][\p{L}\p{N}'-]*)([,.!?;:]?)(\s+)\1\b/giu, + (match, word: string, punctuation: string, spacing: string) => preserveRepeats.has(word.toLowerCase()) + ? match + : `${word}${punctuation}${spacing}`.trimEnd(), + ); + if (collapsed === next) return next; + next = collapsed; + } } function normalizeLineWhitespace(text: string): string { @@ -330,28 +337,83 @@ function formatMultilineText(text: string, settings: Settings): string { .trim(); } -function applyCorrections(text: string, corrections: Array<{ spoken: string; written: string }>, trace?: TextCleanupTrace): string { - return [...corrections] - .sort((left, right) => right.spoken.trim().length - left.spoken.trim().length) - .reduce((currentText, { spoken, written }) => { - const trimmedSpoken = spoken.trim(); - if (!trimmedSpoken) return currentText; - const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedSpoken)}(?=\\s|$|[,.!?])`, "gi"); - let matched = false; - const nextText = currentText.replace(pattern, (_, prefix) => { - matched = true; - return `${prefix}${written}`; - }); - if (matched) trace?.correctionsApplied.push({ spoken: trimmedSpoken, written }); - return nextText; - }, text); -} - -function applySnippets(text: string, snippets: Array<{ trigger: string; content: string }>): string { +interface TextReplacement { + start: number; + end: number; + value: string; + correction?: DictationCorrectionTrace; +} + +const MAX_EDIT_RATIO = 0.5; +const TOKEN_PATTERN = /[\p{L}\p{N}][\p{L}\p{N}'-]*/gu; +const OPEN_BOUNDARY = "(^|[\\s([\\{\\\"'“‘])"; +const CLOSE_BOUNDARY = "(?=\\s|$|[,.!?;:)\\]}\\\"'“”‘’…-])"; + +function fuzzyReplacementCandidates(text: string, correction: CustomCorrection): TextReplacement[] { + const spoken = correction.spoken.trim(); + if (correction.fuzzy !== true || spoken.length < 4) return []; + const tokens = [...text.matchAll(TOKEN_PATTERN)]; + const wordCount = spoken.split(/\s+/).length; + const candidates: TextReplacement[] = []; + for (let index = 0; index < tokens.length; index++) { + for (const count of [wordCount - 1, wordCount, wordCount + 1]) { + if (count < 1 || index + count > tokens.length) continue; + const first = tokens[index]; + const last = tokens[index + count - 1]; + if (first?.index === undefined || last?.index === undefined) continue; + const start = first.index; + const end = last.index + last[0].length; + const candidate = text.slice(start, end); + if (normalizedEditDistance(candidate, spoken) >= MAX_EDIT_RATIO) continue; + if (editDistance(candidate.toLowerCase(), spoken.toLowerCase()) > 2) continue; + if (!phoneticKeysEqual(candidate, spoken)) continue; + candidates.push({ start, end, value: correction.written, correction: { spoken, written: correction.written } }); + } + } + return candidates; +} + +export function applyDictionary(text: string, settings: Settings, trace?: TextCleanupTrace): string { + const replacements: TextReplacement[] = []; + for (const correction of settings.customCorrections ?? []) { + if (correction.enabled === false) continue; + const spoken = correction.spoken.trim(); + if (!spoken) continue; + const pattern = new RegExp( + correction.wholeWord === false ? escapeRegExp(spoken) : `${OPEN_BOUNDARY}${escapeRegExp(spoken)}${CLOSE_BOUNDARY}`, + correction.caseSensitive ? "g" : "gi", + ); + for (const match of text.matchAll(pattern)) { + const prefixLength = correction.wholeWord === false ? 0 : (match[1]?.length ?? 0); + const start = (match.index ?? 0) + prefixLength; + replacements.push({ start, end: start + spoken.length, value: correction.written, correction: { spoken, written: correction.written } }); + } + replacements.push(...fuzzyReplacementCandidates(text, correction)); + } + const selected: TextReplacement[] = []; + for (const candidate of replacements.sort((left, right) => (right.end - right.start) - (left.end - left.start) || left.start - right.start)) { + if (selected.some(existing => candidate.start < existing.end && candidate.end > existing.start)) continue; + selected.push(candidate); + } + const matched = new Set(); + for (const replacement of selected) if (replacement.correction) matched.add(JSON.stringify(replacement.correction)); + for (const encoded of matched) trace?.correctionsApplied.push(JSON.parse(encoded) as DictationCorrectionTrace); + return [...selected].sort((left, right) => right.start - left.start).reduce( + (current, replacement) => `${current.slice(0, replacement.start)}${replacement.value}${current.slice(replacement.end)}`, + text, + ); +} + +function applySnippets( + text: string, + snippets: Snippet[], + appProfileId: string | null | undefined, + placeholderResolver?: (name: "date" | "time" | "clipboard") => string, +): string { // Longest-trigger-first so overlapping triggers resolve to the longest match. const ordered = [...snippets] - .map(({ trigger, content }) => ({ trigger: trigger.trim(), content })) - .filter(({ trigger }) => trigger.length > 0) + .map(snippet => ({ ...snippet, trigger: snippet.trigger.trim() })) + .filter(({ trigger, appProfileIds }) => trigger.length > 0 && (!appProfileIds || (appProfileId !== null && appProfileId !== undefined && appProfileIds.includes(appProfileId)))) .sort((left, right) => right.trigger.length - left.trigger.length); if (ordered.length === 0) return text; @@ -362,29 +424,36 @@ function applySnippets(text: string, snippets: Array<{ trigger: string; content: // cross-form cascade (e.g. a typed snippet whose body contains `snippet name` // expanding again on a separate spoken pass). const alternation = ordered.map(({ trigger }) => escapeRegExp(trigger)).join("|"); + const bareAlternation = ordered + .filter(({ trigger, matchBareTrigger }) => (matchBareTrigger ?? false) && (trigger.length >= 6 || trigger.split(/\s+/).length >= 2)) + .map(({ trigger }) => escapeRegExp(trigger)) + .join("|"); const combined = new RegExp( `(^|\\s)/(${alternation})(?=\\s|$|[,.!?;:])` + - `|(^|[\\s,.!?;:])snippet\\s+(${alternation})(?=\\s|$|[,.!?;:])`, + `|(^|[\\s,.!?;:])snippet\\s+(${alternation})(?=\\s|$|[,.!?;:])` + + (bareAlternation ? `|(^|[\\s,.!?;:])(${bareAlternation})(?=\\s|$|[,.!?;:])` : ""), "gi", ); - const byTrigger = new Map(ordered.map(({ trigger, content }) => [trigger.toLowerCase(), content])); - const lookup = (raw: string): string => byTrigger.get(raw.toLowerCase()) ?? raw; + const byTrigger = new Map(ordered.map(snippet => [snippet.trigger.toLowerCase(), snippet.content])); + const lookup = (raw: string): string => (byTrigger.get(raw.toLowerCase()) ?? raw).replace(/\{\{(date|time|clipboard)\}\}/g, (_match, name: "date" | "time" | "clipboard") => placeholderResolver?.(name) ?? `{{${name}}}`); return text.replace( combined, - (_match, typedPrefix: string, typedName: string, spokenPrefix: string, spokenName: string) => + (_match, typedPrefix: string, typedName: string, spokenPrefix: string, spokenName: string, barePrefix: string, bareName: string) => typedName !== undefined ? `${typedPrefix}${lookup(typedName)}` - : `${spokenPrefix}${lookup(spokenName)}`, + : spokenName !== undefined + ? `${spokenPrefix}${lookup(spokenName)}` + : `${barePrefix}${lookup(bareName)}`, ); } -export function cleanupText({ rawText, settings, trace }: TextCleanupInput): string { +export function cleanupText({ rawText, settings, trace, skipCorrections = false, appProfileId, placeholderResolver }: TextCleanupInput): string { // Dictionary corrections and snippet expansion are user-defined replacements — // apply them even when general cleanup is off, otherwise the dictionary never triggers. - const corrected = applyCorrections(rawText, settings.customCorrections ?? [], trace); - const expanded = applySnippets(corrected, settings.snippets ?? []); + const corrected = skipCorrections ? rawText : applyDictionary(rawText, settings, trace); + const expanded = applySnippets(corrected, settings.snippets ?? [], appProfileId, placeholderResolver); if (!settings.cleanupEnabled) { const deduped = collapseAdjacentDuplicateWords(expanded); diff --git a/src/main/transcription.ts b/src/main/transcription.ts index c1174e0..f19d810 100644 --- a/src/main/transcription.ts +++ b/src/main/transcription.ts @@ -5,14 +5,49 @@ import { CredentialsStore } from "./store/credentials"; import { debug, warn } from "@main/log"; import { missingContentWords } from "@shared/contentGuard"; -const MAX_SINGLE_STT_CLIP_SECONDS = 30; +export const MAX_SINGLE_STT_CLIP_SECONDS = 30; const STT_CHUNK_OVERLAP_SECONDS = 2; +const TRANSCRIPTION_BASE_TIMEOUT_MS = 30_000; +const TRANSCRIPTION_PER_ADDITIONAL_CHUNK_TIMEOUT_MS = 10_000; +export const MAX_TRANSCRIPTION_TIMEOUT_MS = 300_000; +const LOW_LOGPROB_THRESHOLD = -1.2; + +const STRONGER_STT_MODELS: Record = { + groq: "whisper-large-v3", +}; + +export interface TranscriptionAttempt { + clip: AudioClip; + model: string; +} interface TranscribeOptions { languageOverride?: string; providerOverride?: string; rejectResult?: (result: TranscriptionResult) => boolean; retryClip?: AudioClip; + deadlineAt?: number; +} + +export function getTranscriptionTimeoutMs(durationSeconds: number): number { + const expectedChunkCount = Math.max(1, Math.ceil(Math.max(0, durationSeconds) / MAX_SINGLE_STT_CLIP_SECONDS)); + return Math.min( + MAX_TRANSCRIPTION_TIMEOUT_MS, + TRANSCRIPTION_BASE_TIMEOUT_MS + (expectedChunkCount - 1) * TRANSCRIPTION_PER_ADDITIONAL_CHUNK_TIMEOUT_MS, + ); +} + +export class TranscriptionDeadlineExceededError extends Error { + constructor() { + super("Transcription deadline exceeded."); + this.name = "TranscriptionDeadlineExceededError"; + } +} + +function throwIfTranscriptionDeadlineExceeded(deadlineAt?: number): void { + if (deadlineAt !== undefined && Date.now() >= deadlineAt) { + throw new TranscriptionDeadlineExceededError(); + } } export interface FormatTranscriptTraceResult { @@ -47,15 +82,19 @@ export class TranscriptionService { for (let providerIndex = 0; providerIndex < chain.length; providerIndex += 1) { const { id, provider, apiKey } = chain[providerIndex]!; const clips = options?.retryClip ? [clip, options.retryClip] : [clip]; - for (let clipIndex = 0; clipIndex < clips.length; clipIndex += 1) { + const attempts = buildTranscriptionAttempts(id, provider.models, clips, settings.transcriptionModel); + for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex += 1) { + const attempt = attempts[attemptIndex]!; + throwIfTranscriptionDeadlineExceeded(options?.deadlineAt); const startedAt = Date.now(); try { - const result = await transcribePossiblyChunked(provider, clips[clipIndex]!, { + const result = await transcribePossiblyChunked(provider, attempt.clip, { apiKey, language, + model: attempt.model || undefined, prompt: speechContextPrompt, - temperature: 0 - }); + temperature: 0, + }, options?.deadlineAt); const quality = { ...result.quality, provider: result.quality?.provider ?? id, @@ -68,11 +107,12 @@ export class TranscriptionService { quality, }; providerAttempts.push({ provider: id, success: true, latencyMs: Date.now() - startedAt, quality }); - if (options?.rejectResult?.(withQuality)) { + const lowConfidence = quality.avgLogprob != null && quality.avgLogprob < LOW_LOGPROB_THRESHOLD; + if (options?.rejectResult?.(withQuality) || lowConfidence) { lastRejectedResult = withQuality; - const canRetrySameProvider = clipIndex === 0 && clips.length > 1; - if (canRetrySameProvider) { - warn("transcription", `Provider "${id}" returned suspicious transcript; retrying with untrimmed audio`); + const hasNextAttempt = attemptIndex < attempts.length - 1; + if (hasNextAttempt) { + warn("transcription", `Provider "${id}" returned a low-confidence transcript; retrying transcription`); continue; } if (settings.failoverEnabled && providerIndex < chain.length - 1) { @@ -89,6 +129,9 @@ export class TranscriptionService { providerAttempts, }; } catch (error) { + if (error instanceof TranscriptionDeadlineExceededError || (options?.deadlineAt !== undefined && Date.now() >= options.deadlineAt)) { + throw error instanceof TranscriptionDeadlineExceededError ? error : new TranscriptionDeadlineExceededError(); + } if (isAuthError(error)) { throw error; } @@ -265,6 +308,7 @@ async function transcribePossiblyChunked( provider: TranscriptionProvider, clip: AudioClip, options: Parameters[1], + deadlineAt?: number, ): Promise { if (clip.durationSeconds <= MAX_SINGLE_STT_CLIP_SECONDS) { return provider.transcribe(clip, options); @@ -274,6 +318,7 @@ async function transcribePossiblyChunked( debug("transcription", `Chunking long clip for STT: ${clip.durationSeconds.toFixed(2)}s into ${chunks.length} chunks`); const results: TranscriptionResult[] = []; for (const [index, chunk] of chunks.entries()) { + throwIfTranscriptionDeadlineExceeded(deadlineAt); debug("transcription", `Transcribing chunk ${index + 1}/${chunks.length}: ${chunk.durationSeconds.toFixed(2)}s`); results.push(await provider.transcribe(chunk, options)); } @@ -281,13 +326,30 @@ async function transcribePossiblyChunked( return mergeChunkedTranscriptionResults(results, chunks); } -function splitAudioClip(clip: AudioClip, maxDurationSeconds: number, overlapSeconds: number): AudioClip[] { +export function buildTranscriptionAttempts( + providerId: string, + providerModels: TranscriptionProvider["models"], + clips: AudioClip[], + configuredModel: string, +): TranscriptionAttempt[] { + const model = providerModels.some((candidate) => candidate.id === configuredModel) ? configuredModel : ""; + const attempts = clips.map((clip) => ({ clip, model })); + const strongerModel = STRONGER_STT_MODELS[providerId]; + if (strongerModel && configuredModel !== strongerModel) { + const firstClip = clips[0]; + if (firstClip) attempts.push({ clip: firstClip, model: strongerModel }); + } + return attempts; +} + +export function splitAudioClip(clip: AudioClip, maxDurationSeconds: number, overlapSeconds: number): AudioClip[] { const samplesPerChunk = Math.max(1, Math.floor(clip.sampleRate * maxDurationSeconds)); const overlapSamples = Math.max(0, Math.min(samplesPerChunk - 1, Math.floor(clip.sampleRate * overlapSeconds))); const stepSamples = Math.max(1, samplesPerChunk - overlapSamples); const chunks: AudioClip[] = []; for (let start = 0; start < clip.pcmData.length; start += stepSamples) { - const end = Math.min(clip.pcmData.length, start + samplesPerChunk); + const nominalEnd = Math.min(clip.pcmData.length, start + samplesPerChunk); + const end = snapChunkEndToSilence(clip, start, nominalEnd); const pcmData = clip.pcmData.slice(start, end); chunks.push({ pcmData, @@ -300,6 +362,26 @@ function splitAudioClip(clip: AudioClip, maxDurationSeconds: number, overlapSeco return chunks.length > 0 ? chunks : [clip]; } +function snapChunkEndToSilence(clip: AudioClip, start: number, nominalEnd: number): number { + if (clip.rmsFrames.length === 0 || nominalEnd >= clip.pcmData.length) return nominalEnd; + + const windowSamples = Math.floor(clip.sampleRate * 2); + const windowStart = Math.max(start + 1, nominalEnd - windowSamples); + const windowEnd = Math.min(clip.pcmData.length, nominalEnd + windowSamples); + const framesPerSample = clip.rmsFrames.length / clip.pcmData.length; + const firstFrame = Math.max(0, Math.floor(windowStart * framesPerSample)); + const lastFrame = Math.min(clip.rmsFrames.length - 1, Math.ceil(windowEnd * framesPerSample) - 1); + if (firstFrame > lastFrame) return nominalEnd; + + let minimumFrame = firstFrame; + for (let frame = firstFrame + 1; frame <= lastFrame; frame += 1) { + if (clip.rmsFrames[frame]! < clip.rmsFrames[minimumFrame]!) minimumFrame = frame; + } + + const snappedEnd = Math.round((minimumFrame + 0.5) / framesPerSample); + return snappedEnd > start ? Math.min(snappedEnd, clip.pcmData.length) : nominalEnd; +} + function sliceRmsFramesForSamples(clip: AudioClip, startSample: number, endSample: number): number[] { if (clip.rmsFrames.length === 0 || clip.pcmData.length === 0) return []; const framesPerSample = clip.rmsFrames.length / clip.pcmData.length; diff --git a/src/preload/index.ts b/src/preload/index.ts index 64e0f54..46bc273 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -53,6 +53,8 @@ const api: VaaniAPI = { reportRendererReady: () => ipcRenderer.send(IpcChannel.RendererReady), reportRendererError: (payload) => ipcRenderer.send(IpcChannel.RendererError, payload), testApiKey: (providerId, apiKey) => ipcRenderer.invoke(IpcChannel.TestApiKey, providerId, apiKey), + setProviderApiKey: (providerId, apiKey) => ipcRenderer.invoke(IpcChannel.SetProviderApiKey, providerId, apiKey), + clearProviderApiKey: (providerId) => ipcRenderer.invoke(IpcChannel.ClearProviderApiKey, providerId), getProviderStatus: () => ipcRenderer.invoke(IpcChannel.GetProviderStatus), whisperListModels: () => ipcRenderer.invoke(IpcChannel.WhisperListModels), whisperLoadModel: (modelName) => { diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 5ba47da..edb5d48 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -17,6 +17,7 @@ import { useColorMode } from '../context/color-mode' import { useUpdateNotification } from '@renderer/hooks/useUpdateNotification' import SettingsModal from '@renderer/components/SettingsModal' import OnboardingModal from '@renderer/components/OnboardingModal' +import PermissionGuard from '@renderer/components/PermissionGuard' import UpdateBanner from '@renderer/components/UpdateBanner' import devanagariLightUrl from '../../../assets/iconset/devanagari/devanagari_light.svg?url' import devanagariDarkUrl from '../../../assets/iconset/devanagari/devanagari_dark.svg?url' @@ -110,45 +111,55 @@ function Sidebar({ isOpen, onClose, onSettings }: { isOpen: boolean; onClose: () export default function AppLayout() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false) + const [permissionsBlocked, setPermissionsBlocked] = useState(true) const { settings, settingsLoading, updateSettings } = useVaaniUi() const { notification, dismiss } = useUpdateNotification() const onboardingOpen = !settingsLoading && !settings.onboardingCompleted return (
- setIsMobileMenuOpen(false)} - onSettings={() => setIsSettingsOpen(true)} - /> +
{ + if (element) element.inert = permissionsBlocked + }} + className="contents" + > + setIsMobileMenuOpen(false)} + onSettings={() => setIsSettingsOpen(true)} + /> -
-
- -
+
+
+ +
- {notification && } + {notification && } -
- -
-
+
+ +
+
- setIsSettingsOpen(false)} /> - {!settingsLoading && !settings.onboardingCompleted && ( - updateSettings({ onboardingCompleted: true })} - /> - )} + setIsSettingsOpen(false)} /> + {!settingsLoading && !settings.onboardingCompleted && ( + updateSettings({ onboardingCompleted: true })} + /> + )} +
+
) } diff --git a/src/renderer/components/OnboardingModal.tsx b/src/renderer/components/OnboardingModal.tsx index eb6109a..6981a60 100644 --- a/src/renderer/components/OnboardingModal.tsx +++ b/src/renderer/components/OnboardingModal.tsx @@ -59,21 +59,18 @@ export default function OnboardingModal({ settings, onComplete, updateSettings } const [permissions, setPermissions] = useState({ microphone: "unknown", accessibility: "unknown" }); const [busy, setBusy] = useState(false); const [micAttempted, setMicAttempted] = useState(false); - const [apiKey, setApiKey] = useState(() => { - const entry = (settings.providerApiKeys ?? []).find((k) => k.providerId === settings.transcriptionProvider); - return entry?.key ?? (settings.transcriptionProvider === "groq" ? settings.groqApiKey ?? "" : ""); - }); + const [apiKey, setApiKey] = useState(""); const [showApiKey, setShowApiKey] = useState(false); - const [llmApiKey, setLlmApiKey] = useState(() => { - const entry = (settings.providerApiKeys ?? []).find((k) => k.providerId === settings.formattingProvider); - return entry?.key ?? ""; - }); + const [llmApiKey, setLlmApiKey] = useState(""); const [showLlmApiKey, setShowLlmApiKey] = useState(false); - function upsertProviderKey(providerId: string, key: string) { - const current = settings.providerApiKeys ?? []; - const idx = current.findIndex((k) => k.providerId === providerId); - return idx >= 0 ? current.map((k, i) => (i === idx ? { providerId, key } : k)) : [...current, { providerId, key }]; + async function saveProviderKey(providerId: string, key: string) { + if (!key.trim()) { + await window.vaani.clearProviderApiKey(providerId); + } else { + await window.vaani.setProviderApiKey(providerId, key); + } + await updateSettings({}); } async function refreshPermissions() { @@ -148,28 +145,27 @@ export default function OnboardingModal({ settings, onComplete, updateSettings } key="api" settings={settings} apiKey={apiKey} + hasConfiguredApiKey={!!(settings.providerApiKeys ?? []).find((pk) => pk.providerId === settings.transcriptionProvider)?.hasKey} showApiKey={showApiKey} llmApiKey={llmApiKey} showLlmApiKey={showLlmApiKey} onKeyChange={(v) => { setApiKey(v); - void updateSettings({ providerApiKeys: upsertProviderKey(settings.transcriptionProvider, v), ...(settings.transcriptionProvider === "groq" ? { groqApiKey: v } : {}) }); }} + onKeyBlur={() => { void saveProviderKey(settings.transcriptionProvider, apiKey); }} onToggleShow={() => setShowApiKey(!showApiKey)} onProviderChange={(v) => { void updateSettings({ transcriptionProvider: v }); - const entry = (settings.providerApiKeys ?? []).find((k) => k.providerId === v); - setApiKey(entry?.key ?? (v === "groq" ? settings.groqApiKey ?? "" : "")); + setApiKey(""); }} onLlmKeyChange={(v) => { setLlmApiKey(v); - void updateSettings({ providerApiKeys: upsertProviderKey(settings.formattingProvider, v) }); }} + onLlmKeyBlur={() => { void saveProviderKey(settings.formattingProvider, llmApiKey); }} onToggleLlmShow={() => setShowLlmApiKey(!showLlmApiKey)} onLlmProviderChange={(v) => { void updateSettings({ formattingProvider: v }); - const entry = (settings.providerApiKeys ?? []).find((k) => k.providerId === v); - setLlmApiKey(entry?.key ?? ""); + setLlmApiKey(""); }} onLanguageChange={(v) => { void updateSettings({ language: v }); }} />, @@ -184,11 +180,13 @@ export default function OnboardingModal({ settings, onComplete, updateSettings } const selectedSttProvider = KNOWN_PROVIDERS.find((p) => p.id === settings.transcriptionProvider && (p.type === "stt" || p.type === "local-stt")); const requiresApiKey = selectedSttProvider?.requiresApiKey !== false; - const hasRequiredSttKey = !requiresApiKey || !!apiKey.trim(); + const hasConfiguredSttKey = !!(settings.providerApiKeys ?? []).find((pk) => pk.providerId === settings.transcriptionProvider)?.hasKey; + const hasRequiredSttKey = !requiresApiKey || !!apiKey.trim() || hasConfiguredSttKey; const selectedLlmProvider = KNOWN_PROVIDERS.find((p) => p.id === settings.formattingProvider && p.type === "llm"); const llmRequiresKey = selectedLlmProvider?.requiresApiKey !== false; const llmNeedsOnboardingKey = llmRequiresKey && !EXCLUDED_LLM_KEY_PROVIDERS.has(selectedLlmProvider?.id ?? ""); - const hasRequiredLlmKey = !llmNeedsOnboardingKey || !!llmApiKey.trim(); + const hasConfiguredLlmKey = !!(settings.providerApiKeys ?? []).find((pk) => pk.providerId === settings.formattingProvider)?.hasKey; + const hasRequiredLlmKey = !llmNeedsOnboardingKey || !!llmApiKey.trim() || hasConfiguredLlmKey; const hasRequiredApiKey = hasRequiredSttKey && hasRequiredLlmKey; const nextDisabled = (slide === 2 && !canContinueFromPermissions) || (slide === 3 && !hasRequiredApiKey) || busy; @@ -344,15 +342,15 @@ function PermissionsSlide({ } function ProviderApiSlide({ - settings, apiKey, showApiKey, llmApiKey, showLlmApiKey, - onKeyChange, onToggleShow, onProviderChange, onLlmKeyChange, onToggleLlmShow, onLlmProviderChange, onLanguageChange, + settings, apiKey, hasConfiguredApiKey, showApiKey, llmApiKey, showLlmApiKey, + onKeyChange, onKeyBlur, onToggleShow, onProviderChange, onLlmKeyChange, onLlmKeyBlur, onToggleLlmShow, onLlmProviderChange, onLanguageChange, }: { - settings: Settings; apiKey: string; showApiKey: boolean; llmApiKey: string; showLlmApiKey: boolean; - onKeyChange: (v: string) => void; onToggleShow: () => void; onProviderChange: (v: string) => void; - onLlmKeyChange: (v: string) => void; onToggleLlmShow: () => void; onLlmProviderChange: (v: string) => void; + settings: Settings; apiKey: string; hasConfiguredApiKey: boolean; showApiKey: boolean; llmApiKey: string; showLlmApiKey: boolean; + onKeyChange: (v: string) => void; onKeyBlur: () => void; onToggleShow: () => void; onProviderChange: (v: string) => void; + onLlmKeyChange: (v: string) => void; onLlmKeyBlur: () => void; onToggleLlmShow: () => void; onLlmProviderChange: (v: string) => void; onLanguageChange: (v: string) => void; }) { - const isValid = apiKey.trim().length > 0; + const isValid = apiKey.trim().length > 0 || hasConfiguredApiKey; const sttProviders = KNOWN_PROVIDERS.filter((p) => p.type === "stt" || p.type === "local-stt"); const activeProvider = sttProviders.find((p) => p.id === settings.transcriptionProvider); const llmProviders = KNOWN_PROVIDERS.filter((p) => p.type === "llm"); @@ -381,7 +379,7 @@ function ProviderApiSlide({
- onKeyChange(e.target.value)} autoComplete="off" spellCheck={false} + onKeyChange(e.target.value)} onBlur={onKeyBlur} autoComplete="off" spellCheck={false} placeholder={activeProvider?.id === "openai" ? "sk-..." : activeProvider?.id === "deepgram" ? "Token..." : "gsk_..."} className="pr-11 font-mono" /> +
+
+ + ); +} + +function PermissionRow({ + icon, title, description, state, action, guidance, primaryRef, busy, onRequest, onSettings, onRetry, +}: { + icon: ReactNode; + title: string; + description: string; + state: MacOSPermissionState; + action: "none" | "request" | "open-settings" | "retry"; + guidance?: string; + primaryRef?: Ref; + busy: boolean; + onRequest: () => void; + onSettings: () => void; + onRetry: () => void; +}) { + const granted = state === "granted"; + return ( + +
+
+ {icon} +
+
+
+
{title}
+
+ {granted ? : null}{permissionLabel(state)} +
+
+

{description}

+ {guidance &&

{guidance}

} + {!granted && action !== "none" && ( +
+ {action === "request" && } + {action === "open-settings" && } + {action === "retry" && } + {(action === "retry" || (title === "Accessibility" && action === "request")) && } +
+ )} +
+
+
+ ); +} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 4cc3b73..5327268 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -242,11 +242,18 @@ export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) { setNewProfileLanguage('auto'); }; - const saveProviderKey = (providerId: string, key: string) => { - const current = settings.providerApiKeys ?? [] - const existing = current.findIndex((p) => p.providerId === providerId) - const next = existing >= 0 ? current.map((p, i) => (i === existing ? { providerId, key } : p)) : [...current, { providerId, key }] - void updateSettings({ providerApiKeys: next }) + const clearProviderKey = async (providerId: string) => { + await window.vaani.clearProviderApiKey(providerId) + await updateSettings({}) + } + + const saveProviderKey = async (providerId: string, key: string) => { + if (!key.trim()) { + await clearProviderKey(providerId) + return + } + await window.vaani.setProviderApiKey(providerId, key) + await updateSettings({}) } const handleExportData = () => { @@ -282,16 +289,27 @@ export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {

{providerSummary(activeStt)}

+ {activeStt && activeStt.models.length > 0 && ( +
+ Transcription Model +