Commit Graph
721 Commits
Author SHA1 Message Date
TRIPandbaiqing ce21d99f05 feat(settings): hide local ASR behind new Advanced tab + confirm dialog (#400)
* 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>
2026-05-10 18:06:28 +08:00
Chris233 a47e27e2c5 Merge pull request #396 from H-Chris233/fix/issue-372-correction-rules
支持自定义听写纠正规则
2026-05-10 14:34:28 +08:00
H-Chris233andOmX 95237d2c49 Let users repair recurring ASR classifier mistakes
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>
2026-05-10 10:59:07 +08:00
TRIP 225859e18d Merge pull request #395 from appergb/chore/bump-1.2.24-7
chore: bump version to 1.2.24-7 (Beta-7)
2026-05-10 10:53:02 +08:00
baiqing 049147c481 chore(release): sync package-lock.json version to 1.2.24-7
CI ci.yml "Verify version sync across all 5 files" 步骤要求 5 处版本号一致:
package.json / package-lock.json (root + nested) / tauri.conf.json /
Cargo.toml / Cargo.lock 的 [openless] 包。上一个 commit (568d30d) 漏改了
package-lock.json 两处 → Linux + macOS CI fail。

修法跟 PR #383 后续 commit 941542c6 同形:手动同步两处 'version'
字段到 1.2.24-7。下次正确流程是用 scripts/bump-version.sh 一把过。
2026-05-10 10:42:20 +08:00
baiqing 568d30d079 chore: bump version to 1.2.24-7 (Beta-7)
10 PRs landed on beta this cycle:
- #377 paste shortcut configurable (issue #360)
- #386 TS UserPreferences updateChannel alignment
- #387 focus_target leak on Processing-phase cancel
- #388 [严重] MacHotkeyAdapter::shutdown stops CFRunLoop + tap
- #389 emit_capsule window.show/hide off audio thread
- #390 QA / dictation hotkey routing race
- #391 audio-mute spawn_blocking (async hygiene)
- #392 hotkey supervisor + global dispatcher exit signal
- #393 post-audit logic-review hotfixes (QA mute .await + focus_target Processing branch)
- #394 in-process credentials cache (kills repeated Keychain prompts)

Bump 4 files: package.json, tauri.conf.json, Cargo.toml, Cargo.lock.
2026-05-10 10:34:08 +08:00
TRIP 0f7961963b Merge pull request #394 from appergb/fix/keychain-cache
fix(vault): cache CredsRoot in process to stop repeated Keychain prompts
2026-05-10 10:12:27 +08:00
baiqing 8a9fb4f4f8 fix(vault): don't cache fallback creds when keyring read fails
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.
2026-05-10 10:01:00 +08:00
baiqing 2dc7ac59cf ci: re-trigger pr_agent after PR description cleanup 2026-05-10 09:41:07 +08:00
baiqing b0da8e01b9 fix(vault): cache CredsRoot in process to stop repeated Keychain prompts
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.
2026-05-10 09:23:29 +08:00
TRIP 618fc7f0b1 Merge pull request #393 from appergb/fix/post-audit-logic-review
fix(coordinator): post-audit logic-review hotfixes
2026-05-10 09:11:20 +08:00
baiqing d40b08ab44 fix(coordinator): post-audit logic-review hotfixes
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.
2026-05-10 08:42:23 +08:00
TRIP 892f3c4bfe Merge pull request #389 from appergb/fix/audit-emit-capsule-mainthread
fix(coordinator): marshal capsule show/hide to main thread [高]
2026-05-10 08:41:01 +08:00
baiqing e45860538d ci: re-trigger pr_agent after PR description cleanup (no code change) 2026-05-10 08:24:04 +08:00
TRIP 400097adbf Merge pull request #391 from appergb/fix/audit-async-hygiene
fix(coordinator): wrap audio-mute shellouts in spawn_blocking [中]
2026-05-10 08:22:53 +08:00
TRIP ff5e8f8ee1 Merge pull request #377 from appergb/fix/issue-360-paste-shortcut
fix(insertion): make Windows/Linux paste shortcut configurable (#360)
2026-05-10 08:10:15 +08:00
baiqing 1889b7912b fix(coordinator): log spawn_blocking panic in acquire_recording_mute
Addresses pr_agent "Possible Issue" review on PR #391. The original
`let _ = tokio::task::spawn_blocking(...).await` silently swallowed
JoinError, which means a panic inside AudioMuteGuard::activate() (or
the parking_lot lock) would leave the user with system audio leaking
during recording — and zero [audio-mute] log to explain why.

Match the JoinResult and log::error! the panic. State (holders/guard)
stays consistent under panic — activate panic means no guard stored
and holders not incremented; lock panic can't reach the mutate. The
fix is purely about observability.
2026-05-10 08:09:02 +08:00
baiqing 84ee3d96d5 fix(coordinator): read show_capsule pref inside main-thread closure
Addresses pr_agent "Stale State" review on PR #389. show_capsule was
read on the audio callback thread and snapshotted into the
run_on_main_thread closure. If the user toggled the preference between
the audio-thread emit and the main-thread closure firing (one or two
frames later), the queued window show/hide could apply the old
preference and briefly flash the wrong UI state.

Move that read inside the closure so the latest user pref always wins.

Why visible / translation stay snapshotted: they're the payload of
THIS specific capsule:state event — the whole point is to tell the JS
frontend "this is the state at the moment emit_capsule was called".
Re-reading them inside the closure would mean the OS-level window state
and the JS-side payload disagree (the closure lands one frame later
than the emit_to that already shipped).

No behavior change in the steady state; eliminates a 16–33 ms stale-
pref window when the user changes show_capsule mid-dictation.
2026-05-10 08:08:07 +08:00
baiqing 26e31f2d86 Merge remote-tracking branch 'origin/beta' into fix/issue-360-paste-shortcut
# Conflicts:
#	openless-all/app/src-tauri/src/coordinator.rs
2026-05-10 08:00:08 +08:00
Chris233 033e5724ca Merge pull request #390 from appergb/fix/audit-qa-dictation-routing
fix(coordinator): block QA edge routing while dictation runs [高]
2026-05-10 07:34:32 +08:00
Chris233 01c3424353 Merge pull request #392 from appergb/fix/audit-loop-exit-signals
fix(coordinator): add shutdown flag to supervisor + dispatcher loops [中]
2026-05-10 07:33:17 +08:00
Chris233 a6f62e1d6e Merge pull request #386 from appergb/fix/audit-ts-update-channel
fix(types): align TS UserPreferences with Rust update_channel
2026-05-10 07:32:37 +08:00
Chris233 13bdf524f1 Merge pull request #387 from appergb/fix/audit-focus-target-leak
fix(coordinator): clear focus_target on cancel regardless of phase
2026-05-10 07:32:18 +08:00
Chris233 9414c30a99 Merge pull request #388 from appergb/fix/audit-mac-hotkey-shutdown
fix(hotkey): MacHotkeyAdapter::shutdown stops CFRunLoop + tap [严重]
2026-05-10 07:31:58 +08:00
baiqing 4de6164152 fix(coordinator): add shutdown flag to hotkey supervisor + dispatcher loops
The five hotkey supervisor loops in coordinator.rs (dictation / qa /
combo / translation / action) and the global-hotkey dispatcher loop in
global_hotkey_runtime.rs run as `loop { ... sleep N }` with no exit
signal. In production the OS reaps these threads at process exit, but:

- Integration tests can't drive coordinator up/down without leaking
  threads across runs.
- Future RunEvent::Exit cleanup work has nowhere to plug in.
- A panicked listener thread leaves the supervisor spinning forever
  with no way to surface the failure.

Fix:
- Inner gains an `AtomicBool shutdown` field; each supervisor loop
  reads it at the top of each iteration (before any work / sleep) and
  returns when set.
- GlobalHotkeyRuntime gains a parallel AtomicBool shutdown; dispatcher
  loop checks it before each 250 ms recv_timeout.
- Public methods Coordinator::request_shutdown and
  GlobalHotkeyRuntime::request_shutdown set the respective flags.
  Marked #[allow(dead_code)] until the RunEvent::Exit hook lands —
  infrastructure-only change for now.

Crucially this is *passive*: nothing sets the flags in the production
binary, so behavior is unchanged. Tests that want bounded teardown can
now call request_shutdown explicitly.

Audit IDs 3.1.2 + 3.4.4 (CONFIRMED 中 + 低). 3.1.3 (JoinHandle storage)
is deferred — without a supervisor consumer for is_finished()
liveness checks, storing the handle is no-op.

Test: 183/183 lib tests pass. cargo check clean.
2026-05-10 01:44:53 +08:00
baiqing 6171df6105 fix(coordinator): wrap audio-mute shellouts in spawn_blocking
AudioMuteGuard::activate() shells out to osascript on macOS (~100–300 ms
AppleScript runtime startup) and to wpctl/pactl on Linux (similar cost).
Both implementations execute synchronously through std::process::Command.
acquire_recording_mute and release_recording_mute were called from the
async begin_session path, so the tokio worker thread was blocked for the
full shellout duration on every dictation start and stop.

Fix:
- Make acquire_recording_mute async; wrap the existing
  Mutex/holders/activate body in tokio::task::spawn_blocking. The
  parking_lot Mutex never crosses an await — it lives entirely inside
  the blocking task. Caller (start_recorder_for_starting) awaits.
- release_recording_mute stays a sync entry point (some call sites are
  on plain std::thread workers, e.g. the recorder error monitor) but
  delegates to spawn_blocking when a tokio Handle::try_current is
  available. Falls back to running synchronously when there's no tokio
  context — that case is already on a non-async OS thread, so blocking
  there blocks nothing.
- start_recorder_for_starting becomes async to thread the
  acquire_recording_mute().await up. Three call sites (Bailian, generic
  Volcengine, the start_recorder_and_enter_listening helper) get
  matching .await? sequels.

Net effect: dictation-start latency on macOS no longer absorbs the
~100–300 ms osascript startup before Recorder::start can run; the work
runs on a tokio blocking pool thread instead. Same on Linux. Windows
path uses native COM (IAudioEndpointVolume::SetMute), already fast —
spawn_blocking is a small extra hop but harmless.

Audit ID 3.2.4 (CONFIRMED 中). Audit IDs 3.2.3 (sync inserter.insert)
and 3.2.5 (Linux-only 120 ms thread::sleep in permission probe) deferred
to follow-up — both are smaller-impact perf concerns and don't share
code with the audio-mute path.

Test: 183/183 lib tests pass. Manual verification (start dictation,
measure time-to-first-mic-capture before/after) requires the running
app and is expected to drop ~150 ms on macOS.
2026-05-10 01:39:58 +08:00
baiqing f46adce317 fix(coordinator): don't route hotkey edges to QA while dictation runs
handle_pressed_edge previously routed the dictation hotkey to the QA
panel whenever qa_state.panel_visible was true, regardless of whether a
main dictation session was already active. If the user opened the QA
panel mid-dictation (or while polishing/inserting), the next dictation-
hotkey edge was routed into begin_qa_session, which calls
Recorder::start a second time on the same mic device. cpal usually
rejects the second build_input_stream on macOS/Windows but the
dictation session keeps running with no UX path to stop it from the QA
panel; on Linux/PipeWire the second open can succeed and you get two
concurrent capture streams competing for the audio device.

Symmetrically, handle_released_edge swallowed Released entirely when
the panel was visible — fine when QA owned the press, but if dictation
owned the press, Hold-mode dictation could never end because the
Released edge was eaten.

Fix: read inner.state.phase alongside panel_visible. If a dictation
session is non-Idle (Starting / Listening / Processing / Inserting),
both edges go to dictation regardless of panel_visible. The QA panel
stays open, just doesn't capture this hotkey.

3.3.4 (same PR, same files): open_qa_panel always emit'd CapsuleState::Idle
to sweep stale Done residue, but if dictation was mid-flight that
clobbered the in-flight capsule (Recording bar, Polishing progress, the
brief Done toast). Guard the sweep on dictation phase == Idle.

Audit IDs 3.3.1 + 3.3.4 (CONFIRMED 高 + 中).

Test: 183/183 lib tests pass. Manual verification (open QA panel during
dictation, press dictation hotkey — should stop dictation, not begin QA;
also Hold-mode dictation while panel visible should still stop on
release) requires the running app, to be done after merge.
2026-05-10 01:35:10 +08:00
baiqing faf02ad47e fix(coordinator): marshal capsule window.show/hide to main thread
emit_capsule does two things — (a) push a CapsulePayload event to the
JS frontend via app.emit_to, and (b) flip the OS-level window visibility
via window.show/hide and reposition it via maybe_position_capsule_*.
Path (a) is safe from any thread (Tauri event bus marshals internally).
Path (b) calls NSWindow / HWND APIs that expect the main thread —
but emit_capsule is invoked at ~30 Hz from the cpal process_callback
(audio thread) via the level_handler in coordinator/dictation.rs.

On macOS this risks dispatch_assert_queue_fail SIGTRAP if the AppKit
runloop assertion fires; on Windows SendMessage can deadlock against
the GUI thread; in either case the audio callback can miss its deadline
and trigger kAudioUnitErr_TooManyFramesToProcess on macOS. The 30 Hz
throttle limits frequency but does not change per-call risk.

Fix: wrap window.show / window.hide / maybe_position_capsule_bottom_center
/ show_capsule_window_no_activate / restore_main_window_key_if_active
in app.run_on_main_thread. The closure captures an Arc<Inner> clone +
AppHandle clone + Copy primitives (state, translation, show_capsule,
visible) — no payload clone needed. emit_to stays on the calling thread
since it is internally thread-safe, preserving the existing happens-
before relationship between event payload and window state on the JS
frontend (frontend processes events asynchronously regardless).

This pattern is already established in the codebase — see
coordinator.rs::stop_qa_hotkey_listener (line 326) for the same
reasoning around Carbon hotkey drop on the wrong thread (issue #169).

Audit ID 3.2.2 (CONFIRMED 高).

Test: 31 lib tests pass. Functional verification (no SIGTRAP on long
recordings, capsule still appears at start of dictation) requires
running the app — to be done after PR ships.
2026-05-10 01:32:15 +08:00
baiqing f4678cc2ab fix(hotkey): MacHotkeyAdapter::shutdown stops CFRunLoop + disables tap
CFRunLoopRun() in run_listen_loop has no return path; when
HotkeyMonitor::drop runs adapter.shutdown(), the macOS branch fell
through to the empty default impl from the trait. So every preference-
driven monitor swap (e.g. user changes hotkey trigger) leaked the
listener thread + CGEventTap. Long-running sessions that cycle bindings
would steadily accumulate background threads.

Fix:
- Add CFRunLoopStop FFI extern.
- Add MacShutdownHandles { tap, runloop } shared via Arc between the
  listener thread (writes refs in run_listen_loop after CGEventTapCreate
  and CFRunLoopGetCurrent) and MacHotkeyAdapter (reads them in
  shutdown).
- Implement MacHotkeyAdapter::shutdown — disable tap then stop runloop,
  in that order, so the OS stops dispatching before we tear down.
  take()s on the option locks make shutdown idempotent.
- run_listen_loop now does Box::from_raw(context) after CFRunLoopRun
  returns so the listener thread releases the callback context before
  exiting.
- Move tap re-enable in tap_callback (TAP_DISABLED_BY_TIMEOUT) to read
  through ctx.handles.tap (same lock the adapter releases on shutdown,
  consistent with the rest of the FFI thread-safety story).

Mirrors the Windows adapter's PostThreadMessageW(WM_QUIT) shutdown
pattern. Apple documents CGEventTapEnable + CFRunLoopStop as safe to
call from any thread, captured in a SAFETY comment on the
unsafe Send/Sync impl.

Audit ID 3.1.1 (CONFIRMED 严重).

Test: hotkey + coordinator + types + commands lib tests pass (31/31).
Functional verification of the leak fix requires running the app and
swapping bindings — to be done after PR ships.
2026-05-10 01:29:22 +08:00
baiqing ce82fcd9fa fix(coordinator): clear focus_target on cancel regardless of phase
finish_cancel_session_state previously only cleared state.focus_target
when the cancelled phase was not Processing — Processing-phase cancel
left the stale window-resource handle in place until the next
begin_session_state overwrote it. Between cancel and next begin, any
reader of focus_target sees a value belonging to a session that is
about to be torn down.

Move state.focus_target = None ahead of the phase check so it always
fires when the cancel was accepted (i.e. not rejected for Idle /
Inserting phases). Phase transition stays conditional on the original
non-Processing branch, since end_session must still own the
Processing → Idle collapse to avoid racing with polish/insert.

Tighten cancel_session_state_machine_is_table_driven so it asserts
focus_target gets cleared for every accepted phase (including
Processing) and stays untouched on rejected cancels.

Audit ID 3.3.5.
2026-05-10 01:25:11 +08:00
baiqing fd2d43083b fix(types): align TS UserPreferences with Rust update_channel field
Rust types.rs declares pub update_channel: UpdateChannel, but the TS
UserPreferences interface in src/lib/types.ts and the mockSettings
fixture in src/lib/ipc.ts both omitted the field. setSettings()
round-trips were lossless only because Rust's serde(default) refilled
the field, but consumers that destructured UserPreferences would
silently miss it.

- Move UpdateChannel type from ipc.ts to types.ts (avoids ipc <-> types
  circular import); ipc.ts re-exports for backward compat with
  SettingsModal etc.
- Add updateChannel: UpdateChannel to UserPreferences interface.
- Add updateChannel: 'stable' to mockSettings fixture.
- Update stylePrefs.test.ts fixture for new required field.

Audit ID 2.2.1 (docs/audit-2026-05-10-validated.md, kept local).
2026-05-10 01:23:28 +08:00
Chris233 b1c387265b Merge pull request #385 from H-Chris233/fix/issue-384-bailian-asr
接入百炼 DashScope 实时 ASR
2026-05-10 00:29:14 +08:00
H-Chris233 0a2b6bd14c Avoid missed Bailian task-start notifications
Bailian uses Notify::notify_waiters when the remote task starts. send_last_frame must register the Notified future before checking the started flag so a task-started event cannot land between the check and waiter registration.\n\nConstraint: DashScope stop should not falsely time out when task-started races with user stop.\nRejected: Replace Notify with polling | broader and less direct than the Tokio enable pattern.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib asr::bailian::tests\nCo-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 23:30:39 +08:00
H-Chris233 00f150a091 Prevent stale Bailian startup resources after connect failure
Bailian startup stores the ASR instance before opening the remote session so recorder startup audio can be buffered. If the websocket setup then fails in the active startup path, the provider must be cancelled before the coordinator returns to idle. The partial-result fallback remains intentional and is now documented as matching the existing Volcengine no-loss behavior.\n\nConstraint: OpenLess ASR paths prefer not losing already recognized user speech.\nRejected: Treat every websocket close with partial text as failure | would diverge from Volcengine fallback semantics.\nConfidence: high\nScope-risk: narrow\nTested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml\nTested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib\nTested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml\nCo-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 21:59:55 +08:00
H-Chris233andOmX 9551786cae Enable DashScope realtime ASR without changing recorder flow
百炼传统实时识别协议可以直接消费 OpenLess 已有的 16 kHz mono PCM,所以新增独立 ASR provider 并复用现有 DeferredAsrBridge / coordinator 会话状态机,而不是引入另一条录音链路。Settings 侧增加百炼选项、默认北京 endpoint、默认 fun-asr-realtime 模型,并允许填写可选 vocabulary_id 下发已创建的百炼热词表。

Constraint: issue #384 要求接入百炼 DashScope 实时 ASR,并保持北京/新加坡 endpoint 可配置。
Constraint: 凭据仍必须走系统凭据库,新增 asr.vocabulary_id 只作为 active ASR provider 的可选 provider 设置。
Rejected: 同时实现 qwen3-asr-flash OpenAI Realtime 协议 | 协议和结果事件不同,超出最小更改范围。
Rejected: 自动从本地词汇表创建百炼热词表 | 需要远端资源生命周期和用户确认,不能静默替用户管理供应商资源。
Confidence: high
Scope-risk: moderate
Directive: 百炼 provider 当前走传统 /api-ws/v1/inference 协议;不要把 qwen realtime 事件格式混进同一实现。
Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml
Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib
Tested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml
Tested: cd openless-all/app && npm run build
Not-tested: 需要真实 DashScope API Key 和麦克风环境才能完成线上 macOS dictation 验证。
Related: #384
Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 21:23:53 +08:00
Chris233 4475e026c4 Merge pull request #383 from H-Chris233/fix/issue-312-style-master-toggle
修复 Style 总开关关闭后的兜底风格
2026-05-09 21:01:07 +08:00
H-Chris233 6341f19dec Keep style master off state stable across default changes
Review noted that changing defaultMode after turning Style master off made the switch look on again because enabledModes still reflected the old fallback pair.\n\nWhen master is already off, default-mode changes now persist the whole style preference set through setSettings so enabledModes is recomputed to raw plus the new default. Normal enabled master behavior still uses the narrower setDefaultPolishMode IPC.\n\nConstraint: Issue #312 selected raw/default fallback semantics for master-off state.\nRejected: Add a separate persisted masterEnabled flag | broader schema and migration change for a narrow UI consistency bug.\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npx tsx src/lib/stylePrefs.test.ts\nTested: cd openless-all/app && npm run build\nNot-tested: Manual Style page click flow in packaged Tauri app.\nRelated: https://github.com/appergb/openless/pull/383\nCo-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 20:31:40 +08:00
H-Chris233 91769e0e4c Keep style master toggle from clearing fallback modes
Style 页总开关关闭时沿用现有注释语义:保留 raw 和当前 default mode,避免把 enabledModes 持久化为空集合。\n\n将 master toggle 的 off 判定抽到 stylePrefs,确保仅剩 raw/default 时 UI 仍显示为关闭,并用现有手写测试覆盖最终保存值。\n\nConstraint: Issue #312 要求总开关实现与注释一致,并覆盖关闭后的 enabledModes。\nRejected: 保持 enabledModes=[] 仅改注释 | 会让切换风格热键面对空集合,偏离 raw/default 兜底语义。\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npx tsx src/lib/stylePrefs.test.ts\nTested: cd openless-all/app && npm run build\nNot-tested: Real Tauri UI click path on macOS/Windows.\nRelated: https://github.com/appergb/openless/issues/312\nCo-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 19:27:52 +08:00
Chris233 9ccaf0571c Merge pull request #382 from weikeyi/beta
fix(windows): 修正 LeftOption 映射到左 Alt
2026-05-09 17:34:09 +08:00
weikeyi 941542c6e7 chore(release): 同步 package-lock 版本到 1.2.24-6 2026-05-09 17:22:21 +08:00
wei keyi 9cdb58d3ae Merge branch 'appergb:beta' into beta 2026-05-09 17:03:47 +08:00
baiqing 7b39fd57aa chore: bump version to 1.2.24-6 (Beta-6)
Beta-6 包含的合并:
- #379 fix(recorder): watchdog 在 sleep 醒来后重检 stop_flag,消除停采误报
- #380 fix(commands): wrap tray refresh in run_on_main_thread 修主线程死锁
- #381 feat(ui): consolidate footer/nav, sliding indicator, hover cues, top-right saved toast
- #378 拆分 coordinator 子状态机模块(间接合入)

Tag: v1.2.24-6-beta-tauri 推到 main 后触发 release-tauri.yml Beta 流水线。
2026-05-09 16:31:32 +08:00
TRIPandbaiqing fa6038f92d feat(ui): consolidate footer/nav, sliding indicator, hover cues, top-right saved toast (#381)
整体 UI 整理 + 跨页面"已保存"提示统一到右上角小 pill。

## Footer 简化
- 删 账号 icon(= openSettings('providers') 跟齿轮重复入口)
- 删 帮助 popover(version + 2 条外链跟 Settings → 关于 重复)
- 删 检查更新 按钮(Settings.about 已有同等控件)
- BETA 角标从 sidebar 移到右下角紧贴版本号
- 设置 icon 内嵌 12px 不再贴右边角

## Sidebar / Settings 三处 nav 统一
- 滑动指示器:用一个 absolute pill,currentTab 切换时 top/height 平滑过渡
  到目标 button(cubic-bezier(0.16,1,0.3,1) 360ms,无 overshoot 的 Apple
  ease-out-quint),不是各按钮瞬切背景。
- Hover 三段视觉层次:
    基础态 ink-3 中灰文字 + 透明底
    Hover  ink 全黑文字 + 浅灰底(图标通过 currentColor 同步加深)
    选中  ink 全黑文字 + 白色滑动 pill 底
  用 .ol-nav-btn class + :hover 实现,inline color 让位给 CSS(CSS 不能盖
  inline style 是关键)。

## "已保存" toast 改右上角小 pill
- 新增 components/SavedToast.tsx + lib/savedEvent.ts(emitSaved 事件总线)
- Translation / SelectionAsk 内联 banner 改为锚到 ol-thinscroll 控制台卡
  右上角的 absolute pill;scroll wrapper 加 position:relative
- Settings → ASR 配置:CredentialField API key 内联"已保存"挪到 SettingsModal
  右上角统一 pill(offsetStyle: right:54 避开关闭按钮);只保留持续性的
  readError 在原位标识字段不可用

## Translation / SelectionAsk 简化
- 删跟 Settings.shortcuts 重复的快捷键卡(用户改键统一在 Settings 完成)
- SelectionAsk "历史保存"从全宽 Card 改成左对齐的小模块(标签 + toggle 紧贴)
- PageHeader 砍 kicker+desc → 单行 title
- 使用方法卡去 inset 提示块(保留 5 步 ol)

## Overview 收头
- PageHeader 单行 title(去 kicker / desc / 右侧"按 X 开始"pill)

Co-authored-by: baiqing <lbx12309@icloud.com>
2026-05-09 16:29:52 +08:00
TRIPandbaiqing 14bfa5f749 fix(commands): wrap tray refresh in run_on_main_thread to avoid macOS UI freeze (#380)
set_settings 和 set_default_polish_mode 是同步 Tauri commands,跑在 IPC handler
线程。它们直接调 refresh_tray_microphone_menu,里面 tray.set_menu(...) 改 macOS
NSStatusItem 必须在主线程上做。从 IPC 线程直调会触发 macOS dispatch queue
死锁,导致用户改任何偏好开关后整个 UI 永久卡死、所有按键无响应。

修复:把两处 tray 刷新都包到 app.run_on_main_thread(move || ...) dispatch 到主线程,
IPC 线程立即返回不阻塞。这跟 lib.rs:558 里 start_tray_microphone_watcher 已经
在用的 pattern 完全一致(issue #169 stop_qa_hotkey_listener 同样用法)。

防御性扫描结论:整个项目里 tray.set_menu() 只在 refresh_tray_microphone_menu
(lib.rs:518)一处出现,所有 5 个调用点已确认安全:
  - lib.rs:207 tray hover event:tray 事件本身就在主线程
  - lib.rs:559 麦克风设备 watcher:已有 run_on_main_thread
  - lib.rs:605 tray 菜单事件:本身在主线程
  - commands.rs:160 set_settings:本提交修复
  - commands.rs:944 set_default_polish_mode:本提交修复

其它 sync IPC commands(set_dictation/qa/translation/switch_style/open_app_hotkey)
通过 coord.refresh_*_hotkey() 间接刷新,那些函数内部已包了 run_on_main_thread;
不碰 AppKit 的 commands 安全。

Co-authored-by: baiqing <lbx12309@icloud.com>
2026-05-09 16:29:45 +08:00
Chris233 feadbfa70b Merge pull request #378 from H-Chris233/fix/issue-296-coordinator-modules
拆分 coordinator 子状态机模块
2026-05-09 16:16:21 +08:00
weikeyi af2048e5ca fix(windows): map LeftOption to left Alt 2026-05-09 16:15:47 +08:00
Chris233 2198f0cbf8 Merge pull request #379 from appergb/fix/recorder-watchdog-stop-race
fix(recorder): watchdog 在 sleep 醒来后重检 stop_flag,消除停采误报
2026-05-09 16:09:31 +08:00
baiqing 1bb38ef0f7 fix(recorder): watchdog 在 sleep 醒来后重检 stop_flag,消除停采误报
当用户松开 hotkey 进入 Listening → Processing 转移时,coordinator 调
rec.stop() 设置 stop_flag 并 pause cpal Stream。但 watchdog 线程此时
可能正卡在 1 秒的 sleep 里:sleep 结束后旧逻辑直接读 last_callback_time,
看到"已 >3 秒未更新"就触发 EngineFailed("录音回调静默停止 N 秒"),
让 coordinator 把正常停采当成引擎崩溃,胶囊弹出 "audio engine failed"。

复现日志(~/Library/Logs/OpenLess/openless.log):

  27.011  [recorder] cb#4250 ...                <- 最后一次回调
  27.054  [asr] server JSON ...                 <- ASR 仍在收尾
  ~27.5   end_session 调 rec.stop() 设置 stop_flag + pause cpal
  31.667  [ERROR] [recorder] watchdog: 录音回调已停止 4 秒,触发错误恢复
  31.667  [ERROR] [coord] recorder runtime error: audio engine failed

修复:watchdog sleep 醒来后立即再 load 一次 stop_flag,flag 已置位则
直接 break 退出,不再做 elapsed 判定。这样:

- 主动停采路径(hotkey-release / cancel):watchdog 静默退出,无误报
- 真故障路径(CoreAudio 设备掉线、OS 音频复位):stop_flag 仍为 false,
  watchdog 照常按 3 秒静默阈值触发 EngineFailed,行为不变

附 18 行注释解释 race,避免后续重构时被无意识回退。
2026-05-09 14:06:20 +08:00
baiqing 644dc63454 fix(insertion): use slice index instead of TakeWhile::rev for partial release
`TakeWhile<Iter, _>` 不实现 `DoubleEndedIterator`,所以 `.take_while(..).rev()`
在 Linux target 上编译不过(macOS host 因为整段 cfg(not(macos)) 没编译,所以
之前 cargo check 没暴露)。改用 `modifiers[..pressed].iter().rev()`:切片的
Iter 实现 DoubleEndedIterator,可以安全 `.rev()`。

顺手简化错误恢复路径:用一个 `first_err` 累加器,press → click → release
三段任何一段先报错都先记下来,最后一次性返回。语义跟原版 simulate_paste
对齐,不留卡键。

cargo test --lib 仍然 181 passed。
2026-05-09 13:24:14 +08:00
H-Chris233andOmX 14813322dd Reduce coordinator refactor risk with focused modules
coordinator.rs had accumulated dictation lifecycle, QA panel state, and shared recording resources in one file. This split keeps behavior unchanged while moving the dictation edge/session helpers, QA panel state helpers, and resource ownership helpers into child modules under coordinator/.

Constraint: Issue #296 asks for module boundaries after Rust state-machine coverage is in place

Rejected: Split every helper into separate public modules | larger churn with no behavior benefit

Confidence: high

Scope-risk: moderate

Directive: Keep cross-module wiring in coordinator; leaf modules should remain coordinator-private

Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml

Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib

Tested: cargo test --manifest-path openless-all/app/src-tauri/backend-tests/Cargo.toml

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-09 13:19:22 +08:00