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.