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 | | /// Test-only backend that records what *would* have been typed. The three |
28 | | /// real backends each need a live compositor or portal, so without this the |
29 | | /// typing path cannot be asserted on at all. |
30 | | #[cfg(test)] |
31 | | Capture(std::sync::Arc<std::sync::Mutex<String>>), |
32 | | } |
33 | | |
34 | | // SAFETY: see Simulator doc comment — single-writer access enforced by daemon. |
35 | | unsafe impl Send for Simulator {} |
36 | | unsafe impl Sync for Simulator {} |
37 | | |
38 | | impl Simulator { |
39 | | /// Create a simulator for the requested write method. |
40 | | /// |
41 | | /// # Errors |
42 | | /// Returns an error when a *specific* method is requested and fails, or — |
43 | | /// for `Auto` — when every backend in the chain is unavailable. |
44 | 0 | pub async fn new(method: WriteMethod) -> Result<Self> { |
45 | 0 | let sim = match method { |
46 | 0 | WriteMethod::Auto => Self::auto().await?, |
47 | | WriteMethod::XdgDesktopPortal => { |
48 | 0 | let backend = XdgPortalBackend::new().await?; |
49 | 0 | Self::XdgPortal(backend) |
50 | | } |
51 | | WriteMethod::Ydotool => { |
52 | 0 | anyhow::ensure!(YdotoolBackend::is_available(), "ydotool is not available"); |
53 | 0 | Self::Ydotool(YdotoolBackend::new()) |
54 | | } |
55 | 0 | WriteMethod::WaylandProtocol => Self::WaylandProtocol(Box::new(EnigoBackend::new()?)), |
56 | | }; |
57 | 0 | Ok(sim) |
58 | 0 | } |
59 | | |
60 | | /// Auto-detect: Wayland protocol → XDG Portal → ydotool. |
61 | | /// |
62 | | /// The Wayland protocol leads because it needs nothing installed beyond a |
63 | | /// compositor exposing `zwp_virtual_keyboard_manager_v1`, types the user's |
64 | | /// actual layout, and costs no D-Bus round-trips or authorization prompt. |
65 | | /// The portal follows for sessions that withhold the virtual-keyboard |
66 | | /// global, and ydotool last since it needs a running `ydotoold` and types a |
67 | | /// hardcoded US-QWERTY map. |
68 | | /// |
69 | | /// # Errors |
70 | | /// Only when every backend is unavailable. The message carries each rung's |
71 | | /// reason: a failed recording is the daemon's sole chance to explain why |
72 | | /// nothing can type. |
73 | 0 | async fn auto() -> Result<Self> { |
74 | 0 | debug!("Auto-detecting write method..."); |
75 | 0 | let mut unavailable = Vec::new(); |
76 | | |
77 | 0 | match EnigoBackend::new() { |
78 | 0 | Ok(backend) => return Ok(Self::WaylandProtocol(Box::new(backend))), |
79 | 0 | Err(e) => { |
80 | 0 | debug!("Wayland protocol unavailable: {e}"); |
81 | 0 | unavailable.push(format!("Wayland protocol ({e})")); |
82 | | } |
83 | | } |
84 | | |
85 | 0 | let portal_available = XdgPortalBackend::is_available().await; |
86 | 0 | debug!("XDG Desktop Portal available: {portal_available}"); |
87 | 0 | if portal_available { |
88 | 0 | match XdgPortalBackend::new().await { |
89 | 0 | Ok(backend) => return Ok(Self::XdgPortal(backend)), |
90 | 0 | Err(e) => { |
91 | 0 | warn!("XDG Portal available but session failed: {e}"); |
92 | 0 | unavailable.push(format!("XDG Desktop Portal ({e})")); |
93 | | } |
94 | | } |
95 | 0 | } else { |
96 | 0 | unavailable |
97 | 0 | .push("XDG Desktop Portal (RemoteDesktop interface not on the session bus)".into()); |
98 | 0 | } |
99 | | |
100 | 0 | let ydotool_available = YdotoolBackend::is_available(); |
101 | 0 | debug!("ydotool available: {ydotool_available}"); |
102 | 0 | if ydotool_available { |
103 | 0 | return Ok(Self::Ydotool(YdotoolBackend::new())); |
104 | 0 | } |
105 | 0 | unavailable.push("ydotool (not installed or ydotoold not running)".into()); |
106 | | |
107 | 0 | anyhow::bail!( |
108 | | "no write method available — tried {}", |
109 | 0 | unavailable.join(", ") |
110 | | ) |
111 | 0 | } |
112 | | |
113 | | /// Whether this backend may be held across recordings. |
114 | | /// |
115 | | /// Everything except enigo is cached. Rebuilding the portal session costs |
116 | | /// three D-Bus round-trips before capture can start and may prompt the |
117 | | /// user for authorization each time, so paying it per recording is not an |
118 | | /// option. enigo is the exception: Wayland compositors recycle idle |
119 | | /// connections, leaving a stale `Con` that fails silently on the next |
120 | | /// recording, and recreating it is cheap. |
121 | | #[must_use] |
122 | 4 | pub fn is_cacheable(&self) -> bool { |
123 | 4 | !matches!(self, Self::WaylandProtocol(_)) |
124 | 4 | } |
125 | | |
126 | | /// The concrete method this simulator drives. |
127 | | /// |
128 | | /// Never `Auto` in a shipped build: `auto()` resolves the chain at |
129 | | /// construction, and this is the only way a client can learn which rung it |
130 | | /// landed on (`POST /write_method/test`). The test-only capture backend |
131 | | /// types through no real method and so reports the unresolved `Auto`. |
132 | | #[must_use] |
133 | 2 | pub fn resolved_method(&self) -> WriteMethod { |
134 | 2 | match self { |
135 | 0 | Self::XdgPortal(_) => WriteMethod::XdgDesktopPortal, |
136 | 0 | Self::Ydotool(_) => WriteMethod::Ydotool, |
137 | 0 | Self::WaylandProtocol(_) => WriteMethod::WaylandProtocol, |
138 | | #[cfg(test)] |
139 | 2 | Self::Capture(_) => WriteMethod::Auto, |
140 | | } |
141 | 2 | } |
142 | | |
143 | | /// Human-readable name for logging. |
144 | | #[must_use] |
145 | 2 | pub fn name(&self) -> &'static str { |
146 | 2 | match self { |
147 | 0 | Self::XdgPortal(_) => "XDG Desktop Portal", |
148 | 0 | Self::Ydotool(_) => "ydotool", |
149 | 0 | Self::WaylandProtocol(_) => "Wayland protocol", |
150 | | #[cfg(test)] |
151 | 2 | Self::Capture(_) => "capture (test)", |
152 | | } |
153 | 2 | } |
154 | | |
155 | | /// Type text using the active backend. Async so the portal backend awaits |
156 | | /// its D-Bus calls directly and the blocking backends yield the worker |
157 | | /// (audit Tier 3 #35). |
158 | | /// |
159 | | /// # Errors |
160 | | /// Returns an error if the backend fails to simulate key input. |
161 | | /// |
162 | | /// # Panics |
163 | | /// The test-only capture backend panics if its buffer mutex is poisoned. |
164 | 22 | pub async fn type_text(&mut self, text: &str) -> Result<()> { |
165 | 22 | match self { |
166 | | // The enigo/ydotool backends are synchronous and `!Send`; run them |
167 | | // under `block_in_place` so their handle never crosses an await and |
168 | | // the runtime spins up a replacement worker rather than stalling. The |
169 | | // portal backend is genuinely async — await it directly. |
170 | 0 | Self::WaylandProtocol(b) => tokio::task::block_in_place(|| b.type_text(text)), |
171 | 0 | Self::Ydotool(_) => tokio::task::block_in_place(|| YdotoolBackend::type_text(text)), |
172 | 0 | Self::XdgPortal(b) => b.type_text(text).await, |
173 | | #[cfg(test)] |
174 | 22 | Self::Capture(buf) => { |
175 | 22 | buf.lock().expect("capture buffer poisoned").push_str(text); |
176 | 22 | Ok(()) |
177 | | } |
178 | | } |
179 | 22 | } |
180 | | |
181 | | /// Backspace N characters using the active backend. |
182 | | /// |
183 | | /// # Errors |
184 | | /// Returns an error if the backend fails to simulate key input. |
185 | | /// |
186 | | /// # Panics |
187 | | /// The test-only capture backend panics if its buffer mutex is poisoned. |
188 | 2 | pub async fn backspace_n(&mut self, n: usize) -> Result<()> { |
189 | 2 | match self { |
190 | 0 | Self::WaylandProtocol(b) => { |
191 | 0 | tokio::task::block_in_place(|| b.backspace_n(n)); |
192 | 0 | Ok(()) |
193 | | } |
194 | 0 | Self::Ydotool(_) => tokio::task::block_in_place(|| YdotoolBackend::backspace_n(n)), |
195 | 0 | Self::XdgPortal(b) => b.backspace_n(n).await, |
196 | | #[cfg(test)] |
197 | 2 | Self::Capture(buf) => { |
198 | 2 | let mut guard = buf.lock().expect("capture buffer poisoned"); |
199 | | // Truncate by chars, not bytes — a real backspace removes one |
200 | | // grapheme, and truncating mid-UTF-8 would panic. |
201 | 2 | let keep = guard.chars().count().saturating_sub(n); |
202 | 2 | *guard = guard.chars().take(keep).collect(); |
203 | 2 | Ok(()) |
204 | | } |
205 | | } |
206 | 2 | } |
207 | | } |
208 | | |
209 | | #[cfg(test)] |
210 | | impl Simulator { |
211 | | /// A simulator that accumulates typed text instead of driving a keyboard. |
212 | | /// Returns the simulator and a handle to the accumulated text. |
213 | 50 | pub(crate) fn capture() -> (Self, std::sync::Arc<std::sync::Mutex<String>>) { |
214 | 50 | let buf = std::sync::Arc::new(std::sync::Mutex::new(String::new())); |
215 | 50 | (Self::Capture(std::sync::Arc::clone(&buf)), buf) |
216 | 50 | } |
217 | | } |
218 | | |
219 | | #[cfg(test)] |
220 | | mod tests { |
221 | | use super::Simulator; |
222 | | |
223 | | /// The capture backend has to behave like a real one — text accumulates and |
224 | | /// backspace removes trailing *characters* (not bytes) — or tests written |
225 | | /// against it will not reflect what lands in a user's window. |
226 | | #[tokio::test] |
227 | 2 | async fn capture_backend_accumulates_text_and_honors_backspace() { |
228 | 2 | let (mut sim, buf) = Simulator::capture(); |
229 | | |
230 | 2 | sim.type_text("hello").await.expect("type"); |
231 | 2 | sim.type_text(" wörld").await.expect("type"); |
232 | 2 | assert_eq!(*buf.lock().unwrap(), "hello wörld"); |
233 | | |
234 | | // Multi-byte char must be removed whole. |
235 | 2 | sim.backspace_n(4).await.expect("backspace"); |
236 | 2 | assert_eq!(*buf.lock().unwrap(), "hello w"); |
237 | | |
238 | 2 | assert_eq!(sim.name(), "capture (test)"); |
239 | 2 | } |
240 | | |
241 | | /// Caching is the default; only enigo opts out. A regression that inverts |
242 | | /// this rebuilds the portal session before every recording, costing three |
243 | | /// D-Bus round-trips and possibly an authorization prompt. enigo itself |
244 | | /// needs a live compositor to construct, so this pins the side of the rule |
245 | | /// that is reachable in a test. |
246 | | #[test] |
247 | 2 | fn backends_are_cached_by_default() { |
248 | 2 | let (sim, _buf) = Simulator::capture(); |
249 | 2 | assert!(sim.is_cacheable()); |
250 | 2 | } |
251 | | } |