fix(p0): close three P0 coverage gaps after rc.5 audit
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<AtomicU8>`,
enabling cross-thread updates without a Mutex.
`PermissionState::{to_u8, from_u8}` carry the encoding.
* The backend owns a `tokio::sync::watch::Sender<PttBackendDescriptor>`
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.
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"startAudioAction": "启动语音",
|
||||
"pttHoldToTalk": "按住说话",
|
||||
"pttTransmitting": "正在发送…",
|
||||
"pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。",
|
||||
"pttCapabilityBadge": "对讲能力:{level}({backend})",
|
||||
"pttConfigureAction": "配置",
|
||||
"pttConfigureTitle": "配置对讲按键",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<Box<dyn DesktopPttBackend>> {
|
||||
pub struct MacOSEventTapBackend {
|
||||
binding: PttBinding,
|
||||
gate: Option<AudioTransmitGate>,
|
||||
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<AtomicU8>,
|
||||
desc_tx: watch::Sender<PttBackendDescriptor>,
|
||||
desc_rx: watch::Receiver<PttBackendDescriptor>,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
permission_worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<PttBackendDescriptor> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user