Issue #402 needs a Settings toggle that applies to every LLM runtime path without changing prompt behavior. The implementation keeps the preference in user settings, threads it through polish, translate, QA, and validation, and sends only provider-channel request fields documented by each backend instead of prompt instructions or model-name heuristics.
Constraint: Thinking controls are provider-specific request fields, not a portable prompt convention
Rejected: Prompt-level thinking instructions | changes model-facing prompts and cannot guarantee provider thinking mode
Rejected: Per-model allow/deny lists | user requested channel-level behavior and provider-side handling for unsupported models
Confidence: medium
Scope-risk: moderate
Directive: Do not add prompt injection or model-name thinking adapters for #402 without a new explicit product decision
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib openai_chat_body -- --nocapture
Tested: cd openless-all/app && cargo test --manifest-path src-tauri/Cargo.toml --lib disabled_thinking_config -- --nocapture
Tested: cd openless-all/app && cargo test --manifest-path src-tauri/Cargo.toml --lib build_generate_body -- --nocapture
Tested: cd openless-all/app && cargo check --manifest-path src-tauri/Cargo.toml
Tested: cd openless-all/app && npm run build
Tested: git diff --check
Related: #402
* feat(settings): cross-platform parity for local ASR
- AdvancedSection: warning collapses to inline 11px amber badge in
the title row's top-right; the enable-confirm popup is now a
centered fixed modal with backdrop blur(8px) and click-outside-
to-cancel (busy-guarded).
- Symmetric rows by platform — fixes the #400 bug where the Qwen3
row was rendered on Windows even though all qwen_engine /
local_provider / cache modules are #[cfg(target_os = "macos")],
so Enable on Windows would always error:
macOS: Qwen3 row enabled / Foundry row hidden
Windows: Foundry row enabled / Qwen3 row disabled with
notSupportedHere desc
- Providers ASR Card: replaces the plain-text localAsrActiveNotice
branch with an always-rendered dropdown that includes the
platform-native local engine (macOS=Qwen3, Windows=Foundry) as a
disabled option, plus a takeover hint underneath. Entire
dropdown locks when a local engine is active, so it can only be
released via Advanced (matches "启动之后供应商将被接管" UX).
- onLlmProviderChange / onAsrProviderChange now emit saving →
saved through emitSaved. Data was already being persisted; the
saved-toast was missing.
- LocalAsr embedded: hide Qwen3 model management (mirror /
engine-status / model list) on Windows — dead UI without the
Qwen3 backend. The shared error Card is kept unconditional
since Foundry handlers also call setError.
- i18n × 5 (zh-CN/zh-TW/en/ja/ko): shorten localAsrWarningShort;
rewrite qwen3Desc and foundryDesc to '启动之后,ASR 提供商将被接管';
add notSupportedHere and localAsrTakeoverHint.
* fix(settings): clear saving toast on provider-switch failure
pr_agent #403 caught: emitSaved('saving') with no try/catch — if
setActiveLlmProvider / setActiveAsrProvider / any later credential
write throws, the right-corner toast stays stuck on saving forever.
Wrap both onLlmProviderChange and onAsrProviderChange bodies in
try/catch. On error, emit 'failed' (and re-throw) only if seq still
matches — stale calls superseded by a newer switch shouldn't
overwrite the newer call's own saving state.
* fix(settings): provider-card visual cleanup + log-export permission + pr_agent #403
Fixes piled up in user feedback during local verify of the previous
commit; bundled into one commit to keep #403 small.
- capabilities/default.json: add `dialog:default` permission. The
About → Export error log button always failed because the Tauri 2
capabilities did not allow `tauri-plugin-dialog`'s `save()` command;
the JS rejected before export_error_log was ever invoked.
- ProvidersSection ASR Card (pr_agent #403 'Wrong active label'):
when committedAsrProvider is a local engine, selectedValue now
reflects the actually-active engine instead of being forced to
platformLocalAsr. Cross-machine config sync (macOS profile with
foundry-local-whisper / Windows profile with local-qwen3) used to
make the dropdown lie about which backend is running. Anomalous
local engines are added as disabled <option>s so the controlled
<select> still finds its value and doesn't fall back to the first
option.
- ProvidersSection: drop verbose desc on LLM and ASR provider rows.
'选择后将自动填入 Base URL 默认值。' is ~16 Chinese chars and
always wraps inside the 180px label column, leaving a stranded
second line on the left — the dropdown itself already conveys the
semantic, the desc was redundant.
- AdvancedSection: replace the [启用] / [已启用] Btn with the
shared Toggle component for Qwen3 and Foundry rows. Toggle off
switches back to volcengine. The Qwen3 row's Toggle truthfully
reflects committed state on Windows (so a cross-platform anomaly
shows ON even though it is non-interactive there). The standalone
'禁用本地 ASR' escape row is now only rendered when no inline
Toggle can disable the active engine — i.e., Linux / unsupported
platform, or anomaly where the row for the foreign engine is
hidden / non-interactive.
- PermissionsSection: every status pill (microphone, accessibility,
hotkey, Windows IME, network) now right-aligns inside its row via
`justifyContent: flex-end` + `width: 100%`. Pill text moved to the
right of inline messages so the green '已授权' / 'Authorized'
reads as a right-edge badge — closer to macOS System Settings
convention.
* fix(settings): keep Windows Qwen3 toggle visually OFF (pr_agent #403)
pr_agent 'Stale Windows state' caught: previous commit had `on=
{isOnLocalQwen3}` which truthfully reflected committed state, so a
profile synced from macOS with activeAsrProvider=local-qwen3 would
render Toggle ON next to a desc that says '本平台暂不支持'. The
visual contradiction was the concern.
Switch to `on={isMac && isOnLocalQwen3}` — Windows always renders
the row OFF + non-interactive, matching the unsupported desc. The
cross-platform anomaly state surfaces via the standalone '禁用本地
ASR' escape row, which is rendered exactly when no inline Toggle
can disable the active engine.
Comment updated to match the new behavior and document the
tradeoff.
* fix(localasr): scope Qwen3 mgmt UI to macOS only (pr_agent #403)
pr_agent 'Linux regression' caught: previous wrap was `!IS_WINDOWS`,
so a hypothetical Linux render path would still expose the Qwen3
mirror / model-list / engine-status UI even though Qwen3's backend
is `#[cfg(target_os = "macos")]`.
Swap the two wrappers to `IS_MAC` so the management cards only
materialize on macOS. Today's AdvancedSection guards LocalAsr
embedded with `platformSupported = isMac || isWin`, so Linux already
short-circuits before LocalAsr renders — but defense in depth here
costs nothing and keeps the comment accurate.
---------
Co-authored-by: baiqing <lbx12309@icloud.com>
Codex OAuth should let users spend their existing Codex subscription on short dictation polish, translation, and QA without storing another API key in OpenLess. The provider reads the local Codex auth file, keeps OpenAI-compatible providers on their existing path, uses a lighter Spark default for latency, and broadens Codex auth discovery for Windows GUI environments.
The same change keeps Volcengine on the duration-priced BigASR resource validated locally and preserves proxy behavior so Codex network paths can use system proxy settings while loopback/local provider checks avoid the proxy.
Constraint: Codex OAuth credentials must remain sourced from the Codex auth file and must not be copied into OpenLess credential storage.
Constraint: PR #401 is a fork PR from BBossss/openless:beta to appergb/openless:beta.
Rejected: Require a separate OpenAI API key | the requested path was to reuse the existing Codex subscription.
Rejected: Keep gpt-5.5 as the default | it is unnecessarily heavy for short dictation polish and was observed timing out.
Rejected: Use only HOME for Codex auth lookup | Windows GUI sessions commonly expose USERPROFILE instead.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Do not persist Codex OAuth tokens into OpenLess credentials; keep using the Codex auth file as the source of truth.
Directive: Keep Codex OAuth defaults optimized for short low-latency dictation polishing unless product requirements change.
Tested: git diff --cached --check
Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib
Tested: npm run build
Tested: PR #401 CI passed on Windows, macOS, and Linux before this history-only squash.
Co-authored-by: OmX <omx@oh-my-codex.dev>
* chore: ignore video-materials/ promo recordings
video-materials/ holds ~2.2 GB of promo recordings (promo /
promo-materials / promo-openless / promo-openless-v2 / SC). These
are local working assets that don't belong in the repo. Add to
.gitignore so future stash / commit operations can't accidentally
pull the binary blob in.
* feat(llm): Google Gemini provider with forced thinking-off + fix provider-switch credentials
Add Google Gemini as a native LLM provider hitting v1beta
:generateContent (single-shot) and :streamGenerateContent?alt=sse
(QA chat) with x-goog-api-key auth. Per-model thinkingConfig is
injected to keep thinking off for every supported ID — exact
matrix verified against ai.google.dev/gemini-api/docs/thinking:
gemini-2.5-flash, gemini-2.5-flash-lite thinkingBudget: 0
gemini-2.5-pro field omitted (Google: cannot disable)
gemini-3*-pro* thinkingLevel: "low"
gemini-3*-flash*, unknown future IDs thinkingLevel: "minimal"
Why native, not the OpenAI-compat shim: shim is beta and for 3.x
needs an extra_body workaround for thinking control; auth header
and contents shape differ enough that reusing
OpenAICompatibleLLMProvider would have been the worse compromise.
fix(settings): switching LLM preset previously only auto-filled
endpoint/model when those slots were empty. All providers share
ark.endpoint / ark.model_id (per-provider entries were never
introduced — see persistence.rs lookup_account), so for returning
users with non-empty slots, switching the dropdown updated active
provider but kept the OLD endpoint — polish silently kept calling
the previous provider. Now switching to any non-custom preset
overwrites endpoint/model to that preset's defaults. Trade-off:
user-customized endpoints within a preset are dropped on switch;
silent wrong-routing was a worse failure mode.
Backend:
- New module llm_gemini.rs (15 unit tests for thinking-config table,
URL builder, response parser, SSE shape, history role mapping)
- polish.rs extracts compose_polish_prompts / compose_translate_prompts
/ compose_qa_system_prompt as pub(crate) helpers shared by both
LLM paths so prompt assembly can't drift
- coordinator.rs's polish_text / translate_text / answer_chat_dispatch
open with `if get_active_llm() == "gemini"` early-return; existing
OpenAI-compat callsite stays untouched
Frontend:
- LLM_PRESETS gains gemini entry (baseUrl v1beta, model placeholder
gemini-2.5-flash)
- ProviderTools hidden when committedLlmProvider === 'gemini'
(its "Fetch models" button assumes OpenAI /models shape; native
/v1beta/models response shape doesn't match)
- Static geminiModelHint lists the 7 available IDs so users pick
correctly without auto-fetch
- 5 locales (zh-CN, zh-TW, en, ja, ko) gain presets.gemini and
geminiModelHint
Test plan:
- cargo test --lib 200/200 pass
- npm run build (tsc + vite) green
- Manual: switch to Gemini in Settings,
paste Google AI Studio key, dictate;
verify polish reaches v1beta and respects
the per-model thinking-off contract.
* docs: 2026-05-10 audit + beta logic review snapshots
Snapshot two audit artifacts produced 2026-05-10:
- docs/audit-2026-05-10-validated.md — 21 audit items run against
the live source tree at openless-all/app/src-tauri/src/ with a
CONFIRMED / FALSE_POSITIVE / PARTIAL verdict per item plus the
exact file:line range that supports each verdict.
- docs/logic-review-2026-05-10.md — 5-path logic review of
origin/beta @ 400097ad covering startup, press/release, end-of-
session, capsule emit, and shutdown. Notable: at 400097ad the
emit_capsule fix from PR #389 was NOT yet an ancestor, so the
cpal audio-callback → AppKit/Win32 SIGTRAP risk was still live
on that snapshot (subsequent merges have addressed it; this doc
remains the dated record).
Both files are pure snapshots — no code changes here.
* fix(llm-gemini): buffer SSE bytes across chunks to handle multibyte UTF-8 split
PR #398 pr_agent flagged: reqwest::chunk() can split a multi-byte
UTF-8 codepoint (CJK / emoji) across two network chunks, but the
streaming code decoded each chunk independently with from_utf8 →
half a 你 in the tail of one chunk would surface as
"non-utf8 SSE chunk" and abort the whole stream. This breaks
Gemini QA streaming for any Chinese-language answer that doesn't
happen to align with packet boundaries.
Fix: buffer raw bytes (Vec<u8>) until we see the SSE delimiter
b"\n\n", and only then decode the complete event content as UTF-8.
The delimiter \n\n is two ASCII bytes (0x0A 0x0A) which can never
appear inside a multi-byte UTF-8 sequence, so byte-level splitting
at that boundary is always safe.
Extracted the parsing as drain_complete_sse_events(&mut Vec<u8>) so
the invariant is testable without a TcpListener. Three new unit
tests cover:
- normal split-on-delimiter + remainder buffering
- multibyte UTF-8 split across chunks (the actual regression)
- invalid-UTF-8 event content gracefully skipped (warn, don't crash)
The same byte-level pattern fix applies to polish.rs's
chat_completion_history_streaming — left untouched here to keep
this PR scoped to the Gemini diff pr_agent reviewed. polish.rs
should get the same fix in a follow-up.
* feat(llm-gemini): teach ProviderTools to fetch native Gemini /v1beta/models
Drop the verbose static geminiModelHint paragraph and restore the
normal ProviderTools "Fetch models" button when the active provider
is Gemini. Users now read live model IDs from Google instead of
staring at a wall of i18n text below their model field.
Backend fetch_provider_models now branches on the base URL:
- Google's generativelanguage.googleapis.com → x-goog-api-key auth +
parse_gemini_model_ids() that walks `models[].name` and strips the
`models/` prefix so the result is a plain `gemini-2.5-flash` style
ID directly writable to ark.model_id.
- Anything else → unchanged Bearer auth + OpenAI `data[].id` shape.
Tests: parse_gemini_model_ids_strips_models_prefix_and_dedups +
is_gemini_base_url_matches_official_domain. cargo test --lib green
(32/32 in commands::tests subset).
i18n: settings.providers.geminiModelHint removed from all 5 locales.
The TS `typeof zhCN` constraint enforces the consistent removal.
* fix(llm-gemini): pr_agent #398 follow-ups — filter non-chat models + CRLF SSE delim
Two follow-up fixes for pr_agent advisories on PR #398:
1. parse_gemini_model_ids now filters by `supportedGenerationMethods`.
Google's /v1beta/models lists every family — embeddings,
TTS-image, predict-only — alongside generateContent-capable
chat models. Without filtering, a "Fetch models" click would
surface ids like `gemini-embedding-2`, and a user picking one
would write it into ark.model_id and have polish silently fail
on every dictation. Field is conservatively included when
missing (some preview models don't expose it yet).
2. drain_complete_sse_events now accepts both `\n\n` and `\r\n\r\n`
as event delimiters. Some HTTP/2 middleware and CDNs normalize
line endings to CRLF; the previous LF-only implementation would
never drain a complete event from those servers and report the
stream as empty even when data was received. Tied positions
can't happen by construction (\r\n\r\n contains no \n\n
substring), so "earliest match wins" is unambiguous.
Tests:
- parse_gemini_model_ids_filters_out_non_generate_content_families
- drain_complete_sse_events_handles_crlf_delimiter
- drain_complete_sse_events_picks_earliest_delimiter_when_mixed
---------
Co-authored-by: baiqing <lbx12309@icloud.com>
Snapshot two audit artifacts produced 2026-05-10:
- docs/audit-2026-05-10-validated.md — 21 audit items run against
the live source tree at openless-all/app/src-tauri/src/ with a
CONFIRMED / FALSE_POSITIVE / PARTIAL verdict per item plus the
exact file:line range that supports each verdict.
- docs/logic-review-2026-05-10.md — 5-path logic review of
origin/beta @ 400097ad covering startup, press/release, end-of-
session, capsule emit, and shutdown. Notable: at 400097ad the
emit_capsule fix from PR #389 was NOT yet an ancestor, so the
cpal audio-callback → AppKit/Win32 SIGTRAP risk was still live
on that snapshot (subsequent merges have addressed it; this doc
remains the dated record).
Both files are pure snapshots — no code changes here.
video-materials/ holds ~2.2 GB of promo recordings (promo /
promo-materials / promo-openless / promo-openless-v2 / SC). These
are local working assets that don't belong in the repo. Add to
.gitignore so future stash / commit operations can't accidentally
pull the binary blob in.
* feat(settings): hide local ASR behind new Advanced tab + system confirm dialog
Move local ASR options (local-qwen3 / foundry-local-whisper) out of
the main Providers → ASR dropdown into a new "Advanced" Settings tab,
so first-time users can't accidentally enable CPU inference and get
slower / less-accurate transcription than they expected. Enabling
local ASR now requires reading a strong yellow/amber warning and
confirming a system-level dialog (@tauri-apps/plugin-dialog ask()
with kind:'warning') — the in-page modal pattern wasn't loud enough.
Behavior changes:
- visibleAsrPresets now excludes local-qwen3 and foundry-local-whisper
(the dropdown is cloud-only)
- useEffect lookup uses ASR_PRESETS (all) so committedAsrProvider can
still resolve to a local provider when one is active
- When committedAsrProvider is local, the ASR Card replaces the
dropdown with a plain notice ("Local ASR (X) is currently active —
switch / disable in the Advanced tab"); existing
LocalAsrProviderHint rendering for the Card body is preserved
- New AdvancedSection component:
- Strong warning block (amber background + amber border + amber text,
fontWeight 500, ⚠️ icon) — meets user's "yellow/red obvious" ask
- Per-platform engine selector:
macOS : Qwen3-ASR only
Windows: Qwen3-ASR + Foundry Local Whisper
Linux : "Local ASR model integration is not supported on this platform"
- Enable buttons trigger Tauri ask() confirm dialog before switching
- When already on local: shows a "Disable local ASR" button
(switches to volcengine, no confirm needed) plus the existing
LocalAsrProviderHint for model status / download navigation
- SettingsSectionId gains 'advanced'; SECTION_ORDER appends it after
language so it sits last in the sidebar (matches macOS conventions
for Advanced/Experimental panes)
i18n: 5 locales (zh-CN, zh-TW, en, ja, ko) gain
- settings.sections.advanced
- settings.providers.localAsrActiveNotice
- settings.advanced.{localAsrTitle, localAsrDesc, localAsrWarningStrong,
qwen3Desc, foundryDesc, enable, alreadyActive, disableLocalLabel,
disableLocalDesc, disable, platformNotSupported,
confirmEnableLocalTitle, confirmEnableLocalBody}
Test plan:
- npm run build (tsc + vite) — green; the typeof zhCN constraint
catches any locale that drifted out of shape
- Manual: switch Settings tab to "Advanced", verify warning is
amber-on-amber and clearly visible, click "Enable" on Qwen3-ASR,
verify a system dialog (not a React modal) appears with kind:warning
iconography, cancel — verify nothing changed; confirm — verify the
Providers tab now shows "Local ASR is currently active" instead of
the dropdown
- Manual on macOS: confirm Foundry option does NOT appear
- Manual on Linux (if available): confirm "platform not supported"
message renders
* feat(advanced): inline LocalAsr model UI + page-top confirm popup + concise warning
User feedback: the standalone "Models" page (LocalAsr.tsx as a main
nav tab) was redundant once Advanced settings exists, the inline
amber warning was a wall of text, and clicking Enable triggered an
OS-native dialog that didn't feel like part of the app. Refactor
the whole local-ASR surface around what users actually need:
- "Models" main-nav tab removed (FloatingShell + NAVIGATE_LOCAL_ASR_EVENT
listener gone). Model management is now exclusively reachable via
Settings → Advanced.
- LocalAsr.tsx gains an `embedded` prop. When embedded, the outer
page padding/height, PageHeader, and standalone warning Card are
skipped — the host (AdvancedSection) supplies the chrome.
- AdvancedSection no longer calls @tauri-apps/plugin-dialog ask().
Enabling local ASR sets pendingTarget which renders a single
amber-bordered Card at the very top of the section with
Confirm / Cancel buttons. One box, in-app, dismissable. Disable
is direct (no confirm needed — going back to cloud is safe).
- Persistent inline warning shrunk to one line. The longer wording
(including the new "may drop words on under-spec hardware" risk
the user asked for) lives in the popup body so it's only seen
when actually consequential.
- ProvidersSection ASR Card body returns null when active is
local — the dropdown row's localAsrActiveNotice already points
to Advanced, no need to duplicate manage UI here.
i18n (5 locales): localAsrWarningStrong replaced by
localAsrWarningShort; confirmEnableLocalBody / Title shortened;
new `confirm` key for the popup CTA.
LocalAsrProviderHint and NAVIGATE_LOCAL_ASR_EVENT in Settings.tsx
are now unused but left in place to keep the diff focused; will
clean up in a follow-up commit if pr_agent flags them.
Test plan:
- npm run build (tsc + vite) — green
- Manual on macOS: Settings → Advanced shows one short amber line +
Enable Qwen3-ASR button + LocalAsr model UI inline. Click Enable
→ page-top popup appears (no OS dialog), with Confirm / Cancel.
Cancel → no change. Confirm → switches; Disable button appears,
still single-click no confirm.
- Manual: Providers → ASR with active=local-qwen3 shows only the
"Local ASR is currently active — switch in Advanced" notice,
no manage UI.
* fix(advanced): pr_agent #400 follow-up — keep disable accessible on unsupported platform
If a user enables local-qwen3 (or foundry-local-whisper) on a machine
that supports it, then their preferences sync to a Linux/unsupported
machine, the previous Advanced UI rendered only the
"platformNotSupported" message and hid the disable button — they had
no in-app path to switch back to cloud and dictation would fail
silently on every press.
Move the disable SettingRow out of the platformSupported branch.
It's gated only on isOnAnyLocal now, so:
- Supported platform + on local → enable Qwen3/Foundry rows + disable row
- Supported platform + on cloud → enable rows only
- Unsupported platform + on local → "not supported" notice + disable row
- Unsupported platform + on cloud → "not supported" notice only
This is the pr_agent advisory from PR #400 review @ d0a425a1.
---------
Co-authored-by: baiqing <lbx12309@icloud.com>
User-requested mechanism (/init args): when implementing against any
external surface — third-party HTTP APIs, unfamiliar Rust crates / npm
packages, or platform APIs (Apple Security framework / Win32 / Carbon
/ AppKit) — Claude must dispatch a dedicated subagent to fetch
authoritative documentation rather than write integration code from
training memory. The subagent acts as a network-search-and-retrieval
layer that returns distilled, version-aware usage data so the main
context stays uncluttered and the implementation always tracks the
current upstream surface.
Two surgical edits:
1. AGENTS.md §"Third-party service integrations" — heading widened to
"Third-party service integrations & library / platform API research"
so the existing 5-step research-first workflow explicitly covers
library + platform APIs, not just HTTP service integrations.
Rephrased the lead-in paragraph to enumerate the broader scope.
2. CLAUDE.md — new top-level section "调研先于编码:派子 agent 查 API
/ 库 / 平台文档" pointing back to AGENTS.md as the canonical rules
and listing the concrete tooling Claude Code has available:
Context7 MCP (resolve-library-id + query-docs), the
documentation-lookup skill, and Agent dispatch with
subagent_type=general-purpose. Specifies trigger conditions,
non-trigger conditions (when grep is enough), the prompt fields
every subagent dispatch must include, and the four canonical
anti-patterns.
No code changes; pure docs.
Issue #372 reports repeated classifier errors such as 2粒 being intended as 2例. The existing vocabulary hotword path is probabilistic, so this adds a separate deterministic correction-rules store and applies supported literal/{num} rules in the coordinator before polish and before insertion.
The review follow-up keeps the UI refresh path consistent, handles remove failures, rejects rules that would otherwise save as enabled but never run, and fixes the Traditional Chinese wording.
Constraint: Keep correction rules separate from dictionary.json and vocab presets.
Constraint: Only support literal replacement and one {num} wildcard; do not expose arbitrary regex.
Rejected: Add import/export or a new top-level page | outside the approved scheme-C scope.
Rejected: Change history rawTranscript schema | product/data-model decision not approved in this review round.
Confidence: high
Scope-risk: moderate
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction_rule_syntax_rejects_silent_noops
Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml
Tested: cd openless-all/app && npm run build
Tested: git diff --check
Related: #372
Co-authored-by: OmX <omx@oh-my-codex.dev>
pr_agent on PR #394 flagged a "Stale cache on read error" issue:
both load_credentials / load_credentials_for_update were
unconditionally writing the result into CREDENTIALS_CACHE, including
the legacy-file fallback returned when load_keyring_credentials() hit
an Err (e.g. user hasn't yet clicked "Always Allow" on the first
keychain dialog, login keychain locked, DataProtection error).
Effect: after a transient keyring failure the process pinned the
fallback (or default) creds in cache for its entire lifetime — even
after the user granted access, subsequent reads would never retry
the keyring and would keep returning stale defaults / legacy content
until the app is restarted.
Fix: cache only on the Ok(Some) and Ok(None)→migrate paths. The Err
path now returns the legacy fallback without populating the cache, so
the next call retries the keyring and picks up the user's just-granted
access.
185/185 lib tests pass; cargo check clean.
Without an in-process cache, every CredentialsVault::get_* /
get_active_asr / snapshot call drove load_credentials() →
load_keyring_credentials(), which reads the manifest entry plus every
chunk entry from the OS keyring. On macOS each distinct keychain entry
has its own ACL — so an ad-hoc-signed binary (or any binary whose ACL
grants haven't yet been "Always Allow"-ed) prompts the user on every
read of every entry. A single dictation cycle reads credentials 5–10
times, multiplied by (1 manifest + N chunks), produces tens of
"OpenLess wants to use the keychain" prompts per recording.
Add a process-wide CREDENTIALS_CACHE: OnceLock<Mutex<Option<CredsRoot>>>:
- load_credentials / load_credentials_for_update consult the cache
before falling through to Keychain. First read populates the cache;
every subsequent read in the same process is silent.
- save_credentials populates the cache with the cleaned root after
successful Keychain write, keeping Settings → Recording credential
edits visible immediately to dictation.
- migrate_legacy_sources{,_for_update} call save_credentials internally
so legacy-migration paths inherit the cache update for free.
Trade-off: cross-process changes (e.g. user runs `security` CLI
manually, or a second instance of the app — single-instance is enforced
but defense in depth) are invisible until next launch. Acceptable per
the credential vault contract: the keyring is owned by this app.
Note: this does NOT fix the underlying problem that ad-hoc-signed
builds change cdhash on every rebuild and lose "Always Allow" grants.
For that the build must use a stable Apple Developer ID signature
(APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_TEAM_ID env
vars). The cache only ensures that within a single process lifetime,
the user is prompted at most once per Keychain entry.
185/185 lib tests pass. cargo check clean.
Two real bugs surfaced by the 2026-05-10 end-to-end logic review of
beta after the 8-PR audit-fix wave landed.
1. coordinator.rs:2313 — `acquire_recording_mute(inner, "qa");` was
missing `.await`. PR #391 (`6171df61`) made the function `async fn`
and updated the dictation call site at coordinator/dictation.rs:451,
but the QA call site was missed. Effect: when a user has
`mute_during_recording = true` and triggers QA via Right Option, the
returned Future is dropped on the next line, `spawn_blocking` is
never scheduled, holders never increments, and system audio is NOT
muted (e.g. YouTube playback continues into the QA recording). Both
the matching `release_recording_mute(inner, "qa")` calls become
no-ops (early return at holders == 0). The compiler was emitting
`unused_must_use` for this site (verified before this commit).
Fix: add `.await`.
2. coordinator/dictation.rs:843-849 — PR #387 (`ce82fcd9`) was framed
as "clear focus_target on cancel regardless of phase", but the only
code path that gained the unconditional clear was
`finish_cancel_session_state`. cancel_session deliberately skips
that helper for Processing (so end_session can drive its own
teardown), and end_session's "ASR-finished, cancelled" exit at
dictation.rs:843-849 set phase=Idle but never touched focus_target.
Result: cancel-during-Processing leaves a stale `usize` focus slot
in state.focus_target until the next begin_session_state overwrites
it. Today the leak is bounded (no documented reader between cancel
and next begin), but it violates PR #387's stated contract and is a
silent footgun for any future reader of focus_target on that
interval. Fix: in the cancelled-after-ASR exit branch, take the
state lock once and clear both phase and focus_target together.
Source: docs/logic-review-2026-05-10.md (subagent end-to-end review).
185/185 lib tests pass; cargo check clean (unused_must_use gone).
Manual verification checklist for both fixes in the review doc.