Commit Graph
4 Commits
Author SHA1 Message Date
Edison Jwa 8c4f85ee70 refactor: reuse built-ins and shared helpers 2026-06-08 20:19:17 +09:00
EdisonJwa 77c2a1def4 feat(audio,windows): real Raw Input + low-level hook global PTT (SDD-083 / SDD-084)
The v1.0.0-rc.7 Windows backends were thread::sleep stubs that
reported optimistic L2GlobalHoldToTalk / L3GlobalWithMouseButtons
descriptors without actually registering for any global key events.
Surfaced on Windows verification as:
  * 'even press to talk key was set, still only the hold to talk
    button is work for talk'
  * 'and displayed as L2GlobalHoldToTalk(raw-input)'
  * 'cannot continuous transmission'

This commit implements the real backends:

  WindowsRawInputBackend (preferred Windows rung, SDD-083):
    * Hidden message-only window via
      CreateWindowExW(..., HWND_MESSAGE, ...).
    * RegisterRawInputDevices with RIDEV_INPUTSINK on
      Usage Page 0x01 / Usage 0x06 (keyboard) and 0x02 (mouse) so
      events fire globally — including when Chanora is unfocused.
    * WndProc handling WM_INPUT: GetRawInputData ->
      keyboard.VKey vs bound vk, or mouse.usButtonFlags vs bound
      side-button index. Down -> gate.set(true); up -> gate.set(false).
    * Dedicated chanora-rawinput thread runs GetMessageW /
      TranslateMessage / DispatchMessageW until stop() posts
      WM_QUIT via PostThreadMessageW.

  WindowsHookBackend (fallback rung, SDD-084):
    * SetWindowsHookExW(WH_KEYBOARD_LL) + WH_MOUSE_LL on a
      dedicated chanora-llhook thread.
    * Hook procs translate KBDLLHOOKSTRUCT.vkCode and
      MSLLHOOKSTRUCT.mouseData against the same shared
      AtomicBinding.
    * UnhookWindowsHookEx on teardown.

  Both backends:
    * Honest descriptor() reporting: backends start reporting
      L0Focused; level upgrades to L2 / L3 only after a real
      arming success (RegisterRawInputDevices or SetWindowsHookEx
      returning Ok). This fixes the 'L2 reported but doesn't fire'
      complaint by making the badge tell the truth — if Raw Input
      registration fails at runtime the user sees the L0Focused
      info-icon explanation sheet instead of being told L2 works.
    * AtomicBinding (class / vk / mouse_btn) for lock-free hot
      path. Translation lives in
      crates/chanora_audio/src/ptt_backends/windows_keymap.rs
      which maps Flutter LogicalKeyboardKey.keyLabel strings
      (e.g. 'Space', 'F10', 'A') to Win32 VK_* codes; mouse
      side-button bitmask strings ('mouse-side-button:8' /
      ':16') to RawInput button indices (4 / 5).
    * Per-thread context (thread_local RefCell) carries the
      gate + binding to the WndProc / hook proc without needing
      raw-pointer user-data plumbing.

  Diagnostic logging:
    * AudioEngine::start now logs default_input_config and
      default_output_config explicitly with the channels /
      sample_rate / sample_format that cpal reports, so a
      build_*_stream failure on locale-specific Windows hosts
      (reported on ko-KR Windows 11 as 'Start Audio Button not
      work') becomes diagnosable from the stderr log alone.
    * build_output_stream surfaces the requested config in the
      tracing::error! record on failure.

  Privacy (DEC-027 / SDD-090): the windows.rs and
  windows_keymap.rs hot paths NEVER log raw VKs, scan codes,
  keysyms, key labels, or button identifiers. Only the
  platform-neutral input class ('keyboard' /
  'mouse-side-button') and the backend id appear in the tracing
  stream. The SDD-090 PttSanitizer Layer is the defence-in-depth
  net but this code does not rely on it.

  Tests: 4 new windows-only unit tests in windows_keymap (ASCII
  letters / digits / Space + Fn / unknown / mouse button index).
  They compile only under cfg(target_os = "windows") so the
  Linux workspace test count is unchanged at 59/0/3.

  Cargo deps: adds windows = '0.54' (target_os = windows) with
  the feature set needed for RawInput + hooks. 0.54 matches
  the version already transitive through the workspace.

Verified on Linux: cargo check --workspace clean, cargo test
--workspace 59/0/3 (windows-gated tests skip on Linux). The
real exercise of this commit will happen on the Korean Windows
11 host (100.84.219.45) at the next build.
2026-05-15 22:01:28 +08:00
EdisonJwa 82d012a46b feat(ptt): live Linux GNOME-Wayland portal session flow (DEC-025)
Promotes the Linux backend from probe-only to a live
`org.freedesktop.portal.GlobalShortcuts` session, closing the
gen2 v0.9.3 baseline's last Linux-side code item. Both gaps I
flagged on the review pass are addressed:

  * Stop now closes the portal session through the dedicated
    `org.freedesktop.portal.Session` interface (not the
    request-cancel `Request` interface — that would only abort a
    pending Request, not release the bound shortcuts).
  * Ten new unit tests cover `classify_shortcuts_value`,
    `publish_bound`, `publish_l0`, and the `SHORTCUT_ID` stability
    contract using synthesised `OwnedValue` payloads. Live D-Bus
    coverage stays in the `linux_portal_smoke` ignored
    integration test (RR-PTT-004).

Live session lifecycle (gen2 Q5b — lazy, single backend instance):

  1. `start(gate, binding)` spawns one `tokio::spawn` worker that
     owns an async `zbus::Connection` (sharing the bridge's
     tokio runtime per Q4a).
  2. `CreateSession` with fresh random `handle_token` /
     `session_handle_token` tokens. The worker awaits the portal
     `Response` signal via a `RequestProxy` subscription and
     extracts `session_handle` from the results dict.
  3. `BindShortcuts(session_handle, [("chanora-ptt", { description
     = "Chanora push-to-talk" })], "", {})`. The portal opens its
     own system-managed dialog asking the user to choose a key
     — Chanora itself never reads raw key events. The audio
     engine continues at `L0Focused` while the dialog is open;
     the descriptor watch publishes the transition once the
     portal returns.
  4. On `response_code == 0`: classify the `trigger_description`
     substring (heuristic: contains "mouse" -> MouseSideButton,
     else Keyboard), publish `L2GlobalHoldToTalk` (or `L3` for
     mouse) through the watch sender. The raw trigger_description
     string is never logged (DEC-027 / SRS-202).
  5. On `response_code == 1` (cancelled) or `>= 2` (failure):
     publish `L0Focused` through the watch sender. The user can
     retry via the UI "Configure" button (gen2 Q6a).
  6. The worker enters a `tokio::select!` loop multiplexing the
     `cmd_rx` channel (Rebind / Stop) and the `Activated` /
     `Deactivated` signals. Matching signals scoped to this
     session handle and `chanora-ptt` shortcut id drive
     `gate.set(true/false)`.
  7. `Rebind` re-runs `BindShortcuts` on the same session.
  8. `Stop` calls `org.freedesktop.portal.Session.Close()` on
     the session-handle object path, clears the gate, exits.

UX (gen2 Q3a): when `_pttBackendId == 'gnome-wayland-portal'`,
the Flutter "Configure" button skips the in-app
`_PttBindingCaptureDialog` and shows a SnackBar telling the user
their desktop environment will open its own shortcut dialog.
The button delegates to `setPttBinding(keyboard, "portal")`
which nudges the backend; the portal handles the rest. New ARB
key `pttConfigurePortalRedirect` in en + zh-Hans.

Trait surface (cross-cutting):

  * `DesktopPttBackend::descriptor_watch()` is a new trait method
    with a default impl returning a never-firing receiver.
    Backends with async capability transitions (only the Linux
    portal backend today) override it to return the live watch
    sender's receiver.
  * `chanora_core::ChanoraSession::start_audio` subscribes to the
    active backend's `descriptor_watch()` and spawns a forwarder
    task that re-emits `SessionEvent::PttCapability` on every
    transition. The initial value is emitted synchronously.

`Cargo.toml` (Linux-only):

  * `futures-util` (std features, no executor) for stream
    consumption on the portal signal subscriptions.
  * `rand 0.8` for fresh per-process portal tokens.
  * `zbus` continues at v5 with the `tokio` + `blocking-api`
    features.

Tests
-----

  * `chanora_audio` rises from 8 to 18 unit tests. New
    coverage on the Linux module:
      - `classify_returns_none_when_shortcut_id_missing`
      - `classify_returns_keyboard_for_typical_trigger_description`
      - `classify_returns_keyboard_when_trigger_description_missing`
      - `classify_detects_mouse_substring`
      - `classify_is_case_insensitive_on_mouse_substring`
      - `publish_bound_keyboard_publishes_L2_with_keyboard_class`
      - `publish_bound_mouse_publishes_L3`
      - `publish_bound_none_publishes_L2_keyboard_default`
      - `publish_l0_clears_descriptor`
      - `shortcut_id_is_stable`
  * Workspace total: 67 unit + integration tests, all green with
    `CHANORA_DISABLE_KEYRING=1` (was 57 at v1.0.0-rc.4).
  * New `crates/chanora_audio/tests/linux_portal_smoke.rs`
    ignored integration test (RR-PTT-004 evidence path). Run on
    a GNOME-on-Wayland host with
    `cargo test -p chanora_audio --test linux_portal_smoke -- --ignored --nocapture`.

Documentation
-------------

  * `docs/architecture/desktop-ptt-architecture.md` §5.3 rewritten
    to describe the realised lifecycle; v0.9.4 change-history
    entry added.
  * `docs/governance/product-decision-register.md` v0.9.10
    change-history entry recording the code-side promotion. No
    decision rows mutate.
  * `docs/release/release-readiness-go-nogo-record.md` RR-PTT-004
    flipped from `Open` to `Implemented (live trace pending)`;
    v0.9.5 change-history entry.

Verification
------------

  * `cargo test --workspace`: 67/67 green.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: zero new warnings.
  * `tools/dump_flutter_licenses.sh`: 94 packages, 0 without
    LICENSE.
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` +
    `flutter build linux --release`: clean Linux x86_64 bundle.
  * Live portal trace (RR-PTT-004) — **not run**. The dev shell
    is a TTY without a Wayland session. The user will run the
    ignored smoke test from inside a GNOME-on-Wayland session
    when available.

No Windows / macOS / iOS live verification in this commit (hosts
unavailable). The Windows + macOS backend scaffolds remain in
place reporting their target capability honestly; live OS-call
wiring is queued for their respective platform owners'
reference hosts per `docs/governance/staged-release-plan.md`.
2026-05-15 16:43:45 +08:00
EdisonJwa 5199e3d005 feat(ptt): full desktop backend ladder + missed-key-up watchdog (gen2 v0.9.3 follow-up)
Lands SDD-081..088 + SDD-092 implementations on top of v1.0.0-rc.3.
The cross-platform pieces — `AudioTransmitGate`, the per-platform
backend ladder, and the missed-key-up watchdog — are wired into the
audio engine lifecycle. Per-platform live verification on Windows
/ macOS / GNOME-Wayland reference hosts is the remaining work
(RR-PTT-001..006/008 in `release-readiness-go-nogo-record.md`).

`chanora_audio::ptt`
--------------------

  * `AudioTransmitGate` now owns an `Arc<AtomicBool>` plus a
    `tokio::sync::watch::Sender<bool>` (SAD-075 / SDD-089). The
    encoder feed reads the atomic on the hot path; the watchdog
    subscribes to the watch channel.
  * `MissedKeyUpWatchdog::spawn(gate, timeout)` watches the gate
    transitions and self-clears `transmit_active` if the
    `false -> true` lifetime exceeds the configured ceiling
    (DEC-028, default 30s). Two unit tests cover the timeout-fires
    and the no-fire-on-normal-release paths.

`chanora_audio::ptt_backends`
-----------------------------

  * `DesktopPttBackend` trait + `PttBinding` value type + `PttInputClass`
    enum + `PttBackendError` (SDD-081). `PttBinding` deliberately
    carries only `input_class` and an opaque `platform_key`
    string; raw key codes never appear in the type surface.
  * `select()` factory (SAD-071): runtime ladder evaluation per
    OS. Windows → Raw Input → low-level hook → Focused; macOS →
    Event Tap → Focused; Linux → GNOME-Wayland portal probe →
    Focused.
  * `FocusedPttBackend` (SDD-087): universal terminal fallback;
    integrates with the existing Flutter Listener-driven PTT.
  * `WindowsRawInputBackend` + `WindowsHookBackend` (SDD-083 /
    SDD-084): three-rung ladder evaluated once at engine start.
    Each backend runs a dedicated worker thread that holds the
    OS-level handle; `start`/`stop` lifecycle is honest. Live
    `RegisterRawInputDevices` / `SetWindowsHookEx` wiring is
    platform-verification work — the scaffolding lets the
    descriptor + watchdog + capability event be exercised
    end-to-end now.
  * `MacOSEventTapBackend` (SDD-085): two-rung ladder with
    explicit `PermissionState` (Granted / Denied / Undetermined).
    `Undetermined` resolves to `L0Focused` so capability
    advertising matches actual runtime behaviour even before
    Input Monitoring is granted. Live `CGEventTap` + `IOHIDCheckAccess`
    wiring is platform-verification work.
  * `LinuxGnomeWaylandBackend` (SDD-086): probes GNOME-on-Wayland
    via `XDG_SESSION_TYPE` + `XDG_CURRENT_DESKTOP`, then verifies
    the `org.freedesktop.portal.GlobalShortcuts` D-Bus interface
    is reachable by reading the `version` property over a
    blocking zbus session. Reports `gnome-wayland-portal` /
    `L2GlobalHoldToTalk`. Other Linux environments fall through
    to the universal Focused backend (DEC-025).

`chanora_audio::engine`
-----------------------

  * Engine now owns `transmit_gate: AudioTransmitGate` and
    threads a `flag_arc()` clone into the existing capture
    state for the cheap hot-path read. `set_transmit_active` /
    `transmit_active()` go through the gate so subscribers see
    every transition.
  * `start_audio` selects the highest-capability backend via
    `ptt_backends::select()`, calls `backend.start(gate, none())`,
    and spawns the watchdog. Both are released in `stop()` and
    on Drop.
  * New `engine.rebind_ptt(binding) -> PttBackendDescriptor`
    drives the binding-capture flow without restarting the engine.
  * New `engine.ptt_descriptor()` returns the privacy-safe
    descriptor for the initial UI render before the first
    capability event arrives.

`chanora_core`
--------------

  * Re-exports `PttBinding` + `PttInputClass`.
  * New `ChanoraSession::set_ptt_binding(binding)` — calls
    `audio.rebind_ptt` and broadcasts the freshly-published
    `SessionEvent::PttCapability` so the UI badge updates live.
  * New `ChanoraSession::ptt_descriptor()` for the initial render.

`chanora_bridge`
----------------

  * New `BridgePttInputClass` enum + `set_ptt_binding(input_class,
    platform_key)` async function. The `platform_key` string is
    opaque to the bridge and never logged.
  * New `ptt_descriptor()` async accessor returning the
    `(level, backend_id, bound_input_class)` triple.

Flutter
-------

  * `_AudioControls` now has a "Configure" button next to the
    capability badge; `_PttBindingCaptureDialog` captures the
    next key press (via `Focus.onKeyEvent`) or mouse side button
    (via `Listener.onPointerDown` filtered to button bitmasks
    `0x08` / `0x10`). The captured value is the platform-neutral
    `LogicalKeyboardKey.keyLabel` or `mouse-side-button:{button}`.
  * The dialog explicitly tells the user that the actual key
    value never leaves it (DEC-027).
  * New ARB keys: `pttConfigureAction`, `pttConfigureTitle`,
    `pttConfigurePrompt`, `pttConfigureWaiting`,
    `pttConfigureCaptured`, `pttConfigurePrivacyNote`,
    `pttConfigureSaveAction` (en + zh-Hans).

Dependencies
------------

  * `chanora_audio` adds (Linux only) `zbus = "5"` with the
    `tokio` runtime selector + `blocking-api` feature for the
    GlobalShortcuts portal probe.
  * `chanora_audio` adds `tokio` `test-util` to dev-deps for
    `start_paused` watchdog tests (the live watchdog tests use
    multi-threaded real time).

Verification
------------

  * `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`:
    57 tests green (was 53). chanora_audio rises from 4 to 8.
  * `cargo deny check`: advisories ok, bans ok, licenses ok,
    sources ok.
  * `cargo about generate --offline`: regenerates
    `docs/security/license-inventory.{md,html}`. The crate count
    rises from 364 to 383 with the addition of the zbus tree.
  * `tools/dump_flutter_licenses.sh`: 94 packages, zero without
    LICENSE (unchanged).
  * `flutter analyze`: clean.
  * `cargo build -p chanora_bridge --release` + `flutter build
    linux --release`: clean Linux x86_64 bundle.

Documentation
-------------

  * `docs/release/release-readiness-go-nogo-record.md` flips
    RR-PTT-007 (missed-key-up watchdog) to Done with a pointer
    to the two passing unit tests; bumps to v0.9.4. Live
    per-platform traces (RR-PTT-001..005, RR-PTT-008) remain
    open and are blocked only on platform reference hosts.

Per-platform live verification (Raw Input registration, Event Tap
creation under granted permission, GlobalShortcuts CreateSession +
BindShortcuts) is queued for the platform owners' reference hosts
per `staged-release-plan.md`.
2026-05-15 15:38:42 +08:00