1pub mod cli;
17pub mod diag;
18#[cfg(feature = "recorder")]
19pub mod record;
20#[cfg(feature = "recorder")]
21pub mod replay;
22
23use std::io::Write;
24use std::time::Duration;
25
26use regex::Regex;
27use tracing_subscriber::EnvFilter;
28
29use wdotool_core::detector::Environment;
30use wdotool_core::keysym;
31use wdotool_core::{
32 Backend, KeyDirection, MouseButton, Result, WdoError, WindowGeometry, WindowId, WindowInfo,
33};
34
35pub use cli::{Cli, Command};
36
37pub struct DispatchCtx<'a> {
41 pub backend: &'a dyn Backend,
42 pub env: &'a Environment,
43 pub stdout: &'a mut dyn Write,
44 pub stderr: &'a mut dyn Write,
45}
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54#[must_use]
55pub struct ExitCode(pub i32);
56
57impl ExitCode {
58 pub const SUCCESS: ExitCode = ExitCode(0);
59 pub const FAILURE: ExitCode = ExitCode(1);
60
61 pub fn is_success(self) -> bool {
62 self.0 == 0
63 }
64}
65
66pub async fn dispatch(ctx: &mut DispatchCtx<'_>, cmd: Command) -> Result<ExitCode> {
70 match cmd {
71 Command::Capabilities => {
72 let value = wdotool_core::capabilities::report_json(ctx.env, ctx.backend);
73 let pretty = serde_json::to_string_pretty(&value)
74 .map_err(|e| WdoError::InvalidArg(format!("capabilities serialization: {e}")))?;
75 writeln!(ctx.stdout, "{pretty}").map_err(io_err)?;
76 }
77 Command::Info => {
78 let caps = ctx.backend.capabilities();
79 writeln!(ctx.stdout, "backend: {}", ctx.backend.name()).map_err(io_err)?;
80 writeln!(ctx.stdout, "desktop: {:?}", ctx.env.desktop).map_err(io_err)?;
81 writeln!(ctx.stdout, "session: {:?}", ctx.env.session_type).map_err(io_err)?;
82 writeln!(ctx.stdout, "display: {:?}", ctx.env.wayland_display).map_err(io_err)?;
83 writeln!(ctx.stdout, "hints: {:?}", ctx.env.compositor_hints).map_err(io_err)?;
84 writeln!(ctx.stdout, "wayland: {}", ctx.env.is_wayland()).map_err(io_err)?;
85 writeln!(ctx.stdout, "capabilities:").map_err(io_err)?;
86 writeln!(ctx.stdout, " key_input: {}", caps.key_input).map_err(io_err)?;
87 writeln!(ctx.stdout, " text_input: {}", caps.text_input).map_err(io_err)?;
88 writeln!(
89 ctx.stdout,
90 " pointer_move_absolute: {}",
91 caps.pointer_move_absolute
92 )
93 .map_err(io_err)?;
94 writeln!(
95 ctx.stdout,
96 " pointer_move_relative: {}",
97 caps.pointer_move_relative
98 )
99 .map_err(io_err)?;
100 writeln!(
101 ctx.stdout,
102 " pointer_button: {}",
103 caps.pointer_button
104 )
105 .map_err(io_err)?;
106 writeln!(ctx.stdout, " scroll: {}", caps.scroll).map_err(io_err)?;
107 writeln!(ctx.stdout, " list_windows: {}", caps.list_windows)
108 .map_err(io_err)?;
109 writeln!(
110 ctx.stdout,
111 " active_window: {}",
112 caps.active_window
113 )
114 .map_err(io_err)?;
115 writeln!(
116 ctx.stdout,
117 " activate_window: {}",
118 caps.activate_window
119 )
120 .map_err(io_err)?;
121 writeln!(ctx.stdout, " close_window: {}", caps.close_window)
122 .map_err(io_err)?;
123 writeln!(
124 ctx.stdout,
125 " pointer_position: {}",
126 caps.pointer_position
127 )
128 .map_err(io_err)?;
129 writeln!(ctx.stdout, " list_outputs: {}", caps.list_outputs)
130 .map_err(io_err)?;
131 writeln!(
132 ctx.stdout,
133 " window_geometry: {}",
134 caps.window_geometry
135 )
136 .map_err(io_err)?;
137 }
138 Command::Key {
139 clearmodifiers,
140 chain,
141 } => {
142 if clearmodifiers {
143 clear_modifiers(ctx.backend).await;
144 }
145 run_key(ctx.backend, &chain, KeyDirection::PressRelease).await?;
146 }
147 Command::Keydown {
148 clearmodifiers,
149 chain,
150 } => {
151 if clearmodifiers {
152 clear_modifiers(ctx.backend).await;
153 }
154 run_key(ctx.backend, &chain, KeyDirection::Press).await?;
155 }
156 Command::Keyup {
157 clearmodifiers,
158 chain,
159 } => {
160 if clearmodifiers {
161 clear_modifiers(ctx.backend).await;
162 }
163 run_key(ctx.backend, &chain, KeyDirection::Release).await?;
164 }
165 Command::Type {
166 delay,
167 file,
168 clearmodifiers,
169 text,
170 } => {
171 let resolved = resolve_type_input(file, text)?;
172 if clearmodifiers {
173 clear_modifiers(ctx.backend).await;
174 }
175 ctx.backend
176 .type_text(&resolved, Duration::from_millis(delay))
177 .await?;
178 }
179 Command::Mousemove {
180 relative,
181 output,
182 x,
183 y,
184 } => {
185 match output {
193 Some(name) => ctx.backend.mouse_move_to_output(&name, x, y).await?,
194 None => ctx.backend.mouse_move(x, y, !relative).await?,
195 }
196 }
197 Command::Click { button } => {
198 ctx.backend
199 .mouse_button(MouseButton::from_index(button), KeyDirection::PressRelease)
200 .await?;
201 }
202 Command::Mousedown { button } => {
203 ctx.backend
204 .mouse_button(MouseButton::from_index(button), KeyDirection::Press)
205 .await?;
206 }
207 Command::Mouseup { button } => {
208 ctx.backend
209 .mouse_button(MouseButton::from_index(button), KeyDirection::Release)
210 .await?;
211 }
212 Command::Scroll { dx, dy } => {
213 ctx.backend.scroll(dx, dy).await?;
214 }
215 Command::Search {
216 name,
217 class,
218 pid,
219 regex,
220 ignore_case,
221 any,
222 all: _,
223 } => {
224 let windows = ctx.backend.list_windows().await?;
225 let filters = SearchFilters::compile(SearchFlags {
226 name: name.as_deref(),
227 class: class.as_deref(),
228 pid,
229 regex,
230 ignore_case,
231 any,
232 })?;
233 let mut matched = false;
234 for w in windows.iter().filter(|w| filters.matches(w)) {
235 writeln!(ctx.stdout, "{}\t{}", w.id, w.title).map_err(io_err)?;
236 matched = true;
237 }
238 if !matched {
241 return Ok(ExitCode::FAILURE);
242 }
243 }
244 Command::Getactivewindow => match ctx.backend.active_window().await? {
245 Some(w) => writeln!(ctx.stdout, "{}", w.id).map_err(io_err)?,
246 None => return Err(WdoError::WindowNotFound("active".into())),
247 },
248 Command::Outputs { json } => {
249 let outputs = ctx.backend.list_outputs().await?;
250 if json {
251 let value = serde_json::to_value(
252 outputs
253 .iter()
254 .map(|o| {
255 serde_json::json!({
256 "name": o.name,
257 "x": o.x,
258 "y": o.y,
259 "width": o.width,
260 "height": o.height,
261 "scale": o.scale,
262 })
263 })
264 .collect::<Vec<_>>(),
265 )
266 .map_err(|e| WdoError::InvalidArg(format!("outputs serialization: {e}")))?;
267 let pretty = serde_json::to_string_pretty(&value)
268 .map_err(|e| WdoError::InvalidArg(format!("outputs serialization: {e}")))?;
269 writeln!(ctx.stdout, "{pretty}").map_err(io_err)?;
270 } else {
271 writeln!(ctx.stdout, "name\tx\ty\twidth\theight\tscale").map_err(io_err)?;
272 for o in &outputs {
273 writeln!(
274 ctx.stdout,
275 "{}\t{}\t{}\t{}\t{}\t{}",
276 o.name, o.x, o.y, o.width, o.height, o.scale
277 )
278 .map_err(io_err)?;
279 }
280 }
281 }
282 Command::Getmouselocation => match ctx.backend.pointer_position().await? {
283 Some((x, y)) => writeln!(ctx.stdout, "x:{x} y:{y}").map_err(io_err)?,
284 None => {
285 writeln!(
286 ctx.stderr,
287 "wdotool: pointer position is unreadable on the {} backend (Wayland \
288 virtual-pointer protocols are send-only). Use the kde or gnome backend, \
289 or your compositor's IPC (hyprctl cursorpos, swaymsg get_seats).",
290 ctx.backend.name()
291 )
292 .map_err(io_err)?;
293 return Ok(ExitCode::FAILURE);
294 }
295 },
296 Command::Windowactivate { id } => ctx.backend.activate_window(&WindowId(id)).await?,
297 Command::Windowclose { id } => ctx.backend.close_window(&WindowId(id)).await?,
298 Command::Getwindowname { id } => {
299 let w = find_window(ctx.backend, &id).await?;
300 writeln!(ctx.stdout, "{}", w.title).map_err(io_err)?;
301 }
302 Command::Getwindowpid { id } => {
303 let w = find_window(ctx.backend, &id).await?;
304 match w.pid {
305 Some(pid) => writeln!(ctx.stdout, "{pid}").map_err(io_err)?,
306 None => {
307 writeln!(ctx.stderr, "wdotool: pid not available for window {id}")
308 .map_err(io_err)?;
309 return Ok(ExitCode::FAILURE);
310 }
311 }
312 }
313 Command::Getwindowclassname { id } => {
314 let w = find_window(ctx.backend, &id).await?;
315 match w.app_id {
316 Some(app_id) => writeln!(ctx.stdout, "{app_id}").map_err(io_err)?,
317 None => {
318 writeln!(
319 ctx.stderr,
320 "wdotool: classname (app_id) not available for window {id}"
321 )
322 .map_err(io_err)?;
323 return Ok(ExitCode::FAILURE);
324 }
325 }
326 }
327 Command::Getwindowgeometry { id } => {
328 match ctx.backend.window_geometry(&WindowId(id.clone())).await? {
336 Some(WindowGeometry {
337 x,
338 y,
339 width,
340 height,
341 }) => {
342 writeln!(ctx.stdout, "Window {id}").map_err(io_err)?;
347 writeln!(ctx.stdout, " Position: {x},{y}").map_err(io_err)?;
348 writeln!(ctx.stdout, " Geometry: {width}x{height}").map_err(io_err)?;
349 }
350 None => {
351 writeln!(
352 ctx.stderr,
353 "wdotool: window geometry is unreadable on the {} backend (no Wayland \
354 protocol exposes window geometry to other clients). Use the kde or \
355 gnome backend.",
356 ctx.backend.name()
357 )
358 .map_err(io_err)?;
359 return Ok(ExitCode::FAILURE);
360 }
361 }
362 }
363 Command::Diag { .. } => {
364 unreachable!("Diag short-circuits before dispatch");
367 }
368 #[cfg(feature = "recorder")]
369 Command::Record { .. } => {
370 unreachable!("Record short-circuits before dispatch");
373 }
374 Command::Prime => {
375 unreachable!("Prime short-circuits before dispatch");
379 }
380 #[cfg(feature = "recorder")]
381 Command::Replay { file, speed } => {
382 replay::run(ctx.backend, &file, speed).await?;
383 }
384 }
385 Ok(ExitCode::SUCCESS)
386}
387
388fn io_err(e: std::io::Error) -> WdoError {
389 WdoError::InvalidArg(format!("write failed: {e}"))
394}
395
396async fn find_window(backend: &dyn Backend, id: &str) -> Result<WindowInfo> {
401 let windows = backend.list_windows().await?;
402 windows
403 .into_iter()
404 .find(|w| w.id.0 == id)
405 .ok_or_else(|| WdoError::WindowNotFound(id.to_string()))
406}
407
408async fn clear_modifiers(backend: &dyn Backend) {
414 const STANDARD_MODIFIERS: &[&str] = &[
415 "Control_L",
416 "Control_R",
417 "Shift_L",
418 "Shift_R",
419 "Alt_L",
420 "Alt_R",
421 "Super_L",
422 "Super_R",
423 "ISO_Level3_Shift",
424 ];
425 for sym in STANDARD_MODIFIERS {
426 let _ = backend.key(sym, KeyDirection::Release).await;
427 }
428}
429
430fn resolve_type_input(file: Option<String>, text: Option<String>) -> Result<String> {
434 use std::io::Read;
435 match (file, text) {
436 (Some(path), _) => {
437 if path == "-" {
438 let mut buf = String::new();
439 std::io::stdin()
440 .read_to_string(&mut buf)
441 .map_err(|e| WdoError::InvalidArg(format!("failed to read stdin: {e}")))?;
442 Ok(buf)
443 } else {
444 std::fs::read_to_string(&path)
445 .map_err(|e| WdoError::InvalidArg(format!("failed to read {path}: {e}")))
446 }
447 }
448 (None, Some(t)) => Ok(t),
449 (None, None) => Err(WdoError::InvalidArg(
450 "type requires either --file <path> or a positional text argument".into(),
451 )),
452 }
453}
454
455pub(crate) async fn run_key(backend: &dyn Backend, chain: &str, dir: KeyDirection) -> Result<()> {
458 let parsed = keysym::parse_chain(chain)?;
459 match dir {
460 KeyDirection::Press => {
461 for m in &parsed.modifiers {
462 backend.key(m, KeyDirection::Press).await?;
463 }
464 backend.key(&parsed.key, KeyDirection::Press).await?;
465 }
466 KeyDirection::Release => {
467 backend.key(&parsed.key, KeyDirection::Release).await?;
468 for m in parsed.modifiers.iter().rev() {
469 backend.key(m, KeyDirection::Release).await?;
470 }
471 }
472 KeyDirection::PressRelease => {
473 for m in &parsed.modifiers {
474 backend.key(m, KeyDirection::Press).await?;
475 }
476 backend.key(&parsed.key, KeyDirection::PressRelease).await?;
477 for m in parsed.modifiers.iter().rev() {
478 backend.key(m, KeyDirection::Release).await?;
479 }
480 }
481 }
482 Ok(())
483}
484
485pub fn init_tracing(verbose: bool) {
486 let default = if verbose {
487 "wdotool=debug"
488 } else {
489 "wdotool=info,warn"
490 };
491 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
492 let _ = tracing_subscriber::fmt()
493 .with_env_filter(filter)
494 .with_target(false)
495 .with_writer(std::io::stderr)
496 .try_init();
497}
498
499pub struct SearchFlags<'a> {
502 pub name: Option<&'a str>,
503 pub class: Option<&'a str>,
504 pub pid: Option<u32>,
505 pub regex: bool,
506 pub ignore_case: bool,
507 pub any: bool,
508}
509
510pub struct SearchFilters {
514 name: Option<Regex>,
515 class: Option<Regex>,
516 pid: Option<u32>,
517 any: bool,
519}
520
521impl SearchFilters {
522 pub fn compile(flags: SearchFlags<'_>) -> Result<Self> {
523 let make_regex = |pat: &str, field: &'static str| -> Result<Regex> {
524 let body = if flags.regex {
528 pat.to_string()
529 } else {
530 regex::escape(pat)
531 };
532 let full = if flags.ignore_case {
533 format!("(?i){body}")
534 } else {
535 body
536 };
537 Regex::new(&full)
538 .map_err(|e| WdoError::InvalidArg(format!("invalid {field} pattern {pat:?}: {e}")))
539 };
540 Ok(Self {
541 name: flags.name.map(|p| make_regex(p, "--name")).transpose()?,
542 class: flags.class.map(|p| make_regex(p, "--class")).transpose()?,
543 pid: flags.pid,
544 any: flags.any,
545 })
546 }
547
548 pub fn matches(&self, w: &WindowInfo) -> bool {
549 if self.any {
550 self.matches_any(w)
551 } else {
552 self.matches_all(w)
553 }
554 }
555
556 fn matches_all(&self, w: &WindowInfo) -> bool {
558 if let Some(re) = &self.name {
559 if !re.is_match(&w.title) {
560 return false;
561 }
562 }
563 if let Some(re) = &self.class {
564 match w.app_id.as_deref() {
568 Some(a) if re.is_match(a) => {}
569 _ => return false,
570 }
571 }
572 if let Some(p) = self.pid {
573 if w.pid != Some(p) {
574 return false;
575 }
576 }
577 true
578 }
579
580 fn matches_any(&self, w: &WindowInfo) -> bool {
585 let any_set = self.name.is_some() || self.class.is_some() || self.pid.is_some();
586 if !any_set {
587 return true;
588 }
589 if let Some(re) = &self.name {
590 if re.is_match(&w.title) {
591 return true;
592 }
593 }
594 if let Some(re) = &self.class {
595 if let Some(a) = w.app_id.as_deref() {
596 if re.is_match(a) {
597 return true;
598 }
599 }
600 }
601 if let Some(p) = self.pid {
602 if w.pid == Some(p) {
603 return true;
604 }
605 }
606 false
607 }
608}
609
610#[cfg(test)]
611mod search_tests {
612 use super::*;
613 use wdotool_core::WindowId;
614
615 fn win(id: &str, title: &str, app_id: Option<&str>, pid: Option<u32>) -> WindowInfo {
616 WindowInfo {
617 id: WindowId(id.into()),
618 title: title.into(),
619 app_id: app_id.map(str::to_string),
620 pid,
621 }
622 }
623
624 fn flags<'a>(
625 name: Option<&'a str>,
626 class: Option<&'a str>,
627 pid: Option<u32>,
628 ) -> SearchFlags<'a> {
629 SearchFlags {
630 name,
631 class,
632 pid,
633 regex: false,
634 ignore_case: false,
635 any: false,
636 }
637 }
638
639 #[test]
640 fn substring_name_match_is_default() {
641 let f = SearchFilters::compile(flags(Some("fox"), None, None)).unwrap();
642 assert!(f.matches(&win("1", "Firefox", None, None)));
643 assert!(!f.matches(&win("2", "Chromium", None, None)));
644 }
645
646 #[test]
647 fn dot_in_pattern_is_escaped_without_regex_flag() {
648 let f = SearchFilters::compile(flags(Some("Fire.fox"), None, None)).unwrap();
651 assert!(!f.matches(&win("1", "Firefox", None, None)));
652 assert!(f.matches(&win("2", "Fire.fox Browser", None, None)));
653 }
654
655 #[test]
656 fn regex_flag_enables_pattern_semantics() {
657 let f = SearchFilters::compile(SearchFlags {
658 name: Some("Fire.*x"),
659 class: None,
660 pid: None,
661 regex: true,
662 ignore_case: false,
663 any: false,
664 })
665 .unwrap();
666 assert!(f.matches(&win("1", "Firefox", None, None)));
667 assert!(!f.matches(&win("2", "Chromium", None, None)));
668 }
669
670 #[test]
671 fn ignore_case_works_in_substring_mode() {
672 let f = SearchFilters::compile(SearchFlags {
673 name: Some("FIREFOX"),
674 class: None,
675 pid: None,
676 regex: false,
677 ignore_case: true,
678 any: false,
679 })
680 .unwrap();
681 assert!(f.matches(&win("1", "Mozilla Firefox", None, None)));
682 }
683
684 #[test]
685 fn ignore_case_works_in_regex_mode() {
686 let f = SearchFilters::compile(SearchFlags {
687 name: Some("FIRE.*X"),
688 class: None,
689 pid: None,
690 regex: true,
691 ignore_case: true,
692 any: false,
693 })
694 .unwrap();
695 assert!(f.matches(&win("1", "Mozilla Firefox", None, None)));
696 }
697
698 #[test]
699 fn class_filter_matches_app_id() {
700 let f = SearchFilters::compile(flags(None, Some("firefox"), None)).unwrap();
701 assert!(f.matches(&win("1", "Some Page", Some("org.mozilla.firefox"), None)));
702 assert!(!f.matches(&win("2", "kitty", Some("kitty"), None)));
703 }
704
705 #[test]
706 fn class_filter_skips_windows_without_app_id() {
707 let f = SearchFilters::compile(flags(None, Some("anything"), None)).unwrap();
708 assert!(!f.matches(&win("1", "Some Page", None, None)));
711 }
712
713 #[test]
714 fn pid_filter_requires_exact_match() {
715 let f = SearchFilters::compile(flags(None, None, Some(1234))).unwrap();
716 assert!(f.matches(&win("1", "Firefox", None, Some(1234))));
717 assert!(!f.matches(&win("2", "Firefox", None, Some(5678))));
718 assert!(!f.matches(&win("3", "Firefox", None, None)));
720 }
721
722 #[test]
723 fn filters_are_anded_together() {
724 let f =
725 SearchFilters::compile(flags(Some("Firefox"), Some("mozilla"), Some(1234))).unwrap();
726 assert!(f.matches(&win(
727 "1",
728 "Firefox - Wikipedia",
729 Some("org.mozilla.firefox"),
730 Some(1234)
731 )));
732 assert!(!f.matches(&win(
734 "2",
735 "Firefox - Wikipedia",
736 Some("org.mozilla.firefox"),
737 Some(5678)
738 )));
739 }
740
741 #[test]
742 fn no_filters_matches_everything() {
743 let f = SearchFilters::compile(flags(None, None, None)).unwrap();
744 assert!(f.matches(&win("1", "Anything", None, None)));
745 assert!(f.matches(&win("2", "Else", Some("kitty"), Some(99))));
746 }
747
748 fn flags_any<'a>(
749 name: Option<&'a str>,
750 class: Option<&'a str>,
751 pid: Option<u32>,
752 ) -> SearchFlags<'a> {
753 SearchFlags {
754 name,
755 class,
756 pid,
757 regex: false,
758 ignore_case: false,
759 any: true,
760 }
761 }
762
763 #[test]
764 fn any_matches_when_only_name_matches() {
765 let f = SearchFilters::compile(flags_any(Some("Firefox"), Some("nope"), Some(99))).unwrap();
766 assert!(f.matches(&win(
769 "1",
770 "Firefox - Wikipedia",
771 Some("org.mozilla.firefox"),
772 Some(1234)
773 )));
774 }
775
776 #[test]
777 fn any_matches_when_only_class_matches() {
778 let f = SearchFilters::compile(flags_any(Some("nope"), Some("firefox"), Some(99))).unwrap();
779 assert!(f.matches(&win(
780 "1",
781 "Wikipedia",
782 Some("org.mozilla.firefox"),
783 Some(1234)
784 )));
785 }
786
787 #[test]
788 fn any_matches_when_only_pid_matches() {
789 let f = SearchFilters::compile(flags_any(Some("nope"), Some("nope"), Some(1234))).unwrap();
790 assert!(f.matches(&win(
791 "1",
792 "Wikipedia",
793 Some("org.mozilla.firefox"),
794 Some(1234)
795 )));
796 }
797
798 #[test]
799 fn any_rejects_when_no_filter_matches() {
800 let f = SearchFilters::compile(flags_any(Some("nope"), Some("nope"), Some(99))).unwrap();
801 assert!(!f.matches(&win(
802 "1",
803 "Wikipedia",
804 Some("org.mozilla.firefox"),
805 Some(1234)
806 )));
807 }
808
809 #[test]
810 fn any_with_no_filters_matches_everything() {
811 let f = SearchFilters::compile(flags_any(None, None, None)).unwrap();
814 assert!(f.matches(&win("1", "Anything", None, None)));
815 assert!(f.matches(&win("2", "Else", Some("kitty"), Some(99))));
816 }
817
818 #[test]
819 fn any_with_class_filter_skips_window_without_app_id() {
820 let f = SearchFilters::compile(flags_any(None, Some("anything"), None)).unwrap();
823 assert!(!f.matches(&win("1", "Some Page", None, None)));
824 }
825
826 #[test]
827 fn invalid_regex_pattern_returns_invalid_arg() {
828 let result = SearchFilters::compile(SearchFlags {
829 name: Some("[unclosed"),
830 class: None,
831 pid: None,
832 regex: true,
833 ignore_case: false,
834 any: false,
835 });
836 match result {
837 Err(WdoError::InvalidArg(msg)) => assert!(msg.contains("--name")),
838 Err(other) => panic!("expected InvalidArg, got {other:?}"),
839 Ok(_) => panic!("expected InvalidArg, got Ok"),
840 }
841 }
842}