1use schemars::JsonSchema;
2use serde::Deserialize;
3
4use std::collections::HashMap;
5
6use crate::{
7 AutomataError, Browser, Desktop, Element, SelectorPath, ShadowDom, action::sub_output,
8 output::Output,
9};
10
11#[derive(Debug, Clone, Deserialize, JsonSchema)]
15pub struct TextMatch {
16 pub exact: Option<String>,
17 pub contains: Option<String>,
18 pub starts_with: Option<String>,
19 pub regex: Option<String>,
21 #[serde(default)]
22 pub non_empty: bool,
23}
24
25impl TextMatch {
26 pub fn exact(s: impl Into<String>) -> Self {
27 Self {
28 exact: Some(s.into()),
29 contains: None,
30 starts_with: None,
31 regex: None,
32 non_empty: false,
33 }
34 }
35 pub fn contains(s: impl Into<String>) -> Self {
36 Self {
37 exact: None,
38 contains: Some(s.into()),
39 starts_with: None,
40 regex: None,
41 non_empty: false,
42 }
43 }
44 pub fn non_empty() -> Self {
45 Self {
46 exact: None,
47 contains: None,
48 starts_with: None,
49 regex: None,
50 non_empty: true,
51 }
52 }
53
54 pub fn test(&self, s: &str) -> bool {
55 if let Some(v) = &self.exact {
56 return s == v;
57 }
58 if let Some(v) = &self.contains {
59 return s.contains(v.as_str());
60 }
61 if let Some(v) = &self.starts_with {
62 return s.starts_with(v.as_str());
63 }
64 if let Some(v) = &self.regex {
65 return fancy_regex::Regex::new(v)
66 .ok()
67 .and_then(|re| re.is_match(s).ok())
68 .unwrap_or(false);
69 }
70 if self.non_empty {
71 return !s.is_empty();
72 }
73 false
74 }
75}
76
77#[derive(Debug, Clone, Deserialize, JsonSchema)]
79pub struct TitleMatch {
80 pub exact: Option<String>,
81 pub contains: Option<String>,
82 pub starts_with: Option<String>,
83}
84
85impl TitleMatch {
86 pub fn exact(s: impl Into<String>) -> Self {
87 Self {
88 exact: Some(s.into()),
89 contains: None,
90 starts_with: None,
91 }
92 }
93 pub fn contains(s: impl Into<String>) -> Self {
94 Self {
95 exact: None,
96 contains: Some(s.into()),
97 starts_with: None,
98 }
99 }
100 pub fn starts_with(s: impl Into<String>) -> Self {
101 Self {
102 exact: None,
103 contains: None,
104 starts_with: Some(s.into()),
105 }
106 }
107
108 pub fn test(&self, s: &str) -> bool {
109 if let Some(v) = &self.exact {
110 return s == v;
111 }
112 if let Some(v) = &self.contains {
113 return s.contains(v.as_str());
114 }
115 if let Some(v) = &self.starts_with {
116 return s.starts_with(v.as_str());
117 }
118 false
119 }
120}
121
122pub const EXEC_EXIT_CODE_KEY: &str = "__exec_exit_code__";
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
130#[serde(rename_all = "snake_case")]
131pub enum WindowState {
132 Active,
134 Visible,
136}
137
138#[derive(Debug, Clone, Deserialize)]
144#[serde(try_from = "serde_yaml::Value")]
145pub enum Condition {
146 ElementFound {
147 scope: String,
148 selector: SelectorPath,
149 },
150 ElementEnabled {
151 scope: String,
152 selector: SelectorPath,
153 },
154 ElementVisible {
155 scope: String,
156 selector: SelectorPath,
157 },
158 ElementHasText {
159 scope: String,
160 selector: SelectorPath,
161 pattern: TextMatch,
162 },
163 ElementHasChildren {
164 scope: String,
165 selector: SelectorPath,
166 },
167
168 WindowWithAttribute {
175 title: Option<TitleMatch>,
176 automation_id: Option<String>,
177 pid: Option<u32>,
178 process: Option<String>,
179 },
180
181 ProcessRunning {
185 process: String,
186 },
187 WindowClosed {
192 anchor: String,
193 },
194 WindowWithState {
196 anchor: String,
197 state: WindowState,
198 },
199 DialogPresent {
200 scope: String,
201 },
202 DialogAbsent {
203 scope: String,
204 },
205
206 ForegroundIsDialog {
207 scope: String,
208 title: Option<TitleMatch>,
209 },
210
211 FileExists {
214 path: String,
215 },
216
217 Always,
220
221 ExecSucceeded,
224
225 EvalCondition {
229 expr: String,
230 },
231
232 TabWithAttribute {
238 scope: String,
239 title: Option<TextMatch>,
240 url: Option<TextMatch>,
241 },
242
243 TabWithState {
247 scope: String,
248 expr: String,
249 },
250
251 AllOf {
252 conditions: Vec<Condition>,
253 },
254 AnyOf {
255 conditions: Vec<Condition>,
256 },
257 Not {
258 condition: Box<Condition>,
259 },
260}
261
262impl TryFrom<serde_yaml::Value> for Condition {
265 type Error = String;
266
267 fn try_from(v: serde_yaml::Value) -> Result<Self, String> {
268 let map = v.as_mapping().ok_or("Condition must be a YAML mapping")?;
269
270 let type_str = map
271 .get("type")
272 .and_then(|v| v.as_str())
273 .ok_or("Condition missing string field 'type'")?;
274
275 let str_field = |key: &str| -> Option<String> {
276 map.get(key).and_then(|v| v.as_str()).map(String::from)
277 };
278 let req_str = |key: &str| -> Result<String, String> {
279 str_field(key).ok_or_else(|| format!("Condition '{type_str}' missing '{key}'"))
280 };
281 let req_selector = |key: &str| -> Result<SelectorPath, String> {
282 let s = req_str(key)?;
283 SelectorPath::parse(&s).map_err(|e| e.to_string())
284 };
285
286 match type_str {
287 "ElementFound" => Ok(Condition::ElementFound {
288 scope: req_str("scope")?,
289 selector: req_selector("selector")?,
290 }),
291 "ElementEnabled" => Ok(Condition::ElementEnabled {
292 scope: req_str("scope")?,
293 selector: req_selector("selector")?,
294 }),
295 "ElementVisible" => Ok(Condition::ElementVisible {
296 scope: req_str("scope")?,
297 selector: req_selector("selector")?,
298 }),
299 "ElementHasText" => {
300 let pattern_val = map
301 .get("pattern")
302 .ok_or("ElementHasText missing 'pattern'")?;
303 let pattern: TextMatch = serde_yaml::from_value(pattern_val.clone())
304 .map_err(|e| format!("ElementHasText.pattern: {e}"))?;
305 Ok(Condition::ElementHasText {
306 scope: req_str("scope")?,
307 selector: req_selector("selector")?,
308 pattern,
309 })
310 }
311 "ElementHasChildren" => Ok(Condition::ElementHasChildren {
312 scope: req_str("scope")?,
313 selector: req_selector("selector")?,
314 }),
315 "WindowWithAttribute" => {
316 let title: Option<TitleMatch> = map
317 .get("title")
318 .and_then(|v| serde_yaml::from_value(v.clone()).ok());
319 let automation_id = str_field("automation_id");
320 let pid = map.get("pid").and_then(|v| v.as_u64()).map(|v| v as u32);
321 if title.is_none() && automation_id.is_none() && pid.is_none() {
322 return Err(
323 "WindowWithAttribute requires at least one of: title, automation_id, pid"
324 .into(),
325 );
326 }
327 Ok(Condition::WindowWithAttribute {
328 title,
329 automation_id,
330 pid,
331 process: str_field("process"),
332 })
333 }
334 "ProcessRunning" => Ok(Condition::ProcessRunning {
335 process: req_str("process")?,
336 }),
337 "WindowClosed" => Ok(Condition::WindowClosed {
338 anchor: req_str("anchor")?,
339 }),
340 "WindowWithState" => {
341 let anchor = req_str("anchor")?;
342 let state_str = req_str("state")?;
343 let state = match state_str.as_str() {
344 "active" => WindowState::Active,
345 "visible" => WindowState::Visible,
346 other => return Err(format!("unknown WindowState '{other}'")),
347 };
348 Ok(Condition::WindowWithState { anchor, state })
349 }
350 "DialogPresent" => Ok(Condition::DialogPresent {
351 scope: req_str("scope")?,
352 }),
353 "DialogAbsent" => Ok(Condition::DialogAbsent {
354 scope: req_str("scope")?,
355 }),
356 "ForegroundIsDialog" => {
357 let title = if let Some(t) = map.get("title") {
358 Some(
359 serde_yaml::from_value(t.clone())
360 .map_err(|e| format!("ForegroundIsDialog.title: {e}"))?,
361 )
362 } else {
363 None
364 };
365 Ok(Condition::ForegroundIsDialog {
366 scope: req_str("scope")?,
367 title,
368 })
369 }
370 "FileExists" => Ok(Condition::FileExists {
371 path: req_str("path")?,
372 }),
373 "AllOf" => {
374 let conditions = parse_condition_list(map, "conditions", type_str)?;
375 Ok(Condition::AllOf { conditions })
376 }
377 "AnyOf" => {
378 let conditions = parse_condition_list(map, "conditions", type_str)?;
379 Ok(Condition::AnyOf { conditions })
380 }
381 "Not" => {
382 let inner_val = map
383 .get("condition")
384 .ok_or("Not missing 'condition'")?
385 .clone();
386 let condition = Box::new(Condition::try_from(inner_val)?);
387 Ok(Condition::Not { condition })
388 }
389 "TabWithAttribute" => {
390 let title: Option<TextMatch> = map
391 .get("title")
392 .and_then(|v| serde_yaml::from_value(v.clone()).ok());
393 let url: Option<TextMatch> = map
394 .get("url")
395 .and_then(|v| serde_yaml::from_value(v.clone()).ok());
396 if title.is_none() && url.is_none() {
397 return Err("TabWithAttribute requires at least one of: title, url".into());
398 }
399 Ok(Condition::TabWithAttribute {
400 scope: req_str("scope")?,
401 title,
402 url,
403 })
404 }
405 "TabWithState" => Ok(Condition::TabWithState {
406 scope: req_str("scope")?,
407 expr: req_str("expr")?,
408 }),
409 "Always" => Ok(Condition::Always),
410 "ExecSucceeded" => Ok(Condition::ExecSucceeded),
411 "EvalCondition" => {
412 let expr = map
413 .get("expr")
414 .and_then(|v| v.as_str())
415 .ok_or("EvalCondition missing 'expr'")?
416 .to_string();
417 Ok(Condition::EvalCondition { expr })
418 }
419 other => Err(format!("unknown Condition type '{other}'")),
420 }
421 }
422}
423
424fn parse_condition_list(
425 map: &serde_yaml::Mapping,
426 key: &str,
427 type_str: &str,
428) -> Result<Vec<Condition>, String> {
429 let seq = map
430 .get(key)
431 .and_then(|v| v.as_sequence())
432 .ok_or_else(|| format!("{type_str} missing sequence field '{key}'"))?;
433 seq.iter().map(|v| Condition::try_from(v.clone())).collect()
434}
435
436impl Condition {
439 pub fn apply_output(&self, locals: &HashMap<String, String>, output: &Output) -> Self {
441 let sub = |s: &str| sub_output(s, locals, output);
442 let sub_tm = |tm: &TextMatch| TextMatch {
443 exact: tm.exact.as_deref().map(|s| sub(s)),
444 contains: tm.contains.as_deref().map(|s| sub(s)),
445 starts_with: tm.starts_with.as_deref().map(|s| sub(s)),
446 regex: tm.regex.clone(),
447 non_empty: tm.non_empty,
448 };
449 match self {
450 Condition::ElementHasText {
451 scope,
452 selector,
453 pattern,
454 } => Condition::ElementHasText {
455 scope: scope.clone(),
456 selector: selector.clone(),
457 pattern: sub_tm(pattern),
458 },
459 Condition::AllOf { conditions } => Condition::AllOf {
460 conditions: conditions
461 .iter()
462 .map(|c| c.apply_output(locals, output))
463 .collect(),
464 },
465 Condition::AnyOf { conditions } => Condition::AnyOf {
466 conditions: conditions
467 .iter()
468 .map(|c| c.apply_output(locals, output))
469 .collect(),
470 },
471 Condition::FileExists { path } => Condition::FileExists { path: sub(path) },
472 Condition::Not { condition } => Condition::Not {
473 condition: Box::new(condition.apply_output(locals, output)),
474 },
475 Condition::TabWithAttribute { scope, title, url } => Condition::TabWithAttribute {
476 scope: scope.clone(),
477 title: title.as_ref().map(|t| sub_tm(t)),
478 url: url.as_ref().map(|u| sub_tm(u)),
479 },
480 Condition::TabWithState { scope, expr } => Condition::TabWithState {
481 scope: scope.clone(),
482 expr: sub(expr),
483 },
484 _ => self.clone(),
485 }
486 }
487
488 pub fn scope_name(&self) -> Option<&str> {
489 match self {
490 Condition::ElementFound { scope, .. }
491 | Condition::ElementEnabled { scope, .. }
492 | Condition::ElementVisible { scope, .. }
493 | Condition::ElementHasText { scope, .. }
494 | Condition::ElementHasChildren { scope, .. }
495 | Condition::DialogPresent { scope }
496 | Condition::DialogAbsent { scope }
497 | Condition::ForegroundIsDialog { scope, .. } => Some(scope),
498 _ => None,
499 }
500 }
501
502 pub fn describe(&self) -> String {
503 match self {
504 Condition::ElementFound { scope, selector } => {
505 format!("ElementFound({scope}:{selector})")
506 }
507 Condition::ElementEnabled { scope, selector } => {
508 format!("ElementEnabled({scope}:{selector})")
509 }
510 Condition::ElementVisible { scope, selector } => {
511 format!("ElementVisible({scope}:{selector})")
512 }
513 Condition::ElementHasText {
514 scope, selector, ..
515 } => {
516 format!("ElementHasText({scope}:{selector})")
517 }
518 Condition::ElementHasChildren { scope, selector } => {
519 format!("ElementHasChildren({scope}:{selector})")
520 }
521 Condition::WindowWithAttribute {
522 title,
523 automation_id,
524 pid,
525 process,
526 } => {
527 let mut parts = Vec::new();
528 if let Some(t) = title {
529 parts.push(format!("{t:?}"));
530 }
531 if let Some(aid) = automation_id {
532 parts.push(format!("automation_id={aid}"));
533 }
534 if let Some(p) = pid {
535 parts.push(format!("pid={p}"));
536 }
537 if let Some(p) = process {
538 parts.push(format!("process={p}"));
539 }
540 format!("WindowWithAttribute({})", parts.join(", "))
541 }
542 Condition::ProcessRunning { process } => format!("ProcessRunning({process})"),
543 Condition::WindowClosed { anchor } => format!("WindowClosed({anchor})"),
544 Condition::WindowWithState { anchor, state } => {
545 format!("WindowWithState({anchor}:{state:?})")
546 }
547 Condition::DialogPresent { scope } => format!("DialogPresent({scope})"),
548 Condition::DialogAbsent { scope } => format!("DialogAbsent({scope})"),
549 Condition::ForegroundIsDialog { scope, .. } => {
550 format!("ForegroundIsDialog({scope})")
551 }
552 Condition::Always => "Always".to_string(),
553 Condition::ExecSucceeded => "ExecSucceeded".to_string(),
554 Condition::AllOf { conditions } => format!(
555 "AllOf({})",
556 conditions
557 .iter()
558 .map(|c| c.describe())
559 .collect::<Vec<_>>()
560 .join(", ")
561 ),
562 Condition::AnyOf { conditions } => format!(
563 "AnyOf({})",
564 conditions
565 .iter()
566 .map(|c| c.describe())
567 .collect::<Vec<_>>()
568 .join(", ")
569 ),
570 Condition::FileExists { path } => format!("FileExists({path})"),
571 Condition::Not { condition } => format!("Not({})", condition.describe()),
572 Condition::EvalCondition { expr } => format!("EvalCondition({expr:?})"),
573 Condition::TabWithAttribute { scope, .. } => format!("TabWithAttribute({scope})"),
574 Condition::TabWithState { scope, expr } => {
575 format!("TabWithState({scope}: {expr:?})")
576 }
577 }
578 }
579
580 pub fn evaluate<D: Desktop>(
581 &self,
582 dom: &mut ShadowDom<D>,
583 desktop: &D,
584 locals: &std::collections::HashMap<String, String>,
585 params: &std::collections::HashMap<String, String>,
586 output: &crate::Output,
587 ) -> Result<bool, AutomataError> {
588 match self {
589 Condition::ElementFound { scope, selector } => {
590 Ok(find_in_scope(dom, desktop, scope, selector)?.is_some())
591 }
592 Condition::ElementEnabled { scope, selector } => {
593 Ok(find_in_scope(dom, desktop, scope, selector)?
594 .and_then(|el| el.is_enabled().ok())
595 .unwrap_or(false))
596 }
597 Condition::ElementVisible { scope, selector } => {
598 Ok(find_in_scope(dom, desktop, scope, selector)?
599 .and_then(|el| el.is_visible().ok())
600 .unwrap_or(false))
601 }
602 Condition::ElementHasText {
603 scope,
604 selector,
605 pattern,
606 } => Ok(find_in_scope(dom, desktop, scope, selector)?
607 .and_then(|el| el.text().ok())
608 .map(|t| pattern.test(&t))
609 .unwrap_or(false)),
610 Condition::ElementHasChildren { scope, selector } => {
611 Ok(find_in_scope(dom, desktop, scope, selector)?
612 .and_then(|el| el.children().ok())
613 .map(|ch| !ch.is_empty())
614 .unwrap_or(false))
615 }
616 Condition::WindowWithAttribute {
617 title,
618 automation_id,
619 pid,
620 process,
621 } => {
622 let proc_filter = process.as_deref().map(|s| s.to_lowercase());
623 Ok(desktop
624 .application_windows()
625 .unwrap_or_default()
626 .iter()
627 .filter(|w| {
628 proc_filter.as_deref().map_or(true, |pf| {
629 w.process_name()
630 .map(|n| n.to_lowercase() == pf)
631 .unwrap_or(false)
632 })
633 })
634 .any(|w| {
635 let title_ok = title
636 .as_ref()
637 .map_or(true, |t| w.name().map(|n| t.test(&n)).unwrap_or(false));
638 let aid_ok = automation_id
639 .as_ref()
640 .map_or(true, |aid| w.automation_id().as_deref() == Some(aid));
641 let pid_ok =
642 pid.map_or(true, |p| w.process_id().map_or(false, |wp| wp == p));
643 title_ok && aid_ok && pid_ok
644 }))
645 }
646 Condition::ProcessRunning { process } => {
647 let target = process.to_lowercase();
648 Ok(desktop
649 .application_windows()
650 .unwrap_or_default()
651 .iter()
652 .any(|w| {
653 w.process_name()
654 .map(|n| n.to_lowercase() == target)
655 .unwrap_or(false)
656 }))
657 }
658 Condition::WindowClosed { anchor } => {
659 let windows = desktop.application_windows().unwrap_or_default();
660 if let Some(hwnd) = dom.anchor_hwnd(anchor) {
661 Ok(!windows.iter().any(|w| w.hwnd() == Some(hwnd)))
663 } else if let Some(pid) = dom.anchor_pid(anchor) {
664 Ok(!windows
667 .iter()
668 .any(|w| w.process_id().map_or(false, |p| p == pid)))
669 } else {
670 Ok(dom.get(anchor, desktop).is_err())
672 }
673 }
674 Condition::WindowWithState { anchor, state } => {
675 let el = match dom.get(anchor, desktop).ok().cloned() {
676 Some(e) => e,
677 None => return Ok(false),
678 };
679 Ok(match state {
680 WindowState::Active => {
681 let fg = match desktop.foreground_window() {
682 Some(w) => w,
683 None => return Ok(false),
684 };
685 el.process_id().unwrap_or(0) != 0
686 && el.process_id().ok() == fg.process_id().ok()
687 }
688 WindowState::Visible => el.is_visible().unwrap_or(false),
689 })
690 }
691 Condition::DialogPresent { scope } => has_dialog_child(dom, desktop, scope),
692 Condition::DialogAbsent { scope } => Ok(!has_dialog_child(dom, desktop, scope)?),
693 Condition::ForegroundIsDialog { scope: _, title } => {
694 let fg = match desktop.foreground_window() {
695 Some(w) => w,
696 None => return Ok(false),
697 };
698 if fg.role() != "dialog" {
699 return Ok(false);
700 }
701 if let Some(tm) = title {
702 if !tm.test(&fg.name().unwrap_or_default()) {
703 return Ok(false);
704 }
705 }
706 Ok(true)
707 }
708 Condition::AllOf { conditions } => {
709 for c in conditions {
710 if !c.evaluate(dom, desktop, locals, params, output)? {
711 return Ok(false);
712 }
713 }
714 Ok(true)
715 }
716 Condition::AnyOf { conditions } => {
717 for c in conditions {
718 if c.evaluate(dom, desktop, locals, params, output)? {
719 return Ok(true);
720 }
721 }
722 Ok(false)
723 }
724 Condition::Always => Ok(true),
725 Condition::ExecSucceeded => {
726 Ok(locals.get(EXEC_EXIT_CODE_KEY).map(String::as_str) == Some("0"))
727 }
728 Condition::FileExists { path } => Ok(std::path::Path::new(path).exists()),
729 Condition::Not { condition } => {
730 Ok(!condition.evaluate(dom, desktop, locals, params, output)?)
731 }
732 Condition::EvalCondition { expr } => {
733 crate::expression::eval_bool_expr(expr, locals, params, output)
734 .map_err(|e| AutomataError::Internal(format!("EvalCondition: {e}")))
735 }
736 Condition::TabWithAttribute { scope, title, url } => {
737 let tab_id = match dom.tab_handle(scope) {
738 Some(h) => h.tab_id.clone(),
739 None => return Ok(false),
740 };
741 let info = desktop
742 .browser()
743 .tab_info(&tab_id)
744 .map_err(|e| AutomataError::Internal(format!("tab_info: {e}")))?;
745 let title_ok = title.as_ref().map_or(true, |t| t.test(&info.title));
746 let url_ok = url.as_ref().map_or(true, |u| u.test(&info.url));
747 Ok(title_ok && url_ok)
748 }
749 Condition::TabWithState { scope, expr } => {
750 let tab_id = match dom.tab_handle(scope) {
751 Some(h) => h.tab_id.clone(),
752 None => return Ok(false),
753 };
754 let result = desktop
755 .browser()
756 .eval(&tab_id, expr)
757 .map_err(|e| AutomataError::Internal(format!("TabWithState eval: {e}")))?;
758 Ok(result.trim() == "true")
759 }
760 }
761 }
762}
763
764fn find_in_scope<D: Desktop>(
767 dom: &mut ShadowDom<D>,
768 desktop: &D,
769 scope: &str,
770 selector: &SelectorPath,
771) -> Result<Option<D::Elem>, AutomataError> {
772 dom.find_descendant(scope, selector, desktop)
773}
774
775fn has_dialog_child<D: Desktop>(
776 dom: &mut ShadowDom<D>,
777 desktop: &D,
778 scope: &str,
779) -> Result<bool, AutomataError> {
780 let root = match dom.get(scope, desktop).ok().cloned() {
781 Some(el) => el,
782 None => return Ok(false),
783 };
784 Ok(root
785 .children()
786 .unwrap_or_default()
787 .iter()
788 .any(|c| c.role() == "dialog"))
789}