Skip to main content

rightkit_control/
offscreen.rs

1//! Off-screen launch for recording scenarios (`launch_offscreen`).
2//!
3//! The driven window is placed outside the union of ALL displays while staying
4//! visible (never hidden, never minimized), so the window server keeps compositing
5//! it and a recorder can capture it without anything appearing on a user's screen.
6//!
7//! Two halves:
8//! - In-process (the driven app, embedded path): the app calls
9//!   [`start_from_env`] / [`start_for_window`] at startup, before its window is first
10//!   ordered, when it was launched with `RIGHTKIT_OFFSCREEN=1`. macOS: class-scoped
11//!   `constrainFrameRect:toScreen:` override, `NSProcessInfo` activity held for the
12//!   session (App Nap defence), and (cargo feature `offscreen-private-spi`, default
13//!   off) the private WKWebView `_setWindowOcclusionDetectionEnabled:NO`. Windows:
14//!   `SetWindowPos` to off-monitor coordinates and an EcoQoS (execution-speed
15//!   throttling) opt-out.
16//! - Controller side: [`launch_offscreen`] spawns the app with that env, waits for
17//!   the app's session report, and returns a session with PID and native window
18//!   identity. [`OffscreenLaunch::end`] kills the process it started.
19//!
20//! [`offscreen_qualification`] re-reads the live window and verifies it is off all
21//! displays, visible, not miniaturized and (macOS) that the occlusion SPI responded.
22
23use crate::json::esc;
24use std::fmt;
25use std::path::PathBuf;
26use std::process::{Child, Command, Stdio};
27use std::sync::Mutex;
28use std::time::{Duration, Instant};
29
30#[cfg(target_os = "macos")]
31#[path = "offscreen_mac.rs"]
32mod native;
33#[cfg(windows)]
34#[path = "offscreen_win.rs"]
35mod native;
36#[cfg(not(any(target_os = "macos", windows)))]
37mod native {
38    use super::*;
39    pub struct Session;
40    pub fn end(_: Session) -> Result<(), OffscreenError> {
41        Err(OffscreenError::UnsupportedPlatform)
42    }
43    pub fn session_qualification(_: &Session) -> Result<QualificationReport, OffscreenError> {
44        Err(OffscreenError::UnsupportedPlatform)
45    }
46    pub fn report(_: &Session) -> SessionReport {
47        unreachable!("no session exists on this platform")
48    }
49}
50
51/// Env flag the controller sets and the app reads.
52pub const ENV_OFFSCREEN: &str = "RIGHTKIT_OFFSCREEN";
53/// Env path where the app writes its session report (JSON, atomic rename).
54pub const ENV_REPORT: &str = "RIGHTKIT_OFFSCREEN_REPORT";
55/// Distance past the right edge of the display union.
56pub const OFFSCREEN_GAP: f64 = 1000.0;
57/// Placement read-back tolerance in points/pixels.
58pub const PLACEMENT_TOLERANCE: f64 = 1.0;
59
60// ---------- errors ----------
61
62#[derive(Debug, Clone, PartialEq)]
63pub enum OffscreenError {
64    UnsupportedPlatform,
65    NoDisplays,
66    InvalidSize,
67    NotMainThread,
68    NullWindow,
69    AlreadyActive,
70    NoActiveSession,
71    /// `constrainFrameRect:toScreen:` could not be overridden on the window class.
72    ConstrainOverrideUnavailable,
73    /// Cargo feature `offscreen-private-spi` is off: the occlusion SPI was not compiled in.
74    OcclusionSpiNotCompiled,
75    /// The WKWebView does not respond to `_setWindowOcclusionDetectionEnabled:`.
76    OcclusionSpiUnavailable,
77    /// `NSProcessInfo` activity could not be started.
78    ActivityUnavailable,
79    /// Windows: EcoQoS opt-out failed (Win32 error code).
80    ThrottlingOptOutFailed(u32),
81    PlacementNotApplied {
82        requested: Rect,
83        actual: Rect,
84    },
85    RestoreFailed {
86        requested: Rect,
87        actual: Rect,
88    },
89    WindowHidden,
90    WindowMiniaturized,
91    WindowOnDisplay {
92        frame: Rect,
93    },
94    InvalidBackingScale,
95    WindowGone,
96    /// Window reads/writes failed with a platform message.
97    Native(String),
98    // launcher
99    Launch(String),
100    ReportTimeout,
101    ChildExited(String),
102    AppReportedError(String),
103    BadReport(String),
104    IdentityMismatch {
105        child_pid: u32,
106        reported_pid: u32,
107    },
108    WindowNotFound {
109        pid: u32,
110        window_id: u64,
111    },
112}
113
114impl fmt::Display for OffscreenError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "{self:?}")
117    }
118}
119impl std::error::Error for OffscreenError {}
120
121// ---------- geometry (pure) ----------
122
123/// A rectangle in the platform's global desktop coordinates (AppKit bottom-left on
124/// macOS, virtual-screen top-left on Windows). The placement math only uses x extents
125/// and the lowest y, so it is axis-convention agnostic.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub struct Rect {
128    pub x: f64,
129    pub y: f64,
130    pub w: f64,
131    pub h: f64,
132}
133
134impl Rect {
135    pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
136        Self { x, y, w, h }
137    }
138    pub fn right(&self) -> f64 {
139        self.x + self.w
140    }
141    pub fn bottom(&self) -> f64 {
142        self.y + self.h
143    }
144    /// True when the interiors overlap (touching edges do not intersect).
145    pub fn intersects(&self, o: &Rect) -> bool {
146        self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom()
147    }
148    fn near(&self, o: &Rect, tol: f64) -> bool {
149        (self.x - o.x).abs() <= tol
150            && (self.y - o.y).abs() <= tol
151            && (self.w - o.w).abs() <= tol
152            && (self.h - o.h).abs() <= tol
153    }
154}
155
156/// Bounding box of all displays.
157pub fn display_union(displays: &[Rect]) -> Option<Rect> {
158    let first = displays.first()?;
159    let (mut x0, mut y0, mut x1, mut y1) = (first.x, first.y, first.right(), first.bottom());
160    for d in &displays[1..] {
161        x0 = x0.min(d.x);
162        y0 = y0.min(d.y);
163        x1 = x1.max(d.right());
164        y1 = y1.max(d.bottom());
165    }
166    Some(Rect::new(x0, y0, x1 - x0, y1 - y0))
167}
168
169/// Frame for a `size` window placed [`OFFSCREEN_GAP`] right of the union of ALL
170/// displays (not just the primary one), aligned to the union's lowest y. The result
171/// intersects neither any display nor the union bounding box.
172pub fn offscreen_frame(displays: &[Rect], size: (f64, f64)) -> Result<Rect, OffscreenError> {
173    if !(size.0.is_finite() && size.1.is_finite() && size.0 > 0.0 && size.1 > 0.0) {
174        return Err(OffscreenError::InvalidSize);
175    }
176    if displays
177        .iter()
178        .any(|d| !(d.x.is_finite() && d.y.is_finite() && d.w.is_finite() && d.h.is_finite()))
179    {
180        return Err(OffscreenError::NoDisplays);
181    }
182    let u = display_union(displays).ok_or(OffscreenError::NoDisplays)?;
183    Ok(Rect::new(u.right() + OFFSCREEN_GAP, u.y, size.0, size.1))
184}
185
186// ---------- qualification (pure) ----------
187
188#[derive(Debug, Clone, Copy, PartialEq)]
189pub enum SpiStatus {
190    /// Selector present and invoked.
191    Responded,
192    NotCompiled,
193    Unavailable,
194    /// Windows has no occlusion SPI.
195    NotApplicable,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq)]
199pub enum Platform {
200    Mac,
201    Windows,
202}
203
204impl Platform {
205    pub fn current() -> Self {
206        if cfg!(windows) {
207            Platform::Windows
208        } else {
209            Platform::Mac
210        }
211    }
212}
213
214/// Live window facts the qualification checks run on.
215#[derive(Debug, Clone, PartialEq)]
216pub struct WindowState {
217    pub frame: Rect,
218    pub displays: Vec<Rect>,
219    pub visible: bool,
220    pub miniaturized: bool,
221    pub backing_scale: f64,
222    pub spi: SpiStatus,
223}
224
225#[derive(Debug, Clone, PartialEq)]
226pub struct Check {
227    pub name: &'static str,
228    pub error: Option<OffscreenError>,
229}
230
231#[derive(Debug, Clone, PartialEq)]
232pub struct QualificationReport {
233    pub platform: Platform,
234    pub checks: Vec<Check>,
235}
236
237impl QualificationReport {
238    pub fn passed(&self) -> bool {
239        self.checks.iter().all(|c| c.error.is_none())
240    }
241    pub fn failures(&self) -> Vec<&Check> {
242        self.checks.iter().filter(|c| c.error.is_some()).collect()
243    }
244    /// First failure as a typed error.
245    pub fn into_result(self) -> Result<Self, OffscreenError> {
246        match self.checks.iter().find_map(|c| c.error.clone()) {
247            Some(e) => Err(e),
248            None => Ok(self),
249        }
250    }
251}
252
253/// Per-OS qualification over a window snapshot.
254pub fn qualify(state: &WindowState, platform: Platform) -> QualificationReport {
255    let on_display = state.displays.iter().any(|d| d.intersects(&state.frame))
256        || display_union(&state.displays).is_some_and(|u| u.intersects(&state.frame));
257    let mut checks = vec![
258        Check {
259            name: "off_all_displays",
260            error: if state.displays.is_empty() {
261                Some(OffscreenError::NoDisplays)
262            } else if on_display {
263                Some(OffscreenError::WindowOnDisplay { frame: state.frame })
264            } else {
265                None
266            },
267        },
268        Check {
269            name: "visible",
270            error: (!state.visible).then_some(OffscreenError::WindowHidden),
271        },
272        Check {
273            name: "not_miniaturized",
274            error: state
275                .miniaturized
276                .then_some(OffscreenError::WindowMiniaturized),
277        },
278        Check {
279            name: "backing_scale",
280            error: (!(state.backing_scale.is_finite() && state.backing_scale > 0.0))
281                .then_some(OffscreenError::InvalidBackingScale),
282        },
283    ];
284    if platform == Platform::Mac {
285        checks.push(Check {
286            name: "occlusion_spi_responded",
287            error: match state.spi {
288                SpiStatus::Responded => None,
289                SpiStatus::NotCompiled => Some(OffscreenError::OcclusionSpiNotCompiled),
290                SpiStatus::Unavailable | SpiStatus::NotApplicable => {
291                    Some(OffscreenError::OcclusionSpiUnavailable)
292                }
293            },
294        });
295    }
296    QualificationReport { platform, checks }
297}
298
299// ---------- placement / restore over a window abstraction ----------
300
301/// Window operations the placement logic needs. Implemented by the native windows
302/// and by fakes in tests.
303pub trait WindowOps {
304    fn displays(&self) -> Result<Vec<Rect>, OffscreenError>;
305    fn frame(&self) -> Result<Rect, OffscreenError>;
306    fn backing_scale(&self) -> Result<f64, OffscreenError>;
307    fn is_visible(&self) -> Result<bool, OffscreenError>;
308    fn is_miniaturized(&self) -> Result<bool, OffscreenError>;
309    /// Checks before any move (Windows: refuse a minimized window or one owned by another process).
310    fn precheck(&self) -> Result<(), OffscreenError> {
311        Ok(())
312    }
313    fn set_origin(&mut self, x: f64, y: f64) -> Result<(), OffscreenError>;
314    /// Make the window visible to the window server without activating the app.
315    fn show(&mut self) -> Result<(), OffscreenError>;
316    /// Take the window off screen before it is moved back (macOS `orderOut:`, so the
317    /// restore never flashes on a display; Windows `ShowWindow(SW_HIDE)`). Default no-op.
318    fn hide_for_restore(&mut self) -> Result<(), OffscreenError> {
319        Ok(())
320    }
321}
322
323/// Where the window was and where it is now.
324#[derive(Debug, Clone, Copy, PartialEq)]
325pub struct Placement {
326    pub original: Rect,
327    pub placed: Rect,
328    pub backing_scale: f64,
329}
330
331/// Move `w` off all displays, make it visible, and read the frame and scale back.
332/// Verifies the read-back frame equals the request; the caller still runs
333/// [`qualify`] for visibility and display checks.
334pub fn place<W: WindowOps>(w: &mut W) -> Result<Placement, OffscreenError> {
335    w.precheck()?;
336    let original = w.frame()?;
337    let target = offscreen_frame(&w.displays()?, (original.w, original.h))?;
338    w.set_origin(target.x, target.y)?;
339    w.show()?;
340    let placed = w.frame()?;
341    if !placed.near(&target, PLACEMENT_TOLERANCE) {
342        return Err(OffscreenError::PlacementNotApplied {
343            requested: target,
344            actual: placed,
345        });
346    }
347    Ok(Placement {
348        original,
349        placed,
350        backing_scale: w.backing_scale()?,
351    })
352}
353
354/// Hide, move back to the original frame and verify.
355pub fn restore<W: WindowOps>(w: &mut W, p: &Placement) -> Result<(), OffscreenError> {
356    w.hide_for_restore()?;
357    w.set_origin(p.original.x, p.original.y)?;
358    let actual = w.frame()?;
359    if !actual.near(&p.original, PLACEMENT_TOLERANCE) {
360        return Err(OffscreenError::RestoreFailed {
361            requested: p.original,
362            actual,
363        });
364    }
365    Ok(())
366}
367
368/// Snapshot a window for [`qualify`].
369pub fn snapshot<W: WindowOps>(w: &W, spi: SpiStatus) -> Result<WindowState, OffscreenError> {
370    Ok(WindowState {
371        frame: w.frame()?,
372        displays: w.displays()?,
373        visible: w.is_visible()?,
374        miniaturized: w.is_miniaturized()?,
375        backing_scale: w.backing_scale()?,
376        spi,
377    })
378}
379
380// ---------- session report ----------
381
382/// Native identity and placement of the driven window.
383#[derive(Debug, Clone, PartialEq)]
384pub struct SessionReport {
385    pub pid: u32,
386    /// macOS `NSWindow.windowNumber` (== CGWindowID); Windows HWND.
387    pub window_id: u64,
388    pub placed: Rect,
389    pub original: Rect,
390    pub backing_scale: f64,
391    pub qualified: bool,
392}
393
394fn rect_json(r: &Rect) -> String {
395    format!(
396        "{{\"x\":{},\"y\":{},\"w\":{},\"h\":{}}}",
397        r.x, r.y, r.w, r.h
398    )
399}
400
401impl SessionReport {
402    pub fn to_json(&self) -> String {
403        format!(
404            "{{\"pid\":{},\"window_id\":{},\"placed\":{},\"original\":{},\"backing_scale\":{},\"qualified\":{}}}",
405            self.pid,
406            self.window_id,
407            rect_json(&self.placed),
408            rect_json(&self.original),
409            self.backing_scale,
410            self.qualified
411        )
412    }
413    pub fn from_json(body: &str) -> Result<Self, OffscreenError> {
414        let bad = |k: &str| OffscreenError::BadReport(format!("missing {k}"));
415        let num = |scope: &str, k: &str| json_num(scope, k).ok_or_else(|| bad(k));
416        let sub = |k: &str| -> Result<Rect, OffscreenError> {
417            let i = body.find(&format!("\"{k}\":{{")).ok_or_else(|| bad(k))?;
418            let s = &body[i..];
419            let s = &s[..s.find('}').ok_or_else(|| bad(k))?];
420            Ok(Rect::new(
421                num(s, "x")?,
422                num(s, "y")?,
423                num(s, "w")?,
424                num(s, "h")?,
425            ))
426        };
427        Ok(Self {
428            pid: num(body, "pid")? as u32,
429            window_id: num(body, "window_id")? as u64,
430            placed: sub("placed")?,
431            original: sub("original")?,
432            backing_scale: num(body, "backing_scale")?,
433            qualified: body.contains("\"qualified\":true"),
434        })
435    }
436}
437
438fn json_num(body: &str, key: &str) -> Option<f64> {
439    let pat = format!("\"{key}\":");
440    let rest = &body[body.find(&pat)? + pat.len()..];
441    let end = rest
442        .find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '.' | 'e' | 'E' | '+')))
443        .unwrap_or(rest.len());
444    rest[..end].parse().ok()
445}
446
447// ---------- in-process active session ----------
448
449struct Active(native::Session);
450// SAFETY: the native session holds raw window pointers that are only dereferenced
451// on the main thread (enforced by an explicit main-thread check in every entry).
452unsafe impl Send for Active {}
453
454static ACTIVE: Mutex<Option<Active>> = Mutex::new(None);
455
456fn lock_active() -> std::sync::MutexGuard<'static, Option<Active>> {
457    ACTIVE.lock().unwrap_or_else(|e| e.into_inner())
458}
459
460#[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))]
461fn install(session: native::Session) -> Result<SessionReport, OffscreenError> {
462    let mut g = lock_active();
463    if g.is_some() {
464        return Err(OffscreenError::AlreadyActive);
465    }
466    let report = native::report(&session);
467    *g = Some(Active(session));
468    Ok(report)
469}
470
471#[cfg_attr(not(any(target_os = "macos", windows)), allow(dead_code))]
472fn is_active() -> bool {
473    lock_active().is_some()
474}
475
476/// True when this process was launched with `RIGHTKIT_OFFSCREEN=1`.
477pub fn offscreen_requested() -> bool {
478    std::env::var(ENV_OFFSCREEN).is_ok_and(|v| v == "1")
479}
480
481/// Report of the active in-process session, if any.
482pub fn active_report() -> Option<SessionReport> {
483    lock_active().as_ref().map(|a| native::report(&a.0))
484}
485
486/// Per-OS qualification of the active in-process session: re-reads the live window.
487/// Must run on the main thread.
488pub fn offscreen_qualification() -> Result<QualificationReport, OffscreenError> {
489    let g = lock_active();
490    let a = g.as_ref().ok_or(OffscreenError::NoActiveSession)?;
491    native::session_qualification(&a.0)
492}
493
494/// End the active session on the main thread: restore the window and release the
495/// process activity / throttling opt-out.
496pub fn end_active() -> Result<(), OffscreenError> {
497    let a = lock_active()
498        .take()
499        .ok_or(OffscreenError::NoActiveSession)?;
500    native::end(a.0)
501}
502
503/// Start off-screen mode on a native window. macOS: `ns_window` is the `NSWindow*`
504/// and `wk_webview` the `WKWebView*`. Windows: `ns_window` carries the HWND value and
505/// `wk_webview` is ignored. Main thread only, before the window is first ordered.
506///
507/// # Safety
508/// Pointers must be live native objects for the duration of the session.
509pub unsafe fn start_native(
510    ns_window: *mut std::ffi::c_void,
511    wk_webview: *mut std::ffi::c_void,
512) -> Result<SessionReport, OffscreenError> {
513    #[cfg(any(target_os = "macos", windows))]
514    {
515        if is_active() {
516            return Err(OffscreenError::AlreadyActive);
517        }
518        install(native::start(ns_window, wk_webview)?)
519    }
520    #[cfg(not(any(target_os = "macos", windows)))]
521    {
522        let _ = (ns_window, wk_webview);
523        Err(OffscreenError::UnsupportedPlatform)
524    }
525}
526
527/// Write the report (or an `{"error":..}` body) atomically to `ENV_REPORT`, if set.
528pub fn write_report(result: &Result<SessionReport, OffscreenError>) {
529    let Some(path) = std::env::var_os(ENV_REPORT) else {
530        return;
531    };
532    let body = match result {
533        Ok(r) => r.to_json(),
534        Err(e) => format!("{{\"error\":\"{}\"}}", esc(&e.to_string())),
535    };
536    let path = PathBuf::from(path);
537    let tmp = path.with_extension("tmp");
538    if std::fs::write(&tmp, body).is_ok() {
539        let _ = std::fs::rename(&tmp, &path);
540    }
541}
542
543/// Tauri embedded path: start off-screen mode on `win` (must be created hidden,
544/// i.e. `visible: false`, so it has never been ordered). `done` runs on the main
545/// thread with the outcome; no blocking, so it is safe to call from `setup`.
546#[cfg(feature = "tauri-plugin")]
547pub fn start_for_window<R: tauri::Runtime>(
548    win: &tauri::WebviewWindow<R>,
549    done: impl FnOnce(Result<SessionReport, OffscreenError>) + Send + 'static,
550) {
551    #[cfg(target_os = "macos")]
552    let handle = win.ns_window().map(|p| p as usize);
553    #[cfg(windows)]
554    let handle = win.hwnd().map(|h| h.0 as usize);
555    #[cfg(not(any(target_os = "macos", windows)))]
556    let handle: tauri::Result<usize> = Ok(0);
557    let handle = match handle {
558        Ok(h) => h,
559        Err(e) => return done(Err(OffscreenError::Native(e.to_string()))),
560    };
561    let r = win.with_webview(move |pv| {
562        #[cfg(target_os = "macos")]
563        let wk = pv.inner();
564        #[cfg(not(target_os = "macos"))]
565        let wk = {
566            let _ = &pv;
567            std::ptr::null_mut()
568        };
569        // SAFETY: handles come from the live window; this closure runs on the main thread.
570        done(unsafe { start_native(handle as *mut std::ffi::c_void, wk) });
571    });
572    if let Err(e) = r {
573        // `done` was moved into the closure; surface via the report file only.
574        write_report(&Err(OffscreenError::Native(e.to_string())));
575    }
576}
577
578/// App startup hook: if `RIGHTKIT_OFFSCREEN=1`, start off-screen mode, write the
579/// report file for the controller, and call `done`. Returns whether it was requested.
580#[cfg(feature = "tauri-plugin")]
581pub fn start_from_env<R: tauri::Runtime>(
582    win: &tauri::WebviewWindow<R>,
583    done: impl FnOnce(&Result<SessionReport, OffscreenError>) + Send + 'static,
584) -> bool {
585    if !offscreen_requested() {
586        return false;
587    }
588    start_for_window(win, move |r| {
589        write_report(&r);
590        done(&r);
591    });
592    true
593}
594
595// ---------- controller-side launcher ----------
596
597/// What to launch. `program` is the app's executable (spawned directly so this
598/// process owns the PID; no `open`/LaunchServices indirection).
599#[derive(Debug, Clone)]
600pub struct LaunchSpec {
601    pub program: PathBuf,
602    pub args: Vec<String>,
603    pub env: Vec<(String, String)>,
604    pub report_timeout: Duration,
605    /// Directory for the report file; default `std::env::temp_dir()`.
606    pub report_dir: Option<PathBuf>,
607}
608
609impl LaunchSpec {
610    pub fn new(program: impl Into<PathBuf>) -> Self {
611        Self {
612            program: program.into(),
613            args: vec![],
614            env: vec![],
615            report_timeout: Duration::from_secs(30),
616            report_dir: None,
617        }
618    }
619}
620
621/// A launched off-screen app. Dropping it kills the process it started.
622#[derive(Debug)]
623pub struct OffscreenLaunch {
624    child: Option<Child>,
625    report_path: PathBuf,
626    pub report: SessionReport,
627}
628
629impl OffscreenLaunch {
630    pub fn pid(&self) -> u32 {
631        self.report.pid
632    }
633    /// Kill the launched process and clean up the report file. The window dies with
634    /// the process; use [`end_active`] inside the app to restore a surviving window.
635    pub fn end(mut self) -> std::io::Result<()> {
636        self.shutdown()
637    }
638    fn shutdown(&mut self) -> std::io::Result<()> {
639        let _ = std::fs::remove_file(&self.report_path);
640        match self.child.take() {
641            Some(mut c) => {
642                let k = c.kill();
643                let w = c.wait();
644                k.and(w.map(|_| ()))
645            }
646            None => Ok(()),
647        }
648    }
649}
650
651impl Drop for OffscreenLaunch {
652    fn drop(&mut self) {
653        let _ = self.shutdown();
654    }
655}
656
657fn kill(child: &mut Child) {
658    let _ = child.kill();
659    let _ = child.wait();
660}
661
662/// Spawn the app with off-screen mode requested and wait for its session report.
663/// Verifies the reported PID is the spawned child and (macOS) that the reported
664/// window id exists and belongs to that PID.
665pub fn launch_offscreen(spec: &LaunchSpec) -> Result<OffscreenLaunch, OffscreenError> {
666    let dir = spec.report_dir.clone().unwrap_or_else(std::env::temp_dir);
667    let nonce = std::time::SystemTime::now()
668        .duration_since(std::time::UNIX_EPOCH)
669        .map_or(0, |d| d.as_nanos());
670    let report_path = dir.join(format!(
671        "rightkit-offscreen-{}-{nonce}.json",
672        std::process::id()
673    ));
674    let _ = std::fs::remove_file(&report_path);
675    let mut cmd = Command::new(&spec.program);
676    cmd.args(&spec.args)
677        .envs(spec.env.iter().map(|(k, v)| (k, v)))
678        .env(ENV_OFFSCREEN, "1")
679        .env(ENV_REPORT, &report_path)
680        .stdin(Stdio::null())
681        .stdout(Stdio::null())
682        .stderr(Stdio::null());
683    #[cfg(windows)]
684    {
685        use std::os::windows::process::CommandExt;
686        cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
687    }
688    let mut child = cmd
689        .spawn()
690        .map_err(|e| OffscreenError::Launch(e.to_string()))?;
691    let start = Instant::now();
692    let body = loop {
693        if let Ok(b) = std::fs::read_to_string(&report_path) {
694            break b;
695        }
696        if let Ok(Some(st)) = child.try_wait() {
697            let _ = std::fs::remove_file(&report_path);
698            return Err(OffscreenError::ChildExited(st.to_string()));
699        }
700        if start.elapsed() > spec.report_timeout {
701            kill(&mut child);
702            let _ = std::fs::remove_file(&report_path);
703            return Err(OffscreenError::ReportTimeout);
704        }
705        std::thread::sleep(Duration::from_millis(50));
706    };
707    let fail = |child: &mut Child, e: OffscreenError| {
708        kill(child);
709        let _ = std::fs::remove_file(&report_path);
710        Err(e)
711    };
712    if let Some(i) = body.find("\"error\":\"") {
713        let msg = body[i + 9..].trim_end_matches(['}', '"', '\n']).to_string();
714        return fail(&mut child, OffscreenError::AppReportedError(msg));
715    }
716    let report = match SessionReport::from_json(&body) {
717        Ok(r) => r,
718        Err(e) => return fail(&mut child, e),
719    };
720    if report.pid != child.id() {
721        let e = OffscreenError::IdentityMismatch {
722            child_pid: child.id(),
723            reported_pid: report.pid,
724        };
725        return fail(&mut child, e);
726    }
727    #[cfg(target_os = "macos")]
728    if !crate::mac::windows(report.pid as i32)
729        .iter()
730        .any(|w| u64::from(w.id) == report.window_id)
731    {
732        let e = OffscreenError::WindowNotFound {
733            pid: report.pid,
734            window_id: report.window_id,
735        };
736        return fail(&mut child, e);
737    }
738    Ok(OffscreenLaunch {
739        child: Some(child),
740        report_path,
741        report,
742    })
743}
744
745/// [`launch_offscreen`] admitted through an [`crate::admission::EffectGate`] (EFF-001).
746/// The gate's error channel is string-typed; use the ungated function for typed errors.
747pub fn launch_offscreen_gated(
748    gate: &crate::admission::EffectGate,
749    spec: &LaunchSpec,
750) -> crate::admission::Effect<OffscreenLaunch> {
751    use crate::admission::{CancelToken, EffectKind, EffectRequest, ExecError};
752    let params = format!(
753        "{{\"program\":\"{}\",\"args\":{},\"env\":{}}}",
754        esc(&spec.program.to_string_lossy()),
755        spec.args.len(),
756        spec.env.len()
757    );
758    let req = EffectRequest::new(
759        EffectKind::Process,
760        "launch_offscreen",
761        spec.program.to_string_lossy().to_string(),
762    )
763    .with_params(params);
764    gate.admit(req, &CancelToken::new(), |_| {
765        launch_offscreen(spec).map_err(|e| ExecError::from(e.to_string()))
766    })
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    fn d(x: f64, y: f64, w: f64, h: f64) -> Rect {
774        Rect::new(x, y, w, h)
775    }
776
777    fn clear_of_all(displays: &[Rect], f: &Rect) {
778        for dd in displays {
779            assert!(!dd.intersects(f), "{f:?} intersects {dd:?}");
780        }
781        assert!(!display_union(displays).unwrap().intersects(f));
782    }
783
784    #[test]
785    fn single_display() {
786        let ds = [d(0.0, 0.0, 1920.0, 1080.0)];
787        let f = offscreen_frame(&ds, (800.0, 600.0)).unwrap();
788        assert_eq!(f, d(2920.0, 0.0, 800.0, 600.0));
789        clear_of_all(&ds, &f);
790    }
791
792    #[test]
793    fn negative_origins_left_and_below() {
794        // secondary display left of and below the primary (AppKit bottom-left coords)
795        let ds = [
796            d(0.0, 0.0, 1440.0, 900.0),
797            d(-1920.0, -300.0, 1920.0, 1080.0),
798        ];
799        let f = offscreen_frame(&ds, (1000.0, 700.0)).unwrap();
800        assert_eq!(f.x, 1440.0 + OFFSCREEN_GAP);
801        assert_eq!(f.y, -300.0);
802        clear_of_all(&ds, &f);
803    }
804
805    #[test]
806    fn all_negative_layout_uses_union_right_edge() {
807        let ds = [
808            d(-3000.0, -500.0, 1000.0, 800.0),
809            d(-2000.0, -400.0, 1000.0, 800.0),
810        ];
811        let f = offscreen_frame(&ds, (400.0, 300.0)).unwrap();
812        assert_eq!(f.x, -1000.0 + OFFSCREEN_GAP);
813        assert_eq!(f.y, -500.0);
814        clear_of_all(&ds, &f);
815    }
816
817    #[test]
818    fn rightmost_display_is_not_the_primary() {
819        // primary at origin, a wide display placed to its right: the mover must clear it.
820        let ds = [d(0.0, 0.0, 1000.0, 800.0), d(1000.0, 0.0, 3840.0, 2160.0)];
821        let f = offscreen_frame(&ds, (500.0, 500.0)).unwrap();
822        assert_eq!(f.x, 4840.0 + OFFSCREEN_GAP);
823        clear_of_all(&ds, &f);
824    }
825
826    #[test]
827    fn stacked_and_gapped_displays() {
828        let ds = [
829            d(0.0, 0.0, 1920.0, 1080.0),
830            d(100.0, 1080.0, 1920.0, 1080.0),
831            d(5000.0, -200.0, 800.0, 600.0),
832        ];
833        let f = offscreen_frame(&ds, (640.0, 480.0)).unwrap();
834        assert_eq!(f.x, 5800.0 + OFFSCREEN_GAP);
835        clear_of_all(&ds, &f);
836    }
837
838    #[test]
839    fn rejects_bad_input() {
840        assert_eq!(
841            offscreen_frame(&[], (10.0, 10.0)),
842            Err(OffscreenError::NoDisplays)
843        );
844        let ds = [d(0.0, 0.0, 10.0, 10.0)];
845        assert_eq!(
846            offscreen_frame(&ds, (0.0, 10.0)),
847            Err(OffscreenError::InvalidSize)
848        );
849        assert_eq!(
850            offscreen_frame(&ds, (f64::NAN, 10.0)),
851            Err(OffscreenError::InvalidSize)
852        );
853        assert_eq!(
854            offscreen_frame(&[d(f64::INFINITY, 0.0, 1.0, 1.0)], (10.0, 10.0)),
855            Err(OffscreenError::NoDisplays)
856        );
857    }
858
859    #[test]
860    fn touching_edges_do_not_intersect() {
861        assert!(!d(0.0, 0.0, 10.0, 10.0).intersects(&d(10.0, 0.0, 10.0, 10.0)));
862        assert!(d(0.0, 0.0, 10.0, 10.0).intersects(&d(9.0, 9.0, 10.0, 10.0)));
863    }
864
865    struct Fake {
866        displays: Vec<Rect>,
867        frame: Rect,
868        visible: bool,
869        mini: bool,
870        scale: f64,
871        /// Clamp any requested origin back to this one (window refuses placement).
872        clamp_to: Option<(f64, f64)>,
873        hidden_calls: u32,
874        shown: bool,
875        hidden_precheck: bool,
876    }
877    impl Fake {
878        fn new() -> Self {
879            Self {
880                displays: vec![d(0.0, 0.0, 1920.0, 1080.0), d(-1280.0, 0.0, 1280.0, 800.0)],
881                frame: d(100.0, 100.0, 800.0, 600.0),
882                visible: false,
883                mini: false,
884                scale: 2.0,
885                clamp_to: None,
886                hidden_calls: 0,
887                shown: false,
888                hidden_precheck: false,
889            }
890        }
891    }
892    impl WindowOps for Fake {
893        fn displays(&self) -> Result<Vec<Rect>, OffscreenError> {
894            Ok(self.displays.clone())
895        }
896        fn frame(&self) -> Result<Rect, OffscreenError> {
897            Ok(self.frame)
898        }
899        fn backing_scale(&self) -> Result<f64, OffscreenError> {
900            Ok(self.scale)
901        }
902        fn is_visible(&self) -> Result<bool, OffscreenError> {
903            Ok(self.visible)
904        }
905        fn is_miniaturized(&self) -> Result<bool, OffscreenError> {
906            Ok(self.mini)
907        }
908        fn precheck(&self) -> Result<(), OffscreenError> {
909            if self.hidden_precheck {
910                Err(OffscreenError::WindowHidden)
911            } else {
912                Ok(())
913            }
914        }
915        fn set_origin(&mut self, x: f64, y: f64) -> Result<(), OffscreenError> {
916            let (x, y) = self.clamp_to.unwrap_or((x, y));
917            self.frame.x = x;
918            self.frame.y = y;
919            Ok(())
920        }
921        fn show(&mut self) -> Result<(), OffscreenError> {
922            self.visible = true;
923            self.shown = true;
924            Ok(())
925        }
926        fn hide_for_restore(&mut self) -> Result<(), OffscreenError> {
927            self.hidden_calls += 1;
928            self.visible = false;
929            Ok(())
930        }
931    }
932
933    #[test]
934    fn place_then_restore_round_trip() {
935        let mut w = Fake::new();
936        let p = place(&mut w).unwrap();
937        assert_eq!(p.original, d(100.0, 100.0, 800.0, 600.0));
938        assert_eq!(p.placed.x, 1920.0 + OFFSCREEN_GAP);
939        assert_eq!(p.backing_scale, 2.0);
940        assert!(w.shown);
941        let st = snapshot(&w, SpiStatus::Responded).unwrap();
942        assert!(qualify(&st, Platform::Mac).passed());
943        restore(&mut w, &p).unwrap();
944        assert_eq!(w.frame, p.original);
945        assert_eq!(w.hidden_calls, 1);
946        assert!(
947            !w.visible,
948            "restore hides first so it never flashes on a display"
949        );
950    }
951
952    #[test]
953    fn place_detects_clamped_window() {
954        let mut w = Fake::new();
955        w.clamp_to = Some((50.0, 50.0));
956        match place(&mut w) {
957            Err(OffscreenError::PlacementNotApplied { actual, .. }) => assert_eq!(actual.x, 50.0),
958            other => panic!("{other:?}"),
959        }
960    }
961
962    #[test]
963    fn restore_detects_failure() {
964        let mut w = Fake::new();
965        let p = place(&mut w).unwrap();
966        w.clamp_to = Some((0.0, 0.0));
967        assert!(matches!(
968            restore(&mut w, &p),
969            Err(OffscreenError::RestoreFailed { .. })
970        ));
971    }
972
973    #[test]
974    fn precheck_refusal_moves_nothing() {
975        let mut w = Fake::new();
976        w.hidden_precheck = true;
977        assert_eq!(place(&mut w), Err(OffscreenError::WindowHidden));
978        assert_eq!(w.frame, d(100.0, 100.0, 800.0, 600.0));
979    }
980
981    fn good_state() -> WindowState {
982        WindowState {
983            frame: d(3000.0, 0.0, 800.0, 600.0),
984            displays: vec![d(0.0, 0.0, 1920.0, 1080.0)],
985            visible: true,
986            miniaturized: false,
987            backing_scale: 2.0,
988            spi: SpiStatus::Responded,
989        }
990    }
991
992    #[test]
993    fn qualification_passes_and_each_check_fails_typed() {
994        assert!(qualify(&good_state(), Platform::Mac).passed());
995
996        let mut s = good_state();
997        s.frame = d(1900.0, 0.0, 800.0, 600.0);
998        assert!(matches!(
999            qualify(&s, Platform::Mac).into_result(),
1000            Err(OffscreenError::WindowOnDisplay { .. })
1001        ));
1002
1003        // in the gap between displays but inside the union bounding box also fails
1004        let mut s = good_state();
1005        s.displays = vec![d(0.0, 0.0, 1000.0, 1000.0), d(5000.0, 0.0, 1000.0, 1000.0)];
1006        s.frame = d(2000.0, 0.0, 800.0, 600.0);
1007        assert!(matches!(
1008            qualify(&s, Platform::Mac).into_result(),
1009            Err(OffscreenError::WindowOnDisplay { .. })
1010        ));
1011
1012        let mut s = good_state();
1013        s.visible = false;
1014        assert_eq!(
1015            qualify(&s, Platform::Windows).into_result(),
1016            Err(OffscreenError::WindowHidden)
1017        );
1018
1019        let mut s = good_state();
1020        s.miniaturized = true;
1021        assert_eq!(
1022            qualify(&s, Platform::Windows).into_result(),
1023            Err(OffscreenError::WindowMiniaturized)
1024        );
1025
1026        let mut s = good_state();
1027        s.backing_scale = 0.0;
1028        assert_eq!(
1029            qualify(&s, Platform::Mac).into_result(),
1030            Err(OffscreenError::InvalidBackingScale)
1031        );
1032
1033        let mut s = good_state();
1034        s.displays.clear();
1035        assert_eq!(
1036            qualify(&s, Platform::Mac).into_result(),
1037            Err(OffscreenError::NoDisplays)
1038        );
1039    }
1040
1041    #[test]
1042    fn qualification_spi_is_macos_only() {
1043        let mut s = good_state();
1044        for (spi, err) in [
1045            (
1046                SpiStatus::NotCompiled,
1047                OffscreenError::OcclusionSpiNotCompiled,
1048            ),
1049            (
1050                SpiStatus::Unavailable,
1051                OffscreenError::OcclusionSpiUnavailable,
1052            ),
1053        ] {
1054            s.spi = spi;
1055            assert_eq!(qualify(&s, Platform::Mac).into_result(), Err(err));
1056            assert!(qualify(&s, Platform::Windows).passed());
1057        }
1058        s.spi = SpiStatus::NotApplicable;
1059        assert!(qualify(&s, Platform::Windows).passed());
1060        assert!(!qualify(&s, Platform::Mac).passed());
1061    }
1062
1063    #[test]
1064    fn report_json_round_trip() {
1065        let r = SessionReport {
1066            pid: 4242,
1067            window_id: 987,
1068            placed: d(3920.0, -100.5, 800.0, 600.0),
1069            original: d(-100.0, 50.0, 800.0, 600.0),
1070            backing_scale: 2.0,
1071            qualified: true,
1072        };
1073        assert_eq!(SessionReport::from_json(&r.to_json()).unwrap(), r);
1074        assert!(matches!(
1075            SessionReport::from_json("{}"),
1076            Err(OffscreenError::BadReport(_))
1077        ));
1078    }
1079
1080    #[test]
1081    fn no_active_session_is_typed() {
1082        // Not installed by any unit test.
1083        assert_eq!(
1084            offscreen_qualification().unwrap_err(),
1085            OffscreenError::NoActiveSession
1086        );
1087        assert_eq!(end_active().unwrap_err(), OffscreenError::NoActiveSession);
1088    }
1089
1090    #[test]
1091    fn launch_reports_child_exit_and_missing_binary() {
1092        let mut spec = LaunchSpec::new("/usr/bin/true");
1093        spec.report_timeout = Duration::from_secs(5);
1094        #[cfg(unix)]
1095        assert!(matches!(
1096            launch_offscreen(&spec),
1097            Err(OffscreenError::ChildExited(_))
1098        ));
1099        let spec = LaunchSpec::new("/nonexistent/rightkit-offscreen-app");
1100        assert!(matches!(
1101            launch_offscreen(&spec),
1102            Err(OffscreenError::Launch(_))
1103        ));
1104    }
1105}