234 lines
7.8 KiB
Rust
234 lines
7.8 KiB
Rust
//! Platform-specific desktop Push-to-Talk backends (SDD-081 trait,
|
|
//! SDD-083..087 implementations).
|
|
//!
|
|
//! The cross-platform trait surface lives in this module's root;
|
|
//! per-OS implementations live in the platform sub-modules and are
|
|
//! conditionally compiled. `select()` is the runtime factory the
|
|
//! audio engine calls during `start_audio`.
|
|
//!
|
|
//! Every backend has the same shape:
|
|
//! * `start(gate, binding)` arms the backend.
|
|
//! * `stop()` releases OS-level resources.
|
|
//! * `rebind(binding)` updates the active binding without
|
|
//! restarting the backend (used by the UI binding-capture
|
|
//! flow).
|
|
//! * `descriptor()` returns the privacy-safe descriptor for
|
|
//! diagnostics + the UI capability badge.
|
|
//!
|
|
//! Backends never log raw key codes or scan codes — only the
|
|
//! stable `bound_input_class` string ("keyboard",
|
|
//! "mouse-side-button") plus the backend identifier.
|
|
|
|
use core::fmt;
|
|
|
|
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
|
use thiserror::Error;
|
|
|
|
mod focused;
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod windows;
|
|
#[cfg(target_os = "windows")]
|
|
mod windows_keymap;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod macos;
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod linux;
|
|
|
|
pub use focused::FocusedPttBackend;
|
|
|
|
/// User-bound PTT input. The struct deliberately carries only
|
|
/// privacy-safe coarse-grained values so the diagnostics rule
|
|
/// (DEC-027) holds at the type level. The OS-side key identity
|
|
/// stays inside the platform backend implementation and is never
|
|
/// exposed across this surface.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PttBinding {
|
|
/// Coarse-grained class. Stable values: `"keyboard"`,
|
|
/// `"mouse-side-button"`. Future levels (DEC-026 mouse buttons
|
|
/// on Linux portal) may add `"mouse-other"`.
|
|
pub input_class: PttInputClass,
|
|
/// Opaque platform-defined key value. The value is a string so
|
|
/// diverse platform representations (Windows scan code, macOS
|
|
/// key code, Linux portal trigger description) all fit. The
|
|
/// diagnostics sanitizer's banned-field rule (SAD-077 /
|
|
/// SDD-090) prevents this field from being logged because it
|
|
/// never appears in a tracing record — the audio + bridge
|
|
/// layers consult only `input_class` and the backend `descriptor()`.
|
|
pub platform_key: String,
|
|
}
|
|
|
|
impl PttBinding {
|
|
/// A "no binding" sentinel. Backends never produce a
|
|
/// transmit-active event from this value.
|
|
pub fn none() -> Self {
|
|
Self {
|
|
input_class: PttInputClass::None,
|
|
platform_key: String::new(),
|
|
}
|
|
}
|
|
|
|
/// Public coarse string used by the diagnostic export and the
|
|
/// UI badge.
|
|
pub fn class_str(&self) -> &'static str {
|
|
self.input_class.as_str()
|
|
}
|
|
}
|
|
|
|
/// Coarse input class. Stable strings shared by the diagnostics
|
|
/// export, the UI capability badge, and the release verification
|
|
/// record.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PttInputClass {
|
|
/// No binding is active.
|
|
None,
|
|
/// A keyboard key.
|
|
Keyboard,
|
|
/// A mouse side button (Mouse4 / Mouse5).
|
|
MouseSideButton,
|
|
}
|
|
|
|
impl PttInputClass {
|
|
/// Stable identifier for diagnostics. The value is never the
|
|
/// raw key code or scan code — see DEC-027.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::None => "",
|
|
Self::Keyboard => "keyboard",
|
|
Self::MouseSideButton => "mouse-side-button",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for PttInputClass {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
/// Errors raised by a desktop PTT backend.
|
|
#[derive(Debug, Error)]
|
|
pub enum PttBackendError {
|
|
/// The OS rejected the backend initialisation (e.g. Raw Input
|
|
/// registration failed, event tap creation failed).
|
|
#[error("init failed: {0}")]
|
|
Init(String),
|
|
/// The user-granted permission required for global capture is
|
|
/// not granted (typically macOS Input Monitoring / Accessibility).
|
|
#[error("permission denied")]
|
|
PermissionDenied,
|
|
/// The display server or compositor does not expose the
|
|
/// expected interface (typically a non-tested Linux compositor).
|
|
#[error("unsupported environment")]
|
|
UnsupportedEnvironment,
|
|
/// Caller submitted a binding whose `platform_key` cannot be
|
|
/// parsed in the active OS.
|
|
#[error("invalid binding: {0}")]
|
|
InvalidBinding(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ptt_backend_error_display_strings_stay_stable() {
|
|
assert_eq!(
|
|
PttBackendError::Init("rawinput".into()).to_string(),
|
|
"init failed: rawinput"
|
|
);
|
|
assert_eq!(
|
|
PttBackendError::PermissionDenied.to_string(),
|
|
"permission denied"
|
|
);
|
|
assert_eq!(
|
|
PttBackendError::UnsupportedEnvironment.to_string(),
|
|
"unsupported environment"
|
|
);
|
|
assert_eq!(
|
|
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
|
"invalid binding: bad key"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Cross-platform desktop PTT backend (SDD-081).
|
|
///
|
|
/// All implementations call exactly the audio transmit gate's
|
|
/// `set(bool)` method to drive `transmit_active`; they never log
|
|
/// raw key data.
|
|
pub trait DesktopPttBackend: Send {
|
|
/// Privacy-safe descriptor of this backend instance.
|
|
fn descriptor(&self) -> PttBackendDescriptor;
|
|
|
|
/// Subscribe to descriptor transitions. Implementations that
|
|
/// cannot transition asynchronously (`FocusedPttBackend`,
|
|
/// `WindowsRawInputBackend`, `WindowsHookBackend`,
|
|
/// `MacOSEventTapBackend` today) return a receiver that
|
|
/// observes only the initial value and never fires again. The
|
|
/// Linux portal backend uses this channel to publish the
|
|
/// post-`BindShortcuts` capability transition (Bound /
|
|
/// Cancelled).
|
|
fn descriptor_watch(&self) -> tokio::sync::watch::Receiver<PttBackendDescriptor> {
|
|
let (_tx, rx) = tokio::sync::watch::channel(self.descriptor());
|
|
// Hold `_tx` alive by leaking it — receivers detect the
|
|
// sender drop via `changed()` returning `Err`, which the
|
|
// core supervisor treats as "no further updates". By
|
|
// leaking we instead keep the receiver "live" indefinitely
|
|
// (no spurious closed-channel events), and the channel is
|
|
// dropped together with the audio engine because the
|
|
// backend Drop releases the box.
|
|
std::mem::forget(_tx);
|
|
rx
|
|
}
|
|
|
|
/// Arm the backend. After this call the backend listens for
|
|
/// the bound input and toggles the gate accordingly.
|
|
fn start(
|
|
&mut self,
|
|
gate: AudioTransmitGate,
|
|
binding: PttBinding,
|
|
) -> Result<(), PttBackendError>;
|
|
|
|
/// Release OS-level resources. Idempotent. The backend
|
|
/// instance may be dropped immediately after.
|
|
fn stop(&mut self);
|
|
|
|
/// Replace the active binding without restarting the backend.
|
|
/// May fail with `PttBackendError::InvalidBinding` if the new
|
|
/// binding cannot be honoured.
|
|
fn rebind(&mut self, binding: PttBinding) -> Result<(), PttBackendError>;
|
|
}
|
|
|
|
/// Runtime factory (SAD-071 / `platform_input::select`). Returns
|
|
/// the highest-capability backend the current OS, permission set,
|
|
/// and display server permit, falling back through the ladder
|
|
/// described in `desktop-ptt-architecture.md` to the universal
|
|
/// `FocusedPttBackend`.
|
|
///
|
|
/// The factory never fails — `FocusedPttBackend` is always
|
|
/// constructible.
|
|
pub fn select() -> Box<dyn DesktopPttBackend> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Some(b) = windows::try_select() {
|
|
return b;
|
|
}
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
if let Some(b) = macos::try_select() {
|
|
return b;
|
|
}
|
|
}
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if let Some(b) = linux::try_select() {
|
|
return b;
|
|
}
|
|
}
|
|
Box::new(FocusedPttBackend::new())
|
|
}
|