Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}