app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
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);
}
}