app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028
//! The 23 `swx_*` verbs.
//!
//! Every response ab relays VERBATIM to the calling AI, so the `_hint` on each
//! one IS this bridge's documentation — the part that actually gets read. The
//! rules the shapes follow:
//!
//! * `success` is always present and boolean.
//! * failures carry a stable, branchable `errorCode` when the caller can act
//! on it (`host_app_not_installed`, `reason_required`) and `_reportIssues`
//! so a real-machine bug can be filed without asking how.
//! * anything that touches a window carries `windowEtiquette:"background"` —
//! this bridge never steals the user's screen.
//! * slow verbs carry a terminal `progress` frame, and `swx_progress` reports
//! what is in flight right now.
//!
//! Verbs that do NOT need COM (status, readiness, describe, progress,
//! find_files, uninstall) work with SOLIDWORKS absent — that is the difference
//! between a bridge that is useless before the trial serial arrives and one that
//! can already answer "what do I have?". swx_screenshot is gated even though it
//! delegates to ab: there is nothing to screenshot without a running SOLIDWORKS
//! window, so it returns host_app_not_installed rather than an empty capture.
use crate::comhost::{ComHost, SidecarError};
use crate::detect::{detect, Detection};
use crate::jobs::Jobs;
use serde_json::{json, Map, Value};
use std::sync::{Arc, RwLock};
use std::time::Duration;
/// The bridge's own wiki page — where bugs in THIS bridge go.
const SLUG: &str = "adom/solidworks-bridge";
pub const TRIAL_HINT: &str =
"SOLIDWORKS is not installed on this machine. A 30-day desktop trial was requested from \
Hawk Ridge Systems (reseller) — the serial number arrives by email, then the Installation \
Manager installs it. Until then only the detection/file-search verbs work (swx_status, \
swx_readiness, swx_describe, swx_find_files, swx_progress). Do NOT tell the user to install \
it by hand; offer to walk the install once the serial exists.";
pub fn report_issues() -> String {
format!(
"THIS MAY BE A SOLIDWORKS BRIDGE BUG. PLEASE REPORT IT. Check for a duplicate with \
`adom-wiki issue list {SLUG}`, then file: `adom-wiki issue create {SLUG} --title \
\"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, \
what happened, the VERBATIM response JSON, the bridge version from bridge_list, and the \
OS>\"`. Reports from real machines are the only way this bridge gets correct. (An \
ab-core problem, such as the relay, bridge lifecycle or caller identity, belongs on \
adom/adom-bridge instead.)"
)
}
/// Everything a verb needs: the com host, the progress registry, and the last
/// detection result.
pub struct Ctx {
pub com: Arc<ComHost>,
pub jobs: Arc<Jobs>,
pub detection: RwLock<Detection>,
/// The documents THIS SERVER PROCESS created and has not closed — what
/// `swx_reset_workspace` may sweep and what the mutating verbs may change.
///
/// An entry is (title, SOLIDWORKS session, path-once-saved). All three
/// matter, and the first version had only the title:
///
/// * SOLIDWORKS names new documents `Part1`, `Part2`, `Assem1` — and it
/// RESTARTS that numbering with every session. A remembered `Part1`
/// would therefore match a fresh `Part1` the USER made after a restart,
/// and the bridge would treat their document as its own. Hence the
/// session (SOLIDWORKS' own process id): an entry from another session
/// is never owned.
/// * Titles were also forgotten only on the paths that run through this
/// bridge. A document closed in the SOLIDWORKS UI, by a crash, or by
/// `CloseAllDocuments` would linger as "ours" forever. So ownership is
/// additionally checked against what SOLIDWORKS reports as OPEN RIGHT
/// NOW, and stale entries are pruned there.
///
/// The earlier comment claiming "a restart forgets" was false: the bridge
/// server outlives SOLIDWORKS.
pub scratch_docs: std::sync::Mutex<Vec<ScratchDoc>>,
}
/// Are these the same Windows path?
///
/// Windows paths are CASE-INSENSITIVE, and SOLIDWORKS normalises what it reports
/// — ask it to save `…\smoke.sldprt` and it will tell you the document lives at
/// `…\smoke.SLDPRT`. An exact string compare therefore said the bridge's own
/// freshly-saved scratch document was somebody else's: the ownership record kept
/// the path we ASKED for, the prune compared against the path SOLIDWORKS
/// REPORTED, they differed by one letter's case, and the bridge disowned its own
/// document and refused to clean it up.
///
/// Separators are normalised too, for the same reason: `\` and `/` both reach
/// the same file and a caller may write either.
pub fn same_path(a: &str, b: &str) -> bool {
fn norm(s: &str) -> String {
s.replace('/', "\\").to_lowercase()
}
!a.is_empty() && !b.is_empty() && norm(a) == norm(b)
}
/// A document TITLE without its SOLIDWORKS extension (0.10.10). SOLIDWORKS'
/// `GetTitle` follows Windows Explorer's "Hide extensions for known file
/// types" (HideFileExt): with it ON — the Windows DEFAULT — a saved part is
/// titled `Part14-17`, with it off `Part14-17.SLDPRT`. MEASURED 2026-10-15 on
/// Michael's SOLIDWORKS 2021: every verb the self-test aimed at its scratch
/// part by the title WITH the extension failed `document_not_open`. Only a
/// trailing `.sldprt` / `.sldasm` / `.slddrw` (any case) is removed: a dot
/// elsewhere (`Rev1.2`) is part of the name.
pub fn title_stem(t: &str) -> &str {
let t = t.trim_end();
for ext in [".sldprt", ".sldasm", ".slddrw"] {
if t.len() > ext.len() && t.is_char_boundary(t.len() - ext.len()) && t[t.len() - ext.len()..].eq_ignore_ascii_case(ext) {
return &t[..t.len() - ext.len()];
}
}
t
}
/// THE ONE TITLE COMPARISON (0.10.10): equal once the SOLIDWORKS extension is
/// dropped from both sides — a title match must never depend on HideFileExt.
pub fn same_title(a: &str, b: &str) -> bool {
!a.trim().is_empty() && !b.trim().is_empty() && title_stem(a) == title_stem(b)
}
/// IS THIS OPEN DOCUMENT THE ONE WE RECORDED?
///
/// The single comparison both the ownership check and the prune must use.
/// They used different ones: `owns_doc` compared paths with [`same_path`]
/// (case- and separator-insensitive, which is what Windows means by "the same
/// file"), while `prune_scratch` compared them BYTE-EXACT. SOLIDWORKS hands the
/// same file back in different casings from different APIs, so a document could
/// pass the ownership test and still be pruned out of the registry that grants
/// it — and the next write to it was refused as "this bridge did not create
/// it". Bug #5's user-visible symptom, from a different cause.
///
/// A document with a PATH is identified by the path. One that has never been
/// saved is identified by title, and only against another unsaved document —
/// an entry we have since saved refers to a file, not to whatever now carries
/// its old name.
pub fn doc_matches(
stored_title: &str,
stored_path: Option<&str>,
open_title: &str,
open_path: Option<&str>,
) -> bool {
match (stored_path, open_path) {
(Some(a), Some(b)) => same_path(a, b),
(None, None) => same_title(stored_title, open_title),
_ => false,
}
}
/// One document this bridge created. See [`Ctx::scratch_docs`].
#[derive(Clone, Debug, PartialEq)]
pub struct ScratchDoc {
pub title: String,
/// SOLIDWORKS' own process id when this document was created. `None` means
/// the session could not be read, which counts as NOT OWNED.
pub session: Option<i64>,
/// Filled in once saved, so a retitle is survivable.
pub path: Option<String>,
}
/// The document a mutating verb is about to change, resolved ONCE on the server
/// and then handed to the com host EXPLICITLY.
///
/// This type exists because of a real defect. The ownership gate resolved a
/// target from `targetDocument`, but only `swx_run_script` forwarded that
/// argument — every other verb sent `path` (usually null), so the com host fell
/// back to ActiveDoc and changed a DIFFERENT document from the one the gate had
/// authorized. `swx_save_as {outputPath:"…", targetDocument:"Part1"}` renamed
/// and relocated the user's open part: the v0.2 data-loss bug, resurrected by
/// its own fix. Carrying the resolution through is the answer — whatever the
/// gate approved is exactly what the com host is told to act on.
#[derive(Clone, Debug)]
pub struct Target {
pub title: String,
pub path: Option<String>,
pub owned: bool,
}
impl Target {
/// The identity args every mutating verb must send. `docName` is a
/// dedicated key precisely so it cannot collide with the `name` argument
/// that means a DIMENSION on swx_set_parameter and a PREFERENCE on
/// swx_set_preference.
pub fn args(&self) -> Value {
json!({"docName": self.title, "path": self.path})
}
/// How to name this document in a message to a human.
pub fn describe(&self) -> String {
match &self.path {
Some(p) => format!("{} ({p})", self.title),
None => format!("{} (unsaved)", self.title),
}
}
}
impl Ctx {
pub fn new(detection: Detection) -> Self {
// BUG #5. The registry is loaded from disk, not started empty: a bridge
// restart used to disown every document the bridge had created, and on
// 2026-09-03 that made 23 of its own crate documents un-closable and
// un-savable. Entries whose SOLIDWORKS process id has since been reused
// by a different process are dropped on the way in (see
// `crate::owned::reconcile`) — this can only ever REMOVE a claim.
let restored: Vec<ScratchDoc> = crate::owned::restore();
Self {
com: Arc::new(ComHost::new()),
jobs: Jobs::new(),
detection: RwLock::new(detection),
scratch_docs: std::sync::Mutex::new(restored),
}
}
/// Write the registry through to disk. Called after every change, so the
/// file is the truth a restart (or a second look after a com-host respawn)
/// reads back.
fn persist_scratch(&self) {
let docs = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).clone();
crate::owned::save(&docs, crate::owned::session_started_at);
}
/// Remember a document this bridge created, so the sweep may close it.
pub(crate) fn remember_scratch(&self, title: &str, session: Option<i64>) {
if title.is_empty() {
return;
}
{
let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
if !v.iter().any(|d| same_title(&d.title, title) && d.session == session) {
v.push(ScratchDoc { title: title.to_string(), session, path: None });
}
}
self.persist_scratch();
}
/// Remember a document the bridge created that ALREADY HAS A PATH — an
/// import, whose `GetPathName` is its `…\Temp\swx<pid>\IC~~\…` conversion
/// temp even though it was never saved anywhere real.
///
/// A NEW entry, never a mutation of an existing one. `rename_scratch` finds
/// its target BY TITLE, so using it here would reassign the recorded path of
/// a *different* owned document that happens to share the imported title —
/// SOLIDWORKS titles an imported STEP `<stem>.SLDPRT`, so importing beside a
/// saved part of the same name is the ordinary case, and that would have
/// disowned the user-facing document (the collision `resolve` already warns
/// about, arriving from the ownership side instead).
pub(crate) fn remember_scratch_at(&self, title: &str, session: Option<i64>, path: &str) {
if title.is_empty() || path.is_empty() {
return;
}
{
let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
let already = v.iter().any(|d| {
d.session == session
&& d.path.as_deref().map(|p| same_path(p, path)).unwrap_or(false)
});
if !already {
v.push(ScratchDoc {
title: title.to_string(),
session,
path: Some(path.to_string()),
});
}
}
self.persist_scratch();
}
#[cfg(test)]
pub(crate) fn forget_scratch(&self, title: &str) {
self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).retain(|d| !same_title(&d.title, title));
self.persist_scratch();
}
/// Forget the ONE document that was closed — matched the way ownership is
/// decided ([`doc_matches`]: by path when it has one), not by title. A
/// title-only forget dropped every owned document that shared the closed
/// one's name, so the bridge disowned a second document it had made
/// (0.9.29 audit, prompted by issue #2's title-only ownership test).
pub(crate) fn forget_scratch_doc(&self, title: &str, path: Option<&str>) {
let path = path.filter(|p| !p.is_empty());
self.scratch_docs
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|d| !doc_matches(&d.title, d.path.as_deref(), title, path));
self.persist_scratch();
}
/// A scratch document that has been saved keeps its identity under its new
/// title, so `save_as` does not silently disown it.
#[cfg(test)]
pub(crate) fn rename_scratch(&self, from: &str, to: &str, path: Option<String>) {
self.rename_scratch_doc(from, None, to, path, true)
}
/// Rename the ONE entry that IS the document saved — by path when it had
/// one (0.9.29 audit). `title_fallback`: an entry for a never-saved
/// document is found by title, as before.
pub(crate) fn rename_scratch_doc(&self, from: &str, from_path: Option<&str>, to: &str, path: Option<String>, title_fallback: bool) {
{
let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
let from_path = from_path.filter(|p| !p.is_empty());
let hit = v.iter().position(|d| doc_matches(&d.title, d.path.as_deref(), from, from_path)).or_else(|| {
if title_fallback { v.iter().position(|d| same_title(&d.title, from)) } else { None }
});
if let Some(d) = hit.map(|i| &mut v[i]) {
d.title = to.to_string();
d.path = path;
}
}
self.persist_scratch();
}
/// Drop every entry that cannot still be ours: a different SOLIDWORKS
/// session (its Part numbering restarts, so titles repeat), or a document
/// that is no longer open at all.
/// Drop every entry that cannot still be ours: a different SOLIDWORKS
/// session, or a document that is no longer open. `open` is (title, path)
/// for each open document — matched on PATH where the entry has one,
/// because a title alone cannot tell two documents apart.
pub(crate) fn prune_scratch(&self, session: Option<i64>, open: &[(String, Option<String>)]) {
// BUG #5, THE OTHER HALF. `session` is None exactly when SOLIDWORKS
// could not be asked — a com-host respawn mid-call, a timeout, a wedged
// app. The old code pruned anyway, and since EVERY entry fails the
// `d.session == None` test, one unanswerable probe permanently disowned
// every document the bridge had created. An unreadable session is
// "I do not know", and I-do-not-know is not grounds to forget.
if session.is_none() {
return;
}
{
let mut v = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
v.retain(|d| {
d.session.is_some()
&& d.session == session
// ONE comparison, shared with `owns_doc`. See `doc_matches`.
&& open.iter().any(|(t, op)| {
doc_matches(&d.title, d.path.as_deref(), t, op.as_deref())
})
});
}
self.persist_scratch();
}
/// (title, path) for every document currently believed to be ours — the
/// PATH is what a caller must use to name one unambiguously.
pub(crate) fn scratch_paths(&self) -> Vec<(String, Option<String>)> {
self.scratch_docs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|d| (d.title.clone(), d.path.clone()))
.collect()
}
/// Titles currently believed to be ours (as of the last prune).
pub(crate) fn scratch_titles(&self) -> Vec<String> {
self.scratch_docs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|d| d.title.clone())
.collect()
}
/// FAIL CLOSED: with no session we cannot tell our `Part1` from the user's.
///
/// A TITLE IS NOT AN IDENTITY. `C:\users\thing.sldprt` and
/// `C:\bridge\thing.sldprt` are both titled `thing.sldprt`, and having two
/// such documents open is ordinary. So when the document has a PATH, that is
/// what ownership is decided on; the title only decides for a document that
/// has never been saved, which cannot collide because SOLIDWORKS will not
/// issue the same untitled name twice in one session.
pub(crate) fn owns_doc(&self, title: &str, path: Option<&str>, session: Option<i64>) -> bool {
if session.is_none() {
return false;
}
let docs = self.scratch_docs.lock().unwrap_or_else(|e| e.into_inner());
let path = path.filter(|p| !p.is_empty());
docs.iter()
.any(|d| d.session == session && doc_matches(&d.title, d.path.as_deref(), title, path))
}
pub fn detection(&self) -> Detection {
self.detection.read().unwrap_or_else(|e| e.into_inner()).clone()
}
pub(crate) fn redetect(&self) -> Detection {
let fresh = detect();
*self.detection.write().unwrap_or_else(|e| e.into_inner()) = fresh.clone();
fresh
}
pub(crate) fn call(&self, cmd: &str, args: Value, secs: u64) -> Result<Value, SidecarError> {
self.com.call(cmd, args, Duration::from_secs(secs))
}
}
// ── response builders ───────────────────────────────────────────────────────
/// `{success:true, ...result, ...extras}` — extras win, exactly like the Python
/// `{"success": True, **r, "extra": ...}` this port replaces.
pub(crate) fn ok_merge(result: Value, extras: Value) -> Value {
let mut out = Map::new();
out.insert("success".into(), Value::Bool(true));
for source in [result, extras] {
if let Value::Object(map) = source {
for (k, v) in map {
out.insert(k, v);
}
}
}
Value::Object(out)
}
pub fn ok(extras: Value) -> Value {
ok_merge(json!({}), extras)
}
pub(crate) fn err(message: impl Into<String>, hint: impl Into<String>) -> Value {
json!({"success": false, "error": message.into(), "_hint": hint.into()})
}
pub fn err_code(code: &str, message: impl Into<String>, hint: impl Into<String>) -> Value {
json!({"success": false, "errorCode": code, "error": message.into(), "_hint": hint.into()})
}
/// A failure that came back from the com host. `detail` is the host's stderr
/// tail — the difference between "it broke" and "here is what it said".
pub(crate) fn sidecar_error(e: SidecarError) -> Value {
json!({
"success": false,
// The host's own code where it gave one, so a caller can branch instead
// of pattern-matching prose. The live run returned four failures with
// `errorCode: None` and nothing but a sentence.
"errorCode": e.code,
"error": e.message,
"detail": e.detail,
// WHAT THE FAILING CALL SAID, structured, when the host had it. A
// translator failure diagnosed from prose that omits the HRESULT is not
// diagnosed at all.
"hostError": e.data,
// A CODED REFUSAL IS ITS OWN EXPLANATION, and the generic sentence must
// not compete with it. `interconnect_blocks_import` carried "If SOLIDWORKS
// is showing a modal dialog, dismiss it and retry" — there is no dialog,
// nothing is hung, and retrying changes nothing until the user acts on the
// setting. This is the 0.7.6 defect (a blanket instruction that was wrong
// for the specific item) in a second place, and `_hint` is the field
// agents read first.
"_hint": if e.code.is_some() {
"The COM host REFUSED this, with a reason: read `errorCode` and `detail` — they say \
what would change the answer. `hostError` carries the structured failure where there \
was one. The host process is restarted automatically on the next verb."
} else {
"The COM host reported an error. If SOLIDWORKS is showing a modal dialog, dismiss it \
(or ask the user to) and retry; swx_status shows whether SOLIDWORKS is running and \
attached at all. The host process is restarted automatically on the next verb."
},
})
}
// ── the host-app gate ───────────────────────────────────────────────────────
/// Every COM-backed verb starts here: with SOLIDWORKS absent, fail fast with a
/// branchable code and the current install story, instead of a COM error the
/// caller cannot act on.
pub(crate) fn host_gate(ctx: &Ctx) -> Option<Value> {
gate_for(crate::is_mock(), ctx.detection().installed)
}
/// May `swx_start` launch SOLIDWORKS?
///
/// Mock mode says yes so the suite can drive the launch path — EXCEPT under
/// `SWX_TEST_NO_LAUNCH`, which exists because the refusal branch is the one that
/// matters on the machine this bridge actually runs on, and it was previously
/// unreachable from any test on any platform. (A guard nothing can execute is
/// not a guard, it is a hope.)
pub fn may_launch(mock: bool, detection: &Detection) -> bool {
if mock {
return std::env::var("SWX_TEST_NO_LAUNCH").as_deref() != Ok("1");
}
detection.can_launch()
}
/// The gate itself, as a pure decision — the branch that matters most on a
/// machine where SOLIDWORKS has not arrived yet, and the one that cannot be
/// reached from a Linux test host any other way.
pub fn gate_for(mock: bool, installed: bool) -> Option<Value> {
if mock || installed {
return None; // the mock backend serves every verb
}
Some(err_code("host_app_not_installed", "SOLIDWORKS is not installed", TRIAL_HINT))
}
macro_rules! gate {
($ctx:expr) => {
if let Some(g) = $crate::verbs::host_gate($ctx) {
return g;
}
};
}
pub(crate) use gate;
pub(crate) fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
args.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty())
}
pub(crate) fn arg_bool(args: &Value, key: &str, default: bool) -> bool {
args.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}
/// Validate a caller-supplied destination path. A relay AI's `out` must not be
/// a relative path (which would resolve against the bridge's working directory —
/// a `../../victim` traversal off the desktop) and must not contain a `..`
/// component. An ABSOLUTE path is honoured: choosing where an export or a BOM
/// lands on one's own desktop is the whole point of `out`; escaping the process
/// cwd with a relative path is not.
pub(crate) fn safe_out(out: &str) -> Result<String, Value> {
let path = std::path::Path::new(out);
// A leading drive letter only counts as absolute when a separator FOLLOWS
// the colon: `C:\x` / `C:/x` are rooted, but `C:foo` is DRIVE-RELATIVE (it
// resolves against the cwd on drive C) and must be refused like any other
// relative path.
let drive_rooted = {
let b = out.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
};
let absolute = path.is_absolute()
|| out.starts_with('\\') // UNC / rooted-from-current-drive on Windows
|| drive_rooted;
if !absolute {
return Err(err_code(
"unsafe_path",
format!("out must be an absolute path, got a relative one: {out}"),
"Pass an absolute destination (e.g. C:\\\\exports\\\\part.step). A relative path \
would resolve against the bridge's own working directory, which is a traversal \
risk; omit `out` entirely to let the file land in the bridge's pruned exports cache.",
));
}
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(err_code(
"unsafe_path",
format!("out must not contain a '..' component: {out}"),
"Give a direct absolute path with no parent-directory traversal.",
));
}
Ok(out.to_string())
}
// ── verbs: bridge + detection ───────────────────────────────────────────────
fn swx_status(ctx: &Ctx, _args: &Value) -> Value {
let detection = ctx.redetect();
// swx_status is the declared statusVerb — ab tells callers to POLL it when a
// long verb times out. So it must NOT block on the com host's request lock,
// which a 580s Pack and Go could be holding. Read a non-blocking snapshot
// first; only query the host for live COM state when it is actually idle.
let snap = ctx.com.snapshot();
// `com_timed_out` MUST mean "a COM call exceeded its budget". It used to be
// `error present OR the host is busy` — and the host is busy exactly when
// THIS BRIDGE is running a long verb, which is exactly when SOLIDWORKS
// stops pumping its message loop. Two "independent" signals from one cause,
// and the user's working session reported as hung. Busy is now UNKNOWN.
let mut com_timed_out: Option<bool> = None;
let com = if snap.busy {
json!({
"busy": true,
"hostPid": snap.pid,
"inFlight": snap.in_flight,
"note": "a long-running verb holds the COM host; poll swx_progress for its live frame",
})
} else {
match ctx.call("status", json!({}), 20) {
Ok(mut v) => {
com_timed_out = Some(false);
if let Value::Object(map) = &mut v {
// Post-call: the status query spawns the child if needed, so
// read the pid AFTER, not from the pre-call snapshot.
map.insert("hostPid".into(), json!(ctx.com.pid()));
map.insert("busy".into(), json!(false));
}
v
}
Err(e) => {
// Only a BUDGET OVERRUN is a timeout, and the com host says so
// itself rather than this line re-deciding it from the wording
// of a message. An UNRECOGNISED failure is UNKNOWN, not
// "did not time out": a crashed child tells us nothing either
// way about whether SOLIDWORKS is wedged.
com_timed_out = if e.timed_out { Some(true) } else { None };
json!({
"error": e.message,
"hostPid": ctx.com.pid(),
"timedOut": e.timed_out,
"note": "a com-host failure that is not a timeout says nothing about whether \
SOLIDWORKS itself is responding",
})
}
}
};
let hint = if !detection.installed && !crate::is_mock() {
TRIAL_HINT.to_string()
} else {
"swx_docs lists open documents; swx_open opens one (background). swx_bom / \
swx_mass_properties / swx_interference are the analysis verbs; swx_find_files searches \
disk without needing SOLIDWORKS open. `launch` reports any launch in flight (stage, \
route, routes already tried); `solidworks.responding` / `.privateMemoryMB` / `.handles` \
are read WITHOUT COM, so they still answer when SOLIDWORKS is wedged."
.to_string()
};
// THE HALF COM CANNOT ANSWER. `responding`, memory and handles are read
// from the OS — which is the whole point, because the moment those numbers
// matter is the moment COM has stopped replying. A com `status` that timed
// out is the COM half of the hang test; IsHungAppWindow is the other half.
let os = crate::launchos::os();
let open_docs = com.get("openDocs").and_then(|v| v.as_u64());
let com_pid = com.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);
// THE SOLIDWORKS WE ARE ATTACHED TO, for the lineage walk: with more than one
// sldworks.exe on the machine (a zombie from a crash, or the user's own
// session beside ours) the one a caller MEANS is the one COM answers for.
crate::launch::note_attached_solidworks(com_pid);
// WHICH SOLIDWORKS THE LAST LAUNCH STARTED (0.10.7), decided once it is
// attached — the claim a recycle and the quit gate rely on.
if com_pid.is_some() {
crate::launch::attribute_on_attach(os.as_ref(), com_pid);
}
let health = crate::launch::host_app_health(os.as_ref(), open_docs, com_timed_out, com_pid);
let mut solidworks = detection.to_json();
if let (Value::Object(map), Value::Object(h)) = (&mut solidworks, &health) {
for (key, v) in h {
map.insert(key.clone(), v.clone());
}
}
// THE VERSION, read at attach (0.10.0): RevisionNumber "29.5.1" is
// SOLIDWORKS 2021 — the release issue #1/#2 were filed on. Supported vs
// tested are different claims, and both are said (docs/COMPATIBILITY.md).
if let Value::Object(map) = &mut solidworks {
let revision = com.get("version").and_then(|v| v.as_str());
let v = crate::compat::describe(revision);
map.insert("majorVersion".into(), v["majorVersion"].clone());
map.insert("versionSupport".into(), v);
}
ok(json!({
"bridgeVersion": crate::BRIDGE_VERSION,
"mock": crate::is_mock(),
"userWrites": crate::writes::posture(),
// SOLIDWORKS VISUALIZE (0.9.19): running, pid, main window, and what
// it is waiting on (it is never answered by the bridge).
"visualize": crate::visualize::status(),
// TOLD WHETHER COM IS ATTACHED, because that is the fact that decides
// whether a session which came up after a sign-in may be re-claimed as
// ours — and only this verb has it.
"launch": crate::launch::report_with_attach(
os.as_ref(),
com.get("running").and_then(|v| v.as_bool()).unwrap_or(false)
|| com.get("attached").and_then(|v| v.as_bool()).unwrap_or(false),
),
// Whether the ab-delegated verbs (window info, dialogs, screenshot,
// taskbar badge, kill) can work at all. A bridge started by hand rather
// than spawned by Adom Bridge never inherits ADOM_DIRECT_API_URL, and
// every one of those verbs then fails for a reason that has nothing to
// do with SOLIDWORKS.
"abAvailable": crate::caller::ab_available(),
"solidworks": solidworks,
"com": com,
// THE MACHINE'S MEMORY (0.10.5): the commit charge, SOLIDWORKS's own
// share, and the largest holders — read-only facts. Read it before a
// long run (the solidworks-memory skill).
"memory": crate::memory::report(os.as_ref()),
// IS THE USER AT THE MACHINE? (0.10.9) Read without COM, every time, so a
// battery can check it in the same call as memory.
"userPresence": crate::presence::activity().to_json(),
// A self-test cut off by a client timeout leaves its parts open
// (measured 2026-10-15): REPORTED here, closed by the next swx_selftest.
"orphanedSelftestDocuments": crate::selftest::orphan_report(),
"windowEtiquette": "background",
"_hint": hint,
}))
}
fn swx_readiness(ctx: &Ctx, _args: &Value) -> Value {
let d = ctx.detection();
let ready = crate::is_mock() || d.installed;
let hint = format!(
"Read-only; never spawns SOLIDWORKS. {}",
if ready {
"Ready — swx_start launches the app when a verb needs it."
} else {
TRIAL_HINT
}
);
ok(json!({
"ready": ready,
"installed": d.installed,
"running": d.running,
"mock": crate::is_mock(),
"trialPending": !d.installed && !crate::is_mock(),
"_hint": hint,
}))
}
fn swx_state(ctx: &Ctx, args: &Value) -> Value {
let detection = ctx.redetect();
let mut docs = Value::Null;
let mut active = Value::Null;
let mut main_hwnd = Value::Null;
let mut busy = Value::Null;
if detection.running || crate::is_mock() {
if let Ok(r) = ctx.call("app_state", json!({}), 30) {
docs = r.get("documents").cloned().unwrap_or(Value::Null);
active = r.get("activeDoc").cloned().unwrap_or(Value::Null);
main_hwnd = r.get("mainHwnd").cloned().unwrap_or(Value::Null);
busy = r.get("busy").cloned().unwrap_or(Value::Null);
}
}
if docs.is_null() {
docs = json!([]);
}
// THE DIALOG CENSUS (0.11.1): NATIVE and DETERMINISTIC — every top-level
// window of SOLIDWORKS (and its CEF children), judged, every time, whether or
// not a screenshot is wanted. MEASURED 2026-10-15: at 19:30Z this verb said
// `blockingDialogs: []` while a File ▸ Open dialog (hwnd 205698) was up
// before and after — the census went through ab's flat window list, and was
// SKIPPED entirely (reporting a known-empty []) whenever screenshot:false.
// ab's list is the fallback only where the native one cannot be read.
let want_shot = arg_bool(args, "screenshot", true);
let hwnd_i64 = main_hwnd.as_i64();
let mut windows_error: Option<String> = None;
let os = crate::launchos::os();
let mut census_source = "native";
let mut other_windows: Vec<Value> = Vec::new();
let mut notifications: Vec<Value> = Vec::new();
// A census that finds NO SOLIDWORKS process while COM says one is running
// cannot vouch for anything: it is not "no dialogs".
let sw_seen_running = detection.running || !main_hwnd.is_null();
let dialogs: Option<Vec<Value>> = match crate::dialogs::census_windows(os.as_ref()).filter(|(_, pids)| !pids.is_empty() || !sw_seen_running) {
Some((rows, _)) => {
let judged = crate::quit::dialog_census(&rows, os.as_ref());
notifications = judged.notifications.iter().map(|d| d.report(os.as_ref())).collect();
// Owned popups that are not dialogs: listed, never judged away.
other_windows = rows.iter().filter(|w| !w.is_dialog()).map(|w| w.to_json()).collect();
Some(judged.all_blocking().iter().map(|d| d.report(os.as_ref())).collect())
}
None if detection.running || crate::is_mock() => {
census_source = "ab";
// AN UNREACHABLE ab IS NOT "NO DIALOGS": null, never [].
match crate::abwin::list_windows(Duration::from_secs(20)) {
Ok(w) => {
let sw_pid = ctx.call("app_state", json!({}), 20).ok().and_then(|s| s.get("processId").and_then(|p| p.as_i64()));
Some(crate::abwin::dialog_census(&w, hwnd_i64, sw_pid))
}
Err(e) => {
windows_error = Some(e);
None
}
}
}
None => {
census_source = "none";
windows_error = Some("the window census could not be taken".into());
None
}
};
let screenshot = if want_shot && (detection.running || crate::is_mock()) {
hwnd_i64.map(|h| crate::abwin::screenshot(h, Duration::from_secs(45))).unwrap_or(Value::Null)
} else {
Value::Null
};
ok(json!({
"running": detection.running,
"solidworks": detection.to_json(),
"openDocs": docs,
"activeDoc": active,
"activeDocument": active.get("title").cloned().unwrap_or(Value::Null),
"mainHwnd": main_hwnd,
"busy": busy,
// `null` when the desktop could not be enumerated — never `[]`.
"blockingDialogs": dialogs.clone().map(Value::from).unwrap_or(Value::Null),
"blockingDialogsKnown": dialogs.is_some(),
"blockingDialogsError": windows_error,
"dialogCensusSource": census_source,
"dialogNotifications": notifications,
"ownedWindows": other_windows,
"screenshot": screenshot,
"timings": crate::ledger::stages(),
"focusEvents": [],
"windowEtiquette": "background",
"_hint": "The one-call render-me verb: detection + open documents + the active one + \
every blocking dialog + a BACKGROUND screenshot of the SOLIDWORKS window \
(PrintWindow, so it works occluded or minimised and steals no focus) + this \
machine's measured verb timings. screenshot:false skips the ab callbacks when \
you only want the logical state. focusEvents is always empty by design — this \
bridge never takes the user's focus, so there is nothing to audit.",
}))
}
fn swx_progress(ctx: &Ctx, _args: &Value) -> Value {
ok(json!({
"active": ctx.jobs.active(),
"_hint": "Empty active[] means idle. Poll ~1/s while a slow verb runs to drive a live \
bar; each frame carries phase, stepLabel, elapsedSec and etaSec. Slow verbs \
also return their own terminal frame (percent:100) when they finish.",
}))
}
// ── verbs: app lifecycle ────────────────────────────────────────────────────
/// `swx_start`, and then (0.11.2) the launch's own mess cleared: a File ▸ Open
/// with an empty name that a launch route provably raised is cancelled here
/// too — ledgered — so a launch never leaves the session blocked.
fn swx_start(ctx: &Ctx, args: &Value) -> Value {
// A START RIGHT AFTER A QUIT waits for that session to be gone (0.11.3).
let waited = if arg_str(args, "app").map(|a| a == "visualize").unwrap_or(false) {
None
} else {
crate::launch::wait_for_previous_exit(crate::launchos::os().as_ref())
};
let mut out = swx_start_inner(ctx, args);
if let (Some(w), Value::Object(m)) = (waited, &mut out) {
m.insert("waitedForPreviousExit".into(), w);
}
let cancelled = crate::dialogs::cancel_launch_file_dialogs(crate::launchos::os().as_ref());
if !cancelled.is_empty() {
if let Value::Object(m) = &mut out {
m.insert("dialogsCancelled".into(), json!(cancelled));
}
}
out
}
fn swx_start_inner(ctx: &Ctx, args: &Value) -> Value {
// SOLIDWORKS VISUALIZE (0.9.19): its platform shortcut, its own readiness.
if arg_str(args, "app") == Some("visualize") {
return crate::visualize::start(ctx, args);
}
if let Some(other) = arg_str(args, "app").filter(|a| *a != "solidworks") {
return err_code("bad_app", format!("app {other:?} is not \"solidworks\" or \"visualize\""), "Omit app for SOLIDWORKS.");
}
gate!(ctx);
let detection = ctx.redetect();
// ATTACH FIRST. If SOLIDWORKS is already up, this is the whole verb — and on
// SOLIDWORKS Connected it is the ONLY thing that can work.
if let Ok(r) = ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 60) {
// Whatever brought SOLIDWORKS up — our own supervisor, or the user
// clicking their Start Menu while we watched — the attempt is over and
// the route that was in flight is the one that worked.
crate::launch::mark_attached();
let pid = r.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);
crate::launch::attribute_on_attach(crate::launchos::os().as_ref(), pid);
return ok_merge(
r,
json!({
"wasLaunched": false,
"attached": true,
"stillLaunching": false,
"edition": detection.edition(),
"windowEtiquette": "background",
"_hint": "Already running and COM-attached — nothing was launched. swx_open or \
swx_new_document next; swx_docs shows what is already loaded.",
}),
);
}
// Not running. Whether we may start it depends on the EDITION.
//
// LIVE FINDING (2026-09-02): on SOLIDWORKS Connected / 3DEXPERIENCE — which
// includes "Design Professional for Makers" — CoCreateInstance spawns an
// sldworks.exe that immediately refuses with "SOLIDWORKS Design must be
// launched from the 3DEXPERIENCE Platform or from the desktop shortcut
// created from the Platform" and never becomes a drivable COM server. It
// leaves a modal on the user's screen and a useless process behind. So this
// bridge does not try: it says who has to do it and why.
// THE SANCTIONED ROUTE, if this machine has one. See
// `detect::launch_route_from`: SOLIDWORKS Connected refuses a DIRECT
// sldworks.exe launch, but the shortcut the 3DEXPERIENCE Platform installed
// runs CATSTART -> SWXDesktopLauncher with that user's tenant and registry,
// and that is the launch Dassault sanctions. Our earlier "cannot be launched
// programmatically" was a conclusion about the wrong experiment.
if let crate::detect::LaunchRoute::PlatformShortcut(shortcut) = detection.launch_route() {
return start_via_shortcut(ctx, args, &detection, &shortcut);
}
if !may_launch(crate::is_mock(), &detection) {
return err_code(
"host_app_launch_blocked",
format!(
"SOLIDWORKS is not running, and this edition ({}) cannot be started \
programmatically",
detection.edition()
),
"SOLIDWORKS Connected / 3DEXPERIENCE (including Design Professional for Makers) \
refuses any launch that did not come from the 3DEXPERIENCE Platform: a COM launch \
spawns a process that puts up \"SOLIDWORKS Design must be launched from the \
3DEXPERIENCE Platform\" and never attaches. ASK THE USER to start SOLIDWORKS from \
the 3DEXPERIENCE Platform or its desktop shortcut, then retry — everything else in \
this bridge works normally once it is up. Do not try to launch sldworks.exe by \
hand; it produces exactly that modal. \
\
THIS BRIDGE CAN NORMALLY START A CONNECTED EDITION ITSELF, by invoking the \
shortcut the Platform installed (CATSTART -> SWXDesktopLauncher, carrying the \
tenant and registry it baked in). It is refusing here because it could not FIND \
that shortcut — not because the edition cannot be launched. If the user has one, \
point the bridge at it with SWX_SOLIDWORKS_SHORTCUT=<path to the .lnk> and retry; \
swx_status reports `solidworks.shortcut` and `solidworks.launchRoute` so you can see \
what it found. (Classic desktop SOLIDWORKS installs launch over COM and are not \
affected.)",
);
}
let job = ctx.jobs.start("app_start", "Starting SOLIDWORKS (cold start can take 30-90s)", 75);
let started = std::time::Instant::now();
match ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": true}), 280) {
Err(e) => sidecar_error(e),
Ok(r) => {
crate::ledger::record_time("swx_start", started.elapsed().as_secs_f64());
crate::ledger::record("solidworks_launched", json!({"edition": detection.edition()}));
ok_merge(
r,
json!({
"edition": detection.edition(),
"windowEtiquette": "background",
"progress": job.finished(),
"_hint": "SOLIDWORKS is up (COM-attached). It opens on the user's desktop; \
the bridge does not foreground it. swx_open or swx_new_document \
next, or swx_docs to see what is already loaded.",
}),
)
}
}
}
/// `swx_start` on a machine whose SOLIDWORKS is started by the Platform's own
/// shortcut — which is every SOLIDWORKS Connected machine.
///
/// GATED AS A SESSION-STARTING ACTION: a `reason` and nothing more. It runs an
/// external process on the user's desktop, so it is recorded for them; but it
/// creates no document, writes no file and destroys no work, so it does not go
/// near the write choke point. That asymmetry with `swx_stop` — which is always
/// gated — is deliberate and it is the honest one: starting SOLIDWORKS is undone
/// by closing it, whereas stopping it was, until this route existed, not undoable
/// by this bridge at all.
///
/// THIS VERB NO LONGER INVOKES THE LAUNCHER ITSELF. It hands the work to
/// [`crate::launch`], which invokes ONE route, watches the launcher's own log
/// for `Run Begin`, cleans up after a stall and tries the next route — and, most
/// importantly, is the single place a launch can start from. Four launcher
/// chains accumulated on Drew's desktop on 2026-09-04 because this verb's own
/// hint promised idempotence ("call swx_start again, which ATTACHES rather than
/// launching a second copy") that the code did not implement. It does now, and
/// the promise is kept by a persisted attempt rather than by a sentence.
fn start_via_shortcut(
ctx: &Ctx,
args: &Value,
detection: &crate::detect::Detection,
shortcut: &str,
) -> Value {
let os = crate::launchos::os();
// ── 1. IS ONE ALREADY IN FLIGHT? ───────────────────────────────────────
// Before anything else, including the reason gate: a caller polling with
// `swx_start` (which this bridge's own hint tells them to do) must never be
// refused for a missing reason and must never start a second launcher.
if let Some(a) = crate::launch::in_flight(os.as_ref()) {
return still_launching(&a, os.as_ref(), detection, "an attempt this bridge started is still running");
}
// ── 2. IS SOLIDWORKS ALREADY UP BUT UNREACHABLE? ───────────────────────
// We got here because the attach in `swx_start` failed. If sldworks.exe is
// nevertheless running, LAUNCHING AGAIN IS THE WRONG MOVE — it would put a
// second SOLIDWORKS on the user's desktop. Observed live on 2026-09-03: the
// process was alive at 17.4 GB of private memory while COM attach failed.
if crate::launch::sldworks_running(os.as_ref()) == Some(true) {
return running_but_unreachable(ctx, os.as_ref(), detection);
}
// ── 3. THE REASON GATE ─────────────────────────────────────────────────
let Some(reason) = arg_str(args, "reason") else {
return err_code(
"reason_required",
"swx_start needs reason:\"why\" to launch SOLIDWORKS on the user's desktop",
"This launches an application on somebody's machine — a window appears, and on a \
Connected edition it signs in to their 3DEXPERIENCE tenant. The reason is written \
for them: it appears in their Activity Log next to your AI thread name. Say what \
you are about to do with SOLIDWORKS, in one line. Nothing else is required — \
starting the application creates nothing and destroys nothing.",
);
};
// ── 4. BEGIN. Exactly one attempt can win this. ────────────────────────
let caller = crate::caller::current().map(|c| c.thread).unwrap_or_default();
let job = ctx.jobs.start(
"app_start",
"Starting SOLIDWORKS via the 3DEXPERIENCE launcher (cold start runs 1-3 minutes)",
180,
);
let started = std::time::Instant::now();
let channel = crate::launch::Channel { com: std::sync::Arc::clone(&ctx.com) };
let attempt = match crate::launch::begin(channel, shortcut, reason, &caller) {
Ok(a) => a,
// Lost the race with a simultaneous swx_start. That call owns the
// launch; this one reports it and launches nothing.
Err(crate::launch::BeginRefusal::InFlight(a)) => {
return still_launching(&a, os.as_ref(), detection, "another call started this launch first")
}
// BACKOFF. The last attempt failed, and a retry that immediately swept
// all four routes again is how four launcher chains ended up on
// somebody's desktop. Say so, and say for how long.
Err(crate::launch::BeginRefusal::Backoff { seconds, attempt }) => {
let mut out = err_code(
"host_app_launch_backoff",
format!(
"the last launch attempt failed; this bridge waits {seconds}s before trying \
again rather than starting more launcher chains"
),
"A retry that re-sweeps every route starts a fresh CATSTART/SWXDesktopLauncher \
chain per route, and that is how four of them accumulated on a user's desktop. \
The wait grows with each consecutive failure, and the next attempt tries ONE \
route rather than the whole plan. Meanwhile the honest move is the human one: \
ASK THE USER to start SOLIDWORKS from their Start Menu — an interactive launch \
works when these do not. swx_status.launch shows what has been tried.",
);
if let Value::Object(map) = &mut out {
map.insert("backoffSeconds".into(), json!(seconds));
map.insert("launch".into(), crate::launch::report(os.as_ref()));
if let Some(a) = attempt {
map.insert("lastOutcome".into(), json!(a.outcome));
map.insert("lastError".into(), json!(a.error));
}
}
return out;
}
};
// ── 5. WAIT, BOUNDED. ──────────────────────────────────────────────────
// The supervisor keeps running after this verb returns, so the budget here
// is a COURTESY, not the launch's lifetime. It stays short by default:
// a verb that outlives the HTTP request that called it just looks like a
// hang. `waitSeconds` lets a caller with a longer budget block instead.
let budget = std::time::Duration::from_secs(
args.get("waitSeconds").and_then(|v| v.as_u64()).unwrap_or(20).min(240),
);
let mut current = attempt;
while started.elapsed() < budget {
std::thread::sleep(std::time::Duration::from_millis(400));
current = crate::launch::attempt().unwrap_or(current);
if current.finished || current.stage == crate::launch::Stage::Attached {
break;
}
}
let attached = current.stage == crate::launch::Stage::Attached;
if attached {
crate::ledger::record_time("swx_start", started.elapsed().as_secs_f64());
crate::launch::mark_attached();
let pid = ctx
.call("app_state", json!({}), 20)
.ok()
.and_then(|r| r.get("processId").and_then(|v| v.as_u64()))
.map(|p| p as u32);
crate::launch::attribute_on_attach(os.as_ref(), pid);
}
let version = if attached {
ctx.call("start", json!({"visible": arg_bool(args, "visible", true), "launch": false}), 30)
.ok()
.and_then(|r| r.get("version").cloned())
.unwrap_or(Value::Null)
} else {
Value::Null
};
ok(json!({
"started": true,
"attached": attached,
// NOT "started:false". The launch WAS made — the application is coming
// up on the user's desktop. Reporting a failure here would invite a
// caller to launch it again, which is the one thing that must not happen.
"stillLaunching": !attached,
"wasLaunched": true,
"version": version,
"edition": detection.edition(),
"launchRoute": "platformShortcut",
"route": current.route,
"stage": current.stage.name(),
"shortcut": shortcut,
"elapsedSeconds": started.elapsed().as_secs(),
"launch": crate::launch::report(os.as_ref()),
"windowEtiquette": "background",
"progress": job.finished(),
"_hint": if attached {
"SOLIDWORKS started via the 3DEXPERIENCE launcher and is COM-attached. \
swx_new_document or swx_open next.".to_string()
} else {
format!(
"THE LAUNCH IS IN FLIGHT, not failed. Stage: {}. {} A supervisor inside this \
bridge is watching the launcher's own log; if this route stalls before \
'Run Begin' it will end ONLY the chain it started and try the next route by \
itself, so there is nothing for you to retry. Poll swx_status (its `launch` \
block carries the live stage) or call swx_start again — calling it again during \
a launch NEVER starts a second one, it reports this same attempt. Do NOT invoke \
the shortcut any other way in the meantime: two launches means two sign-in \
windows on somebody's desktop. waitSeconds:<n> (up to 240) blocks here instead \
of polling.",
current.stage.name(),
current.stage.advice(),
)
},
}))
}
/// The answer to `swx_start` while a launch this bridge started is still
/// running. `success:true` and `stillLaunching:true` — nothing failed, and the
/// one thing a caller must not do is launch again.
fn still_launching(
a: &crate::launch::Attempt,
os: &dyn crate::launchos::LaunchOs,
detection: &crate::detect::Detection,
why: &str,
) -> Value {
ok(json!({
"started": true,
"attached": false,
"stillLaunching": true,
"wasLaunched": false,
"launchedNow": false,
"reasonNotLaunched": why,
"stage": a.stage.name(),
"route": a.route,
"ageSeconds": a.age_secs(),
"edition": detection.edition(),
"launchRoute": "platformShortcut",
"shortcut": a.shortcut,
"launch": crate::launch::report(os),
"windowEtiquette": "background",
"_hint": format!(
"NOTHING WAS LAUNCHED BY THIS CALL, deliberately: {why} ({}s ago, stage '{}'). {} \
Poll swx_status — its `launch` block carries the live stage, the route being tried \
and every route already tried — or call swx_start again, which will keep answering \
like this until SOLIDWORKS attaches or every route is exhausted. Do NOT invoke the \
shortcut any other way in the meantime: two launches means two sign-in windows on \
somebody's desktop.",
a.age_secs(),
a.stage.name(),
a.stage.advice(),
),
}))
}
/// SOLIDWORKS IS RUNNING AND WE CANNOT ATTACH. Three genuinely different
/// situations, and the bridge must not blur them into "not running" — which is
/// what it said on 2026-09-03, when the honest answer was "it is alive and out
/// of memory".
fn running_but_unreachable(
ctx: &Ctx,
os: &dyn crate::launchos::LaunchOs,
detection: &crate::detect::Detection,
) -> Value {
let status = ctx.call("status", json!({}), 10);
let open_docs = status.as_ref().ok().and_then(|r| r.get("openDocs").and_then(|v| v.as_u64()));
let com_pid = status
.as_ref()
.ok()
.and_then(|r| r.get("processId").and_then(|v| v.as_u64()))
.map(|p| p as u32);
// Did a COM call EXCEED ITS BUDGET, or merely fail? Only the first is the
// COM half of the hang test.
let com_timed_out = match &status {
Ok(_) => Some(false),
// An unrecognised com failure is UNKNOWN — see swx_status.
Err(e) if e.timed_out => Some(true),
Err(_) => None,
};
let health = crate::launch::host_app_health(os, open_docs, com_timed_out, com_pid);
let hung = health.get("hung").and_then(|v| v.as_bool()).unwrap_or(false);
let exhausted = health.get("exhausted").and_then(|v| v.as_str()).map(String::from);
let mut out = if hung {
err_code(
"host_app_hung",
"SOLIDWORKS is running but not responding: Windows reports its window is hung and a \
COM call from this bridge timed out",
crate::launch::HUNG_HINT,
)
} else if let Some(why) = exhausted {
err_code(
"host_app_exhausted",
format!("SOLIDWORKS is running but out of room — {why}"),
crate::launch::EXHAUSTED_HINT,
)
} else {
err_code(
"host_app_com_unreachable",
"SOLIDWORKS is running, but it is not answering COM and this bridge will not start a \
second copy",
"sldworks.exe IS on the user's machine — so launching again would put a SECOND \
SOLIDWORKS on their desktop, which is never the right move. The usual cause on a \
Connected edition is a 3DEXPERIENCE sign-in or licence window waiting for an answer \
only the user can give: ask them to look at their screen. swx_state (with a \
background screenshot) shows what it is displaying, and swx_status.solidworks \
reports whether the window is responding at all.",
)
};
if let Value::Object(map) = &mut out {
map.insert("solidworksProcess".into(), health);
map.insert("edition".into(), json!(detection.edition()));
map.insert("launch".into(), crate::launch::report(os));
map.insert("windowEtiquette".into(), json!("background"));
}
out
}
/// EVERY PRECONDITION FACT, measured in one pass, with no COM call that could
/// block on a wedged session doing the measuring of whether it is wedged.
///
/// This exists as a struct because the sweep has to be REPEATABLE: the ladder
/// re-runs it before every rung. It was originally swept once, before the loop,
/// which meant "verified zero documents open" held for rung (a) only — a
/// document opened while rung (a) settled was still open when rung (b) fired
/// `RunCommand(swCommands_App_Exit)` at the session, and the reasoning that a
/// save prompt at a verified zero "should be impossible" depended on the promise
/// this struct now keeps.
struct StopEvidence {
open_docs: Option<u64>,
listed_docs: Option<u64>,
docs_truncated: bool,
unsaved: Option<u64>,
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>,
/// Every blocking dialog after the first (0.11.1): reported, never dropped.
more_dialogs: 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>,
census_readable: bool,
responding: Option<bool>,
host_busy: bool,
launch_busy: bool,
census: Result<Vec<crate::launchos::ProcInfo>, String>,
}
impl StopEvidence {
fn sweep(ctx: &Ctx, os: &dyn crate::launchos::LaunchOs) -> Self {
use crate::quit;
let state = ctx.call("app_state", json!({}), 30);
let get = |k: &str| state.as_ref().ok().and_then(|s| s.get(k).cloned());
let open_docs = get("documentCount").and_then(|v| v.as_u64());
let docs_truncated =
get("documentsTruncated").and_then(|v| v.as_bool()).unwrap_or(false);
// The ENUMERATION's own length, for the count cross-check. Above the cap
// it is deliberately empty and `documentsTruncated` refuses first, so it
// is only compared when the host claims to have listed everything.
let listed_docs = if docs_truncated {
None
} else {
get("documents").and_then(|v| v.as_array().map(|a| a.len() as u64))
};
let unsaved = get("unsavedDocuments").and_then(|v| v.as_u64());
let busy = get("busy").and_then(|v| v.as_bool());
let com_pid = get("processId").and_then(|v| v.as_u64()).map(|p| p as u32);
let com_hwnd = get("mainHwnd").and_then(|v| v.as_i64());
// P7: pid + creation time + main window, from the census (no COM).
let census = os.processes();
let identity = com_pid.and_then(|pid| {
let created = census
.as_ref()
.ok()
.and_then(|c| c.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))?;
Some(quit::Identity {
pid,
created_at: created,
main_hwnd: com_hwnd.or_else(|| os.main_window_of_pid(pid)),
})
});
// P3: the window census, readable with no ab and no COM — over EVERY pid
// a modal could belong to, not just sldworks.exe.
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();
let mut more_dialogs: 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;
let mut windows: Vec<crate::launchos::WindowInfo> = Vec::new();
for pid in &pids {
match os.windows_of_pid(*pid) {
Ok(w) => windows.extend(w),
// ONE unreadable pid makes the whole answer UNKNOWN: the
// dialog we cannot see is exactly the one that matters.
Err(_) => all_read = false,
}
}
dialog_pids = pids;
census_readable = all_read;
// READ WHAT IT SAYS, here too. 0.8.4 gave the SWEEP the dialog's
// own words and left the LADDER reporting a title — and the
// ladder is where a stuck session gets diagnosed. A title cannot
// 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.
// 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;
more_dialogs = judged.more_blocking;
}
}
// P5: responding, without COM.
let responding = identity
.as_ref()
.and_then(|i| i.main_hwnd)
.and_then(|h| os.window_responding(h));
Self {
open_docs,
listed_docs,
docs_truncated,
unsaved,
busy,
identity,
dialog,
notifications,
more_dialogs,
dialog_pids,
census_readable,
responding,
host_busy: ctx.com.snapshot().busy,
launch_busy: crate::launch::in_flight(os).is_some(),
census,
}
}
fn check(&self, force: bool) -> Result<(), crate::quit::Refusal> {
crate::quit::check_preconditions(
self.open_docs,
self.listed_docs,
self.docs_truncated,
self.unsaved,
self.dialog.as_ref(),
self.census_readable,
self.busy,
self.host_busy,
self.responding,
self.identity.as_ref(),
self.launch_busy,
force,
)
}
fn to_json(&self, force: bool) -> Value {
json!({
"openDocuments": self.open_docs,
"listedDocuments": self.listed_docs,
"documentsTruncated": self.docs_truncated,
"unsavedDocuments": self.unsaved,
// WHICH dialog, whose, how old, and whether a bridge verb raised it
// (0.11.1) — and whether the bridge may cancel it.
"dialog": self.dialog.as_ref().map(|d| d.report(crate::launchos::os().as_ref())),
"moreBlockingDialogs": self.more_dialogs.iter().map(|d| d.report(crate::launchos::os().as_ref())).collect::<Vec<_>>(),
"notifications": self.notifications.iter().map(|d| d.to_json()).collect::<Vec<_>>(),
"dialogCensusReadable": self.census_readable,
"dialogCensusPids": self.dialog_pids,
"busy": self.busy,
"hostBusy": self.host_busy,
"responding": self.responding,
"identity": self.identity.as_ref().map(|i| i.to_json()),
"launchInFlight": self.launch_busy,
"force": force,
})
}
}
/// The caller-facing hint for each refusal code, so the front gate and a
/// mid-ladder refusal say the same thing about the same fact.
fn stop_refusal_hint(code: &str) -> &'static str {
match code {
"open_documents_present" =>
"This bridge quits ONLY a session with no documents open, and `force` does not waive \
that — force waives the UNSAVED question only. Close the documents first (they are \
the user's; ask), or leave SOLIDWORKS running.",
"stop_blocked_by_save_prompt" =>
"A save prompt with a VERIFIED zero open documents should be impossible, so this is a \
bug to report, not a dialog to dismiss: the document count was wrong. This bridge \
never answers a save prompt — the buttons offer to write the user's work to This PC \
or to 3DEXPERIENCE, and that is their decision. Ask them to answer it.",
"stop_blocked_by_dialog" =>
"SOLIDWORKS is asking something. This bridge never answers a dialog — closing a stuck \
modal through the window manager took the whole application down on 2026-09-02. \
preconditions.dialog names it (hwnd, class, owner, ageSeconds, attribution). ONE \
exception: the standard file dialog with an EMPTY file name that a bridge verb \
provably raised (bridgeMayCancel:true) — swx_dismiss_blocking_dialogs {close:true, \
confirm:true, reason} cancels it, a no-op. Otherwise the user cancels it: swx_state \
takes a background screenshot to show them.",
"stop_preconditions_unknown" =>
"Nothing was attempted. Every precondition must be MEASURED, and an unanswerable one \
is not permission — quitting on a guess is how a session ends up half-closed with the \
user's work in it. Retry once the reading is available (swx_status reports each of \
these), or leave it running.",
"identity_mismatch" =>
"The launch record and the live COM session name DIFFERENT SOLIDWORKS processes, so \
this bridge cannot say the session it would end is the one it launched. Nothing was \
attempted. swx_status reports both; swx_start re-establishes which session it is \
talking to, and a bridge restart clears a stale claim.",
"stop_identity_changed" =>
"The SOLIDWORKS this bridge is attached to is NOT the one the ladder started against \
— a different pid, or the same pid with a different creation time. Nothing further \
was sent: every rung's success measurement is 'this exact process is gone', and it \
would now be measuring the wrong process. Call swx_status, then start over.",
_ =>
"Nothing was attempted. swx_status reports the open-document count, whether SOLIDWORKS \
is responding, and whether this bridge's com host is busy.",
}
}
/// `swx_stop` v2 — THE MEASURED LADDER (docs/QUIT-INVESTIGATION.md §5).
///
/// Preconditions verified by evidence, every COM reference released before any
/// rung, success measured by a PROCESS CENSUS rather than a COM call, and nothing
/// disowned until the process is verifiably gone. Never a kill. Never answers a
/// dialog. See `crate::quit` for the three laws and the refcount reasoning.
/// `swx_com_respawn` (0.10.6) — THE REFERENCE QUESTION, SETTLED BY MEASURING.
///
/// Measured 2026-10-14: each scratch document created and closed left
/// SOLIDWORKS ~83 MB and ~125 handles heavier, with GetDocumentCount back at
/// 0. If the BRIDGE pins something (a COM reference its host never released),
/// respawning the host releases it and the handle count drops; if not, nothing
/// changes. This respawns ONLY the bridge's own com host child — never
/// SOLIDWORKS, which it does not call at all — and refuses while a verb holds
/// the host. Ledgered.
fn swx_com_respawn(ctx: &Ctx, args: &Value) -> Value {
let Some(reason) = arg_str(args, "reason").map(str::trim).filter(|r| r.chars().count() >= 8) else {
return err_code(
"reason_required",
"swx_com_respawn needs reason:\"why\" (8+ characters)",
"It restarts the bridge's own COM host process; the reason goes into the ledger beside the \
old and new host pids. SOLIDWORKS is not touched.",
);
};
let snap = ctx.com.snapshot();
if snap.busy {
return err_code(
"com_host_busy",
format!(
"a verb is in flight on the com host ({}); swx_com_respawn never pulls the host out from under a COM call",
snap.in_flight.unwrap_or_else(|| "unknown".into())
),
"Wait for it to finish (swx_progress shows it), then call again. Nothing was changed.",
);
}
match ctx.com.respawn() {
Err(why) => err_code(
"com_host_busy",
format!("the com host could not be respawned: {why}"),
"Nothing was changed. swx_progress shows what is in flight.",
),
Ok(rep) => {
crate::ledger::record(
"com_host_respawn",
json!({"by": "swx_com_respawn", "reason": reason, "oldHostPid": rep.old_pid,
"newHostPid": rep.new_pid, "gracefulExit": rep.graceful_exit}),
);
ok(json!({
"respawned": true,
"oldHostPid": rep.old_pid,
"newHostPid": rep.new_pid,
"gracefulExit": rep.graceful_exit,
"solidworksTouched": false,
"windowEtiquette": "background",
"_hint": "The bridge's COM host was replaced. The old one left through its own loop, so its \
backend was dropped and every COM reference it held was released by Release() — \
not left to COM's ~6-minute ping rundown (gracefulExit:false means it had to be \
killed, and then the rundown applies). SOLIDWORKS was not called: the new host \
attaches on the next verb. Compare SOLIDWORKS's handle count (swx_status.memory) \
before and after: a drop means the bridge was pinning something.",
}))
}
}
}
fn swx_stop(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
use crate::quit;
let os = crate::launchos::os();
let force = arg_bool(args, "force", false);
let started = std::time::Instant::now();
// ── THE LADDER PLAN. Pinned at rung "a" by default: rungs (b)-(f) send
// window messages and run VBA inside somebody's CAD session and not one
// of them has been observed on a real machine.
// ── DREW'S STANDING AUTHORIZATION, 2026-09-11. `userAuthorized:true` says the
// human has already answered the question this verb used to refuse for:
// whether the bridge may end their SOLIDWORKS. It changes exactly two
// things and nothing else.
//
// * the ABSENCE of a launch attribution stops being a blocker — a
// session the bridge did not launch may still be quit, because the
// user said so. (The identity MISMATCH guard stays: a claim that names
// a different process still refuses, because that is not consent, it
// is a bookkeeping error.)
// * rungs (c) and (d) become available, since COM can be wedged behind a
// licence dialog on a locked desktop and those two need no COM at all.
//
// It does NOT waive: the zero-open-documents rule, the dialog rule, the
// busy rule, rung (f) without allowRestartManager, or the law that nothing
// here ever kills anything.
let user_authorized = arg_bool(args, "userAuthorized", false);
let max_rung = args
.get("maxRung")
.and_then(|v| v.as_str())
.unwrap_or(if user_authorized { "a" } else { quit::DEFAULT_MAX_RUNG })
.to_string();
if !user_authorized && matches!(max_rung.as_str(), "c" | "d") {
return err_code(
"rung_needs_user_authorization",
format!(
"rung '{max_rung}' sends a window message into the user's SOLIDWORKS, which needs their say-so: pass userAuthorized:true with a reason"
),
"The window-message rungs act on the application's own frame rather than through COM. They are available when the user has authorized the bridge to close SOLIDWORKS — `userAuthorized:true` — which is also what makes them useful, because a session wedged behind a licence dialog cannot be reached through COM at all.",
);
}
let plan = quit::rungs_up_to(&max_rung);
if plan.is_empty() {
return err_code(
"unknown_rung",
format!("maxRung:\"{max_rung}\" is not a rung of the ladder"),
"Rungs are \"a\" (ExitApp) through \"f\" (Restart Manager, opt-in). The default is \
\"a\" until the live quit gate has run; see docs/QUIT-INVESTIGATION.md §5.3.",
);
}
let allow_rm = arg_bool(args, "allowRestartManager", false);
// ── IS THERE ANYTHING TO STOP? A clear answer for the commonest case, so
// "nothing is running" never arrives dressed as an unmeasurable
// precondition. The com host's own word, preserved.
if let Ok(st) = ctx.call("status", json!({}), 20) {
if st.get("running").and_then(|v| v.as_bool()) == Some(false) {
return ok_merge(
json!({
"stopped": false,
"reason": "not running",
"success": false,
"errorCode": "host_app_not_running",
"error": "SOLIDWORKS is not running, so there is nothing to stop",
"rungsTried": [],
}),
json!({
"windowEtiquette": "background",
"_hint": "Nothing was attempted and nothing needed to be: the com host reports \
no attached SOLIDWORKS. swx_status confirms it. If a SOLIDWORKS \
window IS on the user's screen, this bridge cannot see it — that is \
an ATTACH problem, not a quit problem, and swx_start reports which.",
"_reportIssues": report_issues(),
}),
);
}
}
// ── PRECONDITION EVIDENCE, measured now, never inherited from the caller,
// and re-measured before EVERY rung further down.
let ev = StopEvidence::sweep(ctx, os.as_ref());
let census0 = ev.census.clone();
// ── THE LAUNCH'S CLAIM MUST BE ABOUT THE SESSION COM ANSWERS FOR.
// Re-swept with everything else below, because this verb re-measures
// before every rung and an attribution that was true at rung (a) is not
// automatically true at rung (b).
if let Err(refusal) =
quit::identity_matches_attribution(crate::launch::attributed_solidworks(), ev.identity.as_ref())
.and_then(|_| ev.check(force))
{
// A REFUSAL IS RECORDED. v1 wrote no ledger line at its front gate, so
// "we tried to quit and were told no" was invisible afterwards.
crate::ledger::record(
"solidworks_quit_refused",
json!({"code": refusal.code(), "why": refusal.message(), "maxRung": max_rung}),
);
let mut out =
err_code(refusal.code(), refusal.message(), stop_refusal_hint(refusal.code()));
if let Value::Object(m) = &mut out {
m.insert("preconditions".into(), ev.to_json(force));
m.insert("rungsTried".into(), json!([]));
m.insert("windowEtiquette".into(), json!("background"));
}
return out;
}
let id = ev.identity.clone().expect("checked by the precondition sweep");
// ── P6: the process-level opt-in, a confirm and a human reason. Unchanged.
let grant = match crate::writes::authorize_in(
ctx,
args,
"swx_stop",
crate::writes::Kind::EndSession,
None,
None,
) {
Ok(g) => g,
Err(e) => {
crate::ledger::record(
"solidworks_quit_refused",
json!({"code": "user_writes_disabled", "why": "the write opt-in was not granted"}),
);
return e;
}
};
let job = ctx.jobs.start("app_stop", "Asking SOLIDWORKS to quit (measured ladder)", 60);
let census_before = census0.as_ref().ok().cloned().unwrap_or_default();
if user_authorized {
crate::ledger::record(
"solidworks_quit_authorized",
json!({
"authority": "quit under Drew's standing authorization (2026-09-11)",
"reason": arg_str(args, "reason"),
"maxRung": max_rung,
}),
);
}
crate::ledger::record(
"solidworks_quit_started",
json!({
"identity": id.to_json(),
"plan": plan,
"maxRung": max_rung,
"allowRestartManager": allow_rm,
"reason": arg_str(args, "reason"),
}),
);
let mut tries: Vec<quit::RungTry> = Vec::new();
let mut winning: Option<&'static str> = None;
let mut blocked: Option<quit::BlockingDialog> = None;
// DURING STARTUP (adversary pass 42): the dispatch gate let this quit through
// only because the user authorized it and the plan reaches a window-message
// rung. The COM rungs are not sent to a SOLIDWORKS that has not finished
// starting; (c) and (d) act on the main frame and need neither.
let startup_not_verified = arg_bool(args, "_startupNotVerified", false);
for rung in plan.iter() {
if startup_not_verified && matches!(*rung, "a" | "b" | "e") {
tries.push(quit::RungTry {
rung,
action: quit::rung_action(rung),
result: quit::RungResult::Skipped,
process_gone: None,
elapsed_sec: 0.0,
new_windows: 0,
dialog: None,
children_alive: Vec::new(),
survivors_alive: Vec::new(),
unjudged_alive: Vec::new(),
census_readable: false,
detail: Some(
"SOLIDWORKS has not finished starting (or the start cannot be verified), so \
this COM rung was not sent; the window-message rungs need no COM"
.into(),
),
host_error: None,
primed: false,
});
continue;
}
// Rung (f) is opt-in: ENDSESSION_CLOSEAPP means "save automatically
// without prompting", so it is the one rung whose failure mode is a
// silent write rather than a dialog we can stop on.
if *rung == "f" && !allow_rm {
tries.push(quit::RungTry {
rung,
action: quit::rung_action(rung),
result: quit::RungResult::Skipped,
process_gone: None,
elapsed_sec: 0.0,
new_windows: 0,
dialog: None,
children_alive: Vec::new(),
survivors_alive: Vec::new(),
unjudged_alive: Vec::new(),
// Nothing was measured because nothing was sent.
census_readable: false,
detail: Some(
"the Restart Manager rung needs allowRestartManager:true — its shutdown \
request tells applications to save WITHOUT prompting, so it is never taken \
implicitly"
.into(),
),
host_error: None,
primed: false,
});
continue;
}
// ── RE-MEASURE EVERY PRECONDITION, BEFORE EVERY RUNG. Not once before
// the loop: a rung takes seconds to settle, and a document opened in
// that gap was still open when the NEXT rung fired at the session.
// `force` is passed unchanged, so it still waives the unsaved question
// only.
if !tries.is_empty() {
let now = StopEvidence::sweep(ctx, os.as_ref());
// The same process, still? A different pid — or the same pid with a
// different creation time — means the ladder would be measuring
// something it never acted on.
let same = now.identity.as_ref().map(|i| i.pid == id.pid && i.created_at == id.created_at);
let claim = crate::launch::attributed_solidworks();
let refusal = match quit::identity_matches_attribution(claim, now.identity.as_ref())
.and_then(|_| now.check(force))
{
Err(r) => Some(r),
Ok(()) if same == Some(false) => Some(quit::Refusal::Known {
code: "stop_identity_changed",
message: "the SOLIDWORKS this bridge is attached to is no longer the process \
the ladder started against"
.into(),
}),
Ok(()) => None,
};
if let Some(refusal) = refusal {
crate::ledger::record(
"solidworks_quit_refused",
json!({
"code": refusal.code(),
"why": refusal.message(),
"maxRung": max_rung,
"afterRungs": tries.len(),
"midLadder": true,
}),
);
let mut out = err_code(
refusal.code(),
format!("{} (measured after rung '{}')", refusal.message(), tries.last().map(|t| t.rung).unwrap_or("?")),
stop_refusal_hint(refusal.code()),
);
if let Value::Object(m) = &mut out {
m.insert("preconditions".into(), now.to_json(force));
m.insert(
"rungsTried".into(),
json!(tries.iter().map(|t| t.to_json()).collect::<Vec<_>>()),
);
m.insert("stopped".into(), json!(false));
m.insert("stillRunning".into(), json!(quit::process_gone(now.census.as_ref(), &id).map(|g| !g)));
m.insert("identity".into(), id.to_json());
m.insert("maxRung".into(), json!(max_rung));
m.insert("windowEtiquette".into(), json!("background"));
m.insert("refusedMidLadder".into(), json!(true));
}
let _ = job.finished();
return out;
}
}
let mut attempt = run_rung(ctx, os.as_ref(), &id, rung);
// ── PRIME A NEVER-HAD-A-DOCUMENT SESSION, THEN ASK AGAIN. MEASURED
// 2026-10-01 (pid 20144): rung (a) was declined three times on a
// session that had never held a document, and quit in 3.4 s once one
// scratch part had been created and closed in it. So on rung (a) only,
// under the user's authorization, on an EMPTY session with no dialog,
// whose history says it never held a document (or cannot say): create
// one bridge-owned scratch part, close it, and ExitApp once more.
if *rung == "a"
&& attempt.result == quit::RungResult::Declined
&& attempt.dialog.is_none()
&& user_authorized
{
let presence_overridden = arg_bool(args, "userPresentOverride", false);
match prime_session(ctx, os.as_ref(), &id, presence_overridden) {
Ok(prime) => {
crate::ledger::record(
"solidworks_quit_rung",
json!({"rung": rung, "result": attempt.result.name(), "processGone": attempt.process_gone}),
);
tries.push(attempt);
attempt = run_rung(ctx, os.as_ref(), &id, rung);
attempt.primed = true;
attempt.detail = Some(format!(
"{} — after PRIMING the session (one bridge-owned scratch part created and \
closed: {})",
attempt.detail.clone().unwrap_or_default(),
prime
));
}
Err(why) => {
attempt.detail = Some(format!(
"{} — not primed: {why}",
attempt.detail.clone().unwrap_or_default()
));
}
}
}
let result = attempt.result;
let new_dialog = attempt.dialog.clone();
let gone = attempt.process_gone;
tries.push(attempt);
crate::ledger::record(
"solidworks_quit_rung",
json!({"rung": rung, "result": result.name(), "processGone": gone}),
);
if result == quit::RungResult::Quit {
winning = Some(rung);
break;
}
if result == quit::RungResult::DialogAppeared {
blocked = new_dialog;
break;
}
if result == quit::RungResult::UipiBlocked {
break;
}
}
let census_after = os.processes();
let stopped = winning.is_some();
// v1 shipped `stillRunning`, and it is the field a caller reads to answer
// "is it gone?" without parsing the ladder. Keep it — MEASURED from the
// final census, and null (never false) when the census could not be read,
// because an unreadable census is not proof of an exit.
let still_running = quit::process_gone(census_after.as_ref(), &id).map(|gone| !gone);
// NOTHING IS DISOWNED UNTIL THE PROCESS IS VERIFIABLY GONE. v1 cleared the
// registry before it verified, so a declined quit left the bridge unable to
// touch its own documents.
if stopped {
ctx.scratch_docs.lock().unwrap_or_else(|e| e.into_inner()).clear();
crate::owned::clear();
crate::ledger::record(
"solidworks_stopped",
json!({"rung": winning, "authorized": grant.user_write, "identity": id.to_json()}),
);
// THE QUIT IS REMEMBERED (0.11.3): a start right after it waits for this
// session's process tree to be gone, and the performance log's state now
// — no SOLIDWORKS writing it — bounds what the NEXT session may claim.
crate::perflog::record_baseline("quit", None);
let _ = std::fs::write(
crate::scratch::root().join("last-quit.json"),
json!({"pid": id.pid, "createdAt": id.created_at, "atMs": crate::launchos::epoch_ms() as u64}).to_string(),
);
}
let rungs_json: Vec<Value> = tries.iter().map(|t| t.to_json()).collect();
let census_json = |c: &Result<Vec<crate::launchos::ProcInfo>, String>| -> Value {
match c {
Ok(v) => json!(v.iter().map(|p| p.to_json()).collect::<Vec<_>>()),
Err(e) => json!({"error": e}),
}
};
let body = json!({
"stopped": stopped,
"winningRung": winning,
"rungsTried": rungs_json,
"maxRung": max_rung,
"elapsedSeconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0,
"identity": id.to_json(),
// The sweep that let the FIRST rung run. Each rung's own re-sweep is
// what gated the rungs after it; a mid-ladder refusal returns its own.
"preconditions": ev.to_json(force),
"forceWaivedUnsavedOnly": force,
"processCensusBefore": census_before.iter().map(|p| p.to_json()).collect::<Vec<_>>(),
"processCensusAfter": census_json(&census_after),
"expectedSurvivorsAreNotAFailure": quit::EXPECTED_SURVIVORS,
"stillRunning": still_running,
"blockingDialog": blocked.as_ref().map(|d| d.to_json()),
"windowEtiquette": "background",
"progress": job.finished(),
});
if stopped {
return ok_merge(body, json!({
"_hint": format!(
"SOLIDWORKS quit on rung '{}' ({}), verified by process census — the pid and its \
creation time are gone from the machine, not merely absent from the COM running \
object table. Per-session children reaped; `expectedSurvivorsAlive` lists the \
platform daemons that survive a graceful exit BY DESIGN (ENOPLMCSAClient, \
EdmServerV6, 3DEXPERIENCELauncher*) — their presence is consent, not a failure, \
and nothing should ever kill them. On a Connected edition starting it again takes \
1-3 minutes through swx_start.",
winning.unwrap_or("?"),
quit::rung_action(winning.unwrap_or("a")),
),
}));
}
let mut out = body;
if let Value::Object(m) = &mut out {
m.insert("success".into(), json!(false));
m.insert(
"errorCode".into(),
json!(match blocked.as_ref() {
Some(d) => d.error_code(),
None => "host_app_stop_declined",
}),
);
m.insert(
"error".into(),
json!(match blocked.as_ref() {
Some(d) => format!(
"the quit stopped at a dialog SOLIDWORKS raised ({}): \"{}\" — this bridge \
never answers one",
d.kind, d.title
),
None => format!(
"SOLIDWORKS did not quit; {} rung(s) were tried and measured",
tries.len()
),
}),
);
m.insert(
"_hint".into(),
json!(match blocked.as_ref() {
Some(d) => quit::blocked_hint(d),
None => quit::declined_hint(tries.len(), &max_rung),
}),
);
m.insert("_reportIssues".into(), json!(report_issues()));
}
out
}
fn swx_docs(ctx: &Ctx, _args: &Value) -> Value {
gate!(ctx);
match ctx.call("docs", json!({}), 30) {
Err(e) => sidecar_error(e),
// The com host already degraded above the cap and set `truncated`, so
// this never blocks the full 30s on a huge count. When it truncated,
// the host's own `_hint` (the drain guidance) is the right one and must
// not be overwritten — ok_merge lets extras win, so pass none.
Ok(r) => {
if r.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false) {
ok_merge(r, json!({}))
} else {
ok_merge(
r,
json!({
"_hint": "Pass any doc's `path` to the analysis verbs to target it \
without activating it — no window changes, nothing steals \
focus. `count` is authoritative (GetDocumentCount); if it is \
ever much larger than the list, run swx_close_import_temps.",
}),
)
}
}
}
}
// ── verbs: documents ────────────────────────────────────────────────────────
/// ONE RUNG OF THE QUIT LADDER, sent and MEASURED: release every reference, ask,
/// release again, then poll the process census up to the rung's budget for the
/// two answers that end the ladder (gone, or a dialog). Never a COM call to
/// measure. The caller records it.
fn run_rung(
ctx: &Ctx,
os: &dyn crate::launchos::LaunchOs,
id: &crate::quit::Identity,
rung: &'static str,
) -> crate::quit::RungTry {
use crate::quit;
let rung_started = std::time::Instant::now();
// RELEASE EVERY REFERENCE FIRST. Rung (a) also ends by releasing; this
// makes rungs (b)-(f) START released, which is the ladder-wide invariant.
let _ = ctx.call("release_com", json!({}), 20);
let asked = ctx.call(
"quit_rung",
json!({
"rung": rung,
"mainHwnd": id.main_hwnd,
"processId": id.pid,
"processCreatedAt": id.created_at,
}),
quit::rung_budget_secs(rung) + 20,
);
let (host_word, detail, host_error) = match &asked {
Ok(r) => (
r.get("result").and_then(|v| v.as_str()).unwrap_or("").to_string(),
r.get("detail").and_then(|v| v.as_str()).map(String::from),
None,
),
// A rung that WORKED tears the COM server down underneath the call
// that asked for it, so a transport failure here is not a verdict —
// the census below decides.
Err(e) => (String::new(), None, Some(e.message.clone())),
};
// ── MEASURE, and never with a COM call. Release again first so nothing
// this process holds can keep the app alive while we ask.
let _ = ctx.call("release_com", json!({}), 10);
let refs = ctx
.call("com_refs", json!({}), 10)
.ok()
.and_then(|r| r.get("outstanding").and_then(|v| v.as_u64()))
.unwrap_or(0);
// ── WAIT FOR THE EXIT, don't sample once for it. `ExitApp` RETURNS and
// SOLIDWORKS then takes seconds to tear a session down (10 CEF
// subprocesses, three out-of-process servers, the journal). A single
// reading 1.5 s later called that "declined" — and the ladder's
// response to a decline is to escalate, sending a window message into
// an application that was already exiting. So poll at the settle
// interval up to THIS rung's own budget, and stop early on the two
// answers that end the ladder: gone, or a dialog on screen.
let interval = std::time::Duration::from_millis(
std::env::var("SWX_QUIT_SETTLE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500),
);
// How long to WAIT for the exit: this rung's own budget, because that is
// the longest the rung is allowed to take at all. SWX_QUIT_EXIT_WAIT_SECS
// shortens it — the suite and the gate both drive many rungs and neither
// needs a real half-minute to watch a simulated process go.
let wait_secs = std::env::var("SWX_QUIT_EXIT_WAIT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or_else(|| quit::rung_budget_secs(rung));
let wait_until = std::time::Instant::now() + std::time::Duration::from_secs(wait_secs);
let mut census;
let mut gone;
let mut after_windows;
let mut new_dialog;
let mut polls = 0u32;
loop {
std::thread::sleep(interval);
polls += 1;
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::dialog_census(w, os).blocking);
if gone == Some(true) || new_dialog.is_some() || std::time::Instant::now() >= wait_until
{
break;
}
}
let (children, survivors, unjudged) =
census.as_ref().ok().map(|c| quit::reap_report(c)).unwrap_or_default();
let new_windows = after_windows
.as_ref()
.ok()
.map(|w| w.iter().filter(|x| x.is_dialog() && x.visible).count())
.unwrap_or(0);
let mut result = match gone {
Some(true) => quit::RungResult::Quit,
Some(false) => quit::result_from_host(&host_word),
// A rung that cannot be MEASURED is a rung that failed.
None => quit::RungResult::Unmeasured,
};
if result != quit::RungResult::Quit && new_dialog.is_some() {
result = quit::RungResult::DialogAppeared;
}
let mut detail = detail;
if polls > 1 {
detail = Some(format!(
"{} — the exit was measured over {polls} census reads (a rung returns before the \
process is gone)",
detail.unwrap_or_default()
));
}
if refs > 0 {
detail = Some(format!(
"{} — WARNING: {refs} COM reference(s) were still outstanding at the measurement",
detail.unwrap_or_default()
));
}
quit::RungTry {
rung,
action: quit::rung_action(rung),
result,
process_gone: gone,
elapsed_sec: rung_started.elapsed().as_secs_f64(),
new_windows,
dialog: new_dialog.clone(),
children_alive: children,
survivors_alive: survivors,
unjudged_alive: unjudged,
census_readable: census.is_ok(),
detail,
host_error,
primed: false,
}
}
/// PRIME A SESSION THAT HAS NEVER HELD A DOCUMENT: one bridge-owned scratch part,
/// created and closed through the ordinary owned-document path (default posture,
/// registered, ledgered `quit_prime`). Refused — `Err` with the reason — unless
/// the session is EMPTY, NO dialog is up, and its history says it never held a
/// document or cannot say. Never with a document open: priming is only ever done
/// to a session nobody is working in.
fn prime_session(
ctx: &Ctx,
os: &dyn crate::launchos::LaunchOs,
id: &crate::quit::Identity,
presence_overridden: bool,
) -> Result<String, String> {
use crate::quit;
// THE SESSION'S HISTORY IS ADVISORY — reported, never a guard (0.9.6, P1).
// Live 2026-10-01 (pid 29108, never driven, no document ever opened): a
// startup poll recorded everHadDocument:true, the guard refused to prime, and
// the quit was declined twice. The trigger is the measured condition itself:
// rung (a) declined, the session empty (re-checked below), no blocking
// dialog, userAuthorized — once per swx_stop call.
let ever = crate::session_history::ever_had_document(id.pid, Some(id.created_at));
// NEVER PRIME A SESSION THE USER IS IN (0.9.9): presence is measured again
// here — the front gate's reading is a ladder rung old.
if !presence_overridden {
if let Some((true, evidence)) = crate::presence::measure(ctx) {
return Err(format!(
"the user is working in this session ({}) — the bridge does not create and close a \
document in it",
evidence.get("basis").and_then(|b| b.as_str()).unwrap_or("present")
));
}
}
let count = ctx
.call("app_state", json!({}), 20)
.ok()
.and_then(|s| s.get("documentCount").and_then(|v| v.as_u64()));
if count != Some(0) {
return Err(format!(
"the session is not provably empty (documents open: {}) — a session somebody may \
be working in is never primed",
count.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())
));
}
match os.windows_of_pid(id.pid) {
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}")),
}
let made = crate::verbs3::new_document(ctx, &json!({"type": "part"}));
if made.get("success").and_then(|v| v.as_bool()) != Some(true) {
return Err(format!("the scratch part could not be created: {}", made.get("error").cloned().unwrap_or(Value::Null)));
}
let title = made.pointer("/created/title").and_then(|v| v.as_str()).unwrap_or("").to_string();
let scratch_path = made
.pointer("/created/path")
.or_else(|| made.pointer("/documentIdentity/path"))
.and_then(|v| v.as_str())
.map(String::from);
crate::session_history::mark(id.pid, Some(id.created_at));
let closed = crate::verbs3::reset_workspace(ctx, &json!({"_presenceMeasuredByCaller": true}));
let after = ctx
.call("app_state", json!({}), 20)
.ok()
.and_then(|s| s.get("documentCount").and_then(|v| v.as_u64()));
crate::ledger::record(
"quit_prime",
json!({"created": title, "closed": closed.get("closed").cloned().unwrap_or(Value::Null),
"documentCountAfter": after, "everHadDocumentBefore": ever,
"why": "ExitApp was declined on an empty session that had never held a document \
(measured 2026-10-01: such a session quits once one has been opened and \
closed)"}),
);
if after != Some(0) {
// WHOSE DOCUMENT IS STILL OPEN? If the sweep did not report closing the
// scratch part, it is the bridge's own, and the caller is told where it
// is and how to drain it (adversary pass 42, S6).
let closed_it = closed
.get("closed")
.and_then(|v| v.as_array())
.map(|a| a.iter().any(|t| t.as_str() == Some(title.as_str())))
.unwrap_or(false);
let still = ctx
.call("app_state", json!({}), 20)
.ok()
.and_then(|s| s.get("documents").cloned())
.and_then(|d| d.as_array().cloned())
.map(|docs| {
docs.iter().any(|d| {
d.get("title").and_then(|t| t.as_str()) == Some(title.as_str())
|| (scratch_path.is_some()
&& d.get("path").and_then(|p| p.as_str()) == scratch_path.as_deref())
})
});
let ours_open = still.unwrap_or(!closed_it);
return Err(if ours_open {
format!(
"the scratch part '{title}' was created but could NOT be closed — the bridge's own \
part is still open at {} (documents open: {}). Drain it: swx_reset_workspace closes \
only bridge-created documents (or swx_close_doc with that path). ExitApp is not \
asked again",
scratch_path.as_deref().unwrap_or("(no path reported)"),
after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())
)
} else {
format!(
"the scratch part '{title}' was created and closed, but the session is not empty \
afterwards (documents open: {}) — somebody else's document; ExitApp is not asked again",
after.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into())
)
});
}
Ok(format!("'{title}'"))
}
fn swx_open(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let path = match arg_str(args, "path") {
Some(p) => p.to_string(),
None => {
return err(
"path is required",
"Pass the .sldprt/.sldasm/.slddrw path. swx_find_files locates candidates.",
)
}
};
let job = ctx.jobs.start(
"open",
format!("Opening {}", crate::comhost::mock::base_name(&path)),
30,
);
// Whether opening a document may START SOLIDWORKS is the same decision
// swx_start makes, and it is made in the same place.
let may = may_launch(crate::is_mock(), &ctx.detection());
// Ask the COM host whether it is running, not `detection` — detection is a
// process scan, which is always false under the mock, and this branch is
// exactly the one that must be reachable from a test.
let running = ctx
.call("status", json!({}), 20)
.ok()
.and_then(|s| s.get("running").and_then(|r| r.as_bool()))
.unwrap_or(false);
if !running && !may {
return err_code(
"host_app_launch_blocked",
"SOLIDWORKS is not running, and this edition cannot be started programmatically",
"swx_open will not launch it: on SOLIDWORKS Connected / 3DEXPERIENCE a programmatic \
launch spawns a process that refuses with \"SOLIDWORKS Design must be launched from \
the 3DEXPERIENCE Platform\" and never attaches, leaving a modal on the user's \
screen. ASK THE USER to start SOLIDWORKS from the 3DEXPERIENCE Platform or its \
desktop shortcut, then retry. Do not try to launch sldworks.exe by hand.",
);
}
let read_only = arg_bool(args, "readOnly", true);
// readOnly:false takes a WRITE LOCK on the user's file and converts-and-
// dirties an older-version document in memory. That is a write, and it goes
// through the same choke point as every other one.
let mut open_grant = crate::writes::Grant { host_args: json!({}), user_write: false };
if !read_only {
let (session, _open, _active, _truncated) = crate::verbs3::ownership_context(ctx);
let owned = ctx.owns_doc("", Some(&path), session);
let target = crate::verbs::Target {
title: crate::comhost::mock::base_name(&path).to_string(),
path: Some(path.clone()),
owned,
};
open_grant = match crate::writes::authorize_in(
ctx,
args,
"swx_open {readOnly:false}",
crate::writes::Kind::ModifyDocument,
Some(&target),
None,
) {
Ok(g) => g,
Err(e) => return e,
};
}
let mut call_args = crate::writes::with_grant(json!({}), &open_grant);
if let Value::Object(m) = &mut call_args {
m.insert("path".into(), Value::from(path.clone()));
m.insert("configuration".into(), args.get("configuration").cloned().unwrap_or(Value::Null));
m.insert("launch".into(), Value::Bool(may));
m.insert("readOnly".into(), Value::Bool(read_only));
}
let session_before = crate::verbs3::session_identity(ctx);
match ctx.call("open", call_args, 280) {
Err(e) => sidecar_error(e),
Ok(r) => {
// THE EFFECT (0.11.3): the document is open in the SAME, LIVE session.
// A SOLIDWORKS that was not running before the open (a classic
// install the open launched) is read as the session now answering.
let before = session_before.or_else(|| crate::verbs3::session_identity(ctx));
let opened_path = r.pointer("/doc/path").and_then(|p| p.as_str()).filter(|p| !p.is_empty()).map(String::from).unwrap_or_else(|| path.clone());
let opened_title = r.pointer("/doc/title").and_then(|t| t.as_str()).map(String::from);
let effect = match crate::verbs3::verify_effect(ctx, "swx_open", before, opened_title.as_deref(), Some(&opened_path)) {
Ok(v) => v,
Err(e) => return e,
};
ok_merge(
r,
json!({
"effect": effect,
"windowEtiquette": "background",
"progress": job.finished(),
"_hint": "Open and active, READ-ONLY by default — so the bridge does not hold a \
write lock on the user's file, and an older-version document is not \
converted-and-dirtied in memory just by being looked at. Pass \
readOnly:false if you intend to modify it (which also needs the write \
opt-in unless the bridge created it). swx_bom (assemblies), swx_mass_properties, \
swx_props_get, or swx_export next. NOTE: this verb does NOT start \
SOLIDWORKS on the 3DEXPERIENCE / Connected editions — it cannot, and \
answers host_app_launch_blocked instead. On a classic desktop install \
it will launch it, and the progress frame reports what that cost.",
}),
)
}
}
}
/// Is the target document dirty? `None` means WE DO NOT KNOW.
///
/// The distinction is the point. The previous version collapsed both "clean"
/// and "the COM call failed" into `false` with two `unwrap_or(false)`s, so a
/// SOLIDWORKS that would not answer produced a confident "nothing to lose here"
/// and the close proceeded unconfirmed. Unknown dirtiness is treated as DIRTY
/// by the caller.
fn doc_is_dirty(ctx: &Ctx, target: &crate::verbs::Target) -> Option<bool> {
let docs = ctx.call("docs", json!({}), 30).ok()?;
let list = docs.get("docs")?.as_array()?;
let found = list.iter().find(|x| {
x.get("title").and_then(|t| t.as_str()).map(|t| same_title(t, &target.title)).unwrap_or(false)
&& x.get("path").and_then(|p| p.as_str()).filter(|s| !s.is_empty()).map(String::from)
== target.path
})?;
found.get("dirty")?.as_bool()
}
fn swx_close_doc(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
// `name` is this verb's Fusion-parity spelling for "the document, by title".
// Feed it to the SAME resolver every other document verb uses, so an
// ambiguous title is fatal here too and the com host is told exactly which
// document to close — by PATH where there is one.
let mut resolve_args = args.clone();
if let Value::Object(m) = &mut resolve_args {
if let Some(n) = args.get("name").cloned() {
m.entry("targetDocument").or_insert(n);
}
}
let target = match crate::verbs3::resolve_document(ctx, &resolve_args, "swx_close_doc") {
Ok(t) => t,
Err(e) => return e,
};
let save = arg_bool(args, "save", false);
let confirmed = arg_bool(args, "confirm", false) && arg_str(args, "reason").is_some();
let dirty = doc_is_dirty(ctx, &target);
// TWO ways this verb can touch a document that is not ours, and BOTH need
// permission. The first was missed for five rounds:
//
// * `save:true` WRITES THE USER'S FILE. It is byte-for-byte the same
// `Save3` call that `swx_save` makes — and `swx_save` is gated. So
// `swx_props_set` (ungated by design) followed by
// `swx_close_doc {save:true}` modified their file on disk with no
// confirmation, no reason and no audit trail. That falsified the exact
// invariant both the docs and props_set's own `_hint` used to justify
// leaving props_set open: "nothing reaches the user's file until the
// save does". It only holds if EVERY persist route is gated.
// * `save:false` on a dirty document DISCARDS their work.
// Closing a document the bridge did not create is a change to the user's
// session — their window, their undo history — even when the document is
// clean. Gated at the same choke point so the refusal comes from here with
// a useful message rather than from the com host's terser veto.
//
// AND THE GRANT MUST REACH THE HOST. The result of this authorization used
// to be DISCARDED — only the `save:true` branch produced a grant, so
// `close_doc {save:false}` on a foreign document was authorized by the
// server and then sent to the com host with no evidence of it. The host,
// holding its own independent veto, refused. Live on 2026-09-03: Drew
// authorized closing his own leftover documents, every argument was
// correct, and the bridge still answered "this document was not created by
// this bridge". The server has to TELL the host what the human allowed.
let mut grant = crate::writes::Grant::none();
if !target.owned {
match crate::writes::authorize_in(
ctx,
args,
"swx_close_doc",
crate::writes::Kind::CloseDocument,
Some(&target),
None,
) {
Ok(g) => grant.merge(g),
Err(e) => return e,
}
}
// save:true WRITES THE USER'S FILE — byte for byte the same `Save3` that
// swx_save makes. It goes through the same choke point, and the union of
// both grants is what the host is told.
if save {
match crate::writes::authorize_in(ctx, args, "swx_close_doc {save:true}", crate::writes::Kind::SaveInPlace, Some(&target), None) {
Ok(g) => grant.merge(g),
Err(e) => return e,
}
}
// save:false on a document that is not ours DISCARDS work. Unknown
// dirtiness is treated as dirty: an unanswerable question is not permission.
if !save && !target.owned && dirty != Some(false) && !confirmed {
let known = dirty == Some(true);
return err_code(
"unsaved_documents",
format!(
"'{}' {} and this bridge did not create it",
target.describe(),
if known {
"has unsaved changes"
} else {
"may have unsaved changes (SOLIDWORKS would not say)"
}
),
"Closing it now would throw that work away. swx_save first, or pass confirm:true \
WITH a reason if the user has said the changes are disposable. When SOLIDWORKS \
cannot be asked whether a document is dirty, this verb assumes it IS. Documents \
this bridge created (swx_new_document / swx_import) close without asking.",
);
}
let mut call_args = crate::writes::with_grant(target.args(), &grant);
if let Value::Object(m) = &mut call_args {
m.insert("save".into(), Value::Bool(save));
}
match ctx.call("close_doc", call_args, 60) {
Err(e) => sidecar_error(e),
Ok(r) => {
// A scratch document that is gone is no longer ours to sweep.
ctx.forget_scratch_doc(&target.title, target.path.as_deref());
// THE AUDIT TRAIL. Both docs promised one here and there was none —
// a document promising an accountability record that does not exist
// is worse than one that promises nothing.
crate::ledger::record(
"document_closed",
json!({
"title": target.title,
"path": target.path,
"saved": save,
"bridgeCreated": target.owned,
"by": "close_doc",
}),
);
if !target.owned && save {
crate::ledger::record(
"user_document_saved",
json!({
"document": target.title,
"path": target.path,
"via": "swx_close_doc{save:true}",
"reason": arg_str(args, "reason"),
}),
);
}
if !target.owned && !save && dirty != Some(false) {
crate::ledger::record(
"user_document_changes_discarded",
json!({
"document": target.title,
"path": target.path,
"dirtyKnown": dirty.is_some(),
"reason": arg_str(args, "reason"),
}),
);
}
ok_merge(
r,
json!({
"document": target.title,
"path": target.path,
"saved": save,
"bridgeCreatedDocument": target.owned,
"_hint": "Target it with `path` (unambiguous) or `name` (its title — NOT \
unique, so a title matching two open documents is refused with \
ambiguous_target); with neither, the ACTIVE document is closed. On \
a document this bridge did not create: save:true WRITES their file \
and needs confirm:true + reason (errorCode not_bridge_document), \
and save:false on one with unsaved changes needs the same \
(errorCode unsaved_documents). Every close is recorded in \
swx_action_ledger.",
}),
)
}
}
}
fn swx_save(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
// THE PERSIST STEP. swx_props_set stays ungated — it writes a named
// metadata field the caller spelled out, in memory, and annotating the
// user's parts with PartNo/Material is this bridge's core BOM workflow. But
// that argument only holds while nothing can commit it to their file behind
// their back, and this verb is what commits it. So the write to disk is
// where the gate belongs.
//
// This tightens a v0.2 contract. It is a deliberate break, in the safe
// direction: a caller that means it passes confirm:true and a reason, and
// the change is recorded in swx_action_ledger.
let target = match crate::verbs3::resolve_document(ctx, args, "swx_save") {
Ok(t) => t,
Err(e) => return e,
};
let grant = match crate::writes::authorize_in(ctx, args, "swx_save", crate::writes::Kind::SaveInPlace, Some(&target), None) {
Ok(g) => g,
Err(e) => return e,
};
let started = std::time::Instant::now();
match ctx.call("save", crate::writes::with_grant(target.args(), &grant), 110) {
Err(e) => sidecar_error(e),
Ok(r) => {
crate::ledger::record_time("swx_save", started.elapsed().as_secs_f64());
if !target.owned {
crate::ledger::record(
"user_document_saved",
json!({"document": target.title, "path": target.path}),
);
}
ok_merge(
r,
json!({
"document": target.title,
"bridgeCreatedDocument": target.owned,
"_hint": "Saved in place. errors/warnings are SOLIDWORKS' own save codes — 0 \
means clean. This is the step that makes a swx_props_set permanent, \
which is why saving a document the bridge did not create needs \
confirm:true and a reason.",
}),
)
}
}
}
const EXPORT_FORMATS: &[(&str, &str)] = &[
("step", ".step"),
("stp", ".step"),
("iges", ".igs"),
("igs", ".igs"),
("stl", ".stl"),
("pdf", ".pdf"),
("dxf", ".dxf"),
("dwg", ".dwg"),
("png", ".png"),
("jpg", ".jpg"),
("parasolid", ".x_t"),
// Added in v0.3 after exporting each one from a real solid on a live
// SOLIDWORKS Design Professional for Makers 2026 and checking the bytes.
("x_t", ".x_t"),
("x_b", ".x_b"),
("sat", ".sat"),
("3mf", ".3mf"),
("glb", ".glb"),
("ply", ".ply"),
("amf", ".amf"),
("3dxml", ".3dxml"),
("tif", ".tif"),
("tiff", ".tif"),
("jpeg", ".jpg"),
("xaml", ".xaml"),
// OBJ and U3D are listed NOT because they work — a live part refused both
// with swFileSaveError_e 256 — but so the caller gets SOLIDWORKS' own,
// accurate refusal ("format not supported for this document type") instead
// of this bridge's generic "unsupported format", which would be a different
// and less useful claim. Same reasoning as DXF/DWG, which are refused for a
// part and expected to work from a drawing.
("obj", ".obj"),
("u3d", ".u3d"),
];
/// Destination extension for a requested format name.
pub fn export_extension(format: &str) -> Option<&'static str> {
let f = format.trim().trim_start_matches('.').to_lowercase();
EXPORT_FORMATS.iter().find(|(name, _)| *name == f).map(|(_, ext)| *ext)
}
fn swx_export(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let fmt = arg_str(args, "format").unwrap_or("step").trim().trim_start_matches('.').to_lowercase();
// The `format` argument is checked for the friendly error, but it is NOT the
// guard — SaveAs3 picks the format off the DESTINATION EXTENSION, so
// `swx_export_step {outputPath:"x.wrl"}` would otherwise sail straight past
// a check on `format` (and the per-format aliases hard-code `format`, so for
// them it could never fire at all). The real guard is on `out`, below.
if crate::verbs3::BLOCKED_FORMATS.contains(&fmt.as_str()) {
return crate::verbs3::blocked_path_error(&format!("format:{fmt}"), &fmt);
}
let ext = match export_extension(&fmt) {
Some(e) => e,
None => {
let known: Vec<&str> = EXPORT_FORMATS.iter().map(|(n, _)| *n).collect();
let mut e = err(
format!("unsupported format: {fmt}"),
format!("Formats this bridge knows: {}.", known.join(", ")),
);
if let Value::Object(m) = &mut e {
m.insert("formats".into(), crate::verbs3::export_format_note());
}
return e;
}
};
let out = match arg_str(args, "outputPath").or_else(|| arg_str(args, "out")) {
Some(o) => match safe_out(o) {
Ok(p) => p,
Err(e) => return e,
},
None => {
let stem = crate::comhost::mock::file_stem(arg_str(args, "path").unwrap_or("export"));
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
crate::scratch::exports_dir()
.join(format!("{stem}-{stamp}{ext}"))
.to_string_lossy()
.to_string()
}
};
// THE guard: whatever `out` ends in is what SOLIDWORKS will try to write.
if let Some(bad) = crate::verbs3::blocked_by_extension(&out) {
return crate::verbs3::blocked_path_error(&out, bad);
}
// The extension actually written is `out`'s, which may differ from the
// requested format name (e.g. format:"stp" -> .step) — echo the real one.
let out_ext = std::path::Path::new(&out)
.extension()
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
.unwrap_or_else(|| ext.to_string());
crate::scratch::prune();
// Copy mode, so this does not change the document — but it must still
// EXPORT the document the caller named. `targetDocument` used to be
// accepted and ignored here, which meant the file on disk could come from a
// different model than the caller believed.
let target = match crate::verbs3::resolve_document(ctx, args, "swx_export") {
Ok(t) => t,
Err(e) => return e,
};
let grant = match crate::writes::authorize_in(ctx, args, "swx_export", crate::writes::Kind::ExportCopy, Some(&target), Some(&out)) {
Ok(g) => g,
Err(e) => return e,
};
let mut call_args = crate::writes::with_grant(target.args(), &grant);
if let Value::Object(m) = &mut call_args {
m.insert("out".into(), Value::from(out.clone()));
}
let job = ctx.jobs.start("export", format!("Exporting {}", fmt.to_uppercase()), 45);
match ctx.call("export", call_args, 280) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"format": fmt,
"document": target.title,
"outputExtension": out_ext,
"formats": crate::verbs3::export_format_note(),
"progress": job.finished(),
"_hint": "Export written on the DESKTOP filesystem. From a cloud container, pull \
the bytes back with ab's file verbs (pull_file). Pass `out` to choose \
the path; without it the file lands in the bridge's own exports \
folder, which is pruned after 48h.",
}),
),
}
}
fn swx_pack_and_go(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let out = match arg_str(args, "out") {
Some(o) => match safe_out(o) {
Ok(p) => p,
Err(e) => return e,
},
None => {
return err(
"out (destination directory) is required",
"Pack and Go copies the doc plus every reference into `out` so the set is \
portable — that is the verb for handing a design to someone else.",
)
}
};
// Pack and Go writes MANY files into `out`. A destination that already has
// content is refused for the same reason a single existing file is.
let target = match crate::verbs3::resolve_document(ctx, args, "swx_pack_and_go") {
Ok(t) => t,
Err(e) => return e,
};
// M1: the server passed `destination:None` for an empty/absent directory,
// which skipped the OWNERSHIP question entirely — so it allowed Pack and Go
// on any document, while the real host refused it. The verb was dead on a
// real machine and green in the suite. Both sides now ask the same question:
// the document must be ours, and an occupied destination is an overwrite.
// The two vetoes must ask the SAME question. The server asked "is the
// directory non-empty?" while the host asked `Path::exists()`, so a
// bridge-owned assembly into an existing EMPTY directory was granted by one
// and refused by the other: fail-closed, but the verb was dead for its most
// natural case and the two guards were answering different things. Both now
// use existence, which is also the rule every other destination follows.
let grant = match crate::writes::authorize_in(
ctx,
args,
"swx_pack_and_go",
crate::writes::Kind::CollectFiles,
Some(&target),
Some(out.as_str()),
) {
Ok(g) => g,
Err(e) => return e,
};
let job = ctx.jobs.start("pack_and_go", "Collecting references (Pack and Go)", 90);
let mut call_args = crate::writes::with_grant(target.args(), &grant);
if let Value::Object(m) = &mut call_args {
m.insert("out".into(), Value::from(out.clone()));
m.insert("includeDrawings".into(), Value::Bool(arg_bool(args, "includeDrawings", false)));
}
match ctx.call("pack_and_go", call_args, 580) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"progress": job.finished(),
"_hint": "Statuses of 0 mean saved OK. The folder now holds a self-contained \
copy of the design. includeDrawings:true pulls the drawings in too.",
}),
),
}
}
// ── verbs: properties ───────────────────────────────────────────────────────
fn swx_props_get(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let target = match crate::verbs3::resolve_document(ctx, args, "swx_props_get") {
Ok(t) => t,
Err(e) => return e,
};
let mut call_args = target.args();
if let Value::Object(m) = &mut call_args {
m.insert("configuration".into(), args.get("configuration").cloned().unwrap_or_else(|| Value::from("")));
}
match ctx.call("props_get", call_args, 60) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"_hint": "File-level custom properties (the '' configuration) drive BOMs and \
title blocks. Pass configuration:<name> for config-specific ones; \
`configurations` lists what this document has.",
}),
),
}
}
fn swx_props_set(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let props = match args.get("properties").and_then(|p| p.as_object()) {
Some(p) if !p.is_empty() => p.clone(),
_ => {
return err(
"properties (object of name -> value) is required",
r#"e.g. {"properties": {"PartNo": "BRK-100", "Material": "6061-T6"}}"#,
)
}
};
// props_set is not ownership-gated (see swx_save), but it MUST act on the
// document the caller named rather than falling through to ActiveDoc — the
// same resolution every document verb shares.
//
// It uses the READ resolver deliberately. An earlier version called the
// gated one with a confirm and a reason THE BRIDGE WROTE FOR ITSELF
// ("swx_props_set is in-memory until swx_save, which is gated"), which put
// a self-authored justification into the user's audit trail — and one that
// a close_doc{save:true} bypass then made false. A bridge does not get to
// enter its own consent into the record.
let target = match crate::verbs3::resolve_document(ctx, args, "swx_props_set") {
Ok(t) => t,
Err(e) => return e,
};
// C2: "in memory until a GATED save" ignored the persist route that is not
// gated and never could be — THE USER. This leaves their document dirty, so
// SOLIDWORKS prompts "Save changes?" on close and their own Ctrl+S writes
// bridge-authored data into their file. Modifying their document is the
// harm, whoever eventually commits it.
let grant = match crate::writes::authorize_in(
ctx,
args,
"swx_props_set",
crate::writes::Kind::ModifyDocument,
Some(&target),
None,
) {
Ok(g) => g,
Err(e) => return e,
};
if !target.owned {
crate::ledger::record(
"user_document_properties_set",
json!({
"document": target.title,
"path": target.path,
"properties": props.keys().cloned().collect::<Vec<_>>(),
"note": "in memory only; swx_save or swx_close_doc{save:true} would persist it, \
and both are gated",
}),
);
}
let mut call_args = crate::writes::with_grant(target.args(), &grant);
if let Value::Object(m) = &mut call_args {
m.insert("configuration".into(), args.get("configuration").cloned().unwrap_or_else(|| Value::from("")));
m.insert("properties".into(), Value::Object(props));
}
match ctx.call("props_set", call_args, 60) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"_hint": "Properties are set in MEMORY — call swx_save to persist them to the \
file, or the next close discards them. That is also why this verb is \
not ownership-gated and swx_save is: nothing here reaches the user's \
file until the save does.",
}),
),
}
}
// ── verbs: analysis ─────────────────────────────────────────────────────────
fn swx_bom(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
// A BOM off the WRONG same-titled part is a bill of materials somebody
// orders from — the exact harm the resolver exists to prevent, and this verb
// was bypassing it.
let target = match crate::verbs3::resolve_document(ctx, args, "swx_bom") {
Ok(t) => t,
Err(e) => return e,
};
let mut call_args = target.args();
if let Value::Object(m) = &mut call_args {
m.insert("topLevelOnly".into(), Value::Bool(arg_bool(args, "topLevelOnly", false)));
}
let (result, progress) = {
let job = ctx.jobs.start("bom", "Walking assembly components", 60);
match ctx.call("bom", call_args, 280) {
Err(e) => return sidecar_error(e),
Ok(r) => (r, job.finished()),
}
};
let mut rows: Vec<Value> =
result.get("rows").and_then(|r| r.as_array()).cloned().unwrap_or_default();
crate::bom::sort_rows(&mut rows);
let columns = crate::bom::prop_columns(&rows);
let mut out = Map::new();
out.insert("success".into(), Value::Bool(true));
out.insert("doc".into(), result.get("doc").cloned().unwrap_or(Value::Null));
out.insert("topLevelOnly".into(), result.get("topLevelOnly").cloned().unwrap_or(json!(false)));
out.insert("rowCount".into(), json!(rows.len()));
out.insert("rows".into(), Value::Array(rows.clone()));
out.insert("progress".into(), progress);
if arg_str(args, "format").unwrap_or("json").to_lowercase() == "csv" {
let content = crate::bom::to_csv(&rows, &columns);
out.insert("csv".into(), Value::from(content.clone()));
let csv_path = match arg_str(args, "out") {
Some(p) => match safe_out(p) {
Ok(p) => p,
Err(e) => return e,
},
None => {
let doc_path = result
.get("doc")
.and_then(|d| d.get("path"))
.and_then(|p| p.as_str())
.unwrap_or("bom");
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
crate::scratch::exports_dir()
.join(format!("{}-bom-{stamp}.csv", crate::comhost::mock::file_stem(doc_path)))
.to_string_lossy()
.to_string()
}
};
// C1: this write is done by the SERVER (`std::fs::write`), so neither
// the com host's veto nor `safe_out` — which only checks for an absolute
// path with no `..` — stands between it and any file on disk. It
// overwrote an existing file and reported success. It is the same
// destination rule as every other write, so it goes through the same
// choke point.
if let Err(e) = crate::writes::authorize_in(
ctx,
args,
"swx_bom {format:\"csv\"}",
crate::writes::Kind::ExportCopy,
None,
Some(&csv_path),
) {
return e;
}
crate::scratch::prune();
match std::fs::write(&csv_path, content.as_bytes()) {
Ok(()) => {
out.insert("csvPath".into(), Value::from(csv_path));
}
Err(e) => {
out.insert("csvWriteError".into(), Value::from(e.to_string()));
}
}
}
out.insert(
"_hint".into(),
Value::from(
"Quantities aggregate resolved component instances per (file, configuration); \
suppressed components are excluded. format:'csv' adds `csv` (the text) and \
`csvPath` (a file on the DESKTOP disk), with one column per custom property found \
anywhere in the tree. topLevelOnly:true gives the top-level-only view; the default \
is the full flattened tree.",
),
);
Value::Object(out)
}
fn swx_mass_properties(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let target = match crate::verbs3::resolve_document(ctx, args, "swx_mass_properties") {
Ok(t) => t,
Err(e) => return e,
};
let mut call_args = target.args();
if let Value::Object(m) = &mut call_args {
m.insert("useSystemUnits".into(), Value::Bool(arg_bool(args, "useSystemUnits", true)));
}
match ctx.call("mass_properties", call_args, 110) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"_hint": "SI units: kg, m^3, m^2, m. Mass is only as good as the material \
assignment — a 0 or default density means no material is set on the \
model, so report the density alongside the mass.",
}),
),
}
}
fn swx_interference(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let job = ctx.jobs.start("interference", "Running interference detection", 90);
let target = match crate::verbs3::resolve_document(ctx, args, "swx_interference") {
Ok(t) => t,
Err(e) => return e,
};
let mut call_args = target.args();
if let Value::Object(m) = &mut call_args {
m.insert("coincidenceCounts".into(), Value::Bool(arg_bool(args, "coincidenceCounts", false)));
}
match ctx.call("interference", call_args, 580) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"progress": job.finished(),
"_hint": "READ `count` CAREFULLY, and never with a truthiness check. \
count==0 means NO INTERFERENCES at the components' CURRENT positions \
— a snapshot, not a motion study. count==null means SOLIDWORKS would \
NOT SAY: the enumeration failed, and that is NOT a clear result. \
`null` is falsy in every language this bridge's callers use, so \
`if (!r.count)` and `if not r[\"count\"]` both turn 'I do not know' \
into 'all clear' — on a clearance report somebody builds a physical \
part from. Branch on `countKnown` (or `count === 0`) instead, and \
re-run the check when it is false. `listed` is how many interferences \
came back in the list; a count that disagrees with it is refused \
outright as `interference_enumeration_failed`. coincidenceCounts:true \
also flags touching faces (usually noise on a real assembly).",
}),
),
}
}
fn swx_rebuild_check(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let job = ctx.jobs.start("rebuild", "Force-rebuilding and scanning features", 45);
let target = match crate::verbs3::resolve_document(ctx, args, "swx_rebuild_check") {
Ok(t) => t,
Err(e) => return e,
};
// C3: this calls `ForceRebuild3`, which re-solves every feature and dirties
// the document — and which this bridge's OWN com_eval hazard list names as
// "rebuilds the user's model". A first-class verb doing freely what the
// escape hatch refuses is not a defensible split.
let grant = match crate::writes::authorize_in(
ctx,
args,
"swx_rebuild_check",
crate::writes::Kind::ModifyDocument,
Some(&target),
None,
) {
Ok(g) => g,
Err(e) => return e,
};
match ctx.call("rebuild_check", crate::writes::with_grant(target.args(), &grant), 280) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"progress": job.finished(),
"_hint": "errorCount==0 → the model rebuilds clean. featureErrors names the \
broken features — this is the design-health first check, and worth \
running before any BOM or mass number is quoted.",
}),
),
}
}
// ── verbs: files on disk ────────────────────────────────────────────────────
fn swx_find_files(_ctx: &Ctx, args: &Value) -> Value {
let roots: Vec<String> = match args.get("roots").or_else(|| args.get("root")) {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(a)) => {
a.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
}
_ => Vec::new(),
};
let roots = if roots.is_empty() { default_roots() } else { roots };
// `types` accepts an array OR a bare string ("assembly"), matching Python's
// acceptance; a lone string becomes a one-element list.
let types: Option<Vec<String>> = match args.get("types") {
Some(Value::String(s)) => Some(vec![s.clone()]),
Some(Value::Array(a)) => {
Some(a.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
}
_ => None,
};
let want = crate::findfiles::wanted_extensions(types.as_deref());
let query = arg_str(args, "query").unwrap_or("");
// `limit` accepts a number OR a numeric string ("50"), like Python's int().
let limit = args
.get("limit")
.and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.unwrap_or(200)
.max(1) as usize;
let (hits, truncated) =
crate::findfiles::search(&roots, &want, query, limit, Duration::from_secs(150));
ok(json!({
"count": hits.len(),
"truncated": truncated,
"roots": roots,
"files": hits.iter().map(|h| h.to_json()).collect::<Vec<_>>(),
"_hint": "Newest first. Pass roots:[...] to search specific folders, query:'name' to \
filter, types:['assembly'] to narrow (part/assembly/drawing). ~$ lock files \
are skipped. truncated:true means the limit or the 150s budget cut the walk \
short — narrow the roots. swx_open takes any of these paths, and this verb \
works with SOLIDWORKS closed or not installed.",
}))
}
pub(crate) fn default_roots() -> Vec<String> {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
let home = std::path::Path::new(&home);
vec![
home.join("Documents").to_string_lossy().to_string(),
home.join("Desktop").to_string_lossy().to_string(),
]
}
// ── verbs: escape hatch, screenshot, uninstall, describe ────────────────────
fn swx_screenshot(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
// A free-text `titleContains` forwarded straight to ab made this verb a
// general desktop screenshot tool: `{"titleContains":"1Password"}` captured
// whatever matched. It is catalogued as "screenshot the SOLIDWORKS window",
// it is not gated, and a screenshot is the user's data.
//
// So: capture BY HANDLE, using the hwnd COM reports, which is the only
// window this verb has any business looking at. An explicit `hwnd` is
// honoured but must be one attributable to SOLIDWORKS.
let (main, sw_pid) = crate::verbs3::window_identity_for(ctx);
let want = args.get("hwnd").and_then(|v| v.as_i64());
let hwnd = match want {
Some(h) => {
let ours = crate::abwin::list_windows(Duration::from_secs(20))
.map(|all| crate::abwin::solidworks_windows(&all, main, sw_pid))
.unwrap_or_default()
.into_iter()
.any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h));
if !ours && Some(h) != main {
return err_code(
"window_not_attributed",
format!("hwnd {h} cannot be proved to belong to SOLIDWORKS"),
"This verb captures SOLIDWORKS' own windows. swx_window_info lists them with \
their handles. For anything else on the desktop, ab's own \
desktop_screenshot_window is the right tool and carries the user's approval \
prompt.",
);
}
Some(h)
}
None => main,
};
let Some(hwnd) = hwnd else {
return err_code(
"no_solidworks_window",
"SOLIDWORKS' window handle is not available",
"COM reports the main window handle; with SOLIDWORKS closed there is nothing to \
capture. swx_status says whether it is running.",
);
};
let r = crate::caller::ad_callback(
"desktop_screenshot_window",
json!({"hwnd": hwnd}),
Duration::from_secs(30),
);
// ab's response drives success. A present-but-null `error` is NOT a failure
// (many verbs stamp "error":null on success), so only a non-null, non-empty
// error counts against it.
let has_error = matches!(r.get("error"), Some(Value::String(s)) if !s.is_empty());
let ok_flag = r.get("success").and_then(|v| v.as_bool()).unwrap_or(true) && !has_error;
json!({
"success": ok_flag,
"hwnd": hwnd,
"ab": r,
"_hint": "Delegates to ab's generic desktop_screenshot_window, BY HANDLE — the handle COM \
reports for SOLIDWORKS, or an explicit `hwnd` that swx_window_info attributes \
to it. It captures that window and the dialogs it owns, and nothing else on \
the desktop. SOLIDWORKS must be running; swx_state tells you whether it is.",
})
}
fn swx_com_eval(ctx: &Ctx, args: &Value) -> Value {
gate!(ctx);
let expr = match arg_str(args, "expr") {
Some(e) => e.to_string(),
None => {
return err(
"expr is required",
"A dotted call chain rooted at $sw (the SldWorks.Application) or $doc (the \
active document), e.g. \"$sw.RevisionNumber()\" or \
\"$doc.Extension.NeedsRebuild2()\". Arguments may be quoted strings, numbers \
or true/false.",
)
}
};
// THE SIXTH ROAD. `$doc.SaveAs3("C:\\x.wrl",0,1)` reaches the COM member
// directly, skipping the guarded `save_as3` helper and every rule above it:
// the blocked-extension check, Copy mode, and the ownership gate. It is the
// exact call that hung SOLIDWORKS for 110 seconds. So the escape hatch is
// for PROTOTYPING READS — a mutation belongs in a real verb, where the
// guards live.
if let Some((bad, why)) = crate::comhost::expr::refused_member(&expr) {
return err_code(
"eval_member_blocked",
format!("swx_com_eval will not call '{bad}': {why}"),
"This verb evaluates READS ONLY — an allowlist, not a list of banned members. \
Anything beginning Get/Is/Has/Can/Enum is allowed, plus a short list of navigation \
and scalar properties; everything else is refused, INCLUDING members nobody has \
thought about yet. That is deliberate: a denylist has to anticipate every dangerous \
member of an API with thousands, and the first version missed SendMsgToUser2 (a \
modal on the user's screen that wedges the host), SetUserPreferenceToggle and \
ForceRebuild3. Reaching a write here would also bypass every guard the real verbs \
enforce — the blocked-extension check, Copy mode, and the ownership gate. If you \
have proved a mutation is right, it belongs in a verb; if a genuine READ is \
refused, add it to expr.rs READ_MEMBERS. File either on the bridge's wiki page.",
);
}
if arg_str(args, "reason").is_none() {
return err_code(
"reason_required",
"swx_com_eval drives the live SOLIDWORKS COM object directly; pass reason:\"why\"",
"This verb exists to prototype new capabilities against a real install before they \
become real verbs. The reason is self-asserted and used for the log, not for \
authorization.",
);
}
match ctx.call("eval", json!({"expr": expr}), 110) {
Err(e) => sidecar_error(e),
Ok(r) => ok_merge(
r,
json!({
"_hint": "Late-bound IDispatch call chain, evaluated on the com host. If what \
you just proved out is useful, it belongs in a real verb — file it on \
the bridge's wiki page.",
}),
),
}
}
fn swx_uninstall(ctx: &Ctx, args: &Value) -> Value {
// shutdown() is contention-safe (kills by pid if a long verb holds the
// lock), so the cascade cannot hang here.
ctx.com.shutdown();
let mut removed: Vec<String> = vec!["com host process".to_string()];
// ONLY the bridge's own bookkeeping and exports subtree, best-effort per
// item — never the data root itself, never its recovery folders, never the
// ledger.
let (mut artifacts, failed) = crate::scratch::remove_artifacts();
removed.append(&mut artifacts);
json!({
"success": failed.is_empty(),
"removed": removed,
"failed": failed,
"cascade": arg_bool(args, "cascade", false),
"ledgerPreserved": true,
// A recovery file can be the ONLY copy of work the user had not saved.
"recoveryPreserved": true,
"dataRoot": crate::scratch::root().to_string_lossy(),
"_hint": "Idempotent, non-interactive, best-effort. This removes the bridge's own \
bookkeeping and its exports subtree, and nothing else: the data root itself, \
ab's install cache, SOLIDWORKS, its settings and every user document are \
never touched. A file still locked open is reported in `failed`, not fought \
over. The RECOVERY FOLDERS are DELIBERATELY KEPT — a rescued file can be the \
only copy of work the user had not saved — and so is swx_action_ledger's log: \
this verb is ungated (ab calls it on app uninstall), so deleting the audit \
trail here would let a caller erase the record of what it did by asking the \
bridge to tidy up. Removing `dataRoot` is the user's to do.",
})
}
// ── the catalog ─────────────────────────────────────────────────────────────
/// (verb, risk, summary). The one place the surface is declared — `swx_describe`
/// renders it, dispatch routes from it, and the test suite checks it against
/// bridge.json in both directions.
pub const CATALOG: &[(&str, &str, &str)] = &[
("swx_bom", "write", "assembly BOM: quantities per (file, configuration) + custom props; json or csv (csv writes a file)"),
("swx_close_doc", "exec", "close a document (save:true to save first; a dirty document the bridge did not create needs confirm:true)"),
("swx_com_eval", "exec", "maintainer escape hatch: a late-bound COM call chain on the live app"),
("swx_describe", "read", "this catalog"),
("swx_docs", "read", "list open documents"),
("swx_export", "write", "export by format name or destination extension: STEP/IGES/STL/3MF/GLB/SAT/X_T/X_B/PLY/AMF/3DXML/PDF/PNG/JPG/TIF (DXF/DWG from drawings; VRML refused)"),
("swx_find_files", "read", "search disk for .sldprt/.sldasm/.slddrw (works without SOLIDWORKS)"),
("swx_interference", "read", "assembly interference detection"),
("swx_mass_properties", "read", "mass/volume/area/CoM/density (SI units)"),
("swx_open", "exec", "open a document silently (part/assembly/drawing)"),
("swx_pack_and_go", "write", "copy a design + all references into a folder"),
("swx_progress", "read", "active[] progress blocks for in-flight slow verbs"),
("swx_props_get", "read", "read custom properties (file or configuration scope)"),
("swx_props_set", "write", "write custom properties (swx_save to persist)"),
("swx_readiness", "read", "read-only ready check; never spawns anything"),
("swx_rebuild_check", "exec", "force rebuild + broken-feature census (design health)"),
("swx_save", "write", "save the target document"),
("swx_screenshot", "read", "screenshot the SOLIDWORKS window via ab's generic verb"),
("swx_start", "process", "launch SOLIDWORKS via COM (background; cold start 30-90s); app:\"visualize\" starts SOLIDWORKS Visualize through its platform shortcut and reports what it is waiting on"),
("swx_state", "read", "open docs + active doc + detection (logical state of the union)"),
("swx_status", "read", "bridge + SOLIDWORKS detection + COM state (the everyday probe)"),
("swx_stop", "process", "quit SOLIDWORKS"),
("swx_uninstall", "write", "remove the bridge's own artifacts (ab calls this on app uninstall)"),
// ── v0.3: the Fusion-parity surface ─────────────────────────────────────
("swx_new_document", "exec", "create a NEW part/assembly/drawing from the stock template"),
("swx_import", "exec", "import STEP/IGES/Parasolid/STL as a new document"),
("swx_save_as", "write", "save the document to a new path; the default RENAMES the open document, so one the bridge did not create needs confirm:true"),
("swx_get_app_state", "read", "running + active document + documents + workspace + busy, in one call"),
("swx_document_info", "read", "metadata for one document (name, type, saved, configurations)"),
("swx_activate_document", "exec", "make an open document the active one"),
("swx_close_all_documents", "exec", "close EVERY open document (confirm:true; skips unsaved by default)"),
("swx_reset_workspace", "exec", "close only the scratch documents this bridge created"),
("swx_close_import_temps", "exec", "drain leaked STEP/IGES import temporaries (…\\Temp\\swx<pid>\\IC~~\\…); default posture, never a saved user doc"),
("swx_close_all_safely", "exec", "save EVERY open document into a recovery folder, then close it — nothing closes before it is on disk; needs only a reason"),
("swx_shutdown", "exec", "close_all_safely, then quit SOLIDWORKS; idempotent, and the quit is skipped unless the session really emptied"),
("swx_recover_list", "read", "what previous sweeps preserved, and where — newest first"),
("swx_sign_in", "exec", "sign in to 3DEXPERIENCE for a launch this bridge started (or, with userAuthorized:true, any 3DEXPERIENCE ID login window, e.g. Visualize's or the platform updater's, recognized by its content; window:visualize|solidworks|desktopUpdate|<hwnd> picks one, and a named hwnd is probed by content, never refused on census grounds), from the user's local vault (the password never leaves the laptop or reaches a reply); the page is prepared first (user name, Continue), and ab's policy (sign_in_vault_policy, measured) currently keeps the password for the user to type; fails closed and never retries a rejected password"),
("swx_user_presence", "read", "is the user working in this SOLIDWORKS? foreground + last input + documents the bridge did not open (the guard every quit and sweep passes)"),
("swx_kill", "process", "force-kill SOLIDWORKS (reason + confirm; discards unsaved work)"),
("swx_com_respawn", "process", "respawn ONLY the bridge's own COM host child (reason): every COM reference the bridge holds is released; SOLIDWORKS is never called; refused while a verb is in flight"),
("swx_window_info", "read", "SOLIDWORKS' windows and its main hwnd (read-only, nothing raised)"),
("swx_dismiss_blocking_dialogs", "exec", "census of blocking SOLIDWORKS dialogs (native: every window of the process, with where each came from); close:true is opt-in, gated, and never touches an unattributed window — the one dialog it may CANCEL without the write opt-in is a standard file dialog with an empty name that a bridge verb provably raised"),
("swx_close_window", "exec", "close one window by hwnd (reason + confirm)"),
("swx_check_recovery", "read", "is SOLIDWORKS showing the Document Recovery dialog?"),
("swx_action_ledger", "read", "append-only record of what the bridge did in the background"),
("swx_verb_times", "read", "measured per-verb timings on THIS machine (count/p50/p90)"),
("swx_set_driving_badge", "write", "sticky taskbar badge showing an AI is driving SOLIDWORKS"),
("swx_design_info", "read", "features/sketches/bodies/components/material/rebuild state"),
("swx_get_parameters", "read", "global variables + named model dimensions"),
("swx_set_parameter", "write", "drive one named dimension (valueMm or value) and rebuild"),
("swx_inspect_bodies", "read", "per-body geometry read-back in millimetres (bbox, volume, area, faces)"),
("swx_run_macro", "exec", "run a SOLIDWORKS VBA macro (.swp/.swb/.dll) — arbitrary code, so it needs the write opt-in every time"),
("swx_run_script", "exec", "the modeling DSL in millimetres: new_part, sketch (rect/circle/line/polyline/arc/arc3/tangentArc/slot/polygon, sketch fillets and chamfers, construction geometry — closed contours checked before anything is drawn — on a named plane, an offset plane {offsetFrom, offsetMm}, a face {atPointMm} or a plane offset from a face), sketch_rect/sketch_circle (anchor corner|center), extrude/cut (reverse, direction one|both|midPlane, throughAll, upToNext, upToSurface, offsetFromSurface, draftDeg), revolve/revolve_cut, mirror (features or bodies), hole (plain, counterbore, countersink), fillet, chamfer, shell, linear_pattern, circular_pattern, set_appearance (rgb), set_material (densityKgM3), set_prop, save_as; a feature SOLIDWORKS returns null is an error (refuses to build into a document the bridge did not create)"),
("swx_selftest", "exec", "LONG-RUNNING: call it with responseMode:\"stream\" or \"job\" — level write runs 5-8 minutes, and a client's default 120 s budget cuts it off (a cut-off run's parts are closed by the next swx_selftest, and swx_status lists them). One JSON that says, verb by verb, what works on THIS SOLIDWORKS install: level:\"read\" (default) changes nothing; level:\"write\" also builds, reads back, exports, saves and CLOSES a scratch part in the bridge's own scratch folder, one modelling op per call — how a user on a release we have not run (e.g. SOLIDWORKS 2021) gets first-class support; level:\"cswa\" builds the CSWA benchmark parts shipped in resources/cswa, weighs each with swx_mass_properties and compares it with the published key (\"N of M CSWA parts within tolerance\")"),
("swx_add_component", "write", "insert part/assembly files as components of an assembly, positioned in mm (the verb swx_bom needs)"),
("swx_get_preferences", "read", "the small set of preferences this bridge can round-trip"),
("swx_set_preference", "write", "write one preference and READ IT BACK to verify"),
("swx_cloud_status", "read", "3DEXPERIENCE cloud reachability (spoiler: no COM route exists)"),
("swx_show_part", "exec", "open + activate + zoom-to-fit a part, in the background"),
("swx_show_assembly", "exec", "open + activate + zoom-to-fit an assembly, in the background"),
("swx_show_drawing", "exec", "open + activate + zoom-to-fit a drawing, in the background"),
("swx_export_step", "write", "export STEP (fusion_export_step parity: outputPath)"),
("swx_export_iges", "write", "export IGES"),
("swx_export_stl", "write", "export STL mesh"),
("swx_export_3mf", "write", "export 3MF"),
("swx_export_glb", "write", "export GLB (binary glTF)"),
("swx_export_sat", "write", "export ACIS SAT"),
("swx_export_parasolid", "write", "export Parasolid (.x_t)"),
("swx_export_pdf", "write", "export PDF (3D PDF from a part; a sheet from a drawing)"),
("swx_export_dxf", "write", "export DXF (drawings; refused for a part on this edition)"),
("swx_export_dwg", "write", "export DWG (drawings; refused for a part on this edition)"),
("swx_render_image", "write", "a high-resolution PNG of the active document from SOLIDWORKS's own viewport (RealView, ambient occlusion, shadows, perspective, a plain background; zoomFactor to frame it) at a size independent of the window — measured live: 4K in ~12 s; every display setting it changes is restored and `settings` accounts for each one asked for; the size is read back from the file"),
];
/// `swx_sign_in` — UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). See src/signin.rs.
fn swx_sign_in(_ctx: &Ctx, args: &Value) -> Value {
let Some(reason) = arg_str(args, "reason").filter(|r| !r.trim().is_empty()) else {
return err_code(
"reason_required",
"swx_sign_in needs a reason",
"Say why the bridge is signing the user in (it is ledgered, with who asked).",
);
};
let who = crate::caller::current()
.map(|c| json!({"aiThread": c.thread, "containerName": c.container}))
.unwrap_or(Value::Null);
let opts = crate::signin::Opts {
user_confirmed_new_password: arg_bool(args, "userConfirmedNewPassword", false),
user_authorized: arg_bool(args, "userAuthorized", false),
window: args.get("window").and_then(|w| w.as_str().map(String::from).or_else(|| w.as_i64().map(|h| h.to_string()))),
};
crate::signin::sign_in(reason, opts, who)
}
/// `swx_user_presence` — the presence measurement every quit and sweep is
/// guarded by, on its own (0.9.9). Read-only towards SOLIDWORKS; positive
/// evidence marks the session taken over by the user, exactly as the guard does.
fn swx_user_presence(ctx: &Ctx, _args: &Value) -> Value {
// EXEMPT FROM THE STARTUP GATE (0.9.11, L4): a read about the USER, needed
// most while SOLIDWORKS is starting or wedged. It asks COM for documents
// only when the session is verified ready; otherwise it measures without
// COM and says the document part is unknown.
let ready = crate::readiness::assess(ctx).get("ready").and_then(|v| v.as_bool()) == Some(true);
let measured = if ready {
crate::presence::measure(ctx).or_else(crate::presence::measure_without_com)
} else {
crate::presence::measure_without_com()
};
match measured {
None => ok(json!({"present": Value::Null, "running": false,
"_hint": "No running SOLIDWORKS session to measure."})),
Some((present, evidence)) => ok(json!({
"present": present,
"evidence": evidence,
"_hint": if present {
"The user is working in this SOLIDWORKS: swx_stop, swx_shutdown and every sweep \
refuse user_is_active. Leave the session alone; it is theirs."
} else {
"No sign of the user right now. This is a reading, not a promise — every verb that \
ends work measures again before it acts."
},
})),
}
}
fn swx_describe(_ctx: &Ctx, _args: &Value) -> Value {
let verbs: Vec<Value> = CATALOG
.iter()
.map(|(verb, risk, summary)| json!({"verb": verb, "summary": summary, "risk": risk}))
.collect();
ok(json!({
"bridge": "solidworks",
"keyword": "swx",
"version": crate::BRIDGE_VERSION,
"mock": crate::is_mock(),
"verbs": verbs,
"scriptOps": crate::script::OPS,
// What each modelling op variant has been MEASURED on, live — the one
// table step results' `proven` and swx_selftest report too.
"liveMeasured": crate::compat::live_table(None),
"exportFormats": crate::verbs3::export_format_note(),
"userWrites": crate::writes::posture(),
"writePolicy": "DENY BY DEFAULT. This bridge writes only to documents it created itself \
(swx_new_document / swx_import) and to destination paths that do not \
exist yet. Touching the user's documents, or overwriting an existing \
file, requires the bridge process to be started with \
SWX_ALLOW_USER_WRITES=1 AND the call to carry allowUserWrites:true, \
confirm:true and a reason. An AI cannot grant this to itself; ask the \
user. Every exception is recorded in swx_action_ledger.",
"_hint": "Say 'swx' to reach this bridge. Everyday flow: swx_status → swx_find_files → \
swx_open → swx_bom / swx_mass_properties / swx_export. To MAKE something: \
swx_new_document → swx_run_script → swx_inspect_bodies → swx_save_as. \
swx_get_app_state is the poll-me verb, swx_state the render-me one. Verb names \
and arg names mirror the Fusion 360 bridge (`outputPath`, `filePath`, `name`, \
`parameters`, `bodies`) so fusion_* knowledge transfers directly; \
docs/FUSION-PARITY-REPORT.md records exactly where the two diverge and why. \
Every verb returns its own _hint; that is the documentation that matters.",
}))
}
// ── dispatch ────────────────────────────────────────────────────────────────
/// THE VERBS THAT DO NOT WAIT FOR SOLIDWORKS TO FINISH STARTING. Every other
/// verb is GATED BY DEFAULT (adversary pass 42: a fifteen-name gate list left
/// swx_show_*, the export verbs and swx_com_eval — the crash log's own
/// `CMD:DocSave` — reachable during add-in load). A new verb is gated until it
/// is added here, and `tests/wiring.rs` pins this list name by name. Each one is
/// here because it never reaches the com host, or must work on a start that is
/// wedged:
pub const STARTUP_EXEMPT: &[(&str, &str)] = &[
("swx_status", "the probe that reports readiness itself"),
("swx_readiness", "process detection only; never reaches the com host"),
("swx_start", "launches and attaches; reports `stage: starting|ready`"),
("swx_describe", "the catalog; bridge-local"),
("swx_progress", "the bridge's own job table"),
("swx_action_ledger", "the bridge's own ledger file"),
("swx_verb_times", "the bridge's own timing file"),
("swx_uninstall", "the bridge's own artifacts on disk"),
("swx_find_files", "searches the disk; works without SOLIDWORKS"),
("swx_recover_list", "reads the recovery folders on disk"),
("swx_window_info", "a window census; no COM — a startup dialog is what it is for"),
("swx_dismiss_blocking_dialogs", "a window census (close is opt-in); no COM"),
("swx_close_window", "one window message by hwnd (reason + confirm); no COM"),
("swx_screenshot", "through ab's generic screenshot; no com host"),
("swx_set_driving_badge", "the taskbar badge; no com host"),
("swx_kill", "the last resort for a wedged start (reason + confirm); reads the document count only to say what is lost"),
("swx_sign_in", "acts on the LAUNCHER's sign-in window, never through COM — it is how a start gets past the sign-in at all"),
("swx_user_presence", "a read about the USER, needed most during a start; asks COM for documents only on a verified-ready session"),
("swx_selftest", "never reaches the com host itself: every check it runs goes through this same gate, and a check held at it is REPORTED — a self-test that refused to run while SOLIDWORKS starts could not say so"),
("swx_com_respawn", "respawns the bridge's own com host child and never calls SOLIDWORKS — the readiness gate would"),
];
/// swx_stop is gated with one carve-out: under `userAuthorized`, a plan that
/// reaches the WINDOW-MESSAGE rungs (c, d) runs during startup with the COM rungs
/// (a, b, e) skipped — those two need neither COM nor readiness, and a start
/// wedged behind a dialog is exactly when a quit needs them.
pub fn is_startup_exempt(command: &str) -> bool {
STARTUP_EXEMPT.iter().any(|(v, _)| *v == command)
}
/// Read or mutate, for the gate: catalog risk `read` reads; `exec`, `write` and
/// `process` mutate.
pub fn startup_touch(command: &str) -> crate::readiness::Touch {
match CATALOG.iter().find(|(v, _, _)| *v == command).map(|(_, r, _)| *r) {
Some("read") => crate::readiness::Touch::Read,
_ => crate::readiness::Touch::Mutate,
}
}
/// swx_stop's carve-out from the startup gate: `Some(args)` (marked so the
/// ladder skips its COM rungs) when the user authorized the quit and the plan
/// reaches a window-message rung.
fn stop_window_rungs_during_startup(command: &str, args: &Value) -> Option<Value> {
if command != "swx_stop" || !arg_bool(args, "userAuthorized", false) {
return None;
}
let max = args.get("maxRung").and_then(|v| v.as_str()).unwrap_or("a");
if !crate::quit::rungs_up_to(max).iter().any(|r| matches!(*r, "c" | "d")) {
return None;
}
let mut a = args.clone();
if let Value::Object(m) = &mut a {
m.insert("_startupNotVerified".into(), json!(true));
}
Some(a)
}
pub fn is_known(command: &str) -> bool {
CATALOG.iter().any(|(v, _, _)| *v == command)
}
/// Route one verb. Unknown commands answer with the full valid list — a caller
/// that guessed a name gets the surface back instead of a dead end.
pub fn dispatch(ctx: &Ctx, command: &str, args: &Value) -> Value {
// Test-only fault injection (debug builds only, never in the shipped exe):
// lets the suite prove a handler panic is caught by the HTTP worker and
// turned into JSON, with the worker still serving afterwards.
#[cfg(debug_assertions)]
if std::env::var("SWX_TEST_PANIC").ok().as_deref() == Some(command) {
panic!("test-induced panic in {command}");
}
// NOT BEFORE SOLIDWORKS HAS FINISHED STARTING. Every verb not on
// STARTUP_EXEMPT waits for readiness: measured 2026-10-01, document commands
// sent 8 s after COM attach crashed SOLIDWORKS while its 3DEXPERIENCE add-in
// was still initialising. Reads may run on an unverifiable session after the
// grace; mutations never do. See src/readiness.rs.
// INTERNAL ARGUMENTS ARE NOT CALLER INPUT (adversary pass 44, F5). Every
// underscore-prefixed key is the dispatcher's own (`_startupNotVerified`
// skips the gentle COM quit rungs); a caller who passes one has it removed
// here, before the gate and the handler see it.
let stripped: Value;
let args: &Value = match args {
Value::Object(m) if m.keys().any(|k| k.starts_with('_')) => {
stripped = Value::Object(m.iter().filter(|(k, _)| !k.starts_with('_')).map(|(k, v)| (k.clone(), v.clone())).collect());
&stripped
}
_ => args,
};
let mut gated_args: Option<Value> = None;
// WHAT THE GATE SAW goes into every gated verb's reply, success or refusal
// (0.9.6, G3), so a live run can prove no COM verb ran while starting.
let mut at_gate: Option<Value> = None;
// BEFORE the handler table: several arms return directly (swx_show_*, the
// export verbs), and a gate placed after the table never saw them.
if is_known(command) && !is_startup_exempt(command) {
match crate::readiness::gate(ctx, command, startup_touch(command)) {
Ok(summary) => at_gate = Some(summary),
Err(refusal) => match stop_window_rungs_during_startup(command, args) {
Some(a) => {
let mut summary =
crate::readiness::gate_summary(refusal.get("readiness").unwrap_or(&Value::Null));
if let Value::Object(m) = &mut summary {
m.insert("gatePassedFor".into(), json!("windowMessageRungs"));
}
at_gate = Some(summary);
gated_args = Some(a);
}
None => {
let mut refusal = refusal;
if command == "swx_stop" {
if let Value::Object(m) = &mut refusal {
m.insert(
"windowMessageRungs".into(),
json!("Under userAuthorized:true with maxRung \"c\" or \"d\", \
swx_stop runs during startup with the COM rungs (a, b, e) \
skipped: the window-message rungs need neither COM nor a \
finished start."),
);
}
}
return refusal;
}
},
}
}
let args: &Value = gated_args.as_ref().unwrap_or(args);
// THE USER-PRESENCE GUARD (0.9.9): a verb that ends work never runs on a
// session the user is working in — measured, fail-closed. See
// src/presence.rs.
let mut presence: Option<Value> = None;
// A RENDER THAT CHANGES THE DISPLAY (orientation, style, RealView, AO,
// shadows, perspective, background, zoom) is guarded like a sweep; an
// export of the CURRENT view changes nothing on screen and is not (0.9.18).
let render_touches_screen = command == "swx_render_image" && crate::render::changes_display(args);
if crate::presence::GUARDED.contains(&command) || render_touches_screen {
match crate::presence::guard(ctx, command, args) {
Ok(evidence) => presence = Some(evidence),
Err(refusal) => {
let mut refusal = refusal;
if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {
m.entry("readiness").or_insert(summary);
}
return refusal;
}
}
}
// THE MEMORY GUARD (0.10.5): a verb that creates or loads geometry is
// refused while committed memory is CRITICAL — measured 2026-10-14, the
// SOLIDWORKS Resource Monitor warned at 94 % committed — and the refusal
// changes nothing. `ignoreMemoryPressure:true` with a reason overrides, and
// the override is ledgered. See src/memory.rs.
let mut memory_override: Option<Value> = None;
if crate::memory::guarded(command, args) {
match crate::memory::guard(ctx, command, args) {
Ok(o) => memory_override = o,
Err(refusal) => {
let mut refusal = refusal;
if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {
m.entry("readiness").or_insert(summary);
}
return refusal;
}
}
}
// THE OTHER HALF OF THE PRESENCE RULE (0.10.9): the bridge does not POP
// ITS OWN WINDOWS on a user who is working. A verb creating a document for
// the bridge's own purposes waits for the user to go idle, then refuses
// `user_present`, having created nothing. A user's own request is allowed
// and its reply says the user is active.
if crate::presence::creates_for_bridge(command, args) {
let wait = crate::presence::wait_for_idle_secs(args);
if let Err(a) = crate::presence::wait_for_idle(wait) {
let mut refusal = crate::presence::user_present_refusal(command, wait, &a);
if let (Some(summary), Value::Object(m)) = (at_gate.clone(), &mut refusal) {
m.entry("readiness").or_insert(summary);
}
return refusal;
}
}
let changes_documents = is_known(command)
&& startup_touch(command) == crate::readiness::Touch::Mutate
&& !is_startup_exempt(command);
// WHAT WAS OPEN BEFORE THE VERB — so the baseline grows only by what the
// verb itself opened or created (0.9.10, adversary pass 46).
let before = if changes_documents { crate::presence::snapshot(ctx) } else { None };
// EVERY VERB IS BRACKETED BY A DIALOG CENSUS (0.11.1): a SOLIDWORKS dialog
// that appears while it runs is attributed to it (`dialogs::observe`).
let bracket = (is_known(command) && crate::dialogs::brackets(command)).then(|| crate::dialogs::begin(command));
let mut out = dispatch_after_gate(ctx, command, args);
if let Some(id) = bracket {
let fresh = crate::dialogs::end(id);
if !fresh.is_empty() {
if let Value::Object(m) = &mut out {
m.insert("dialogsAppeared".into(), json!(fresh));
}
}
}
if let (Some(summary), Value::Object(m)) = (at_gate, &mut out) {
m.entry("readiness").or_insert(summary);
}
if let (Some(evidence), Value::Object(m)) = (presence, &mut out) {
m.entry("userPresence").or_insert(evidence);
}
if let (Some(o), Value::Object(m)) = (memory_override, &mut out) {
m.entry("memoryOverride").or_insert(o);
}
// A USER'S OWN document request, while they are active: allowed, and said.
if matches!(command, "swx_new_document" | "swx_run_script" | "swx_import")
&& !crate::presence::creates_for_bridge(command, args)
{
let a = crate::presence::activity();
if a.active {
if let Value::Object(m) = &mut out {
m.entry("userActive").or_insert(json!(true));
m.entry("userPresence").or_insert(a.to_json());
}
}
}
if changes_documents {
// A verb that adds documents on a session the user has taken over is
// allowed — it adds, never removes — and says so.
if crate::session_history::ADDS_DOCUMENTS.contains(&command) {
if let Some(t) = crate::presence::taken_over_now(ctx) {
if let Value::Object(m) = &mut out {
m.insert("userPresent".into(), t);
}
}
}
// WHAT THIS VERB OPENED OR CREATED becomes the bridge's; nothing else
// does. A document that appears between two bridge verbs stays the
// user's.
crate::presence::note_bridge_changed(ctx, before);
}
// EVERY REPLY, while memory is pressing, names the largest holders (0.10.5).
crate::memory::annotate(&mut out);
out
}
/// The handler table and the post-processing, after the startup gate.
fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {
let handler: fn(&Ctx, &Value) -> Value = match command {
"swx_status" => swx_status,
"swx_readiness" => swx_readiness,
"swx_describe" => swx_describe,
"swx_state" => swx_state,
"swx_progress" => swx_progress,
"swx_start" => swx_start,
"swx_stop" => swx_stop,
"swx_com_respawn" => swx_com_respawn,
// DREW'S STANDING AUTHORIZATION, 2026-09-11: the bridge closes documents
// and quits by itself, having first written every document to disk.
"swx_close_all_safely" => crate::verbs3::close_all_safely,
"swx_shutdown" => crate::verbs3::shutdown,
"swx_recover_list" => crate::verbs3::recover_list,
"swx_user_presence" => swx_user_presence,
"swx_sign_in" => swx_sign_in,
"swx_docs" => swx_docs,
"swx_find_files" => swx_find_files,
"swx_open" => swx_open,
"swx_close_doc" => swx_close_doc,
"swx_save" => swx_save,
"swx_export" => swx_export,
"swx_pack_and_go" => swx_pack_and_go,
"swx_props_get" => swx_props_get,
"swx_props_set" => swx_props_set,
"swx_bom" => swx_bom,
"swx_mass_properties" => swx_mass_properties,
"swx_interference" => swx_interference,
"swx_rebuild_check" => swx_rebuild_check,
"swx_screenshot" => swx_screenshot,
"swx_com_eval" => swx_com_eval,
"swx_uninstall" => swx_uninstall,
// v0.3
"swx_new_document" => crate::verbs3::new_document,
"swx_import" => crate::verbs3::import,
"swx_save_as" => crate::verbs3::save_as,
"swx_get_app_state" => crate::verbs3::get_app_state,
"swx_document_info" => crate::verbs3::document_info,
"swx_activate_document" => crate::verbs3::activate_document,
"swx_close_all_documents" => crate::verbs3::close_all_documents,
"swx_reset_workspace" => crate::verbs3::reset_workspace,
"swx_close_import_temps" => crate::verbs3::close_import_temps,
"swx_kill" => crate::verbs3::kill,
"swx_window_info" => crate::verbs3::window_info,
"swx_dismiss_blocking_dialogs" => crate::verbs3::dismiss_blocking_dialogs,
"swx_close_window" => crate::verbs3::close_window,
"swx_check_recovery" => crate::verbs3::check_recovery,
"swx_action_ledger" => crate::verbs3::action_ledger,
"swx_verb_times" => crate::verbs3::verb_times,
"swx_set_driving_badge" => crate::verbs3::set_driving_badge,
"swx_design_info" => crate::verbs3::design_info,
"swx_get_parameters" => crate::verbs3::get_parameters,
"swx_set_parameter" => crate::verbs3::set_parameter,
"swx_inspect_bodies" => crate::verbs3::inspect_bodies,
"swx_run_macro" => crate::verbs3::run_macro,
"swx_run_script" => crate::verbs3::run_script,
"swx_selftest" => crate::selftest::selftest,
"swx_add_component" => crate::verbs3::add_component,
"swx_get_preferences" => crate::verbs3::get_preferences,
"swx_set_preference" => crate::verbs3::set_preference,
"swx_cloud_status" => crate::verbs3::cloud_status,
"swx_show_part" => return crate::verbs3::show(ctx, args, "part"),
"swx_show_assembly" => return crate::verbs3::show(ctx, args, "assembly"),
"swx_show_drawing" => return crate::verbs3::show(ctx, args, "drawing"),
"swx_export_step" => return crate::verbs3::export_as(ctx, args, "step"),
"swx_export_iges" => return crate::verbs3::export_as(ctx, args, "iges"),
"swx_export_stl" => return crate::verbs3::export_as(ctx, args, "stl"),
"swx_export_3mf" => return crate::verbs3::export_as(ctx, args, "3mf"),
"swx_export_glb" => return crate::verbs3::export_as(ctx, args, "glb"),
"swx_export_sat" => return crate::verbs3::export_as(ctx, args, "sat"),
"swx_export_parasolid" => return crate::verbs3::export_as(ctx, args, "parasolid"),
"swx_export_pdf" => return crate::verbs3::export_as(ctx, args, "pdf"),
"swx_export_dxf" => return crate::verbs3::export_as(ctx, args, "dxf"),
"swx_export_dwg" => return crate::verbs3::export_as(ctx, args, "dwg"),
"swx_render_image" => crate::render::render_image,
other => {
let valid: Vec<&str> = CATALOG.iter().map(|(v, _, _)| *v).collect();
return json!({
"success": false,
"error": format!("Unknown command: {other}"),
"_hint": format!("Valid swx commands: {}.", valid.join(", ")),
"_reportIssues": report_issues(),
});
}
};
let mut out = handler(ctx, args);
// A SESSION'S HISTORY: whether it has ever held a document decides how the
// quit ladder treats a declined ExitApp (see src/session_history.rs).
crate::session_history::note_reply(ctx, command, &out);
// UNATTENDED SIGN-IN, ARMED (0.9.13): `signIn:"auto"` — the default is "wait".
if command == "swx_start" && arg_str(args, "signIn") == Some("auto") {
crate::launch::arm_auto_sign_in(crate::launchos::os().as_ref());
if let Value::Object(m) = &mut out {
m.insert("signInMode".into(), json!("auto"));
}
}
// BLOCKED ON THE USER'S SIGN-IN (0.9.12): said plainly, with the window.
// 0.9.23: `signInWindow` is an OBJECT {hwnd, title, ownerImage, host, …},
// and ANY 3DEXPERIENCE ID login on the machine counts — by class/title or by
// CONTENT (measured 2026-10-12: the SWXDesktopUpdate-hosted login, titled
// just "3DEXPERIENCE", was up while swx_status said waitingOn:null).
if command == "swx_start" || command == "swx_status" {
let os = crate::launchos::os();
let attempt = crate::launch::attempt();
// From a LIVE census whenever the record names the sign-in stage —
// the monitor that first saw the window may have died with an older
// bridge process (0.9.13).
let live = match &attempt {
Some(a) if a.stage.name() == "helperSignIn" => match crate::launch::sign_in_now(os.as_ref()) {
Some(Some((h, t))) => Some((Some(h), t)),
_ => None,
},
_ => None,
};
let recorded = attempt.as_ref().and_then(|a| a.sign_in_window.clone().filter(|_| !a.finished)).map(|t| (None, t));
let machine = crate::signin::login_windows(os.as_ref(), true).unwrap_or_default();
let ours_title = live.or(recorded);
let window: Option<Value> = match &ours_title {
Some((h, t)) => Some(
machine
.iter()
.find(|l| Some(l.hwnd) == *h || (h.is_none() && l.title == *t))
.map(|l| l.to_json())
.unwrap_or_else(|| json!({"hwnd": h, "title": t, "ownerImage": Value::Null, "host": Value::Null})),
),
None => machine.iter().find(|l| l.ours).or_else(|| machine.first()).map(|l| l.to_json()),
};
if let Some(w) = window {
if let Value::Object(m) = &mut out {
if let Some(a) = attempt.as_ref().filter(|_| ours_title.is_some()) {
m.insert("signInInventory".into(), a.sign_in_inventory.clone().unwrap_or(Value::Null));
m.insert("signInMode".into(), json!(if a.sign_in_auto { "auto" } else { "wait" }));
}
m.insert("waitingOn".into(), json!("userSignIn"));
m.insert("signInWindow".into(), w);
if machine.len() > 1 {
m.insert("loginWindows".into(), json!(machine.iter().map(|l| l.to_json()).collect::<Vec<_>>()));
}
// swx_start must not say "launching" while the start is parked
// behind a login (0.9.23).
if command == "swx_start" && m.get("stage").and_then(|v| v.as_str()) == Some("launching") {
m.insert("stage".into(), json!("waitingOn"));
}
m.insert(
"_signInHint".into(),
json!("A 3DEXPERIENCE sign-in window is waiting for the user (signInWindow). A launch's \
deadlines are paused and nothing is killed. swx_sign_in {reason} (a launch this bridge \
started) or swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} (on the \
user's say-so) PREPARES the page: it fills the user name from the vault's host list and \
moves to the password page. ab's policy keeps the saved password with the user \
(measured: a bridge is not a verified local caller), so the USER types it until ab \
issue #237 (a fill verb that keeps the secret inside ab) lands. Then poll swx_status."),
);
}
}
}
// `attached` IS NOT `ready`: swx_start and swx_status say which, and on what.
if command == "swx_start" || command == "swx_status" {
let attached = out.get("attached").and_then(|v| v.as_bool()) == Some(true)
|| out.get("stage").and_then(|v| v.as_str()) == Some("attached")
|| out.pointer("/com/running").and_then(|v| v.as_bool()) == Some(true);
if attached && out.get("success").and_then(|v| v.as_bool()) != Some(false) {
let r = crate::readiness::assess(ctx);
let ready = r.get("ready").and_then(|v| v.as_bool());
if let Value::Object(map) = &mut out {
if command == "swx_start" {
match ready {
Some(true) => {
map.insert("stage".into(), json!("ready"));
}
Some(false) => {
map.insert("stage".into(), json!("starting"));
map.insert("stillStarting".into(), json!(true));
map.insert(
"_readyHint".into(),
json!("COM is attached but SOLIDWORKS has not finished starting. \
Document verbs refuse with host_app_starting until it has: \
poll swx_status until `solidworks.ready` is true."),
);
}
None => {}
}
map.insert("ready".into(), json!(ready));
map.insert("readiness".into(), r);
} else if let Some(Value::Object(sw)) = map.get_mut("solidworks") {
// THE SESSION'S DOCUMENT HISTORY, so a caller can predict a
// priming (0.9.5).
let history = r
.get("processId")
.and_then(|v| v.as_u64())
.map(|pid| {
// The creation time readiness resolved (census, else
// the host's own word) — the same key the poll marked.
let created = r.get("processCreatedAt").and_then(|v| v.as_u64());
crate::session_history::describe(pid as u32, created)
})
.unwrap_or(Value::Null);
sw.insert("ready".into(), json!(ready));
sw.insert("readiness".into(), r);
sw.insert("sessionHistory".into(), history);
}
}
}
}
// A failure always tells the caller how to report it — real machines are
// the only place the COM layer can actually be proven.
if out.get("success").and_then(|v| v.as_bool()) == Some(false) {
if let Value::Object(map) = &mut out {
map.entry("_reportIssues").or_insert_with(|| Value::from(report_issues()));
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn titles_compare_without_the_solidworks_extension() {
use super::{same_title, title_stem};
// MEASURED 2026-10-15 (Michael, SOLIDWORKS 2021, HideFileExt=1).
assert!(same_title("Part14-1792088642002.SLDPRT", "Part14-1792088642002"));
assert!(same_title("Part14-1792088642002", "Part14-1792088642002.SLDPRT"));
assert!(same_title("gearbox.sldasm", "gearbox.SLDASM"), "the extension's case does not matter");
assert!(same_title("sheet.SldDrw", "sheet"));
assert!(!same_title("Part1", "part1"), "the NAME is compared exactly");
assert!(!same_title("Part1", "Part12"));
assert!(!same_title("", ""), "an empty title names nothing");
// A dot elsewhere is part of the name, never an extension.
assert_eq!(title_stem("Bracket Rev1.2"), "Bracket Rev1.2");
assert_eq!(title_stem("Bracket Rev1.2.SLDPRT"), "Bracket Rev1.2");
assert!(!same_title("Bracket Rev1.2", "Bracket Rev1"));
assert_eq!(title_stem("drawing.pdf"), "drawing.pdf", "only SOLIDWORKS' own extensions");
assert_eq!(title_stem(".SLDPRT"), ".SLDPRT", "an extension alone is not stripped to nothing");
// Ownership of an unsaved document follows the same rule.
assert!(super::doc_matches("Part3.SLDPRT", None, "Part3", None));
}
use super::*;
#[test]
fn every_catalog_verb_is_unique_prefixed_and_dispatchable() {
let mut names: Vec<&str> = CATALOG.iter().map(|(v, _, _)| *v).collect();
let total = names.len();
assert!(total >= 60, "v0.3 ships the Fusion-parity surface, got {total}");
names.sort();
names.dedup();
assert_eq!(names.len(), total, "duplicate verb in the catalog");
for (v, _, _) in CATALOG {
assert!(is_known(v));
assert!(v.starts_with("swx_"), "{v} must carry the bridge's prefix");
}
}
#[test]
fn export_extensions_cover_the_documented_formats() {
for f in ["step", "STP", ".iges", "stl", "pdf", "dxf", "dwg", "png", "jpg", "parasolid"] {
assert!(export_extension(f).is_some(), "{f}");
}
assert_eq!(export_extension("step"), Some(".step"));
assert_eq!(export_extension("parasolid"), Some(".x_t"));
assert_eq!(export_extension("wat"), None);
}
#[test]
fn the_host_gate_only_blocks_a_real_machine_without_solidworks() {
assert!(gate_for(true, false).is_none(), "mock mode serves every verb");
assert!(gate_for(false, true).is_none(), "an installed host app is not gated");
let blocked = gate_for(false, false).expect("no SOLIDWORKS, no mock => gated");
assert_eq!(blocked["success"], false);
assert_eq!(blocked["errorCode"], "host_app_not_installed");
let hint = blocked["_hint"].as_str().unwrap();
assert!(hint.contains("Hawk Ridge"), "the hint must carry the current install story");
assert!(
hint.contains("Do NOT tell the user to install it by hand"),
"the hint must stop the AI inventing an install path"
);
}
#[test]
fn safe_out_allows_absolute_and_refuses_traversal() {
assert!(safe_out("/home/u/part.step").is_ok());
assert!(safe_out(r"C:\exports\part.step").is_ok());
assert!(safe_out("C:/exports/part.step").is_ok());
assert!(safe_out(r"\\server\share\part.step").is_ok());
// relative → rejected (would resolve against the bridge cwd)
assert!(safe_out("part.step").is_err());
assert!(safe_out("../../victim.txt").is_err());
// drive-RELATIVE (colon, no separator) → rejected: C:foo resolves against
// the cwd on drive C, it is not rooted.
assert!(safe_out("C:foo.step").is_err());
assert!(safe_out("C:..\\victim").is_err());
// absolute-but-with-.. → rejected
assert!(safe_out("/home/u/../../etc/passwd").is_err());
}
#[test]
fn only_a_launchable_edition_gets_past_may_launch() {
let desktop = Detection {
installed: true,
shortcut: None,
exe: Some(r"C:\Program Files\SOLIDWORKS Corp\SOLIDWORKS\SLDWORKS.exe".into()),
registry_versions: vec![],
running: false,
launcher_present: false,
};
let connected = Detection {
exe: Some(r"C:\Program Files\Dassault Systemes\SOLIDWORKS 3DEXPERIENCE R2026x\SOLIDWORKS\SLDWORKS.exe".into()),
..desktop.clone()
};
let unknown = Detection { exe: Some(r"D:\odd\SLDWORKS.exe".into()), ..desktop.clone() };
assert!(may_launch(false, &desktop));
assert!(!may_launch(false, &connected), "Connected editions refuse a programmatic launch");
assert!(!may_launch(false, &unknown), "an unrecognised layout must fail safe");
assert!(may_launch(true, &connected), "the mock app is launchable so the suite can drive it");
}
#[test]
fn the_scratch_registry_is_scoped_to_one_solidworks_session() {
let ctx = Ctx::new(Detection::default());
let s1 = Some(4242i64); // one SOLIDWORKS session
let s2 = Some(9999i64); // ...and the one after it restarted
assert!(ctx.scratch_titles().is_empty(), "a fresh process owns nothing");
ctx.remember_scratch("Part1", s1);
ctx.remember_scratch("Part1", s1); // idempotent
ctx.remember_scratch("Assem1", s1);
ctx.remember_scratch("", s1); // an empty title is not a document
assert_eq!(ctx.scratch_titles(), vec!["Part1".to_string(), "Assem1".to_string()]);
assert!(ctx.owns_doc("Part1", None, s1));
// THE bug this exists to stop: SOLIDWORKS restarts its Part numbering
// every session, so a remembered "Part1" would otherwise match a fresh
// "Part1" the USER made, and the bridge would treat their document as
// its own.
assert!(!ctx.owns_doc("Part1", None, s2), "a title from another session is NOT ours");
// ...and with no session at all we cannot tell the difference, so: no.
assert!(!ctx.owns_doc("Part1", None, None), "an unknown session must fail CLOSED");
// Pruning drops entries from another session, and entries for documents
// that are no longer open — which is what covers a close in the
// SOLIDWORKS UI, a crash, or CloseAllDocuments, none of which run
// through this bridge.
let open = |t: &str| vec![(t.to_string(), None)];
ctx.prune_scratch(s1, &open("Part1"));
assert_eq!(ctx.scratch_titles(), vec!["Part1".to_string()], "Assem1 is no longer open");
ctx.prune_scratch(s2, &open("Part1"));
assert!(ctx.scratch_titles().is_empty(), "a new session invalidates everything");
// An EMPTY path is no path. The live run turned on exactly this
// distinction: SOLIDWORKS reports "" for an unsaved document, and a
// `Some("")` where `None` was expected made it unaddressable.
assert!(
!ctx.owns_doc("Part1", Some(""), s1) || ctx.owns_doc("Part1", None, s1),
"an empty path must be treated as no path"
);
ctx.remember_scratch("PartX", s1);
assert!(ctx.owns_doc("PartX", Some(""), s1), "empty path => fall back to the title");
assert!(ctx.owns_doc("PartX", None, s1), "...same as no path at all");
ctx.forget_scratch("PartX");
// A save_as retitles a document; our claim on it must survive, and it
// becomes a claim on the PATH.
ctx.remember_scratch("Part2", s1);
ctx.rename_scratch("Part2", "bracket.SLDPRT", Some("C:\\p\\bracket.sldprt".into()));
assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\p\\bracket.sldprt"), s1));
assert!(!ctx.owns_doc("Part2", None, s1));
// THE title-collision case. Once a document of ours has a path, a
// DIFFERENT document that merely shares its title is not ours — two
// folders each holding a `bracket.SLDPRT` is an ordinary thing to have
// open, and treating the title as the identity is how a save_as once
// renamed the user's document instead of the bridge's.
assert!(
!ctx.owns_doc("bracket.SLDPRT", Some("C:\\users\\bracket.sldprt"), s1),
"same title, different path => NOT ours"
);
assert!(
!ctx.owns_doc("bracket.SLDPRT", None, s1),
"an unsaved document is never matched by a claim on a saved one"
);
ctx.forget_scratch("bracket.SLDPRT");
assert!(ctx.scratch_titles().is_empty());
ctx.forget_scratch("SomeoneElsesPart1"); // a no-op, not a panic
// 0.9.29 AUDIT: forgetting / renaming ONE document must not touch
// another owned document that merely shares its title.
ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\a\\bracket.sldprt");
ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\b\\bracket.sldprt");
ctx.forget_scratch_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"));
assert!(!ctx.owns_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"), s1), "the closed one is forgotten");
assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\b\\bracket.sldprt"), s1), "the OTHER one is still ours");
ctx.remember_scratch_at("bracket.SLDPRT", s1, "C:\\a\\bracket.sldprt");
ctx.rename_scratch_doc("bracket.SLDPRT", Some("C:\\b\\bracket.sldprt"), "b2.SLDPRT", Some("C:\\b\\b2.sldprt".into()), true);
assert!(ctx.owns_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"), s1), "the one NOT saved keeps its identity");
assert!(ctx.owns_doc("b2.SLDPRT", Some("C:\\b\\b2.sldprt"), s1), "the saved one moved with its path");
ctx.forget_scratch_doc("bracket.SLDPRT", Some("C:\\a\\bracket.sldprt"));
ctx.forget_scratch_doc("b2.SLDPRT", Some("C:\\b\\b2.sldprt"));
assert!(ctx.scratch_titles().is_empty());
}
#[test]
fn ok_merge_lets_extras_win_over_host_result() {
let merged = ok_merge(json!({"a": 1, "b": 2}), json!({"b": 3}));
assert_eq!(merged["success"], true);
assert_eq!(merged["a"], 1);
assert_eq!(merged["b"], 3);
}
}