app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.4 (d43e9f0): a notification is not a modal — windows judged by measured modality, evidence in every dialog report.
20 files changed
+540−40
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+16−1bridge.json+1−1docs/ECOSYSTEM-TABLE.md+2−1docs/LAUNCH-RESILIENCE.md+41docs/QUIT-INVESTIGATION.md+17package.json+1−1page.json+1−7references/closing-and-recovery.md+9src/launchos.rs+85−4src/quit.rs+187−6src/readiness.rs+5−1src/verbs.rs+13−11src/verbs3.rs+18−4tests/quit.rs+81tests/readiness.rs+47tests/wiring.rs+1tools/quit-gate.py+12BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.3+0.9.4
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.3"+version = "0.9.4" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.3"+version = "0.9.4" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+16−1@@ -458,7 +458,22 @@ not. ## Roadmap -- v0.9.3 (this): **attached is not ready — and a declined quit is about the+- v0.9.4 (this): **a notification is not a modal.** Measured 2026-10-01 on+ hosted 0.9.3 (where an ab update was also measured to leave the data root+ byte-for-byte untouched, and readiness worked — with `StartupProcessCompleted`+ TRUE before the log marker, so both signals stay required): the 3DEXPERIENCE+ "Connected to …" notice — a title-less `#32770` tool window whose owner frame+ stayed enabled for 95 s and which had no Button-class control — blocked every+ quit, sweep and import for minutes. The window census now records evidence for+ every candidate (owner, ownerEnabled, style, exstyle, rect, child control+ classes and visibility) and returns it in every dialog report; a window blocks+ when its kind is known-blocking, its owner frame is disabled, or it offers a+ Button-class control, and is a reported, never-answered `notification` only when+ all of that was measured otherwise — anything unread fails closed. Template+ placeholder captions are flagged (`textIsTemplatePlaceholder`) and not presented+ as the window's words; the quit gate prints full evidence when blocked.+ `neverAnswer` is unchanged. 452 tests.+- v0.9.3: **attached is not ready — and a declined quit is about the session's history.** Both measured live on 2026-10-01. Document commands sent 8 s after COM attach CRASHED SOLIDWORKS while its
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.3",+ "version": "0.9.4", "description": "Bridge for SOLIDWORKS desktop CAD \u2014 create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.", "author": "Adom Inc.", "license": "MIT",
docs/ECOSYSTEM-TABLE.md+2−1@@ -112,13 +112,14 @@ templates**, alisamsam **22**. | 44 | **Graceful QUIT that is measured, not assumed** (never a kill, never answers a dialog, proves the exit from the process census) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (rung a)** — `swx_stop {userAuthorized, maxRung:"a"}` quit a bridge-launched session on the FIRST rung (UserControl=false; ExitApp; every COM reference released), verified absent from the process census; `swx_shutdown` then removed a stray empty instance (pid 145188) and `tasklist` showed no `sldworks.exe` at all. **`ExitApp` is NOT declined at zero documents on this edition** — the long-held `host_app_stop_declined` belief was a SAVE-PROMPT STALL misread as a refusal, which is why the ladder was built pinned at rung (a) and why the preserve-then-close sweep is what actually unblocked it. A rung ladder (a `ExitApp` → f Restart Manager *without* `RmForceShutdown`), pinned at rung (a); 7 re-measured preconditions where UNKNOWN refuses and `force` waives the UNSAVED question ONLY; every COM reference released before and after each rung, with outstanding refs named in the report; success = *this pid AND this creation time* absent from the census, never `GetActiveObject`; per-session children reaped while the documented platform daemons are left alone; a dialog stops the ladder permanently and is never answered; nothing disowned until the exit is verified. every precondition re-measured before EVERY rung (a mid-ladder refusal is a real outcome), the exit POLLED to the rung's budget rather than sampled once (a real `ExitApp` returns before the process goes), the dialog census covering the `swCefSubProc` web-view children by parentage, the count cross-checked against the enumeration, and the message rungs re-verifying window ownership before they send. `tools/quit-gate.py` proves the session was bridge-launched, and **its FULL CYCLE PASSED LIVE 2026-09-30 through the bridge ab hosts**: rung (a), process gone 29.5 s, per-session servers reaped, nothing on screen, no crash reporter, every rung reported, and SOLIDWORKS relaunched by the bridge 54.7 s later. The gate now reaches the bridge through ab's verb routing (the side-loaded dev copy on 24695 is terminated by ab 2.1.163 within 30 s) and passes `userAuthorized:true` — the standing authorization — rather than the write-gate pair, which the hosted bridge rightly never enables. **MEASURED 2026-10-01: the decline is about session HISTORY** — rung (a) was declined three times on a session that had never held a document and quit in 3.4 s once one had been created and closed; since 0.9.3 the ladder PRIMES such a session (empty, no dialog, user-authorized only) and asks again, and the gate's "no longer running" claim — which had passed vacuously after declines — now requires `running:false` and is self-tested | ✗ | ✗ | ✗ | ✗ | ✗ | | 48 | **A title collision between SAVED documents is impossible here — measured, not assumed** | ✗ | **✓ MEASURED LIVE 2026-09-17** — `tools/drain-gate.py --fixture new` copied a bridge-created document's own file to a second directory under the same basename and called `swx_open` on it. SOLIDWORKS REFUSED: `errors = 65536` (`swFileWithSameTitleAlreadyOpen`). So the drain's title-collision guard — which refuses to close any document whose title another open document shares, and names both paths — can only ever fire for PATHLESS documents, which is exactly the state identity-at-birth removes. The guard stays (a document the bridge did not create can still be pathless, and an older bridge's leftovers are), and the measurement is recorded next to it in `com.rs` so nobody "simplifies" it on the assumption that saved collisions are the common case. No MCP server measures this at all | ✗ | ✗ | ✗ | ✗ | ✗ |+| 51 | **Dialogs judged by MEASURED MODALITY, not by existence** (a notification never blocks; a modal always does) | ✗ | **✓ 0.9.4, from a blocker MEASURED 2026-10-01**: the 3DEXPERIENCE "Connected to …" notice — a title-less `#32770` tool window whose owner frame stayed ENABLED for 95 s, with no Button-class control — blocked every quit, sweep and import for minutes. Now a window blocks when its kind is known-blocking, its owner frame is disabled, or it has a Button-class control; it is a `notification` (reported with owner, ownerEnabled, styles, rect and child controls; never answered) only when all of that was measured; unread evidence fails closed. Template placeholder captions are flagged, never presented as the window's words. No MCP server looks at a dialog at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 50 | **Readiness gating — attached is not ready** (no document command reaches a SOLIDWORKS that has not finished starting) | ✗ | **✓ 0.9.3, from a crash MEASURED 2026-10-01**: `swx_new_document` 8 s after COM attach, `swx_open` at 9 s and `swx_close_all_safely` at 12 s crashed SOLIDWORKS while its 3DEXPERIENCE add-in was still initialising (performance log: LOAD_ADDIN still running, SWEXH/UNHANDLED, the 3DEXPERIENCE marker only after the document commands, SWERR_REPORT); a control launch with no document commands crashed nothing. Now a READY stage follows `attached`, decided by two independent signals — COM `StartupProcessCompleted` and the performance log's 3DEXPERIENCE marker after the last LOAD_ADDIN — with an honest grace and timeout that never promote to ready; fifteen document and quit verbs refuse `host_app_starting` with `retryAfterSeconds` until then; a sweep during which SOLIDWORKS dies says `host_app_gone` with the log's crash markers. The rescue held under the crash (26,129 bytes verified). No MCP server distinguishes attached from ready at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 49 | **The user's rescued work survives the bridge's own updates — and ab's** (recovery never lives in a directory anything else owns) | ✗ | **✓ 0.9.1: the data root is `%LOCALAPPDATA%\adom-solidworks-bridge\`, a directory only this bridge writes to** — moved out of `Adom Bridge\` altogether after adversary pass 39 found 0.9.0's `Adom Bridge\solidworks-data` sitting inside the directory ab's installer and uninstaller own, whose fate on an ab uninstall or reinstall is documented nowhere; both earlier locations are migrated oldest first, every copy recorded by (root, path, size, mtime, sha256) so a legacy file that changes after its copy is copied again BESIDE it and reported `changedSinceCopy`, never settled by name — and since 0.9.2 a small file whose size and mtime match is hashed too, and two versions of the ledger or the ownership registry are MERGED (union, both versions kept beside) rather than one picked; a temp-directory fallback makes every sweep refuse (`data_root_is_temp`), and an unwritable recovery folder is refused once before the first save (`recovery_dir_unwritable`). **0.9.0, from a SEV-1 MEASURED 2026-09-30**: `bridges-cache\solidworks` was found CREATED 2026-09-18 09:42 by ab's auto-install of 0.8.9 — the updater re-extracts the whole directory, and every recovery folder from the day before, the ledger and the ownership registry went with it (test documents only; nothing of Drew's was lost). The data root is now `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`, a sibling of the install cache; the first start MIGRATES what an older bridge left (copied under a temporary name and renamed into place once verified byte for byte — so a start that dies mid-copy never leaves a truncated rescue under the real name — never over an existing file, originals left in place, each file once, a copy that does not verify removed and retried next start); `swx_recover_list` still reads the old location and marks each rescue there `atRiskFromUpdate`, saying whether a verified copy exists — and in the data root calls a file whose copy has not landed `notCopiedYet` (pointing at its only copy), never "gone"; and a sweep REFUSES (`recovery_inside_install_dir`, nothing written, nothing closed) if its recovery folder would land under the install directory or the install cache — however the path is spelled (links, `..`, case). Every rule mutation-verified. No MCP server keeps a recovery folder at all | ✗ | ✗ | ✗ | ✗ | ✗ | | 47 | **Interconnect-aware import: refuses honestly, with the reason and the setting** | ✗ | **✓ MEASURED + shipped 0.8.2** — on SOLIDWORKS Connected 2026 with `swMultiCAD_Enable3DInterconnect` TRUE there is NO programmatic neutral-file import route at all: LoadFile4 returns nothing (`errors=1`) both with `Nothing` and with a real `GetImportFileData` object, LoadFile2 returns false, OpenDoc6 answers `swFileRequiresRepairError` (2097152) across four option values and `swInvalidFileTypeError` (1024) for docType 0. So `swx_import` makes ONE LoadFile4 attempt (a future edition that accepts it simply works) and then fails with `interconnect_blocks_import`, naming the exact setting — Tools > Options > System Options > Import > Enable 3D Interconnect — the measurement date, and the fact that **this bridge does not change user settings, not even temporarily**. No MCP server reads that toggle at all: they report a translator failure, which sends the reader hunting a missing translator that is not the problem | ✗ | ✗ | ✗ | ✗ | ✗ | | 46 | **3D Interconnect is understood, obeyed and reported** (the user's own setting decides how a neutral file comes in; the bridge reads it and never writes it) | ✗ | **✓ LIVE-MEASURED 2026-09-17, routed in 0.8.0** — `GetUserPreferenceToggle(691)` = TRUE on the install, so a STEP is not translated at all: `LoadFile4` executes and returns nothing with `errors=1`, and SOLIDWORKS instead OPENS the file with `OpenDoc6(path, swDocPART, …)` as a part holding a LINKED 3D Interconnect feature. `swx_import` reads the toggle, routes accordingly, and reports `interconnect` / `linked` / `sourcePath` so a caller knows the part is not self-contained. The preference is NEVER written — not even flip-and-restore, because a crash between the halves would leave the user's CAD configured differently than they left it — and `dissolve:true` is REFUSED until the link-breaking call is measured on this edition. Import-diagnostics auto-run (toggle 291) is also TRUE, so the modal dialog it raises is classified, reported as `stoppedByDialog`, and never answered. Toggle ids read from the installed `SolidWorks.Interop.swconst.dll` | ✗ | ✗ | ✗ | ✗ | ✗ | | 45 | **Closes its own documents without asking — and loses nothing doing it** (preserve-then-close: every open document written to a recovery folder and verified there BEFORE anything closes) | ✗ | **✓ LIVE-PROVEN 2026-09-17 (0.7.7)** — `swx_shutdown {reason}` / `swx_close_all_safely {reason}` save every open document into `%LOCALAPPDATA%\adom-solidworks-bridge\recovery\<timestamp>\` (0.9.0: `Adom Bridge\solidworks-data\…`, inside the directory ab's installer owns; before that `…\bridges-cache\solidworks\recovery\…`, inside ab's install cache, which an update empties: row 49), verify it, close parents before children, then quit; no write opt-in, no per-document confirmation (Drew, 2026-09-11: *"I despise every time you ask for help closing"*). The authorization is to CLOSE, not to lose anything, so the consent question is replaced by checkable rules: the sweep REFUSES TO START when the recovery volume cannot hold the session it is about to write (`insufficient_headroom` with free/needed/shortfall, nothing written, nothing closed), re-checked before every save, with `headroomBytesBefore` and `estimatedWriteBytes` always reported — because a recovery file can be the RIGHT document CUT SHORT and no check on the file can tell (the marker is at offset 4, so every prefix carries it); the recovery file is verified STRUCTURALLY — it must be a recognised SOLIDWORKS container AND at least as big as the smallest real document of its KIND (part 24 KB, assembly 80 KB, drawing 256 KB — one floor for all three would have to be the lowest of them, letting an assembly stop at a third of its size), with the KIND carried from the COM read to the verdict and NEVER re-derived from the recovery file's name, because that name is chosen from the kind and so cannot check it; an unreadable document type falls back to the document's OWN PATH and then its title (evidence about the document, not names this sweep chose) and only then to UNKNOWN, which takes a neutral `.sldrecover` name and the strictest floor — and if SOLIDWORKS refuses to write that name the file is re-saved as `.SLDPRT` with the fallback RECORDED and the unknown-kind verification unchanged; `swx_recover_list` re-runs the verdict on every file it lists and reports `verified` with the reason — a file the sweep refused is listed (never deleted: those bytes are the only ones there are) but never called a rescue, never offered as openable, and its entry says the document was left OPEN — and gives every genuine rescue its own `howToOpen`, because SOLIDWORKS will not open a `.sldrecover` file until it is renamed, published as `recoverySignatureOk` + `recoveryBytes`, and it fails CLOSED (`exists()` passes a zero-byte file and any byte floor passes a truncated one; both are what a full disk leaves). The container marker was MEASURED on 166 real documents from three hardware repositories, because the signature every reference names for a SOLIDWORKS document (OLE compound file) appears in NONE of them — verifying that, as pass 23 specified, would have refused to close every document on a modern install; **FIRST AUTONOMOUS CLOSE, live 2026-09-17**: `swx_close_all_safely {userAuthorized, batch:1}` on a bridge-launched session took 4 documents (3 same-titled pathless import temps + a throwaway part) to `recovery\20260917-150646\` under unique names (25,834 / 26,031 / 25,773 / 26,047 bytes, `recoverySignatureOk:true`, `namingFallback:null`), closed each BY PATH, count 4 → 0, `done:true`, `stoppedBy:null`, headroom 1.59 TB — nobody was asked to close anything. A document that could not be written is left OPEN and named in `remaining`; the user's saved-then-edited file is copied to recovery under a new name and their original is asserted byte-for-byte unchanged; `SaveAs3` uses `swSaveAsOptions_Silent` ALONE so the document RE-POINTS at the recovery file and the close key is read from the document after the save; a dialog stops the sweep and is never answered, with the census taken before EVERY save and EVERY close (a save can raise a prompt of its own, which a per-batch census could not see until the closes had happened); and an UNREADABLE window census is its own outcome — saves proceed, nothing closes, `stoppedBy:"dialogCensusUnknown"`. Ordering is proven from a journal, not from the end state. Every rule is mutation-verified (disable it and exactly one test fails). **Ownership measured live 2026-09-30, both halves**: a pathless `Part2` preserved (25,959 bytes, verified, listed from the manifest) and Drew's saved `R_0603_1608Metric_1.step-1.sldprt` closed with its original untouched (same 32,311 bytes, same mtime). Since 0.9.0 such a document is RECORDED as `closedInPlace` with its original's size and mtime, and `swx_recover_list` says "closed at its own path, untouched" — or that it has changed since, or is gone — by measuring the file, not by assuming it | ✗ | ✗ | ✗ | ✗ | ✗ | -*(50 numbered rows; rows 29–31 are the N/A block that accounts for most of the Fusion verb-count gap. Rows 36–42 added 2026-09-08 from the crate build and the launch-resilience rounds; rows 43–45 from the import-leak, quit and safe-close work, and 46–49 from the live runs of 2026-09-17 and 2026-09-30 — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)*+*(51 numbered rows; rows 29–31 are the N/A block that accounts for most of the Fusion verb-count gap. Rows 36–42 added 2026-09-08 from the crate build and the launch-resilience rounds; rows 43–45 from the import-leak, quit and safe-close work, and 46–49 from the live runs of 2026-09-17 and 2026-09-30 — the bar is "significantly more powerful than the MCP servers", so every robustness capability gets a row with its evidence level, not a slogan.)* ## Per-competitor notes
docs/LAUNCH-RESILIENCE.md+41@@ -1080,3 +1080,44 @@ CONTROL launch with no document commands: no `SWEXH`, and the log gained marker appears; whether `LOAD_ADDIN`-then-marker holds on every start (the log format is undocumented — this is a heuristic read of one machine's log). The launch gate now waits for READY before any probe of its own.++## Live proof 4 — 0.9.3 on Drew's machine (2026-10-01)++| | Measured |+|---|---|+| **An ab update leaves the data root alone** | ab updated the hosted bridge 0.9.2 → 0.9.3 on `refresh_bridges`; the listing of `%LOCALAPPDATA%\adom-solidworks-bridge\` was BYTE-FOR-BYTE IDENTICAL before and after. The move of 0.9.0/0.9.1 is now proven on a real update, not just argued. |+| Readiness | a document verb sent early was refused `host_app_starting` and created nothing; the session became READY 3.4 s after attach, when `<3DXINITERROR>` appeared after the last `LOAD_ADDIN`. **`StartupProcessCompleted` was already TRUE before that marker** — COM alone is not sufficient; requiring both signals is right. |+| Ownership sweep | PASSED: `Part1` preserved by ownership (26,171 bytes, verified, closed); Drew's part `closedInPlace`, untouched (32,311 bytes); `lockFiles: []` |+| Drain gate | 5 / 5 |+| **New blocker** | see below — a NOTIFICATION was judged a modal and blocked every quit |++**The notification that blocked every quit.** `swx_stop` → `stop_blocked_by_dialog`+on a window the census called kind `unknown`, title `""`, text `["Title",+"Description","Button1","Button2","Hyperlink Text 1","Hyperlink Text 2"]`,+buttons `[]`. Captured independently: hwnd 331370, owner process sldworks, class+`#32770`, visible, style 0x94000044, exstyle 0x90080 (WS_EX_TOOLWINDOW |+WS_EX_LAYERED | WS_EX_CONTROLPARENT), rect 533,45–834,112 (301x67 px near the top+of the frame), GW_OWNER = the main frame, ALL children class `Static` (visible:+"Title", "Description"; hidden: the Button/Hyperlink statics). It appeared ~18 s+after a document create + drain (not at idle before any document), persisted+over 100 s, and for all 20 reads over 95 s its OWNER FRAME WAS ENABLED — not a+modal. A background screenshot showed a green check and "Connected to+OI000616940 — SOLIDWORKS is connected to your 3DEXPERIENCE platform": the+3DEXPERIENCE connection notice, custom-drawn, so the Static captions read were+TEMPLATE PLACEHOLDERS, not its words. It blocked the quit gate (rungsTried+empty); an earlier stop had succeeded only because it was gone by then. It does+NOT clear by itself: nine `swx_stop` attempts one minute apart (16:55:13–17:03:15+UTC) all returned `stop_blocked_by_dialog` on the same window, which had appeared+at ~16:52:08 — more than eleven minutes on an idle, empty session. So waiting and+re-censusing is not a remedy, and 0.9.4 adds none: the modality judgement is the+fix.++**0.9.4 judges windows by measured modality** (`quit::judge_window`): a window+blocks when its kind is a known blocking kind, its owner frame is DISABLED, or it+has a Button-class control; it is a `notification` — reported with its evidence+(owner, ownerEnabled, styles, rect, child controls), never answered, and NOT a+reason to refuse a quit, a sweep or an import — only when it was measured to be+a tool window with an enabled owner and no Button-class control. Anything unread+fails closed: blocking. A window whose readable captions are all template+placeholders says `textIsTemplatePlaceholder:true` and does not present them as+its words. `neverAnswer` is unchanged.
docs/QUIT-INVESTIGATION.md+17@@ -1922,3 +1922,20 @@ was measured on sessions that had held documents. Startup timing is NOT the caus The coordinator is still investigating whether a session relaunched after a crash holds document-recovery state; this section does not claim history is the only cause of a decline — only that it is one, measured.++### 5.16 A notification is not a modal — measured 2026-10-01++The 3DEXPERIENCE "Connected to …" notice (hwnd 331370: `#32770`, no title,+WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_CONTROLPARENT, owner = the main frame+and ENABLED for all 20 reads over 95 s, all children Static) appeared ~18 s after+a document create and stayed for minutes, and 0.9.3's census — which treated any+visible `#32770` as blocking — refused every quit while it was up+(`stop_blocked_by_dialog`, `rungsTried: []`). The precondition "no dialog" was+right in spirit and wrong in its instrument: the question is whether the window+is MODAL, and that is measurable. Since 0.9.4 (`quit::judge_window`): blocking =+a known blocking kind, OR the owner frame disabled, OR a Button-class control;+`notification` = a tool window, owner enabled, no Button-class control, all+measured; unknown = blocking. Every dialog report carries the evidence, and+`tools/quit-gate.py` prints it in full when a window stops the ladder. The+licence dialog of 2026-09-17 stays blocking BY KIND — its text and buttons were+measured, its owner's enablement was not.
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.3",+ "version": "0.9.4", "type": "app", "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.", "tags": [
page.json+1−7@@ -22,11 +22,5 @@ "label": "Change a dimension", "prompt": "Set the extrusion depth on my SolidWorks part to 25mm" }- ],- "hero": {- "type": "billboard",- "auto": true,- "headline": "solidworks-bridge",- "subhead": "On the Adom Wiki."- }+ ] }
references/closing-and-recovery.md+9@@ -111,6 +111,15 @@ Read this before closing any document, quitting SOLIDWORKS, draining leaked impo `swx_stop` creates and closes one bridge-owned scratch part and asks again; the second attempt in `rungsTried` says `primed:true`. Nothing is ever primed while a document is open.+- **A NOTIFICATION does not stop anything.** A window is judged by measured+ modality: it blocks when it is a known blocking kind, its owner frame is+ disabled, or it offers a button; a tool window over an enabled frame with+ nothing to click is a `notification` (`kind:"notification"`, `blocking:false`)+ — reported under `notifications` with its `evidence`, never answered, and the+ quit, sweep or import goes ahead. The 3DEXPERIENCE "Connected to …" notice is+ one (measured 2026-10-01). `textIsTemplatePlaceholder:true` means the captions+ read were a template's ("Title", "Description", …), not what the window shows —+ do not quote them to the user as its message. - **`swx_shutdown` is idempotent.** SOLIDWORKS not running answers `success:true, alreadyStopped:true`. It is the last thing you do, and the last thing should be safe to repeat.
src/launchos.rs+85−4@@ -239,14 +239,30 @@ pub fn stamp_of(file_name: &str) -> Option<u64> { /// 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)]+#[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!({@@ -254,6 +270,12 @@ impl WindowInfo { "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.@@ -278,6 +300,11 @@ pub struct DialogContents { /// 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.@@ -317,8 +344,14 @@ impl DialogContents { "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)?+ 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.@@ -1092,6 +1125,8 @@ impl LaunchOs for FakeOs { 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),@@ -1101,11 +1136,18 @@ impl LaunchOs for FakeOs { } 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: list("text"),- buttons: list("buttons"),- checkboxes: list("checkboxes"),+ text,+ buttons,+ checkboxes, truncated: d.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),+ controls, }) } @@ -1120,6 +1162,14 @@ impl LaunchOs for FakeOs { 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() {@@ -1156,6 +1206,7 @@ impl LaunchOs for FakeOs { .unwrap_or("Login | 3DEXPERIENCE ID | Dassault Systemes") .to_string(), visible: true,+ ..Default::default() }); } }@@ -1562,6 +1613,10 @@ mod win { 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) {@@ -1600,11 +1655,37 @@ mod win { 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)
src/quit.rs+187−6@@ -215,21 +215,128 @@ pub struct BlockingDialog { /// WHAT IT SAYS AND WHAT IT OFFERS. `None` = not read, or unreadable — /// UNKNOWN, never "it said nothing". pub contents: Option<crate::launchos::DialogContents>,+ /// The window as measured — class, owner, whether the owner is enabled,+ /// styles, rect — so a window nobody recognises can be judged from the reply.+ pub window: Option<WindowInfo>,+ /// Does it block? Decided by `judge_window` from the evidence, with the reason.+ pub blocking: bool,+ pub why: String,+}++/// The captions a custom-drawn window's dialog TEMPLATE carries — measured+/// 2026-10-01 on the 3DEXPERIENCE "Connected to …" notice, whose Static controls+/// read "Title", "Description", "Button1", "Button2", "Hyperlink Text 1",+/// "Hyperlink Text 2" while the window SHOWED something else entirely. When every+/// readable caption is one of these, the text is not the window's words.+pub const TEMPLATE_PLACEHOLDERS: &[&str] =+ &["Title", "Description", "Button1", "Button2", "Hyperlink Text 1", "Hyperlink Text 2"];++pub fn text_is_template_placeholder(c: &crate::launchos::DialogContents) -> bool {+ let all: Vec<&String> = c.text.iter().chain(c.buttons.iter()).collect();+ !all.is_empty() && all.iter().all(|t| TEMPLATE_PLACEHOLDERS.contains(&t.trim()))+}++/// DOES THIS WINDOW BLOCK? Judged by MEASURED MODALITY, not by "a window exists"+/// (0.9.4). Measured 2026-10-01: a title-less `#32770` (hwnd 331370) stopped+/// every quit for minutes — and it was the 3DEXPERIENCE "Connected to …"+/// NOTIFICATION: owner frame ENABLED for all 20 reads over 95 s, WS_EX_TOOLWINDOW,+/// no Button-class child, custom-drawn. A window BLOCKS when:+/// * its kind (from its title) is one of the known blocking kinds — a save+/// prompt, recovery, licence, import diagnostics, …; or+/// * its owner frame is DISABLED — a real modal; or+/// * it has a Button-class child — something on offer.+/// It is a `notification` — reported, never answered, and NOT a reason to refuse+/// a quit, a sweep or an import — only when ALL of these were MEASURED: it is a+/// tool window, its owner frame is enabled, and its controls were read and none+/// is a Button. Anything unread FAILS CLOSED: blocking.+pub fn judge_window(+ w: &WindowInfo,+ contents: Option<&crate::launchos::DialogContents>,+) -> (bool, &'static str, String) {+ let (kind, _blocking, _never) = crate::abwin::classify(&w.title);+ if kind != "unknown" {+ return (true, kind, format!("a known blocking kind ({kind}) by its title"));+ }+ match w.owner_enabled {+ Some(false) => return (true, kind, "its owner frame is DISABLED: a modal".into()),+ None => return (true, kind, "whether its owner frame is enabled could not be read".into()),+ Some(true) => {}+ }+ let Some(c) = contents else {+ return (true, kind, "its controls could not be read".into());+ };+ if c.has_button_class_child() {+ return (true, kind, "it has a Button-class control: something on offer".into());+ }+ match w.exstyle {+ Some(x) if x & crate::launchos::WS_EX_TOOLWINDOW != 0 => (+ false,+ "notification",+ "a tool window whose owner frame is ENABLED and which has no Button-class control: \+ a notification, not a modal"+ .into(),+ ),+ Some(_) => (true, kind, "not a tool window: not provably a notification".into()),+ None => (true, kind, "its extended style could not be read".into()),+ }+}++/// THE DIALOG CENSUS, JUDGED: every visible `#32770`, its contents read, each+/// judged. `blocking` is the first that blocks; `notifications` are the ones+/// that do not — reported, never answered.+pub struct DialogCensus {+ pub blocking: Option<BlockingDialog>,+ pub notifications: Vec<BlockingDialog>,+}++pub fn dialog_census(windows: &[WindowInfo], os: &dyn crate::launchos::LaunchOs) -> DialogCensus {+ let mut out = DialogCensus { blocking: None, notifications: Vec::new() };+ for w in windows.iter().filter(|w| w.is_dialog() && w.visible) {+ let contents = os.dialog_contents(w.hwnd).ok();+ let (blocking, kind, why) = judge_window(w, contents.as_ref());+ let d = BlockingDialog {+ hwnd: w.hwnd,+ title: w.title.clone(),+ kind,+ contents,+ window: Some(w.clone()),+ blocking,+ why,+ };+ if blocking {+ if out.blocking.is_none() {+ out.blocking = Some(d);+ }+ } else {+ out.notifications.push(d);+ }+ }+ out } impl BlockingDialog { pub fn to_json(&self) -> Value { let c = self.contents.as_ref();+ // A TEMPLATE'S CAPTIONS ARE NOT THE WINDOW'S WORDS (measured 2026-10-01).+ let placeholder = c.map(text_is_template_placeholder).unwrap_or(false); json!({ "hwnd": self.hwnd, "title": self.title, "kind": self.kind,+ "blocking": self.blocking,+ "why": self.why,+ "textIsTemplatePlaceholder": placeholder,+ "placeholderCaptions": if placeholder { c.map(|c| json!(c.text)).unwrap_or(Value::Null) } else { Value::Null },+ "evidence": {+ "window": self.window.as_ref().map(|w| w.to_json()),+ "controls": c.map(|c| c.to_json()["controls"].clone()),+ }, // A TITLE IS NOT THE MESSAGE. Live 2026-09-17 the sweep stopped on // `{"kind":"license","title":"License Manager"}` — correct, and // unactionable: the window actually said the licence SERVER was // unreachable and the session would end after five more failed // attempts. That is a countdown somebody needs to see.- "text": c.map(|c| json!(c.text)).unwrap_or(Value::Null),+ "text": if placeholder { Value::Null } else { c.map(|c| json!(c.text)).unwrap_or(Value::Null) }, "buttons": c.map(|c| json!(c.buttons)).unwrap_or(Value::Null), "contentsReadable": c.is_some(), // Whether the only thing on offer is acknowledgement. This bridge@@ -265,7 +372,15 @@ impl BlockingDialog { pub fn first_blocking_dialog(windows: &[WindowInfo]) -> Option<BlockingDialog> { windows.iter().filter(|w| w.is_dialog() && w.visible).map(|w| { let (kind, _blocking, _never) = crate::abwin::classify(&w.title);- BlockingDialog { hwnd: w.hwnd, title: w.title.clone(), kind, contents: None }+ BlockingDialog {+ hwnd: w.hwnd,+ title: w.title.clone(),+ kind,+ contents: None,+ window: Some(w.clone()),+ blocking: true,+ why: "a visible dialog, not yet judged".into(),+ } }).next() } @@ -682,7 +797,73 @@ mod tests { } fn win(hwnd: i64, class: &str, title: &str) -> WindowInfo {- WindowInfo { hwnd, class_name: class.into(), title: title.into(), visible: true }+ WindowInfo { hwnd, class_name: class.into(), title: title.into(), visible: true, ..Default::default() }+ }++ /// WHICH WINDOWS BLOCK — a cases table of measured evidence → verdict (0.9.4).+ /// Each row says which of its fields were MEASURED on the live machine.+ #[test]+ fn windows_are_judged_by_measured_modality() {+ use crate::launchos::DialogContents;+ let contents = |controls: &[(&str, bool)], text: &[&str], buttons: &[&str]| DialogContents {+ text: text.iter().map(|t| t.to_string()).collect(),+ buttons: buttons.iter().map(|t| t.to_string()).collect(),+ controls: controls.iter().map(|(c, v)| (c.to_string(), *v)).collect(),+ ..Default::default()+ };+ let window = |title: &str, owner_enabled: Option<bool>, exstyle: Option<u32>| WindowInfo {+ hwnd: 331370,+ class_name: "#32770".into(),+ title: title.into(),+ visible: true,+ owner: Some(4242),+ owner_enabled,+ style: Some(0x9400_0044),+ exstyle,+ rect: Some([533, 45, 834, 112]),+ };+ // THE CANONICAL NOTIFICATION — every field MEASURED 2026-10-01 on Drew's+ // machine: hwnd 331370, owner = the main frame, owner ENABLED for all 20+ // reads over 95 s, exstyle 0x90080 (TOOLWINDOW | LAYERED | CONTROLPARENT),+ // 301x67 px at the top of the frame, ALL children Static, the visible ones+ // "Title"/"Description" and the hidden ones "Button1"/"Button2"/+ // "Hyperlink Text 1"/"Hyperlink Text 2" — template placeholders: the+ // window showed "Connected to OI000616940 — SOLIDWORKS is connected to+ // your 3DEXPERIENCE platform" (known as 3dexperience_connected; that name+ // is documentation, not a rule).+ let connected = contents(+ &[("Static", true), ("Static", true), ("Static", false), ("Static", false), ("Static", false), ("Static", false)],+ &["Title", "Description", "Button1", "Button2", "Hyperlink Text 1", "Hyperlink Text 2"],+ &[],+ );+ // THE LICENCE DIALOG — MEASURED 2026-09-17: its title "License Manager",+ // its text and its one button (OK). NOT measured: whether its owner frame+ // was disabled. It blocks BY KIND, whatever the owner says.+ let license = contents(&[("Static", true), ("Button", true)], &["Failed to check 1 license(s): …"], &["OK"]);+ let with_button = contents(&[("Static", true), ("Button", true)], &["Continue?"], &["OK"]);+ let modal_statics = contents(&[("Static", true)], &["Working…"], &[]);+ type Row<'a> = (&'a str, &'a str, Option<bool>, Option<u32>, Option<&'a DialogContents>, bool, &'a str);+ let rows: Vec<Row> = vec![+ ("the 3DEXPERIENCE connected notice (measured)", "", Some(true), Some(0x90080), Some(&connected), false, "notification"),+ ("the same window, owner frame DISABLED", "", Some(false), Some(0x90080), Some(&connected), true, "unknown"),+ ("the same window, owner enablement UNREAD", "", None, Some(0x90080), Some(&connected), true, "unknown"),+ ("the same window, controls UNREADABLE", "", Some(true), Some(0x90080), None, true, "unknown"),+ ("the same window, extended style UNREAD", "", Some(true), None, Some(&connected), true, "unknown"),+ ("the same window, NOT a tool window", "", Some(true), Some(0x10000), Some(&connected), true, "unknown"),+ ("an unknown window with a Button, owner enabled", "", Some(true), Some(0x90080),+ Some(&with_button), true, "unknown"),+ ("an unknown MODAL: owner disabled, statics only", "", Some(false), Some(0x10000),+ Some(&modal_statics), true, "unknown"),+ ("the licence dialog (blocking by kind; owner not measured)", "License Manager", None, None, Some(&license), true, "license"),+ ("the licence dialog even with an enabled owner", "License Manager", Some(true), Some(0x90080), Some(&license), true, "license"),+ ("a save prompt, by kind", "Save Modified Documents", Some(true), Some(0x90080), Some(&connected), true, "save_changes"),+ ];+ for (label, title, owner_enabled, exstyle, c, want_blocking, want_kind) in rows {+ let (blocking, kind, why) = judge_window(&window(title, owner_enabled, exstyle), c);+ assert_eq!((blocking, kind), (want_blocking, want_kind), "{label}: {why}");+ }+ assert!(text_is_template_placeholder(&connected), "the template's captions are not the window's words");+ assert!(!text_is_template_placeholder(&license)); } /// The signature that matters: everything measured, nothing waived.@@ -729,7 +910,7 @@ Some(0), Some(0), false, Some(3), None, true, Some(false), false, Some(true), So Some("unsaved_documents") ); // ...and force waives NONE of the others.- let dialog = BlockingDialog { hwnd: 9, title: "Save changes?".into(), kind: "save_changes", contents: None };+ let dialog = BlockingDialog { hwnd: 9, title: "Save changes?".into(), kind: "save_changes", contents: None, window: None, blocking: true, why: String::new() }; let cases: Vec<(&str, Result<(), Refusal>)> = vec![ ("open docs", check_preconditions( Some(2), Some(2), false, Some(0), None, true, Some(false), false, Some(true), Some(&id()), false, true)),@@ -782,9 +963,9 @@ Some(0), Some(0), false, Some(0), None, true, Some(false), false, Some(true), No #[test] fn a_save_prompt_gets_its_own_code_because_it_should_be_impossible() {- let save = BlockingDialog { hwnd: 9, title: "Save changes to Part1?".into(), kind: "save_changes", contents: None };+ let save = BlockingDialog { hwnd: 9, title: "Save changes to Part1?".into(), kind: "save_changes", contents: None, window: None, blocking: true, why: String::new() }; assert_eq!(save.error_code(), "stop_blocked_by_save_prompt");- let other = BlockingDialog { hwnd: 9, title: "3DEXPERIENCE sign in".into(), kind: "license", contents: None };+ let other = BlockingDialog { hwnd: 9, title: "3DEXPERIENCE sign in".into(), kind: "license", contents: None, window: None, blocking: true, why: String::new() }; assert_eq!(other.error_code(), "stop_blocked_by_dialog"); // ...and each stops the ladder from the precondition sweep. for d in [&save, &other] {
src/readiness.rs+5−1@@ -13,7 +13,11 @@ //! * COM: `ISldWorks::StartupProcessCompleted` (host command `startup_state`); //! * the performance log: a 3DEXPERIENCE load marker written after the last //! `LOAD_ADDIN` line (`perflog::startup_complete`) — file-based, no COM.-//! READY = every available signal says so. When NEITHER can be read, a grace of+//! READY = every available signal says so. MEASURED 2026-10-01 on hosted 0.9.3:+//! `StartupProcessCompleted` was already TRUE before the log's marker appeared,+//! and the session became ready 3.4 s after attach when `<3DXINITERROR>` was+//! written after the last `LOAD_ADDIN` — so COM alone is NOT sufficient, and+//! requiring both is the rule. When NEITHER can be read, a grace of //! `GRACE_SECS` after the bridge first saw this session stands in, and the //! answer says so (`basedOn: ["graceAfterFirstSeen"]`) — never silently. A start //! that does not become ready within `TIMEOUT_SECS` stays `starting`, with
src/verbs.rs+13−11@@ -1082,6 +1082,9 @@ struct StopEvidence { busy: Option<bool>, identity: Option<crate::quit::Identity>, dialog: Option<crate::quit::BlockingDialog>,+ /// Windows the census judged NOT blocking — notifications, reported with+ /// their evidence and never answered (0.9.4).+ notifications: Vec<crate::quit::BlockingDialog>, /// The pids the dialog census actually covered — SOLIDWORKS plus its CEF /// subprocesses, which is where a 3DEXPERIENCE sign-in window lives. dialog_pids: Vec<u32>,@@ -1131,6 +1134,7 @@ impl StopEvidence { let mut dialog_pids: Vec<u32> = Vec::new(); let mut census_readable = false; let mut dialog = None;+ let mut notifications: Vec<crate::quit::BlockingDialog> = Vec::new(); if let Some(id) = identity.as_ref() { if let Some(pids) = quit::dialog_census_pids(census.as_ref(), id) { let mut all_read = true;@@ -1151,10 +1155,11 @@ impl StopEvidence { // tell "your licence expired" from "the licence server is // unreachable and this session ends after five more tries". // Reading is not answering; nothing here presses anything.- dialog = quit::first_blocking_dialog(&windows).map(|mut d| {- d.read_contents(os);- d- });+ // JUDGED BY MEASURED MODALITY (0.9.4): a notification is reported,+ // never answered, and does not refuse the quit.+ let judged = quit::dialog_census(&windows, os);+ dialog = judged.blocking;+ notifications = judged.notifications; } } // P5: responding, without COM.@@ -1170,6 +1175,7 @@ impl StopEvidence { busy, identity, dialog,+ notifications, dialog_pids, census_readable, responding,@@ -1203,6 +1209,7 @@ impl StopEvidence { "documentsTruncated": self.docs_truncated, "unsavedDocuments": self.unsaved, "dialog": self.dialog.as_ref().map(|d| d.to_json()),+ "notifications": self.notifications.iter().map(|d| d.to_json()).collect::<Vec<_>>(), "dialogCensusReadable": self.census_readable, "dialogCensusPids": self.dialog_pids, "busy": self.busy,@@ -1754,12 +1761,7 @@ fn run_rung( census = os.processes(); gone = quit::process_gone(census.as_ref(), &id); after_windows = os.windows_of_pid(id.pid);- new_dialog = after_windows.as_ref().ok().and_then(|w| {- quit::first_blocking_dialog(w).map(|mut d| {- d.read_contents(&*os);- d- })- });+ new_dialog = after_windows.as_ref().ok().and_then(|w| quit::dialog_census(w, os).blocking); if gone == Some(true) || new_dialog.is_some() || std::time::Instant::now() >= wait_until { break;@@ -1845,7 +1847,7 @@ fn prime_session( )); } match os.windows_of_pid(id.pid) {- Ok(w) if quit::first_blocking_dialog(&w).is_none() => {}+ Ok(w) if quit::dialog_census(&w, os).blocking.is_none() => {} Ok(_) => return Err("a dialog is up".into()), Err(e) => return Err(format!("the window census could not be read: {e}")), }
src/verbs3.rs+18−4@@ -710,6 +710,18 @@ fn recovery_dir() -> Result<std::path::PathBuf, String> { /// `None` means the census could not be taken, which also stops: a dialog we /// cannot see is the one that matters. fn blocking_dialog_now(ctx: &Ctx) -> Option<Option<Value>> {+ dialog_census_now(ctx).map(|c| c.blocking.map(|d| d.to_json()))+}++/// The NOTIFICATIONS on screen now — windows the census judged not blocking —+/// with their evidence, for the reply. Empty when the census cannot be taken.+fn notifications_now(ctx: &Ctx) -> Vec<Value> {+ dialog_census_now(ctx)+ .map(|c| c.notifications.iter().map(|d| d.to_json()).collect())+ .unwrap_or_default()+}++fn dialog_census_now(ctx: &Ctx) -> Option<crate::quit::DialogCensus> { let os = crate::launchos::os(); let state = ctx.call("app_state", json!({}), 20); let pid = state@@ -736,10 +748,9 @@ fn blocking_dialog_now(ctx: &Ctx) -> Option<Option<Value>> { // alone cannot tell "your licence expired" from "the licence server is // unreachable and the session ends after five more tries". Reading is not // answering — nothing here presses anything.- Some(crate::quit::first_blocking_dialog(&windows).map(|mut d| {- d.read_contents(os.as_ref());- d.to_json()- }))+ // JUDGED BY MEASURED MODALITY (0.9.4): a notification — owner frame enabled,+ // a tool window, nothing to click — does not stop a sweep or an import.+ Some(crate::quit::dialog_census(&windows, os.as_ref())) } /// `swx_close_all_safely` — PRESERVE EVERYTHING, THEN CLOSE EVERYTHING.@@ -1211,6 +1222,9 @@ pub fn close_all_safely(ctx: &Ctx, args: &Value) -> Value { "largestBytes": estimate.largest, }), "stoppedByDialog": stopped_by_dialog,+ // On screen and NOT blocking — reported with their evidence, never+ // answered (0.9.4).+ "notifications": notifications_now(ctx), // WHY it stopped, in one word a caller can branch on. "stoppedBy": if stopped_by_dialog.is_some() { json!("dialog")
tests/quit.rs+81@@ -664,3 +664,84 @@ fn a_session_with_a_document_open_is_never_primed() { s(&docs) ); }++// ── A NOTIFICATION IS NOT A MODAL (measured 2026-10-01) ────────────────────++/// The window measured on Drew's machine: hwnd 331370, `#32770`, no title, owner+/// = the main frame and ENABLED for all 20 reads over 95 s, exstyle 0x90080+/// (TOOLWINDOW | LAYERED | CONTROLPARENT), 301x67 px, all children Static — the+/// 3DEXPERIENCE "Connected to …" notice, custom-drawn over template captions.+fn connected_notice(owner_enabled: bool) -> (Value, Value) {+ (+ json!([{"hwnd": 331370, "className": "#32770", "title": "", "visible": true,+ "owner": 4242, "ownerEnabled": owner_enabled, "style": 0x9400_0044u64,+ "exStyle": 0x90080u64, "rect": [533, 45, 834, 112]}]),+ json!({"hwnd": 331370, "controls": [+ ["Static", 0, "Title", true], ["Static", 0, "Description", true],+ ["Static", 0, "Button1", false], ["Static", 0, "Button2", false],+ ["Static", 0, "Hyperlink Text 1", false], ["Static", 0, "Hyperlink Text 2", false]+ ]}),+ )+}++#[test]+fn a_notification_window_does_not_block_the_quit_and_is_reported() {+ let (windows, contents) = connected_notice(true);+ let m = Sim::new("quit-notification", json!({"a": "exited"})).windows(windows);+ std::fs::write(m.dir.join("dialog-open.json"), contents.to_string()).unwrap();+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "swx_stop: a notification (owner frame enabled, tool window, nothing to click) does not block the quit",+ r["success"] == true && r["stopped"] == true,+ s(&r)+ );+ let n = r["preconditions"]["notifications"][0].clone();+ check!(+ b,+ "swx_stop: ...it is REPORTED, with its evidence, as a notification — never answered",+ n["kind"] == "notification"+ && n["blocking"] == false+ && n["neverAnswer"] == true+ && n["evidence"]["window"]["ownerEnabled"] == true+ && n["evidence"]["window"]["exStyle"] == "0x90080"+ && n["evidence"]["window"]["rect"] == json!([533, 45, 834, 112])+ && n["evidence"]["controls"].as_array().map(|a| a.len() == 6).unwrap_or(false),+ s(&r["preconditions"])+ );+ check!(+ b,+ "swx_stop: ...and its template captions are NOT presented as its words",+ n["textIsTemplatePlaceholder"] == true && n["text"].is_null() && n["placeholderCaptions"][0] == "Title",+ s(&n)+ );+}++/// The same window with its owner frame DISABLED is a modal: the quit stops on+/// it, and the report carries the evidence that decided it.+#[test]+fn the_same_window_over_a_disabled_frame_blocks_and_says_why() {+ let (windows, contents) = connected_notice(false);+ let m = Sim::new("quit-modal", json!({"a": "exited"})).windows(windows);+ std::fs::write(m.dir.join("dialog-open.json"), contents.to_string()).unwrap();+ let b = m.boot(&[]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_stop", authorized("a"));+ check!(+ b,+ "swx_stop: owner frame disabled → a modal → stop_blocked_by_dialog, nothing attempted",+ r["success"] == false && r["errorCode"] == "stop_blocked_by_dialog" && !m.quit_happened(),+ s(&r)+ );+ let d = r["preconditions"]["dialog"].clone();+ check!(+ b,+ "swx_stop: ...with the evidence and the reason in the report",+ d["blocking"] == true+ && d["evidence"]["window"]["ownerEnabled"] == false+ && d["why"].as_str().unwrap_or("").contains("DISABLED"),+ s(&r)+ );+}
tests/readiness.rs+47@@ -275,3 +275,50 @@ fn a_lock_file_in_a_recovery_folder_is_named_apart() { s(&sweep) ); }++/// A NOTIFICATION DOES NOT STOP A SWEEP (measured 2026-10-01): the 3DEXPERIENCE+/// "Connected to …" notice — owner frame enabled, a tool window, nothing to click+/// — appeared ~18 s after a document create and stayed for minutes. Under 0.9.3+/// it blocked every quit, sweep and import in that window. Now the sweep runs and+/// the notice is reported with its evidence; it is never answered.+#[test]+fn a_notification_does_not_stop_a_sweep_and_is_reported() {+ let scratch = TempDir::new("notice-sweep");+ session(+ &scratch,+ json!([{"docType": "part", "path": "untitled:a", "title": "Part1", "saved": false, "dirty": true}]),+ );+ let machine = TempDir::new("notice-sweep-machine");+ std::fs::write(+ machine.join("windows.json"),+ json!([{"hwnd": 331370, "className": "#32770", "title": "", "visible": true,+ "owner": 4242, "ownerEnabled": true, "style": 0x9400_0044u64,+ "exStyle": 0x90080u64, "rect": [533, 45, 834, 112]}])+ .to_string(),+ )+ .unwrap();+ std::fs::write(+ machine.join("dialog-open.json"),+ json!({"hwnd": 331370, "controls": [["Static", 0, "Title", true], ["Static", 0, "Description", true],+ ["Static", 0, "Button1", false], ["Static", 0, "Button2", false]]})+ .to_string(),+ )+ .unwrap();+ let b = Bridge::start_in(TempDir::borrowed(&scratch.0), &[("SWX_LAUNCH_FAKE_DIR", &machine.path())]);+ b.call("swx_start", json!({}));+ let r = b.call("swx_close_all_safely", json!({"reason": "the user asked me to close SOLIDWORKS"}));+ check!(+ b,+ "swx_close_all_safely: the notification does not stop the sweep",+ r["success"] == true && r["done"] == true && r["stoppedByDialog"].is_null(),+ s(&r)+ );+ check!(+ b,+ "swx_close_all_safely: ...and is reported, with its evidence, never answered",+ r["notifications"][0]["kind"] == "notification"+ && r["notifications"][0]["neverAnswer"] == true+ && r["notifications"][0]["evidence"]["window"]["toolWindow"] == true,+ s(&r["notifications"])+ );+}
tests/wiring.rs+1@@ -956,6 +956,7 @@ fn acknowledge_only_is_exactly_one_acknowledgement() { buttons: buttons.iter().map(|s| s.to_string()).collect(), checkboxes: checkboxes.iter().map(|s| s.to_string()).collect(), truncated: false,+ controls: vec![], }; assert!(d(&["OK"], &[]).acknowledge_only(), "one OK is an acknowledgement"); assert!(d(&["&OK"], &[]).acknowledge_only(), "the accelerator ampersand is not part of it");
tools/quit-gate.py+12@@ -691,6 +691,18 @@ def run_ladder(base, max_rung, allow_rm, reason): print(" rung %s: result=%s processGone=%s newWindows=%s %s" % (t.get("rung"), t.get("result"), t.get("processGone"), t.get("newWindows"), str(t.get("detail") or "")[:120]))+ # THE FULL DIALOG EVIDENCE, whenever a window stopped the ladder (0.9.4).+ # 2026-10-01 a title-less window stopped it with `kind: unknown` and nothing+ # else to judge it by; it turned out to be a NOTIFICATION. Print every field+ # the census measured — owner, ownerEnabled, styles, rect, child controls —+ # so the next one can be judged from this output alone.+ pre = r.get("preconditions") or {}+ blocked = r.get("blockingDialog") or pre.get("dialog")+ if blocked:+ show("BLOCKING WINDOW — full evidence (blocking=%s, why=%s)"+ % (blocked.get("blocking"), blocked.get("why")), blocked)+ for n in pre.get("notifications") or []:+ show("NOTIFICATION (not blocking, never answered) — evidence", n) if r.get("blockingDialog"): NOTES.append("a dialog stopped the ladder: %s — it is STILL ON SCREEN and this gate did " "not answer it — show the user a screenshot (swx_state) and let them "