← Commit history

demo: stop crying wolf about permission - claim nothing while a call is in flight, and name a permission problem only when the RESULT is one. Elapsed time cannot distinguish a 60s surface open from a pending prompt, so no threshold was ever going to work.

John Lauer ·a785ba3e04 ·2mo ago ·parent 2a22645
1 file changed +110−12
web-control-demo.html+110−12
@@ -348,18 +348,25 @@ for (const w of mine) {   // away (that disclosure was fixed in ab 2.0.65), so the honest signal available   // here is TIME - if the call has not come back, a human is being asked.   function setAwaiting(el, promptTag, promptDetail, runningDetail) {+    // Say "running", and NOTHING about permission.+    //+    // Two earlier attempts got this wrong in the same direction. First every card+    // announced "asking permission" the instant it started, which is a lie in Auto+    // mode where no prompt ever appears. Then I escalated to that copy if the call+    // was still outstanding after 1.2s - but a surface open legitimately runs 30-60+    // SECONDS, so a slow verb looks exactly like a pending prompt and the lie came+    // back. Elapsed time cannot tell those apart, so no threshold will ever fix it.+    //+    // A page cannot see its own grant either: a cross-origin /status deliberately+    // projects the grants list away (ab#89). So the honest thing is to claim+    // nothing while the call is in flight, and speak only when the RESULT tells us+    // something real - which the failure paths now do, naming a permission problem+    // only when the response actually is one.     el._promptShown = false;     el.className = "res running";     el.innerHTML = "<span class='tag'>running ...</span>" +       (runningDetail ? " <small>" + runningDetail + "</small>" : "");-    var token = (el._awaitToken = (el._awaitToken || 0) + 1);-    setTimeout(function () {-      if (el._awaitToken !== token) return;           // already resolved - no prompt happened-      el._promptShown = true;-      el.className = "res pending";-      el.innerHTML = "<span class='tag'>" + promptTag + "</span>" +-        (promptDetail ? " <small>" + promptDetail + "</small>" : "");-    }, 1200);+    el._awaitToken = (el._awaitToken || 0) + 1;   }   // Most desktop verbs return {success, output:"<stringified json>"}. Parse output up.   function flat(j) {@@ -924,6 +931,24 @@ for (const w of mine) {     }     clog(c, "Rendering " + withImg.length + " window(s).");     withImg.forEach(function (s) { addShot(c.card, s.image || s.base64 || s.png, s.title || s.label || s.window || "window"); });+    // Is what we are about to render actually the part that was ASKED for?+    // The window title carries the part name, so this is cheap - and it is the+    // check that would have caught a green "Done" printed over screenshots of+    // the previous part (John, 2026-08-20) instead of shipping it as success.+    if (c._verifyName) {+      var want = norm(c._verifyName);+      var titles = withImg.map(function (s) { return (s.title || s.label || s.window || "").toString(); });+      if (titles.length && !titles.some(function (t) { return norm(t).indexOf(want) >= 0; })) {+        withImg.forEach(function (s) { addShot(c.card, s.image || s.base64 || s.png, "MISMATCH: " + (s.title || s.label || "window")); });+        setRes(c.res, "err", "WRONG PART on screen",+          "asked for " + esc(c._verifyName) + " but the surface shows " + esc(titles.slice(0, 2).join(" | ")) ++          " - the evidence below is NOT this part");+        clog(c, "  MISMATCH: no rendered window mentions '" + c._verifyName + "' -> " + titles.join(" | "));+        c.btn.disabled = false;+        return true;+      }+    }+    if (typeof c._onShown === "function") { try { c._onShown(); } catch (e) {} }     setRes(c.res, "ok", withImg.length === 1 ? "Done - the surface is below" : "Demo done - full state below", withImg.length + " window(s) on " + esc(c._who || selfLabel()));     c.btn.disabled = false;     return true;@@ -1008,8 +1033,15 @@ for (const w of mine) {       : { percent: 100, stepLabel: "stopped", failed: true });   } +  // Normalize for comparison: a part's name differs in case and punctuation+  // across the wiki slug, the library entry and the window title+  // (IN-S42ATR / in-s42atr, s10b-ph-sm4-tb / S10B_PH_SM4_TB).+  function norm(x) { return String(x || "").toLowerCase().replace(/[^a-z0-9]/g, ""); }+   function showSurface(c, cfg) {     c.btn.disabled = true;+    c._verifyName = cfg.verifyName || null;+    c._onShown = cfg.onShown || null;     cardLog(c.card).textContent = "";     var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";     setAwaiting(c.res, "asking permission (choose “Allow 1 hr”) ...", "on " + esc(selfLabel()) + " - " + (cfg.note || "opens in the background, then we screenshot it"), "on " + esc(selfLabel()) + " - " + (cfg.note || "opens in the background, then we screenshot it"));@@ -1031,7 +1063,18 @@ for (const w of mine) {         return handleShow(res);       });       function handleShow(res) {-        var ok = res.s === 200 && res.j && (res.j.success === true || res.j.ok === true || res.j.surface || res.j.shots || res.j.result || res.j.state);+        // An EXPLICIT failure is a failure, full stop. This used to OR together+        // several optional fields (`state`, `result`, `shots`, ...) as evidence+        // of success - but a kicad ERROR response also carries `state`, so a+        // refusal like library_cache_stale scored as OK and the card printed a+        // green "Done" over screenshots of the PREVIOUS part. Believing a bridge+        // when it says success:false is the whole fix; the optional fields are a+        // fallback only for a bridge that omits `success` entirely.+        var jj = res.j || {};+        var explicitFail = jj.success === false || !!jj.errorCode ||+          (typeof jj.error === "string" && jj.error.trim() !== "");+        var ok = res.s === 200 && !explicitFail &&+          (jj.success === true || jj.ok === true || jj.surface || jj.shots || jj.result || jj.state);         // The host app may simply not be running yet (fusion_not_running). If the         // bridge names a start verb, run it (it blocks until ready) and retry ONCE.         if (!ok && cfg.startCmd && !c._autoStarted && res.j && /not running/i.test((res.j.error || "") + " " + (res.j._hint || ""))) {@@ -1049,7 +1092,15 @@ for (const w of mine) {         }         if (!ok) {           if (res.j && res.j.errorCode === "web_control_disabled") setRes(c.res, "err", "web control OFF", "re-enable in Settings → Web Control");+          else {+          // Name a permission failure for what it is - this is the ONLY moment the+          // page actually knows one happened.+          var em = ((res.j && (res.j.error || res.j.errorCode)) || "") + "";+          var perm = res.s === 403 || /not approved|denied|consent|permission/i.test(em);+          if (perm) setRes(c.res, "err", "needs your approval",+            "Adom Bridge asked and got no Allow - click Allow 1 hr on the prompt (check the notification center, Win+N) then run this again");           else setRes(c.res, "err", "not opened", (res.j && (res.j.error || res.j.errorCode || res.j._hint)) || ("HTTP " + res.s));+        }           c.btn.disabled = false; return;         }         clog(c, "  opened" + (res.j.surface ? " (surface: " + res.j.surface + ")" : "") + " - capturing state ...");@@ -1330,6 +1381,7 @@ for (const w of mine) {    // Cards whose LABEL names the part, so switching parts re-renders them.   var PART_CARDS = [];+  var KICAD_SHOWN = null;   // last part actually shown in a KiCad editor    function fmtKB(n) { return n >= 1048576 ? (n / 1048576).toFixed(2) + " MB" : Math.round(n / 1024) + " KB"; } @@ -1340,6 +1392,7 @@ for (const w of mine) {   function setPart(idx) {     PART = partFrom(PARTS[idx]);     _fusionPart = null;+    _kicadPart = null;   // re-stage: symbol name, library and paths are per-part     PART_CARDS.forEach(function (e) {       e.cc.btn.textContent = e.label();       setRes(e.cc.res, "idle", "not run yet");@@ -1475,6 +1528,16 @@ for (const w of mine) {       return postVerb("kicad_install_symbol", { filePath: paths[0], quietInstall: true }).then(function (r) {         if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("kicad_install_symbol: " + ((r.j && r.j.error) || r.s));         if (r.j.libraryName) PART.kicadLib = r.j.libraryName;+        // The symbol's name INSIDE the .kicad_sym is often NOT the filename:+        // tja1051t-3-118 declares "TJA1051T_3,118", type-c-31-m-12 declares+        // "USB_C_Receptacle_USB2.0_16P", CL21B104KBCNFNC drops its last letter.+        // Asking for the file stem makes the bridge correctly fail to find it -+        // 4 of 10 parts in the picker. The bridge reports the real name here, so+        // use it for the show call AND for the wrong-part check.+        if (r.j.symbolNames && r.j.symbolNames.length) {+          PART.kicadSymbolName = r.j.symbolNames[0];+          if (PART.kicadSymbolName !== PART.name) clog(c, "  symbol is named '" + PART.kicadSymbolName + "' inside the file (not '" + PART.name + "')");+        }         clog(c, "  installed symbol into the '" + PART.kicadLib + "' library");         return postVerb("kicad_install_footprint", { filePath: paths[1], model: { filePath: paths[2] }, quietInstall: true });       }).then(function (r) {@@ -1518,7 +1581,7 @@ for (const w of mine) {   // ALL EIGHT open cold + background (the 3D warm-only limitation is obsolete:   // kicad-bridge #26 closed via the Win32 menu tier in 0.9.99).   [-    { label: function () { return "Symbol (" + PART.name + ")"; }, cmd: "kicad_show_symbol", args: function () { return { symbolName: PART.name, libraryName: PART.kicadLib }; }, stage: stageKicadPart, pick: "symbol editor", note: "installs the part from its wiki page, then shows JUST the symbol" },+    { label: function () { return "Symbol (" + PART.name + ")"; }, cmd: "kicad_show_symbol", args: function () { return { symbolName: PART.kicadSymbolName || PART.name, libraryName: PART.kicadLib }; }, stage: stageKicadPart, pick: "symbol editor", note: "installs the part from its wiki page, then shows JUST the symbol" },     { label: function () { return "Footprint (" + PART.name + ")"; }, cmd: "kicad_show_footprint", args: function () { return { footprintName: PART.name, library: PART.kicadLib }; }, stage: stageKicadPart, pick: "footprint editor", note: "just the land pattern: pads + courtyard" },     { label: function () { return "3D chip (" + PART.name + ")"; }, cmd: "kicad_show_3d_chip", args: function () { return { footprintName: PART.name, library: PART.kicadLib }; }, stage: stageKicadPart, pick: "3d viewer", note: "the part's STEP model on its footprint" },     { label: function () { return "Library (" + PART.name + " in '" + PART.kicadLib + "')"; }, cmd: "kicad_show_library", args: function () { return { libraryName: PART.kicadLib }; }, stage: stageKicadPart, pick: "symbol editor", note: "the library holding the symbol + footprint + 3D model" },@@ -1529,7 +1592,38 @@ for (const w of mine) {   ].forEach(function (s) {     var cc = card("grp-kicad", "verb", labelOf(s));     PART_CARDS.push({ cc: cc, label: labelFnOf(s) });-    cc.btn.onclick = function () { showSurface(cc, { showCmd: s.cmd, showArgs: s.args, stage: s.stage, pickTitle: s.pick, stateCmd: "kicad_state", note: s.note }); };+    cc.btn.onclick = function () {+      showSurface(cc, {+        showCmd: s.cmd,+        // A KiCad editor indexes its libraries when it OPENS, so after switching+        // parts the already-open editor cannot see the new symbol and keeps+        // showing the previous one (errorCode library_cache_stale). Ask the+        // bridge to close and reopen it - but ONLY when the part actually+        // changed, because the reopen pays the cold library index (~1 min,+        // kicad-bridge #35).+        showArgs: function () {+          var a = (typeof s.args === "function") ? s.args() : (s.args || {});+          // Rescan unless we JUST showed this exact part. On the first open we+          // cannot know what the already-open editor holds, and assuming it is+          // right is precisely how a stale editor gets reported as success.+          if (KICAD_SHOWN !== PART.name) a.rescan = true;+          return a;+        },+        stage: s.stage, pickTitle: s.pick, stateCmd: "kicad_state", note: s.note,+        // Verify against the name we ACTUALLY asked for, which for a symbol is+        // the in-file name, not the file stem.+        // WHAT can be verified by window title differs per surface:+        //   symbol    -> the in-file symbol name (often NOT the file stem)+        //   footprint -> the part name+        //   library   -> NOTHING: the window shows the LIBRARY, and its title+        //                is whichever symbol the editor landed on, so demanding+        //                the part name there is a false alarm, not a catch.+        verifyName: s.cmd === "kicad_show_library" ? null+                  : s.cmd === "kicad_show_symbol"  ? (PART.kicadSymbolName || PART.name)+                  : PART.name,+        onShown: function () { KICAD_SHOWN = PART.name; }+      });+    };   });   var cKState = card("grp-kicad", "read", "Show KiCad state (every window)");   cKState.btn.onclick = function () { showSurface(cKState, { showCmd: "kicad_state", showArgs: { maxWidth: 900 }, stateCmd: "kicad_state", note: "captures every KiCad window as a screenshot" }); };@@ -1596,7 +1690,11 @@ for (const w of mine) {     PART_CARDS.push({ cc: cc, label: labelFnOf(s) });     cc.btn.onclick = function () {       // fallback stays a FUNCTION: its args reference paths the stage step produces.-      showSurface(cc, { showCmd: s.cmd, showArgs: s.args, stage: s.stage, fallback: s.fallback, pickTitle: "fusion", stateCmd: "fusion_state", startCmd: "fusion_start", windowInfoCmd: "fusion_window_info", note: s.note });+      showSurface(cc, { showCmd: s.cmd, showArgs: s.args, stage: s.stage, fallback: s.fallback, pickTitle: "fusion", stateCmd: "fusion_state", startCmd: "fusion_start", windowInfoCmd: "fusion_window_info", note: s.note,+        // Fusion DESIGN surfaces (schematic / board / project) open a generated+        // design whose window is not named after the part, so only the COMPONENT+        // surfaces can be verified by title.+        verifyName: s.verifiable === false ? null : PART.name });     };   });   // Single full-screen shot (keep the simple case too).