super-stt-daemon/src/output/keyboard/mod.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | |
3 | | mod enigo_backend; |
4 | | mod xdg_portal_backend; |
5 | | mod ydotool_backend; |
6 | | |
7 | | use anyhow::Result; |
8 | | use log::{debug, warn}; |
9 | | use super_stt_shared::models::write_method::WriteMethod; |
10 | | |
11 | | use enigo_backend::EnigoBackend; |
12 | | use xdg_portal_backend::XdgPortalBackend; |
13 | | use ydotool_backend::YdotoolBackend; |
14 | | |
15 | | /// Keyboard simulation backend. |
16 | | /// |
17 | | /// # Safety |
18 | | /// |
19 | | /// `Simulator` is `Send + Sync` because it is only ever accessed by one |
20 | | /// recording session at a time (guarded by `busy`). The `!Send` |
21 | | /// inner type (`Enigo` with raw xkbcommon pointers) is never used |
22 | | /// concurrently. |
23 | | pub enum Simulator { |
24 | | WaylandProtocol(Box<EnigoBackend>), |
25 | | Ydotool(YdotoolBackend), |
26 | | XdgPortal(XdgPortalBackend), |
27 | | } |
28 | | |
29 | | // SAFETY: see Simulator doc comment — single-writer access enforced by daemon. |
30 | | unsafe impl Send for Simulator {} |
31 | | unsafe impl Sync for Simulator {} |
32 | | |
33 | | impl Simulator { |
34 | | /// Create a simulator for the requested write method. |
35 | | /// |
36 | | /// # Errors |
37 | | /// Returns an error only when a *specific* method is requested and fails. |
38 | | /// `Auto` always falls back to Wayland protocol. |
39 | 0 | pub async fn new(method: WriteMethod) -> Result<Self> { |
40 | 0 | let sim = match method { |
41 | 0 | WriteMethod::Auto => Self::auto().await?, |
42 | | WriteMethod::XdgDesktopPortal => { |
43 | 0 | let backend = XdgPortalBackend::new().await?; |
44 | 0 | Self::XdgPortal(backend) |
45 | | } |
46 | | WriteMethod::Ydotool => { |
47 | 0 | anyhow::ensure!(YdotoolBackend::is_available(), "ydotool is not available"); |
48 | 0 | Self::Ydotool(YdotoolBackend::new()) |
49 | | } |
50 | 0 | WriteMethod::WaylandProtocol => Self::WaylandProtocol(Box::new(EnigoBackend::new()?)), |
51 | | }; |
52 | 0 | Ok(sim) |
53 | 0 | } |
54 | | |
55 | | /// Auto-detect: XDG Portal → ydotool → Wayland protocol. |
56 | 0 | async fn auto() -> Result<Self> { |
57 | 0 | debug!("Auto-detecting write method..."); |
58 | | |
59 | 0 | let portal_available = XdgPortalBackend::is_available().await; |
60 | 0 | debug!("XDG Desktop Portal available: {portal_available}"); |
61 | 0 | if portal_available { |
62 | 0 | match XdgPortalBackend::new().await { |
63 | 0 | Ok(backend) => return Ok(Self::XdgPortal(backend)), |
64 | 0 | Err(e) => warn!("XDG Portal available but session failed: {e}"), |
65 | | } |
66 | 0 | } |
67 | | |
68 | 0 | let ydotool_available = YdotoolBackend::is_available(); |
69 | 0 | debug!("ydotool available: {ydotool_available}"); |
70 | 0 | if ydotool_available { |
71 | 0 | return Ok(Self::Ydotool(YdotoolBackend::new())); |
72 | 0 | } |
73 | | |
74 | 0 | debug!("Falling back to Wayland protocol"); |
75 | 0 | Ok(Self::WaylandProtocol(Box::new(EnigoBackend::new()?))) |
76 | 0 | } |
77 | | |
78 | | /// Human-readable name for logging. |
79 | | #[must_use] |
80 | 0 | pub fn name(&self) -> &'static str { |
81 | 0 | match self { |
82 | 0 | Self::XdgPortal(_) => "XDG Desktop Portal", |
83 | 0 | Self::Ydotool(_) => "ydotool", |
84 | 0 | Self::WaylandProtocol(_) => "Wayland protocol", |
85 | | } |
86 | 0 | } |
87 | | |
88 | | /// Type text using the active backend. Async so the portal backend awaits |
89 | | /// its D-Bus calls directly and the blocking backends yield the worker |
90 | | /// (audit Tier 3 #35). |
91 | | /// |
92 | | /// # Errors |
93 | | /// Returns an error if the backend fails to simulate key input. |
94 | 0 | pub async fn type_text(&mut self, text: &str) -> Result<()> { |
95 | 0 | match self { |
96 | | // The enigo/ydotool backends are synchronous and `!Send`; run them |
97 | | // under `block_in_place` so their handle never crosses an await and |
98 | | // the runtime spins up a replacement worker rather than stalling. The |
99 | | // portal backend is genuinely async — await it directly. |
100 | 0 | Self::WaylandProtocol(b) => tokio::task::block_in_place(|| b.type_text(text)), |
101 | 0 | Self::Ydotool(_) => tokio::task::block_in_place(|| YdotoolBackend::type_text(text)), |
102 | 0 | Self::XdgPortal(b) => b.type_text(text).await, |
103 | | } |
104 | 0 | } |
105 | | |
106 | | /// Backspace N characters using the active backend. |
107 | | /// |
108 | | /// # Errors |
109 | | /// Returns an error if the backend fails to simulate key input. |
110 | 0 | pub async fn backspace_n(&mut self, n: usize) -> Result<()> { |
111 | 0 | match self { |
112 | 0 | Self::WaylandProtocol(b) => { |
113 | 0 | tokio::task::block_in_place(|| b.backspace_n(n)); |
114 | 0 | Ok(()) |
115 | | } |
116 | 0 | Self::Ydotool(_) => tokio::task::block_in_place(|| YdotoolBackend::backspace_n(n)), |
117 | 0 | Self::XdgPortal(b) => b.backspace_n(n).await, |
118 | | } |
119 | 0 | } |
120 | | } |