Skip to main content

omni_cli/
daemon.rs

1//! omni daemon — persistent-connection desktop keystroke replay (bd-1eefe5).
2//!
3//! `omni daemon` runs as a lightweight service on a desktop, holds a persistent
4//! connection, and REPLAYS received [`InjectionCommand`]s as local OS keystrokes
5//! — so `omni send --daemon <addr>` / `omni cast --daemon <addr>` can cast to
6//! desktops, not just phone/watch keyboards. Unlike tendril (a new SSH
7//! connection per keystroke) the client keeps one established connection.
8//!
9//! The daemon speaks the same wire as the relay ([`crate::transport::relay`]):
10//! each line is either a token-gated `cast` [`ControlFrame`] or a bare
11//! [`InjectionCommand`]. Received commands are replayed through a pluggable
12//! [`Injector`], so the connection/auth/loop is testable headless and real OS
13//! key synthesis is swappable. The same local port accepts `subscribe` clients;
14//! every accepted command is injected once and mirrored to all subscribers.
15
16use std::io::{BufRead, BufReader, Write};
17use std::net::{Shutdown, TcpListener, TcpStream};
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
20use std::sync::{Arc, Mutex};
21use std::thread;
22use std::time::Duration;
23
24use crate::AppError;
25use crate::events::SemanticEvent;
26use crate::transport::relay::ControlFrame;
27use crate::wire::{InjectionCommand, WIRE_VERSION};
28
29// ---------------------------------------------------------------------------
30// injector
31// ---------------------------------------------------------------------------
32
33/// Replays an [`InjectionCommand`] as local OS keystrokes.
34pub trait Injector: Send + Sync {
35    /// Replay one command. A per-command failure is logged by the caller and
36    /// does not tear down the daemon.
37    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError>;
38    /// Short human label (for `omni daemon` startup logging).
39    fn label(&self) -> &str;
40    /// Additive application-event hook. Never fall back to OS injection.
41    fn semantic_event(&self, _event: &SemanticEvent) -> Result<(), AppError> {
42        Ok(())
43    }
44    fn supports_events(&self) -> bool {
45        false
46    }
47}
48
49/// An injector that only logs what it would replay — the safe default for
50/// headless hosts and `--dry-run`. Writes one NDJSON line per command to stderr.
51#[derive(Debug, Default)]
52pub struct LogInjector;
53
54impl Injector for LogInjector {
55    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
56        eprintln!("omni daemon: replay {}", cmd.json_line()?);
57        Ok(())
58    }
59    fn label(&self) -> &str {
60        "log"
61    }
62}
63
64/// An injector that records every command it is asked to replay (for tests).
65#[derive(Debug, Default, Clone)]
66pub struct CaptureInjector {
67    seen: Arc<Mutex<Vec<InjectionCommand>>>,
68}
69
70impl CaptureInjector {
71    #[must_use]
72    pub fn new() -> Self {
73        Self::default()
74    }
75    /// Snapshot of the commands replayed so far.
76    #[must_use]
77    pub fn captured(&self) -> Vec<InjectionCommand> {
78        self.seen.lock().unwrap().clone()
79    }
80}
81
82impl Injector for CaptureInjector {
83    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
84        self.seen.lock().unwrap().push(cmd.clone());
85        Ok(())
86    }
87    fn label(&self) -> &str {
88        "capture"
89    }
90}
91
92/// Injector adapter used by relay-device mode: inject once, then mirror the
93/// received command to every local stream subscriber.
94pub struct BroadcastingInjector {
95    inner: Arc<dyn Injector>,
96    bus: CommandBus,
97}
98
99impl BroadcastingInjector {
100    #[must_use]
101    pub fn new(inner: Arc<dyn Injector>, bus: CommandBus) -> Self {
102        Self { inner, bus }
103    }
104}
105
106impl Injector for BroadcastingInjector {
107    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
108        // Fan out first so a slow platform injector cannot add latency to app
109        // subscribers. The bus is non-blocking and isolates slow consumers.
110        self.bus.publish(cmd);
111        self.inner.inject(cmd)
112    }
113
114    fn label(&self) -> &str {
115        self.inner.label()
116    }
117    fn semantic_event(&self, event: &SemanticEvent) -> Result<(), AppError> {
118        self.bus.publish_event(event);
119        Ok(())
120    }
121    fn supports_events(&self) -> bool {
122        true
123    }
124}
125
126/// Linux/X11 injector: replay commands via `xdotool`.
127#[derive(Debug, Default)]
128pub struct XdotoolInjector;
129
130impl Injector for XdotoolInjector {
131    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
132        for argv in xdotool_argvs(cmd)? {
133            run_argv(&argv)?;
134        }
135        Ok(())
136    }
137    fn label(&self) -> &str {
138        "xdotool"
139    }
140}
141
142/// Run one argv with [`std::process::Command`], mapping a non-zero exit / spawn
143/// failure to an [`AppError`].
144fn run_argv(argv: &[String]) -> Result<(), AppError> {
145    let (prog, rest) = argv
146        .split_first()
147        .ok_or_else(|| AppError::internal("inject_argv", "empty argv"))?;
148    let status = std::process::Command::new(prog)
149        .args(rest)
150        .status()
151        .map_err(|e| AppError::internal("inject_spawn", format!("{prog}: {e}")))?;
152    if status.success() {
153        Ok(())
154    } else {
155        Err(AppError::internal(
156            "inject_status",
157            format!("{} exited with {status}", argv.join(" ")),
158        ))
159    }
160}
161
162/// Map a semantic key name to an X11 keysym understood by `xdotool key`.
163/// Unknown names pass through unchanged (xdotool accepts many keysyms).
164#[must_use]
165pub fn xdotool_keysym(name: &str) -> String {
166    match name {
167        "enter" | "return" => "Return",
168        "esc" | "escape" => "Escape",
169        "tab" => "Tab",
170        "space" => "space",
171        "backspace" => "BackSpace",
172        "delete" | "del" => "Delete",
173        "left" => "Left",
174        "right" => "Right",
175        "up" => "Up",
176        "down" => "Down",
177        "home" => "Home",
178        "end" => "End",
179        other => other,
180    }
181    .to_owned()
182}
183
184/// Map one combo token to its xdotool form: modifiers are normalised, other
185/// tokens go through [`xdotool_keysym`].
186fn xdotool_combo_token(tok: &str) -> String {
187    match tok.trim().to_ascii_lowercase().as_str() {
188        "ctrl" | "control" => "ctrl".to_owned(),
189        "alt" | "option" => "alt".to_owned(),
190        "shift" => "shift".to_owned(),
191        "super" | "cmd" | "command" | "meta" | "win" => "super".to_owned(),
192        other => xdotool_keysym(other),
193    }
194}
195
196/// Build the `xdotool` argv(s) for a command. A command usually maps to one
197/// invocation; `scroll 0` maps to none.
198///
199/// # Errors
200/// [`AppError::validation`] if a command is missing its payload.
201pub fn xdotool_argvs(cmd: &InjectionCommand) -> Result<Vec<Vec<String>>, AppError> {
202    use crate::wire::Kind;
203    let s = |v: &str| v.to_owned();
204    match cmd.kind {
205        Kind::Text => {
206            let text = cmd
207                .text
208                .as_deref()
209                .ok_or_else(|| AppError::validation("inject_text", "text command missing text"))?;
210            Ok(vec![vec![
211                s("xdotool"),
212                s("type"),
213                s("--clearmodifiers"),
214                s("--"),
215                s(text),
216            ]])
217        }
218        Kind::Key => {
219            let key = cmd
220                .key
221                .as_deref()
222                .ok_or_else(|| AppError::validation("inject_key", "key command missing key"))?;
223            Ok(vec![vec![
224                s("xdotool"),
225                s("key"),
226                s("--clearmodifiers"),
227                xdotool_keysym(key),
228            ]])
229        }
230        Kind::Combo => {
231            let combo = cmd.combo.as_deref().ok_or_else(|| {
232                AppError::validation("inject_combo", "combo command missing combo")
233            })?;
234            let chord = combo
235                .split(',')
236                .filter(|t| !t.trim().is_empty())
237                .map(xdotool_combo_token)
238                .collect::<Vec<_>>()
239                .join("+");
240            if chord.is_empty() {
241                return Err(AppError::validation("inject_combo", "empty combo"));
242            }
243            Ok(vec![vec![
244                s("xdotool"),
245                s("key"),
246                s("--clearmodifiers"),
247                chord,
248            ]])
249        }
250        Kind::Scroll => {
251            let amount = cmd.amount.ok_or_else(|| {
252                AppError::validation("inject_scroll", "scroll command missing amount")
253            })?;
254            if amount == 0 {
255                return Ok(vec![]);
256            }
257            // xdotool buttons: 4 = wheel up, 5 = wheel down. Positive = down.
258            let button = if amount > 0 { "5" } else { "4" };
259            let repeat = amount.unsigned_abs().to_string();
260            Ok(vec![vec![
261                s("xdotool"),
262                s("click"),
263                s("--repeat"),
264                repeat,
265                s(button),
266            ]])
267        }
268    }
269}
270
271/// Linux/Wayland injector via `wtype`.
272#[derive(Debug, Default)]
273pub struct WtypeInjector;
274
275impl Injector for WtypeInjector {
276    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
277        for argv in wtype_argvs(cmd)? {
278            run_argv(&argv)?;
279        }
280        Ok(())
281    }
282    fn label(&self) -> &str {
283        "wtype"
284    }
285}
286
287/// wtype modifier name for a token, or `None` if it is not a modifier.
288fn wtype_mod(tok: &str) -> Option<&'static str> {
289    match tok.trim().to_ascii_lowercase().as_str() {
290        "ctrl" | "control" => Some("ctrl"),
291        "alt" | "option" => Some("alt"),
292        "shift" => Some("shift"),
293        "super" | "cmd" | "command" | "meta" | "win" => Some("logo"),
294        _ => None,
295    }
296}
297
298/// Build `wtype` argv(s) (Wayland). Reuses [`xdotool_keysym`] (wtype keys are
299/// XKB keysyms too). `scroll` is unsupported.
300///
301/// # Errors
302/// [`AppError::validation`] for a missing payload or an unsupported scroll.
303pub fn wtype_argvs(cmd: &InjectionCommand) -> Result<Vec<Vec<String>>, AppError> {
304    use crate::wire::Kind;
305    let s = |v: &str| v.to_owned();
306    match cmd.kind {
307        Kind::Text => {
308            let text = cmd
309                .text
310                .as_deref()
311                .ok_or_else(|| AppError::validation("inject_text", "text command missing text"))?;
312            Ok(vec![vec![s("wtype"), s("--"), s(text)]])
313        }
314        Kind::Key => {
315            let key = cmd
316                .key
317                .as_deref()
318                .ok_or_else(|| AppError::validation("inject_key", "key command missing key"))?;
319            Ok(vec![vec![s("wtype"), s("-k"), xdotool_keysym(key)]])
320        }
321        Kind::Combo => {
322            let combo = cmd.combo.as_deref().ok_or_else(|| {
323                AppError::validation("inject_combo", "combo command missing combo")
324            })?;
325            let tokens: Vec<&str> = combo
326                .split(',')
327                .map(str::trim)
328                .filter(|t| !t.is_empty())
329                .collect();
330            if tokens.is_empty() {
331                return Err(AppError::validation("inject_combo", "empty combo"));
332            }
333            let mods: Vec<&str> = tokens.iter().filter_map(|t| wtype_mod(t)).collect();
334            let keys: Vec<&str> = tokens
335                .iter()
336                .copied()
337                .filter(|t| wtype_mod(t).is_none())
338                .collect();
339            let mut argv = vec![s("wtype")];
340            for m in &mods {
341                argv.push(s("-M"));
342                argv.push(s(m));
343            }
344            for k in &keys {
345                argv.push(s("-k"));
346                argv.push(xdotool_keysym(k));
347            }
348            for m in mods.iter().rev() {
349                argv.push(s("-m"));
350                argv.push(s(m));
351            }
352            Ok(vec![argv])
353        }
354        Kind::Scroll => Err(AppError::validation(
355            "inject_scroll_unsupported",
356            "wtype backend does not support scroll",
357        )),
358    }
359}
360
361/// Linux/Wayland injector via `ydotool` (evdev keycodes).
362#[derive(Debug, Default)]
363pub struct YdotoolInjector;
364
365impl Injector for YdotoolInjector {
366    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
367        for argv in ydotool_argvs(cmd)? {
368            run_argv(&argv)?;
369        }
370        Ok(())
371    }
372    fn label(&self) -> &str {
373        "ydotool"
374    }
375}
376
377/// Linux evdev keycode for a semantic key / character (for `ydotool key`).
378#[must_use]
379pub fn evdev_code(name: &str) -> Option<u16> {
380    let code = match name.trim().to_ascii_lowercase().as_str() {
381        "enter" | "return" => 28,
382        "esc" | "escape" => 1,
383        "tab" => 15,
384        "space" => 57,
385        "backspace" => 14,
386        "delete" | "del" => 111,
387        "left" => 105,
388        "right" => 106,
389        "up" => 103,
390        "down" => 108,
391        "home" => 102,
392        "end" => 107,
393        "ctrl" | "control" => 29,
394        "shift" => 42,
395        "alt" | "option" => 56,
396        "super" | "cmd" | "command" | "meta" | "win" => 125,
397        "a" => 30,
398        "b" => 48,
399        "c" => 46,
400        "d" => 32,
401        "e" => 18,
402        "f" => 33,
403        "g" => 34,
404        "h" => 35,
405        "i" => 23,
406        "j" => 36,
407        "k" => 37,
408        "l" => 38,
409        "m" => 50,
410        "n" => 49,
411        "o" => 24,
412        "p" => 25,
413        "q" => 16,
414        "r" => 19,
415        "s" => 31,
416        "t" => 20,
417        "u" => 22,
418        "v" => 47,
419        "w" => 17,
420        "x" => 45,
421        "y" => 21,
422        "z" => 44,
423        "1" => 2,
424        "2" => 3,
425        "3" => 4,
426        "4" => 5,
427        "5" => 6,
428        "6" => 7,
429        "7" => 8,
430        "8" => 9,
431        "9" => 10,
432        "0" => 11,
433        _ => return None,
434    };
435    Some(code)
436}
437
438/// Build `ydotool` argv(s) (Wayland/uinput). `scroll` is unsupported.
439///
440/// # Errors
441/// [`AppError::validation`] for a missing payload, an unknown key/combo token
442/// (no evdev mapping), or an unsupported scroll.
443pub fn ydotool_argvs(cmd: &InjectionCommand) -> Result<Vec<Vec<String>>, AppError> {
444    use crate::wire::Kind;
445    let s = |v: &str| v.to_owned();
446    match cmd.kind {
447        Kind::Text => {
448            let text = cmd
449                .text
450                .as_deref()
451                .ok_or_else(|| AppError::validation("inject_text", "text command missing text"))?;
452            Ok(vec![vec![s("ydotool"), s("type"), s("--"), s(text)]])
453        }
454        Kind::Key => {
455            let key = cmd
456                .key
457                .as_deref()
458                .ok_or_else(|| AppError::validation("inject_key", "key command missing key"))?;
459            let code = evdev_code(key).ok_or_else(|| {
460                AppError::validation(
461                    "inject_key_code",
462                    format!("no evdev keycode for key: {key}"),
463                )
464            })?;
465            Ok(vec![vec![
466                s("ydotool"),
467                s("key"),
468                format!("{code}:1"),
469                format!("{code}:0"),
470            ]])
471        }
472        Kind::Combo => {
473            let combo = cmd.combo.as_deref().ok_or_else(|| {
474                AppError::validation("inject_combo", "combo command missing combo")
475            })?;
476            let tokens: Vec<&str> = combo
477                .split(',')
478                .map(str::trim)
479                .filter(|t| !t.is_empty())
480                .collect();
481            if tokens.is_empty() {
482                return Err(AppError::validation("inject_combo", "empty combo"));
483            }
484            let codes: Vec<u16> = tokens
485                .iter()
486                .map(|t| {
487                    evdev_code(t).ok_or_else(|| {
488                        AppError::validation(
489                            "inject_combo_code",
490                            format!("no evdev keycode for combo token: {t}"),
491                        )
492                    })
493                })
494                .collect::<Result<_, _>>()?;
495            let mut argv = vec![s("ydotool"), s("key")];
496            for c in &codes {
497                argv.push(format!("{c}:1"));
498            }
499            for c in codes.iter().rev() {
500                argv.push(format!("{c}:0"));
501            }
502            Ok(vec![argv])
503        }
504        Kind::Scroll => Err(AppError::validation(
505            "inject_scroll_unsupported",
506            "ydotool backend does not support scroll",
507        )),
508    }
509}
510
511/// macOS injector via `osascript` (System Events).
512#[derive(Debug, Default)]
513pub struct OsascriptInjector;
514
515impl Injector for OsascriptInjector {
516    fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError> {
517        for argv in osascript_argvs(cmd)? {
518            run_argv(&argv)?;
519        }
520        Ok(())
521    }
522    fn label(&self) -> &str {
523        "osascript"
524    }
525}
526
527/// macOS virtual key code for a semantic key name.
528#[must_use]
529pub fn mac_key_code(name: &str) -> Option<u16> {
530    let code = match name.trim().to_ascii_lowercase().as_str() {
531        "enter" | "return" => 36,
532        "esc" | "escape" => 53,
533        "tab" => 48,
534        "space" => 49,
535        "backspace" => 51,
536        "delete" | "del" => 117,
537        "left" => 123,
538        "right" => 124,
539        "down" => 125,
540        "up" => 126,
541        "home" => 115,
542        "end" => 119,
543        _ => return None,
544    };
545    Some(code)
546}
547
548fn mac_modifier(tok: &str) -> Option<&'static str> {
549    match tok.trim().to_ascii_lowercase().as_str() {
550        "ctrl" | "control" => Some("control down"),
551        "alt" | "option" => Some("option down"),
552        "shift" => Some("shift down"),
553        "super" | "cmd" | "command" | "meta" | "win" => Some("command down"),
554        _ => None,
555    }
556}
557
558fn applescript_quote(s: &str) -> String {
559    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
560}
561
562/// Build `osascript -e <script>` argv(s) (macOS). `scroll` is unsupported, and
563/// a combo must have exactly one non-modifier key.
564///
565/// # Errors
566/// [`AppError::validation`] for a missing payload, an unknown special key, a
567/// multi-key combo, or an unsupported scroll.
568pub fn osascript_argvs(cmd: &InjectionCommand) -> Result<Vec<Vec<String>>, AppError> {
569    use crate::wire::Kind;
570    let wrap = |body: String| {
571        vec![vec![
572            "osascript".to_owned(),
573            "-e".to_owned(),
574            format!("tell application \"System Events\" to {body}"),
575        ]]
576    };
577    match cmd.kind {
578        Kind::Text => {
579            let text = cmd
580                .text
581                .as_deref()
582                .ok_or_else(|| AppError::validation("inject_text", "text command missing text"))?;
583            Ok(wrap(format!("keystroke {}", applescript_quote(text))))
584        }
585        Kind::Key => {
586            let key = cmd
587                .key
588                .as_deref()
589                .ok_or_else(|| AppError::validation("inject_key", "key command missing key"))?;
590            let code = mac_key_code(key).ok_or_else(|| {
591                AppError::validation(
592                    "inject_key_code",
593                    format!("no macOS key code for key: {key}"),
594                )
595            })?;
596            Ok(wrap(format!("key code {code}")))
597        }
598        Kind::Combo => {
599            let combo = cmd.combo.as_deref().ok_or_else(|| {
600                AppError::validation("inject_combo", "combo command missing combo")
601            })?;
602            let tokens: Vec<&str> = combo
603                .split(',')
604                .map(str::trim)
605                .filter(|t| !t.is_empty())
606                .collect();
607            if tokens.is_empty() {
608                return Err(AppError::validation("inject_combo", "empty combo"));
609            }
610            let mods: Vec<&str> = tokens.iter().filter_map(|t| mac_modifier(t)).collect();
611            let keys: Vec<&str> = tokens
612                .iter()
613                .copied()
614                .filter(|t| mac_modifier(t).is_none())
615                .collect();
616            if keys.len() != 1 {
617                return Err(AppError::validation(
618                    "inject_combo_macos",
619                    "macOS combo needs exactly one non-modifier key",
620                ));
621            }
622            let using = if mods.is_empty() {
623                String::new()
624            } else {
625                format!(" using {{{}}}", mods.join(", "))
626            };
627            let key = keys[0];
628            let body = if let Some(code) = mac_key_code(key) {
629                format!("key code {code}{using}")
630            } else {
631                format!("keystroke {}{using}", applescript_quote(key))
632            };
633            Ok(wrap(body))
634        }
635        Kind::Scroll => Err(AppError::validation(
636            "inject_scroll_unsupported",
637            "osascript backend does not support scroll",
638        )),
639    }
640}
641
642/// Which injector backend to use.
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub enum Backend {
645    /// Log-only (safe default; headless / --dry-run).
646    Log,
647    /// Linux/X11 via xdotool.
648    Xdotool,
649    /// Linux/Wayland via wtype.
650    Wtype,
651    /// Linux/Wayland via ydotool (uinput).
652    Ydotool,
653    /// macOS via osascript (System Events).
654    Osascript,
655}
656
657impl Backend {
658    /// Parse the `--backend` value.
659    ///
660    /// # Errors
661    /// [`AppError::validation`] for an unknown backend.
662    pub fn parse(s: &str) -> Result<Self, AppError> {
663        match s {
664            "log" => Ok(Backend::Log),
665            "xdotool" => Ok(Backend::Xdotool),
666            "wtype" => Ok(Backend::Wtype),
667            "ydotool" => Ok(Backend::Ydotool),
668            "osascript" => Ok(Backend::Osascript),
669            other => Err(AppError::validation(
670                "daemon_backend",
671                format!("unknown --backend {other} (expected log|xdotool|wtype|ydotool|osascript)"),
672            )),
673        }
674    }
675
676    /// Build the injector for this backend.
677    #[must_use]
678    pub fn injector(self) -> Arc<dyn Injector> {
679        match self {
680            Backend::Log => Arc::new(LogInjector),
681            Backend::Xdotool => Arc::new(XdotoolInjector),
682            Backend::Wtype => Arc::new(WtypeInjector),
683            Backend::Ydotool => Arc::new(YdotoolInjector),
684            Backend::Osascript => Arc::new(OsascriptInjector),
685        }
686    }
687}
688
689/// Whether `name` resolves to a file on `$PATH`.
690#[must_use]
691pub fn tool_on_path(name: &str) -> bool {
692    std::env::var_os("PATH")
693        .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
694}
695
696/// Resolve the `auto` backend from the environment + available tools.
697#[must_use]
698pub fn resolve_auto() -> Backend {
699    resolve_auto_with(
700        cfg!(target_os = "macos"),
701        std::env::var_os("WAYLAND_DISPLAY").is_some(),
702        std::env::var_os("DISPLAY").is_some(),
703        &tool_on_path,
704    )
705}
706
707/// Pure core of [`resolve_auto`] (OS / display / tool-presence injected for
708/// testability): macOS -> osascript; Wayland -> wtype then ydotool; X11 ->
709/// xdotool; else the safe log backend.
710#[must_use]
711pub fn resolve_auto_with(
712    is_macos: bool,
713    wayland: bool,
714    x11: bool,
715    has: &dyn Fn(&str) -> bool,
716) -> Backend {
717    if is_macos {
718        return Backend::Osascript;
719    }
720    if wayland {
721        if has("wtype") {
722            return Backend::Wtype;
723        }
724        if has("ydotool") {
725            return Backend::Ydotool;
726        }
727    }
728    if x11 && has("xdotool") {
729        return Backend::Xdotool;
730    }
731    Backend::Log
732}
733
734// ---------------------------------------------------------------------------
735// daemon loop + local subscriber fan-out
736// ---------------------------------------------------------------------------
737
738/// Bounded, non-blocking fan-out of commands received by the daemon.
739///
740/// Every `omni listen` connection gets its own queue. A stalled subscriber can
741/// drop its own events, but can never delay OS injection or other subscribers.
742#[derive(Clone, Default)]
743pub struct CommandBus {
744    inner: Arc<Mutex<std::collections::HashMap<u64, SyncSender<InjectionCommand>>>>,
745    next_id: Arc<AtomicU64>,
746    events: crate::event_bus::EventBus,
747}
748
749impl CommandBus {
750    const SUBSCRIBER_BUFFER: usize = 256;
751
752    #[must_use]
753    pub fn new() -> Self {
754        Self::default()
755    }
756
757    fn subscribe(&self) -> (u64, Receiver<InjectionCommand>) {
758        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
759        let (tx, rx) = mpsc::sync_channel(Self::SUBSCRIBER_BUFFER);
760        self.inner.lock().unwrap().insert(id, tx);
761        (id, rx)
762    }
763
764    fn unsubscribe(&self, id: u64) {
765        self.inner.lock().unwrap().remove(&id);
766    }
767
768    /// Publish without blocking the command/injection path. Full queues drop
769    /// the newest event; disconnected subscribers are removed.
770    pub fn publish(&self, cmd: &InjectionCommand) {
771        self.inner.lock().unwrap().retain(|_, tx| {
772            !matches!(tx.try_send(cmd.clone()), Err(TrySendError::Disconnected(_)))
773        });
774    }
775
776    /// Non-injecting events use a separate opt-in subscriber set.
777    pub fn publish_event(&self, event: &SemanticEvent) -> usize {
778        self.events.publish(event)
779    }
780    #[must_use]
781    pub fn event_subscriber_count(&self) -> usize {
782        self.events.count()
783    }
784
785    /// Number of currently connected local command stream subscribers.
786    #[must_use]
787    pub fn subscriber_count(&self) -> usize {
788        self.inner.lock().unwrap().len()
789    }
790}
791
792/// Options for [`run_daemon`].
793#[derive(Clone)]
794pub struct DaemonOptions {
795    /// Shared token. When `Some`, `cast` frames must carry a matching token and
796    /// bare InjectionCommand lines are rejected. When `None`, both are accepted
797    /// (loopback / local-trust mode).
798    pub token: Option<String>,
799    /// Stop accepting after this many connections (for tests); `None` = forever.
800    pub max_conns: Option<usize>,
801}
802
803/// Run the daemon accept loop: each connection streams NDJSON; every received
804/// [`InjectionCommand`] (from a `cast` frame or a bare line) is replayed through
805/// `injector`. One thread per connection (mirrors `relay::serve`).
806///
807/// # Errors
808/// [`AppError::internal`] on a fatal accept error.
809pub fn run_daemon(
810    listener: &TcpListener,
811    injector: Arc<dyn Injector>,
812    opts: &DaemonOptions,
813) -> Result<(), AppError> {
814    run_daemon_with_bus(listener, injector, opts, &CommandBus::new())
815}
816
817/// Run the daemon with a caller-provided command bus. Relay-device mode uses
818/// this to share one bus between its outbound Helsinki connection and the local
819/// listener serving any number of `omni listen` clients.
820pub fn run_daemon_with_bus(
821    listener: &TcpListener,
822    injector: Arc<dyn Injector>,
823    opts: &DaemonOptions,
824    bus: &CommandBus,
825) -> Result<(), AppError> {
826    for (accepted, incoming) in listener.incoming().enumerate() {
827        let stream = incoming.map_err(|e| AppError::internal("daemon_accept", e.to_string()))?;
828        let injector = Arc::clone(&injector);
829        let token = opts.token.clone();
830        let bus = bus.clone();
831        thread::spawn(move || {
832            if let Err(error) = handle_conn(stream, injector.as_ref(), token.as_deref(), &bus) {
833                eprintln!("omni daemon: connection error: {error}");
834            }
835        });
836        if opts.max_conns.is_some_and(|max| accepted + 1 >= max) {
837            break;
838        }
839    }
840    Ok(())
841}
842
843fn token_matches(want: Option<&str>, got: &str) -> bool {
844    want.is_none_or(|want| want.trim() == got.trim())
845}
846
847fn serve_subscriber(
848    mut writer: TcpStream,
849    mut reader: BufReader<TcpStream>,
850    bus: &CommandBus,
851) -> Result<(), AppError> {
852    let (id, rx) = bus.subscribe();
853
854    // A subscriber is normally write-only after its handshake. Monitor its read
855    // half so an idle client that disconnects is removed immediately rather than
856    // retaining one thread + queue until the next command happens to be sent.
857    let monitor_bus = bus.clone();
858    let shutdown = writer
859        .try_clone()
860        .map_err(|e| AppError::internal("daemon_clone", e.to_string()))?;
861    let monitor = thread::spawn(move || {
862        let mut line = String::new();
863        loop {
864            line.clear();
865            match reader.read_line(&mut line) {
866                Ok(0) | Err(_) => break,
867                Ok(_) => {}
868            }
869        }
870        monitor_bus.unsubscribe(id);
871        let _ = shutdown.shutdown(Shutdown::Both);
872    });
873
874    for cmd in rx {
875        let line = cmd.json_line()?;
876        if writer
877            .write_all(line.as_bytes())
878            .and_then(|()| writer.write_all(b"\n"))
879            .and_then(|()| writer.flush())
880            .is_err()
881        {
882            break;
883        }
884    }
885    bus.unsubscribe(id);
886    let _ = writer.shutdown(Shutdown::Both);
887    let _ = monitor.join();
888    Ok(())
889}
890
891fn deliver_local(cmd: &InjectionCommand, injector: &dyn Injector, bus: &CommandBus) {
892    // Subscribers are a low-latency tap, independent of whether the OS
893    // injection backend succeeds.
894    bus.publish(cmd);
895    if let Err(error) = injector.inject(cmd) {
896        eprintln!("omni daemon: inject failed: {error}");
897    }
898}
899
900fn handle_conn(
901    stream: TcpStream,
902    injector: &dyn Injector,
903    token: Option<&str>,
904    bus: &CommandBus,
905) -> Result<(), AppError> {
906    // Loopback subscriptions are local-trust even when the daemon also carries
907    // OMNI_RELAY_TOKEN for its outbound relay registration. Remote subscribers
908    // must authenticate when a daemon token is configured.
909    let trusted_local = stream.peer_addr().is_ok_and(|peer| peer.ip().is_loopback());
910    let mut writer = stream
911        .try_clone()
912        .map_err(|e| AppError::internal("daemon_clone", e.to_string()))?;
913    let mut reader = BufReader::new(stream);
914    let mut first = String::new();
915
916    // Read the first non-blank line so we can switch the whole connection into
917    // subscription mode while retaining the read half for disconnect detection.
918    loop {
919        first.clear();
920        let n = reader
921            .read_line(&mut first)
922            .map_err(|e| AppError::internal("daemon_read", e.to_string()))?;
923        if n == 0 {
924            return Ok(());
925        }
926        if !first.trim().is_empty() {
927            break;
928        }
929    }
930
931    let subscription = match serde_json::from_str::<ControlFrame>(first.trim()) {
932        Ok(ControlFrame::Subscribe { token, .. }) => Some((token, false)),
933        Ok(ControlFrame::SubscribeEvents { token, .. }) => Some((token, true)),
934        _ => None,
935    };
936    if let Some((got, event_stream)) = subscription {
937        if !trusted_local && !token_matches(token, &got) {
938            let mut writer = writer;
939            let error = ControlFrame::Error {
940                v: WIRE_VERSION,
941                reason: "unauthorized".to_owned(),
942            }
943            .json_line()?;
944            let _ = writer
945                .write_all(error.as_bytes())
946                .and_then(|()| writer.write_all(b"\n"))
947                .and_then(|()| writer.flush());
948            return Ok(());
949        }
950        if event_stream {
951            return bus.events.serve(writer, reader);
952        }
953        return serve_subscriber(writer, reader, bus);
954    }
955
956    if !deliver_event_frame(first.trim(), token, bus, &mut writer)? {
957        if let Some(cmd) = decode_line(first.trim(), token) {
958            deliver_local(&cmd, injector, bus);
959        }
960    }
961    for line in reader.lines() {
962        let line = line.map_err(|e| AppError::internal("daemon_read", e.to_string()))?;
963        let trimmed = line.trim();
964        if trimmed.is_empty() {
965            continue;
966        }
967        if !deliver_event_frame(trimmed, token, bus, &mut writer)? {
968            if let Some(cmd) = decode_line(trimmed, token) {
969                deliver_local(&cmd, injector, bus);
970            }
971        }
972    }
973    Ok(())
974}
975
976/// Returns true only for a semantic event frame, handled WITHOUT an Injector.
977fn deliver_event_frame(
978    line: &str,
979    token: Option<&str>,
980    bus: &CommandBus,
981    writer: &mut TcpStream,
982) -> Result<bool, AppError> {
983    let (device, event, authorized) = match serde_json::from_str::<ControlFrame>(line) {
984        Ok(ControlFrame::EventCast {
985            v,
986            device,
987            event,
988            token: got,
989        }) => (
990            device,
991            event,
992            v == WIRE_VERSION && token_matches(token, &got),
993        ),
994        _ => match SemanticEvent::decode_line(line) {
995            Ok(event) => ("local".to_owned(), event, token.is_none()),
996            Err(_) => return Ok(false),
997        },
998    };
999    let response = if !authorized {
1000        ControlFrame::Error {
1001            v: WIRE_VERSION,
1002            reason: "unauthorized".into(),
1003        }
1004    } else if event.validate().is_err() {
1005        ControlFrame::Error {
1006            v: WIRE_VERSION,
1007            reason: "invalid_event".into(),
1008        }
1009    } else {
1010        ControlFrame::EventResult {
1011            v: WIRE_VERSION,
1012            device,
1013            delivered: bus.publish_event(&event),
1014        }
1015    }
1016    .json_line()?;
1017    writer
1018        .write_all(response.as_bytes())
1019        .and_then(|()| writer.write_all(b"\n"))
1020        .and_then(|()| writer.flush())
1021        .map_err(|e| AppError::internal("event_daemon_reply", e.to_string()))?;
1022    Ok(true)
1023}
1024
1025/// Decode one NDJSON line into a command to replay, honouring auth. Returns
1026/// `None` for control frames that are not casts, auth failures, or unparseable
1027/// lines (logged, never fatal).
1028fn decode_line(line: &str, token: Option<&str>) -> Option<InjectionCommand> {
1029    // Prefer a control frame (the relay/sender protocol).
1030    if let Ok(frame) = serde_json::from_str::<ControlFrame>(line) {
1031        return match frame {
1032            ControlFrame::Cast { cmd, token: t, .. } => match token {
1033                Some(want) if want.trim() != t.trim() => {
1034                    eprintln!("omni daemon: rejected cast (bad token)");
1035                    None
1036                }
1037                _ => Some(cmd),
1038            },
1039            // register/ping/pong are no-ops for a local replay daemon.
1040            _ => None,
1041        };
1042    }
1043    // Otherwise a bare InjectionCommand line — only when unauthenticated.
1044    if token.is_some() {
1045        eprintln!("omni daemon: rejected bare command (token required)");
1046        return None;
1047    }
1048    match InjectionCommand::decode_line(line) {
1049        Ok(cmd) => Some(cmd),
1050        Err(error) => {
1051            eprintln!("omni daemon: skipping unparseable line: {error}");
1052            None
1053        }
1054    }
1055}
1056
1057// ---------------------------------------------------------------------------
1058// relay-device mode (register to `omni serve`; NAT-traversal desktop delivery)
1059// ---------------------------------------------------------------------------
1060
1061/// Why a relay-device session ended.
1062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1063pub enum SessionEnd {
1064    /// The relay closed the connection (EOF) — reconnect.
1065    Closed,
1066    /// The relay rejected registration (e.g. bad token) — do NOT reconnect.
1067    Rejected,
1068}
1069
1070/// The platform string this build registers as (`linux` / `macos` / `windows`).
1071#[must_use]
1072pub fn current_platform() -> &'static str {
1073    if cfg!(target_os = "macos") {
1074        "macos"
1075    } else if cfg!(target_os = "windows") {
1076        "windows"
1077    } else {
1078        "linux"
1079    }
1080}
1081
1082/// Register to a relay as a DEVICE over `reader`/`writer`, then drain bare
1083/// [`InjectionCommand`] lines, replaying each via `injector`. One pass over a
1084/// connected stream (no reconnect); returns how the session ended.
1085///
1086/// # Errors
1087/// [`AppError::internal`] on a write/read I/O error.
1088pub fn relay_device_session<R: BufRead, W: Write>(
1089    reader: R,
1090    mut writer: W,
1091    device: &str,
1092    platform: &str,
1093    token: &str,
1094    injector: &dyn Injector,
1095) -> Result<SessionEnd, AppError> {
1096    let register = ControlFrame::Register {
1097        v: WIRE_VERSION,
1098        device: device.to_owned(),
1099        platform: platform.to_owned(),
1100        token: token.to_owned(),
1101    };
1102    let line = if injector.supports_events() {
1103        // Legacy hubs ignore this additive property; new hubs opt this daemon
1104        // into event delivery without changing the Register public wire type.
1105        let mut value = serde_json::to_value(&register)
1106            .map_err(|e| AppError::internal("event_register", e.to_string()))?;
1107        value["events"] = serde_json::Value::Bool(true);
1108        serde_json::to_string(&value)
1109            .map_err(|e| AppError::internal("event_register", e.to_string()))?
1110    } else {
1111        register.json_line()?
1112    };
1113    writer
1114        .write_all(line.as_bytes())
1115        .and_then(|()| writer.write_all(b"\n"))
1116        .and_then(|()| writer.flush())
1117        .map_err(|e| AppError::internal("relay_dev_register", e.to_string()))?;
1118
1119    for line in reader.lines() {
1120        let line = line.map_err(|e| AppError::internal("relay_dev_read", e.to_string()))?;
1121        let trimmed = line.trim();
1122        if trimmed.is_empty() {
1123            continue;
1124        }
1125        // The relay sends bare InjectionCommand lines to devices; a rejection
1126        // arrives as a typed ControlFrame::Error before the relay closes us.
1127        if let Ok(cmd) = InjectionCommand::decode_line(trimmed) {
1128            if let Err(error) = injector.inject(&cmd) {
1129                eprintln!("omni daemon: inject failed: {error}");
1130            }
1131        } else if let Ok(event) = SemanticEvent::decode_line(trimmed) {
1132            if injector.supports_events() {
1133                if let Err(error) = injector.semantic_event(&event) {
1134                    eprintln!("omni daemon: event delivery failed: {error}");
1135                }
1136            }
1137        } else if let Ok(frame) = serde_json::from_str::<ControlFrame>(trimmed) {
1138            match frame {
1139                ControlFrame::Error { reason, .. } => {
1140                    eprintln!("omni daemon: relay rejected registration: {reason}");
1141                    return Ok(SessionEnd::Rejected);
1142                }
1143                ControlFrame::Ping { .. } => {
1144                    // Symmetric keepalive robustness (mirrors the apple RelayClient
1145                    // fix): answer a relay-initiated ping with a pong so a device
1146                    // is never evicted if the hub adds app-level device keepalive.
1147                    // Best-effort: a failed pong write just means the next read
1148                    // ends the session, which the reconnect loop already handles.
1149                    let _ = writer
1150                        .write_all(br#"{"v":1,"op":"pong"}"#)
1151                        .and_then(|()| writer.write_all(b"\n"))
1152                        .and_then(|()| writer.flush());
1153                }
1154                // Ignore other non-command frames (e.g. a stray pong from the hub
1155                // answering a future device-initiated ping).
1156                _ => {}
1157            }
1158        }
1159    }
1160    Ok(SessionEnd::Closed)
1161}
1162
1163/// Options for [`run_relay_device`].
1164#[derive(Clone)]
1165pub struct RelayDeviceOptions {
1166    /// Device id to register as (senders target it via `--device`).
1167    pub device: String,
1168    /// Platform string (`linux` / `macos` / `windows`).
1169    pub platform: String,
1170    /// Shared token for the relay register frame.
1171    pub token: String,
1172    /// Stop after this many (re)connect attempts; `None` = run forever.
1173    pub max_attempts: Option<usize>,
1174    /// Delay between reconnect attempts.
1175    pub backoff: Duration,
1176}
1177
1178/// Run the relay-device loop: connect to `relay_addr`, register, drain + replay,
1179/// and reconnect-with-backoff on a clean drop. Stops (returns `Ok`) on a relay
1180/// rejection (token/config error) or when `max_attempts` is reached.
1181///
1182/// # Errors
1183/// [`AppError::internal`] only on an unexpected socket-clone failure; connect /
1184/// session failures are logged and retried.
1185pub fn run_relay_device(
1186    relay_addr: &str,
1187    injector: &dyn Injector,
1188    opts: &RelayDeviceOptions,
1189) -> Result<(), AppError> {
1190    let mut attempts = 0usize;
1191    loop {
1192        attempts += 1;
1193        match TcpStream::connect(relay_addr) {
1194            Ok(stream) => {
1195                let reader = match stream.try_clone() {
1196                    Ok(s) => BufReader::new(s),
1197                    Err(e) => {
1198                        return Err(AppError::internal("relay_dev_clone", e.to_string()));
1199                    }
1200                };
1201                match relay_device_session(
1202                    reader,
1203                    stream,
1204                    &opts.device,
1205                    &opts.platform,
1206                    &opts.token,
1207                    injector,
1208                ) {
1209                    Ok(SessionEnd::Rejected) => {
1210                        eprintln!(
1211                            "omni daemon: relay rejected registration — check --token; not reconnecting"
1212                        );
1213                        return Ok(());
1214                    }
1215                    Ok(SessionEnd::Closed) => {
1216                        eprintln!("omni daemon: relay closed connection; reconnecting");
1217                    }
1218                    Err(error) => {
1219                        eprintln!("omni daemon: relay session error: {error}; reconnecting");
1220                    }
1221                }
1222            }
1223            Err(e) => {
1224                eprintln!("omni daemon: relay connect to {relay_addr} failed: {e}; retrying");
1225            }
1226        }
1227        if let Some(max) = opts.max_attempts {
1228            if attempts >= max {
1229                return Ok(());
1230            }
1231        }
1232        thread::sleep(opts.backoff);
1233    }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239    use crate::transport::relay::{bind, cast_via_relay};
1240    use crate::wire::Kind;
1241    use std::net::TcpStream;
1242    use std::time::Duration;
1243
1244    #[test]
1245    fn backend_parse() {
1246        assert_eq!(Backend::parse("log").unwrap(), Backend::Log);
1247        assert_eq!(Backend::parse("xdotool").unwrap(), Backend::Xdotool);
1248        assert_eq!(Backend::parse("wtype").unwrap(), Backend::Wtype);
1249        assert_eq!(Backend::parse("ydotool").unwrap(), Backend::Ydotool);
1250        assert_eq!(Backend::parse("osascript").unwrap(), Backend::Osascript);
1251        assert_eq!(Backend::parse("nope").unwrap_err().code(), "daemon_backend");
1252    }
1253
1254    #[test]
1255    fn wtype_text_key_combo() {
1256        assert_eq!(
1257            wtype_argvs(&InjectionCommand::text("hi")).unwrap(),
1258            vec![vec!["wtype".to_owned(), "--".to_owned(), "hi".to_owned()]]
1259        );
1260        assert_eq!(
1261            wtype_argvs(&InjectionCommand::key("enter")).unwrap()[0],
1262            vec!["wtype".to_owned(), "-k".to_owned(), "Return".to_owned()]
1263        );
1264        assert_eq!(
1265            wtype_argvs(&InjectionCommand::combo("ctrl,a,g")).unwrap()[0],
1266            ["wtype", "-M", "ctrl", "-k", "a", "-k", "g", "-m", "ctrl"]
1267                .into_iter()
1268                .map(String::from)
1269                .collect::<Vec<_>>()
1270        );
1271        assert_eq!(
1272            wtype_argvs(&InjectionCommand::scroll(1))
1273                .unwrap_err()
1274                .code(),
1275            "inject_scroll_unsupported"
1276        );
1277    }
1278
1279    #[test]
1280    fn ydotool_evdev_mapping() {
1281        assert_eq!(evdev_code("enter"), Some(28));
1282        assert_eq!(evdev_code("a"), Some(30));
1283        assert_eq!(evdev_code("f13"), None);
1284        assert_eq!(
1285            ydotool_argvs(&InjectionCommand::key("enter")).unwrap()[0],
1286            ["ydotool", "key", "28:1", "28:0"]
1287                .into_iter()
1288                .map(String::from)
1289                .collect::<Vec<_>>()
1290        );
1291        assert_eq!(
1292            ydotool_argvs(&InjectionCommand::combo("ctrl,a")).unwrap()[0],
1293            ["ydotool", "key", "29:1", "30:1", "30:0", "29:0"]
1294                .into_iter()
1295                .map(String::from)
1296                .collect::<Vec<_>>()
1297        );
1298        assert_eq!(
1299            ydotool_argvs(&InjectionCommand::key("f13"))
1300                .unwrap_err()
1301                .code(),
1302            "inject_key_code"
1303        );
1304        assert_eq!(
1305            ydotool_argvs(&InjectionCommand::scroll(1))
1306                .unwrap_err()
1307                .code(),
1308            "inject_scroll_unsupported"
1309        );
1310    }
1311
1312    #[test]
1313    fn osascript_scripts() {
1314        let argv = osascript_argvs(&InjectionCommand::text("hello")).unwrap();
1315        assert_eq!(argv[0][0], "osascript");
1316        assert_eq!(argv[0][1], "-e");
1317        assert!(argv[0][2].contains("keystroke \"hello\""));
1318        assert!(
1319            osascript_argvs(&InjectionCommand::key("enter")).unwrap()[0][2].contains("key code 36")
1320        );
1321        assert!(
1322            osascript_argvs(&InjectionCommand::combo("cmd,a")).unwrap()[0][2]
1323                .contains("keystroke \"a\" using {command down}")
1324        );
1325        assert!(
1326            osascript_argvs(&InjectionCommand::combo("cmd,enter")).unwrap()[0][2]
1327                .contains("key code 36 using {command down}")
1328        );
1329        assert_eq!(
1330            osascript_argvs(&InjectionCommand::combo("ctrl,a,g"))
1331                .unwrap_err()
1332                .code(),
1333            "inject_combo_macos"
1334        );
1335        assert_eq!(
1336            osascript_argvs(&InjectionCommand::scroll(1))
1337                .unwrap_err()
1338                .code(),
1339            "inject_scroll_unsupported"
1340        );
1341    }
1342
1343    #[test]
1344    fn auto_backend_resolution() {
1345        let no = |_: &str| false;
1346        assert_eq!(
1347            resolve_auto_with(true, false, false, &no),
1348            Backend::Osascript
1349        );
1350        assert_eq!(
1351            resolve_auto_with(false, true, false, &|n: &str| n == "wtype"),
1352            Backend::Wtype
1353        );
1354        assert_eq!(
1355            resolve_auto_with(false, true, false, &|n: &str| n == "ydotool"),
1356            Backend::Ydotool
1357        );
1358        assert_eq!(
1359            resolve_auto_with(false, false, true, &|n: &str| n == "xdotool"),
1360            Backend::Xdotool
1361        );
1362        assert_eq!(resolve_auto_with(false, false, false, &no), Backend::Log);
1363        assert_eq!(resolve_auto_with(false, true, false, &no), Backend::Log);
1364    }
1365
1366    #[test]
1367    fn xdotool_text_argv() {
1368        let argvs = xdotool_argvs(&InjectionCommand::text("hello world")).unwrap();
1369        assert_eq!(
1370            argvs,
1371            vec![vec![
1372                "xdotool".to_owned(),
1373                "type".to_owned(),
1374                "--clearmodifiers".to_owned(),
1375                "--".to_owned(),
1376                "hello world".to_owned(),
1377            ]]
1378        );
1379    }
1380
1381    #[test]
1382    fn xdotool_key_maps_semantic_names() {
1383        let argvs = xdotool_argvs(&InjectionCommand::key("enter")).unwrap();
1384        assert_eq!(argvs[0].last().unwrap(), "Return");
1385        let argvs = xdotool_argvs(&InjectionCommand::key("esc")).unwrap();
1386        assert_eq!(argvs[0].last().unwrap(), "Escape");
1387        // unknown -> passthrough
1388        let argvs = xdotool_argvs(&InjectionCommand::key("F5")).unwrap();
1389        assert_eq!(argvs[0].last().unwrap(), "F5");
1390    }
1391
1392    #[test]
1393    fn xdotool_combo_chord() {
1394        let argvs = xdotool_argvs(&InjectionCommand::combo("ctrl,a,g")).unwrap();
1395        assert_eq!(argvs[0].last().unwrap(), "ctrl+a+g");
1396        // modifier aliases + semantic key inside a combo
1397        let argvs = xdotool_argvs(&InjectionCommand::combo("cmd,shift,enter")).unwrap();
1398        assert_eq!(argvs[0].last().unwrap(), "super+shift+Return");
1399    }
1400
1401    #[test]
1402    fn xdotool_scroll_direction_and_repeat() {
1403        let down = xdotool_argvs(&InjectionCommand::scroll(3)).unwrap();
1404        assert_eq!(
1405            down,
1406            vec![vec![
1407                "xdotool".to_owned(),
1408                "click".to_owned(),
1409                "--repeat".to_owned(),
1410                "3".to_owned(),
1411                "5".to_owned(),
1412            ]]
1413        );
1414        let up = xdotool_argvs(&InjectionCommand::scroll(-2)).unwrap();
1415        assert_eq!(up[0].last().unwrap(), "4");
1416        assert_eq!(up[0][3], "2");
1417        // zero -> no invocation
1418        assert!(
1419            xdotool_argvs(&InjectionCommand::scroll(0))
1420                .unwrap()
1421                .is_empty()
1422        );
1423    }
1424
1425    #[test]
1426    fn capture_injector_records() {
1427        let inj = CaptureInjector::new();
1428        inj.inject(&InjectionCommand::text("hi")).unwrap();
1429        inj.inject(&InjectionCommand::key("enter")).unwrap();
1430        assert_eq!(
1431            inj.captured(),
1432            vec![InjectionCommand::text("hi"), InjectionCommand::key("enter"),]
1433        );
1434        assert_eq!(inj.captured()[0].kind, Kind::Text);
1435    }
1436
1437    #[test]
1438    fn command_bus_fans_out_to_multiple_subscribers() {
1439        let bus = CommandBus::new();
1440        let (_a_id, a) = bus.subscribe();
1441        let (_b_id, b) = bus.subscribe();
1442        let cmd = InjectionCommand::key("enter");
1443        bus.publish(&cmd);
1444        assert_eq!(a.recv_timeout(Duration::from_millis(50)).unwrap(), cmd);
1445        assert_eq!(b.recv_timeout(Duration::from_millis(50)).unwrap(), cmd);
1446        assert_eq!(bus.subscriber_count(), 2);
1447    }
1448
1449    #[test]
1450    fn decode_line_auth_rules() {
1451        let cast = ControlFrame::Cast {
1452            v: 1,
1453            device: "local".into(),
1454            cmd: InjectionCommand::text("hi"),
1455            token: "secret".into(),
1456        }
1457        .json_line()
1458        .unwrap();
1459        // matching token
1460        assert_eq!(
1461            decode_line(&cast, Some("secret")),
1462            Some(InjectionCommand::text("hi"))
1463        );
1464        // bad token
1465        assert_eq!(decode_line(&cast, Some("nope")), None);
1466        // no token configured -> accept
1467        assert_eq!(decode_line(&cast, None), Some(InjectionCommand::text("hi")));
1468        // bare line accepted only when unauthenticated
1469        let bare = InjectionCommand::scroll(2).json_line().unwrap();
1470        assert_eq!(decode_line(&bare, None), Some(InjectionCommand::scroll(2)));
1471        assert_eq!(decode_line(&bare, Some("secret")), None);
1472    }
1473
1474    #[test]
1475    fn decode_line_trims_token_whitespace() {
1476        // A cast token carrying a trailing newline (the sops-file footgun) still
1477        // matches a clean configured token, and vice versa (bd-790088).
1478        let cast = ControlFrame::Cast {
1479            v: 1,
1480            device: "local".into(),
1481            cmd: InjectionCommand::text("hi"),
1482            token: "secret\n".into(),
1483        }
1484        .json_line()
1485        .unwrap();
1486        assert_eq!(
1487            decode_line(&cast, Some("secret")),
1488            Some(InjectionCommand::text("hi"))
1489        );
1490        let clean = ControlFrame::Cast {
1491            v: 1,
1492            device: "local".into(),
1493            cmd: InjectionCommand::text("hi"),
1494            token: "secret".into(),
1495        }
1496        .json_line()
1497        .unwrap();
1498        assert_eq!(
1499            decode_line(&clean, Some("secret\n")),
1500            Some(InjectionCommand::text("hi"))
1501        );
1502    }
1503
1504    #[test]
1505    fn end_to_end_daemon_replays_cast() {
1506        let listener = bind("127.0.0.1:0").unwrap();
1507        let addr = listener.local_addr().unwrap();
1508        let inj = CaptureInjector::new();
1509        let inj_for_daemon: Arc<dyn Injector> = Arc::new(inj.clone());
1510        let opts = DaemonOptions {
1511            token: Some("tok".into()),
1512            max_conns: Some(1),
1513        };
1514        thread::spawn(move || {
1515            let _ = run_daemon(&listener, inj_for_daemon, &opts);
1516        });
1517
1518        // Client casts two commands over one persistent connection.
1519        cast_via_relay(
1520            addr,
1521            "tok",
1522            "local",
1523            &[
1524                InjectionCommand::text("to desktop"),
1525                InjectionCommand::key("enter"),
1526            ],
1527        )
1528        .unwrap();
1529
1530        // Wait for the daemon thread to replay them.
1531        let mut tries = 0;
1532        while inj.captured().len() < 2 && tries < 50 {
1533            thread::sleep(Duration::from_millis(20));
1534            tries += 1;
1535        }
1536        assert_eq!(
1537            inj.captured(),
1538            vec![
1539                InjectionCommand::text("to desktop"),
1540                InjectionCommand::key("enter"),
1541            ]
1542        );
1543    }
1544
1545    #[test]
1546    fn end_to_end_daemon_rejects_bad_token() {
1547        let listener = bind("127.0.0.1:0").unwrap();
1548        let addr = listener.local_addr().unwrap();
1549        let inj = CaptureInjector::new();
1550        let inj_for_daemon: Arc<dyn Injector> = Arc::new(inj.clone());
1551        let opts = DaemonOptions {
1552            token: Some("right".into()),
1553            max_conns: Some(1),
1554        };
1555        thread::spawn(move || {
1556            let _ = run_daemon(&listener, inj_for_daemon, &opts);
1557        });
1558
1559        let _ = cast_via_relay(addr, "wrong", "local", &[InjectionCommand::text("nope")]);
1560        thread::sleep(Duration::from_millis(150));
1561        assert!(
1562            inj.captured().is_empty(),
1563            "bad-token cast must not be replayed"
1564        );
1565
1566        // close the connection so the daemon's max_conns loop can exit
1567        let _ = TcpStream::connect(addr);
1568    }
1569
1570    #[test]
1571    fn platform_string_is_known() {
1572        assert!(["linux", "macos", "windows"].contains(&current_platform()));
1573    }
1574
1575    #[test]
1576    fn relay_device_registers_and_replays() {
1577        let listener = bind("127.0.0.1:0").unwrap();
1578        let addr = listener.local_addr().unwrap();
1579        let opts = crate::transport::relay::ServeOptions {
1580            token: "tok".into(),
1581            max_conns: Some(2),
1582        };
1583        thread::spawn(move || {
1584            let _ = crate::transport::relay::serve(&listener, &opts);
1585        });
1586
1587        // The desktop daemon registers as a device and drains commands.
1588        let inj = CaptureInjector::new();
1589        let inj_for_device = inj.clone();
1590        let dev = TcpStream::connect(addr).unwrap();
1591        let reader = std::io::BufReader::new(dev.try_clone().unwrap());
1592        thread::spawn(move || {
1593            let _ = relay_device_session(reader, dev, "desktop", "linux", "tok", &inj_for_device);
1594        });
1595
1596        thread::sleep(Duration::from_millis(100)); // let the device register
1597        cast_via_relay(
1598            addr,
1599            "tok",
1600            "desktop",
1601            &[
1602                InjectionCommand::text("via relay"),
1603                InjectionCommand::key("enter"),
1604            ],
1605        )
1606        .unwrap();
1607
1608        let mut tries = 0;
1609        while inj.captured().len() < 2 && tries < 50 {
1610            thread::sleep(Duration::from_millis(20));
1611            tries += 1;
1612        }
1613        assert_eq!(
1614            inj.captured(),
1615            vec![
1616                InjectionCommand::text("via relay"),
1617                InjectionCommand::key("enter"),
1618            ]
1619        );
1620    }
1621
1622    #[test]
1623    fn relay_device_session_rejects_bad_token() {
1624        let listener = bind("127.0.0.1:0").unwrap();
1625        let addr = listener.local_addr().unwrap();
1626        let opts = crate::transport::relay::ServeOptions {
1627            token: "right".into(),
1628            max_conns: Some(1),
1629        };
1630        thread::spawn(move || {
1631            let _ = crate::transport::relay::serve(&listener, &opts);
1632        });
1633
1634        let dev = TcpStream::connect(addr).unwrap();
1635        let reader = std::io::BufReader::new(dev.try_clone().unwrap());
1636        let inj = CaptureInjector::new();
1637        let end = relay_device_session(reader, dev, "d", "linux", "wrong", &inj).unwrap();
1638        assert_eq!(end, SessionEnd::Rejected);
1639        assert!(inj.captured().is_empty());
1640    }
1641
1642    #[test]
1643    fn relay_device_session_answers_ping_with_pong() {
1644        // Symmetric keepalive robustness: a relay-initiated ping must be answered
1645        // with a pong so the device is never evicted. In-memory transport, so the
1646        // assertion is purely on the device's outbound bytes.
1647        let incoming = b"{\"v\":1,\"op\":\"ping\"}\n";
1648        let reader = std::io::BufReader::new(&incoming[..]);
1649        let mut writer: Vec<u8> = Vec::new();
1650        let inj = CaptureInjector::new();
1651        let end = relay_device_session(reader, &mut writer, "dev", "linux", "tok", &inj).unwrap();
1652        assert_eq!(end, SessionEnd::Closed);
1653        let sent = String::from_utf8(writer).unwrap();
1654        // Register frame first, then the pong answer to the ping.
1655        assert!(
1656            sent.contains(r#""op":"register""#),
1657            "register frame missing: {sent}"
1658        );
1659        assert!(
1660            sent.contains(r#""op":"pong""#),
1661            "pong answer missing: {sent}"
1662        );
1663        // A ping is a control frame, not a command, so nothing is injected.
1664        assert!(inj.captured().is_empty());
1665    }
1666}