Skip to main content

rightkit_qa/
control.rs

1//! Drive the real Tauri app through the in-app `rightkit-qa-driver` server.
2//!
3//! The app is launched hidden or backgrounded with `RIGHTKIT_CONTROL_SERVICE` and an
4//! isolated `RIGHTKIT_SUITE_ROOT`, discovers the `rightkit-daemon` record
5//! (`service.json`), and talks over the current-user-only socket / named pipe as the
6//! allowlisted app `right-qa`. No TCP and no shared secrets. The harness never searches for
7//! processes by name to kill them: it records the pid it started and kills that
8//! tree only.
9use crate::process::{is_alive, kill_tree, Tracker};
10use crate::util::{err, new_id, sleep_ms, Error, Result};
11use crate::workspace::QaWorkspace;
12use rightkit_child_process::OwnedChild;
13#[cfg(not(target_os = "macos"))]
14use rightkit_child_process::OwnedCommand;
15use rightkit_daemon::{Client, ServiceError, Suite};
16use serde_json::{json, Value};
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20#[cfg(not(target_os = "macos"))]
21use std::process::Stdio;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Mode {
27    /// Window never shown (`open -g -j -n` on macOS, hidden window on Windows).
28    Hidden,
29    /// Window shown but the app is not activated and frontmost is unchanged.
30    Background,
31    Visible,
32}
33
34impl Mode {
35    pub fn parse(s: &str) -> Result<Self> {
36        match s {
37            "hidden" => Ok(Mode::Hidden),
38            "background" => Ok(Mode::Background),
39            "visible" => Ok(Mode::Visible),
40            other => err(format!(
41                "unknown ui mode '{other}' (hidden|background|visible)"
42            )),
43        }
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct LaunchSpec {
49    /// A `.app` bundle, or a plain executable (wrapped into a throwaway bundle on macOS).
50    pub binary: PathBuf,
51    pub mode: Mode,
52    pub env: Vec<(String, String)>,
53    pub startup_timeout: Duration,
54    pub label: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct StopReport {
59    pub pid: u32,
60    pub endpoint_closed: bool,
61    pub process_gone: bool,
62    pub frontmost_before: Option<String>,
63    pub frontmost_after: Option<String>,
64    pub frontmost_unchanged: bool,
65    /// Every distinct foreground state sampled during the session (empty when unmeasurable).
66    pub frontmost_states: Vec<String>,
67    /// Every sampled foreground PID belonged to no app process started by this control session.
68    /// This is the background invariant; `frontmost_unchanged` remains diagnostic only.
69    /// macOS checks the started pid and every process under the bundle executable; Windows
70    /// checks the started pid only (the owner of the foreground window, from
71    /// `GetForegroundWindow` + `GetWindowThreadProcessId`, sampled every 20 ms). Both fail
72    /// closed when a foreground owner exists but cannot be read. Other targets always report
73    /// `false` for a non-visible launch.
74    pub owned_never_frontmost: bool,
75    /// Owned fixture PIDs observed in foreground, if any.
76    pub owned_frontmost_pids: Vec<u32>,
77    /// Foreground samples whose owner pid could not be read (macOS: the frontmost application;
78    /// Windows: the foreground window's owner). Any such sample makes `owned_never_frontmost`
79    /// false: an unread owner proves nothing.
80    pub frontmost_pid_unavailable_samples: usize,
81    /// Why `owned_never_frontmost` is false, in words that tell "an owned app WAS frontmost"
82    /// from "the frontmost pid could not be read" and from "the owned executable was never
83    /// registered". `None` when the guard holds or was not enforced.
84    pub owned_never_frontmost_failure: Option<String>,
85}
86
87impl StopReport {
88    /// The first sampled foreground name that belongs to a system permission-dialog agent.
89    /// Positive evidence only: `None` does not prove no dialog appeared.
90    pub fn permission_dialog(&self) -> Option<String> {
91        crate::userdata::permission_dialog_in(&self.frontmost_states)
92    }
93}
94
95pub struct Control {
96    pub pid: u32,
97    /// `rightkit-daemon` name the app serves (`control-xxxxxxxx`).
98    pub service: String,
99    pub log_path: PathBuf,
100    raw_log: PathBuf,
101    suite: Suite,
102    endpoint: String,
103    client: Mutex<Option<Arc<Client>>>,
104    child: Option<OwnedChild>,
105    tracker: Tracker,
106    frontmost_before: Option<String>,
107    focus: Option<FocusMonitor>,
108    request_timeout: Duration,
109    worker: Mutex<Option<Worker>>,
110    /// Gap between native input events asked of the app (`paceMs`); 0 sends none.
111    pace_ms: u64,
112    stopped: bool,
113}
114
115const APP: &str = "right-qa";
116
117fn svc_err(e: ServiceError) -> Error {
118    Error(e.to_string())
119}
120
121enum CallError {
122    TimedOut,
123    Service(ServiceError),
124}
125
126/// Run `f` on a helper thread and give up after `timeout`; the thread is left to finish
127/// on its own (it ends when the app is killed or the connection is aborted).
128fn bounded<T: Send + 'static>(
129    timeout: Duration,
130    f: impl FnOnce() -> T + Send + 'static,
131) -> Option<T> {
132    let (tx, rx) = std::sync::mpsc::channel();
133    std::thread::spawn(move || {
134        let _ = tx.send(f());
135    });
136    rx.recv_timeout(timeout).ok()
137}
138
139type Job = Box<dyn FnOnce() + Send>;
140
141/// A reused call thread: requests are handed to it instead of spawning a thread per call. A
142/// call that times out abandons it (the abort ends its call, then it exits) and the next call
143/// starts a fresh one.
144struct Worker {
145    jobs: std::sync::mpsc::Sender<Job>,
146}
147
148impl Worker {
149    fn spawn() -> Self {
150        let (jobs, rx) = std::sync::mpsc::channel::<Job>();
151        std::thread::spawn(move || {
152            for job in rx {
153                job();
154            }
155        });
156        Worker { jobs }
157    }
158}
159
160fn call_bounded(
161    client: &Arc<Client>,
162    slot: &Mutex<Option<Worker>>,
163    method: &str,
164    params: Value,
165    timeout: Duration,
166) -> std::result::Result<Value, CallError> {
167    let (tx, rx) = std::sync::mpsc::channel();
168    let (c, m) = (client.clone(), method.to_string());
169    let mut job: Option<Job> = Some(Box::new(move || {
170        let _ = tx.send(c.call(&m, params));
171    }));
172    {
173        let mut w = slot.lock().unwrap();
174        for _ in 0..2 {
175            let worker = w.get_or_insert_with(Worker::spawn);
176            match worker.jobs.send(job.take().unwrap()) {
177                Ok(()) => break,
178                Err(e) => {
179                    // The thread is gone: take the job back and start another.
180                    job = Some(e.0);
181                    *w = None;
182                }
183            }
184        }
185        if job.is_some() {
186            return Err(CallError::TimedOut);
187        }
188    }
189    match rx.recv_timeout(timeout) {
190        Ok(r) => r.map_err(CallError::Service),
191        Err(_) => {
192            client.abort();
193            *slot.lock().unwrap() = None;
194            Err(CallError::TimedOut)
195        }
196    }
197}
198
199/// The app's driver predates the `wait` method (or does not allow it).
200fn wait_unsupported(msg: &str) -> bool {
201    msg.contains("unknown method")
202        || msg.contains("request_not_allowed")
203        || msg.contains("not allowed")
204}
205
206fn connect_bounded(suite: &Suite, service: &str, timeout: Duration) -> Result<Client> {
207    let (s, sv) = (suite.clone(), service.to_string());
208    match bounded(timeout, move || Client::connect(&s, &sv, APP)) {
209        Some(r) => r.map_err(svc_err),
210        None => err(format!(
211            "connecting to {service} did not finish within {}ms",
212            timeout.as_millis()
213        )),
214    }
215}
216
217fn bounded_connect_health(suite: &Suite, service: &str, timeout: Duration) -> Option<Arc<Client>> {
218    let (s, sv) = (suite.clone(), service.to_string());
219    bounded(timeout, move || {
220        let c = Client::connect(&s, &sv, APP).ok()?;
221        c.call("health", json!({})).ok()?;
222        Some(Arc::new(c))
223    })
224    .flatten()
225}
226
227fn endpoint_open(endpoint: &str) -> bool {
228    rightkit_daemon::probe_endpoint(endpoint)
229}
230
231/// Foreground app as `name` (macOS) or `pid:hwnd` (Windows). `None` means the
232/// foreground could NOT be measured; it is never treated as "unchanged".
233pub fn frontmost() -> Option<String> {
234    #[cfg(target_os = "macos")]
235    {
236        rightkit_qa_driver::mac::frontmost_name()
237    }
238    #[cfg(windows)]
239    {
240        use windows_sys::Win32::UI::WindowsAndMessaging::{
241            GetForegroundWindow, GetWindowThreadProcessId,
242        };
243        unsafe {
244            let h = GetForegroundWindow();
245            if h.is_null() {
246                return None;
247            }
248            let mut pid = 0u32;
249            if GetWindowThreadProcessId(h, &mut pid) == 0 || pid == 0 {
250                return None;
251            }
252            Some(format!("{pid}:{:x}", h as usize))
253        }
254    }
255    #[cfg(not(any(target_os = "macos", windows)))]
256    {
257        None
258    }
259}
260
261/// What the Windows foreground query found.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263#[cfg_attr(not(windows), allow(dead_code))]
264enum Foreground {
265    /// No foreground window at all (Windows reports this transiently, and on a desktop
266    /// with no active window). Nothing we own can be in front, so this is a measurement.
267    NoWindow,
268    /// The process that owns the foreground window.
269    Pid(u32),
270    /// A foreground window exists but its owner could not be read.
271    Unreadable,
272}
273
274/// Interpret `GetForegroundWindow` / `GetWindowThreadProcessId` results.
275#[cfg_attr(not(windows), allow(dead_code))]
276fn classify_foreground(window_present: bool, thread_id: u32, pid: u32) -> Foreground {
277    if !window_present {
278        Foreground::NoWindow
279    } else if thread_id == 0 || pid == 0 {
280        Foreground::Unreadable
281    } else {
282        Foreground::Pid(pid)
283    }
284}
285
286#[cfg(windows)]
287fn foreground_pid() -> Foreground {
288    use windows_sys::Win32::UI::WindowsAndMessaging::{
289        GetForegroundWindow, GetWindowThreadProcessId,
290    };
291    unsafe {
292        let h = GetForegroundWindow();
293        if h.is_null() {
294            return classify_foreground(false, 0, 0);
295        }
296        let mut pid = 0u32;
297        let thread_id = GetWindowThreadProcessId(h, &mut pid);
298        classify_foreground(true, thread_id, pid)
299    }
300}
301
302/// The Windows background invariant: no sample found a process this session started in
303/// the foreground, and every foreground owner that existed could be read. Process
304/// identity is the started pid only; helper processes (WebView2 renderers) own no
305/// top-level window, so they cannot be the foreground window's owner.
306#[cfg_attr(not(windows), allow(dead_code))]
307fn windows_owned_never_frontmost(
308    enforce_owned: bool,
309    unreadable_owners: usize,
310    observed: &[u32],
311) -> bool {
312    !enforce_owned || (unreadable_owners == 0 && observed.is_empty())
313}
314
315/// Why the background proof failed, most specific first: an owned process WAS in front, the
316/// foreground owner could not be read, the owned executable was never registered, or other samples
317/// were unmeasurable. It is only called when the proof already failed, so it never relaxes it.
318fn owned_failure_reason(
319    observed: &[u32],
320    unavailable: usize,
321    registered: bool,
322    unmeasured: usize,
323) -> String {
324    if !observed.is_empty() {
325        format!("an owned app process was frontmost (pid {observed:?})")
326    } else if unavailable > 0 {
327        format!("the foreground owner's pid could not be read in {unavailable} sample(s); the background proof fails closed")
328    } else if !registered {
329        "the owned executable was never registered before launch, so startup samples could not be attributed".to_string()
330    } else if unmeasured > 0 {
331        format!("{unmeasured} foreground sample(s) were unmeasurable (name or owned-process inventory unavailable); the background proof fails closed")
332    } else {
333        "the background proof did not hold".to_string()
334    }
335}
336
337/// Samples the foreground on its own thread for the whole life of a control session, so
338/// a transient steal (focus leaves and returns) is caught, and an unmeasurable
339/// foreground is reported as such instead of comparing equal.
340struct FocusMonitor {
341    stop: Arc<std::sync::atomic::AtomicBool>,
342    seen: Arc<Mutex<(usize, usize, Vec<String>)>>, // (measured, unavailable, distinct states in order)
343    owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, // primary PID(s), exact executable prefix
344    enforce_owned: bool,
345    owned_frontmost: Arc<Mutex<Vec<u32>>>,
346    /// Foreground samples whose owning pid could not be read (Windows).
347    #[cfg_attr(not(windows), allow(dead_code))]
348    unreadable_owners: Arc<std::sync::atomic::AtomicUsize>,
349    thread: Option<std::thread::JoinHandle<()>>,
350}
351
352impl FocusMonitor {
353    fn start(enforce_owned: bool) -> FocusMonitor {
354        let seen: Arc<Mutex<(usize, usize, Vec<String>)>> = Arc::new(Mutex::new((0, 0, vec![])));
355        let owned: Arc<Mutex<(Vec<u32>, Option<String>)>> = Arc::new(Mutex::new((vec![], None)));
356        let owned_frontmost = Arc::new(Mutex::new(vec![]));
357        let unreadable_owners = Arc::new(std::sync::atomic::AtomicUsize::new(0));
358        let record = move |seen: &Mutex<(usize, usize, Vec<String>)>,
359                           _owned: &Mutex<(Vec<u32>, Option<String>)>,
360                           _owned_frontmost: &Mutex<Vec<u32>>,
361                           _unreadable: &std::sync::atomic::AtomicUsize| {
362            {
363                let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
364                match frontmost() {
365                    Some(f) => {
366                        g.0 += 1;
367                        if g.2.last() != Some(&f) {
368                            g.2.push(f);
369                        }
370                    }
371                    None => g.1 += 1,
372                }
373            }
374            #[cfg(windows)]
375            if enforce_owned {
376                match foreground_pid() {
377                    Foreground::Pid(pid) => {
378                        let owned = _owned.lock().unwrap_or_else(|e| e.into_inner()).0.clone();
379                        if owned.contains(&pid) {
380                            let mut observed =
381                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
382                            if !observed.contains(&pid) {
383                                observed.push(pid);
384                            }
385                        }
386                    }
387                    Foreground::NoWindow => {}
388                    Foreground::Unreadable => {
389                        _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
390                    }
391                }
392            }
393            #[cfg(target_os = "macos")]
394            if enforce_owned {
395                match rightkit_qa_driver::mac::frontmost_pid() {
396                    Some(pid) => {
397                        let (primary, prefix) =
398                            _owned.lock().unwrap_or_else(|e| e.into_inner()).clone();
399                        let mut candidates = primary;
400                        let mut inventory_ok = true;
401                        if let Some(prefix) = prefix {
402                            match pids_with_command_prefix(&prefix) {
403                                Ok(pids) => candidates.extend(pids),
404                                Err(_) => inventory_ok = false,
405                            }
406                        }
407                        if !inventory_ok {
408                            let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
409                            g.1 += 1;
410                        }
411                        if candidates.into_iter().any(|p| p == pid as u32) {
412                            let mut observed =
413                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
414                            if !observed.contains(&(pid as u32)) {
415                                observed.push(pid as u32);
416                            }
417                        }
418                    }
419                    None => {
420                        // An unmeasurable PID makes the strict background proof fail.
421                        _unreadable.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
422                        let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
423                        g.1 += 1;
424                    }
425                }
426            }
427        };
428        record(&seen, &owned, &owned_frontmost, &unreadable_owners);
429        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
430        let (s2, seen2, owned2, observed2, unreadable2) = (
431            stop.clone(),
432            seen.clone(),
433            owned.clone(),
434            owned_frontmost.clone(),
435            unreadable_owners.clone(),
436        );
437        let thread = std::thread::spawn(move || {
438            while !s2.load(std::sync::atomic::Ordering::SeqCst) {
439                std::thread::sleep(Duration::from_millis(20));
440                record(&seen2, &owned2, &observed2, &unreadable2);
441            }
442        });
443        FocusMonitor {
444            stop,
445            seen,
446            owned,
447            enforce_owned,
448            owned_frontmost,
449            unreadable_owners,
450            thread: Some(thread),
451        }
452    }
453    fn track_primary(&self, pid: u32) {
454        let mut owned = self.owned.lock().unwrap_or_else(|e| e.into_inner());
455        if !owned.0.contains(&pid) {
456            owned.0.push(pid);
457        }
458    }
459    #[cfg(target_os = "macos")]
460    fn track_prefix(&self, prefix: String) {
461        self.owned.lock().unwrap_or_else(|e| e.into_inner()).1 = Some(prefix);
462    }
463    /// `(unchanged, states, owned_never_frontmost, owned_frontmost_pids, pid_unavailable_samples,
464    /// failure reason)`.
465    fn finish(&mut self) -> (bool, Vec<String>, bool, Vec<u32>, usize, Option<String>) {
466        self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
467        if let Some(t) = self.thread.take() {
468            let _ = t.join();
469        }
470        let g = self.seen.lock().unwrap_or_else(|e| e.into_inner());
471        let unchanged = g.0 > 0 && g.1 == 0 && g.2.len() == 1;
472        let observed = self
473            .owned_frontmost
474            .lock()
475            .unwrap_or_else(|e| e.into_inner())
476            .clone();
477        #[cfg(target_os = "macos")]
478        // Unverified launch interval: if the executable identity was never registered
479        // before launch, startup samples could not be attributed, so never claim success.
480        let identity_registered = self
481            .owned
482            .lock()
483            .unwrap_or_else(|e| e.into_inner())
484            .1
485            .is_some();
486        #[cfg(target_os = "macos")]
487        let owned_never_frontmost =
488            !self.enforce_owned || (identity_registered && g.1 == 0 && observed.is_empty());
489        #[cfg(windows)]
490        let owned_never_frontmost = windows_owned_never_frontmost(
491            self.enforce_owned,
492            self.unreadable_owners
493                .load(std::sync::atomic::Ordering::SeqCst),
494            &observed,
495        );
496        #[cfg(not(any(target_os = "macos", windows)))]
497        // No native foreground-PID sampler exists on this target; background claims fail
498        // closed instead of treating name-only sampling as proof.
499        let owned_never_frontmost = !self.enforce_owned;
500        let unavailable = self
501            .unreadable_owners
502            .load(std::sync::atomic::Ordering::SeqCst);
503        #[cfg(target_os = "macos")]
504        let registered = identity_registered;
505        #[cfg(not(target_os = "macos"))]
506        let registered = true;
507        let failure = (!owned_never_frontmost)
508            .then(|| owned_failure_reason(&observed, unavailable, registered, g.1));
509        (
510            unchanged,
511            g.2.clone(),
512            owned_never_frontmost,
513            observed,
514            unavailable,
515            failure,
516        )
517    }
518}
519
520/// Caller environment must not put secrets in a process listing: on macOS
521/// `open --env K=V` exposes values in argv. Callers may supply only `RIGHTKIT_*` keys;
522/// harness-owned HOME/temp/profile paths are added separately.
523fn check_env(env: &[(String, String)]) -> Result<()> {
524    for (k, _) in env {
525        let up = k.to_ascii_uppercase();
526        if [
527            "TOKEN",
528            "SECRET",
529            "PASSWORD",
530            "PASSWD",
531            "API_KEY",
532            "APIKEY",
533            "PRIVATE_KEY",
534            "CREDENTIAL",
535        ]
536        .iter()
537        .any(|w| up.contains(w))
538        {
539            return err(format!("environment key {k} names a credential; secrets are never passed to a launched app through its command line or environment"));
540        }
541        #[cfg(target_os = "macos")]
542        if !up.starts_with("RIGHTKIT_") {
543            return err(format!("environment key {k} would be exposed in `open` arguments; macOS launches carry only RIGHTKIT_* keys"));
544        }
545    }
546    Ok(())
547}
548
549/// Absolute path of the executable that `open` will actually run for `bundle`, read from
550/// `Contents/Info.plist` `CFBundleExecutable`. `None` (fail closed) when the key is absent,
551/// names a path component trick, or the file is not a regular file inside `Contents/MacOS`.
552#[cfg(target_os = "macos")]
553#[doc(hidden)]
554pub fn resolve_bundle_executable(bundle: &Path) -> Option<String> {
555    let plist = bundle.join("Contents").join("Info.plist");
556    let out = Command::new("plutil")
557        .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"])
558        .arg(&plist)
559        .output()
560        .ok()?;
561    if !out.status.success() {
562        return None;
563    }
564    let name = String::from_utf8(out.stdout).ok()?;
565    let name = name.trim();
566    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
567        return None;
568    }
569    let exe = bundle.join("Contents").join("MacOS").join(name);
570    let meta = fs::metadata(&exe).ok()?;
571    if !meta.is_file() {
572        return None;
573    }
574    Some(exe.to_string_lossy().into_owned())
575}
576
577#[cfg(target_os = "macos")]
578fn make_bundle(dir: &Path, binary: &Path, id: &str, background: bool) -> Result<PathBuf> {
579    let exe = binary
580        .file_name()
581        .and_then(|n| n.to_str())
582        .ok_or_else(|| Error("binary has no file name".into()))?;
583    let app = dir.join(format!("{exe}.app"));
584    let macos = app.join("Contents").join("MacOS");
585    fs::create_dir_all(&macos)?;
586    let target = macos.join(exe);
587    let _ = fs::remove_file(&target);
588    let abs = fs::canonicalize(binary)
589        .map_err(|e| Error(format!("app binary not found: {}: {e}", binary.display())))?;
590    // Copy into internal storage; never launch a link back to the build volume.
591    fs::copy(&abs, &target)?;
592    // Background fixtures must be non-activating before AppKit creates any window.
593    let ui_element = if background {
594        "<key>LSUIElement</key><true/>\n"
595    } else {
596        ""
597    };
598    let plist = format!(
599        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict>\n<key>CFBundleExecutable</key><string>{exe}</string>\n<key>CFBundleIdentifier</key><string>app.rightkit.qa.{id}</string>\n<key>CFBundleName</key><string>{exe}</string>\n<key>CFBundlePackageType</key><string>APPL</string>\n<key>CFBundleVersion</key><string>1</string>\n{ui_element}</dict></plist>\n"
600    );
601    fs::write(app.join("Contents").join("Info.plist"), plist)?;
602    Ok(app)
603}
604
605#[cfg(target_os = "macos")]
606fn stage_bundle(spec: &LaunchSpec, ws: &QaWorkspace, id: &str) -> Result<PathBuf> {
607    let dir = ws.home.join("apps").join(id);
608    fs::create_dir_all(&dir)?;
609    if spec.binary.extension().is_some_and(|e| e == "app") {
610        let source = fs::canonicalize(&spec.binary)?;
611        let bundle = dir.join(
612            source
613                .file_name()
614                .ok_or_else(|| Error("bundle has no file name".into()))?,
615        );
616        // Preserve bundle resources, framework links, permissions & signatures.
617        let status = Command::new("/usr/bin/ditto")
618            .arg(&source)
619            .arg(&bundle)
620            .status()?;
621        if !status.success() {
622            return err(format!("app bundle staging failed: {status}"));
623        }
624        check_bundle_links(&bundle, &bundle)?;
625        Ok(bundle)
626    } else {
627        make_bundle(&dir, &spec.binary, &id[..8], spec.mode != Mode::Visible)
628    }
629}
630
631#[cfg(target_os = "macos")]
632fn check_bundle_links(bundle: &Path, dir: &Path) -> Result<()> {
633    for entry in fs::read_dir(dir)? {
634        let entry = entry?;
635        let path = entry.path();
636        let kind = entry.file_type()?;
637        if kind.is_symlink() {
638            if !fs::canonicalize(&path)?.starts_with(bundle) {
639                return err(format!(
640                    "staged app bundle link escapes internal bundle: {}",
641                    path.display()
642                ));
643            }
644        } else if kind.is_dir() {
645            check_bundle_links(bundle, &path)?;
646        }
647    }
648    Ok(())
649}
650
651fn launch_env(
652    spec: &LaunchSpec,
653    ws: &QaWorkspace,
654    suite: &Suite,
655    service: &str,
656) -> Result<Vec<(String, String)>> {
657    // Caller values retain the credential/argv restrictions. Workspace values are
658    // harness-owned paths, so HOME, TMPDIR & profile keys can safely travel via open.
659    check_env(&spec.env)?;
660    let mut env = ws.env.clone();
661    env.insert("RIGHTKIT_CONTROL_SERVICE".into(), service.into());
662    env.insert(
663        "RIGHTKIT_SUITE_ROOT".into(),
664        suite.root().to_string_lossy().into(),
665    );
666    env.insert(
667        "RIGHTKIT_QA_HIDDEN".into(),
668        if spec.mode == Mode::Hidden { "1" } else { "0" }.into(),
669    );
670    env.insert(
671        "RIGHTKIT_QA_BACKGROUND".into(),
672        if spec.mode != Mode::Visible { "1" } else { "0" }.into(),
673    );
674    for (key, value) in &spec.env {
675        let path = Path::new(value);
676        if path.is_absolute()
677            && (!path.starts_with(&ws.home)
678                || path
679                    .components()
680                    .any(|c| c == std::path::Component::ParentDir))
681        {
682            return err(format!(
683                "environment path {key} must be staged inside the QA app home"
684            ));
685        }
686        if let Some(owned) = env.get(key) {
687            let app_path = ws.env.contains_key(key)
688                && key != "RIGHTKIT_SUITE_ROOT"
689                && Path::new(owned).is_absolute();
690            let internal = Path::new(value).starts_with(&ws.home)
691                && !Path::new(value)
692                    .components()
693                    .any(|c| c == std::path::Component::ParentDir);
694            if value != owned && !(app_path && internal) {
695                return err(format!(
696                    "environment key {key} overrides a harness-owned launch value"
697                ));
698            }
699        }
700        env.insert(key.clone(), value.clone());
701    }
702    Ok(env.into_iter().collect())
703}
704
705#[cfg(target_os = "macos")]
706fn open_command(
707    spec: &LaunchSpec,
708    ws: &QaWorkspace,
709    env: &[(String, String)],
710    log_path: &Path,
711    bundle: &Path,
712) -> Command {
713    let mut cmd = Command::new("open");
714    cmd.current_dir(&ws.home);
715    match spec.mode {
716        Mode::Hidden => cmd.args(["-g", "-j", "-n"]),
717        Mode::Background => cmd.args(["-g", "-n"]),
718        Mode::Visible => cmd.args(["-n"]),
719    };
720    for (k, v) in env {
721        cmd.env(k, v).arg("--env").arg(format!("{k}={v}"));
722    }
723    cmd.arg("--stdout")
724        .arg(log_path)
725        .arg("--stderr")
726        .arg(log_path)
727        .arg(bundle);
728    cmd
729}
730
731#[cfg(target_os = "macos")]
732/// Pids whose command line STARTS with `prefix` (so `open`, which merely carries the
733/// bundle path as an argument, is never mistaken for the app).
734fn pids_with_command_prefix(prefix: &str) -> std::result::Result<Vec<u32>, String> {
735    let out = Command::new("ps")
736        .args(["-ax", "-o", "pid=,command="])
737        .output()
738        .map_err(|e| format!("ps PID inventory failed: {e}"))?;
739    if !out.status.success() {
740        return Err(format!("ps PID inventory exited with {}", out.status));
741    }
742    let mut pids = vec![];
743    for line in String::from_utf8_lossy(&out.stdout).lines() {
744        let line = line.trim_start();
745        let Some((pid, cmd)) = line.split_once(' ') else {
746            continue;
747        };
748        if cmd.trim_start().starts_with(prefix) {
749            pids.push(
750                pid.trim()
751                    .parse::<u32>()
752                    .map_err(|e| format!("invalid PID inventory row: {e}"))?,
753            );
754        }
755    }
756    Ok(pids)
757}
758
759pub fn launch(spec: &LaunchSpec, ws: &QaWorkspace, tracker: &Tracker) -> Result<Control> {
760    if !spec.binary.exists() {
761        return err(format!(
762            "UI app binary not found: {}",
763            spec.binary.display()
764        ));
765    }
766    // Before anything is created or started: the QA home must be a QA root this runner made, on the
767    // internal disk, and clear of every user-data location (see `userdata`). No opt-out.
768    let guard = crate::userdata::Guard::for_workspace(ws)?;
769    let id = new_id().replace('-', "");
770    let service = format!("control-{}", &id[..8]);
771    // Isolated per-run suite root: the app's service.json never collides with an installed app's.
772    let suite =
773        Suite::at(ws.home.join(format!("suite-{}", &id[..8]))).map_err(|e| Error(e.to_string()))?;
774    let log_path = ws.evidence_dir.join(format!("app-{}.log", &id[..8]));
775    // launchd-started apps cannot open log files on external volumes (`open` fails with
776    // -10810), so the live log goes to the system temp dir and is copied into the
777    // evidence dir when the app stops.
778    // Kept in a subdirectory: apps commonly list or watch HOME (file explorers, recents), and
779    // a live log written directly into it is a change source that can feed back into the app.
780    let log_dir = ws.home.join(".rightkit-qa");
781    fs::create_dir_all(&log_dir)?;
782    let raw_log = log_dir.join(format!("app-{}.log", &id[..8]));
783    let env = launch_env(spec, ws, &suite, &service)?;
784    guard.check_dir(&ws.home)?;
785    guard.check_env(&env)?;
786    // The app must be told its isolated data directory; otherwise it resolves its own default.
787    crate::userdata::check_app_data_dir(&guard, &spec.binary, &env)?;
788    let frontmost_before = frontmost();
789    let focus = FocusMonitor::start(spec.mode != Mode::Visible);
790    let deadline = Instant::now() + spec.startup_timeout;
791
792    // The executable identity is registered inside start_process BEFORE `open`, so
793    // startup foreground samples are attributed; the pid follows once known.
794    let (pid, child) = start_process(spec, ws, &env, &raw_log, &id, tracker, &focus, deadline)?;
795    focus.track_primary(pid);
796    tracker.register(pid, &spec.label);
797    // Disposal may end what this app started and left in the home, and nothing else.
798    crate::workspace::record_launched(ws, pid);
799
800    let mut control = Control {
801        pid,
802        service: service.clone(),
803        log_path,
804        raw_log,
805        suite: suite.clone(),
806        endpoint: String::new(),
807        client: Mutex::new(None),
808        child,
809        tracker: tracker.clone(),
810        frontmost_before,
811        focus: Some(focus),
812        request_timeout: Duration::from_secs(20),
813        worker: Mutex::new(None),
814        pace_ms: std::env::var("RIGHTKIT_QA_PACE_MS")
815            .ok()
816            .and_then(|v| v.trim().parse().ok())
817            .unwrap_or(0),
818        stopped: false,
819    };
820    loop {
821        // Each attempt is bounded by the startup deadline: a hung host must not defeat it.
822        let left = deadline.saturating_duration_since(Instant::now());
823        if let Some(c) = bounded_connect_health(&suite, &service, left.min(Duration::from_secs(5)))
824        {
825            control.endpoint = c.record().endpoint.clone();
826            *control.client.lock().unwrap() = Some(c);
827            break;
828        }
829        if !is_alive(pid) {
830            let tail = fs::read_to_string(&control.raw_log)
831                .map(|t| crate::util::tail(&t, 1500))
832                .unwrap_or_default();
833            return err(format!(
834                "app exited before its control server came up (pid {pid}); log tail: {tail}"
835            ));
836        }
837        if Instant::now() >= deadline {
838            let _ = control.stop();
839            return err(format!(
840                "control service did not answer within {}ms; was the app built with its control feature and launched with RIGHTKIT_CONTROL_SERVICE?{}",
841                spec.startup_timeout.as_millis(),
842                non_interactive_hint()
843            ));
844        }
845        sleep_ms(100);
846    }
847    // The server comes up before the window exists (hidden launch): wait until the page
848    // itself answers, so the first scenario step never races window creation.
849    loop {
850        // `about:blank` reports "complete" before the app's page has even started loading.
851        if let Ok(Value::String(state)) =
852            control.eval("return document.readyState + ' ' + location.href;")
853        {
854            let (ready, href) = state.split_once(' ').unwrap_or((&state, ""));
855            if (ready == "complete" || ready == "interactive") && href != "about:blank" {
856                break;
857            }
858        }
859        if Instant::now() >= deadline {
860            let alive = is_alive(pid);
861            let _ = control.stop();
862            return err(format!(
863                "app window never became ready within {}ms (process alive: {}){}",
864                spec.startup_timeout.as_millis(),
865                alive,
866                non_interactive_hint()
867            ));
868        }
869        sleep_ms(20);
870    }
871    Ok(control)
872}
873
874/// Extra explanation for a launch timeout when the harness itself runs outside the
875/// interactive desktop (SSH or a service on Windows): a WebView2 app launched from there
876/// never gets a ready window. Empty everywhere else.
877fn non_interactive_hint() -> String {
878    #[cfg(windows)]
879    if let Some(why) = windows_non_interactive_reason() {
880        return format!(
881            ". This harness process is not in the interactive desktop session ({why}); a hidden WebView2 app cannot get a ready window from a non-interactive session (SSH, service). Start the run from the logged-in user's desktop session, for example a one-shot `schtasks /create /it` task (see the rightkit-qa README, Windows section)"
882        );
883    }
884    String::new()
885}
886
887/// `Some(reason)` when this process is in session 0 or on a window station that is not
888/// visible (the shape of an SSH or service logon); `None` on an interactive desktop or when
889/// it cannot be determined.
890#[cfg(windows)]
891fn windows_non_interactive_reason() -> Option<String> {
892    use std::ffi::c_void;
893    #[repr(C)]
894    struct UserObjectFlags {
895        inherit: i32,
896        reserved: i32,
897        flags: u32,
898    }
899    #[link(name = "user32")]
900    extern "system" {
901        fn GetProcessWindowStation() -> *mut c_void;
902        fn GetUserObjectInformationW(
903            h: *mut c_void,
904            index: i32,
905            info: *mut c_void,
906            len: u32,
907            needed: *mut u32,
908        ) -> i32;
909    }
910    #[link(name = "kernel32")]
911    extern "system" {
912        fn GetCurrentProcessId() -> u32;
913        fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
914    }
915    const UOI_FLAGS: i32 = 1;
916    const WSF_VISIBLE: u32 = 1;
917    let mut session = 0u32;
918    // SAFETY: plain Win32 queries on this process with correctly sized out-parameters.
919    unsafe {
920        if ProcessIdToSessionId(GetCurrentProcessId(), &mut session) != 0 && session == 0 {
921            return Some("session 0".into());
922        }
923        let station = GetProcessWindowStation();
924        if station.is_null() {
925            return None;
926        }
927        let mut info = UserObjectFlags {
928            inherit: 0,
929            reserved: 0,
930            flags: 0,
931        };
932        let mut needed = 0u32;
933        if GetUserObjectInformationW(
934            station,
935            UOI_FLAGS,
936            &mut info as *mut _ as *mut c_void,
937            std::mem::size_of::<UserObjectFlags>() as u32,
938            &mut needed,
939        ) != 0
940            && info.flags & WSF_VISIBLE == 0
941        {
942            return Some(format!("session {session}, non-visible window station"));
943        }
944    }
945    None
946}
947
948#[allow(clippy::too_many_arguments)]
949fn start_process(
950    spec: &LaunchSpec,
951    ws: &QaWorkspace,
952    env: &[(String, String)],
953    log_path: &Path,
954    id: &str,
955    _tracker: &Tracker,
956    focus: &FocusMonitor,
957    deadline: Instant,
958) -> Result<(u32, Option<OwnedChild>)> {
959    #[cfg(target_os = "macos")]
960    {
961        let bundle = stage_bundle(spec, ws, id)?;
962        let needle = format!("{}/Contents/MacOS/", bundle.display());
963        // Register the REAL executable (CFBundleExecutable) before `open`. When it cannot be
964        // resolved and validated, register nothing: the launch interval stays unverified and
965        // `owned_never_frontmost` fails closed instead of matching a guessed name.
966        if let Some(executable) = resolve_bundle_executable(&bundle) {
967            focus.track_prefix(executable);
968        }
969        let mut cmd = open_command(spec, ws, env, log_path, &bundle);
970        let st = cmd.status()?;
971        if !st.success() {
972            return err(format!("open exited {st}"));
973        }
974        loop {
975            if let Some(pid) = pids_with_command_prefix(&needle)
976                .ok()
977                .and_then(|pids| pids.into_iter().next())
978            {
979                return Ok((pid, None));
980            }
981            if Instant::now() >= deadline {
982                return err("launched app pid not found");
983            }
984            sleep_ms(100);
985        }
986    }
987    #[cfg(not(target_os = "macos"))]
988    {
989        let _ = (ws, id);
990        let log = fs::File::create(log_path)?;
991        let log2 = log.try_clone()?;
992        let mut cmd = Command::new(&spec.binary);
993        cmd.current_dir(&ws.home)
994            .stdin(Stdio::null())
995            .stdout(Stdio::from(log))
996            .stderr(Stdio::from(log2));
997        for (k, v) in env {
998            cmd.env(k, v);
999        }
1000        let mut owned = OwnedCommand::from_command(cmd);
1001        if spec.mode != Mode::Visible {
1002            owned.windows_hide();
1003        }
1004        let _ = deadline;
1005        let child = owned
1006            .spawn()
1007            .map_err(|e| Error(format!("failed to start {}: {e}", spec.binary.display())))?;
1008        // Attribute foreground samples to the app from its first instant.
1009        focus.track_primary(child.id());
1010        Ok((child.id(), Some(child)))
1011    }
1012}
1013
1014impl Control {
1015    /// Fail when a system permission dialog is known to be showing for this app. TODO: the
1016    /// automation layer cannot enumerate windows by owner (`UserNotificationCenter`,
1017    /// `CoreServicesUIAgent`), so this is a no-op that returns `Ok` today; see
1018    /// [`crate::userdata::live_permission_dialog_check`]. Use
1019    /// [`StopReport::permission_dialog`] for the evidence that does exist.
1020    pub fn assert_no_permission_dialog(&self) -> Result<()> {
1021        match crate::userdata::live_permission_dialog_check(self.pid) {
1022            crate::userdata::DialogCheck::Dialog(owner) => err(format!(
1023                "a system permission dialog ({owner}) appeared for the app under test"
1024            )),
1025            _ => Ok(()),
1026        }
1027    }
1028
1029    pub fn set_request_timeout(&mut self, t: Duration) {
1030        self.request_timeout = t;
1031    }
1032
1033    /// One bounded request. The deadline is real: when it passes the connection is
1034    /// aborted (unblocking the read) and the call fails. A request that may already have
1035    /// reached the app is replayed only when the method is a pure read; effects
1036    /// (click, key, type, command, eval, ...) are never silently executed twice.
1037    fn rpc(&self, method: &str, params: Value) -> Result<Value> {
1038        self.rpc_within(method, params, self.request_timeout)
1039    }
1040
1041    fn rpc_within(&self, method: &str, params: Value, request_timeout: Duration) -> Result<Value> {
1042        let replayable = matches!(method, "health" | "dom" | "ax" | "screenshot" | "move");
1043        let mut g = self.client.lock().unwrap();
1044        for attempt in 0..2 {
1045            let client = match g.as_ref() {
1046                Some(c) => c.clone(),
1047                None => {
1048                    // Not yet sent anything: reconnecting is always safe.
1049                    let c = Arc::new(connect_bounded(
1050                        &self.suite,
1051                        &self.service,
1052                        request_timeout,
1053                    )?);
1054                    *g = Some(c.clone());
1055                    c
1056                }
1057            };
1058            match call_bounded(
1059                &client,
1060                &self.worker,
1061                method,
1062                params.clone(),
1063                request_timeout,
1064            ) {
1065                Ok(v) => return Ok(v),
1066                Err(CallError::TimedOut) => {
1067                    *g = None;
1068                    return err(format!(
1069                        "{method}: no reply within {}ms; connection aborted",
1070                        request_timeout.as_millis()
1071                    ));
1072                }
1073                Err(CallError::Service(e)) if e.retryable && replayable && attempt == 0 => {
1074                    *g = None
1075                }
1076                Err(CallError::Service(e)) => {
1077                    if e.retryable {
1078                        *g = None;
1079                    }
1080                    let mut m = svc_err(e);
1081                    if !replayable {
1082                        m = Error(format!("{} (not replayed: {method} has effects and the first attempt may have been applied)", m.0));
1083                    }
1084                    return Err(m);
1085                }
1086            }
1087        }
1088        err("control rpc failed")
1089    }
1090
1091    fn json(&self, method: &str, params: Value) -> Result<Value> {
1092        let v = self.rpc(method, params)?;
1093        Self::ok_or_error(method, v)
1094    }
1095
1096    fn ok_or_error(method: &str, v: Value) -> Result<Value> {
1097        if v.get("ok") == Some(&Value::Bool(false)) {
1098            return err(format!(
1099                "{method}: {}",
1100                v.get("error")
1101                    .and_then(Value::as_str)
1102                    .unwrap_or("unknown error")
1103            ));
1104        }
1105        Ok(v)
1106    }
1107
1108    /// Add the session's input pacing (`paceMs`), when one is set, to an input request.
1109    fn paced(&self, mut p: Value) -> Value {
1110        if self.pace_ms > 0 {
1111            p["paceMs"] = json!(self.pace_ms);
1112        }
1113        p
1114    }
1115    /// Gap between native input events the app inserts (default 0, no gap). Also set from
1116    /// `RIGHTKIT_QA_PACE_MS` at launch. The former fixed pauses were about click 40-50, drag 16,
1117    /// key 20, type 8 ms; use this for a page that needs real time between events.
1118    pub fn set_pace_ms(&mut self, ms: u64) {
1119        self.pace_ms = ms;
1120    }
1121    /// Run `steps` (`(method, params)`) in ONE round trip, stopping at the first failure. Every step is
1122    /// checked against this app's allowlist and passes the effect gate and input lease on its own.
1123    /// Returns each step's result; the error names the failing step. A batch is never replayed.
1124    pub fn batch(&self, steps: &[(&str, Value)]) -> Result<Vec<Value>> {
1125        let steps: Vec<Value> = steps
1126            .iter()
1127            .map(|(m, p)| {
1128                let p = if matches!(*m, "click" | "drag" | "key" | "type") {
1129                    self.paced(p.clone())
1130                } else {
1131                    p.clone()
1132                };
1133                json!({"method": m, "params": p})
1134            })
1135            .collect();
1136        // Allow the whole sequence: the per-request bound applies to each step, not the sum.
1137        let budget = self.request_timeout * (steps.len().max(1) as u32).min(30);
1138        let v = self.rpc_within("batch", json!({"steps": steps}), budget)?;
1139        let v = Self::ok_or_error("batch", v)?;
1140        Ok(v.get("results")
1141            .and_then(Value::as_array)
1142            .cloned()
1143            .unwrap_or_default())
1144    }
1145
1146    pub fn health(&self) -> Result<Value> {
1147        self.rpc("health", json!({}))
1148    }
1149    /// Resize hidden native content without screen clamping or activation.
1150    /// Returns measured CSS dimensions; this effect is never automatically replayed.
1151    pub fn set_viewport(&self, width: u32, height: u32) -> Result<(u32, u32)> {
1152        let v = self.json("set_viewport", json!({"width": width, "height": height}))?;
1153        let dimension = |key: &str| {
1154            v.get(key)
1155                .and_then(Value::as_u64)
1156                .and_then(|n| u32::try_from(n).ok())
1157                .filter(|n| *n > 0)
1158                .ok_or_else(|| Error(format!("set_viewport returned invalid {key}")))
1159        };
1160        Ok((dimension("innerWidth")?, dimension("innerHeight")?))
1161    }
1162    /// Prove the allowlist: an app that is not on it is refused at hello. Returns the error code.
1163    pub fn unlisted_app_probe(&self) -> Result<String> {
1164        match Client::connect(&self.suite, &self.service, "not-allowlisted-probe") {
1165            Ok(_) => err("an unlisted app was accepted by the control service"),
1166            Err(e) => Ok(e.code),
1167        }
1168    }
1169    pub fn click(&self, x: f64, y: f64, button: &str, count: u32) -> Result<()> {
1170        self.json(
1171            "click",
1172            self.paced(json!({"x": x, "y": y, "button": button, "count": count})),
1173        )
1174        .map(|_| ())
1175    }
1176    /// [`click`](Self::click) holding modifiers (`"shift"`, `"cmd"`, `"alt"`, `"ctrl"`). They
1177    /// travel on the native mouse events, so the page sees `e.shiftKey` etc. as real input.
1178    pub fn click_with(
1179        &self,
1180        x: f64,
1181        y: f64,
1182        button: &str,
1183        count: u32,
1184        modifiers: &[&str],
1185    ) -> Result<()> {
1186        self.json(
1187            "click",
1188            self.paced(
1189                json!({"x": x, "y": y, "button": button, "count": count, "modifiers": modifiers}),
1190            ),
1191        )
1192        .map(|_| ())
1193    }
1194    pub fn move_to(&self, x: f64, y: f64) -> Result<()> {
1195        self.json("move", json!({"x": x, "y": y})).map(|_| ())
1196    }
1197    /// One native pointer phase (`down`, `drag`, `up`, `move`) in viewport CSS
1198    /// pixels. Use down/drag/up for a scrub split across requests; keep the same
1199    /// button and modifiers on each phase. Hidden launches use the embedded path.
1200    pub fn pointer(
1201        &self,
1202        phase: &str,
1203        x: f64,
1204        y: f64,
1205        button: &str,
1206        modifiers: &[&str],
1207    ) -> Result<()> {
1208        self.json(
1209            "pointer",
1210            json!({"phase": phase, "x": x, "y": y, "button": button, "modifiers": modifiers}),
1211        )
1212        .map(|_| ())
1213    }
1214    pub fn drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
1215        self.json(
1216            "drag",
1217            self.paced(json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1, "steps": steps})),
1218        )
1219        .map(|_| ())
1220    }
1221    pub fn wheel(&self, x: f64, y: f64, dx: f64, dy: f64) -> Result<()> {
1222        self.json("wheel", json!({"x": x, "y": y, "dx": dx, "dy": dy}))
1223            .map(|_| ())
1224    }
1225    pub fn key(&self, spec: &str) -> Result<()> {
1226        self.json("key", self.paced(json!({"key": spec})))
1227            .map(|_| ())
1228    }
1229    pub fn type_text(&self, text: &str) -> Result<()> {
1230        self.json("type", self.paced(json!({"text": text})))
1231            .map(|_| ())
1232    }
1233    /// Evaluate a JS function body (`return ...`) in the page; returns its JSON value.
1234    pub fn eval(&self, js: &str) -> Result<Value> {
1235        Ok(self
1236            .json("eval", json!({"js": js}))?
1237            .get("value")
1238            .cloned()
1239            .unwrap_or(Value::Null))
1240    }
1241    pub fn dom(&self, selector: &str) -> Result<Vec<Value>> {
1242        let v = self.json("dom", json!({"selector": selector}))?;
1243        Ok(v.get("elements")
1244            .and_then(Value::as_array)
1245            .cloned()
1246            .unwrap_or_default())
1247    }
1248    pub fn ax(&self) -> Result<Vec<Value>> {
1249        let v = self.json("ax", json!({}))?;
1250        Ok(v.get("nodes")
1251            .and_then(Value::as_array)
1252            .cloned()
1253            .unwrap_or_default())
1254    }
1255    pub fn screenshot_png(&self) -> Result<Vec<u8>> {
1256        let v = self.json("screenshot", json!({"inline": true}))?;
1257        // The server masks credential elements before the image leaves the page; a server that
1258        // does not report `masked` predates redaction and its captures are refused.
1259        if v.get("masked").and_then(Value::as_u64).is_none() {
1260            return err("control server does not redact screenshots; capture refused");
1261        }
1262        let body = match v.get("data").and_then(Value::as_str) {
1263            Some(b64) => crate::util::base64_decode(b64)
1264                .ok_or_else(|| Error("screenshot data is not valid base64".into()))?,
1265            // A server older than the inline reply writes the PNG and returns its path.
1266            None => {
1267                let path = PathBuf::from(
1268                    v.get("path")
1269                        .and_then(Value::as_str)
1270                        .ok_or_else(|| Error("screenshot returned no data or path".into()))?,
1271                );
1272                let body = fs::read(&path)?;
1273                let _ = fs::remove_file(&path);
1274                body
1275            }
1276        };
1277        if body.len() < 8 || &body[1..4] != b"PNG" {
1278            return err("screenshot method did not return a PNG");
1279        }
1280        Ok(body)
1281    }
1282    pub fn screenshot_to(&self, path: &Path) -> Result<()> {
1283        if let Some(p) = path.parent() {
1284            fs::create_dir_all(p)?;
1285        }
1286        fs::write(path, self.screenshot_png()?)?;
1287        Ok(())
1288    }
1289    /// A typed app command registered with `Control::command(name, f)`. The server
1290    /// hands the command its `args` string verbatim.
1291    pub fn command(&self, name: &str, args: &Value) -> Result<Value> {
1292        let args = match args {
1293            Value::String(s) => s.clone(),
1294            Value::Null => String::new(),
1295            other => other.to_string(),
1296        };
1297        let v = self.json("command", json!({"name": name, "args": args}))?;
1298        Ok(v.get("result").cloned().unwrap_or(Value::Null))
1299    }
1300
1301    /// Centre of the first element matching `selector`, in viewport CSS px.
1302    pub fn center_of(&self, selector: &str) -> Result<(f64, f64)> {
1303        let els = self.dom(selector)?;
1304        let first = els
1305            .first()
1306            .ok_or_else(|| Error(format!("selector matched no element: {selector}")))?;
1307        let r: Vec<f64> = first
1308            .get("rect")
1309            .and_then(Value::as_array)
1310            .map(|a| a.iter().filter_map(Value::as_f64).collect())
1311            .unwrap_or_default();
1312        if r.len() < 4 || r[2] <= 0.0 || r[3] <= 0.0 {
1313            return err(format!("{selector} has no visible box"));
1314        }
1315        Ok((r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
1316    }
1317    pub fn click_selector(&self, selector: &str) -> Result<()> {
1318        let (x, y) = self.center_of(selector)?;
1319        self.click(x, y, "left", 1)
1320    }
1321
1322    /// Wait until a JS function body returns something truthy; bounded. One round trip: the app
1323    /// waits in the page (DOM mutations, events, a short timer) and replies the moment it holds.
1324    /// An app on an older driver without the `wait` method is polled every 25 ms instead.
1325    pub fn wait_eval(&self, js: &str, timeout: Duration) -> Result<Value> {
1326        let ms = timeout.as_millis().clamp(1, 600_000) as u64;
1327        let reply = self.rpc_within(
1328            "wait",
1329            json!({"js": js, "timeoutMs": ms}),
1330            timeout + self.request_timeout,
1331        );
1332        match reply {
1333            Ok(v) => {
1334                let v = Self::ok_or_error("wait", v)?;
1335                if v.get("matched") == Some(&Value::Bool(true)) {
1336                    return Ok(v.get("value").cloned().unwrap_or(Value::Null));
1337                }
1338                let last = match v.get("error") {
1339                    Some(e) if !e.is_null() => e.clone(),
1340                    _ => v.get("last").cloned().unwrap_or(Value::Null),
1341                };
1342                err(format!("timeout waiting for `{js}` (last: {last})"))
1343            }
1344            Err(e) if wait_unsupported(&e.0) => self.wait_eval_polling(js, timeout),
1345            Err(e) => Err(e),
1346        }
1347    }
1348
1349    fn wait_eval_polling(&self, js: &str, timeout: Duration) -> Result<Value> {
1350        let deadline = Instant::now() + timeout;
1351        let mut last;
1352        loop {
1353            match self.eval(js) {
1354                Ok(v) => {
1355                    let truthy = !matches!(v, Value::Null | Value::Bool(false))
1356                        && v != json!(0)
1357                        && v != json!("");
1358                    if truthy {
1359                        return Ok(v);
1360                    }
1361                    last = v;
1362                }
1363                Err(e) => last = json!(e.0),
1364            }
1365            if Instant::now() >= deadline {
1366                return err(format!("timeout waiting for `{js}` (last: {last})"));
1367            }
1368            sleep_ms(25);
1369        }
1370    }
1371
1372    /// Wait until `text` appears in the visible text (`innerText`) of the first element
1373    /// matching `selector` (`None` = the whole body); see [`wait_eval`](Self::wait_eval).
1374    pub fn wait_for_text(
1375        &self,
1376        selector: Option<&str>,
1377        text: &str,
1378        timeout: Duration,
1379    ) -> Result<()> {
1380        let sel = serde_json::to_string(selector.unwrap_or("body")).unwrap_or_default();
1381        let needle = serde_json::to_string(text).unwrap_or_default();
1382        let js = format!(
1383            "return ((document.querySelector({sel}) || {{}}).innerText || '').includes({needle});"
1384        );
1385        self.wait_eval(&js, timeout).map(|_| ())
1386    }
1387
1388    /// Stop the app: kill the recorded pid tree, prove the port closed, and check
1389    /// we did not change the frontmost app.
1390    pub fn stop(&mut self) -> Result<StopReport> {
1391        if self.stopped {
1392            return err("control session already stopped");
1393        }
1394        self.stopped = true;
1395        if let Some(mut c) = self.child.take() {
1396            let _ = c.terminate_tree();
1397        }
1398        kill_tree(self.pid);
1399        let deadline = Instant::now() + Duration::from_secs(5);
1400        while Instant::now() < deadline && (is_alive(self.pid) || endpoint_open(&self.endpoint)) {
1401            sleep_ms(50);
1402        }
1403        // Keep the live log until workspace finish/disposal; failed evidence copies
1404        // must not destroy the only readable startup diagnostics.
1405        let log_copy = fs::copy(&self.raw_log, &self.log_path);
1406        let after = frontmost();
1407        let (
1408            steady,
1409            states,
1410            owned_never_frontmost,
1411            owned_frontmost_pids,
1412            frontmost_pid_unavailable_samples,
1413            owned_never_frontmost_failure,
1414        ) = self.focus.take().map(|mut f| f.finish()).unwrap_or((
1415            false,
1416            vec![],
1417            false,
1418            vec![],
1419            0,
1420            Some("no focus monitor ran".to_string()),
1421        ));
1422        let process_gone = !is_alive(self.pid);
1423        let endpoint_closed = self.endpoint.is_empty() || !endpoint_open(&self.endpoint);
1424        if process_gone {
1425            self.tracker.forget(self.pid);
1426        }
1427        log_copy.map_err(|e| {
1428            Error(format!(
1429                "cannot preserve app log {}: {e}",
1430                self.raw_log.display()
1431            ))
1432        })?;
1433        Ok(StopReport {
1434            pid: self.pid,
1435            endpoint_closed,
1436            process_gone,
1437            frontmost_unchanged: steady
1438                && self.frontmost_before.is_some()
1439                && self.frontmost_before == after,
1440            frontmost_states: states,
1441            frontmost_before: self.frontmost_before.clone(),
1442            frontmost_after: after,
1443            owned_never_frontmost,
1444            owned_frontmost_pids,
1445            frontmost_pid_unavailable_samples,
1446            owned_never_frontmost_failure,
1447        })
1448    }
1449}
1450
1451impl Drop for Control {
1452    fn drop(&mut self) {
1453        if !self.stopped {
1454            let _ = self.stop();
1455        }
1456    }
1457}
1458
1459#[cfg(test)]
1460mod launch_path_tests {
1461    use super::*;
1462
1463    #[test]
1464    fn windows_foreground_is_classified_without_a_windows_host() {
1465        assert_eq!(classify_foreground(false, 0, 0), Foreground::NoWindow);
1466        assert_eq!(classify_foreground(true, 7, 4242), Foreground::Pid(4242));
1467        // A foreground window whose owner cannot be read is not "nothing in front".
1468        assert_eq!(classify_foreground(true, 0, 0), Foreground::Unreadable);
1469        assert_eq!(classify_foreground(true, 7, 0), Foreground::Unreadable);
1470        assert_eq!(classify_foreground(true, 0, 4242), Foreground::Unreadable);
1471    }
1472
1473    #[test]
1474    fn background_failure_reason_tells_frontmost_from_unreadable() {
1475        let r = owned_failure_reason(&[42], 3, true, 3);
1476        assert!(r.contains("was frontmost") && r.contains("42"), "{r}");
1477        let r = owned_failure_reason(&[], 2, true, 2);
1478        assert!(r.contains("pid could not be read in 2"), "{r}");
1479        assert!(!r.contains("was frontmost"), "{r}");
1480        assert!(owned_failure_reason(&[], 0, false, 0).contains("never registered"));
1481        assert!(owned_failure_reason(&[], 0, true, 4).contains("unmeasurable"));
1482    }
1483
1484    #[test]
1485    fn windows_background_invariant_fails_closed() {
1486        // Visible launches enforce nothing.
1487        assert!(windows_owned_never_frontmost(false, 3, &[9]));
1488        // Background: the owned pid in front, or an unreadable owner, both fail.
1489        assert!(windows_owned_never_frontmost(true, 0, &[]));
1490        assert!(!windows_owned_never_frontmost(true, 0, &[9]));
1491        assert!(!windows_owned_never_frontmost(true, 1, &[]));
1492    }
1493
1494    fn fixture() -> (QaWorkspace, LaunchSpec) {
1495        let root = crate::storage::app_temp_dir().join(format!("rkqa-launch-{}", new_id()));
1496        let ws = crate::workspace::create(&root, None, "control-test-app").unwrap();
1497        let binary = ws.root.join("control-test-app");
1498        fs::write(&binary, "fixture binary").unwrap();
1499        let spec = LaunchSpec {
1500            binary,
1501            mode: Mode::Hidden,
1502            env: vec![],
1503            startup_timeout: Duration::from_secs(90),
1504            label: "fixture".into(),
1505        };
1506        (ws, spec)
1507    }
1508
1509    fn cleanup(ws: &QaWorkspace) {
1510        let root = ws
1511            .root
1512            .parent()
1513            .unwrap()
1514            .parent()
1515            .unwrap()
1516            .parent()
1517            .unwrap()
1518            .to_path_buf();
1519        let report = crate::workspace::dispose(ws, false, 1, 0);
1520        assert!(report.retained.is_empty(), "{report:?}");
1521        fs::remove_dir_all(root).unwrap();
1522    }
1523
1524    #[test]
1525    fn launch_environment_keeps_all_owned_paths_internal_and_rejects_external_overrides() {
1526        let (ws, mut spec) = fixture();
1527        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1528        let env: std::collections::BTreeMap<_, _> =
1529            launch_env(&spec, &ws, &suite, "control-12345678")
1530                .unwrap()
1531                .into_iter()
1532                .collect();
1533        for (key, value) in &env {
1534            if Path::new(value).is_absolute() {
1535                assert!(Path::new(value).starts_with(&ws.home), "{key}={value}");
1536                assert!(!Path::new(value).starts_with(&ws.root), "{key}={value}");
1537            }
1538        }
1539        assert_eq!(env["HOME"], ws.home.to_string_lossy());
1540        assert_eq!(env["TMPDIR"], ws.home.join("tmp").to_string_lossy());
1541        assert_eq!(env["RIGHTKIT_SUITE_ROOT"], suite.root().to_string_lossy());
1542        assert_eq!(env["RIGHTKIT_QA_HIDDEN"], "1");
1543        assert_eq!(env["RIGHTKIT_QA_BACKGROUND"], "1");
1544        // rightkit-daemon falls back to TMPDIR/rk-<suite-hash>-<service>.sock
1545        // when its runtime socket exceeds 100 bytes. App TMPDIR must fit too.
1546        #[cfg(target_os = "macos")]
1547        assert!(
1548            Path::new(&env["TMPDIR"])
1549                .join("rk-12345678-control-12345678.sock")
1550                .as_os_str()
1551                .len()
1552                <= 100
1553        );
1554        assert!(suite
1555            .service_dir("control-12345678")
1556            .join("service.json")
1557            .starts_with(&ws.home));
1558        for key in [
1559            "RIGHTKIT_SUITE_ROOT",
1560            "RIGHTKIT_QA_DATA_DIR",
1561            "RIGHTKIT_RUN_ROOT",
1562        ] {
1563            spec.env = vec![(
1564                key.into(),
1565                ws.root.join("external").to_string_lossy().into(),
1566            )];
1567            assert!(
1568                launch_env(&spec, &ws, &suite, "control-12345678").is_err(),
1569                "{key}"
1570            );
1571        }
1572        spec.env = vec![("RIGHTKIT_SECRET".into(), "redacted".into())];
1573        assert!(launch_env(&spec, &ws, &suite, "control-12345678").is_err());
1574        cleanup(&ws);
1575    }
1576
1577    #[cfg(target_os = "macos")]
1578    #[test]
1579    fn raw_binary_and_supplied_bundle_are_staged_inside_app_home_before_open() {
1580        let (ws, mut spec) = fixture();
1581        let id = "1234567890abcdef";
1582        let bundle = stage_bundle(&spec, &ws, id).unwrap();
1583        assert!(bundle.starts_with(ws.home.join("apps")));
1584        assert!(!bundle.starts_with(&ws.root));
1585        assert_eq!(
1586            fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1587            b"fixture binary"
1588        );
1589        let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1590        assert!(plist.contains("<key>LSUIElement</key><true/>"));
1591        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1592        let env = launch_env(&spec, &ws, &suite, "control-12345678").unwrap();
1593        let log = ws.home.join("app-12345678.log");
1594        let cmd = open_command(&spec, &ws, &env, &log, &bundle);
1595        assert_eq!(cmd.get_current_dir(), Some(ws.home.as_path()));
1596        let args: Vec<_> = cmd
1597            .get_args()
1598            .map(|v| v.to_string_lossy().into_owned())
1599            .collect();
1600        assert_eq!(&args[..3], &["-g", "-j", "-n"]);
1601        for (key, value) in &env {
1602            assert!(
1603                args.windows(2)
1604                    .any(|pair| pair[0] == "--env" && pair[1] == format!("{key}={value}")),
1605                "{key}"
1606            );
1607        }
1608        for flag in ["--stdout", "--stderr"] {
1609            assert!(args
1610                .windows(2)
1611                .any(|pair| pair[0] == flag && Path::new(&pair[1]) == log));
1612        }
1613        assert_eq!(args.last().unwrap(), &bundle.to_string_lossy());
1614        // A provided .app must be copied too; it may live on the external build volume.
1615        spec.binary = make_bundle(&ws.root, &spec.binary, "original", true).unwrap();
1616        fs::create_dir_all(spec.binary.join("Contents/Resources")).unwrap();
1617        fs::write(
1618            spec.binary.join("Contents/Resources/fixture.txt"),
1619            "resource",
1620        )
1621        .unwrap();
1622        let supplied = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
1623        assert!(supplied.starts_with(&ws.home));
1624        assert_ne!(supplied, spec.binary);
1625        assert_eq!(
1626            fs::read_to_string(supplied.join("Contents/Resources/fixture.txt")).unwrap(),
1627            "resource"
1628        );
1629        std::os::unix::fs::symlink(
1630            ws.root.join("control-test-app"),
1631            spec.binary.join("Contents/Resources/external"),
1632        )
1633        .unwrap();
1634        assert!(stage_bundle(&spec, &ws, "0123456789abcdef")
1635            .unwrap_err()
1636            .0
1637            .contains("escapes internal bundle"));
1638        cleanup(&ws);
1639    }
1640}