main
Drew Owens 0.11.3 source 7364e19 5d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461
//! THE OS FACTS THE LAUNCH STATE MACHINE RUNS ON — and the fake that makes
//! every one of its transitions testable off Windows.
//!
//! `launch.rs` decides WHAT to do about a stalled 3DEXPERIENCE launcher chain.
//! This module answers the four questions it needs to decide anything, and
//! nothing else:
//!
//!   * which launcher-chain processes exist, and **when each was created**
//!     (the creation time is the whole safety property — see
//!     [`crate::launch::may_terminate`]);
//!   * which `SWXDesktopLauncher` / `SWXDesktopHelper` log files exist, when
//!     each was created, and what is in them;
//!   * is SOLIDWORKS' main window answering messages, and how much memory and
//!     how many handles is the process holding;
//!   * terminate ONE process, BY PID.
//!
//! WHY BY PID AND NEVER BY IMAGE NAME. `taskkill /IM CATSTART.exe` would kill a
//! chain the USER started thirty seconds earlier as readily as the one this
//! bridge started, and `taskkill /F /T` would take its children with it. Every
//! terminate here opens ONE pid, checks the image name it was opened for still
//! matches, and terminates that handle. There is no image-name kill anywhere in
//! this bridge's launch path.
//!
//! Off Windows (and under `SWX_LAUNCH_FAKE_DIR`) the whole surface is served by
//! [`FakeOs`], a directory-backed simulator the test suite drives. A launch
//! state machine that can only be exercised on one particular Windows desktop
//! with one particular CAD application installed is a state machine that is
//! never exercised at all.

use serde_json::{json, Value};
use std::time::{SystemTime, UNIX_EPOCH};

/// Point the OS layer at a simulated machine. Test-only; never set in
/// production. Its presence is what selects [`FakeOs`] over the real one.
pub const ENV_FAKE_DIR: &str = "SWX_LAUNCH_FAKE_DIR";

/// The images the launch machine looks at. Deliberately short: this is a
/// process census for ONE purpose, not a task manager.
pub const WATCHED_IMAGES: &[&str] = &[
    "CATSTART.exe",
    "SWXDesktopLauncher.exe",
    "SLDWORKS.exe",
    "3DEXPERIENCELauncher.exe",
    "3DEXPERIENCELauncherSysTray.exe",
    "3DEXPERIENCELauncherService.exe",
    // THE QUIT LADDER'S CENSUS (docs/QUIT-INVESTIGATION.md §5.4): the
    // per-session children a graceful exit must REAP, and the platform daemons
    // that must SURVIVE one. Their survival is consent, not a failure — see
    // `quit::EXPECTED_SURVIVORS`.
    "sldProcMon.exe",
    "sldsurfacing.exe",
    "sldimport.exe",
    "swCefSubProc.exe",
    "ENOPLMCSAClient.exe",
    "EdmServerV6.exe",
    // SOLIDWORKS Visualize: it raises its OWN 3DEXPERIENCE ID login (measured
    // 2026-10-02), which swx_sign_in answers under the user's authorization;
    // swx_start {app:"visualize"} starts it (0.9.19), and these run with it.
    "SLDWORKSVisualize.exe",
    "SWVisualize.Exchange.PluginHost.exe",
    "SWVisualize.Queue.Server.exe",
    "SWXDesktopHelper.exe",
    // THE PLATFORM UPDATER (0.9.23): it hosts a third 3DEXPERIENCE ID login —
    // measured 2026-10-12 running from %TEMP%\SWXDesktopUpdate\, started the
    // moment the platform shortcut was invoked. Matched by image name, so any
    // path counts. Never terminable.
    "SWXDesktopUpdate.exe",
    "sldBgDwld.exe",
    // PARENT LINKS ONLY — never terminable (see `launch::NEVER_TERMINATE`).
    // A CATSTART's parent is the shell that ran the .lnk, and without these in
    // the census every parent link out of a chain root dangles: the walk could
    // not tell a chain descended from OUR invoke from one descended from the
    // user's double-click, because neither parent existed to compare.
    "explorer.exe",
    "cmd.exe",
    "powershell.exe",
    "pwsh.exe",
];

/// One process, with the two facts that matter: WHEN it started, and WHO
/// started it.
#[derive(Clone, Debug, PartialEq)]
pub struct ProcInfo {
    pub pid: u32,
    /// The image name as the OS reports it (`CATSTART.exe`). Compared
    /// case-insensitively everywhere — Windows paths and image names are.
    pub image: String,
    /// Unix epoch seconds. `None` means the creation time could not be read,
    /// which [`crate::launch::may_terminate`] treats as "not ours".
    pub created_at: Option<u64>,
    /// Parent pid, so a chain member that appeared after we stopped looking can
    /// still be attributed to the CATSTART we started. `None` = unknown, which
    /// attributes nothing.
    pub ppid: Option<u32>,
}

impl ProcInfo {
    pub fn to_json(&self) -> Value {
        json!({"pid": self.pid, "image": self.image, "createdAt": self.created_at, "ppid": self.ppid})
    }
    pub fn is(&self, image: &str) -> bool {
        self.image.eq_ignore_ascii_case(image)
    }
    /// Identity for the kill path: a pid ALONE is not one, because Windows
    /// reuses pids. (pid, image, creation time) is.
    pub fn same_process(&self, other: &ProcInfo) -> bool {
        self.pid == other.pid
            && self.is(&other.image)
            && self.created_at.is_some()
            && self.created_at == other.created_at
    }
}

/// A launcher/helper log file and when it appeared. The launch machine cares
/// only about logs created AFTER its own invoke — an older one describes
/// somebody else's launch.
#[derive(Clone, Debug, PartialEq)]
pub struct LogFile {
    pub path: String,
    pub created_at: Option<u64>,
    /// The `yyyyMMddHHmmss` the LAUNCHER ITSELF put in the filename, as a plain
    /// comparable integer.
    ///
    /// Preferred over the filesystem's creation time for "did this log appear
    /// after our invoke?": the launcher writes the stamp when it starts, so it
    /// is the launcher's own account of when it ran, while `created()` can be
    /// absent on some filesystems, can be the time the file was COPIED, and on
    /// Windows survives a delete-and-recreate of the same name (file-system
    /// tunnelling) — which would make a brand-new log look older than our
    /// invoke and hide the very stall we are watching for. Compared only
    /// against other stamps, never converted to epoch, so no timezone is
    /// guessed.
    pub stamp: Option<u64>,
}

/// The widest real UTC offset is +14:00 and the widest negative -12:00; add an
/// hour of DST and some slack. A filename stamp is written in LOCAL time and
/// the filesystem records UTC, so the two may legitimately differ by up to
/// this much — and by no more.
const MAX_TZ_SKEW_SECS: u64 = 26 * 3600;
/// A stamp this far ahead of now (allowing for any timezone) did not come from
/// a clock this machine is keeping.
const FUTURE_STAMP_SLACK_SECS: u64 = 300;

/// `yyyyMMddHHmmss` read as if it were UTC. Used only to CORROBORATE a stamp
/// against the filesystem's own time — never as a timestamp in its own right,
/// because the launcher writes local time and this function does not know the
/// offset.
pub fn stamp_as_utc_epoch(stamp: u64) -> Option<u64> {
    let (y, mo, d) = ((stamp / 10_000_000_000) as i64, ((stamp / 100_000_000) % 100) as i64, ((stamp / 1_000_000) % 100) as i64);
    let (h, mi, sec) = ((stamp / 10_000) % 100, (stamp / 100) % 100, stamp % 100);
    if !(1970..=9999).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
        return None;
    }
    if h > 23 || mi > 59 || sec > 60 {
        return None;
    }
    // days-from-civil (Howard Hinnant): exact, no table, no leap-year special case.
    let y2 = y - i64::from(mo <= 2);
    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;
    let yoe = y2 - era * 400;
    let mp = (mo + 9) % 12;
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146_097 + doe - 719_468;
    u64::try_from(days * 86_400 + (h * 3600 + mi * 60 + sec) as i64).ok()
}

impl LogFile {
    /// The filename stamp, but ONLY when the filesystem corroborates it.
    ///
    /// The launcher stamps LOCAL time. A stamp that cannot be reconciled with
    /// the file's own UTC creation time — a log copied from another machine, a
    /// clock correction, a file written by a different timezone's session —
    /// is not evidence of anything, and one such file in that directory (which
    /// nothing ever prunes) would poison every comparison after it.
    pub fn trusted_stamp(&self) -> Option<u64> {
        let stamp = self.stamp?;
        let as_utc = stamp_as_utc_epoch(stamp)?;
        if as_utc > epoch_secs() + MAX_TZ_SKEW_SECS + FUTURE_STAMP_SLACK_SECS {
            return None; // from the future, whatever timezone this is
        }
        let created = self.created_at?;
        (as_utc.abs_diff(created) <= MAX_TZ_SKEW_SECS).then_some(stamp)
    }

    /// Is this log NEWER than the newest one that existed before our invoke?
    ///
    /// TWO INDEPENDENT CLOCKS, and EITHER may answer yes — the launcher's own
    /// local-time stamp, or the filesystem's UTC creation time. Three defects
    /// in the first version, all of them making the gate's central measurement
    /// a fabrication:
    ///
    ///   * it SHORT-CIRCUITED on the stamps and never consulted the filesystem.
    ///     For the one hour a year a local clock repeats, our own log carries a
    ///     LOWER stamp than one written before it, so the bridge concluded its
    ///     own log did not exist and reported that nothing started.
    ///   * `(Some(_), None) => true` accepted ANY log when there was no
    ///     baseline — and no baseline is exactly what a FAILED directory
    ///     listing produced. Five of the nine logs on the real machine contain
    ///     `Run Begin`, so the bridge would read somebody else's launch from
    ///     three days ago and jump straight past stall detection.
    ///   * a future-dated stamp poisoned the directory permanently.
    ///
    /// With NO baseline the answer is NO. An unreadable listing is UNKNOWN, and
    /// unknown is never "the directory was empty".
    pub fn is_newer_than(&self, baseline_stamp: Option<u64>, baseline_created: Option<u64>) -> bool {
        if baseline_stamp.is_none() && baseline_created.is_none() {
            return false;
        }
        let stamp_newer = matches!(
            (self.trusted_stamp(), baseline_stamp),
            (Some(mine), Some(base)) if mine > base
        );
        let created_newer = matches!(
            (self.created_at, baseline_created),
            (Some(mine), Some(base)) if mine > base
        );
        stamp_newer || created_newer
    }
}

/// The thread id the launcher stamps on its own lines: `17:23:09 [116244] info`.
///
/// Read from the FIRST line that has one, because that line is written by the
/// launcher's main thread at startup — the thread most likely still alive while
/// we are watching. (`Run Begin` comes from a second thread ~2s later, which is
/// exactly the pair the ground-truth log shows: [107936] then [116244].)
pub fn writer_thread_of(log_text: &str) -> Option<u32> {
    for line in log_text.lines().take(40) {
        let open = line.find('[')?;
        let close = line[open..].find(']')? + open;
        if let Ok(tid) = line[open + 1..close].trim().parse::<u32>() {
            return Some(tid);
        }
    }
    None
}

/// `20260904172307_V6R2026x.HF4.13.log` -> `20260904172307`.
pub fn stamp_of(file_name: &str) -> Option<u64> {
    let digits: String = file_name.chars().take_while(|c| c.is_ascii_digit()).collect();
    (digits.len() == 14).then(|| digits.parse().ok())?
}

/// One top-level window, as the OS reports it — read WITHOUT ab.
///
/// Two callers need this and neither can depend on ab: the busy-vs-dead verdict
/// ("did a dialog appear, or is it silently doing nothing?") and, in 0.5.1, the
/// quit ladder's "no blocking dialog" precondition. `swx_status` on the dev
/// bridge reports `abAvailable:false`, and an unanswerable precondition refuses,
/// so depending on ab would make those permanently unavailable during an outage.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct WindowInfo {
    pub hwnd: i64,
    pub class_name: String,
    pub title: String,
    pub visible: bool,
    // ── THE EVIDENCE A WINDOW IS JUDGED BY (0.9.4). Measured 2026-10-01: a
    //    `#32770` with no title stopped every quit for minutes and was a
    //    NOTIFICATION — its owner frame stayed enabled for all 20 reads. Whether
    //    a window blocks is decided from these, and every report carries them.
    //    `None` = not read; an unread fact is never taken as reassuring.
    /// GetWindow(GW_OWNER).
    pub owner: Option<i64>,
    /// Is the owner window ENABLED (no WS_DISABLED)? A real modal disables it.
    pub owner_enabled: Option<bool>,
    pub style: Option<u32>,
    pub exstyle: Option<u32>,
    /// left, top, right, bottom.
    pub rect: Option<[i32; 4]>,
}

/// WS_EX_TOOLWINDOW.
pub const WS_EX_TOOLWINDOW: u32 = 0x80;

impl WindowInfo {
    pub fn to_json(&self) -> Value {
        json!({
            "hwnd": self.hwnd,
            "className": self.class_name,
            "title": self.title,
            "visible": self.visible,
            "owner": self.owner,
            "ownerEnabled": self.owner_enabled,
            "style": self.style.map(|v| format!("0x{v:08x}")),
            "exStyle": self.exstyle.map(|v| format!("0x{v:x}")),
            "toolWindow": self.exstyle.map(|v| v & WS_EX_TOOLWINDOW != 0),
            "rect": self.rect,
        })
    }
    /// The Win32 dialog class every SOLIDWORKS modal is.
    pub fn is_dialog(&self) -> bool {
        self.class_name == "#32770"
    }
}

/// THE STANDARD WINDOWS FILE DIALOG (0.11.1): is this window one (it has the
/// file-name combo, control 1148 — or the old-style edit, 1152 — and a Cancel
/// button, control 2), and what is in its file-name box (`None` = could not be
/// read, never "empty").
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FileDialogState {
    pub standard: bool,
    pub file_name: Option<String>,
}

/// The readable contents of a dialog: what it says, and what it offers.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DialogContents {
    /// Every static text line, in z-order.
    pub text: Vec<String>,
    /// The name on every PUSH BUTTON. Checkboxes are not buttons here — see
    /// `checkboxes` — because "Don't show this again" is not a choice about what
    /// happens to the user's work.
    pub buttons: Vec<String>,
    /// Checkbox labels, kept separately and never counted as choices.
    pub checkboxes: Vec<String>,
    /// TRUE if any control's text hit the read buffer and may be cut short. The
    /// reads are 1024 wide; a longer message is reported as truncated rather than
    /// silently shortened, because the part that got cut is exactly where a
    /// countdown or a file name tends to live.
    pub truncated: bool,
    /// EVERY child control, by Win32 class and visibility — the evidence a
    /// window is judged by: a Button-class child means something on offer.
    /// Hidden ones included (they are what a custom-drawn window's template
    /// left behind — measured 2026-10-01).
    pub controls: Vec<(String, bool)>,
}

/// CLASSIFY ONE CONTROL from what Win32 says about it.
///
/// A Win32 CHECKBOX HAS CLASS "Button" — `BS_CHECKBOX` (0x02) and
/// `BS_AUTOCHECKBOX` (0x03) are STYLE bits, not classes — so a control tree read
/// by class alone files "Don't show this again" as a button. That matters because
/// `acknowledge_only` counts buttons: a licence dialog with OK plus a "do not
/// show again" box would have read as two choices and stopped being
/// acknowledge-only, and a dialog with only a checkbox would have read as one.
///
/// Style is the low 4 bits of the button type field.
pub fn classify_control(class: &str, style: u32, text: &str) -> Option<&'static str> {
    if text.trim().is_empty() {
        return None;
    }
    let c = class.to_ascii_lowercase();
    if c.contains("button") {
        // BS_CHECKBOX = 0x2, BS_AUTOCHECKBOX = 0x3, BS_3STATE = 0x5,
        // BS_AUTO3STATE = 0x6, BS_RADIOBUTTON = 0x4, BS_AUTORADIOBUTTON = 0x9.
        return Some(match style & 0xF {
            0x2 | 0x3 | 0x5 | 0x6 => "checkbox",
            0x4 | 0x9 => "radio",
            _ => "button",
        });
    }
    if c.contains("static") || c.contains("edit") {
        return Some("text");
    }
    None
}

impl DialogContents {
    pub fn to_json(&self) -> Value {
        json!({
            "text": self.text,
            "buttons": self.buttons,
            "checkboxes": self.checkboxes,
            "truncated": self.truncated,
            "controls": self.controls.iter().map(|(c, v)| json!({"class": c, "visible": v})).collect::<Vec<_>>(),
        })
    }
    /// Does any child control have the Win32 class `Button` (a push button, a
    /// checkbox or a radio — anything that is something to click)?
    /// THE BODY COULD NOT BE READ (0.9.12). MEASURED 2026-10-02: the License
    /// Manager notice's body is a `DirectUIHWND` task dialog — no child Statics
    /// or Buttons carry its words — so the child-control walk read nothing, and
    /// reporting that as "read, and it says nothing" was false. No text and no
    /// buttons beside a DirectUIHWND child (or no children at all) is UNREADABLE.
    pub fn body_unreadable(&self) -> bool {
        self.text.is_empty()
            && self.buttons.is_empty()
            && (self.controls.is_empty()
                || self.controls.iter().any(|(c, _)| c.eq_ignore_ascii_case("DirectUIHWND")))
    }

    pub fn has_button_class_child(&self) -> bool {
        self.controls.iter().any(|(c, _)| c.eq_ignore_ascii_case("button"))
    }
    /// A dialog whose only choice is to acknowledge it: EXACTLY ONE button, and
    /// that button is an acknowledgement. Any second button is a choice, and a
    /// choice is never acknowledge-only — "OK" beside "Cancel" decides something.
    ///
    /// This says nothing about whether this bridge may PRESS it. It may not:
    /// `never_answer` stays true, and whether an OK-only informational dialog is
    /// ever pressable is a policy question for the user, not a deduction from a
    /// control tree.
    pub fn acknowledge_only(&self) -> bool {
        let named: Vec<String> = self
            .buttons
            .iter()
            .map(|b| b.trim().trim_start_matches('&').replace('&', "").to_ascii_lowercase())
            .filter(|b| !b.is_empty())
            .collect();
        named.len() == 1 && matches!(named[0].as_str(), "ok" | "close")
    }
}

/// What SOLIDWORKS is costing the machine right now.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ProcMetrics {
    /// Total CPU time (kernel + user) the process has consumed, in seconds. The
    /// DELTA over a short sample is what separates a process that is thrashing
    /// from one that is dead: on 2026-09-08 SOLIDWORKS at 17.9 GB went
    /// not-responding with a COM timeout for ~5 minutes and was reported as
    /// hung — while it was burning ~70% of a core and RECOVERED once the user
    /// saved. "Not responding" is a statement about a message loop, not about
    /// whether work is happening.
    pub cpu_seconds: Option<f64>,
    /// `PrivateUsage` — the commit charge, which is what runs out.
    pub private_mb: Option<u64>,
    /// `WorkingSetSize` — RESIDENT memory. Reported BESIDE private rather than
    /// instead of it: the two diverge by many gigabytes on a paged-out CAD
    /// session, and quoting one as "memory" invites a wrong conclusion about
    /// the other.
    pub working_set_mb: Option<u64>,
    pub handles: Option<u32>,
}

/// THE MACHINE'S MEMORY (0.10.5), from GlobalMemoryStatusEx. The COMMIT pair
/// is what runs out: measured 2026-10-14, the SOLIDWORKS Resource Monitor
/// warned "available committed memory is critically low" at 72.3 of 76.6 GB
/// committed while gigabytes of RAM were still free.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SystemMemory {
    /// `ullTotalPageFile` — the commit limit (RAM plus the page file).
    pub commit_limit_mb: u64,
    /// `ullAvailPageFile` — what can still be committed.
    pub commit_avail_mb: u64,
    pub phys_total_mb: u64,
    pub phys_avail_mb: u64,
}

/// One process's committed bytes (`PrivateUsage`). READ-ONLY FACTS: the bridge
/// reports who holds the memory and never acts on a process that is not
/// SOLIDWORKS.
#[derive(Clone, Debug, PartialEq)]
pub struct ProcPrivate {
    pub name: String,
    pub pid: u32,
    pub private_mb: u64,
}

/// The OS facts, behind one trait so the state machine has exactly one seam and
/// the suite can drive every branch of it.
pub trait LaunchOs: Send + Sync {
    /// Every process whose image is in [`WATCHED_IMAGES`].
    ///
    /// `Err` means THE CENSUS FAILED — never an empty list. "I could not look"
    /// and "nothing is running" lead to opposite decisions, and collapsing the
    /// first into the second is the defect this whole file was written after.
    fn processes(&self) -> Result<Vec<ProcInfo>, String>;
    /// `SWXDesktopLauncher\*.log`, newest first.
    fn launcher_logs(&self) -> Result<Vec<LogFile>, String>;
    /// `SWXDesktopHelper\*.log`, newest first — the sign-in/licence stage.
    fn helper_logs(&self) -> Result<Vec<LogFile>, String>;
    fn read_log(&self, path: &str) -> Result<String, String>;
    /// Terminate ONE pid, having re-checked that it still is `image` AND was
    /// created at `created_at`.
    ///
    /// The creation time closes the TOCTOU: between the census that chose this
    /// pid and the kill, the process can exit and Windows can hand its number
    /// to something else. The check is re-done on the OPENED HANDLE, which is
    /// the only ordering that cannot be raced — a check against a fresh census
    /// is still a check against a different object from the one the handle
    /// refers to.
    fn terminate(&self, pid: u32, image: &str, created_at: Option<u64>) -> Result<(), String>;

    /// Is this pid running? `None` = cannot tell, which is never read as "yes".
    fn pid_alive(&self, pid: u32) -> Option<bool> {
        self.processes().ok().map(|c| c.iter().any(|p| p.pid == pid))
    }
    /// `IsHungAppWindow` on a top-level window. `None` = unknown, which is
    /// never reported as "responding".
    fn window_responding(&self, hwnd: i64) -> Option<bool>;
    /// The main top-level window owned by `pid`, for when COM cannot tell us.
    fn main_window_of_pid(&self, pid: u32) -> Option<i64>;

    /// WHICH PROCESS OWNS THIS THREAD? The launcher stamps a thread id in every
    /// line it writes (`17:23:09 [116244] info Run Begin`), so a log can be
    /// bound to the process that wrote it — which is the only positive proof
    /// that a log is OURS rather than merely newer than the ones we saw before.
    ///
    /// Best effort in one direction only: a match proves authorship, a miss
    /// proves nothing (threads exit, and the writing thread usually has by the
    /// time we read the file).
    fn thread_owner(&self, _tid: u32) -> Option<u32> {
        None
    }
    fn process_metrics(&self, pid: u32) -> ProcMetrics;
    /// Every top-level window owned by `pid`. `Err` = could not look, which is
    /// UNKNOWN — never "no dialogs".
    fn windows_of_pid(&self, _pid: u32) -> Result<Vec<WindowInfo>, String> {
        Err("this host cannot enumerate windows".into())
    }

    /// WHAT A DIALOG SAYS, AND WHICH BUTTONS IT HAS. Read-only, and `Err` is
    /// UNKNOWN — never "it says nothing".
    ///
    /// Live, 2026-09-17: the safe-close sweep stopped correctly on a dialog and
    /// reported `{"kind":"license","title":"License Manager"}` — the title and
    /// nothing else. Read by hand afterwards, the window said:
    ///
    /// > Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional:
    /// > Unable to connect to server. The session will end after the next 5
    /// > unsuccessful attempts.
    ///
    /// with a single button, OK. A title alone cannot distinguish "your licence
    /// expired" from "the licence server is unreachable and the session dies in
    /// five tries" — and the second is a countdown a person needs to see.
    ///
    /// THIS NEVER PRESSES ANYTHING. It reads.
    fn dialog_contents(&self, _hwnd: i64) -> Result<DialogContents, String> {
        Err("this host cannot read dialog contents".into())
    }

    /// THE FOREGROUND WINDOW, for the user-presence guard (0.9.9): `Ok(None)`
    /// when no window has the foreground; `Err` = could not look, which the
    /// guard treats as PRESENT.
    fn foreground_window(&self) -> Result<Option<ForegroundWindow>, String> {
        Err("this host cannot read the foreground window".into())
    }
    /// SECONDS SINCE THE LAST USER INPUT, system-wide (GetLastInputInfo). `Err`
    /// = could not read, which the guard treats as PRESENT.
    fn seconds_since_last_input(&self) -> Result<u64, String> {
        Err("this host cannot read the last-input time".into())
    }

    /// RE-READ ONE WINDOW'S OWNER: is the frame that owns `hwnd` enabled right
    /// now? `None` = no owner, or the read failed — UNKNOWN, never "enabled".
    /// The owner-state settle (0.9.8) reads this several times, because a frame
    /// that is merely busy for an instant reads disabled too.
    fn owner_enabled_of(&self, _hwnd: i64) -> Option<bool> {
        None
    }

    /// Is `hwnd` the standard file dialog, and what is typed in it? READ-ONLY.
    fn file_dialog(&self, _hwnd: i64) -> Result<FileDialogState, String> {
        Err("this host cannot read a file dialog".into())
    }

    /// CANCEL a dialog: WM_COMMAND IDCANCEL, as Esc or its Cancel button would.
    /// Used ONLY on a file dialog the bridge provably raised, with an empty file
    /// name (dialogs::may_cancel). `Ok` only when the window is gone after.
    fn cancel_dialog(&self, _hwnd: i64) -> Result<(), String> {
        Err("this host cannot cancel a dialog".into())
    }

    /// This process's own pid — recorded on a launch attempt so a LATER bridge
    /// can tell "somebody is supervising this" from "this was left behind".
    fn self_pid(&self) -> u32 {
        std::process::id()
    }
    fn now(&self) -> u64 {
        epoch_secs()
    }
    /// THE COMMIT CHARGE (0.10.5). `Err` = could not read, which is UNKNOWN —
    /// never "plenty".
    fn system_memory(&self) -> Result<SystemMemory, String> {
        Err("this host cannot read the machine's memory".into())
    }
    /// EVERY process this host can open, with its committed bytes — for the
    /// largest-holders report. Read-only; nothing here ever acts on one.
    fn private_by_process(&self) -> Result<Vec<ProcPrivate>, String> {
        Err("this host cannot list processes by memory".into())
    }
    /// One process's committed MB and handle count, read WITHOUT side effects
    /// (the simulator's `process_metrics` advances its CPU clock; this must not,
    /// because it runs on every ledger line).
    fn memory_of(&self, pid: u32) -> (Option<u64>, Option<u32>) {
        let m = self.process_metrics(pid);
        (m.private_mb, m.handles)
    }
    /// Name for the ledger, so a reader can tell a simulated run from a real one.
    fn kind(&self) -> &'static str;
}

/// The window that has the foreground, and the process that owns it.
#[derive(Clone, Debug, Default)]
pub struct ForegroundWindow {
    pub hwnd: i64,
    pub pid: u32,
    pub title: String,
    pub class_name: String,
}

pub fn epoch_secs() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

pub fn epoch_ms() -> u128 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)
}

/// The OS layer for this process: the real Windows one, or the simulator when
/// `SWX_LAUNCH_FAKE_DIR` is set (or we are not on Windows at all, where the
/// real one could not answer anything).
pub fn os() -> Box<dyn LaunchOs> {
    if let Ok(dir) = std::env::var(ENV_FAKE_DIR) {
        if !dir.is_empty() {
            return Box::new(FakeOs { dir: std::path::PathBuf::from(dir) });
        }
    }
    #[cfg(windows)]
    {
        Box::new(win::WindowsOs)
    }
    #[cfg(not(windows))]
    {
        Box::new(UnknownOs)
    }
}

/// Off Windows with no simulator configured: every question answers UNKNOWN.
/// Not "nothing is running" — the honest answer to a question this host cannot
/// be asked.
pub struct UnknownOs;

impl LaunchOs for UnknownOs {
    fn processes(&self) -> Result<Vec<ProcInfo>, String> {
        Err("no process census on this host (not Windows, and no launch simulator configured)".into())
    }
    fn launcher_logs(&self) -> Result<Vec<LogFile>, String> {
        Err("no 3DEXPERIENCE launcher logs on this host".into())
    }
    fn helper_logs(&self) -> Result<Vec<LogFile>, String> {
        Err("no 3DEXPERIENCE helper logs on this host".into())
    }
    fn read_log(&self, _path: &str) -> Result<String, String> {
        Err("no launcher logs on this host".into())
    }
    fn terminate(&self, _pid: u32, _image: &str, _created_at: Option<u64>) -> Result<(), String> {
        Err("this host cannot terminate processes for the launch machine".into())
    }
    fn pid_alive(&self, _pid: u32) -> Option<bool> {
        None
    }
    fn window_responding(&self, _hwnd: i64) -> Option<bool> {
        None
    }
    fn main_window_of_pid(&self, _pid: u32) -> Option<i64> {
        None
    }
    fn process_metrics(&self, _pid: u32) -> ProcMetrics {
        ProcMetrics::default()
    }
    fn kind(&self) -> &'static str {
        "unavailable"
    }
}

// ── the simulator ───────────────────────────────────────────────────────────

/// A machine on disk. Everything the state machine can observe is derived from
/// two test-authored files plus the invocation log the mock com host appends
/// to, so a test writes a SCENARIO ("route A stalls, route B works") and then
/// measures what the bridge DID about it.
///
/// Layout under `SWX_LAUNCH_FAKE_DIR`:
///
/// ```text
/// routes.json        {"<route>": {"chain":bool,"runBeginAfterMs":n|null,
///                                 "helperAfterMs":n|null,"sldworksAfterMs":n|null,
///                                 "attachAfterMs":n|null}}
/// preexisting.json   [{"pid":6076,"image":"3DEXPERIENCELauncher.exe","ageSeconds":3600}]
/// sldworks.json      {"responding":true,"privateMB":800,"handles":3000,"hwnd":4242}
/// invocations.jsonl  appended by the mock com host: {"seq":1,"route":"explorer","atMs":...}
/// terminated.jsonl   appended by terminate(): {"pid":...,"image":"...","atMs":...}
/// ```
pub struct FakeOs {
    pub dir: std::path::PathBuf,
}

/// What one route does on the simulated machine.
#[derive(Clone, Debug)]
pub struct RouteSpec {
    pub chain: bool,
    /// Can THIS invocation say what it started? Defaults to whatever the route
    /// can do in principle. Set `false` to model the shape `ShellExecuteExW` on
    /// a `.lnk` is most likely to produce: the shell hands the link to an
    /// already-running process (DDE) and returns NO HANDLE, so a route that can
    /// normally attribute does not, on this launch.
    pub attributable: Option<bool>,
    /// The chain runs, but its ROOT never appears in the census — modelling a
    /// handle-proven root whose image this bridge does not watch. Its children
    /// still appear, so lineage can (and must) carry across it.
    pub root_visible: bool,
    /// The chain runs but never appears in the process census — a launcher
    /// whose images this bridge does not watch, or one the census cannot see.
    /// Models "the invoke told us a root and we never found it".
    pub invisible_chain: bool,
    pub run_begin_after_ms: Option<u64>,
    pub helper_after_ms: Option<u64>,
    pub sldworks_after_ms: Option<u64>,
    pub attach_after_ms: Option<u64>,
    /// `sldworksSurvivesKill` (0.11.2): the launcher had already spawned
    /// sldworks.exe when its chain was killed, so it comes up anyway — measured
    /// 2026-10-15, 6 s after the "stall" cleanup.
    pub sldworks_survives_kill: bool,
}

impl Default for RouteSpec {
    /// THE OBSERVED FAILURE, as the default: the chain starts, the log stops at
    /// `SecurityContext:`, and nothing else ever happens. A scenario that does
    /// not mention a route gets the behaviour five of five real bridge launches
    /// produced on 2026-09-04.
    fn default() -> Self {
        RouteSpec {
            chain: true,
            attributable: None,
            root_visible: true,
            invisible_chain: false,
            run_begin_after_ms: None,
            helper_after_ms: None,
            sldworks_after_ms: None,
            attach_after_ms: None,
            sldworks_survives_kill: false,
        }
    }
}

/// One simulated launcher invocation.
#[derive(Clone, Debug)]
pub struct Invocation {
    pub seq: u64,
    pub route: String,
    pub at_ms: u128,
}

impl Invocation {
    /// Deterministic pids, so a test can name the process it expects to die.
    pub fn catstart_pid(&self) -> u32 {
        900_000 + (self.seq as u32) * 10
    }
    pub fn launcher_pid(&self) -> u32 {
        self.catstart_pid() + 1
    }
    pub fn sldworks_pid(&self) -> u32 {
        self.catstart_pid() + 2
    }
    pub fn log_path(&self) -> String {
        format!("fake://launcher/{}", self.seq)
    }
    pub fn helper_log_path(&self) -> String {
        format!("fake://helper/{}", self.seq)
    }
}

impl FakeOs {
    fn read_json(&self, name: &str) -> Value {
        std::fs::read_to_string(self.dir.join(name))
            .ok()
            .and_then(|t| serde_json::from_str(&t).ok())
            .unwrap_or(Value::Null)
    }

    /// Does this route create a launcher chain on the simulated machine?
    pub fn route_starts_chain(&self, route: &str) -> bool {
        self.spec(route).chain
    }

    /// The simulated thread ids a launcher process writes with: two per
    /// process, mirroring the real log's [main] then [run] pair.
    fn thread_of(launcher_pid: u32) -> u32 {
        launcher_pid + 500_000
    }

    /// Does THIS invocation hand back a root? A scenario may say `false` for a
    /// route that normally can, which is how the "no handle" branch is tested.
    pub fn route_reports_root(&self, route: &str) -> bool {
        let spec = self.spec(route);
        spec.chain && spec.attributable.unwrap_or(true)
    }

    fn spec(&self, route: &str) -> RouteSpec {
        let table = self.read_json("routes.json");
        let entry = table.get(route);
        let ms = |k: &str| entry.and_then(|e| e.get(k)).and_then(|v| v.as_u64());
        RouteSpec {
            chain: entry
                .and_then(|e| e.get("chain"))
                .and_then(|v| v.as_bool())
                .unwrap_or(true),
            attributable: entry.and_then(|e| e.get("attributable")).and_then(|v| v.as_bool()),
            root_visible: entry
                .and_then(|e| e.get("rootVisible"))
                .and_then(|v| v.as_bool())
                .unwrap_or(true),
            invisible_chain: entry
                .and_then(|e| e.get("invisibleChain"))
                .and_then(|v| v.as_bool())
                .unwrap_or(false),
            run_begin_after_ms: ms("runBeginAfterMs"),
            helper_after_ms: ms("helperAfterMs"),
            sldworks_after_ms: ms("sldworksAfterMs"),
            attach_after_ms: ms("attachAfterMs"),
            sldworks_survives_kill: entry.and_then(|e| e.get("sldworksSurvivesKill")).and_then(|v| v.as_bool()).unwrap_or(false),
        }
    }

    /// Every invocation the mock com host has made, oldest first.
    pub fn invocations(&self) -> Vec<Invocation> {
        std::fs::read_to_string(self.dir.join("invocations.jsonl"))
            .unwrap_or_default()
            .lines()
            .filter_map(|l| serde_json::from_str::<Value>(l).ok())
            .filter_map(|v| {
                Some(Invocation {
                    seq: v.get("seq")?.as_u64()?,
                    route: v.get("route")?.as_str()?.to_string(),
                    at_ms: v.get("atMs")?.as_u64()? as u128,
                })
            })
            .collect()
    }

    fn terminated_pids(&self) -> Vec<u32> {
        std::fs::read_to_string(self.dir.join("terminated.jsonl"))
            .unwrap_or_default()
            .lines()
            .filter_map(|l| serde_json::from_str::<Value>(l).ok())
            .filter_map(|v| v.get("pid").and_then(|p| p.as_u64()).map(|p| p as u32))
            .collect()
    }

    /// An invocation whose chain we killed makes no further progress — exactly
    /// like the real thing, where killing CATSTART ends that attempt.
    fn alive(&self, inv: &Invocation, dead: &[u32]) -> bool {
        !dead.contains(&inv.catstart_pid()) && !dead.contains(&inv.launcher_pid())
    }

    fn elapsed_ms(&self, inv: &Invocation) -> u128 {
        epoch_ms().saturating_sub(inv.at_ms)
    }

    fn reached(&self, inv: &Invocation, after: Option<u64>) -> bool {
        match after {
            None => false,
            Some(ms) => self.elapsed_ms(inv) >= ms as u128,
        }
    }

    /// Record an invocation. Called by the MOCK com backend, which is the
    /// simulated equivalent of "the shortcut was run".
    /// The simulated Visualize's pid, if it is running.
    pub fn visualize_running(&self) -> Option<u32> {
        let v = self.read_json("visualize.json");
        let pid = v.get("pid").and_then(|p| p.as_u64())? as u32;
        let before = v.get("runningBefore").and_then(|b| b.as_bool()).unwrap_or(false);
        let started = v.get("startsOnInvoke").and_then(|b| b.as_bool()).unwrap_or(true)
            && self.dir.join("visualize-invoked.json").exists();
        (before || started).then_some(pid)
    }

    fn visualize_windows(&self) -> Vec<Value> {
        self.read_json("visualize.json").get("windows").and_then(|w| w.as_array()).cloned().unwrap_or_default()
    }

    pub fn record_invoke(dir: &std::path::Path, route: &str, shortcut: &str) -> u64 {
        let _ = std::fs::create_dir_all(dir);
        let path = dir.join("invocations.jsonl");
        let seq = std::fs::read_to_string(&path).unwrap_or_default().lines().count() as u64 + 1;
        let line = json!({"seq": seq, "route": route, "atMs": epoch_ms() as u64, "shortcut": shortcut});
        let mut text = std::fs::read_to_string(&path).unwrap_or_default();
        text.push_str(&line.to_string());
        text.push('\n');
        let _ = std::fs::write(&path, text);
        // `dialogOnInvoke` (0.11.2): window rows this route's invoke puts up —
        // the File ▸ Open a re-invoke at a starting Connected SOLIDWORKS raised.
        let routes: Value = std::fs::read_to_string(dir.join("routes.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
        if let Some(rows) = routes.get(route).and_then(|r| r.get("dialogOnInvoke")).and_then(|d| d.as_array()) {
            let wpath = dir.join("windows.json");
            let mut all: Vec<Value> = std::fs::read_to_string(&wpath).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
            all.extend(rows.iter().cloned());
            let _ = std::fs::write(&wpath, Value::Array(all).to_string());
        }
        seq
    }

    /// Is a simulated SOLIDWORKS COM-attachable right now? Read by the MOCK com
    /// backend so the whole simulation has ONE source of truth: a route that
    /// stalls never becomes attachable, however long anybody polls.
    /// The pid of the SOLIDWORKS an ATTACHABLE invocation produced — the process
    /// COM would be talking to.
    ///
    /// The simulator used to publish a second SOLIDWORKS row for the mock's own
    /// "session", with a different pid and no parent, so one simulated session
    /// appeared in the census as two processes: one attached and parentless, one
    /// parented and unattached. That is not a machine state that exists, and it
    /// made the attached process unprovable by construction.
    pub fn attached_sldworks_pid(dir: &std::path::Path) -> Option<u32> {
        let me = FakeOs { dir: dir.to_path_buf() };
        let dead = me.terminated_pids();
        me.invocations()
            .into_iter()
            .filter(|i| me.alive(i, &dead) || me.spec(&i.route).sldworks_survives_kill)
            .find(|i| me.reached(i, me.spec(&i.route).attach_after_ms))
            .map(|i| i.sldworks_pid())
    }

    pub fn attachable(dir: &std::path::Path) -> bool {
        let me = FakeOs { dir: dir.to_path_buf() };
        let dead = me.terminated_pids();
        me.invocations()
            .iter()
            .filter(|i| me.alive(i, &dead) || me.spec(&i.route).sldworks_survives_kill)
            .any(|i| me.reached(i, me.spec(&i.route).attach_after_ms))
    }
}

impl LaunchOs for FakeOs {
    fn processes(&self) -> Result<Vec<ProcInfo>, String> {
        if self.dir.join("census-fails").exists() {
            return Err("simulated: the process census could not be taken".into());
        }
        let now = epoch_secs();
        let dead = self.terminated_pids();
        // A QUIT THAT HAPPENED reaps SOLIDWORKS and its per-session children from
        // the census. The mock backend (which performs the rung) and this census
        // (which measures it) read the SAME file, so the measurement can never
        // disagree with the act — the whole point of measuring by census.
        // A rung may report success while the process takes SECONDS to actually
        // go (a real `ExitApp` returns first), so each line carries the instant
        // its exit becomes EFFECTIVE. Until then SOLIDWORKS is still in the
        // census — which is exactly the divergence a single post-rung sample used
        // to hide.
        let quit = std::fs::read_to_string(self.dir.join("quit.jsonl"))
            .map(|t| {
                t.lines().filter(|l| !l.trim().is_empty()).any(|l| {
                    let v: Value = serde_json::from_str(l).unwrap_or(Value::Null);
                    let at = v
                        .get("effectiveAtMs")
                        .or_else(|| v.get("atMs"))
                        .and_then(|x| x.as_u64())
                        .unwrap_or(0);
                    at as u128 <= epoch_ms()
                })
            })
            .unwrap_or(false);
        let reaped = |image: &str| -> bool {
            quit && (image.eq_ignore_ascii_case("SLDWORKS.exe")
                || crate::quit::SESSION_CHILDREN.iter().any(|c| c.eq_ignore_ascii_case(image)))
        };
        let mut out: Vec<ProcInfo> = Vec::new();
        // Processes that were ALREADY THERE. The whole point of the creation-time
        // rule is that these survive every stall recovery.
        if let Some(rows) = self.read_json("preexisting.json").as_array() {
            for r in rows {
                let Some(pid) = r.get("pid").and_then(|p| p.as_u64()) else { continue };
                let pid = pid as u32;
                if dead.contains(&pid) {
                    continue;
                }
                let image = r.get("image").and_then(|i| i.as_str()).unwrap_or("").to_string();
                // `reapedAfterQuitMs` (0.11.3): a per-session child that outlives
                // the quit by this long — what a start right after a quit must
                // wait out.
                let lingers = r.get("reapedAfterQuitMs").and_then(|x| x.as_u64()).map(|n| {
                    let first_quit = std::fs::read_to_string(self.dir.join("quit.jsonl")).unwrap_or_default().lines().filter_map(|l| {
                        let v: Value = serde_json::from_str(l).ok()?;
                        v.get("effectiveAtMs").or_else(|| v.get("atMs")).and_then(|x| x.as_u64())
                    }).min();
                    first_quit.map(|q| (epoch_ms() as u64) < q + n).unwrap_or(true)
                });
                if reaped(&image) && lingers != Some(true) {
                    continue;
                }
                out.push(ProcInfo {
                    pid,
                    image,
                    created_at: Some(
                        now.saturating_sub(r.get("ageSeconds").and_then(|a| a.as_u64()).unwrap_or(3600)),
                    ),
                    ppid: r.get("ppid").and_then(|x| x.as_u64()).map(|v| v as u32),
                });
            }
        }
        // The SIMULATED SOLIDWORKS, as the mock backend published it. Present
        // until a quit rung reaps it — so the ladder's success measurement reads
        // the same truth the rung acted on.
        if !quit {
            if let Ok(t) = std::fs::read_to_string(self.dir.join("mock-session")) {
                if let Ok(pid) = t.trim().parse::<i64>() {
                    let pid = (pid.unsigned_abs() % 4_000_000_000) as u32;
                    // ONE SESSION IS ONE PROCESS. If a route of this scenario
                    // already produced a SOLIDWORKS, the mock's session IS that
                    // process — publishing a second row with another pid and no
                    // parent invents a machine state that cannot occur.
                    let route_spawned = Self::attached_sldworks_pid(&self.dir);
                    if route_spawned.is_some() {
                        // fall through: the invocation-derived row below is the one
                    } else if !dead.contains(&pid) && !out.iter().any(|p| p.pid == pid) {
                        // A STABLE creation time, written once and reused. Deriving
                        // it from `now` on every read made it DRIFT — and the quit
                        // ladder's success test is "this pid AND this creation time
                        // are gone", so a drifting one reported the process as gone
                        // on the very next census. A real creation time never moves.
                        let f = self.dir.join("sldworks-created");
                        let created = match std::fs::read_to_string(&f)
                            .ok()
                            .and_then(|t| t.trim().parse::<u64>().ok())
                        {
                            Some(v) => v,
                            None => {
                                let v = now.saturating_sub(3600);
                                let _ = std::fs::write(&f, v.to_string());
                                v
                            }
                        };
                        out.push(ProcInfo {
                            pid,
                            image: "SLDWORKS.exe".into(),
                            created_at: Some(created),
                            // LINEAGE, when a scenario wants to model it: the pid
                            // that started SOLIDWORKS. Re-attribution after a
                            // sign-in requires a POSITIVE link back to our own
                            // chain, and this is how a test supplies (or withholds)
                            // one. `sldworks-parent` holds a pid.
                            ppid: std::fs::read_to_string(self.dir.join("sldworks-parent"))
                                .ok()
                                .and_then(|t| t.trim().parse::<u32>().ok()),
                        });
                        // ITS OWN CHILDREN, parented to it. A 3DEXPERIENCE
                        // sign-in / licence / cloud-save window is a WEB VIEW,
                        // and its hwnd belongs to a `swCefSubProc.exe` child —
                        // so a dialog census that only knows the SOLIDWORKS pid
                        // cannot see the one modal most likely to be up. The
                        // fixture names the images; their pids are derived from
                        // the parent's so a test can predict nothing about them
                        // except the parentage, which is the whole point.
                        for (i, img) in self
                            .read_json("sldworks-children.json")
                            .as_array()
                            .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
                            .unwrap_or_default()
                            .iter()
                            .enumerate()
                        {
                            let child = pid.wrapping_add(1 + i as u32);
                            if dead.contains(&child) || out.iter().any(|p| p.pid == child) {
                                continue;
                            }
                            out.push(ProcInfo {
                                pid: child,
                                image: img.clone(),
                                created_at: Some(created + 1),
                                ppid: Some(pid),
                            });
                        }
                    }
                }
            }
        }
        for inv in self.invocations() {
            let spec = self.spec(&inv.route);
            let started = (inv.at_ms / 1000) as u64;
            if spec.chain && !spec.invisible_chain {
                for (pid, image, ppid) in [
                    (inv.catstart_pid(), "CATSTART.exe", None),
                    (inv.launcher_pid(), "SWXDesktopLauncher.exe", Some(inv.catstart_pid())),
                ] {
                    // `rootVisible:false` hides the CATSTART the invoke reported
                    // as its root while leaving its child in the census, which
                    // is what an unwatched shim looks like from here.
                    if pid == inv.catstart_pid() && !spec.root_visible {
                        continue;
                    }
                    if !dead.contains(&pid) {
                        out.push(ProcInfo { pid, image: image.into(), created_at: Some(started), ppid });
                    }
                }
            }
            if (self.alive(&inv, &dead) || spec.sldworks_survives_kill) && self.reached(&inv, spec.sldworks_after_ms) {
                out.push(ProcInfo {
                    pid: inv.sldworks_pid(),
                    image: "SLDWORKS.exe".into(),
                    created_at: Some(started),
                    // A real sldworks.exe is spawned BY the launcher, so the
                    // simulator models it as a child: the descendant walk must
                    // meet it and refuse it, not never meet it.
                    //
                    // `sldworks-parent` OVERRIDES that, which is how a scenario
                    // says "this SOLIDWORKS is the USER'S — it descends from a
                    // launcher that was never ours". Without the override here a
                    // fixture could only express the friendly case, and a
                    // lineage proof that is never asked a hostile question is not
                    // a proof.
                    ppid: std::fs::read_to_string(self.dir.join("sldworks-parent"))
                        .ok()
                        .and_then(|t| t.trim().parse::<u32>().ok())
                        .or(Some(inv.launcher_pid())),
                });
            }
        }
        // ── PID REUSE, which nothing else here could express.
        //
        //    A pid is one number held by DIFFERENT PROCESSES AT DIFFERENT TIMES,
        //    and that is the whole hazard the lineage rule exists for: our
        //    launcher dies, the user's launcher is issued the same number, and
        //    their SOLIDWORKS records it as its parent. Until now a fixture could
        //    only say "this pid exists"; it could not RETIRE one and reissue it,
        //    so the hostile case could only be posed in a unit test and never
        //    end-to-end.
        //
        //    `census-overrides.json` is a list of edits applied last, in order:
        //      {"retire": 900011}                     -> remove that pid
        //      {"pid": 900011, "image": "CATSTART.exe",
        //       "createdAt": 1789…, "ppid": null}     -> (re)issue it
        //    Reissuing a retired pid with a NEW creation time is exactly the
        //    situation `same_process` and the lineage window are written against.
        if let Some(rows) = self.read_json("census-overrides.json").as_array() {
            for r in rows {
                if let Some(pid) = r.get("retire").and_then(|x| x.as_u64()) {
                    out.retain(|p| p.pid != pid as u32);
                    continue;
                }
                let Some(pid) = r.get("pid").and_then(|x| x.as_u64()) else { continue };
                let pid = pid as u32;
                out.retain(|p| p.pid != pid);
                out.push(ProcInfo {
                    pid,
                    image: r
                        .get("image")
                        .and_then(|x| x.as_str())
                        .unwrap_or("CATSTART.exe")
                        .to_string(),
                    // `ageSeconds` (now minus that many, as everywhere else in
                    // this simulator) or an explicit `createdAt` epoch.
                    created_at: r
                        .get("ageSeconds")
                        .and_then(|x| x.as_i64())
                        .map(|age| if age >= 0 {
                            now.saturating_sub(age as u64)
                        } else {
                            now + age.unsigned_abs()
                        })
                        .or_else(|| r.get("createdAt").and_then(|x| x.as_u64())),
                    ppid: r.get("ppid").and_then(|x| x.as_u64()).map(|p| p as u32),
                });
            }
        }
        // SOLIDWORKS VISUALIZE (0.9.19): `visualize.json` —
        // {pid, runningBefore?, startsOnInvoke?} — running before the bridge
        // looked, or once the bridge invoked its shortcut.
        if let Some(v) = self.visualize_running() {
            if !out.iter().any(|p| p.pid == v) {
                out.push(ProcInfo { pid: v, image: "SLDWORKSVisualize.exe".into(), created_at: Some(now.saturating_sub(30)), ppid: None });
            }
        }
        Ok(out)
    }

    fn launcher_logs(&self) -> Result<Vec<LogFile>, String> {
        let mut out: Vec<LogFile> = self
            .invocations()
            .iter()
            .filter(|i| self.spec(&i.route).chain)
            .map(|i| LogFile {
                path: i.log_path(),
                created_at: Some((i.at_ms / 1000) as u64),
                // Monotone in invocation time, exactly like the launcher's own
                // yyyyMMddHHmmss. Only ever compared with other stamps.
                stamp: Some(i.at_ms as u64),
            })
            .collect();
        out.reverse();
        Ok(out)
    }

    fn helper_logs(&self) -> Result<Vec<LogFile>, String> {
        let dead = self.terminated_pids();
        let mut out: Vec<LogFile> = self
            .invocations()
            .iter()
            .filter(|i| self.alive(i, &dead) && self.reached(i, self.spec(&i.route).helper_after_ms))
            .map(|i| LogFile {
                path: i.helper_log_path(),
                created_at: Some((i.at_ms / 1000) as u64),
                stamp: Some(i.at_ms as u64),
            })
            .collect();
        out.reverse();
        Ok(out)
    }

    fn read_log(&self, path: &str) -> Result<String, String> {
        let seq: u64 = path
            .rsplit('/')
            .next()
            .and_then(|s| s.parse().ok())
            .ok_or_else(|| format!("not a simulated log path: {path}"))?;
        let inv = self
            .invocations()
            .into_iter()
            .find(|i| i.seq == seq)
            .ok_or_else(|| format!("no simulated launch {seq}"))?;
        let dead = self.terminated_pids();
        // The REAL shape: the header and `SecurityContext:` are written by the
        // first thread and always appear; `Run Begin` comes from a SECOND thread
        // about two seconds later, and on the failing route it never comes.
        // The launcher stamps the writing THREAD's id on every line, and the
        // simulator models a distinct thread per launcher process so a log can
        // be bound to the process that wrote it — see `writer_thread_of`.
        let tid = Self::thread_of(inv.launcher_pid());
        let mut text = format!(
            "00:00:00 [{tid}] info PackagingVersionShortName: V6R2026x.HF4.13\n\
             00:00:00 [{tid}] info AppName=SWXCSWK_AP\n\
             00:00:00 [{tid}] info SecurityContext: \n"
        );
        if self.alive(&inv, &dead) && self.reached(&inv, self.spec(&inv.route).run_begin_after_ms) {
            text.push_str(&format!(
                "00:00:02 [{}] info Run Begin\n\
                 00:00:02 [{}] info Located C:\\Program Files\\...\\sldworks.exe majorversion: 34\n",
                tid + 1,
                tid + 1
            ));
        }
        Ok(text)
    }

    fn terminate(&self, pid: u32, image: &str, created_at: Option<u64>) -> Result<(), String> {
        let census = self.processes()?;
        let Some(p) = census.iter().find(|p| p.pid == pid) else {
            return Err(format!("pid {pid} is not running"));
        };
        if !p.is(image) {
            return Err(format!("pid {pid} is {} now, not {image} — refusing", p.image));
        }
        if let Some(want) = created_at {
            if p.created_at != Some(want) {
                return Err(format!(
                    "pid {pid} started at {:?}, not {want} — a different process has this number now",
                    p.created_at
                ));
            }
        }
        let path = self.dir.join("terminated.jsonl");
        let mut text = std::fs::read_to_string(&path).unwrap_or_default();
        text.push_str(&json!({"pid": pid, "image": image, "atMs": epoch_ms() as u64}).to_string());
        text.push('\n');
        std::fs::write(&path, text).map_err(|e| e.to_string())
    }

    fn window_responding(&self, _hwnd: i64) -> Option<bool> {
        self.read_json("sldworks.json").get("responding").and_then(|v| v.as_bool()).or(Some(true))
    }

    fn main_window_of_pid(&self, _pid: u32) -> Option<i64> {
        self.read_json("sldworks.json").get("hwnd").and_then(|v| v.as_i64()).or(Some(4242))
    }

    /// The simulated desktop: `windows.json`, scoped to the pid when a row names
    /// one. `window-census-fails` makes the census UNREADABLE, which callers must
    /// treat as unknown rather than as "nothing on screen".
    fn dialog_contents(&self, hwnd: i64) -> Result<DialogContents, String> {
        // A Visualize window's words (its scenario row's `text` / `buttons`).
        if let Some(row) = self.visualize_windows().into_iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd)) {
            let list = |k: &str| -> Vec<String> {
                row.get(k).and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default()
            };
            let (text, buttons) = (list("text"), list("buttons"));
            let mut controls: Vec<(String, bool)> = text.iter().map(|_| ("Static".to_string(), true)).collect();
            controls.extend(buttons.iter().map(|_| ("Button".to_string(), true)));
            return Ok(DialogContents { text, buttons, controls, ..Default::default() });
        }
        if self.dir.join("dialog-contents-unreadable").exists() {
            return Err("simulated: the dialog's controls could not be read".into());
        }
        // The scenario's own dialog carries its text and buttons, so a test can
        // hold the LIVE licence dialog exactly as it was read on 2026-09-17.
        let d = self.read_json("dialog-open.json");
        let matches = d.get("hwnd").and_then(|v| v.as_i64()).map(|h| h == hwnd).unwrap_or(false);
        if !matches {
            return Err("no such dialog in this scenario".into());
        }
        let list = |k: &str| -> Vec<String> {
            d.get(k)
                .and_then(|v| v.as_array())
                .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
                .unwrap_or_default()
        };
        // THE SIMULATOR CAN HAND BACK RAW CONTROLS TOO. Pre-classified lists are
        // convenient and prove nothing about the classifier, so a scenario may
        // instead give `controls: [[class, style, text], …]` and go through the
        // same `classify_control` the real host uses.
        if let Some(rows) = d.get("controls").and_then(|v| v.as_array()) {
            let mut out = DialogContents::default();
            for r in rows {
                let class = r.get(0).and_then(|v| v.as_str()).unwrap_or("");
                let style = r.get(1).and_then(|v| v.as_u64()).unwrap_or(0) as u32;
                let text = r.get(2).and_then(|v| v.as_str()).unwrap_or("").to_string();
                let visible = r.get(3).and_then(|v| v.as_bool()).unwrap_or(true);
                out.controls.push((class.to_string(), visible));
                match classify_control(class, style, &text) {
                    Some("button") => out.buttons.push(text),
                    Some("checkbox") | Some("radio") => out.checkboxes.push(text),
                    Some("text") => out.text.push(text),
                    _ => {}
                }
            }
            return Ok(out);
        }
        // Pre-classified lists: the controls are what they say they are — a
        // button is a Button-class child, a line of text a Static.
        let (text, buttons, checkboxes) = (list("text"), list("buttons"), list("checkboxes"));
        let mut controls: Vec<(String, bool)> = Vec::new();
        controls.extend(text.iter().map(|_| ("Static".to_string(), true)));
        controls.extend(buttons.iter().chain(checkboxes.iter()).map(|_| ("Button".to_string(), true)));
        Ok(DialogContents {
            text,
            buttons,
            checkboxes,
            truncated: d.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),
            controls,
        })
    }

    /// `foreground.json`: `{"hwnd","pid","title","className"}`; `{"none": true}`
    /// for "no window has the foreground"; absent = another application. `presence-unreadable` makes both
    /// presence reads fail.
    fn foreground_window(&self) -> Result<Option<ForegroundWindow>, String> {
        if self.dir.join("presence-unreadable").exists() {
            return Err("simulated: the foreground window could not be read".into());
        }
        // No scenario: the user's desktop has some other application in front.
        // `{"none": true}` is "no window has the foreground" (secure desktop).
        if !self.dir.join("foreground.json").exists() {
            return Ok(Some(ForegroundWindow {
                hwnd: 1,
                pid: 1,
                title: "(simulated: another application)".into(),
                class_name: "SimulatedOtherApp".into(),
            }));
        }
        let v = self.read_json("foreground.json");
        let Some(hwnd) = v.get("hwnd").and_then(|h| h.as_i64()) else { return Ok(None) };
        Ok(Some(ForegroundWindow {
            hwnd,
            pid: v.get("pid").and_then(|p| p.as_u64()).unwrap_or(0) as u32,
            title: v.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),
            class_name: v.get("className").and_then(|t| t.as_str()).unwrap_or("").to_string(),
        }))
    }

    /// `last-input-secs` holds the seconds since the last user input; absent =
    /// an idle machine (an hour).
    fn seconds_since_last_input(&self) -> Result<u64, String> {
        if self.dir.join("presence-unreadable").exists() {
            return Err("simulated: the last-input time could not be read".into());
        }
        // `user-arrives-after-documents`: N (0.10.9) — the user sits down once
        // the simulated SOLIDWORKS has created N documents, so a test can make
        // presence change in the middle of a run.
        if let Some(n) = std::fs::read_to_string(self.dir.join("user-arrives-after-documents")).ok().and_then(|t| t.trim().parse::<u64>().ok()) {
            let made = std::fs::read_to_string(self.dir.join("documents-created")).ok().and_then(|t| t.trim().parse::<u64>().ok()).unwrap_or(0);
            if made >= n {
                return Ok(5);
            }
        }
        Ok(std::fs::read_to_string(self.dir.join("last-input-secs"))
            .ok()
            .and_then(|t| t.trim().parse().ok())
            .unwrap_or(3600))
    }

    /// The scenario's row for `hwnd` may carry `ownerEnabledReads: [false,
    /// false, true]` — one value per re-read, the last repeating; otherwise its
    /// `ownerEnabled` holds for every read.
    /// `file-dialogs.json`: {"<hwnd>": {"standard": bool, "fileName": "..." | null}}.
    fn file_dialog(&self, hwnd: i64) -> Result<FileDialogState, String> {
        let all = self.read_json("file-dialogs.json");
        let row = all.get(hwnd.to_string()).ok_or("simulated: not a scripted file dialog")?;
        Ok(FileDialogState {
            standard: row.get("standard").and_then(|v| v.as_bool()).unwrap_or(false),
            file_name: row.get("fileName").and_then(|v| v.as_str()).map(String::from),
        })
    }

    /// Records the cancel (`cancelled-dialogs.jsonl`) and takes the window out
    /// of `windows.json`, as Windows would; `cancel-dialog-fails` leaves it up.
    fn cancel_dialog(&self, hwnd: i64) -> Result<(), String> {
        use std::io::Write;
        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.dir.join("cancelled-dialogs.jsonl")) {
            let _ = writeln!(f, "{}", json!({"hwnd": hwnd}));
        }
        if self.dir.join("cancel-dialog-fails").exists() {
            return Err("simulated: the dialog was still up after IDCANCEL".into());
        }
        if let Some(rows) = self.read_json("windows.json").as_array() {
            let kept: Vec<Value> = rows.iter().filter(|r| r.get("hwnd").and_then(|h| h.as_i64()) != Some(hwnd)).cloned().collect();
            let _ = std::fs::write(self.dir.join("windows.json"), Value::Array(kept).to_string());
        }
        Ok(())
    }

    fn owner_enabled_of(&self, hwnd: i64) -> Option<bool> {
        let rows = self.read_json("windows.json");
        let row = rows.as_array()?.iter().find(|r| r.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd))?.clone();
        match row.get("ownerEnabledReads").and_then(|v| v.as_array()) {
            Some(seq) if !seq.is_empty() => {
                let cursor = self.dir.join(format!("owner-reads-{hwnd}.cursor"));
                let i: usize = std::fs::read_to_string(&cursor).ok().and_then(|t| t.trim().parse().ok()).unwrap_or(0);
                let _ = std::fs::write(&cursor, (i + 1).to_string());
                seq.get(i.min(seq.len() - 1)).and_then(|v| v.as_bool())
            }
            _ => row.get("ownerEnabled").and_then(|v| v.as_bool()),
        }
    }

    fn windows_of_pid(&self, pid: u32) -> Result<Vec<WindowInfo>, String> {
        if self.dir.join("window-census-fails").exists() {
            return Err("simulated: the window census could not be taken".into());
        }
        if self.visualize_running() == Some(pid) {
            return Ok(self
                .visualize_windows()
                .iter()
                .filter_map(|w| {
                    Some(WindowInfo {
                        hwnd: w.get("hwnd")?.as_i64()?,
                        class_name: w.get("className").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        title: w.get("title").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        visible: true,
                        ..Default::default()
                    })
                })
                .collect());
        }
        let mut out: Vec<WindowInfo> = Vec::new();
        let read = |r: &Value| -> Option<WindowInfo> {
            Some(WindowInfo {
                hwnd: r.get("hwnd")?.as_i64()?,
                class_name: r.get("className").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                title: r.get("title").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                visible: r.get("visible").and_then(|x| x.as_bool()).unwrap_or(true),
                owner: r.get("owner").and_then(|x| x.as_i64()),
                owner_enabled: r.get("ownerEnabled").and_then(|x| x.as_bool()),
                style: r.get("style").and_then(|x| x.as_u64()).map(|v| v as u32),
                exstyle: r.get("exStyle").and_then(|x| x.as_u64()).map(|v| v as u32),
                rect: r.get("rect").and_then(|x| x.as_array()).and_then(|a| {
                    let v: Vec<i32> = a.iter().filter_map(|n| n.as_i64().map(|n| n as i32)).collect();
                    (v.len() == 4).then(|| [v[0], v[1], v[2], v[3]])
                }),
            })
        };
        if let Some(rows) = self.read_json("windows.json").as_array() {
            for r in rows {
                let owner = r.get("pid").and_then(|x| x.as_u64()).map(|p| p as u32);
                if owner.is_some() && owner != Some(pid) {
                    continue;
                }
                if let Some(w) = read(r) {
                    out.push(w);
                }
            }
        }
        // THE 3DEXPERIENCE ID SIGN-IN, on whichever process the scenario says
        // owns it. `sign-in.json` is {"ownerPid":N,...} and the file is DELETED
        // to model the user answering it — which is what lets a test prove that
        // the paused deadlines resume.
        // `sign-in.json` may hold ONE login window or an ARRAY of them (0.9.16:
        // the Visualize host and the launcher's at once).
        let si_all = self.read_json("sign-in.json");
        let rows: Vec<Value> = match &si_all {
            Value::Array(a) => a.clone(),
            v if v.is_object() => vec![v.clone()],
            _ => Vec::new(),
        };
        for si in &rows {
            let owner = si.get("ownerPid").and_then(|x| x.as_u64()).map(|p| p as u32);
            // No owner named: it belongs to whichever launcher pid asks, which is
            // how a scenario says "our chain is showing it".
            if owner.is_none() || owner == Some(pid) {
                out.push(WindowInfo {
                    hwnd: si.get("hwnd").and_then(|x| x.as_i64()).unwrap_or(777001),
                    class_name: si
                        .get("className")
                        .and_then(|x| x.as_str())
                        .unwrap_or("DSYSysLoginPanelWebView2Class")
                        .to_string(),
                    title: si
                        .get("title")
                        .and_then(|x| x.as_str())
                        .unwrap_or("Login | 3DEXPERIENCE ID | Dassault Systemes")
                        .to_string(),
                    visible: true,
                    ..Default::default()
                });
            }
        }
        // A dialog a QUIT RUNG raised. The ladder must stop on it, and this is
        // how a scenario puts one on screen between rungs.
        let d = self.read_json("dialog-open.json");
        if d.is_object() {
            if let Some(w) = read(&d) {
                out.push(w);
            }
        }
        Ok(out)
    }

    fn thread_owner(&self, tid: u32) -> Option<u32> {
        let dead = self.terminated_pids();
        self.invocations()
            .into_iter()
            .find(|i| Self::thread_of(i.launcher_pid()) == tid || Self::thread_of(i.launcher_pid()) + 1 == tid)
            .map(|i| i.launcher_pid())
            .filter(|p| !dead.contains(p))
    }

    fn process_metrics(&self, _pid: u32) -> ProcMetrics {
        let s = self.read_json("sldworks.json");
        let private_mb = s.get("privateMB").and_then(|v| v.as_u64()).or(Some(800));
        // `cpuSecondsPerRead` advances the simulated CPU clock on every read, so
        // a scenario can be "burning CPU" (>0) or "dead" (0).
        let step = s.get("cpuSecondsPerRead").and_then(|v| v.as_f64()).unwrap_or(0.0);
        let reads = {
            let f = self.dir.join("cpu-reads");
            let n = std::fs::read_to_string(&f).ok().and_then(|t| t.trim().parse::<u64>().ok()).unwrap_or(0);
            let _ = std::fs::write(&f, (n + 1).to_string());
            n
        };
        ProcMetrics {
            private_mb,
            working_set_mb: s
                .get("workingSetMB")
                .and_then(|v| v.as_u64())
                .or_else(|| private_mb.map(|p| p * 3 / 4)),
            cpu_seconds: s
                .get("cpuSeconds")
                .and_then(|v| v.as_f64())
                .or(Some(100.0))
                .map(|base| base + step * reads as f64),
            handles: s.get("handles").and_then(|v| v.as_u64()).map(|h| h as u32).or(Some(3000)),
        }
    }

    /// `memory.json` — `{"commitLimitMB", "commitAvailMB", "physTotalMB",
    /// "physAvailMB", "processes": [{"name", "pid", "privateMB"}], "afterQuit":
    /// {...}}`. `afterQuit` overlays the rest once a quit has happened, so a
    /// scenario can show what a recycle returned.
    fn system_memory(&self) -> Result<SystemMemory, String> {
        let m = self.memory_json()?;
        let get = |k: &str| m.get(k).and_then(|v| v.as_u64());
        Ok(SystemMemory {
            commit_limit_mb: get("commitLimitMB").ok_or("simulated: memory.json has no commitLimitMB")?,
            commit_avail_mb: get("commitAvailMB").ok_or("simulated: memory.json has no commitAvailMB")?,
            phys_total_mb: get("physTotalMB").unwrap_or(0),
            phys_avail_mb: get("physAvailMB").unwrap_or(0),
        })
    }

    fn private_by_process(&self) -> Result<Vec<ProcPrivate>, String> {
        let m = self.memory_json()?;
        let mut out: Vec<ProcPrivate> = m
            .get("processes")
            .and_then(|p| p.as_array())
            .map(|a| {
                a.iter()
                    .filter_map(|r| {
                        Some(ProcPrivate {
                            name: r.get("name")?.as_str()?.to_string(),
                            pid: r.get("pid")?.as_u64()? as u32,
                            private_mb: r.get("privateMB")?.as_u64()?,
                        })
                    })
                    .collect()
            })
            .unwrap_or_default();
        // The simulated SOLIDWORKS, holding what sldworks.json says.
        if let Ok(census) = self.processes() {
            for p in census.iter().filter(|p| p.is("SLDWORKS.exe")) {
                if !out.iter().any(|x| x.pid == p.pid) {
                    if let (Some(mb), _) = self.memory_of(p.pid) {
                        out.push(ProcPrivate { name: p.image.clone(), pid: p.pid, private_mb: mb });
                    }
                }
            }
        }
        Ok(out)
    }

    fn memory_of(&self, _pid: u32) -> (Option<u64>, Option<u32>) {
        let s = self.read_json("sldworks.json");
        // `growthPerDocumentMB` (0.10.6): the measured leak — every document
        // the simulated SOLIDWORKS created keeps that much after its close.
        let created = std::fs::read_to_string(self.dir.join("documents-created"))
            .ok()
            .and_then(|t| t.trim().parse::<u64>().ok())
            .unwrap_or(0);
        let growth = s.get("growthPerDocumentMB").and_then(|v| v.as_u64()).unwrap_or(0) * created;
        (
            s.get("privateMB").and_then(|v| v.as_u64()).or(Some(800)).map(|m| m + growth),
            s.get("handles").and_then(|v| v.as_u64()).map(|h| h as u32).or(Some(3000)),
        )
    }

    fn kind(&self) -> &'static str {
        "simulated"
    }
}

impl FakeOs {
    fn memory_json(&self) -> Result<Value, String> {
        let mut m = self.read_json("memory.json");
        if !m.is_object() {
            return Err("simulated: no memory.json".into());
        }
        let quit = std::fs::read_to_string(self.dir.join("quit.jsonl")).map(|t| !t.trim().is_empty()).unwrap_or(false);
        if quit {
            if let Some(Value::Object(after)) = m.get("afterQuit").cloned() {
                if let Value::Object(map) = &mut m {
                    for (k, v) in after {
                        map.insert(k, v);
                    }
                }
            }
        }
        Ok(m)
    }
}

// ── the real one ────────────────────────────────────────────────────────────

#[cfg(windows)]
mod win {
    use super::*;
    use windows::core::PCWSTR;
    use windows::core::BOOL;
    use windows::Win32::Foundation::{CloseHandle, FILETIME, HANDLE, HWND, LPARAM};
    use windows::Win32::System::Diagnostics::ToolHelp::{
        CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
        TH32CS_SNAPPROCESS,
    };
    use windows::Win32::System::ProcessStatus::{
        K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
    };
    use windows::Win32::System::Threading::{
        GetProcessHandleCount, GetProcessTimes, OpenProcess, TerminateProcess,
        PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE,
    };
    use windows::Win32::UI::WindowsAndMessaging::{
        EnumWindows, GetWindowLongW, GetWindowThreadProcessId, IsHungAppWindow, IsWindowVisible,
        GWL_STYLE, WS_VISIBLE,
    };

    pub struct WindowsOs;

    /// Where the 3DEXPERIENCE launcher and its helper write their logs. These
    /// are the two files that say whether a launch is PROGRESSING or has stalled
    /// at `SecurityContext:` — the single most useful signal on the machine, and
    /// the reason this bridge no longer has to guess.
    fn log_dir(sub: &str) -> Option<std::path::PathBuf> {
        let appdata = std::env::var("APPDATA").ok().filter(|s| !s.is_empty())?;
        Some(std::path::Path::new(&appdata).join("DassaultSystemes").join(sub))
    }

    fn logs_in(sub: &str) -> Result<Vec<LogFile>, String> {
        let dir = log_dir(sub).ok_or("APPDATA is not set")?;
        let entries = std::fs::read_dir(&dir)
            .map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
        let mut out = Vec::new();
        for e in entries.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            // `*_dslcc.log` is the licence client's own trace, not the launcher's
            // progress log — it is large, noisy, and says nothing about Run Begin.
            if !name.to_ascii_lowercase().ends_with(".log") || name.contains("_dslcc") {
                continue;
            }
            // CREATED, falling back to MODIFIED — and they are not the same
            // fact. The launcher appends to its log for as long as it runs, so
            // `modified()` on a live log is "a moment ago" whatever the launch
            // is doing. The fallback is only reached on a filesystem that does
            // not record creation times (some network shares), where it is the
            // only thing available; on NTFS `created()` always answers, and the
            // filename stamp corroborates it either way.
            let created = e
                .metadata()
                .ok()
                .and_then(|m| m.created().or_else(|_| m.modified()).ok())
                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                .map(|d| d.as_secs());
            out.push(LogFile {
                path: e.path().to_string_lossy().to_string(),
                created_at: created,
                stamp: stamp_of(&name),
            });
        }
        // Newest first, by the launcher's own stamp where it has one.
        out.sort_by(|a, b| b.stamp.cmp(&a.stamp).then_with(|| b.created_at.cmp(&a.created_at)));
        Ok(out)
    }

    fn filetime_to_epoch(ft: FILETIME) -> Option<u64> {
        let ticks = ((ft.dwHighDateTime as u64) << 32) | ft.dwLowDateTime as u64;
        if ticks == 0 {
            return None;
        }
        // FILETIME counts 100ns ticks from 1601-01-01; 11644473600 seconds to epoch.
        Some((ticks / 10_000_000).saturating_sub(11_644_473_600))
    }

    /// The creation time of an ALREADY-OPEN handle. This is the form that
    /// closes the kill-path TOCTOU: a handle refers to one particular process
    /// object for as long as it is open, so a check made through it cannot be
    /// answered about a different process that took the same pid.
    unsafe fn created_at_of_handle(h: HANDLE) -> Option<u64> {
        let mut created = FILETIME::default();
        let (mut exit, mut kernel, mut user) =
            (FILETIME::default(), FILETIME::default(), FILETIME::default());
        if unsafe { GetProcessTimes(h, &mut created, &mut exit, &mut kernel, &mut user) }.is_ok() {
            filetime_to_epoch(created)
        } else {
            None
        }
    }

    fn created_at_of(pid: u32) -> Option<u64> {
        unsafe {
            let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
            let out = created_at_of_handle(h);
            let _ = CloseHandle(h);
            out
        }
    }

    /// The EnumWindows callback has nowhere to put its answer but a static, so
    /// only ONE enumeration may be in flight at a time — the supervisor thread
    /// and an HTTP worker answering `swx_status` can both ask at once, and two
    /// overlapping searches would hand one of them the OTHER process's window.
    /// An hwnd attributed to the wrong process is then fed to IsHungAppWindow,
    /// which is a wrong answer to the one question that must not be wrong.
    static ENUM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
    /// (pid asked about, hwnds found) for the window census. Serialised by
    /// `ENUM_LOCK` for the same reason `FOUND_HWND` is.
    static CENSUS: std::sync::Mutex<(u32, Vec<i64>)> = std::sync::Mutex::new((0, Vec::new()));
    static FOUND_HWND: std::sync::Mutex<(u32, i64)> = std::sync::Mutex::new((0, 0));

    unsafe extern "system" fn census_proc(hwnd: HWND, _l: LPARAM) -> BOOL {
        let mut guard = match CENSUS.lock() {
            Ok(g) => g,
            Err(e) => e.into_inner(),
        };
        let mut pid = 0u32;
        GetWindowThreadProcessId(hwnd, Some(&mut pid));
        if pid == guard.0 {
            guard.1.push(hwnd.0 as i64);
        }
        BOOL(1)
    }

    unsafe extern "system" fn enum_proc(hwnd: HWND, _l: LPARAM) -> BOOL {
        let mut guard = match FOUND_HWND.lock() {
            Ok(g) => g,
            Err(e) => e.into_inner(),
        };
        if guard.1 != 0 {
            return BOOL(1);
        }
        let mut pid = 0u32;
        GetWindowThreadProcessId(hwnd, Some(&mut pid));
        if pid == guard.0 && IsWindowVisible(hwnd).as_bool() {
            // Only a real top-level application window — a tool window with no
            // WS_VISIBLE style is not what IsHungAppWindow should be asked about.
            if GetWindowLongW(hwnd, GWL_STYLE) as u32 & WS_VISIBLE.0 != 0 {
                guard.1 = hwnd.0 as i64;
            }
        }
        BOOL(1)
    }

    impl LaunchOs for WindowsOs {
        fn processes(&self) -> Result<Vec<ProcInfo>, String> {
            unsafe {
                let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
                    .map_err(|e| format!("CreateToolhelp32Snapshot failed: {e}"))?;
                let mut entry = PROCESSENTRY32W {
                    dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
                    ..Default::default()
                };
                let mut out = Vec::new();
                if Process32FirstW(snap, &mut entry).is_ok() {
                    loop {
                        let len =
                            entry.szExeFile.iter().position(|c| *c == 0).unwrap_or(entry.szExeFile.len());
                        let image = String::from_utf16_lossy(&entry.szExeFile[..len]);
                        if WATCHED_IMAGES.iter().any(|w| w.eq_ignore_ascii_case(&image)) {
                            let pid = entry.th32ProcessID;
                            out.push(ProcInfo {
                                pid,
                                image,
                                created_at: created_at_of(pid),
                                ppid: Some(entry.th32ParentProcessID),
                            });
                        }
                        if Process32NextW(snap, &mut entry).is_err() {
                            break;
                        }
                    }
                }
                let _ = CloseHandle(snap);
                Ok(out)
            }
        }

        fn system_memory(&self) -> Result<SystemMemory, String> {
            use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
            const MB: u64 = 1024 * 1024;
            let mut m = MEMORYSTATUSEX { dwLength: std::mem::size_of::<MEMORYSTATUSEX>() as u32, ..Default::default() };
            unsafe { GlobalMemoryStatusEx(&mut m) }.map_err(|e| format!("GlobalMemoryStatusEx failed: {e}"))?;
            Ok(SystemMemory {
                commit_limit_mb: m.ullTotalPageFile / MB,
                commit_avail_mb: m.ullAvailPageFile / MB,
                phys_total_mb: m.ullTotalPhys / MB,
                phys_avail_mb: m.ullAvailPhys / MB,
            })
        }

        /// Every process this account can open (PROCESS_QUERY_LIMITED_INFORMATION),
        /// with its PrivateUsage. A protected process that cannot be opened is
        /// simply absent — this is a report of the largest holders, not a census
        /// anything acts on.
        fn private_by_process(&self) -> Result<Vec<ProcPrivate>, String> {
            const MB: u64 = 1024 * 1024;
            unsafe {
                let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
                    .map_err(|e| format!("CreateToolhelp32Snapshot failed: {e}"))?;
                let mut entry = PROCESSENTRY32W {
                    dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
                    ..Default::default()
                };
                let mut out = Vec::new();
                if Process32FirstW(snap, &mut entry).is_ok() {
                    loop {
                        let pid = entry.th32ProcessID;
                        if pid != 0 {
                            let len = entry.szExeFile.iter().position(|c| *c == 0).unwrap_or(entry.szExeFile.len());
                            let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
                            if let Ok(h) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) {
                                let mut c = PROCESS_MEMORY_COUNTERS_EX::default();
                                if K32GetProcessMemoryInfo(
                                    h,
                                    &mut c as *mut _ as *mut PROCESS_MEMORY_COUNTERS,
                                    std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
                                )
                                .as_bool()
                                {
                                    out.push(ProcPrivate { name, pid, private_mb: c.PrivateUsage as u64 / MB });
                                }
                                let _ = CloseHandle(h);
                            }
                        }
                        if Process32NextW(snap, &mut entry).is_err() {
                            break;
                        }
                    }
                }
                let _ = CloseHandle(snap);
                Ok(out)
            }
        }

        fn launcher_logs(&self) -> Result<Vec<LogFile>, String> {
            logs_in("SWXDesktopLauncher")
        }

        fn helper_logs(&self) -> Result<Vec<LogFile>, String> {
            logs_in("SWXDesktopHelper")
        }

        fn read_log(&self, path: &str) -> Result<String, String> {
            // The launcher holds its log open; a plain read still works, but a
            // huge `_dslcc` sibling could be megabytes, so cap what we take.
            let bytes = std::fs::read(path).map_err(|e| format!("cannot read {path}: {e}"))?;
            const CAP: usize = 256 * 1024;
            let slice = if bytes.len() > CAP { &bytes[..CAP] } else { &bytes[..] };
            Ok(String::from_utf8_lossy(slice).to_string())
        }

        /// Terminate ONE pid — image AND creation time re-checked ON THE OPEN
        /// HANDLE, immediately before the kill.
        ///
        /// A pid is reusable, so a census taken even milliseconds earlier is a
        /// statement about a process that may already be gone. The handle is
        /// the only thing that pins the identity: open first, verify through
        /// the handle, then terminate through that same handle. `PROCESS_TERMINATE`
        /// alone cannot answer GetProcessTimes, so the handle is opened with
        /// QUERY_LIMITED_INFORMATION as well.
        fn terminate(&self, pid: u32, image: &str, created_at: Option<u64>) -> Result<(), String> {
            let census = self.processes()?;
            let Some(p) = census.iter().find(|p| p.pid == pid) else {
                return Err(format!("pid {pid} is no longer running"));
            };
            if !p.is(image) {
                return Err(format!("pid {pid} is {} now, not {image} — refusing to terminate it", p.image));
            }
            let Some(want) = created_at else {
                return Err(format!(
                    "pid {pid} has no recorded creation time, so it cannot be proven to be the \
                     process this bridge started — refusing to terminate it"
                ));
            };
            unsafe {
                let h: HANDLE =
                    OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
                        .map_err(|e| format!("OpenProcess({pid}) failed: {e}"))?;
                let live = created_at_of_handle(h);
                if live != Some(want) {
                    let _ = CloseHandle(h);
                    return Err(format!(
                        "pid {pid} started at {live:?}, not {want}: between the census and the \
                         kill this number was reused — refusing to terminate it"
                    ));
                }
                let r = TerminateProcess(h, 1);
                let _ = CloseHandle(h);
                r.map_err(|e| format!("TerminateProcess({pid}) failed: {e}"))
            }
        }

        /// Liveness WITHOUT a full census — used to ask whether the bridge that
        /// recorded a launch attempt is still around. A pid we cannot open at
        /// all is reported as unknown, not as dead.
        fn pid_alive(&self, pid: u32) -> Option<bool> {
            unsafe {
                match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) {
                    Ok(h) => {
                        let alive = created_at_of_handle(h).is_some();
                        let _ = CloseHandle(h);
                        Some(alive)
                    }
                    // ERROR_INVALID_PARAMETER is "no such process"; anything
                    // else (access denied on a service, say) is UNKNOWN.
                    Err(e) if e.code().0 as u32 == 0x8007_0057 => Some(false),
                    Err(_) => None,
                }
            }
        }

        /// The SAME clock the process creation times come from, converted the
        /// SAME way — so "created at or after our invoke" is a comparison
        /// between two readings of one clock, and needs no slack to absorb a
        /// conversion difference.
        fn now(&self) -> u64 {
            use windows::Win32::System::SystemInformation::GetSystemTimeAsFileTime;
            filetime_to_epoch(unsafe { GetSystemTimeAsFileTime() }).unwrap_or_else(epoch_secs)
        }

        fn window_responding(&self, hwnd: i64) -> Option<bool> {
            if hwnd == 0 {
                return None;
            }
            unsafe { Some(!IsHungAppWindow(HWND(hwnd as *mut std::ffi::c_void)).as_bool()) }
        }

        /// Walk the thread table for the owner of one thread id. The launcher
        /// stamps that id on every line it writes, so this is what turns "a log
        /// appeared" into "OUR launcher wrote this log".
        fn thread_owner(&self, tid: u32) -> Option<u32> {
            use windows::Win32::System::Diagnostics::ToolHelp::{
                Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
            };
            unsafe {
                let snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).ok()?;
                let mut entry = THREADENTRY32 {
                    dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
                    ..Default::default()
                };
                let mut owner = None;
                if Thread32First(snap, &mut entry).is_ok() {
                    loop {
                        if entry.th32ThreadID == tid {
                            owner = Some(entry.th32OwnerProcessID);
                            break;
                        }
                        if Thread32Next(snap, &mut entry).is_err() {
                            break;
                        }
                    }
                }
                let _ = CloseHandle(snap);
                owner
            }
        }

        /// Every top-level window owned by `pid`, with its class — the quit
        /// ladder's P3 instrument, and readable with no ab and no COM.
        /// READ-ONLY, and it presses nothing. `EnumChildWindows` walks the
        /// dialog's controls; `GetClassNameW` says what each one is and
        /// `GetWindowTextW` what it says. Static text becomes `text`, buttons
        /// become `buttons`.
        ///
        /// Measured 2026-09-17 on the live licence dialog: one static line
        /// ("Failed to check 1 license(s): 3DEXPERIENCE SOLIDWORKS Professional:
        /// Unable to connect to server. The session will end after the next 5
        /// unsuccessful attempts.") and one button, OK. The sweep and the quit
        /// are both blocked while it is up, and it appeared when the 3DEXPERIENCE
        /// licence heartbeat failed — it coincided with the desktop dropping off
        /// the relay.
        fn dialog_contents(&self, hwnd: i64) -> Result<DialogContents, String> {
            use windows::core::BOOL;
            use windows::Win32::UI::WindowsAndMessaging::{
                EnumChildWindows, GetClassNameW, GetWindowTextW,
            };
            static CHILDREN: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());
            unsafe extern "system" fn child_proc(h: HWND, _: LPARAM) -> BOOL {
                if let Ok(mut g) = CHILDREN.lock() {
                    g.push(h.0 as i64);
                }
                BOOL(1)
            }
            let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            CHILDREN.lock().unwrap_or_else(|e| e.into_inner()).clear();
            let parent = HWND(hwnd as *mut std::ffi::c_void);
            unsafe {
                let _ = EnumChildWindows(Some(parent), Some(child_proc), LPARAM(0));
            }
            let kids = CHILDREN.lock().unwrap_or_else(|e| e.into_inner()).clone();
            if kids.is_empty() {
                return Err("the dialog reported no child controls".into());
            }
            let mut out = DialogContents::default();
            for k in kids {
                let h = HWND(k as *mut std::ffi::c_void);
                let mut class = [0u16; 256];
                let mut text = [0u16; 1024];
                let (cl, tl) =
                    unsafe { (GetClassNameW(h, &mut class), GetWindowTextW(h, &mut text)) };
                let class = String::from_utf16_lossy(&class[..cl.max(0) as usize]);
                let body = String::from_utf16_lossy(&text[..tl.max(0) as usize]).trim().to_string();
                out.controls.push((
                    class.clone(),
                    unsafe { windows::Win32::UI::WindowsAndMessaging::IsWindowVisible(h) }.as_bool(),
                ));
                // A FULL BUFFER MAY BE A CUT MESSAGE. The reads are 1024 wide and
                // the part that gets cut is where a countdown or a file name lives.
                if tl as usize >= text.len().saturating_sub(1) {
                    out.truncated = true;
                }
                // STYLE, not class: a Win32 checkbox IS class "Button".
                let style = unsafe {
                    windows::Win32::UI::WindowsAndMessaging::GetWindowLongPtrW(
                        h,
                        windows::Win32::UI::WindowsAndMessaging::GWL_STYLE,
                    )
                } as u32;
                match classify_control(&class, style, &body) {
                    Some("button") => out.buttons.push(body),
                    Some("checkbox") | Some("radio") => out.checkboxes.push(body),
                    Some("text") => out.text.push(body),
                    _ => {}
                }
            }
            Ok(out)
        }

        fn foreground_window(&self) -> Result<Option<ForegroundWindow>, String> {
            use windows::Win32::UI::WindowsAndMessaging::{
                GetClassNameW, GetForegroundWindow, GetWindowTextW, GetWindowThreadProcessId,
            };
            unsafe {
                let h = GetForegroundWindow();
                if h.0.is_null() {
                    return Ok(None);
                }
                let mut pid = 0u32;
                if GetWindowThreadProcessId(h, Some(&mut pid)) == 0 || pid == 0 {
                    return Err("GetWindowThreadProcessId failed on the foreground window".into());
                }
                let mut class = [0u16; 256];
                let mut title = [0u16; 512];
                let cl = GetClassNameW(h, &mut class);
                let tl = GetWindowTextW(h, &mut title);
                Ok(Some(ForegroundWindow {
                    hwnd: h.0 as i64,
                    pid,
                    title: String::from_utf16_lossy(&title[..tl.max(0) as usize]),
                    class_name: String::from_utf16_lossy(&class[..cl.max(0) as usize]),
                }))
            }
        }

        fn seconds_since_last_input(&self) -> Result<u64, String> {
            use windows::Win32::System::SystemInformation::GetTickCount;
            use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
            unsafe {
                let mut info = LASTINPUTINFO { cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32, dwTime: 0 };
                if !GetLastInputInfo(&mut info).as_bool() {
                    return Err("GetLastInputInfo failed".into());
                }
                Ok((GetTickCount().wrapping_sub(info.dwTime) / 1000) as u64)
            }
        }

        /// The file-name combo (control 1148, `cmb13`) or the old-style edit
        /// (1152, `edt1`), and the Cancel button (IDCANCEL, 2), found among ALL
        /// descendants; the name read with WM_GETTEXT, which Windows marshals
        /// across processes (GetWindowText would not read another process's edit).
        fn file_dialog(&self, hwnd: i64) -> Result<FileDialogState, String> {
            use windows::core::BOOL;
            use windows::Win32::Foundation::{LPARAM, WPARAM};
            use windows::Win32::UI::WindowsAndMessaging::{
                EnumChildWindows, GetClassNameW, GetDlgCtrlID, GetParent, SendMessageTimeoutW, SMTO_ABORTIFHUNG, WM_GETTEXT,
                WM_GETTEXTLENGTH,
            };
            static KIDS: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());
            unsafe extern "system" fn kid(h: HWND, _: LPARAM) -> BOOL {
                if let Ok(mut g) = KIDS.lock() {
                    g.push(h.0 as i64);
                }
                BOOL(1)
            }
            let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            KIDS.lock().unwrap_or_else(|e| e.into_inner()).clear();
            unsafe {
                let _ = EnumChildWindows(Some(HWND(hwnd as *mut std::ffi::c_void)), Some(kid), LPARAM(0));
            }
            let kids = KIDS.lock().unwrap_or_else(|e| e.into_inner()).clone();
            if kids.is_empty() {
                return Err("the dialog reported no child controls".into());
            }
            let class_of = |h: i64| -> String {
                let mut c = [0u16; 128];
                let n = unsafe { GetClassNameW(HWND(h as *mut std::ffi::c_void), &mut c) };
                String::from_utf16_lossy(&c[..n.max(0) as usize])
            };
            let id_of = |h: i64| unsafe { GetDlgCtrlID(HWND(h as *mut std::ffi::c_void)) };
            let combo = kids.iter().copied().find(|h| id_of(*h) == 1148 && class_of(*h).starts_with("ComboBox"));
            let old_edit = kids.iter().copied().find(|h| id_of(*h) == 1152 && class_of(*h) == "Edit");
            let cancel = kids.iter().any(|h| id_of(*h) == 2 && class_of(*h) == "Button");
            let edit = old_edit.or_else(|| {
                let c = combo?;
                kids.iter().copied().find(|h| {
                    class_of(*h) == "Edit" && {
                        let mut p = unsafe { GetParent(HWND(*h as *mut std::ffi::c_void)) }.ok();
                        let mut found = false;
                        for _ in 0..4 {
                            match p {
                                Some(x) if x.0 as i64 == c => {
                                    found = true;
                                    break;
                                }
                                Some(x) => p = unsafe { GetParent(x) }.ok(),
                                None => break,
                            }
                        }
                        found
                    }
                })
            });
            let standard = (combo.is_some() || old_edit.is_some()) && cancel;
            let file_name = edit.and_then(|e| unsafe {
                let h = HWND(e as *mut std::ffi::c_void);
                let mut len: usize = 0;
                let r = SendMessageTimeoutW(h, WM_GETTEXTLENGTH, WPARAM(0), LPARAM(0), SMTO_ABORTIFHUNG, 500, Some(&mut len));
                if r.0 == 0 {
                    return None;
                }
                let mut buf = vec![0u16; len + 1];
                let mut got: usize = 0;
                let r = SendMessageTimeoutW(h, WM_GETTEXT, WPARAM(buf.len()), LPARAM(buf.as_mut_ptr() as isize), SMTO_ABORTIFHUNG, 500, Some(&mut got));
                (r.0 != 0).then(|| String::from_utf16_lossy(&buf[..got.min(len)]))
            });
            Ok(FileDialogState { standard, file_name })
        }

        fn cancel_dialog(&self, hwnd: i64) -> Result<(), String> {
            use windows::Win32::Foundation::{LPARAM, WPARAM};
            use windows::Win32::UI::WindowsAndMessaging::{IsWindow, PostMessageW, WM_COMMAND};
            let h = HWND(hwnd as *mut std::ffi::c_void);
            // IDCANCEL (2), BN_CLICKED (0) in the high word: what Esc does.
            unsafe { PostMessageW(Some(h), WM_COMMAND, WPARAM(2), LPARAM(0)) }.map_err(|e| format!("PostMessage failed: {e}"))?;
            for _ in 0..30 {
                std::thread::sleep(std::time::Duration::from_millis(100));
                if !unsafe { IsWindow(Some(h)) }.as_bool() {
                    return Ok(());
                }
            }
            Err("the dialog was still up 3 s after IDCANCEL".into())
        }

        fn owner_enabled_of(&self, hwnd: i64) -> Option<bool> {
            use windows::Win32::UI::WindowsAndMessaging::{GetWindow, GetWindowLongPtrW, GWL_STYLE, GW_OWNER};
            unsafe {
                let w = HWND(hwnd as *mut std::ffi::c_void);
                let owner = GetWindow(w, GW_OWNER).ok().filter(|o| !o.0.is_null())?;
                let style = GetWindowLongPtrW(owner, GWL_STYLE) as u32;
                // A zero style is a failed read, not an enabled window.
                (style != 0).then_some(style & 0x0800_0000 == 0)
            }
        }

        fn windows_of_pid(&self, pid: u32) -> Result<Vec<WindowInfo>, String> {
            use windows::Win32::UI::WindowsAndMessaging::{GetClassNameW, GetWindowTextW};
            let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            *CENSUS.lock().unwrap_or_else(|e| e.into_inner()) = (pid, Vec::new());
            unsafe {
                let _ = EnumWindows(Some(census_proc), LPARAM(0));
            }
            let hwnds = CENSUS.lock().unwrap_or_else(|e| e.into_inner()).1.clone();
            let mut out = Vec::new();
            for h in hwnds {
                let hwnd = HWND(h as *mut std::ffi::c_void);
                let mut class = [0u16; 256];
                let mut title = [0u16; 512];
                let (cl, tl) = unsafe {
                    (GetClassNameW(hwnd, &mut class), GetWindowTextW(hwnd, &mut title))
                };
                // THE EVIDENCE (0.9.4): owner, whether the owner is enabled (a real
                // modal disables it — WS_DISABLED in its style), styles, rect.
                use windows::Win32::UI::WindowsAndMessaging::{
                    GetWindow, GetWindowLongPtrW, GetWindowRect, GWL_EXSTYLE, GWL_STYLE, GW_OWNER,
                };
                let (owner, owner_enabled, style, exstyle, rect) = unsafe {
                    let owner = GetWindow(hwnd, GW_OWNER).ok().filter(|o| !o.0.is_null());
                    let owner_style = owner.map(|o| GetWindowLongPtrW(o, GWL_STYLE) as u32);
                    let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32;
                    let exstyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32;
                    let mut r = windows::Win32::Foundation::RECT::default();
                    let rect = GetWindowRect(hwnd, &mut r).is_ok().then(|| [r.left, r.top, r.right, r.bottom]);
                    (
                        owner.map(|o| o.0 as i64),
                        // A zero style is a failed read, not an enabled window.
                        owner_style.filter(|s| *s != 0).map(|s| s & 0x0800_0000 == 0),
                        (style != 0).then_some(style),
                        Some(exstyle),
                        rect,
                    )
                };
                out.push(WindowInfo {
                    hwnd: h,
                    class_name: String::from_utf16_lossy(&class[..cl.max(0) as usize]),
                    title: String::from_utf16_lossy(&title[..tl.max(0) as usize]),
                    visible: unsafe { IsWindowVisible(hwnd) }.as_bool(),
                    owner,
                    owner_enabled,
                    style,
                    exstyle,
                    rect,
                });
            }
            Ok(out)
        }

        fn main_window_of_pid(&self, pid: u32) -> Option<i64> {
            let _serialised = ENUM_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let mut guard = FOUND_HWND.lock().unwrap_or_else(|e| e.into_inner());
            *guard = (pid, 0);
            drop(guard);
            unsafe {
                let _ = EnumWindows(Some(enum_proc), LPARAM(0));
            }
            let found = FOUND_HWND.lock().unwrap_or_else(|e| e.into_inner()).1;
            (found != 0).then_some(found)
        }

        fn process_metrics(&self, pid: u32) -> ProcMetrics {
            unsafe {
                let Ok(h) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
                    return ProcMetrics::default();
                };
                let mut counters = PROCESS_MEMORY_COUNTERS_EX::default();
                let got = K32GetProcessMemoryInfo(
                    h,
                    &mut counters as *mut _ as *mut PROCESS_MEMORY_COUNTERS,
                    std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
                )
                .as_bool();
                let mut handles = 0u32;
                let got_handles = GetProcessHandleCount(h, &mut handles).is_ok();
                // Kernel + user time, in 100ns ticks. The absolute value is
                // meaningless; the DELTA across two reads is the busy-vs-dead
                // signal.
                let mut cpu = None;
                {
                    let (mut c, mut e, mut k, mut u) = (
                        FILETIME::default(),
                        FILETIME::default(),
                        FILETIME::default(),
                        FILETIME::default(),
                    );
                    if GetProcessTimes(h, &mut c, &mut e, &mut k, &mut u).is_ok() {
                        let ticks = |f: FILETIME| {
                            ((f.dwHighDateTime as u64) << 32) | f.dwLowDateTime as u64
                        };
                        cpu = Some((ticks(k) + ticks(u)) as f64 / 10_000_000.0);
                    }
                }
                let _ = CloseHandle(h);
                ProcMetrics {
                    private_mb: got.then(|| (counters.PrivateUsage as u64) / (1024 * 1024)),
                    working_set_mb: got.then(|| (counters.WorkingSetSize as u64) / (1024 * 1024)),
                    cpu_seconds: cpu,
                    handles: got_handles.then_some(handles),
                }
            }
        }

        fn kind(&self) -> &'static str {
            "windows"
        }
    }

    /// Run a `.lnk` through `ShellExecuteExW` with an EXPLICIT NORMAL show
    /// state — and BRING BACK THE IDENTITY OF WHAT IT STARTED.
    ///
    /// `SEE_MASK_NOCLOSEPROCESS` makes the shell hand us `hProcess` for the
    /// process it created. `GetProcessId` + `GetProcessTimes` on that handle
    /// give a (pid, creation time) pair that came from the invoke ITSELF, not
    /// from a census we searched afterwards hoping to recognise our own work.
    /// That is the only attribution this bridge accepts as authority to
    /// terminate anything — see `launch::AttributionRule`.
    ///
    /// Called from the com host, whose thread is STA-initialised: the shell's
    /// link resolver needs an apartment, and the HTTP workers have none.
    pub fn shell_execute_lnk(path: &str) -> Result<Option<(u32, Option<u64>)>, String> {
        use windows::Win32::System::Threading::GetProcessId;
        use windows::Win32::UI::Shell::{
            ShellExecuteExW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS,
            SHELLEXECUTEINFOW,
        };
        use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
        let wide: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
        let mut info = SHELLEXECUTEINFOW {
            cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
            fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC | SEE_MASK_NOCLOSEPROCESS,
            lpFile: PCWSTR(wide.as_ptr()),
            nShow: SW_SHOWNORMAL.0,
            ..Default::default()
        };
        unsafe { ShellExecuteExW(&mut info) }.map_err(|e| format!("ShellExecuteExW failed: {e}"))?;
        if info.hProcess.is_invalid() {
            // The shell handed the link to an ALREADY-RUNNING process (DDE) or
            // declined to say. No handle, no attribution — and therefore, later,
            // no kill.
            return Ok(None);
        }
        let pid = unsafe { GetProcessId(info.hProcess) };
        let created = unsafe { created_at_of_handle(info.hProcess) };
        let _ = unsafe { CloseHandle(info.hProcess) };
        Ok((pid != 0).then_some((pid, created)))
    }

    /// `Start-Process -PassThru` and READ THE PID IT PRINTS.
    ///
    /// Waits on the PowerShell child (which returns as soon as it has started
    /// the target, not when the target exits), so a non-zero exit or anything
    /// on stderr is reported as "this route did not start anything" rather than
    /// being mistaken for a stall in a launcher that never ran.
    pub fn powershell_start_lnk(path: &str) -> Result<Option<(u32, Option<u64>)>, String> {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        let quoted = path.replace('\'', "''");
        let out = std::process::Command::new("powershell.exe")
            .args([
                "-NoProfile",
                "-NonInteractive",
                "-Command",
                &format!("(Start-Process -FilePath '{quoted}' -PassThru).Id"),
            ])
            .creation_flags(CREATE_NO_WINDOW)
            .output()
            .map_err(|e| format!("could not run powershell: {e}"))?;
        if !out.status.success() {
            return Err(format!(
                "powershell exited {} — the route started nothing. stderr: {}",
                out.status.code().unwrap_or(-1),
                String::from_utf8_lossy(&out.stderr).trim()
            ));
        }
        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
        if !stderr.is_empty() {
            return Err(format!("powershell wrote to stderr, so the route is not trusted to have started anything: {stderr}"));
        }
        let pid: u32 = match String::from_utf8_lossy(&out.stdout).trim().parse() {
            Ok(p) => p,
            Err(_) => return Ok(None), // it ran, but would not say what it started
        };
        Ok(Some((pid, created_at_of(pid))))
    }
}

#[cfg(windows)]
pub use win::{powershell_start_lnk, shell_execute_lnk};

#[cfg(test)]
mod tests {
    use super::*;

    fn fake(tag: &str) -> (std::path::PathBuf, FakeOs) {
        let dir = std::env::temp_dir().join(format!("swx-fakeos-{}-{tag}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        (dir.clone(), FakeOs { dir })
    }

    #[test]
    fn a_failed_census_is_an_error_never_an_empty_list() {
        let (dir, os) = fake("census");
        std::fs::write(dir.join("census-fails"), "").unwrap();
        assert!(os.processes().is_err(), "'I could not look' must not read as 'nothing is running'");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn the_simulator_models_the_stall_and_the_working_route() {
        let (dir, os) = fake("stall");
        std::fs::write(
            dir.join("routes.json"),
            r#"{"cmdStart":{"chain":true},
                "explorer":{"chain":true,"runBeginAfterMs":0,"sldworksAfterMs":0,"attachAfterMs":0}}"#,
        )
        .unwrap();
        FakeOs::record_invoke(&dir, "cmdStart", "x.lnk");
        let procs = os.processes().unwrap();
        assert_eq!(procs.len(), 2, "the stalled route still starts a chain: {procs:?}");
        let log = os.read_log(&os.launcher_logs().unwrap()[0].path).unwrap();
        assert!(log.contains("SecurityContext:"));
        assert!(!log.contains("Run Begin"), "the stall is the ABSENCE of Run Begin");
        assert!(!FakeOs::attachable(&dir));

        FakeOs::record_invoke(&dir, "explorer", "x.lnk");
        let log2 = os.read_log(&os.launcher_logs().unwrap()[0].path).unwrap();
        assert!(log2.contains("Run Begin"), "the working route reaches Run Begin");
        assert!(FakeOs::attachable(&dir));
        assert!(os.processes().unwrap().iter().any(|p| p.is("SLDWORKS.exe")));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn terminate_refuses_a_pid_that_is_not_the_image_it_was_asked_for() {
        let (dir, os) = fake("terminate");
        std::fs::write(dir.join("routes.json"), r#"{"cmdStart":{"chain":true}}"#).unwrap();
        let seq = FakeOs::record_invoke(&dir, "cmdStart", "x.lnk");
        let inv = os.invocations().into_iter().find(|i| i.seq == seq).unwrap();
        let live = os.processes().unwrap();
        let cat = live.iter().find(|p| p.pid == inv.catstart_pid()).unwrap().clone();
        // The wrong IMAGE for that pid is refused...
        assert!(os.terminate(cat.pid, "SWXDesktopLauncher.exe", cat.created_at).is_err());
        // ...and so is the right image with the WRONG CREATION TIME, which is
        // what a reused pid looks like.
        assert!(
            os.terminate(cat.pid, "CATSTART.exe", Some(1)).is_err(),
            "a pid whose creation time does not match is a different process now"
        );
        assert!(os.terminate(cat.pid, "CATSTART.exe", cat.created_at).is_ok());
        assert!(
            !os.processes().unwrap().iter().any(|p| p.pid == inv.catstart_pid()),
            "a terminated process leaves the census"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}