From b5d5339afda176b7ead54f8692c163ebe1ccc83d Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Mon, 29 Jun 2026 23:31:26 +0200 Subject: [PATCH] fix(audio-input): open-source returns the actually-bound device (read-back) openInputSource() surfaces the provider's bound device on the command return (payload.bound = { type, name }) for the trusted in-process caller, so a silent wrong-device substitution can be detected. Kept out of the redacted source-opened event + diagnostics snapshot (raw device names are PII). Purely additive. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + static/capabilities/audio-session.js | 12 +++++++++++- tests/js/audio_session_input.test.js | 29 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f40a4c21..c9e48e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers). ### Fixed +- **`audio-input` `open-source` now reports the device the engine *actually* bound, so the wrong-mic case can be caught instead of trusting the pick blind.** A provider's `source.open` handler may return `payload: { boundType, boundName }`; `openInputSource()` (`static/capabilities/audio-session.js`) surfaces it on the command's **return** value as `payload.bound = { type, name }` for the trusted in-process caller, enabling an honest "Now listening to: " readout and detection of a silent substitution (picked BlackHole, got the internal mic). The raw device name is PII, so it is deliberately kept **out** of the emitted `source-opened` event and the diagnostics snapshot (which stay redacted) — mirroring how `list-sources` already returns the device `label` verbatim to the UI but pseudonymizes it in diagnostics. Purely additive: the open-session summary shape is unchanged and `bound` is omitted when the provider reports nothing. Pairs with feedBack-desktop's stable name-based input identity + fail-loud open. Tests: `tests/js/audio_session_input.test.js` (read-back surfaced to the caller, absent from event + snapshot). - **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`. - **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`. - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. diff --git a/static/capabilities/audio-session.js b/static/capabilities/audio-session.js index 45fa92e0..e9e1762d 100644 --- a/static/capabilities/audio-session.js +++ b/static/capabilities/audio-session.js @@ -1766,6 +1766,16 @@ const providerResult = _providerOutcome(raw); openSession.state = providerResult.outcome === 'handled' ? 'open' : (providerResult.status || providerResult.outcome); openSession.reason = providerResult.reason; + // Read-back: the provider (desktop renderer) reports which device the + // native engine ACTUALLY bound. Surface it to the in-process caller so + // the wizard can show "Now listening to: " and catch a silent + // mismatch (picked BlackHole, got the internal mic). Kept OUT of the + // redacted summary/event below: that flows into diagnostics, where a raw + // device name (e.g. "Byron's AirPods") is PII — but it is fine to return + // verbatim to the trusted same-renderer caller, exactly as list-sources + // already returns the device `label` verbatim. + const boundInfo = _plainObject(providerResult.payload); + const bound = { type: _string(boundInfo.boundType, ''), name: _string(boundInfo.boundName, '') }; if (providerResult.outcome === 'handled') { openSession.openedAt = _now(); currentSession.openInputSessions.set(key, openSession); @@ -1773,7 +1783,7 @@ _recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: 'handled', status: 'open' }); capabilities.emitEvent('audio-input', 'source-opened', summary); _touch(); - return _handled(summary); + return _handled((bound.name || bound.type) ? { ...summary, bound } : summary); } const summary = _redactedOpenSession(openSession, _newPseudonymizer()); _recordOutcome({ domain: 'audio-input', operation: 'open-source', participantId: requesterId, requesterId, providerId: provider.providerId, sourceId: selected.sourceId, logicalSourceKey: selected.logicalSourceKey, openSessionId: openSession.openSessionId, outcome: providerResult.outcome, status: openSession.state, reason: providerResult.reason }); diff --git a/tests/js/audio_session_input.test.js b/tests/js/audio_session_input.test.js index 5aa8ac46..1503ac62 100644 --- a/tests/js/audio_session_input.test.js +++ b/tests/js/audio_session_input.test.js @@ -281,6 +281,35 @@ test('open-source and close-source record outcomes events and no live handles', assert.equal(encoded.includes('token=abc'), false); }); +test('open-source returns the actually-bound device for read-back without leaking it into diagnostics', async () => { + const window = loadAudioSession(); + const api = window.feedBack.capabilities; + const openedEvents = captureEvents(window, 'audio-input:source-opened'); + + await registerSource(api, { + sourceId: 'readback-source', + logicalSourceKey: 'test:readback', + operationHandlers: { + 'source.open': () => ({ outcome: 'handled', status: 'open', payload: { boundType: 'CoreAudio', boundName: 'BlackHole 16ch', requestedName: 'BlackHole 16ch' } }), + 'source.close': () => ({ outcome: 'handled', status: 'closed' }), + }, + }); + await api.dispatch({ capability: 'audio-input', command: 'select-source', source: 'user', payload: { logicalSourceKey: 'test:readback' } }); + const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', purpose: 'note-detection' } }); + + // The trusted in-process caller (input_setup's confirmation gate) gets the real bound device, + // so it can show "Now listening to: " and catch a silent wrong-mic substitution. + assert.equal(open.outcome, 'handled'); + assert.equal(open.payload.bound.type, 'CoreAudio'); + assert.equal(open.payload.bound.name, 'BlackHole 16ch'); + + // ...but a raw device name is PII: it must NOT reach the emitted event or the diagnostics snapshot. + assert.equal(openedEvents.length, 1); + assert.equal('bound' in openedEvents[0], false); + const encoded = JSON.stringify(window.feedBack.audioSession.snapshot()); + assert.equal(encoded.includes('BlackHole'), false); +}); + test('open-source reports no-owner no-handler unsupported failed and malformed provider data distinctly', async () => { const window = loadAudioSession(); const api = window.feedBack.capabilities;