1use 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
29pub trait Injector: Send + Sync {
35 fn inject(&self, cmd: &InjectionCommand) -> Result<(), AppError>;
38 fn label(&self) -> &str;
40 fn semantic_event(&self, _event: &SemanticEvent) -> Result<(), AppError> {
42 Ok(())
43 }
44 fn supports_events(&self) -> bool {
45 false
46 }
47}
48
49#[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#[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 #[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
92pub 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 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#[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
142fn 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#[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
184fn 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
196pub 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 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#[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
287fn 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
298pub 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#[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#[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
438pub 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#[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#[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
562pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub enum Backend {
645 Log,
647 Xdotool,
649 Wtype,
651 Ydotool,
653 Osascript,
655}
656
657impl Backend {
658 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 #[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#[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#[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#[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#[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 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 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 #[must_use]
787 pub fn subscriber_count(&self) -> usize {
788 self.inner.lock().unwrap().len()
789 }
790}
791
792#[derive(Clone)]
794pub struct DaemonOptions {
795 pub token: Option<String>,
799 pub max_conns: Option<usize>,
801}
802
803pub 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
817pub 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 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 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 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 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
976fn 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
1025fn decode_line(line: &str, token: Option<&str>) -> Option<InjectionCommand> {
1029 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 _ => None,
1041 };
1042 }
1043 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1063pub enum SessionEnd {
1064 Closed,
1066 Rejected,
1068}
1069
1070#[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
1082pub 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 let mut value = serde_json::to_value(®ister)
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 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 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 _ => {}
1157 }
1158 }
1159 }
1160 Ok(SessionEnd::Closed)
1161}
1162
1163#[derive(Clone)]
1165pub struct RelayDeviceOptions {
1166 pub device: String,
1168 pub platform: String,
1170 pub token: String,
1172 pub max_attempts: Option<usize>,
1174 pub backoff: Duration,
1176}
1177
1178pub 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 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 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 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 assert_eq!(
1461 decode_line(&cast, Some("secret")),
1462 Some(InjectionCommand::text("hi"))
1463 );
1464 assert_eq!(decode_line(&cast, Some("nope")), None);
1466 assert_eq!(decode_line(&cast, None), Some(InjectionCommand::text("hi")));
1468 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 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 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 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 let _ = TcpStream::connect(addr);
1568 }
1569
1570 #[test]
1571 fn platform_string_is_known() {
1572 assert!(["linux", "macos", "windows"].contains(¤t_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 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)); 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 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 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 assert!(inj.captured().is_empty());
1665 }
1666}