Skip to main content

rightkit_qa/
control.rs

1//! Drive the real Tauri app through the in-app `rightkit-control` server.
2//!
3//! The app is launched hidden or backgrounded with `RIGHTKIT_CONTROL_SERVICE` and an
4//! isolated `RIGHTKIT_SUITE_ROOT`, discovers the `rightkit-service` 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_process::OwnedChild;
13#[cfg(not(target_os = "macos"))]
14use rightkit_process::OwnedCommand;
15use rightkit_service::{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    pub owned_never_frontmost: bool,
70    /// Owned fixture PIDs observed in foreground, if any.
71    pub owned_frontmost_pids: Vec<u32>,
72}
73
74pub struct Control {
75    pub pid: u32,
76    /// `rightkit-service` name the app serves (`control-xxxxxxxx`).
77    pub service: String,
78    pub log_path: PathBuf,
79    raw_log: PathBuf,
80    suite: Suite,
81    endpoint: String,
82    client: Mutex<Option<Arc<Client>>>,
83    child: Option<OwnedChild>,
84    tracker: Tracker,
85    frontmost_before: Option<String>,
86    focus: Option<FocusMonitor>,
87    request_timeout: Duration,
88    stopped: bool,
89}
90
91const APP: &str = "right-qa";
92
93fn svc_err(e: ServiceError) -> Error {
94    Error(e.to_string())
95}
96
97enum CallError {
98    TimedOut,
99    Service(ServiceError),
100}
101
102/// Run `f` on a helper thread and give up after `timeout`; the thread is left to finish
103/// on its own (it ends when the app is killed or the connection is aborted).
104fn bounded<T: Send + 'static>(
105    timeout: Duration,
106    f: impl FnOnce() -> T + Send + 'static,
107) -> Option<T> {
108    let (tx, rx) = std::sync::mpsc::channel();
109    std::thread::spawn(move || {
110        let _ = tx.send(f());
111    });
112    rx.recv_timeout(timeout).ok()
113}
114
115fn call_bounded(
116    client: &Arc<Client>,
117    method: &str,
118    params: Value,
119    timeout: Duration,
120) -> std::result::Result<Value, CallError> {
121    let c = client.clone();
122    let m = method.to_string();
123    match bounded(timeout, move || c.call(&m, params)) {
124        Some(r) => r.map_err(CallError::Service),
125        None => {
126            client.abort();
127            Err(CallError::TimedOut)
128        }
129    }
130}
131
132fn connect_bounded(suite: &Suite, service: &str, timeout: Duration) -> Result<Client> {
133    let (s, sv) = (suite.clone(), service.to_string());
134    match bounded(timeout, move || Client::connect(&s, &sv, APP)) {
135        Some(r) => r.map_err(svc_err),
136        None => err(format!(
137            "connecting to {service} did not finish within {}ms",
138            timeout.as_millis()
139        )),
140    }
141}
142
143fn bounded_connect_health(suite: &Suite, service: &str, timeout: Duration) -> Option<Arc<Client>> {
144    let (s, sv) = (suite.clone(), service.to_string());
145    bounded(timeout, move || {
146        let c = Client::connect(&s, &sv, APP).ok()?;
147        c.call("health", json!({})).ok()?;
148        Some(Arc::new(c))
149    })
150    .flatten()
151}
152
153fn endpoint_open(endpoint: &str) -> bool {
154    rightkit_service::probe_endpoint(endpoint)
155}
156
157/// Foreground app as `name` (macOS) or `pid:hwnd` (Windows). `None` means the
158/// foreground could NOT be measured; it is never treated as "unchanged".
159pub fn frontmost() -> Option<String> {
160    #[cfg(target_os = "macos")]
161    {
162        rightkit_control::mac::frontmost_name()
163    }
164    #[cfg(windows)]
165    {
166        use windows::Win32::UI::WindowsAndMessaging::{
167            GetForegroundWindow, GetWindowThreadProcessId,
168        };
169        unsafe {
170            let h = GetForegroundWindow();
171            if h.0.is_null() {
172                return None;
173            }
174            let mut pid = 0u32;
175            if GetWindowThreadProcessId(h, Some(&mut pid)) == 0 || pid == 0 {
176                return None;
177            }
178            Some(format!("{pid}:{:x}", h.0 as usize))
179        }
180    }
181    #[cfg(not(any(target_os = "macos", windows)))]
182    {
183        None
184    }
185}
186
187/// Samples the foreground on its own thread for the whole life of a control session, so
188/// a transient steal (focus leaves and returns) is caught, and an unmeasurable
189/// foreground is reported as such instead of comparing equal.
190struct FocusMonitor {
191    stop: Arc<std::sync::atomic::AtomicBool>,
192    seen: Arc<Mutex<(usize, usize, Vec<String>)>>, // (measured, unavailable, distinct states in order)
193    owned: Arc<Mutex<(Vec<u32>, Option<String>)>>, // primary PID(s), exact executable prefix
194    enforce_owned: bool,
195    owned_frontmost: Arc<Mutex<Vec<u32>>>,
196    thread: Option<std::thread::JoinHandle<()>>,
197}
198
199impl FocusMonitor {
200    fn start(enforce_owned: bool) -> FocusMonitor {
201        let seen: Arc<Mutex<(usize, usize, Vec<String>)>> = Arc::new(Mutex::new((0, 0, vec![])));
202        let owned: Arc<Mutex<(Vec<u32>, Option<String>)>> = Arc::new(Mutex::new((vec![], None)));
203        let owned_frontmost = Arc::new(Mutex::new(vec![]));
204        let record = move |seen: &Mutex<(usize, usize, Vec<String>)>,
205                           _owned: &Mutex<(Vec<u32>, Option<String>)>,
206                           _owned_frontmost: &Mutex<Vec<u32>>| {
207            {
208                let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
209                match frontmost() {
210                    Some(f) => {
211                        g.0 += 1;
212                        if g.2.last() != Some(&f) {
213                            g.2.push(f);
214                        }
215                    }
216                    None => g.1 += 1,
217                }
218            }
219            #[cfg(target_os = "macos")]
220            if enforce_owned {
221                match rightkit_control::mac::frontmost_pid() {
222                    Some(pid) => {
223                        let (primary, prefix) =
224                            _owned.lock().unwrap_or_else(|e| e.into_inner()).clone();
225                        let mut candidates = primary;
226                        let mut inventory_ok = true;
227                        if let Some(prefix) = prefix {
228                            match pids_with_command_prefix(&prefix) {
229                                Ok(pids) => candidates.extend(pids),
230                                Err(_) => inventory_ok = false,
231                            }
232                        }
233                        if !inventory_ok {
234                            let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
235                            g.1 += 1;
236                        }
237                        if candidates.into_iter().any(|p| p == pid as u32) {
238                            let mut observed =
239                                _owned_frontmost.lock().unwrap_or_else(|e| e.into_inner());
240                            if !observed.contains(&(pid as u32)) {
241                                observed.push(pid as u32);
242                            }
243                        }
244                    }
245                    None => {
246                        // An unmeasurable PID makes the strict background proof fail.
247                        let mut g = seen.lock().unwrap_or_else(|e| e.into_inner());
248                        g.1 += 1;
249                    }
250                }
251            }
252        };
253        record(&seen, &owned, &owned_frontmost);
254        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
255        let (s2, seen2, owned2, observed2) = (
256            stop.clone(),
257            seen.clone(),
258            owned.clone(),
259            owned_frontmost.clone(),
260        );
261        let thread = std::thread::spawn(move || {
262            while !s2.load(std::sync::atomic::Ordering::SeqCst) {
263                std::thread::sleep(Duration::from_millis(20));
264                record(&seen2, &owned2, &observed2);
265            }
266        });
267        FocusMonitor {
268            stop,
269            seen,
270            owned,
271            enforce_owned,
272            owned_frontmost,
273            thread: Some(thread),
274        }
275    }
276    fn track_primary(&self, pid: u32) {
277        self.owned
278            .lock()
279            .unwrap_or_else(|e| e.into_inner())
280            .0
281            .push(pid);
282    }
283    #[cfg(target_os = "macos")]
284    fn track_prefix(&self, prefix: String) {
285        self.owned.lock().unwrap_or_else(|e| e.into_inner()).1 = Some(prefix);
286    }
287    /// `(unchanged, states, owned_never_frontmost, owned_frontmost_pids)`.
288    fn finish(&mut self) -> (bool, Vec<String>, bool, Vec<u32>) {
289        self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
290        if let Some(t) = self.thread.take() {
291            let _ = t.join();
292        }
293        let g = self.seen.lock().unwrap_or_else(|e| e.into_inner());
294        let unchanged = g.0 > 0 && g.1 == 0 && g.2.len() == 1;
295        let observed = self
296            .owned_frontmost
297            .lock()
298            .unwrap_or_else(|e| e.into_inner())
299            .clone();
300        #[cfg(target_os = "macos")]
301        // Unverified launch interval: if the executable identity was never registered
302        // before launch, startup samples could not be attributed, so never claim success.
303        let identity_registered = self
304            .owned
305            .lock()
306            .unwrap_or_else(|e| e.into_inner())
307            .1
308            .is_some();
309        #[cfg(target_os = "macos")]
310        let owned_never_frontmost =
311            !self.enforce_owned || (identity_registered && g.1 == 0 && observed.is_empty());
312        #[cfg(not(target_os = "macos"))]
313        // No native foreground-PID sampler exists on these targets yet; background
314        // claims fail closed instead of treating name-only sampling as proof.
315        let owned_never_frontmost = !self.enforce_owned;
316        (unchanged, g.2.clone(), owned_never_frontmost, observed)
317    }
318}
319
320/// Caller environment must not put secrets in a process listing: on macOS
321/// `open --env K=V` exposes values in argv. Callers may supply only `RIGHTKIT_*` keys;
322/// harness-owned HOME/temp/profile paths are added separately.
323fn check_env(env: &[(String, String)]) -> Result<()> {
324    for (k, _) in env {
325        let up = k.to_ascii_uppercase();
326        if [
327            "TOKEN",
328            "SECRET",
329            "PASSWORD",
330            "PASSWD",
331            "API_KEY",
332            "APIKEY",
333            "PRIVATE_KEY",
334            "CREDENTIAL",
335        ]
336        .iter()
337        .any(|w| up.contains(w))
338        {
339            return err(format!("environment key {k} names a credential; secrets are never passed to a launched app through its command line or environment"));
340        }
341        #[cfg(target_os = "macos")]
342        if !up.starts_with("RIGHTKIT_") {
343            return err(format!("environment key {k} would be exposed in `open` arguments; macOS launches carry only RIGHTKIT_* keys"));
344        }
345    }
346    Ok(())
347}
348
349/// Absolute path of the executable that `open` will actually run for `bundle`, read from
350/// `Contents/Info.plist` `CFBundleExecutable`. `None` (fail closed) when the key is absent,
351/// names a path component trick, or the file is not a regular file inside `Contents/MacOS`.
352#[cfg(target_os = "macos")]
353#[doc(hidden)]
354pub fn resolve_bundle_executable(bundle: &Path) -> Option<String> {
355    let plist = bundle.join("Contents").join("Info.plist");
356    let out = Command::new("plutil")
357        .args(["-extract", "CFBundleExecutable", "raw", "-o", "-"])
358        .arg(&plist)
359        .output()
360        .ok()?;
361    if !out.status.success() {
362        return None;
363    }
364    let name = String::from_utf8(out.stdout).ok()?;
365    let name = name.trim();
366    if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\0') {
367        return None;
368    }
369    let exe = bundle.join("Contents").join("MacOS").join(name);
370    let meta = fs::metadata(&exe).ok()?;
371    if !meta.is_file() {
372        return None;
373    }
374    Some(exe.to_string_lossy().into_owned())
375}
376
377#[cfg(target_os = "macos")]
378fn make_bundle(dir: &Path, binary: &Path, id: &str, background: bool) -> Result<PathBuf> {
379    let exe = binary
380        .file_name()
381        .and_then(|n| n.to_str())
382        .ok_or_else(|| Error("binary has no file name".into()))?;
383    let app = dir.join(format!("{exe}.app"));
384    let macos = app.join("Contents").join("MacOS");
385    fs::create_dir_all(&macos)?;
386    let target = macos.join(exe);
387    let _ = fs::remove_file(&target);
388    let abs = fs::canonicalize(binary)
389        .map_err(|e| Error(format!("app binary not found: {}: {e}", binary.display())))?;
390    // Copy into internal storage; never launch a link back to the build volume.
391    fs::copy(&abs, &target)?;
392    // Background fixtures must be non-activating before AppKit creates any window.
393    let ui_element = if background {
394        "<key>LSUIElement</key><true/>\n"
395    } else {
396        ""
397    };
398    let plist = format!(
399        "<?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"
400    );
401    fs::write(app.join("Contents").join("Info.plist"), plist)?;
402    Ok(app)
403}
404
405#[cfg(target_os = "macos")]
406fn stage_bundle(spec: &LaunchSpec, ws: &QaWorkspace, id: &str) -> Result<PathBuf> {
407    let dir = ws.home.join("apps").join(id);
408    fs::create_dir_all(&dir)?;
409    if spec.binary.extension().is_some_and(|e| e == "app") {
410        let source = fs::canonicalize(&spec.binary)?;
411        let bundle = dir.join(
412            source
413                .file_name()
414                .ok_or_else(|| Error("bundle has no file name".into()))?,
415        );
416        // Preserve bundle resources, framework links, permissions & signatures.
417        let status = Command::new("/usr/bin/ditto")
418            .arg(&source)
419            .arg(&bundle)
420            .status()?;
421        if !status.success() {
422            return err(format!("app bundle staging failed: {status}"));
423        }
424        check_bundle_links(&bundle, &bundle)?;
425        Ok(bundle)
426    } else {
427        make_bundle(&dir, &spec.binary, &id[..8], spec.mode != Mode::Visible)
428    }
429}
430
431#[cfg(target_os = "macos")]
432fn check_bundle_links(bundle: &Path, dir: &Path) -> Result<()> {
433    for entry in fs::read_dir(dir)? {
434        let entry = entry?;
435        let path = entry.path();
436        let kind = entry.file_type()?;
437        if kind.is_symlink() {
438            if !fs::canonicalize(&path)?.starts_with(bundle) {
439                return err(format!(
440                    "staged app bundle link escapes internal bundle: {}",
441                    path.display()
442                ));
443            }
444        } else if kind.is_dir() {
445            check_bundle_links(bundle, &path)?;
446        }
447    }
448    Ok(())
449}
450
451fn launch_env(
452    spec: &LaunchSpec,
453    ws: &QaWorkspace,
454    suite: &Suite,
455    service: &str,
456) -> Result<Vec<(String, String)>> {
457    // Caller values retain the credential/argv restrictions. Workspace values are
458    // harness-owned paths, so HOME, TMPDIR & profile keys can safely travel via open.
459    check_env(&spec.env)?;
460    let mut env = ws.env.clone();
461    env.insert("RIGHTKIT_CONTROL_SERVICE".into(), service.into());
462    env.insert(
463        "RIGHTKIT_SUITE_ROOT".into(),
464        suite.root().to_string_lossy().into(),
465    );
466    env.insert(
467        "RIGHTKIT_QA_HIDDEN".into(),
468        if spec.mode == Mode::Hidden { "1" } else { "0" }.into(),
469    );
470    env.insert(
471        "RIGHTKIT_QA_BACKGROUND".into(),
472        if spec.mode != Mode::Visible { "1" } else { "0" }.into(),
473    );
474    for (key, value) in &spec.env {
475        let path = Path::new(value);
476        if path.is_absolute()
477            && (!path.starts_with(&ws.home)
478                || path
479                    .components()
480                    .any(|c| c == std::path::Component::ParentDir))
481        {
482            return err(format!(
483                "environment path {key} must be staged inside the QA app home"
484            ));
485        }
486        if let Some(owned) = env.get(key) {
487            let app_path = ws.env.contains_key(key)
488                && key != "RIGHTKIT_SUITE_ROOT"
489                && Path::new(owned).is_absolute();
490            let internal = Path::new(value).starts_with(&ws.home)
491                && !Path::new(value)
492                    .components()
493                    .any(|c| c == std::path::Component::ParentDir);
494            if value != owned && !(app_path && internal) {
495                return err(format!(
496                    "environment key {key} overrides a harness-owned launch value"
497                ));
498            }
499        }
500        env.insert(key.clone(), value.clone());
501    }
502    Ok(env.into_iter().collect())
503}
504
505#[cfg(target_os = "macos")]
506fn open_command(
507    spec: &LaunchSpec,
508    ws: &QaWorkspace,
509    env: &[(String, String)],
510    log_path: &Path,
511    bundle: &Path,
512) -> Command {
513    let mut cmd = Command::new("open");
514    cmd.current_dir(&ws.home);
515    match spec.mode {
516        Mode::Hidden => cmd.args(["-g", "-j", "-n"]),
517        Mode::Background => cmd.args(["-g", "-n"]),
518        Mode::Visible => cmd.args(["-n"]),
519    };
520    for (k, v) in env {
521        cmd.env(k, v).arg("--env").arg(format!("{k}={v}"));
522    }
523    cmd.arg("--stdout")
524        .arg(log_path)
525        .arg("--stderr")
526        .arg(log_path)
527        .arg(bundle);
528    cmd
529}
530
531#[cfg(target_os = "macos")]
532/// Pids whose command line STARTS with `prefix` (so `open`, which merely carries the
533/// bundle path as an argument, is never mistaken for the app).
534fn pids_with_command_prefix(prefix: &str) -> std::result::Result<Vec<u32>, String> {
535    let out = Command::new("ps")
536        .args(["-ax", "-o", "pid=,command="])
537        .output()
538        .map_err(|e| format!("ps PID inventory failed: {e}"))?;
539    if !out.status.success() {
540        return Err(format!("ps PID inventory exited with {}", out.status));
541    }
542    let mut pids = vec![];
543    for line in String::from_utf8_lossy(&out.stdout).lines() {
544        let line = line.trim_start();
545        let Some((pid, cmd)) = line.split_once(' ') else {
546            continue;
547        };
548        if cmd.trim_start().starts_with(prefix) {
549            pids.push(
550                pid.trim()
551                    .parse::<u32>()
552                    .map_err(|e| format!("invalid PID inventory row: {e}"))?,
553            );
554        }
555    }
556    Ok(pids)
557}
558
559pub fn launch(spec: &LaunchSpec, ws: &QaWorkspace, tracker: &Tracker) -> Result<Control> {
560    if !spec.binary.exists() {
561        return err(format!(
562            "UI app binary not found: {}",
563            spec.binary.display()
564        ));
565    }
566    let id = new_id().replace('-', "");
567    let service = format!("control-{}", &id[..8]);
568    // Isolated per-run suite root: the app's service.json never collides with an installed app's.
569    let suite =
570        Suite::at(ws.home.join(format!("suite-{}", &id[..8]))).map_err(|e| Error(e.to_string()))?;
571    let log_path = ws.evidence_dir.join(format!("app-{}.log", &id[..8]));
572    // launchd-started apps cannot open log files on external volumes (`open` fails with
573    // -10810), so the live log goes to the system temp dir and is copied into the
574    // evidence dir when the app stops.
575    let raw_log = ws.home.join(format!("app-{}.log", &id[..8]));
576    let env = launch_env(spec, ws, &suite, &service)?;
577    let frontmost_before = frontmost();
578    let focus = FocusMonitor::start(spec.mode != Mode::Visible);
579    let deadline = Instant::now() + spec.startup_timeout;
580
581    // The executable identity is registered inside start_process BEFORE `open`, so
582    // startup foreground samples are attributed; the pid follows once known.
583    let (pid, child) = start_process(spec, ws, &env, &raw_log, &id, tracker, &focus, deadline)?;
584    focus.track_primary(pid);
585    tracker.register(pid, &spec.label);
586
587    let mut control = Control {
588        pid,
589        service: service.clone(),
590        log_path,
591        raw_log,
592        suite: suite.clone(),
593        endpoint: String::new(),
594        client: Mutex::new(None),
595        child,
596        tracker: tracker.clone(),
597        frontmost_before,
598        focus: Some(focus),
599        request_timeout: Duration::from_secs(20),
600        stopped: false,
601    };
602    loop {
603        // Each attempt is bounded by the startup deadline: a hung host must not defeat it.
604        let left = deadline.saturating_duration_since(Instant::now());
605        if let Some(c) = bounded_connect_health(&suite, &service, left.min(Duration::from_secs(5)))
606        {
607            control.endpoint = c.record().endpoint.clone();
608            *control.client.lock().unwrap() = Some(c);
609            break;
610        }
611        if !is_alive(pid) {
612            let tail = fs::read_to_string(&control.raw_log)
613                .map(|t| crate::util::tail(&t, 1500))
614                .unwrap_or_default();
615            return err(format!(
616                "app exited before its control server came up (pid {pid}); log tail: {tail}"
617            ));
618        }
619        if Instant::now() >= deadline {
620            let _ = control.stop();
621            return err(format!(
622                "control service did not answer within {}ms; was the app built with its control feature and launched with RIGHTKIT_CONTROL_SERVICE?",
623                spec.startup_timeout.as_millis()
624            ));
625        }
626        sleep_ms(100);
627    }
628    // The server comes up before the window exists (hidden launch): wait until the page
629    // itself answers, so the first scenario step never races window creation.
630    loop {
631        if let Ok(Value::String(state)) = control.eval("return document.readyState;") {
632            if state == "complete" || state == "interactive" {
633                break;
634            }
635        }
636        if Instant::now() >= deadline {
637            let _ = control.stop();
638            return err(format!(
639                "app window never became ready within {}ms",
640                spec.startup_timeout.as_millis()
641            ));
642        }
643        sleep_ms(100);
644    }
645    Ok(control)
646}
647
648#[allow(clippy::too_many_arguments)]
649fn start_process(
650    spec: &LaunchSpec,
651    ws: &QaWorkspace,
652    env: &[(String, String)],
653    log_path: &Path,
654    id: &str,
655    _tracker: &Tracker,
656    focus: &FocusMonitor,
657    deadline: Instant,
658) -> Result<(u32, Option<OwnedChild>)> {
659    #[cfg(target_os = "macos")]
660    {
661        let bundle = stage_bundle(spec, ws, id)?;
662        let needle = format!("{}/Contents/MacOS/", bundle.display());
663        // Register the REAL executable (CFBundleExecutable) before `open`. When it cannot be
664        // resolved and validated, register nothing: the launch interval stays unverified and
665        // `owned_never_frontmost` fails closed instead of matching a guessed name.
666        if let Some(executable) = resolve_bundle_executable(&bundle) {
667            focus.track_prefix(executable);
668        }
669        let mut cmd = open_command(spec, ws, env, log_path, &bundle);
670        let st = cmd.status()?;
671        if !st.success() {
672            return err(format!("open exited {st}"));
673        }
674        loop {
675            if let Some(pid) = pids_with_command_prefix(&needle)
676                .ok()
677                .and_then(|pids| pids.into_iter().next())
678            {
679                return Ok((pid, None));
680            }
681            if Instant::now() >= deadline {
682                return err("launched app pid not found");
683            }
684            sleep_ms(100);
685        }
686    }
687    #[cfg(not(target_os = "macos"))]
688    {
689        let _ = (ws, id, focus);
690        let log = fs::File::create(log_path)?;
691        let log2 = log.try_clone()?;
692        let mut cmd = Command::new(&spec.binary);
693        cmd.current_dir(&ws.home)
694            .stdin(Stdio::null())
695            .stdout(Stdio::from(log))
696            .stderr(Stdio::from(log2));
697        for (k, v) in env {
698            cmd.env(k, v);
699        }
700        let mut owned = OwnedCommand::from_command(cmd);
701        if spec.mode != Mode::Visible {
702            owned.windows_hide();
703        }
704        let _ = deadline;
705        let child = owned
706            .spawn()
707            .map_err(|e| Error(format!("failed to start {}: {e}", spec.binary.display())))?;
708        Ok((child.id(), Some(child)))
709    }
710}
711
712impl Control {
713    pub fn set_request_timeout(&mut self, t: Duration) {
714        self.request_timeout = t;
715    }
716
717    /// One bounded request. The deadline is real: when it passes the connection is
718    /// aborted (unblocking the read) and the call fails. A request that may already have
719    /// reached the app is replayed only when the method is a pure read; effects
720    /// (click, key, type, command, eval, ...) are never silently executed twice.
721    fn rpc(&self, method: &str, params: Value) -> Result<Value> {
722        let replayable = matches!(method, "health" | "dom" | "ax" | "screenshot" | "move");
723        let mut g = self.client.lock().unwrap();
724        for attempt in 0..2 {
725            let client = match g.as_ref() {
726                Some(c) => c.clone(),
727                None => {
728                    // Not yet sent anything: reconnecting is always safe.
729                    let c = Arc::new(connect_bounded(
730                        &self.suite,
731                        &self.service,
732                        self.request_timeout,
733                    )?);
734                    *g = Some(c.clone());
735                    c
736                }
737            };
738            match call_bounded(&client, method, params.clone(), self.request_timeout) {
739                Ok(v) => return Ok(v),
740                Err(CallError::TimedOut) => {
741                    *g = None;
742                    return err(format!(
743                        "{method}: no reply within {}ms; connection aborted",
744                        self.request_timeout.as_millis()
745                    ));
746                }
747                Err(CallError::Service(e)) if e.retryable && replayable && attempt == 0 => {
748                    *g = None
749                }
750                Err(CallError::Service(e)) => {
751                    if e.retryable {
752                        *g = None;
753                    }
754                    let mut m = svc_err(e);
755                    if !replayable {
756                        m = Error(format!("{} (not replayed: {method} has effects and the first attempt may have been applied)", m.0));
757                    }
758                    return Err(m);
759                }
760            }
761        }
762        err("control rpc failed")
763    }
764
765    fn json(&self, method: &str, params: Value) -> Result<Value> {
766        let v = self.rpc(method, params)?;
767        if v.get("ok") == Some(&Value::Bool(false)) {
768            return err(format!(
769                "{method}: {}",
770                v.get("error")
771                    .and_then(Value::as_str)
772                    .unwrap_or("unknown error")
773            ));
774        }
775        Ok(v)
776    }
777
778    pub fn health(&self) -> Result<Value> {
779        self.rpc("health", json!({}))
780    }
781    /// Resize hidden native content without screen clamping or activation.
782    /// Returns measured CSS dimensions; this effect is never automatically replayed.
783    pub fn set_viewport(&self, width: u32, height: u32) -> Result<(u32, u32)> {
784        let v = self.json("set_viewport", json!({"width": width, "height": height}))?;
785        let dimension = |key: &str| {
786            v.get(key)
787                .and_then(Value::as_u64)
788                .and_then(|n| u32::try_from(n).ok())
789                .filter(|n| *n > 0)
790                .ok_or_else(|| Error(format!("set_viewport returned invalid {key}")))
791        };
792        Ok((dimension("innerWidth")?, dimension("innerHeight")?))
793    }
794    /// Prove the allowlist: an app that is not on it is refused at hello. Returns the error code.
795    pub fn unlisted_app_probe(&self) -> Result<String> {
796        match Client::connect(&self.suite, &self.service, "not-allowlisted-probe") {
797            Ok(_) => err("an unlisted app was accepted by the control service"),
798            Err(e) => Ok(e.code),
799        }
800    }
801    pub fn click(&self, x: f64, y: f64, button: &str, count: u32) -> Result<()> {
802        self.json(
803            "click",
804            json!({"x": x, "y": y, "button": button, "count": count}),
805        )
806        .map(|_| ())
807    }
808    /// [`click`](Self::click) holding modifiers (`"shift"`, `"cmd"`, `"alt"`, `"ctrl"`). They
809    /// travel on the native mouse events, so the page sees `e.shiftKey` etc. as real input.
810    pub fn click_with(
811        &self,
812        x: f64,
813        y: f64,
814        button: &str,
815        count: u32,
816        modifiers: &[&str],
817    ) -> Result<()> {
818        self.json(
819            "click",
820            json!({"x": x, "y": y, "button": button, "count": count, "modifiers": modifiers}),
821        )
822        .map(|_| ())
823    }
824    pub fn move_to(&self, x: f64, y: f64) -> Result<()> {
825        self.json("move", json!({"x": x, "y": y})).map(|_| ())
826    }
827    /// One native pointer phase (`down`, `drag`, `up`, `move`) in viewport CSS
828    /// pixels. Use down/drag/up for a scrub split across requests; keep the same
829    /// button and modifiers on each phase. Hidden launches use the embedded path.
830    pub fn pointer(
831        &self,
832        phase: &str,
833        x: f64,
834        y: f64,
835        button: &str,
836        modifiers: &[&str],
837    ) -> Result<()> {
838        self.json(
839            "pointer",
840            json!({"phase": phase, "x": x, "y": y, "button": button, "modifiers": modifiers}),
841        )
842        .map(|_| ())
843    }
844    pub fn drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
845        self.json(
846            "drag",
847            json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1, "steps": steps}),
848        )
849        .map(|_| ())
850    }
851    pub fn wheel(&self, x: f64, y: f64, dx: f64, dy: f64) -> Result<()> {
852        self.json("wheel", json!({"x": x, "y": y, "dx": dx, "dy": dy}))
853            .map(|_| ())
854    }
855    pub fn key(&self, spec: &str) -> Result<()> {
856        self.json("key", json!({"key": spec})).map(|_| ())
857    }
858    pub fn type_text(&self, text: &str) -> Result<()> {
859        self.json("type", json!({"text": text})).map(|_| ())
860    }
861    /// Evaluate a JS function body (`return ...`) in the page; returns its JSON value.
862    pub fn eval(&self, js: &str) -> Result<Value> {
863        Ok(self
864            .json("eval", json!({"js": js}))?
865            .get("value")
866            .cloned()
867            .unwrap_or(Value::Null))
868    }
869    pub fn dom(&self, selector: &str) -> Result<Vec<Value>> {
870        let v = self.json("dom", json!({"selector": selector}))?;
871        Ok(v.get("elements")
872            .and_then(Value::as_array)
873            .cloned()
874            .unwrap_or_default())
875    }
876    pub fn ax(&self) -> Result<Vec<Value>> {
877        let v = self.json("ax", json!({}))?;
878        Ok(v.get("nodes")
879            .and_then(Value::as_array)
880            .cloned()
881            .unwrap_or_default())
882    }
883    pub fn screenshot_png(&self) -> Result<Vec<u8>> {
884        let v = self.json("screenshot", json!({}))?;
885        // The server masks credential elements before writing; a server that does not
886        // report `masked` predates redaction and its captures are refused.
887        if v.get("masked").and_then(Value::as_u64).is_none() {
888            return err("control server does not redact screenshots; capture refused");
889        }
890        let path = PathBuf::from(
891            v.get("path")
892                .and_then(Value::as_str)
893                .ok_or_else(|| Error("screenshot returned no path".into()))?,
894        );
895        let body = fs::read(&path)?;
896        let _ = fs::remove_file(&path);
897        if body.len() < 8 || &body[1..4] != b"PNG" {
898            return err("screenshot method did not return a PNG");
899        }
900        Ok(body)
901    }
902    pub fn screenshot_to(&self, path: &Path) -> Result<()> {
903        if let Some(p) = path.parent() {
904            fs::create_dir_all(p)?;
905        }
906        fs::write(path, self.screenshot_png()?)?;
907        Ok(())
908    }
909    /// A typed app command registered with `Control::command(name, f)`. The server
910    /// hands the command its `args` string verbatim.
911    pub fn command(&self, name: &str, args: &Value) -> Result<Value> {
912        let args = match args {
913            Value::String(s) => s.clone(),
914            Value::Null => String::new(),
915            other => other.to_string(),
916        };
917        let v = self.json("command", json!({"name": name, "args": args}))?;
918        Ok(v.get("result").cloned().unwrap_or(Value::Null))
919    }
920
921    /// Centre of the first element matching `selector`, in viewport CSS px.
922    pub fn center_of(&self, selector: &str) -> Result<(f64, f64)> {
923        let els = self.dom(selector)?;
924        let first = els
925            .first()
926            .ok_or_else(|| Error(format!("selector matched no element: {selector}")))?;
927        let r: Vec<f64> = first
928            .get("rect")
929            .and_then(Value::as_array)
930            .map(|a| a.iter().filter_map(Value::as_f64).collect())
931            .unwrap_or_default();
932        if r.len() < 4 || r[2] <= 0.0 || r[3] <= 0.0 {
933            return err(format!("{selector} has no visible box"));
934        }
935        Ok((r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
936    }
937    pub fn click_selector(&self, selector: &str) -> Result<()> {
938        let (x, y) = self.center_of(selector)?;
939        self.click(x, y, "left", 1)
940    }
941
942    /// Poll a JS function body until it returns something truthy; bounded.
943    pub fn wait_eval(&self, js: &str, timeout: Duration) -> Result<Value> {
944        let deadline = Instant::now() + timeout;
945        let mut last;
946        loop {
947            match self.eval(js) {
948                Ok(v) => {
949                    let truthy = !matches!(v, Value::Null | Value::Bool(false))
950                        && v != json!(0)
951                        && v != json!("");
952                    if truthy {
953                        return Ok(v);
954                    }
955                    last = v;
956                }
957                Err(e) => last = json!(e.0),
958            }
959            if Instant::now() >= deadline {
960                return err(format!("timeout waiting for `{js}` (last: {last})"));
961            }
962            sleep_ms(100);
963        }
964    }
965
966    /// Wait until `text` appears in the visible text (`innerText`) of the first element
967    /// matching `selector` (`None` = the whole body). Polls every 100 ms until `timeout`.
968    pub fn wait_for_text(
969        &self,
970        selector: Option<&str>,
971        text: &str,
972        timeout: Duration,
973    ) -> Result<()> {
974        let sel = serde_json::to_string(selector.unwrap_or("body")).unwrap_or_default();
975        let needle = serde_json::to_string(text).unwrap_or_default();
976        let js = format!(
977            "return ((document.querySelector({sel}) || {{}}).innerText || '').includes({needle});"
978        );
979        self.wait_eval(&js, timeout).map(|_| ())
980    }
981
982    /// Stop the app: kill the recorded pid tree, prove the port closed, and check
983    /// we did not change the frontmost app.
984    pub fn stop(&mut self) -> Result<StopReport> {
985        if self.stopped {
986            return err("control session already stopped");
987        }
988        self.stopped = true;
989        if let Some(mut c) = self.child.take() {
990            let _ = c.terminate_tree();
991        }
992        kill_tree(self.pid);
993        let deadline = Instant::now() + Duration::from_secs(5);
994        while Instant::now() < deadline && (is_alive(self.pid) || endpoint_open(&self.endpoint)) {
995            sleep_ms(50);
996        }
997        // Keep the live log until workspace finish/disposal; failed evidence copies
998        // must not destroy the only readable startup diagnostics.
999        let log_copy = fs::copy(&self.raw_log, &self.log_path);
1000        let after = frontmost();
1001        let (steady, states, owned_never_frontmost, owned_frontmost_pids) = self
1002            .focus
1003            .take()
1004            .map(|mut f| f.finish())
1005            .unwrap_or((false, vec![], false, vec![]));
1006        let process_gone = !is_alive(self.pid);
1007        let endpoint_closed = self.endpoint.is_empty() || !endpoint_open(&self.endpoint);
1008        if process_gone {
1009            self.tracker.forget(self.pid);
1010        }
1011        log_copy.map_err(|e| {
1012            Error(format!(
1013                "cannot preserve app log {}: {e}",
1014                self.raw_log.display()
1015            ))
1016        })?;
1017        Ok(StopReport {
1018            pid: self.pid,
1019            endpoint_closed,
1020            process_gone,
1021            frontmost_unchanged: steady
1022                && self.frontmost_before.is_some()
1023                && self.frontmost_before == after,
1024            frontmost_states: states,
1025            frontmost_before: self.frontmost_before.clone(),
1026            frontmost_after: after,
1027            owned_never_frontmost,
1028            owned_frontmost_pids,
1029        })
1030    }
1031}
1032
1033impl Drop for Control {
1034    fn drop(&mut self) {
1035        if !self.stopped {
1036            let _ = self.stop();
1037        }
1038    }
1039}
1040
1041#[cfg(test)]
1042mod launch_path_tests {
1043    use super::*;
1044
1045    fn fixture() -> (QaWorkspace, LaunchSpec) {
1046        let root = crate::storage::app_temp_dir().join(format!("rkqa-launch-{}", new_id()));
1047        let ws = crate::workspace::create(&root, None, "control-test-app").unwrap();
1048        let binary = ws.root.join("control-test-app");
1049        fs::write(&binary, "fixture binary").unwrap();
1050        let spec = LaunchSpec {
1051            binary,
1052            mode: Mode::Hidden,
1053            env: vec![],
1054            startup_timeout: Duration::from_secs(90),
1055            label: "fixture".into(),
1056        };
1057        (ws, spec)
1058    }
1059
1060    fn cleanup(ws: &QaWorkspace) {
1061        let root = ws
1062            .root
1063            .parent()
1064            .unwrap()
1065            .parent()
1066            .unwrap()
1067            .parent()
1068            .unwrap()
1069            .to_path_buf();
1070        let report = crate::workspace::dispose(ws, false, 1, 0);
1071        assert!(report.retained.is_empty(), "{report:?}");
1072        fs::remove_dir_all(root).unwrap();
1073    }
1074
1075    #[test]
1076    fn launch_environment_keeps_all_owned_paths_internal_and_rejects_external_overrides() {
1077        let (ws, mut spec) = fixture();
1078        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1079        let env: std::collections::BTreeMap<_, _> =
1080            launch_env(&spec, &ws, &suite, "control-12345678")
1081                .unwrap()
1082                .into_iter()
1083                .collect();
1084        for (key, value) in &env {
1085            if Path::new(value).is_absolute() {
1086                assert!(Path::new(value).starts_with(&ws.home), "{key}={value}");
1087                assert!(!Path::new(value).starts_with(&ws.root), "{key}={value}");
1088            }
1089        }
1090        assert_eq!(env["HOME"], ws.home.to_string_lossy());
1091        assert_eq!(env["TMPDIR"], ws.home.join("tmp").to_string_lossy());
1092        assert_eq!(env["RIGHTKIT_SUITE_ROOT"], suite.root().to_string_lossy());
1093        assert_eq!(env["RIGHTKIT_QA_HIDDEN"], "1");
1094        assert_eq!(env["RIGHTKIT_QA_BACKGROUND"], "1");
1095        // rightkit-service falls back to TMPDIR/rk-<suite-hash>-<service>.sock
1096        // when its runtime socket exceeds 100 bytes. App TMPDIR must fit too.
1097        #[cfg(target_os = "macos")]
1098        assert!(
1099            Path::new(&env["TMPDIR"])
1100                .join("rk-12345678-control-12345678.sock")
1101                .as_os_str()
1102                .len()
1103                <= 100
1104        );
1105        assert!(suite
1106            .service_dir("control-12345678")
1107            .join("service.json")
1108            .starts_with(&ws.home));
1109        for key in [
1110            "RIGHTKIT_SUITE_ROOT",
1111            "RIGHTKIT_QA_DATA_DIR",
1112            "RIGHTKIT_RUN_ROOT",
1113        ] {
1114            spec.env = vec![(
1115                key.into(),
1116                ws.root.join("external").to_string_lossy().into(),
1117            )];
1118            assert!(
1119                launch_env(&spec, &ws, &suite, "control-12345678").is_err(),
1120                "{key}"
1121            );
1122        }
1123        spec.env = vec![("RIGHTKIT_SECRET".into(), "redacted".into())];
1124        assert!(launch_env(&spec, &ws, &suite, "control-12345678").is_err());
1125        cleanup(&ws);
1126    }
1127
1128    #[cfg(target_os = "macos")]
1129    #[test]
1130    fn raw_binary_and_supplied_bundle_are_staged_inside_app_home_before_open() {
1131        let (ws, mut spec) = fixture();
1132        let id = "1234567890abcdef";
1133        let bundle = stage_bundle(&spec, &ws, id).unwrap();
1134        assert!(bundle.starts_with(ws.home.join("apps")));
1135        assert!(!bundle.starts_with(&ws.root));
1136        assert_eq!(
1137            fs::read(bundle.join("Contents/MacOS/control-test-app")).unwrap(),
1138            b"fixture binary"
1139        );
1140        let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap();
1141        assert!(plist.contains("<key>LSUIElement</key><true/>"));
1142        let suite = Suite::at(ws.home.join("suite-12345678")).unwrap();
1143        let env = launch_env(&spec, &ws, &suite, "control-12345678").unwrap();
1144        let log = ws.home.join("app-12345678.log");
1145        let cmd = open_command(&spec, &ws, &env, &log, &bundle);
1146        assert_eq!(cmd.get_current_dir(), Some(ws.home.as_path()));
1147        let args: Vec<_> = cmd
1148            .get_args()
1149            .map(|v| v.to_string_lossy().into_owned())
1150            .collect();
1151        assert_eq!(&args[..3], &["-g", "-j", "-n"]);
1152        for (key, value) in &env {
1153            assert!(
1154                args.windows(2)
1155                    .any(|pair| pair[0] == "--env" && pair[1] == format!("{key}={value}")),
1156                "{key}"
1157            );
1158        }
1159        for flag in ["--stdout", "--stderr"] {
1160            assert!(args
1161                .windows(2)
1162                .any(|pair| pair[0] == flag && Path::new(&pair[1]) == log));
1163        }
1164        assert_eq!(args.last().unwrap(), &bundle.to_string_lossy());
1165        // A provided .app must be copied too; it may live on the external build volume.
1166        spec.binary = make_bundle(&ws.root, &spec.binary, "original", true).unwrap();
1167        fs::create_dir_all(spec.binary.join("Contents/Resources")).unwrap();
1168        fs::write(
1169            spec.binary.join("Contents/Resources/fixture.txt"),
1170            "resource",
1171        )
1172        .unwrap();
1173        let supplied = stage_bundle(&spec, &ws, "fedcba0987654321").unwrap();
1174        assert!(supplied.starts_with(&ws.home));
1175        assert_ne!(supplied, spec.binary);
1176        assert_eq!(
1177            fs::read_to_string(supplied.join("Contents/Resources/fixture.txt")).unwrap(),
1178            "resource"
1179        );
1180        std::os::unix::fs::symlink(
1181            ws.root.join("control-test-app"),
1182            spec.binary.join("Contents/Resources/external"),
1183        )
1184        .unwrap();
1185        assert!(stage_bundle(&spec, &ws, "0123456789abcdef")
1186            .unwrap_err()
1187            .0
1188            .contains("escapes internal bundle"));
1189        cleanup(&ws);
1190    }
1191}