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`.
This commit is contained in:
@@ -391,6 +391,38 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConfigurePtt(BuildContext context) async {
|
||||
// Open a focus-scoped dialog that captures the next key press
|
||||
// and submits it to the platform backend as the new PTT
|
||||
// binding. The bridge carries only the coarse input class and
|
||||
// an opaque platform-key string; the actual key value never
|
||||
// appears in any log record (DEC-027 / SRS-202).
|
||||
final l10n = AppL10n.of(context);
|
||||
if (!mounted) return;
|
||||
final binding = await showDialog<_CapturedBinding>(
|
||||
context: context,
|
||||
builder: (ctx) => const _PttBindingCaptureDialog(),
|
||||
);
|
||||
if (binding == null) return;
|
||||
try {
|
||||
await rust.setPttBinding(
|
||||
inputClass: binding.inputClass,
|
||||
platformKey: binding.platformKey,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// Surface the failure as a snackbar so the user sees that
|
||||
// their binding did not stick.
|
||||
if (!mounted) return;
|
||||
// Use the State's context (guaranteed valid because we
|
||||
// re-checked `mounted` immediately above).
|
||||
final messenger = ScaffoldMessenger.of(this.context);
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(l10n.statusError(e.toString())),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onShowAbout(BuildContext context) async {
|
||||
// DEC-018 / DEC-019 / DEC-020 surface: public name, non-
|
||||
// affiliation statement, dual-license declaration. The Flutter
|
||||
@@ -670,6 +702,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
onToggleInputMute: _toggleInputMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onGainChanged: _setOutputGain,
|
||||
onConfigurePtt: () => _onConfigurePtt(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@@ -827,6 +860,7 @@ class _AudioControls extends StatefulWidget {
|
||||
required this.onToggleInputMute,
|
||||
required this.onToggleOutputMute,
|
||||
required this.onGainChanged,
|
||||
required this.onConfigurePtt,
|
||||
});
|
||||
|
||||
final rust.BridgeAudioStats? stats;
|
||||
@@ -841,6 +875,7 @@ class _AudioControls extends StatefulWidget {
|
||||
final VoidCallback onToggleInputMute;
|
||||
final VoidCallback onToggleOutputMute;
|
||||
final ValueChanged<double> onGainChanged;
|
||||
final VoidCallback onConfigurePtt;
|
||||
|
||||
@override
|
||||
State<_AudioControls> createState() => _AudioControlsState();
|
||||
@@ -896,6 +931,11 @@ class _AudioControlsState extends State<_AudioControls> {
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 14),
|
||||
label: Text(l10n.pttConfigureAction),
|
||||
onPressed: widget.onConfigurePtt,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1072,3 +1112,151 @@ class _SnapshotView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a successful PTT binding capture. Carries only the
|
||||
/// coarse input class and an opaque platform-key label —
|
||||
/// per DEC-027 the actual key code never crosses out of the
|
||||
/// capture dialog.
|
||||
class _CapturedBinding {
|
||||
const _CapturedBinding({required this.inputClass, required this.platformKey});
|
||||
final rust.BridgePttInputClass inputClass;
|
||||
final String platformKey;
|
||||
}
|
||||
|
||||
/// Focus-scoped dialog that captures the next key press or mouse
|
||||
/// side-button click and returns it as a `_CapturedBinding`. The
|
||||
/// captured value is the platform-neutral `LogicalKeyboardKey`
|
||||
/// debug label or `"mouse-side-button:{button}"`; the platform
|
||||
/// backend interprets the string and never logs it.
|
||||
class _PttBindingCaptureDialog extends StatefulWidget {
|
||||
const _PttBindingCaptureDialog();
|
||||
|
||||
@override
|
||||
State<_PttBindingCaptureDialog> createState() =>
|
||||
_PttBindingCaptureDialogState();
|
||||
}
|
||||
|
||||
class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
String? _captured;
|
||||
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
// Skip modifier-only presses so the user can chord into the
|
||||
// real binding.
|
||||
final keyLabel = event.logicalKey.keyLabel;
|
||||
if (keyLabel.isEmpty) return KeyEventResult.ignored;
|
||||
setState(() {
|
||||
_captured = keyLabel;
|
||||
_capturedClass = rust.BridgePttInputClass.keyboard;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _captureMouseSideButton(int button) {
|
||||
setState(() {
|
||||
_captured = 'mouse-side-button:$button';
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.pttConfigureTitle),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
// Capture mouse side buttons (4 and 5) without
|
||||
// capturing primary / secondary clicks which the
|
||||
// user uses to interact with the dialog itself. The
|
||||
// raw button bitmask values are stable per Flutter's
|
||||
// `PointerEvent.buttons` documentation (back = 0x08,
|
||||
// forward = 0x10).
|
||||
onPointerDown: (e) {
|
||||
const int back = 0x08;
|
||||
const int forward = 0x10;
|
||||
if (e.buttons == back || e.buttons == forward) {
|
||||
_captureMouseSideButton(e.buttons);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttConfigurePrompt,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_captured == null
|
||||
? l10n.pttConfigureWaiting
|
||||
: '${l10n.pttConfigureCaptured}: $_captured',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttConfigurePrivacyNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _captured == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
_CapturedBinding(
|
||||
inputClass: _capturedClass,
|
||||
platformKey: _captured!,
|
||||
),
|
||||
),
|
||||
child: Text(l10n.pttConfigureSaveAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user