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:
EdisonJwa
2026-05-15 16:50:48 +08:00
parent 82d012a46b
commit 03f5d6bca3
8 changed files with 282 additions and 56 deletions
+1
View File
@@ -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": {
+1
View File
@@ -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';
+52 -37
View File
@@ -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,
),
),
],
),
),
),
),