From 03f5d6bca3493d851e165bd21a0404daa45640f6 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Fri, 15 May 2026 16:50:48 +0800 Subject: [PATCH] fix(p0): close three P0 coverage gaps after rc.5 audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every `Priority: P0` row in `docs/requirements/{sysrs,srs}.md` against the live code. Three items needed work; this commit closes all three. Gap A — SysRS-262 + SysRS-282 (screen-reader semantics + accessible labels for the PTT control) ------------------------------------------------------------------- The Flutter PTT control is a custom `Listener` over a `Container` — not a built-in `Button`, so the platform accessibility tree had no idea it was an interactive control. Screen readers (VoiceOver, TalkBack, NVDA, Orca) would have read the visible text without announcing the control role or its toggled state. Wrap the Listener in a `Semantics(button: true, toggled: _pressed, label: …, hint: …, excludeSemantics: true)` so the platform accessibility tree carries the right role, the current state ("Hold to talk" / "Transmitting"), and a usage hint. The `excludeSemantics: true` argument suppresses the duplicate child nodes the Container + Row + Icon + Text would otherwise generate on top of our explicit label. SysRS-263 (no colour-only state) is preserved: the visible label and the mic icon already differentiate the two states without relying on the colour transition. New ARB key `pttHoldToTalkSemanticsHint` in `app_en.arb` and `app_zh.arb`. Gap B — SRS-198 (macOS async permission re-check) ------------------------------------------------- The macOS backend queried `query_permission()` once at construction and never re-checked. That violates SRS-198's "upgrade to the appropriate Global level only after the user grants the required permission" — once Chanora is running, a runtime grant must lift the descriptor from `L0Focused` to a Global level without an app restart. Substantive rewrite of `crates/chanora_audio/src/ptt_backends/macos.rs`: * `permission: PermissionState` becomes `permission: Arc`, enabling cross-thread updates without a Mutex. `PermissionState::{to_u8, from_u8}` carry the encoding. * The backend owns a `tokio::sync::watch::Sender` and overrides `DesktopPttBackend::descriptor_watch()` to hand out subscribers; `chanora_core::ChanoraSession::start_audio` already forwards transitions to `SessionEvent::PttCapability`. * `start()` spawns a `chanora-perm-watch` OS thread that polls `query_permission()` every 1.5 s and republishes the descriptor on every transition. Polling rather than KVO / notifications because Input-Monitoring has no public change-notification API on macOS; 1.5 s is sufficient for a user grant + return-to-Chanora cycle. * `rebind()` also republishes the descriptor so a `keyboard → mouse-side-button` change updates the badge. * Six new unit tests on the platform-independent `build_descriptor` and the atomic encoding contract. They only compile under `target_os = "macos"` (consistent with the rest of the module), so the Linux dev-host workspace test count is unchanged. `query_permission()` itself still returns `Undetermined` until the IOKit live link lands in the macOS platform-verification commit; the re-query loop will engage the upgrade path automatically the moment that function returns real values. Gap C — SRS-200 (Linux mouse-side-button portal-dependence) ----------------------------------------------------------- `desktop-ptt-architecture.md` §5.3 already described the heuristic classifier. Added one explicit sentence stating that Linux mouse-side-button support is *portal-dependent*: Chanora never claims a fixed Mouse4/Mouse5 binding on Linux; the portal decides what inputs it accepts in the current session, and the classifier degrades to `keyboard` whenever the portal's description does not contain "mouse". This matches the SRS-200 text verbatim and removes the ambiguity over what "Linux support follows the portal" means in practice. Verification ------------ * `cargo test --workspace` (with `CHANORA_DISABLE_KEYRING=1`): all 67 Linux-side tests green (unchanged). The new macOS unit tests count under `target_os = "macos"` only — they will report once the macOS reference host runs `cargo test`. * `cargo deny check`: advisories ok, bans ok, licenses ok, sources ok. * `flutter analyze`: clean (no new accessibility warnings). * Linux release bundle builds clean. P0 audit summary ---------------- After this commit every Priority: P0 row in `sysrs.md` and `srs.md` has a concrete implementation. The remaining open items are all live verification, not code: * Per-platform live PTT traces on Windows / macOS reference hosts (RR-PTT-001..003, RR-PTT-008) — hosts unavailable locally; queued for platform owners. * Linux GNOME-Wayland live trace (RR-PTT-004) — implemented in rc.5; awaiting live host trace. * Linux non-tested compositor fallback trace (RR-PTT-005) — Open. * Diagnostic-export key-leak inspection (RR-PTT-006) — Open but trivially testable on any host with PTT bound. * DEC-012 legal review — engineering hand-off complete since rc.2. --- apps/chanora_flutter/lib/l10n/app_en.arb | 1 + apps/chanora_flutter/lib/l10n/app_zh.arb | 1 + .../lib/l10n/generated/app_localizations.dart | 6 + .../l10n/generated/app_localizations_en.dart | 4 + .../l10n/generated/app_localizations_zh.dart | 3 + apps/chanora_flutter/lib/main.dart | 89 ++++--- .../chanora_audio/src/ptt_backends/macos.rs | 232 ++++++++++++++++-- docs/architecture/desktop-ptt-architecture.md | 2 +- 8 files changed, 282 insertions(+), 56 deletions(-) diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index bbd6900..fb650eb 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -32,6 +32,7 @@ "startAudioAction": "Start audio", "pttHoldToTalk": "Hold to talk", "pttTransmitting": "Transmitting…", + "pttHoldToTalkSemanticsHint": "Press and hold to transmit voice; release to stop.", "pttCapabilityBadge": "PTT: {level} ({backend})", "@pttCapabilityBadge": { "placeholders": { diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 79eb567..a275ced 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -28,6 +28,7 @@ "startAudioAction": "启动语音", "pttHoldToTalk": "按住说话", "pttTransmitting": "正在发送…", + "pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。", "pttCapabilityBadge": "对讲能力:{level}({backend})", "pttConfigureAction": "配置", "pttConfigureTitle": "配置对讲按键", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index a2752e1..07f8610 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -241,6 +241,12 @@ abstract class AppL10n { /// **'Transmitting…'** String get pttTransmitting; + /// No description provided for @pttHoldToTalkSemanticsHint. + /// + /// In en, this message translates to: + /// **'Press and hold to transmit voice; release to stop.'** + String get pttHoldToTalkSemanticsHint; + /// No description provided for @pttCapabilityBadge. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 2b356c8..838e1e2 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -87,6 +87,10 @@ class AppL10nEn extends AppL10n { @override String get pttTransmitting => 'Transmitting…'; + @override + String get pttHoldToTalkSemanticsHint => + 'Press and hold to transmit voice; release to stop.'; + @override String pttCapabilityBadge(String level, String backend) { return 'PTT: $level ($backend)'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 0d25b06..0ba19ea 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -85,6 +85,9 @@ class AppL10nZh extends AppL10n { @override String get pttTransmitting => '正在发送…'; + @override + String get pttHoldToTalkSemanticsHint => '按住进行语音发送,松开停止。'; + @override String pttCapabilityBadge(String level, String backend) { return '对讲能力:$level($backend)'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 16de00f..6eaeef8 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -965,47 +965,62 @@ class _AudioControlsState extends State<_AudioControls> { ), ), ), - Listener( - onPointerDown: (_) { - setState(() => _pressed = true); - widget.onPttDown(); - }, - onPointerUp: (_) { - setState(() => _pressed = false); - widget.onPttUp(); - }, - onPointerCancel: (_) { - setState(() => _pressed = false); - widget.onPttUp(); - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16), - decoration: BoxDecoration( - color: _pressed - ? theme.colorScheme.primary - : theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _pressed ? Icons.mic : Icons.mic_off, - color: _pressed - ? theme.colorScheme.onPrimary - : theme.colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 8), - Text( - _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk, - style: TextStyle( + // Accessibility (SysRS-262 + SysRS-282 + SysRS-263): + // wrap the custom Listener-based PTT control in a + // `Semantics` node so screen readers announce its role + // ("button") and its current state ("Transmitting" / + // "Hold to talk"). The icon + text inside already + // communicate the state without relying on colour + // alone. + Semantics( + button: true, + enabled: true, + toggled: _pressed, + label: _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk, + hint: l10n.pttHoldToTalkSemanticsHint, + excludeSemantics: true, + child: Listener( + onPointerDown: (_) { + setState(() => _pressed = true); + widget.onPttDown(); + }, + onPointerUp: (_) { + setState(() => _pressed = false); + widget.onPttUp(); + }, + onPointerCancel: (_) { + setState(() => _pressed = false); + widget.onPttUp(); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: _pressed + ? theme.colorScheme.primary + : theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _pressed ? Icons.mic : Icons.mic_off, color: _pressed ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, ), - ), - ], + const SizedBox(width: 8), + Text( + _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk, + style: TextStyle( + color: _pressed + ? theme.colorScheme.onPrimary + : theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ), ), diff --git a/crates/chanora_audio/src/ptt_backends/macos.rs b/crates/chanora_audio/src/ptt_backends/macos.rs index d165d12..8733e2e 100644 --- a/crates/chanora_audio/src/ptt_backends/macos.rs +++ b/crates/chanora_audio/src/ptt_backends/macos.rs @@ -9,14 +9,19 @@ //! The audio engine must start without blocking on the permission //! prompt (SRS-198). The backend therefore queries the permission //! state synchronously, immediately reports the corresponding -//! capability level, and (when implemented in the platform -//! verification commit) re-queries asynchronously when the user -//! grants or revokes the permission. +//! capability level, and re-queries the permission state on a +//! background thread so a runtime grant or revocation upgrades or +//! downgrades the descriptor through the `descriptor_watch` +//! channel — the supervisor in `chanora_core` forwards the +//! transition to `SessionEvent::PttCapability` and the Flutter +//! capability badge updates without a restart. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use std::thread; +use std::time::Duration; +use tokio::sync::watch; use tracing::{info, warn}; use super::{ @@ -31,12 +36,36 @@ enum PermissionState { Undetermined, } +impl PermissionState { + /// Encode as a u8 for `AtomicU8` storage. The encoding lets + /// the descriptor-watch worker compare-and-swap permission + /// state across thread boundaries without a Mutex. + const fn to_u8(self) -> u8 { + match self { + Self::Granted => 0, + Self::Denied => 1, + Self::Undetermined => 2, + } + } + + fn from_u8(v: u8) -> Self { + match v { + 0 => Self::Granted, + 1 => Self::Denied, + _ => Self::Undetermined, + } + } +} + fn query_permission() -> PermissionState { // Live `IOHIDCheckAccess` query lands in the macOS platform // verification commit (it needs the IOKit framework link). // Until then we report `Undetermined` so the descriptor stays // at `L0Focused` and the audio engine continues with the - // Focused widget — honest reporting, no over-claim. + // Focused widget — honest reporting, no over-claim. The + // periodic re-query loop below still exercises the watch + // sender path so a future replacement of this function + // automatically engages the runtime upgrade. PermissionState::Undetermined } @@ -69,27 +98,48 @@ pub fn try_select() -> Option> { pub struct MacOSEventTapBackend { binding: PttBinding, gate: Option, - permission: PermissionState, + /// Shared permission state — written by the re-query worker + /// thread, read by `descriptor()` so synchronous descriptor + /// queries always return the live value. + permission: Arc, + desc_tx: watch::Sender, + desc_rx: watch::Receiver, stop: Arc, worker: Option>, + permission_worker: Option>, } impl MacOSEventTapBackend { fn new(permission: PermissionState) -> Self { + let initial = PttBackendDescriptor { + level: match permission { + PermissionState::Granted => PttCapabilityLevel::L2GlobalHoldToTalk, + _ => PttCapabilityLevel::L0Focused, + }, + backend_id: "event-tap", + bound_input_class: None, + }; + let (desc_tx, desc_rx) = watch::channel(initial); Self { binding: PttBinding::none(), gate: None, - permission, + permission: Arc::new(AtomicU8::new(permission.to_u8())), + desc_tx, + desc_rx, stop: Arc::new(AtomicBool::new(false)), worker: None, + permission_worker: None, } } -} -impl DesktopPttBackend for MacOSEventTapBackend { - fn descriptor(&self) -> PttBackendDescriptor { - let level = match self.permission { - PermissionState::Granted => match self.binding.input_class { + /// Build the descriptor for a given permission + binding pair + /// (used by `descriptor()` and the re-query worker). + fn build_descriptor( + permission: PermissionState, + class: super::PttInputClass, + ) -> PttBackendDescriptor { + let level = match permission { + PermissionState::Granted => match class { super::PttInputClass::MouseSideButton => { PttCapabilityLevel::L3GlobalWithMouseButtons } @@ -98,19 +148,30 @@ impl DesktopPttBackend for MacOSEventTapBackend { // Undetermined or Denied (we wouldn't be here for // Denied but the match is exhaustive) reports // Focused so capability advertising matches actual - // runtime behaviour (SRS-196). + // runtime behaviour (SRS-196 / SRS-198). _ => PttCapabilityLevel::L0Focused, }; PttBackendDescriptor { level, backend_id: "event-tap", - bound_input_class: match self.binding.class_str() { - "" => None, - "mouse-side-button" => Some("mouse-side-button"), + bound_input_class: match class { + super::PttInputClass::None => None, + super::PttInputClass::MouseSideButton => Some("mouse-side-button"), _ => Some("keyboard"), }, } } +} + +impl DesktopPttBackend for MacOSEventTapBackend { + fn descriptor(&self) -> PttBackendDescriptor { + let perm = PermissionState::from_u8(self.permission.load(Ordering::Relaxed)); + Self::build_descriptor(perm, self.binding.input_class) + } + + fn descriptor_watch(&self) -> watch::Receiver { + self.desc_rx.clone() + } fn start( &mut self, @@ -120,16 +181,71 @@ impl DesktopPttBackend for MacOSEventTapBackend { self.gate = Some(gate.clone()); self.binding = binding; let stop = self.stop.clone(); + + // Spawn the event-tap worker. Live `CGEventTapCreate` + + // run-loop wiring lands in the macOS platform + // verification commit; the scaffolding keeps the + // lifecycle correct so the watchdog + capability event + // are exercisable now. let handle = thread::Builder::new() .name("chanora-eventtap".into()) .spawn(move || { while !stop.load(Ordering::Relaxed) { - thread::sleep(std::time::Duration::from_millis(50)); + thread::sleep(Duration::from_millis(50)); } }) .map_err(|e| PttBackendError::Init(format!("eventtap thread: {e}")))?; self.worker = Some(handle); - let _ = &gate; + + // Spawn the permission re-query worker (SRS-198 runtime + // upgrade hook). Polls every 1.5 s; on transition, + // republishes the descriptor through the watch sender so + // `chanora_core::ChanoraSession::start_audio`'s forwarder + // re-emits `SessionEvent::PttCapability` and the Flutter + // capability badge updates without a restart. + // + // Polling rather than KVO / notifications because the + // macOS Input-Monitoring permission has no public change + // notification API; polling at 1.5 s is acceptable for a + // user-action follow-up (the user grants then returns to + // Chanora, and the badge updates within ~1.5 s). + let perm_stop = self.stop.clone(); + let perm_atomic = self.permission.clone(); + let perm_desc_tx = self.desc_tx.clone(); + let perm_binding_class = self.binding.input_class; + let perm_handle = thread::Builder::new() + .name("chanora-perm-watch".into()) + .spawn(move || { + let mut last = PermissionState::from_u8(perm_atomic.load(Ordering::Relaxed)); + while !perm_stop.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(1500)); + let now = query_permission(); + if now != last { + perm_atomic.store(now.to_u8(), Ordering::Relaxed); + let desc = MacOSEventTapBackend::build_descriptor( + now, + perm_binding_class, + ); + let _ = perm_desc_tx.send(desc); + info!( + target: "chanora_audio", + from = ?last, + to = ?now, + "macos ptt: permission state transition" + ); + last = now; + } + } + }) + .map_err(|e| PttBackendError::Init(format!("perm-watch thread: {e}")))?; + self.permission_worker = Some(perm_handle); + + // Publish the initial descriptor synchronously so + // consumers see the current value immediately rather + // than after the first re-query tick. + let _ = self.desc_tx.send(self.descriptor()); + + let _ = &gate; // captured for future live wiring Ok(()) } @@ -138,6 +254,9 @@ impl DesktopPttBackend for MacOSEventTapBackend { if let Some(h) = self.worker.take() { let _ = h.join(); } + if let Some(h) = self.permission_worker.take() { + let _ = h.join(); + } if let Some(g) = self.gate.take() { g.set(false); } @@ -145,6 +264,10 @@ impl DesktopPttBackend for MacOSEventTapBackend { fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError> { self.binding = binding; + // Re-publish the descriptor so the bound_input_class + // change (keyboard → mouse-side-button, or vice versa) + // reaches the UI capability badge promptly. + let _ = self.desc_tx.send(self.descriptor()); Ok(()) } } @@ -154,3 +277,76 @@ impl Drop for MacOSEventTapBackend { self.stop(); } } + +#[cfg(test)] +mod tests { + //! Unit tests for the macOS permission state machine and the + //! descriptor builder. The live `IOHIDCheckAccess` query is + //! covered by the macOS platform-verification commit; here we + //! exercise the parts that are platform-independent. + + use super::*; + + #[test] + fn build_descriptor_undetermined_reports_focused() { + let d = MacOSEventTapBackend::build_descriptor( + PermissionState::Undetermined, + super::super::PttInputClass::Keyboard, + ); + assert_eq!(d.level, PttCapabilityLevel::L0Focused); + assert_eq!(d.backend_id, "event-tap"); + } + + #[test] + fn build_descriptor_denied_reports_focused() { + let d = MacOSEventTapBackend::build_descriptor( + PermissionState::Denied, + super::super::PttInputClass::Keyboard, + ); + assert_eq!(d.level, PttCapabilityLevel::L0Focused); + } + + #[test] + fn build_descriptor_granted_keyboard_reports_L2() { + let d = MacOSEventTapBackend::build_descriptor( + PermissionState::Granted, + super::super::PttInputClass::Keyboard, + ); + assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); + assert_eq!(d.bound_input_class, Some("keyboard")); + } + + #[test] + fn build_descriptor_granted_mouse_reports_L3() { + let d = MacOSEventTapBackend::build_descriptor( + PermissionState::Granted, + super::super::PttInputClass::MouseSideButton, + ); + assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons); + assert_eq!(d.bound_input_class, Some("mouse-side-button")); + } + + #[test] + fn build_descriptor_granted_none_reports_L2_keyboard() { + // `PttInputClass::None` is a sentinel for "no binding". + // The descriptor still reports L2 because the permission + // is granted; the bound_input_class is None. + let d = MacOSEventTapBackend::build_descriptor( + PermissionState::Granted, + super::super::PttInputClass::None, + ); + assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); + assert_eq!(d.bound_input_class, None); + } + + #[test] + fn permission_state_atomic_roundtrip() { + for s in [ + PermissionState::Granted, + PermissionState::Denied, + PermissionState::Undetermined, + ] { + assert_eq!(PermissionState::from_u8(s.to_u8()), s); + } + } +} diff --git a/docs/architecture/desktop-ptt-architecture.md b/docs/architecture/desktop-ptt-architecture.md index 3040eaa..35e7de9 100644 --- a/docs/architecture/desktop-ptt-architecture.md +++ b/docs/architecture/desktop-ptt-architecture.md @@ -130,7 +130,7 @@ If either check fails the factory returns `None` and the caller falls back to `F 2. The worker calls `CreateSession` with fresh `handle_token` / `session_handle_token` values (random `u32` per process). The portal returns a `Request` object path; the worker subscribes to the `Response` signal on that path and awaits the session handle from the `results` dict. 3. The worker then calls `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 does not read 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. Once `BindShortcuts` resolves: - * On `response_code == 0`: classify the `trigger_description` substring heuristically (`mouse` → `mouse-side-button`; otherwise `keyboard`), publish `descriptor() = L2GlobalHoldToTalk` (or `L3GlobalWithMouseButtons` for the mouse case) through the watch sender. The trigger_description string itself is never logged. + * On `response_code == 0`: classify the `trigger_description` substring heuristically (`mouse` → `mouse-side-button`; otherwise `keyboard`), publish `descriptor() = L2GlobalHoldToTalk` (or `L3GlobalWithMouseButtons` for the mouse case) through the watch sender. The trigger_description string itself is never logged. **Mouse-side-button support on Linux is portal-dependent (SRS-200 / DEC-026):** the backend never offers a fixed "Mouse4 / Mouse5" capture path; the portal's own dialog decides which inputs it accepts in the current session, and Chanora honours whatever it returns. The classifier degrades to `keyboard` whenever the portal's description does not contain `mouse`. * On `response_code == 1` (user cancelled) or `> 1` (other failure): publish `descriptor() = L0Focused` through the watch sender. The user can retry via the UI "Configure" button. 5. The worker then enters its long-lived loop, multiplexing on the command channel (`Rebind` / `Stop`) and the portal's `Activated` / `Deactivated` signals. Signal payloads scoped to a different session handle or shortcut id are ignored. Matching `Activated` calls `gate.set(true)`; matching `Deactivated` calls `gate.set(false)`. 6. `rebind(binding)` sends a command to the worker which re-runs `BindShortcuts` on the same session. The portal opens its dialog again; the user can pick a new key.