app
Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
← Commit history
relabel: the verb lists browsers, not apps
1 file changed
+1114−1114
web-control-demo.html+1114−1114@@ -1,1114 +1,1114 @@-<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8" /> -<meta name="viewport" content="width=device-width, initial-scale=1" /> -<title>Adom Bridge web control - local test harness</title> -<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEuklEQVR4nLWXT2xUVRTGf/e+13mddkrbaYf+AbRCBUGMogGMgDEGXBB1YVxoQlxgiBg34MaFCTHqVsW4EILGxIWy0kRNCAkRRFloNNUEW6UUWirt0HaY/pl2+mbee9fFmce00BkmtJzkzZ2Ze3PPd757znfuUxgDSsGpM+1EMvtwzSN4ns3dMNv2cNSf5GLH2PnUEMagABgb2E5f/9cQrCTrhouX1rnnyRh1wOj/WH//S9S3n1Nc+KONwcG/iDUmSCY9IhGNUmZpvRfMGEUuF9DSajORHuW+VQ/bXBx4neZEguEhD8e5O9SHphQ4juZa0qOtPUHflf0apTYxM21wHHVXnc81x1HMTBuU2iQR2/binBsjD0iUqoLtbFthjFo85caAZUGiGTSQnYX0RBHM7XAs2nlzE+RcOHlaMn3dati4AVIpmJq+LYg7B2AMNDbA0DAcPASzOaiywfdgw3o4dACa4jCWAq1LblN6phKLReHwUXHw0FroWAWrO+DyJdh7UJhZFivmx5IBCM99fBJG0tC6HLI5CAKZ6+gA34f3D0O8qexWi2NAa7CtWyOcnYUVbXC+B853Q2N9SRYWB6CcKQWWDRcuQbS6JIDKk9Dc+JBh7oZKIV2lkPHGQHUE8p70lKD0trcHEDqyrPn/RaOS9fk8ZLPye64Y9Q1AdTXs2AKjYyXLsTyAsNRiURifKhyYBYEPVVXChLIg60oChpEGvuTAm69BxIHJzB0ACEVmaBjeOgrX06ALLGjAzYvzLz4UIPl8Yd4XIA11kMmKDpQRo4UBhGWWc0VktAWtifkU66xErhARymbnO5rKFNeWsYWrwBjR9p9/k83X3FPIaqvw6EIOVEmiZbPgB1L7vi/fK2xKpY9AK/DykmizuSIwCWvhKijnMLz63exmwcVKSVTr1oi2l5HSsha2aaWgNirj3NZdFkB6Qrrag+vh6rCU1M1rPF+yv5Tz2lo5sogDmWkZHQtqaiRpSwIILZWCtw/IJv39heTTEI1AcgSWN0LDMjn3ufQaA4m4AO9YCclrsPtFkeXOTujrh7ZWMDkUJ779nrq6Z8lkfMCaByAIiv3+3cPQ3SPymveEkY/egZblkB4vAjBG6E6Owd4DsG0zTE7BTFYYSMTh5I8+Xx2zWNHyQ3kh0hpS16GuFj5+TyL495LI644tsuHNda4UzLqw9VF4+gno6RWQ9cskoMtX4PHN8ORW6P2nAilWSm42U9OwagWsXSNCMzq2sMIpBXYETp2F1Lg411qY0Rqa45DJwOlfoDleYTcMazo9AVeTMJy89dxD+p2I3Atf3S+OQufhvCqMe96Ant4CAM+rrM7CJNR64ZpXCtyctN/Pj0AsJrSHa5UqdFIFX35ieKDTaIzpoqZW4bpL8zZkDHg52LkdmhpgJFUEEQQwloaaqGH3LgV2l6bz3k+ZSo3S0mrjuh7GBIB/x48xPhHH5+yvPid/8mlL+LiuT3rCZ3omoCXu0T9oc/ybEZ7ZfUS4mRjaRnfvcfQiX049IALk8nJP7O2HxzZC19/w/Cvw2QfwwnPw3YlB9ux9GaXOqRtS+fuZdiYz+/CX4vVcQd5AfZWMJoDui7BhrYfjdLFj1zGUGsYY/geDsv+dOi74jwAAAABJRU5ErkJggg=="> -<style> - :root { color-scheme: dark; } - html, body { margin: 0; padding: 0; background: #0d1117; color: #e6edf3; font-family: system-ui, "Segoe UI", sans-serif; } - .wrap { max-width: 1000px; margin: 0 auto; padding: 24px; } - h1 { font-size: 20px; margin: 0 0 4px; } - .sub { font-size: 12px; color: #8b949e; margin-bottom: 18px; } - .sub code { color: #79c0ff; } - .bar { display: flex; align-items: center; gap: 10px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px 14px; margin-bottom: 18px; flex-wrap: wrap; } - .bar label { font-size: 12px; color: #8b949e; } - select { background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 6px 8px; font-size: 13px; } - #dot { width: 11px; height: 11px; border-radius: 50%; background: #484f58; display: inline-block; } - .grp { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #6e7681; margin: 18px 0 8px; } - .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; align-items: start; } - .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; } - .card button { padding: 8px 10px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; cursor: pointer; font-size: 13px; text-align: left; transition: opacity .12s, border-color .12s; } - .card.read button { background: #12181f; } - .card.verb button { background: #1a2230; border-color: #2b3a55; } - .card.multi button { background: #24202e; border-color: #4a3a63; } - .card button:hover:not(:disabled) { border-color: #8b949e; } - .card button:disabled { opacity: .5; cursor: default; } - .res { font-size: 12px; line-height: 1.35; min-height: 16px; color: #8b949e; word-break: break-word; } - .res .tag { font-weight: 600; } - .res.idle { color: #6e7681; } - .res.pending { color: #d29922; } - .res.ok { color: #3fb950; } - .res.err { color: #f85149; } - .res small { color: #8b949e; font-weight: 400; } - .slog { margin: 4px 0 0; padding: 8px; background: #010409; border: 1px solid #21262d; border-radius: 4px; font-size: 11px; max-height: 180px; overflow: auto; white-space: pre-wrap; color: #9aa4af; line-height: 1.45; } - .shots { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; } - .shots .cap { font-size: 10px; color: #8b949e; margin-bottom: 3px; } - .shots img { max-width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; } - .pbar { margin: 8px 0 2px; height: 16px; background: #010409; border: 1px solid #21262d; border-radius: 8px; overflow: hidden; position: relative; } - .pbar-fill { height: 100%; width: 0; background: linear-gradient(90deg, #1f6feb, #2ea043); transition: width .35s ease; } - .pbar-meta { font-size: 11px; color: #9aa4af; margin-bottom: 4px; } - .pbar-meta .beat { color: #6e7681; } - .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid #30363d; color: #8b949e; } - details { margin-top: 20px; } summary { cursor: pointer; color: #8b949e; font-size: 12px; } - pre#log { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; font-size: 11px; max-height: 240px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; color: #c9d1d9; } - .doc { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px 18px; font-size: 13px; line-height: 1.55; color: #c9d1d9; } - .doc h3 { font-size: 13px; margin: 18px 0 6px; color: #e6edf3; } - .doc h3:first-of-type { margin-top: 4px; } - .doc p { margin: 0 0 10px; } - .doc code { background: #0d1117; border: 1px solid #21262d; border-radius: 4px; padding: 1px 5px; font-size: 12px; color: #79c0ff; } - .doc pre { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; overflow: auto; margin: 0 0 10px; } - .doc pre code { background: none; border: none; color: #c9d1d9; padding: 0; font-size: 12px; line-height: 1.55; display: block; white-space: pre; } - .doc ul { margin: 6px 0 0; padding-left: 20px; } .doc li { margin: 5px 0; } - .doc .warn { color: #d29922; } -</style> -</head> -<body> -<div class="wrap"> - <h1><span id="dot"></span> Adom Bridge web control - local test harness</h1> - <div class="sub"> - Page origin: <code id="origin">...</code> · API: <code>http://127.0.0.1:24680</code> - · ab: <span id="ver" class="pill">unknown</span> - · web control: <span id="wc" class="pill">?</span> - </div> - <div id="sandbox-warn" style="display:none; margin:10px 0; padding:10px 14px; border:1px solid #f85149; border-radius:8px; background:rgba(248,81,73,.08); color:#f85149; font-size:14px;"> - <b>This embed cannot reach Adom Bridge.</b> The page is running in a sandboxed view (origin <code>null</code>) - whose security policy blocks every fetch to <code>127.0.0.1</code>, so ALL buttons here will fail with - “Failed to fetch”. Open the page directly instead: - <a href="http://127.0.0.1:24681/" target="_blank" rel="noopener" style="color:#58a6ff">http://127.0.0.1:24681/</a> - (any real browser tab works). - </div> - - <div class="bar"> - <label for="target">Run on machine:</label> - <select id="target"><option value="">this machine (self)</option></select> - <button id="refresh-targets" style="background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:13px">Detect my computers</button> - <span class="pill" id="target-count">self only until detected</span> - </div> - - <div class="grp">Detect - open to any page, no permission prompt</div> - <div class="cards" id="grp-read"></div> - - <div class="grp">Info verbs - each asks your permission</div> - <div class="cards" id="grp-info"></div> - - <div class="grp">Do something - each asks permission, streams live status, then captures the app's FULL window state</div> - <div class="cards" id="grp-do"></div> - - <div class="grp">KiCad surfaces - a REAL wiki part, end to end: <a href="https://wiki.adom.inc/aravk/in-s42atr" target="_blank" rel="noopener">Inolux IN-S42ATR</a> (red 0402 SMD LED). Each button fetches the part's files from its wiki page, installs them into your real KiCad, opens JUST that surface in the background, and renders it from kicad_state. This is the whole circle: wiki component to the app the EE designs in.</div> - <div class="cards" id="grp-kicad"></div> - - <div class="grp">Fusion surfaces - the SAME wiki part (IN-S42ATR.lbr + .step from its page), one first-class verb per content type (fusion_show_*, v1.9.10+). Component surfaces stage the library automatically; design surfaces additionally need the part project staged (fusion-bridge #30).</div> - <div class="cards" id="grp-fusion"></div> - - <div class="grp">Multi-machine - fan a verb across every connected computer</div> - <div class="cards" id="grp-multi"></div> - - <div class="grp">How a web page drives Adom Bridge (the embed pattern + sample code)</div> - <div class="doc"> - <p>There is <b>no SDK and no library</b>. A web page drives the machine with a plain <code>fetch()</code> (AJAX) to Adom Bridge's loopback HTTP API at <code>http://127.0.0.1:24680</code>. That is the whole integration: the wiki, or any other Adom surface, embeds a page whose buttons do the fetches below. Every state-changing call pops an Allow / Deny prompt in the app before anything runs.</p> - - <h3>1. Detect it (open to any page, no prompt)</h3> - <pre><code>const r = await fetch("http://127.0.0.1:24680/health"); -const h = await r.json(); // { ok: true, service: "adom-bridge" } -const running = h.service === "adom-bridge";</code></pre> - - <h3>2. Run one verb (pops Allow / Deny in the app, then runs)</h3> - <pre><code>const r = await fetch("http://127.0.0.1:24680/command", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command: "notify_user", args: { title: "Hi", body: "from a web page" } }) -}); -const out = await r.json(); -// 200 = ran. 403 { errorCode: "web_denied" } = the user clicked Deny. -// A screenshot comes back as base64 you render inline: -// { command: "desktop_screenshot_base64", args: { hwnd: 12345, maxWidth: 900 } } -> out.image</code></pre> - - <h3>3. Stream a long op (open app → wait for its window → screenshot), live status over ONE connection</h3> - <pre><code>const resp = await fetch("http://127.0.0.1:24680/command/stream", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ label: "Open KiCad", steps: [ - { command: "kicad_open_symbol_editor", args: {}, label: "Launching KiCad", usuallySec: 30 }, - { waitForWindow: { titleContains: "Symbol Editor", timeoutSec: 90 }, label: "Waiting for the window" } - ]}) -}); -// Read the Server-Sent-Events stream: -const reader = resp.body.getReader(), dec = new TextDecoder(); let buf = ""; -for (;;) { - const { value, done } = await reader.read(); if (done) break; - buf += dec.decode(value, { stream: true }); let i; - while ((i = buf.indexOf("\n\n")) >= 0) { - const block = buf.slice(0, i); buf = buf.slice(i + 2); - let ev = "message", data = ""; - block.split("\n").forEach(l => { - if (l.startsWith("event:")) ev = l.slice(6).trim(); - else if (l.startsWith("data:")) data += l.slice(5).trim(); - }); - console.log(ev, JSON.parse(data)); // ev = start | progress | image | done - } -}</code></pre> - - <h3>To capture an app's FULL state, screenshot every one of its windows</h3> - <pre><code>// 1) list windows, 2) keep the app's, 3) screenshot EACH by hwnd (the parent/child array). -const wins = JSON.parse((await (await fetch(API + "/command", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command: "desktop_list_windows" }) })).json()).output).data.windows; -const mine = wins.filter(w => /kicad|symbol editor|pcb editor/i.test(w.title)); -for (const w of mine) { - const shot = await (await fetch(API + "/command", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command: "desktop_screenshot_base64", args: { hwnd: w.hwnd, maxWidth: 900 } }) })).json(); - render(shot.image, w.title); // shot.image is base64 PNG -}</code></pre> - - <h3>Security and embedding notes</h3> - <ul> - <li>Adom Bridge binds <b>127.0.0.1 only</b> and runs the human Allow / Deny gate on every command. Detection (<code>/health</code>, <code>/status</code>) is open; commands are gated. It also supports an optional cloud-K-V handshake so only the real signed-in wiki can even reach the prompt.</li> - <li><code>http://127.0.0.1</code> is a browser <b>secure context</b>, so an HTTPS page reaching it is <b>not</b> blocked as mixed content, and Adom Bridge returns the CORS + Private-Network-Access headers a public page needs.</li> - <li><span class="warn">Caveat - the wiki readme iframe cannot do this yet.</span> The wiki serves a readme.html inside a sandboxed, null-origin iframe with <code>Content-Security-Policy: ... connect-src 'self'</code>, which blocks the fetch to loopback. A normally-served page (like this one) works fine; the wiki-embedded case needs the wiki to allow <code>connect-src http://127.0.0.1:*</code> (filed as a wiki issue). Until then, embed this on a normally-served Adom page, not inside the sandboxed readme iframe.</li> - </ul> - </div> - - <details><summary>Full request/response log</summary><pre id="log">Ready.</pre></details> -</div> - -<script> -(function () { - var AB = "http://127.0.0.1:24680"; - var log = document.getElementById("log"); - var dot = document.getElementById("dot"); - var sel = document.getElementById("target"); - document.getElementById("origin").textContent = location.origin || "(null)"; - // A sandboxed embed (wiki readme iframe, an in-app webview) runs at origin - // "null" and its CSP blocks EVERY loopback fetch - the whole page is dead - // there. Say so up front instead of letting each button fail cryptically. - var SANDBOXED = !location.origin || location.origin === "null"; - function sandboxWarn() { document.getElementById("sandbox-warn").style.display = "block"; } - if (SANDBOXED) sandboxWarn(); - // A dead fetch means the SANDBOX banner ONLY when we actually are sandboxed. - // In a real tab the same TypeError is a network transient (ab restarting, a - // stale keep-alive socket after a reboot) - showing the null-origin banner - // there was a lie John caught on 2026-08-17. Real-tab failures are handled - // by the one-shot POST retry in postRaw below. - function fetchDied(e) { if (SANDBOXED && /failed to fetch|networkerror/i.test(String(e))) sandboxWarn(); } - // Chrome auto-retries a GET on a stale reused connection but NEVER a POST - - // so one dead keep-alive socket makes exactly one button fail instantly. - // Retry a network-failed POST once after 600ms; a repeat failure is real. - function postRaw(body) { - var doFetch = function () { - return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); - }; - return doFetch().catch(function (e) { - if (SANDBOXED || !/failed to fetch|networkerror/i.test(String(e))) throw e; - logLine(" (network drop - retrying the POST once)"); - return new Promise(function (r) { setTimeout(r, 600); }).then(doFetch); - }); - } - function logLine(m) { log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; } - function chosenTarget() { return sel.value || null; } - // WHO is "this machine": ab 2.0.53+ reports its relay client name at - // /status.machine, so every card can say "on AdomLapper" instead of a vague - // "this machine" (John: "always tell me which machine this demo is running on"). - var selfName = null, selfVersion = null, latestVersion = null; - function loadSelfName() { - return fetch(AB + "/status").then(function (r) { return r.json(); }).then(function (s) { - var m = s && s.machine; - if (m && (m.clientName || m.hostname)) selfName = m.clientName || m.hostname; - if (s && s.version) selfVersion = s.version; - return selfName; - }).catch(function () { return null; }); - } - // The LATEST published ab, from the wiki's own update manifest (the same file - // the auto-updater reads) - so the picker can say whether a machine is - // up to date, not just what it runs (John, 2026-08-17). - function loadLatestVersion() { - return fetch("https://wiki.adom.inc/api/v1/pages/adom-bridge/files/version.json") - .then(function (r) { return r.json(); }) - .then(function (v) { latestVersion = v && v.version; return latestVersion; }) - .catch(function () { return null; }); - } - function verParts(v) { return String(v || "0").split(".").map(function (x) { return parseInt(x, 10) || 0; }); } - function verBehind(v) { // true when v < latestVersion - if (!v || !latestVersion) return false; - var a = verParts(v), b = verParts(latestVersion); - for (var i = 0; i < 3; i++) { if ((a[i] || 0) < (b[i] || 0)) return true; if ((a[i] || 0) > (b[i] || 0)) return false; } - return false; - } - function verTag(v) { // "(v2.0.54 - update available)" | "(v2.0.55, latest)" | "(v2.0.55)" - if (!v) return ""; - if (!latestVersion) return " (v" + v + ")"; - return verBehind(v) ? " (v" + v + " - update available, latest " + latestVersion + ")" : " (v" + v + ", latest)"; - } - function selfOptionLabel() { return "this machine (" + selfLabel() + ")" + (selfVersion ? verTag(selfVersion) : ""); } - Promise.all([loadSelfName(), loadLatestVersion()]).then(function () { - // Name + version the "self" option as soon as we know - no Detect needed. - if (selfName && sel.options.length && !sel.options[0].value) sel.options[0].textContent = selfOptionLabel(); - }); - // The label for where a run lands. Verbs that pass no target run on SELF. - function machineLabel(target) { - if (target === undefined) target = chosenTarget(); - if (target === "all") return "ALL machines"; - return target || selfName || "this machine"; - } - function selfLabel() { return selfName || "this machine"; } - function setRes(el, cls, tag, detail) { - el.className = "res " + cls; - el.innerHTML = "<span class='tag'>" + tag + "</span>" + (detail ? " <small>" + detail + "</small>" : ""); - } - // Most desktop verbs return {success, output:"<stringified json>"}. Parse output up. - function flat(j) { - if (j && typeof j.output === "string" && j.output.charAt(0) === "{") { - try { return Object.assign({}, j, JSON.parse(j.output)); } catch (e) {} - } - return j; - } - function esc(s) { return (s || "").replace(/[<>&]/g, function (c) { return { "<": "<", ">": ">", "&": "&" }[c]; }); } - - function doGet(el, path, summarize) { - setRes(el, "pending", "checking...", ""); - logLine("GET " + AB + path); - fetch(AB + path).then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); }) - .then(function (res) { logLine(" <- HTTP " + res.s); if (res.s === 200) setRes(el, "ok", "OK", summarize ? summarize(res.j) : ""); else setRes(el, "err", "HTTP " + res.s, ""); }) - .catch(function (e) { fetchDied(e); setRes(el, "err", "unreachable", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : String(e)); }); - } - function doVerb(el, command, args, target, summarize) { - var body = { command: command }; if (args) body.args = args; - if (target === undefined) target = chosenTarget(); if (target) body.target = target; - if (target) { body.args = body.args || {}; if (!body.args.caller) body.args.caller = { aiThread: "web-control demo page", containerName: selfLabel() }; } - var who = machineLabel(target || null); - setRes(el, "pending", "asking permission...", "on " + esc(who) + " - approve the Adom Bridge toast (no banner? Windows may have tucked it in the notification center - press Win+N)"); - logLine("POST /command " + JSON.stringify(body) + " [on " + who + "]"); - postRaw(body) - .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); }) - .then(function (res) { - logLine(" <- HTTP " + res.s + " " + JSON.stringify(res.j).slice(0, 300)); - if (res.s === 200) { var sum = summarize ? summarize(flat(res.j)) : ""; setRes(el, "ok", "Allowed & ran", "on " + esc(who) + (sum ? " - " + sum : "")); } - else if (res.j && res.j.errorCode === "web_control_disabled") setRes(el, "err", "Refused: web control OFF", "re-enable in Settings → Web Control"); - else setRes(el, "err", "Not approved", ((res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)) + " - missed the prompt? The toast waits in your notification center for 1 hour; Allow 1 hr there, then click again"); - }) - .catch(function (e) { fetchDied(e); setRes(el, "err", "blocked", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : "network drop to ab (stale connection or ab restarting) - retried once; click again in a moment. " + String(e)); }); - } - // POST that returns the flattened JSON. Optional target. - function postVerb(command, args, target) { - var body = { command: command }; if (args) body.args = args; if (target) body.target = target; - // A relayed verb arrives at the OTHER machine via_relay, where ab requires - // caller identity - name this page as the thread so the run is attributed. - if (target) { body.args = body.args || {}; if (!body.args.caller) body.args.caller = { aiThread: "web-control demo page", containerName: selfLabel() }; } - logLine("POST /command " + JSON.stringify(body)); - return postRaw(body) - .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: flat(j) }; }); }); - } - // Loopback self POST (the window array + per-window screenshots run on THIS machine). - function postSelf(command, args) { - return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: command, args: args || {} }) }) - .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: flat(j) }; }); }); - } - function windowsList() { - return postSelf("desktop_list_windows", {}).then(function (res) { - var j = res.j, out = j.output; try { if (typeof out === "string") out = JSON.parse(out); } catch (e) {} - return (out && out.data && out.data.windows) || (out && out.windows) || j.windows || []; - }); - } - - function card(groupId, cls, label) { - var c = document.createElement("div"); c.className = "card " + cls; - var b = document.createElement("button"); b.textContent = label; - var r = document.createElement("div"); r.className = "res idle"; r.textContent = "not run yet"; - c.appendChild(b); c.appendChild(r); document.getElementById(groupId).appendChild(c); - return { btn: b, res: r, card: c }; - } - // Per-card live stream log (right under the button). - function cardLog(card) { - var el = card.querySelector("pre.slog"); - if (!el) { el = document.createElement("pre"); el.className = "slog"; card.appendChild(el); } - return el; - } - function clog(c, msg) { var el = cardLog(c.card); el.textContent += (el.textContent ? "\n" : "") + msg; el.scrollTop = el.scrollHeight; } - // Per-card gallery of screenshots (the app's window array). - function shotsBox(card) { var b = card.querySelector("div.shots"); if (!b) { b = document.createElement("div"); b.className = "shots"; card.appendChild(b); } return b; } - function addShot(card, b64, label) { - var box = shotsBox(card); - // Demo runs number their evidence in ARRIVAL order (1., 2., ...) so the - // gallery reads as the receipt stream it is - John's ask, 2026-08-16. - if (card._numberEvidence) { card._seq = (card._seq || 0) + 1; label = card._seq + ". " + label; } - var wrap = document.createElement("div"); - var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = label; - var img = document.createElement("img"); img.src = "data:image/png;base64," + b64; - wrap.appendChild(cap); wrap.appendChild(img); box.appendChild(wrap); - } - - // Capture the app's FULL window state: list windows, keep the ones matching this - // app (include patterns, minus exclude patterns), and screenshot EACH by hwnd. - // This is the parent/child window array - the real "state of the union" of the app. - function captureWindows(c, spec) { - clog(c, "Capturing the app's full window state (all its windows) ..."); - var inc = (spec.include || []).map(function (s) { return s.toLowerCase(); }); - var exc = (spec.exclude || []).map(function (s) { return s.toLowerCase(); }); - // A just-opened window (esp. a browser) may still be LOADING - its title is - // not the page title yet. So poll a few seconds before giving up. - var attempts = 0, maxAttempts = 8; - function findMatches() { - return windowsList().then(function (wins) { - return wins.filter(function (w) { var t = (w.title || "").toLowerCase(); return inc.some(function (p) { return t.indexOf(p) >= 0; }) && !exc.some(function (p) { return t.indexOf(p) >= 0; }); }); - }); - } - function attempt() { - attempts++; - return findMatches().then(function (match) { - if (!match.length) { - if (attempts < maxAttempts) { clog(c, " [" + attempts + "] no matching window yet - waiting for it to finish loading ..."); return new Promise(function (r) { setTimeout(r, 1200); }).then(attempt); } - clog(c, " no windows matched after " + attempts + " tries - is the app actually open?"); setRes(c.res, "err", "no windows to capture", "the app may not have opened, or its title never matched"); - return; - } - clog(c, " found " + match.length + " window(s): " + match.map(function (w) { return '"' + (w.title || "").slice(0, 42) + '"'; }).join(", ")); - var i = 0; - function next() { - if (i >= match.length) { setRes(c.res, "ok", "Done - full state below", match.length + " window(s)"); clog(c, "DONE - captured the app's full window state (" + match.length + " windows)."); return; } - var w = match[i++]; - return postSelf("desktop_screenshot_base64", { hwnd: w.hwnd, maxWidth: 900 }).then(function (sr) { - if (sr.s === 200 && sr.j.image) { clog(c, " captured window " + i + "/" + match.length + ': "' + (w.title || "").slice(0, 50) + '" ' + sr.j.width + "x" + sr.j.height); addShot(c.card, sr.j.image, w.title || "window"); } - else { clog(c, " could not capture: \"" + (w.title || "") + "\""); } - return next(); - }); - } - return next(); - }); - } - return attempt(); - } - - // ---- SSE streaming (open -> wait for window), then capture the window array ---- - function onStream(c, ev, dataStr) { - var d = {}; try { d = JSON.parse(dataStr); } catch (e) {} - var t = Math.round((d.elapsedMs || 0) / 1000); - var u = (d.usuallySec != null) ? (" (usually ~" + d.usuallySec + "s)") : ""; - var k = (d.tookMs != null) ? (" in " + (d.tookMs / 1000).toFixed(1) + "s") : ""; - var w = (d.waitedSec != null ? d.waitedSec : t); - if (ev === "start") { clog(c, "Adom Bridge accepted the recipe (" + d.steps + " steps). Streaming live:"); return; } - if (ev === "progress") { - if (d.state === "running") { setRes(c.res, "pending", d.label + " ...", "running" + u); clog(c, "> step " + d.step + "/" + d.of + ": " + d.label + u + " ..."); } - else if (d.state === "working") { setRes(c.res, "pending", d.label, w + "s - still working" + u); clog(c, " [" + w + "s] still working" + u + " ..."); } - else if (d.state === "waiting") { setRes(c.res, "pending", "waiting for the window", w + "s" + u); clog(c, " [" + w + "s] waiting for the window" + u + " ..."); } - else if (d.state === "appeared") { setRes(c.res, "pending", "window appeared" + k, d.window || ""); clog(c, ' window appeared: "' + (d.window || "") + '"' + k); } - else if (d.state === "ok") { clog(c, " step " + d.step + " done" + k); } - else if (d.state === "failed") { setRes(c.res, "err", "step " + d.step + " failed", JSON.stringify(d.result || {}).slice(0, 120)); clog(c, " step " + d.step + " FAILED" + k); } - return; - } - if (ev === "error") { setRes(c.res, "err", "error", d.error || ""); clog(c, " ERROR: " + (d.error || "")); return; } - if (ev === "done") { - if (d.ok) { clog(c, " launch confirmed (total " + t + "s). Now grabbing the full window state ..."); if (c._capture) captureWindows(c, c._capture); } - else { setRes(c.res, "err", "did not complete", d.reason || ("failed step " + d.failedStep)); clog(c, "DONE - did not complete."); } - return; - } - } - function streamRecipe(c, steps, capture) { - c._capture = capture; - c.btn.disabled = true; - cardLog(c.card).textContent = ""; - var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = ""; - setRes(c.res, "pending", "opening a live connection ...", "approve the toast, then watch"); - logLine("POST /command/stream " + c.btn.textContent); - function finish() { c.btn.disabled = false; } - fetch(AB + "/command/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: c.btn.textContent, steps: steps }) }) - .then(function (resp) { - if (!resp.ok) { return resp.json().catch(function () { return {}; }).then(function (j) { setRes(c.res, "err", (j.errorCode === "web_control_disabled" ? "web control OFF" : "refused"), j.error || j.errorCode || ("HTTP " + resp.status)); finish(); }); } - var reader = resp.body.getReader(), dec = new TextDecoder(), buf = ""; - function pump() { - return reader.read().then(function (rr) { - if (rr.done) { finish(); return; } - buf += dec.decode(rr.value, { stream: true }); var idx; - while ((idx = buf.indexOf("\n\n")) >= 0) { - var raw = buf.slice(0, idx); buf = buf.slice(idx + 2); - var ev = "message", data = ""; raw.split("\n").forEach(function (l) { if (l.indexOf("event:") === 0) ev = l.slice(6).trim(); else if (l.indexOf("data:") === 0) data += l.slice(5).trim(); }); - if (data) onStream(c, ev, data); - } - return pump(); - }); - } - return pump(); - }) - .catch(function (e) { setRes(c.res, "err", "blocked", String(e)); finish(); }); - } - - // ---- Bridge-OWNED demo verb: one long-running call, real progress bar ---- - // This is the modern pattern: the BRIDGE owns a "demo" verb that runs the whole - // tour, reports structured progress (percent/stepLabel/beat/elapsed/estimated), - // and returns its full window state as inline screenshots. The web page just - // starts it and polls - no piecing bridge commands together, no title-guessing. - // (The KiCad bridge shipped kicad_demo in 0.9.92; Fusion's fusion_demo is next.) - function progressBar(card) { - var box = card.querySelector("div.pbar"); - if (!box) { - var meta = document.createElement("div"); meta.className = "pbar-meta"; meta.textContent = "starting ..."; - box = document.createElement("div"); box.className = "pbar"; - var fill = document.createElement("div"); fill.className = "pbar-fill"; box.appendChild(fill); - // insert right after the .res line - var res = card.querySelector("div.res"); - if (res && res.nextSibling) { card.insertBefore(meta, res.nextSibling); card.insertBefore(box, meta.nextSibling); } - else { card.appendChild(meta); card.appendChild(box); } - } - return { fill: box.querySelector(".pbar-fill"), meta: card.querySelector(".pbar-meta") }; - } - function setProgress(card, pct, label, beatIdx, beatCount, elapsed, estimate) { - var p = progressBar(card); - p.fill.style.width = Math.max(0, Math.min(100, pct || 0)) + "%"; - var beat = (beatCount ? ' <span class="beat">- step ' + Math.min((beatIdx || 0) + 1, beatCount) + "/" + beatCount + "</span>" : ""); - var timing = (elapsed != null && estimate != null) ? ' <span class="beat">- ' + elapsed + "s of ~" + estimate + "s</span>" : ""; - p.meta.innerHTML = (pct != null ? pct + "% " : "") + esc(label || "") + beat + timing; - } - function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&", "<": "<", ">": ">" }[c]; }); } - - // Drive a bridge's demo verb: start (background) -> poll progress -> render the - // returned window array. cfg = { startCmd, startArgs, pollArgs, stateCmd }. - function runBridgeDemo(c, cfg) { - // The demo HONORS the machine picker: pick ConfRoomROG and the tour runs - // THERE (this page's ab relays the verbs to that machine). Captured ONCE - // at click so changing the picker mid-run cannot split the run. - var target = chosenTarget(); - if (target === "all") { setRes(c.res, "err", "pick ONE machine", "a demo streams per-machine progress - fan-out is not supported here"); return; } - var who = machineLabel(target); - c._who = who; - c._bridgeShots = false; - c.card._numberEvidence = true; c.card._seq = 0; // fresh numbering per run - c.btn.disabled = true; - cardLog(c.card).textContent = ""; - var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = ""; - setProgress(c.card, 0, "starting ..."); - setRes(c.res, "pending", "asking permission (choose “Allow 1 hr”) ...", "runs on " + esc(who) + " - the demo runs many steps; a 1-hour grant keeps the progress silent"); - clog(c, "Target machine: " + who + (target ? " (relayed through this machine's Adom Bridge)" : " (the machine serving this page)")); - clog(c, "Starting the bridge's own demo verb: " + cfg.startCmd + " " + JSON.stringify(cfg.startArgs || {})); - // 1) Start the background tour (cross-origin -> ONE consent prompt). - postVerb(cfg.startCmd, cfg.startArgs || {}, target).then(function (res) { - if (res.s !== 200 || !res.j || (res.j.started !== true && res.j.success !== true)) { - if (res.j && res.j.errorCode === "web_control_disabled") { setRes(c.res, "err", "Refused: web control OFF", "re-enable in Settings → Web Control"); } - else { setRes(c.res, "err", "could not start", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)); } - c.btn.disabled = false; return; - } - var est = res.j.estimatedSec || 100; - // Thread an id from the START response into the poll args when the bridge - // hands one back (e.g. Fusion's fusion_demo returns a demoId that - // fusion_demo_progress needs). KiCad's poll is stateless, so no idFrom. - var pollArgs = Object.assign({}, cfg.pollArgs || { progress: true }); - if (cfg.idFrom) { - var idVal = res.j[cfg.idFrom]; - if (idVal == null && res.j.data) idVal = res.j.data[cfg.idFrom]; - if (idVal != null) { pollArgs[cfg.idAs || cfg.idFrom] = idVal; clog(c, " tracking " + (cfg.idAs || cfg.idFrom) + "=" + idVal); } - } - clog(c, "Started (bridge estimates ~" + est + "s). Polling for live progress ..."); - setProgress(c.card, res.j.percent || 2, "preparing ...", 0, 0, 0, est); - setRes(c.res, "pending", "running the demo ...", "usually ~" + est + "s - watch the bar"); - // LIVE PROOF: at every step transition, snap the app's current state via - // the bridge's state verb and pin it in the card - the user SEES each - // stage of the tour as it happens, not just text + a final gallery - // (John, 2026-08-16). One snap in flight at a time; a slow snap skips - // steps rather than queueing behind the tour. - var lastStep = null, snapBusy = false; - function maybeSnap(label) { - if (!cfg.snap || snapBusy) return; - snapBusy = true; - var done = function () { snapBusy = false; }; - // HONEST label: this is the page's own capture taken DURING the step, - // not the bridge's evidence OF the step - the window shown may lag the - // label. Bridge-supplied stepShot (above) supersedes these entirely. - var pin = function (img, title) { addShot(c.card, img, "during “" + label + "”: " + (title || "app window")); clog(c, " [page capture during \"" + label + "\": " + (title || "app window") + "]"); }; - if (cfg.snap.viaHwnd) { - // fusion_state's own screenshot exceeds the 4MB inline cap (it comes - // back as a PATH on the far machine), so snap via the hwnd instead: - // state verb -> hwnd -> desktop_screenshot_base64 (always inline). - postVerb(cfg.snap.viaHwnd, { screenshot: false }, target).then(function (wr) { - var wd = (wr.j && (wr.j.data || wr.j)) || {}; - if (!wd.hwnd) { done(); return; } - return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 640 }, target).then(function (sh) { - done(); - if (sh.s === 200 && sh.j && sh.j.image) pin(sh.j.image, wd.title); - }); - }).catch(done); - return; - } - postVerb(cfg.snap.cmd, Object.assign({}, cfg.snap.args || {}), target).then(function (sr) { - done(); - if (!(sr.s === 200 && sr.j)) return; - var withImg = findShots(sr.j).filter(function (s) { return s && (s.image || s.base64 || s.png); }); - if (!withImg.length) return; - var s = withImg[withImg.length - 1]; // the newest window is the step's surface - pin(s.image || s.base64 || s.png, s.title); - }).catch(done); - } - // 2) Poll progress every 2.5s (the Allow-1hr grant makes these silent). - var polls = 0, maxPolls = 240; // ~10 min ceiling - function poll() { - polls++; - if (polls > maxPolls) { setRes(c.res, "err", "timed out", "the demo did not finish in time"); c.btn.disabled = false; return; } - postVerb(cfg.pollCmd || cfg.startCmd, pollArgs, target).then(function (pr) { - if (pr.s !== 200 || !pr.j) { return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); } - var d = pr.j; - if (d.error && d.active === undefined && d.done === undefined) { - // job already gone - treat as done, go fetch the final state - clog(c, " progress job ended; fetching final state ..."); - return finishDemo(c, cfg, est, target); - } - setProgress(c.card, d.percent, d.stepLabel || d.step, d.beatIndex, d.beatCount, d.elapsedSec, d.estimatedSec || est); - // BRIDGE-SUPPLIED step evidence is authoritative: the bridge knows - // the exact moment a step completed and which window is that step's - // surface, so a `stepShot {step, title, image}` on a progress frame - // is pinned verbatim and disables the page's own guess-snaps (which - // race the tour and can label the wrong window - John's catch). - var ss = d.stepShot || (d.result && d.result.stepShot); - if (ss && (ss.image || ss.base64)) { - c._bridgeShots = true; - addShot(c.card, ss.image || ss.base64, "step: " + (ss.step || ss.title || d.stepLabel || "step")); - clog(c, " [proof] bridge evidence for \"" + (ss.step || ss.title || d.stepLabel) + "\""); - } - // Log + snap only on CHANGE - a 2.5s poll of a 30s step used to spam - // the same line eight times. - if (d.stepLabel && d.stepLabel !== lastStep) { - lastStep = d.stepLabel; - clog(c, " " + (d.percent != null ? d.percent + "% " : "") + d.stepLabel + (d.beatCount ? " (step " + ((d.beatIndex || 0) + 1) + "/" + d.beatCount + ")" : "")); - if (!c._bridgeShots) maybeSnap(d.stepLabel); - } - if (d.done === true || d.active === false) { - setProgress(c.card, 100, "done", d.beatCount, d.beatCount, d.elapsedSec, d.estimatedSec || est); - // The bridge rides its final state on the last poll (result.state.shots). - // Render it if present; otherwise ask the state verb. - if (!renderShots(c, d)) { return finishDemo(c, cfg, est, target); } - return; - } - return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); - }).catch(function (e) { return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); }); - } - poll(); - }).catch(function (e) { setRes(c.res, "err", "blocked", String(e)); c.btn.disabled = false; }); - } - // Pull the bridge's full state (the window array) and render it. - function finishDemo(c, cfg, est, target) { - if (!cfg.stateCmd) { setRes(c.res, "ok", "Demo finished", "the bridge closed its windows"); c.btn.disabled = false; return; } - clog(c, "Fetching the bridge's full window state: " + cfg.stateCmd); - return postVerb(cfg.stateCmd, {}, target).then(function (sr) { - if (sr.s === 200 && sr.j && renderShots(c, sr.j)) return; - // The state verb's own capture may not ride the relay inline (fusion's - // exceeds the 4MB cap and comes back as a far-machine PATH). Same - // fallback as the live snaps: hwnd -> desktop_screenshot_base64. - if (cfg.snap && cfg.snap.viaHwnd) { - clog(c, " no inline captures - falling back to an hwnd screenshot of the app"); - return postVerb(cfg.snap.viaHwnd, { screenshot: false }, target).then(function (wr) { - var wd = (wr.j && (wr.j.data || wr.j)) || {}; - if (!wd.hwnd) throw new Error("no app window reported"); - return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }, target).then(function (sh) { - if (!(sh.s === 200 && sh.j && sh.j.image)) throw new Error("screenshot failed"); - addShot(c.card, sh.j.image, wd.title || "app window"); - setRes(c.res, "ok", "Demo done - final window below", "1 window on " + esc(c._who || selfLabel()) + " (generic capture)"); - c.btn.disabled = false; - }); - }).catch(function (e2) { - setRes(c.res, "ok", "Demo finished", "the bridge already closed its windows - nothing to show (" + String(e2 && e2.message || e2).slice(0, 60) + ")"); - c.btn.disabled = false; - }); - } - setRes(c.res, "ok", "Demo finished", "the bridge already closed its windows - nothing to show"); - clog(c, "DONE - the demo completed; the bridge closed its windows before we captured state."); - c.btn.disabled = false; - }).catch(function (e) { setRes(c.res, "err", "state fetch failed", String(e)); c.btn.disabled = false; }); - } - // Find the bridge's window array wherever it rides: the final poll embeds it at - // result.state.shots; kicad_state returns it at shots. Handle every known path. - function findShots(d) { - // Bridges ride their window array on the final frame in various shapes - - // KiCad at result.state.shots, Fusion's final fusion_demo_progress poll at - // (an array called shots OR screenshots, possibly nested under data/result). - // Check every plausible path so a new bridge's demo renders with no rewrite. - var cands = [ - d.shots, d.screenshots, d.images, d.windows, - d.data && d.data.shots, d.data && d.data.screenshots, d.data && d.data.images, - d.state && d.state.shots, d.state && d.state.screenshots, - d.result && d.result.shots, d.result && d.result.screenshots, - d.result && d.result.state && d.result.state.shots, - d.result && d.result.state && d.result.state.screenshots - ]; - for (var i = 0; i < cands.length; i++) { - if (Array.isArray(cands[i]) && cands[i].length) return cands[i]; - } - // fusion_state (1.9.42+) is a SINGLE-window server-side render: a top-level - // base64 `image` (with imageAs:"base64") plus title/hwnd. Wrap it so the - // one-window shape renders through the same path as the arrays. - var dd = d.data || d; - var single = dd.image || dd.imageBase64 || dd.screenshotBase64; - if (typeof single === "string" && single.length > 100) { - return [{ title: dd.title || "app window", image: single }]; - } - return []; - } - // Render a bridge state payload's screenshots (each item: {title, image, ...}). - // Returns true if it rendered any window, false if there was nothing to show. - // pickTitle (optional): show JUST the window(s) whose title contains it — the - // surface buttons ask for ONE surface ("just the symbol"), and the state verb - // returns every open window. A zero-match filter falls back to all windows - // (better to over-show than render nothing when a title changes). - function renderShots(c, d, pickTitle) { - var narrate = (d.result && d.result.narrate) || d.narrate; - var failed = (d.result && d.result.failedSteps) || d.failedSteps; - if (narrate) clog(c, " bridge says: " + narrate); - if (Array.isArray(failed) && failed.length) clog(c, " note: the bridge could not open: " + failed.join(", ")); - var shots = findShots(d); - var withImg = shots.filter(function (s) { return s && (s.image || s.base64 || s.png); }); - if (!withImg.length) return false; - if (pickTitle) { - var want = pickTitle.toLowerCase(); - var picked = withImg.filter(function (s) { return ((s.title || s.label || "") + "").toLowerCase().indexOf(want) >= 0; }); - if (picked.length) { withImg = picked; } - else clog(c, ' (no window titled like "' + pickTitle + '" - showing all ' + withImg.length + ')'); - } - 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"); }); - 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; - } - - // Open ONE surface via a bridge's first-class show verb, then render it from the - // bridge's state verb. The show verbs open in the BACKGROUND by design, so we - // SHOW the window by screenshotting state - we never foreground the app. On a - // prerequisite miss the bridge returns a _hint; we surface it. - // cfg.stage (optional): a function(c) -> Promise run BEFORE the show verb — the - // staging chain (fetch the wiki part, install its library, generate the demo - // project). Electronics is a dependency ladder: you cannot show a 3D board - // without a routed 2D board, which needs a schematic, which needs the part's - // library installed. stage() owns climbing that ladder idempotently. - // cfg.showArgs may be a FUNCTION (called after stage) so args can carry paths - // the staging step just produced. - // cfg.pickTitle: render just the one window this surface is about. - // cfg.fallback {cmd,args}: retried when the primary verb is unknown (a not-yet- - // shipped show verb degrades to today's open verb instead of a dead button). - function showSurface(c, cfg) { - c.btn.disabled = true; - cardLog(c.card).textContent = ""; - var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = ""; - setRes(c.res, "pending", "asking permission (choose “Allow 1 hr”) ...", "on " + esc(selfLabel()) + " - " + (cfg.note || "opens in the background, then we screenshot it")); - clog(c, "Target machine: " + selfLabel()); - var staged = cfg.stage ? cfg.stage(c) : Promise.resolve(); - staged.then(function () { - var args = (typeof cfg.showArgs === "function") ? cfg.showArgs() : (cfg.showArgs || {}); - clog(c, "Calling " + cfg.showCmd + " " + JSON.stringify(args) + (cfg.note ? " (" + cfg.note + ")" : "")); - return postVerb(cfg.showCmd, args).then(function (res) { - if (res.j && res.j.windowEtiquette === "background") clog(c, " (opens in the BACKGROUND by design - the bridge's window etiquette; we render it below instead of stealing focus)"); - if (res.j && res.j._hint) clog(c, " bridge hint: " + res.j._hint); - var unknown = res.j && /unknown command/i.test(res.j.error || ""); - if (unknown && cfg.fallback) { - var fb = (typeof cfg.fallback === "function") ? cfg.fallback() : cfg.fallback; - clog(c, " " + cfg.showCmd + " is not shipped yet on this bridge - falling back to " + fb.cmd); - return postVerb(fb.cmd, fb.args || args).then(function (r2) { return handleShow(r2); }); - } - 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); - // 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 || ""))) { - c._autoStarted = true; - clog(c, " " + (res.j.error || "app not running") + " - calling " + cfg.startCmd + " (blocks until the add-in is ready) ..."); - setRes(c.res, "pending", "starting the app ...", "cold start can take a couple of minutes"); - return postVerb(cfg.startCmd, {}).then(function (st) { - if (!(st.s === 200 && st.j && st.j.success !== false)) { - setRes(c.res, "err", "could not start the app", (st.j && (st.j.error || st.j._hint)) || ("HTTP " + st.s)); - c.btn.disabled = false; return; - } - clog(c, " app is ready - retrying " + cfg.showCmd); - return postVerb(cfg.showCmd, args).then(handleShow); - }); - } - 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 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 ..."); - // The show verb MAY already carry shots; else pull the state verb. - if (renderShots(c, res.j, cfg.pickTitle)) return; - return settleThenCapture(c, cfg.stateCmd).then(function (sr) { - if (sr.s === 200 && sr.j && renderShots(c, sr.j, cfg.pickTitle)) return; - // The state verb may not be shipped on this bridge yet (fusion_state is - // "next release" per fusion-bridge #28). Fall back to a GENERIC window - // capture: ask the bridge which window is its app's, then screenshot it. - if (cfg.windowInfoCmd) { - clog(c, " " + cfg.stateCmd + " returned no captures - falling back to a window screenshot (" + cfg.windowInfoCmd + " + desktop_screenshot_base64)"); - return postVerb(cfg.windowInfoCmd, {}).then(function (wi) { - var wd = (wi.j && (wi.j.data || wi.j)) || {}; - if (!wd.hwnd) throw new Error("no app window reported"); - return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }).then(function (sh) { - if (!(sh.s === 200 && sh.j && sh.j.image)) throw new Error("screenshot failed"); - addShot(c.card, sh.j.image, wd.title || "app window"); - setRes(c.res, "ok", "Done - captured the app window", "generic capture (the bridge's state verb isn't shipped yet)"); - c.btn.disabled = false; - }); - }).catch(function (e2) { - setRes(c.res, "ok", "Opened" + (res.j.surface ? " (" + res.j.surface + ")" : ""), "no window captured: " + String(e2 && e2.message || e2).slice(0, 90)); - c.btn.disabled = false; - }); - } - setRes(c.res, "ok", "Opened" + (res.j.surface ? " (" + res.j.surface + ")" : ""), "no window captured yet (may need a prerequisite - see the hint)"); - c.btn.disabled = false; - }); - } - }).catch(function (e) { - setRes(c.res, "err", e && e.staged ? "staging failed" : "blocked", String(e && e.message || e).slice(0, 140)); - c.btn.disabled = false; - }); - } - - // The app may still be mid-load when the show verb returns (KiCad's "Load PCB" - // progress dialog raced the first capture and produced a bounding-box 3D render). - // Poll the state verb CHEAPLY (no images) until the window set is stable and no - // modal dialog remains, then take the real capture. Errors fall through to a - // direct capture so a bridge without includeImages support still renders. - function settleThenCapture(c, stateCmd) { - // ONE capture after a fixed settle delay - NO probe polling. The old loop - // polled the state verb up to 8x waiting for the window set to stabilize, - // and under an "Allow once" grant EVERY poll was its own consent prompt: - // John got NINE toasts for one screenshot (2026-08-17). Each cross-origin - // call costs one consent, so the page makes as few as possible. The - // bridges are being asked to own settle+capture inside the show verbs - // (capture:true) so this becomes zero extra calls. - clog(c, " waiting ~3.5s for the surface to settle, then ONE capture ..."); - return new Promise(function (r) { setTimeout(r, 3500); }) - .then(function () { return postVerb(stateCmd, { maxWidth: 900 }); }); - } - - // summaries - function n(x) { return (x === undefined || x === null) ? 0 : x; } - function bridgesSummary(j) { var d = j.data || j; var a = d.bridges || []; return a.length + " bridge(s): " + a.map(function (b) { return b.name; }).slice(0, 6).join(", "); } - function targetsSummary(j) { var d = j.data || j; var a = d.targets || d.clients || []; return (a.length || 1) + " machine(s): " + a.map(function (t) { return t.name || t.hostname; }).join(", "); } - function windowsSummary(j) { var d = j.data || j; var a = d.windows || []; return a.length + " open. Top 3: " + a.slice(0, 3).map(function (w) { return '"' + (w.title || "").slice(0, 26) + '"'; }).join(", "); } - function browsersSummary(j) { var d = j.data || j; var a = d.browsers || (Array.isArray(d) ? d : []); if (!Array.isArray(a)) a = []; return a.length + " installed. Top 3: " + a.slice(0, 3).map(function (b) { return b.name || b.browser || b.displayName || b.id || "browser"; }).join(", "); } - - // ---- Detect (reads) ---- - var cDetect = card("grp-read", "read", "Detect Adom Bridge"); - cDetect.btn.onclick = function () { - setRes(cDetect.res, "pending", "checking...", ""); - fetch(AB + "/health").then(function (r) { return r.json(); }).then(function (h) { - dot.style.background = (h && h.service === "adom-bridge") ? "#2ea043" : "#d29922"; - return fetch(AB + "/status").then(function (r) { return r.json(); }).then(function (s) { - if (s.machine && (s.machine.clientName || s.machine.hostname)) selfName = s.machine.clientName || s.machine.hostname; - document.getElementById("ver").textContent = "v" + (s.version || "?") + (selfName ? " on " + selfName : ""); - var wc = (s && s.webControl) || {}; var el = document.getElementById("wc"); - var on = wc.enabled !== false; el.textContent = on ? "ON" : "OFF"; el.style.color = on ? "#2ea043" : "#f85149"; - setRes(cDetect.res, "ok", "Running: v" + (s.version || "?"), "web control " + (on ? "ON" : "OFF")); - }); - }).catch(function (e) { fetchDied(e); dot.style.background = "#484f58"; setRes(cDetect.res, "err", "not reachable", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : String(e)); }); - }; - var cStatus = card("grp-read", "read", "Read status"); - cStatus.btn.onclick = function () { doGet(cStatus.res, "/status", function (s) { var st = n(s.bridgeUpdates && s.bridgeUpdates.staleCount); return "v" + s.version + " - web control " + ((s.webControl && s.webControl.enabled === false) ? "OFF" : "ON") + (st ? (" - " + st + " bridge update(s)") : ""); }); }; - - // ---- Info verbs (with top-3 lists) ---- - var cBridges = card("grp-info", "verb", "List installed bridges"); - cBridges.btn.onclick = function () { doVerb(cBridges.res, "bridge_list", null, undefined, bridgesSummary); }; - var cTargets = card("grp-info", "verb", "List my computers (targets)"); - cTargets.btn.onclick = function () { doVerb(cTargets.res, "targets", null, null, targetsSummary); }; - var cWindows = card("grp-info", "verb", "List open windows (top 3)"); - cWindows.btn.onclick = function () { doVerb(cWindows.res, "desktop_list_windows", null, undefined, windowsSummary); }; - var cApps = card("grp-info", "verb", "What apps are installed (top 3)"); - cApps.btn.onclick = function () { doVerb(cApps.res, "desktop_list_browsers", null, undefined, browsersSummary); }; - - // ---- Do something: stream the launch, then capture the FULL window array ---- - var cKicad = card("grp-do", "verb", "Run the KiCad demo (bridge-driven, live progress)"); - cKicad.btn.onclick = function () { - // The KiCad bridge OWNS this demo (kicad_demo, 0.9.92+): it builds a sample - // project, opens the symbol/footprint/3D/board/schematic views, reports real - // progress, and returns every window as a screenshot. We just start + poll. - runBridgeDemo(cKicad, { - startCmd: "kicad_demo", startArgs: { all: true, background: true }, - pollCmd: "kicad_demo", pollArgs: { progress: true }, - stateCmd: "kicad_state", - snap: { cmd: "kicad_state", args: { maxWidth: 640 } } - }); - }; - var cFusion = card("grp-do", "verb", "Run the Fusion demo (bridge-driven, live progress)"); - cFusion.btn.onclick = function () { - // The Fusion bridge OWNS this demo (fusion_demo, v1.9.9+): an async tour over - // symbol / footprint / device / 3D-package (+ schematic/2D/3D board) off its - // bundled sample library. Start returns a demoId that fusion_demo_progress - // needs, and the final poll carries the screenshot array. Same harness as - // KiCad, just a demoId to thread through. - runBridgeDemo(cFusion, { - startCmd: "fusion_demo", startArgs: { mode: "tour" }, - pollCmd: "fusion_demo_progress", pollArgs: {}, - idFrom: "demoId", idAs: "demoId", - stateCmd: "fusion_state", - snap: { viaHwnd: "fusion_state" } - }); - }; - // ---- The wiki part every surface button shows: Inolux IN-S42ATR ---- - // The part's page publishes the component artifacts (symbol, footprint, STEP, - // .lbr); the buttons fetch them straight off the wiki (the files API sends - // Access-Control-Allow-Origin: *), install them through the bridges' install - // verbs, and only then open the surface. Electronics is a dependency ladder — - // 3D board needs a routed board, needs a schematic, needs the library — so - // each stage function climbs exactly as far as its surface requires, and each - // is MEMOIZED (one staging per page load; every later button reuses it). - var PART = { - name: "IN-S42ATR", - kicadLib: "Adom", // kicad_install_symbol installs into this library - page: "https://wiki.adom.inc/aravk/in-s42atr", - filesBase: "https://wiki.adom.inc/api/pages/aravk/in-s42atr/files/", - dir: "~/Documents/adom-demo-parts/IN-S42ATR/" // staged on the PC via write_file (~ expands) - }; - function fetchPartText(f) { return fetch(PART.filesBase + f).then(function (r) { if (!r.ok) throw new Error("wiki fetch " + f + ": HTTP " + r.status); return r.text(); }); } - function fetchPartBytes(f) { return fetch(PART.filesBase + f).then(function (r) { if (!r.ok) throw new Error("wiki fetch " + f + ": HTTP " + r.status); return r.arrayBuffer(); }); } - function b64Text(s) { return btoa(unescape(encodeURIComponent(s))); } - function b64Bytes(buf) { - var u8 = new Uint8Array(buf), out = "", CH = 0x8000; - for (var i = 0; i < u8.length; i += CH) out += String.fromCharCode.apply(null, u8.subarray(i, i + CH)); - return btoa(out); - } - function stageFail(msg) { var e = new Error(msg); e.staged = true; return e; } - // Write one part file onto the PC via ab's write_file; resolves the ABSOLUTE path. - function stageFile(c, name, b64) { - return postSelf("write_file", { path: PART.dir + name, content: b64, encoding: "base64" }).then(function (r) { - if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("write_file " + name + ": " + ((r.j && (r.j.error || r.j.errorCode)) || r.s)); - clog(c, " staged " + name + " -> " + r.j.path); - return r.j.path; - }); - } - - // KiCad component staging (the kicad-bridge's own recipe, adom-bridge-sdk#4 - // reply 1007/1032): fetch symbol+footprint+STEP from the wiki, write all three - // onto the PC, then install by filePath. kicad_install_footprint's `model` - // arg (0.9.108) installs the .step into 3dmodels/Adom and WIRES the - // footprint's (model ...) reference itself — the page's old hand-splice is - // retired; the bridge owns model wiring now. - var _kicadPart = null; - function stageKicadPart(c) { - if (_kicadPart) { clog(c, "Part already staged this session (reusing)."); return _kicadPart; } - clog(c, "Staging " + PART.name + " from " + PART.page + " ..."); - _kicadPart = Promise.all([ - fetchPartText(PART.name + ".kicad_sym"), - fetchPartText(PART.name + ".kicad_mod"), - fetchPartBytes(PART.name + ".step") - ]).then(function (got) { - clog(c, " fetched symbol (" + got[0].length + " B), footprint (" + got[1].length + " B), STEP (" + got[2].byteLength + " B) from the wiki"); - return Promise.all([ - stageFile(c, PART.name + ".kicad_sym", b64Text(got[0])), - stageFile(c, PART.name + ".kicad_mod", b64Text(got[1])), - stageFile(c, PART.name + ".step", b64Bytes(got[2])) - ]); - }).then(function (paths) { - 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; - 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) { - if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("kicad_install_footprint: " + ((r.j && r.j.error) || r.s)); - clog(c, " installed footprint; the bridge wired the 3D model reference (0.9.108 model arg)"); - }); - }); - _kicadPart.catch(function () { _kicadPart = null; }); // a failed staging retries on the next click - return _kicadPart; - } - - // KiCad PROJECT staging — kicad_make_part_project (0.9.108+, closed #29): - // generates a real project around the part and returns the file paths the - // project/schematic/board buttons need. 0.9.112: SINGLE-COMPONENT by design - // (John's ruling — just the part, no companion resistor), refdes from the - // part's own Reference property, part-dominant tight edge cuts so an 0402 - // fills the 3D view instead of rendering as a speck. - var _kicadProj = null; - function stageKicadProject(c) { - if (_kicadProj) return _kicadProj; - _kicadProj = stageKicadPart(c).then(function () { - clog(c, "Generating the part's demo project (kicad_make_part_project) ..."); - return postVerb("kicad_make_part_project", { partName: PART.name }).then(function (r) { - if (r.s === 200 && r.j && r.j.success) { - PART.kicadProject = { - proPath: r.j.projectFile, - schPath: r.j.schematicFile, - pcbPath: r.j.boardFile - }; - clog(c, " project generated (" + (r.j.reference || PART.name) + " on a part-dominant board): " + (r.j.projectFile || "?")); - return; - } - throw stageFail("kicad_make_part_project: " + ((r.j && (r.j.error || r.j.errorCode)) || r.s)); - }); - }); - _kicadProj.catch(function () { _kicadProj = null; }); - return _kicadProj; - } - - // ---- KiCad surfaces: the eight-surface ladder for the wiki part ---- - // 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: "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: "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: "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: "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" }, - { label: "Schematic (just the " + PART.name + ")", cmd: "kicad_show_schematic", args: function () { return { filePath: (PART.kicadProject || {}).schPath }; }, stage: stageKicadProject, pick: "schematic editor", note: "the generated single-component schematic (kicad_make_part_project)" }, - { label: "2D board (part placed, tight edge cuts)", cmd: "kicad_show_2d_board", args: function () { return { filePath: (PART.kicadProject || {}).pcbPath }; }, stage: stageKicadProject, pick: "pcb editor", note: "the part-dominant board" }, - { label: "3D board (real 3D body on the board)", cmd: "kicad_show_3d_board", args: function () { return { filePath: (PART.kicadProject || {}).pcbPath }; }, stage: stageKicadProject, pick: "3d viewer", note: "the populated board in 3D - the whole ladder underneath it" }, - { label: "Project (top of the tree)", cmd: "kicad_show_project", args: function () { return { filePath: (PART.kicadProject || {}).proPath }; }, stage: stageKicadProject, pick: "kicad 10", note: "the Project Manager: schematic + board hang off it" } - ].forEach(function (s) { - var cc = card("grp-kicad", "verb", s.label); - cc.btn.onclick = function () { showSurface(cc, { showCmd: s.cmd, showArgs: s.args, stage: s.stage, pickTitle: s.pick, stateCmd: "kicad_state", note: s.note }); }; - }); - 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" }); }; - - // Fusion component staging: the SAME wiki part's .lbr + .step, written onto the - // PC (the fusion_show_* verbs take a libraryPath / stepPath). - var _fusionPart = null; - function stageFusionPart(c) { - if (_fusionPart) { clog(c, "Part already staged this session (reusing)."); return _fusionPart; } - clog(c, "Staging " + PART.name + ".lbr + .step from " + PART.page + " ..."); - _fusionPart = Promise.all([ - fetchPartText(PART.name + ".lbr"), - fetchPartBytes(PART.name + ".step") - ]).then(function (got) { - return Promise.all([ - stageFile(c, PART.name + ".lbr", b64Text(got[0])), - stageFile(c, PART.name + ".step", b64Bytes(got[1])) - ]).then(function (paths) { PART.lbrPath = paths[0]; PART.stepPath = paths[1]; }); - }); - _fusionPart.catch(function () { _fusionPart = null; }); - return _fusionPart; - } - // Fusion PROJECT staging — the fusion-bridge's own per-part flow (#30 reply, - // v1.9.41): (1) fusion_bind_library_3d mints the part's cloud 3D urn and binds - // it into the .lbr (signed-in Autodesk session required; best-effort — the - // design still stages without it, just with a placeholder 3D body), then - // (2) fusion_make_test_project generates a SINGLE-COMPONENT design (John's - // ruling: just the part — one symbol, one element on a small board) with the - // part's real refdes. After it, schematic / 2D / 3D board show the real design. - var _fusionProj = null; - function stageFusionProject(c) { - if (_fusionProj) return _fusionProj; - _fusionProj = stageFusionPart(c).then(function () { - clog(c, "Binding the part's 3D body into the library (fusion_bind_library_3d; needs a signed-in Autodesk session) ..."); - return postVerb("fusion_bind_library_3d", { libraryPath: PART.lbrPath, stepPath: PART.stepPath }).then(function (r) { - if (r.s === 200 && r.j && r.j.success) clog(c, " 3D body bound into the library"); - else clog(c, " 3D bind skipped (" + ((r.j && (r.j.error || r.j.errorCode)) || r.s) + ") - continuing; the 3D board may show a placeholder body"); - clog(c, "Generating the single-component design (fusion_make_test_project) ..."); - return postVerb("fusion_make_test_project", { libraryPath: PART.lbrPath }); - }).then(function (r) { - if (r.s === 200 && r.j && r.j.success) { clog(c, " design generated + open (" + (r.j.reference || PART.name) + ")"); return; } - throw stageFail("fusion_make_test_project: " + ((r.j && (r.j.error || r.j.errorCode)) || r.s)); - }); - }); - _fusionProj.catch(function () { _fusionProj = null; }); - return _fusionProj; - } - - // ---- Fusion surfaces: same part, same ladder ---- - // All eight fusion_show_* verbs are live (v1.9.41) and open in the BACKGROUND; - // fusion_state (fusion-bridge #28) is the render path, so pickTitle:"fusion" - // pulls the surface's window out of the state array. - [ - { label: "Symbol (" + PART.name + ")", cmd: "fusion_show_symbol", args: function () { return { name: PART.name, libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "full pinout in the symbol editor" }, - { label: "Footprint (" + PART.name + ")", cmd: "fusion_show_footprint", args: function () { return { name: PART.name, libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "pads + layers in the footprint editor" }, - { label: "3D package (" + PART.name + ")", cmd: "fusion_show_3d_package", args: function () { return { name: PART.name, libraryPath: PART.lbrPath, stepPath: PART.stepPath }; }, stage: stageFusionPart, note: "attaches the part's STEP to its footprint" }, - { label: "Library (" + PART.name + ".lbr)", cmd: "fusion_show_library", args: function () { return { libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "the Electronics Library with symbol + footprint + 3D" }, - { label: "Schematic (just the " + PART.name + ")", cmd: "fusion_show_schematic", args: function () { return {}; }, stage: stageFusionProject, note: "the generated single-component design's schematic view" }, - { label: "2D board (part placed)", cmd: "fusion_show_2d_board", args: function () { return {}; }, stage: stageFusionProject, note: "the PCB editor view of the staged design" }, - { label: "3D board (real 3D body)", cmd: "fusion_show_3d_board", args: function () { return {}; }, stage: stageFusionProject, note: "populated board in 3D: the bound urn renders the real component" }, - { label: "Project (top of the design)", cmd: "fusion_show_project", args: function () { return {}; }, stage: stageFusionProject, note: "the generated project's top level" } - ].forEach(function (s) { - var cc = card("grp-fusion", "verb", s.label); - 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 }); - }; - }); - // Single full-screen shot (keep the simple case too). - var cShot = card("grp-do", "verb", "Take a screenshot (whole screen)"); - cShot.btn.onclick = function () { - var sb = cShot.card.querySelector("div.shots"); if (sb) sb.innerHTML = ""; - setRes(cShot.res, "pending", "asking permission...", "approve the toast"); - postVerb("desktop_screenshot_base64", { maxWidth: 900 }, chosenTarget()).then(function (res) { - if (res.s === 200 && res.j.image) { setRes(cShot.res, "ok", "Captured " + res.j.width + "x" + res.j.height, ""); addShot(cShot.card, res.j.image, "whole screen"); } - else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cShot.res, "err", "Refused: web control OFF", "re-enable in Settings"); - else setRes(cShot.res, "err", "no image", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)); - }).catch(function (e) { setRes(cShot.res, "err", "blocked", String(e)); }); - }; - var cUrl = card("grp-do", "verb", "Open a URL (then screenshot the REAL browser)"); - cUrl.btn.onclick = function () { - streamRecipe(cUrl, [ - { command: "desktop_open_url", args: { url: "https://wiki.adom.inc" }, label: "Opening wiki.adom.inc in your default browser", usuallySec: 2 }, - { waitForWindow: { titleContains: "Adom Wiki", timeoutSec: 30 }, label: "Waiting for the page to load", usuallySec: 4 } - ], { include: ["adom wiki", "wiki.adom.inc"], exclude: ["for testing", "127.0.0.1", "harness", "puppeteer"] }); - }; - // Notify + VISIBLE state re-query. - var cNotify = card("grp-do", "verb", "Notify me (sticky + query state)"); - var notifyId = null; - cNotify.btn.onclick = function () { - notifyId = "webdemo-" + (Math.floor(Math.random() * 1e9)); - setRes(cNotify.res, "pending", "asking permission...", "approve the toast"); - postVerb("notify_user", { title: "Hello from a web page", body: "Sticky toast. Click Got it or Dismiss, then Check toast state.", scenario: "reminder", id: notifyId, buttons: [{ label: "Got it" }, { label: "Dismiss" }] }, chosenTarget()).then(function (res) { - var act = res.j && res.j.action; - if (res.s === 200 && act && String(act).toLowerCase().indexOf("error") === 0) { setRes(cNotify.res, "err", "notify rejected", String(act).slice(0, 140)); return; } - if (res.s === 200) { - if (res.j.id) notifyId = res.j.id; - setRes(cNotify.res, "ok", "Toast shown (id " + notifyId + ")", "click Got it / Dismiss on it, then Check toast state"); - if (!cNotify.card.querySelector("button.chk")) { - var cb = document.createElement("button"); cb.className = "chk"; cb.textContent = "Check toast state"; - cb.style.cssText = "margin-top:6px;background:#12181f"; - cb.onclick = function () { - setRes(cNotify.res, "pending", "re-querying toast state ...", "GET notify_state id=" + notifyId); - postVerb("notify_state", { id: notifyId }, chosenTarget()).then(function (sr) { - if (sr.s === 200) { - var st = sr.j.state || "unknown"; - var extra = sr.j.action ? (" (you clicked: " + sr.j.action + ")") : (sr.j.onScreen ? " (still on screen)" : ""); - setRes(cNotify.res, st === "responded" ? "ok" : "pending", "state: " + st + extra, sr.j.note || "click a toast button, then check again"); - logLine(" notify_state -> " + JSON.stringify(sr.j).slice(0, 200)); - } else setRes(cNotify.res, "err", "state query failed", (sr.j && (sr.j.error || sr.j.errorCode)) || ("HTTP " + sr.s)); - }).catch(function (e) { setRes(cNotify.res, "err", "state query error", String(e)); }); - }; - cNotify.card.appendChild(cb); - } - } else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cNotify.res, "err", "Refused: web control OFF", "re-enable in Settings"); - else setRes(cNotify.res, "err", "not approved", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)); - }).catch(function (e) { setRes(cNotify.res, "err", "blocked", String(e)); }); - }; - - // ---- Multi-machine ---- - var cNotifyAll = card("grp-multi", "multi", "Notify ALL my computers"); - cNotifyAll.btn.onclick = function () { doVerb(cNotifyAll.res, "notify_user", { title: "Fan-out from the web", body: "Sent to every connected Adom Bridge." }, "all", function (j) { var s = j.summary || (j.data && j.data.summary); return s ? (s.ok + "/" + s.total + " machines notified") : "sent to all machines"; }); }; - var cShotSel = card("grp-multi", "multi", "Screenshot the SELECTED machine"); - cShotSel.btn.onclick = function () { - var sb = cShotSel.card.querySelector("div.shots"); if (sb) sb.innerHTML = ""; - var who = machineLabel(); - setRes(cShotSel.res, "pending", "asking permission...", "capturing " + who); - postVerb("desktop_screenshot_base64", { maxWidth: 900 }, chosenTarget()).then(function (res) { - if (res.s === 200 && res.j.image) { setRes(cShotSel.res, "ok", "Captured " + res.j.width + "x" + res.j.height, "on " + who); addShot(cShotSel.card, res.j.image, who); } - else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cShotSel.res, "err", "Refused: web control OFF", "re-enable in Settings"); - else setRes(cShotSel.res, "err", "no image", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)); - }).catch(function (e) { setRes(cShotSel.res, "err", "blocked", String(e)); }); - }; - - // ---- target picker ---- - document.getElementById("refresh-targets").onclick = function () { - var btn = this, pill = document.getElementById("target-count"); - // The user must SEE the click did something, instantly - and see the - // failure in the pill, not buried in the collapsed log (John, 2026-08-16). - btn.disabled = true; pill.style.color = ""; pill.textContent = "detecting ... (approve the Adom Bridge prompt if one appears)"; - logLine("POST /command {targets}"); - // Resolve the self name first so the dedupe + "self" label are right even - // when Detect was never clicked (an old ab just returns no machine block). - (selfName ? Promise.resolve(selfName) : loadSelfName()) - .then(function () { return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: "targets" }) }); }) - .then(function (r) { return r.json(); }) - .then(function (j) { - btn.disabled = false; - // The direct API answers with clients[]; the relay-CLI shape is targets[]. - var d = j.data || j; var list = d.targets || d.clients || []; - // The relay lists THIS machine by name too - that row is redundant with - // the "self" option, so fold it in (it names the self option instead). - var peers = list.filter(function (t) { - var n = (t.name || t.hostname || "").toLowerCase(); - return !(selfName && n === selfName.toLowerCase()); - }); - sel.innerHTML = '<option value="">' + esc(selfOptionLabel()) + '</option>'; - peers.forEach(function (t) { var o = document.createElement("option"); o.value = t.name || t.hostname; o.textContent = (t.name || t.hostname) + verTag(t.version); sel.appendChild(o); }); - if (peers.length >= 1) { var a = document.createElement("option"); a.value = "all"; a.textContent = "ALL machines (fan out)"; sel.appendChild(a); } - document.getElementById("target-count").textContent = selfLabel() + (peers.length ? " + " + peers.length + " more" : " only") + (latestVersion ? " · latest ab v" + latestVersion : ""); - }) - .catch(function (e) { - btn.disabled = false; fetchDied(e); - pill.style.color = "#f85149"; - pill.textContent = SANDBOXED ? "blocked: sandboxed embed (see banner)" : ("detect failed: " + String(e).slice(0, 80)); - logLine("targets failed: " + e); - }); - }; -})(); -</script> -</body> -</html> +<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8" />+<meta name="viewport" content="width=device-width, initial-scale=1" />+<title>Adom Bridge web control - local test harness</title>+<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEuklEQVR4nLWXT2xUVRTGf/e+13mddkrbaYf+AbRCBUGMogGMgDEGXBB1YVxoQlxgiBg34MaFCTHqVsW4EILGxIWy0kRNCAkRRFloNNUEW6UUWirt0HaY/pl2+mbee9fFmce00BkmtJzkzZ2Ze3PPd757znfuUxgDSsGpM+1EMvtwzSN4ns3dMNv2cNSf5GLH2PnUEMagABgb2E5f/9cQrCTrhouX1rnnyRh1wOj/WH//S9S3n1Nc+KONwcG/iDUmSCY9IhGNUmZpvRfMGEUuF9DSajORHuW+VQ/bXBx4neZEguEhD8e5O9SHphQ4juZa0qOtPUHflf0apTYxM21wHHVXnc81x1HMTBuU2iQR2/binBsjD0iUqoLtbFthjFo85caAZUGiGTSQnYX0RBHM7XAs2nlzE+RcOHlaMn3dati4AVIpmJq+LYg7B2AMNDbA0DAcPASzOaiywfdgw3o4dACa4jCWAq1LblN6phKLReHwUXHw0FroWAWrO+DyJdh7UJhZFivmx5IBCM99fBJG0tC6HLI5CAKZ6+gA34f3D0O8qexWi2NAa7CtWyOcnYUVbXC+B853Q2N9SRYWB6CcKQWWDRcuQbS6JIDKk9Dc+JBh7oZKIV2lkPHGQHUE8p70lKD0trcHEDqyrPn/RaOS9fk8ZLPye64Y9Q1AdTXs2AKjYyXLsTyAsNRiURifKhyYBYEPVVXChLIg60oChpEGvuTAm69BxIHJzB0ACEVmaBjeOgrX06ALLGjAzYvzLz4UIPl8Yd4XIA11kMmKDpQRo4UBhGWWc0VktAWtifkU66xErhARymbnO5rKFNeWsYWrwBjR9p9/k83X3FPIaqvw6EIOVEmiZbPgB1L7vi/fK2xKpY9AK/DykmizuSIwCWvhKijnMLz63exmwcVKSVTr1oi2l5HSsha2aaWgNirj3NZdFkB6Qrrag+vh6rCU1M1rPF+yv5Tz2lo5sogDmWkZHQtqaiRpSwIILZWCtw/IJv39heTTEI1AcgSWN0LDMjn3ufQaA4m4AO9YCclrsPtFkeXOTujrh7ZWMDkUJ779nrq6Z8lkfMCaByAIiv3+3cPQ3SPymveEkY/egZblkB4vAjBG6E6Owd4DsG0zTE7BTFYYSMTh5I8+Xx2zWNHyQ3kh0hpS16GuFj5+TyL495LI644tsuHNda4UzLqw9VF4+gno6RWQ9cskoMtX4PHN8ORW6P2nAilWSm42U9OwagWsXSNCMzq2sMIpBXYETp2F1Lg411qY0Rqa45DJwOlfoDleYTcMazo9AVeTMJy89dxD+p2I3Atf3S+OQufhvCqMe96Ant4CAM+rrM7CJNR64ZpXCtyctN/Pj0AsJrSHa5UqdFIFX35ieKDTaIzpoqZW4bpL8zZkDHg52LkdmhpgJFUEEQQwloaaqGH3LgV2l6bz3k+ZSo3S0mrjuh7GBIB/x48xPhHH5+yvPid/8mlL+LiuT3rCZ3omoCXu0T9oc/ybEZ7ZfUS4mRjaRnfvcfQiX049IALk8nJP7O2HxzZC19/w/Cvw2QfwwnPw3YlB9ux9GaXOqRtS+fuZdiYz+/CX4vVcQd5AfZWMJoDui7BhrYfjdLFj1zGUGsYY/geDsv+dOi74jwAAAABJRU5ErkJggg==">+<style>+ :root { color-scheme: dark; }+ html, body { margin: 0; padding: 0; background: #0d1117; color: #e6edf3; font-family: system-ui, "Segoe UI", sans-serif; }+ .wrap { max-width: 1000px; margin: 0 auto; padding: 24px; }+ h1 { font-size: 20px; margin: 0 0 4px; }+ .sub { font-size: 12px; color: #8b949e; margin-bottom: 18px; }+ .sub code { color: #79c0ff; }+ .bar { display: flex; align-items: center; gap: 10px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px 14px; margin-bottom: 18px; flex-wrap: wrap; }+ .bar label { font-size: 12px; color: #8b949e; }+ select { background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 6px 8px; font-size: 13px; }+ #dot { width: 11px; height: 11px; border-radius: 50%; background: #484f58; display: inline-block; }+ .grp { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #6e7681; margin: 18px 0 8px; }+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; align-items: start; }+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }+ .card button { padding: 8px 10px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; cursor: pointer; font-size: 13px; text-align: left; transition: opacity .12s, border-color .12s; }+ .card.read button { background: #12181f; }+ .card.verb button { background: #1a2230; border-color: #2b3a55; }+ .card.multi button { background: #24202e; border-color: #4a3a63; }+ .card button:hover:not(:disabled) { border-color: #8b949e; }+ .card button:disabled { opacity: .5; cursor: default; }+ .res { font-size: 12px; line-height: 1.35; min-height: 16px; color: #8b949e; word-break: break-word; }+ .res .tag { font-weight: 600; }+ .res.idle { color: #6e7681; }+ .res.pending { color: #d29922; }+ .res.ok { color: #3fb950; }+ .res.err { color: #f85149; }+ .res small { color: #8b949e; font-weight: 400; }+ .slog { margin: 4px 0 0; padding: 8px; background: #010409; border: 1px solid #21262d; border-radius: 4px; font-size: 11px; max-height: 180px; overflow: auto; white-space: pre-wrap; color: #9aa4af; line-height: 1.45; }+ .shots { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }+ .shots .cap { font-size: 10px; color: #8b949e; margin-bottom: 3px; }+ .shots img { max-width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }+ .pbar { margin: 8px 0 2px; height: 16px; background: #010409; border: 1px solid #21262d; border-radius: 8px; overflow: hidden; position: relative; }+ .pbar-fill { height: 100%; width: 0; background: linear-gradient(90deg, #1f6feb, #2ea043); transition: width .35s ease; }+ .pbar-meta { font-size: 11px; color: #9aa4af; margin-bottom: 4px; }+ .pbar-meta .beat { color: #6e7681; }+ .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid #30363d; color: #8b949e; }+ details { margin-top: 20px; } summary { cursor: pointer; color: #8b949e; font-size: 12px; }+ pre#log { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; font-size: 11px; max-height: 240px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; color: #c9d1d9; }+ .doc { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px 18px; font-size: 13px; line-height: 1.55; color: #c9d1d9; }+ .doc h3 { font-size: 13px; margin: 18px 0 6px; color: #e6edf3; }+ .doc h3:first-of-type { margin-top: 4px; }+ .doc p { margin: 0 0 10px; }+ .doc code { background: #0d1117; border: 1px solid #21262d; border-radius: 4px; padding: 1px 5px; font-size: 12px; color: #79c0ff; }+ .doc pre { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; overflow: auto; margin: 0 0 10px; }+ .doc pre code { background: none; border: none; color: #c9d1d9; padding: 0; font-size: 12px; line-height: 1.55; display: block; white-space: pre; }+ .doc ul { margin: 6px 0 0; padding-left: 20px; } .doc li { margin: 5px 0; }+ .doc .warn { color: #d29922; }+</style>+</head>+<body>+<div class="wrap">+ <h1><span id="dot"></span> Adom Bridge web control - local test harness</h1>+ <div class="sub">+ Page origin: <code id="origin">...</code> · API: <code>http://127.0.0.1:24680</code>+ · ab: <span id="ver" class="pill">unknown</span>+ · web control: <span id="wc" class="pill">?</span>+ </div>+ <div id="sandbox-warn" style="display:none; margin:10px 0; padding:10px 14px; border:1px solid #f85149; border-radius:8px; background:rgba(248,81,73,.08); color:#f85149; font-size:14px;">+ <b>This embed cannot reach Adom Bridge.</b> The page is running in a sandboxed view (origin <code>null</code>)+ whose security policy blocks every fetch to <code>127.0.0.1</code>, so ALL buttons here will fail with+ “Failed to fetch”. Open the page directly instead:+ <a href="http://127.0.0.1:24681/" target="_blank" rel="noopener" style="color:#58a6ff">http://127.0.0.1:24681/</a>+ (any real browser tab works).+ </div>++ <div class="bar">+ <label for="target">Run on machine:</label>+ <select id="target"><option value="">this machine (self)</option></select>+ <button id="refresh-targets" style="background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:13px">Detect my computers</button>+ <span class="pill" id="target-count">self only until detected</span>+ </div>++ <div class="grp">Detect - open to any page, no permission prompt</div>+ <div class="cards" id="grp-read"></div>++ <div class="grp">Info verbs - each asks your permission</div>+ <div class="cards" id="grp-info"></div>++ <div class="grp">Do something - each asks permission, streams live status, then captures the app's FULL window state</div>+ <div class="cards" id="grp-do"></div>++ <div class="grp">KiCad surfaces - a REAL wiki part, end to end: <a href="https://wiki.adom.inc/aravk/in-s42atr" target="_blank" rel="noopener">Inolux IN-S42ATR</a> (red 0402 SMD LED). Each button fetches the part's files from its wiki page, installs them into your real KiCad, opens JUST that surface in the background, and renders it from kicad_state. This is the whole circle: wiki component to the app the EE designs in.</div>+ <div class="cards" id="grp-kicad"></div>++ <div class="grp">Fusion surfaces - the SAME wiki part (IN-S42ATR.lbr + .step from its page), one first-class verb per content type (fusion_show_*, v1.9.10+). Component surfaces stage the library automatically; design surfaces additionally need the part project staged (fusion-bridge #30).</div>+ <div class="cards" id="grp-fusion"></div>++ <div class="grp">Multi-machine - fan a verb across every connected computer</div>+ <div class="cards" id="grp-multi"></div>++ <div class="grp">How a web page drives Adom Bridge (the embed pattern + sample code)</div>+ <div class="doc">+ <p>There is <b>no SDK and no library</b>. A web page drives the machine with a plain <code>fetch()</code> (AJAX) to Adom Bridge's loopback HTTP API at <code>http://127.0.0.1:24680</code>. That is the whole integration: the wiki, or any other Adom surface, embeds a page whose buttons do the fetches below. Every state-changing call pops an Allow / Deny prompt in the app before anything runs.</p>++ <h3>1. Detect it (open to any page, no prompt)</h3>+ <pre><code>const r = await fetch("http://127.0.0.1:24680/health");+const h = await r.json(); // { ok: true, service: "adom-bridge" }+const running = h.service === "adom-bridge";</code></pre>++ <h3>2. Run one verb (pops Allow / Deny in the app, then runs)</h3>+ <pre><code>const r = await fetch("http://127.0.0.1:24680/command", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ command: "notify_user", args: { title: "Hi", body: "from a web page" } })+});+const out = await r.json();+// 200 = ran. 403 { errorCode: "web_denied" } = the user clicked Deny.+// A screenshot comes back as base64 you render inline:+// { command: "desktop_screenshot_base64", args: { hwnd: 12345, maxWidth: 900 } } -> out.image</code></pre>++ <h3>3. Stream a long op (open app → wait for its window → screenshot), live status over ONE connection</h3>+ <pre><code>const resp = await fetch("http://127.0.0.1:24680/command/stream", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ label: "Open KiCad", steps: [+ { command: "kicad_open_symbol_editor", args: {}, label: "Launching KiCad", usuallySec: 30 },+ { waitForWindow: { titleContains: "Symbol Editor", timeoutSec: 90 }, label: "Waiting for the window" }+ ]})+});+// Read the Server-Sent-Events stream:+const reader = resp.body.getReader(), dec = new TextDecoder(); let buf = "";+for (;;) {+ const { value, done } = await reader.read(); if (done) break;+ buf += dec.decode(value, { stream: true }); let i;+ while ((i = buf.indexOf("\n\n")) >= 0) {+ const block = buf.slice(0, i); buf = buf.slice(i + 2);+ let ev = "message", data = "";+ block.split("\n").forEach(l => {+ if (l.startsWith("event:")) ev = l.slice(6).trim();+ else if (l.startsWith("data:")) data += l.slice(5).trim();+ });+ console.log(ev, JSON.parse(data)); // ev = start | progress | image | done+ }+}</code></pre>++ <h3>To capture an app's FULL state, screenshot every one of its windows</h3>+ <pre><code>// 1) list windows, 2) keep the app's, 3) screenshot EACH by hwnd (the parent/child array).+const wins = JSON.parse((await (await fetch(API + "/command", { method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ command: "desktop_list_windows" }) })).json()).output).data.windows;+const mine = wins.filter(w => /kicad|symbol editor|pcb editor/i.test(w.title));+for (const w of mine) {+ const shot = await (await fetch(API + "/command", { method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ command: "desktop_screenshot_base64", args: { hwnd: w.hwnd, maxWidth: 900 } }) })).json();+ render(shot.image, w.title); // shot.image is base64 PNG+}</code></pre>++ <h3>Security and embedding notes</h3>+ <ul>+ <li>Adom Bridge binds <b>127.0.0.1 only</b> and runs the human Allow / Deny gate on every command. Detection (<code>/health</code>, <code>/status</code>) is open; commands are gated. It also supports an optional cloud-K-V handshake so only the real signed-in wiki can even reach the prompt.</li>+ <li><code>http://127.0.0.1</code> is a browser <b>secure context</b>, so an HTTPS page reaching it is <b>not</b> blocked as mixed content, and Adom Bridge returns the CORS + Private-Network-Access headers a public page needs.</li>+ <li><span class="warn">Caveat - the wiki readme iframe cannot do this yet.</span> The wiki serves a readme.html inside a sandboxed, null-origin iframe with <code>Content-Security-Policy: ... connect-src 'self'</code>, which blocks the fetch to loopback. A normally-served page (like this one) works fine; the wiki-embedded case needs the wiki to allow <code>connect-src http://127.0.0.1:*</code> (filed as a wiki issue). Until then, embed this on a normally-served Adom page, not inside the sandboxed readme iframe.</li>+ </ul>+ </div>++ <details><summary>Full request/response log</summary><pre id="log">Ready.</pre></details>+</div>++<script>+(function () {+ var AB = "http://127.0.0.1:24680";+ var log = document.getElementById("log");+ var dot = document.getElementById("dot");+ var sel = document.getElementById("target");+ document.getElementById("origin").textContent = location.origin || "(null)";+ // A sandboxed embed (wiki readme iframe, an in-app webview) runs at origin+ // "null" and its CSP blocks EVERY loopback fetch - the whole page is dead+ // there. Say so up front instead of letting each button fail cryptically.+ var SANDBOXED = !location.origin || location.origin === "null";+ function sandboxWarn() { document.getElementById("sandbox-warn").style.display = "block"; }+ if (SANDBOXED) sandboxWarn();+ // A dead fetch means the SANDBOX banner ONLY when we actually are sandboxed.+ // In a real tab the same TypeError is a network transient (ab restarting, a+ // stale keep-alive socket after a reboot) - showing the null-origin banner+ // there was a lie John caught on 2026-08-17. Real-tab failures are handled+ // by the one-shot POST retry in postRaw below.+ function fetchDied(e) { if (SANDBOXED && /failed to fetch|networkerror/i.test(String(e))) sandboxWarn(); }+ // Chrome auto-retries a GET on a stale reused connection but NEVER a POST -+ // so one dead keep-alive socket makes exactly one button fail instantly.+ // Retry a network-failed POST once after 600ms; a repeat failure is real.+ function postRaw(body) {+ var doFetch = function () {+ return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });+ };+ return doFetch().catch(function (e) {+ if (SANDBOXED || !/failed to fetch|networkerror/i.test(String(e))) throw e;+ logLine(" (network drop - retrying the POST once)");+ return new Promise(function (r) { setTimeout(r, 600); }).then(doFetch);+ });+ }+ function logLine(m) { log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }+ function chosenTarget() { return sel.value || null; }+ // WHO is "this machine": ab 2.0.53+ reports its relay client name at+ // /status.machine, so every card can say "on AdomLapper" instead of a vague+ // "this machine" (John: "always tell me which machine this demo is running on").+ var selfName = null, selfVersion = null, latestVersion = null;+ function loadSelfName() {+ return fetch(AB + "/status").then(function (r) { return r.json(); }).then(function (s) {+ var m = s && s.machine;+ if (m && (m.clientName || m.hostname)) selfName = m.clientName || m.hostname;+ if (s && s.version) selfVersion = s.version;+ return selfName;+ }).catch(function () { return null; });+ }+ // The LATEST published ab, from the wiki's own update manifest (the same file+ // the auto-updater reads) - so the picker can say whether a machine is+ // up to date, not just what it runs (John, 2026-08-17).+ function loadLatestVersion() {+ return fetch("https://wiki.adom.inc/api/v1/pages/adom-bridge/files/version.json")+ .then(function (r) { return r.json(); })+ .then(function (v) { latestVersion = v && v.version; return latestVersion; })+ .catch(function () { return null; });+ }+ function verParts(v) { return String(v || "0").split(".").map(function (x) { return parseInt(x, 10) || 0; }); }+ function verBehind(v) { // true when v < latestVersion+ if (!v || !latestVersion) return false;+ var a = verParts(v), b = verParts(latestVersion);+ for (var i = 0; i < 3; i++) { if ((a[i] || 0) < (b[i] || 0)) return true; if ((a[i] || 0) > (b[i] || 0)) return false; }+ return false;+ }+ function verTag(v) { // "(v2.0.54 - update available)" | "(v2.0.55, latest)" | "(v2.0.55)"+ if (!v) return "";+ if (!latestVersion) return " (v" + v + ")";+ return verBehind(v) ? " (v" + v + " - update available, latest " + latestVersion + ")" : " (v" + v + ", latest)";+ }+ function selfOptionLabel() { return "this machine (" + selfLabel() + ")" + (selfVersion ? verTag(selfVersion) : ""); }+ Promise.all([loadSelfName(), loadLatestVersion()]).then(function () {+ // Name + version the "self" option as soon as we know - no Detect needed.+ if (selfName && sel.options.length && !sel.options[0].value) sel.options[0].textContent = selfOptionLabel();+ });+ // The label for where a run lands. Verbs that pass no target run on SELF.+ function machineLabel(target) {+ if (target === undefined) target = chosenTarget();+ if (target === "all") return "ALL machines";+ return target || selfName || "this machine";+ }+ function selfLabel() { return selfName || "this machine"; }+ function setRes(el, cls, tag, detail) {+ el.className = "res " + cls;+ el.innerHTML = "<span class='tag'>" + tag + "</span>" + (detail ? " <small>" + detail + "</small>" : "");+ }+ // Most desktop verbs return {success, output:"<stringified json>"}. Parse output up.+ function flat(j) {+ if (j && typeof j.output === "string" && j.output.charAt(0) === "{") {+ try { return Object.assign({}, j, JSON.parse(j.output)); } catch (e) {}+ }+ return j;+ }+ function esc(s) { return (s || "").replace(/[<>&]/g, function (c) { return { "<": "<", ">": ">", "&": "&" }[c]; }); }++ function doGet(el, path, summarize) {+ setRes(el, "pending", "checking...", "");+ logLine("GET " + AB + path);+ fetch(AB + path).then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })+ .then(function (res) { logLine(" <- HTTP " + res.s); if (res.s === 200) setRes(el, "ok", "OK", summarize ? summarize(res.j) : ""); else setRes(el, "err", "HTTP " + res.s, ""); })+ .catch(function (e) { fetchDied(e); setRes(el, "err", "unreachable", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : String(e)); });+ }+ function doVerb(el, command, args, target, summarize) {+ var body = { command: command }; if (args) body.args = args;+ if (target === undefined) target = chosenTarget(); if (target) body.target = target;+ if (target) { body.args = body.args || {}; if (!body.args.caller) body.args.caller = { aiThread: "web-control demo page", containerName: selfLabel() }; }+ var who = machineLabel(target || null);+ setRes(el, "pending", "asking permission...", "on " + esc(who) + " - approve the Adom Bridge toast (no banner? Windows may have tucked it in the notification center - press Win+N)");+ logLine("POST /command " + JSON.stringify(body) + " [on " + who + "]");+ postRaw(body)+ .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })+ .then(function (res) {+ logLine(" <- HTTP " + res.s + " " + JSON.stringify(res.j).slice(0, 300));+ if (res.s === 200) { var sum = summarize ? summarize(flat(res.j)) : ""; setRes(el, "ok", "Allowed & ran", "on " + esc(who) + (sum ? " - " + sum : "")); }+ else if (res.j && res.j.errorCode === "web_control_disabled") setRes(el, "err", "Refused: web control OFF", "re-enable in Settings → Web Control");+ else setRes(el, "err", "Not approved", ((res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)) + " - missed the prompt? The toast waits in your notification center for 1 hour; Allow 1 hr there, then click again");+ })+ .catch(function (e) { fetchDied(e); setRes(el, "err", "blocked", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : "network drop to ab (stale connection or ab restarting) - retried once; click again in a moment. " + String(e)); });+ }+ // POST that returns the flattened JSON. Optional target.+ function postVerb(command, args, target) {+ var body = { command: command }; if (args) body.args = args; if (target) body.target = target;+ // A relayed verb arrives at the OTHER machine via_relay, where ab requires+ // caller identity - name this page as the thread so the run is attributed.+ if (target) { body.args = body.args || {}; if (!body.args.caller) body.args.caller = { aiThread: "web-control demo page", containerName: selfLabel() }; }+ logLine("POST /command " + JSON.stringify(body));+ return postRaw(body)+ .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: flat(j) }; }); });+ }+ // Loopback self POST (the window array + per-window screenshots run on THIS machine).+ function postSelf(command, args) {+ return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: command, args: args || {} }) })+ .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: flat(j) }; }); });+ }+ function windowsList() {+ return postSelf("desktop_list_windows", {}).then(function (res) {+ var j = res.j, out = j.output; try { if (typeof out === "string") out = JSON.parse(out); } catch (e) {}+ return (out && out.data && out.data.windows) || (out && out.windows) || j.windows || [];+ });+ }++ function card(groupId, cls, label) {+ var c = document.createElement("div"); c.className = "card " + cls;+ var b = document.createElement("button"); b.textContent = label;+ var r = document.createElement("div"); r.className = "res idle"; r.textContent = "not run yet";+ c.appendChild(b); c.appendChild(r); document.getElementById(groupId).appendChild(c);+ return { btn: b, res: r, card: c };+ }+ // Per-card live stream log (right under the button).+ function cardLog(card) {+ var el = card.querySelector("pre.slog");+ if (!el) { el = document.createElement("pre"); el.className = "slog"; card.appendChild(el); }+ return el;+ }+ function clog(c, msg) { var el = cardLog(c.card); el.textContent += (el.textContent ? "\n" : "") + msg; el.scrollTop = el.scrollHeight; }+ // Per-card gallery of screenshots (the app's window array).+ function shotsBox(card) { var b = card.querySelector("div.shots"); if (!b) { b = document.createElement("div"); b.className = "shots"; card.appendChild(b); } return b; }+ function addShot(card, b64, label) {+ var box = shotsBox(card);+ // Demo runs number their evidence in ARRIVAL order (1., 2., ...) so the+ // gallery reads as the receipt stream it is - John's ask, 2026-08-16.+ if (card._numberEvidence) { card._seq = (card._seq || 0) + 1; label = card._seq + ". " + label; }+ var wrap = document.createElement("div");+ var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = label;+ var img = document.createElement("img"); img.src = "data:image/png;base64," + b64;+ wrap.appendChild(cap); wrap.appendChild(img); box.appendChild(wrap);+ }++ // Capture the app's FULL window state: list windows, keep the ones matching this+ // app (include patterns, minus exclude patterns), and screenshot EACH by hwnd.+ // This is the parent/child window array - the real "state of the union" of the app.+ function captureWindows(c, spec) {+ clog(c, "Capturing the app's full window state (all its windows) ...");+ var inc = (spec.include || []).map(function (s) { return s.toLowerCase(); });+ var exc = (spec.exclude || []).map(function (s) { return s.toLowerCase(); });+ // A just-opened window (esp. a browser) may still be LOADING - its title is+ // not the page title yet. So poll a few seconds before giving up.+ var attempts = 0, maxAttempts = 8;+ function findMatches() {+ return windowsList().then(function (wins) {+ return wins.filter(function (w) { var t = (w.title || "").toLowerCase(); return inc.some(function (p) { return t.indexOf(p) >= 0; }) && !exc.some(function (p) { return t.indexOf(p) >= 0; }); });+ });+ }+ function attempt() {+ attempts++;+ return findMatches().then(function (match) {+ if (!match.length) {+ if (attempts < maxAttempts) { clog(c, " [" + attempts + "] no matching window yet - waiting for it to finish loading ..."); return new Promise(function (r) { setTimeout(r, 1200); }).then(attempt); }+ clog(c, " no windows matched after " + attempts + " tries - is the app actually open?"); setRes(c.res, "err", "no windows to capture", "the app may not have opened, or its title never matched");+ return;+ }+ clog(c, " found " + match.length + " window(s): " + match.map(function (w) { return '"' + (w.title || "").slice(0, 42) + '"'; }).join(", "));+ var i = 0;+ function next() {+ if (i >= match.length) { setRes(c.res, "ok", "Done - full state below", match.length + " window(s)"); clog(c, "DONE - captured the app's full window state (" + match.length + " windows)."); return; }+ var w = match[i++];+ return postSelf("desktop_screenshot_base64", { hwnd: w.hwnd, maxWidth: 900 }).then(function (sr) {+ if (sr.s === 200 && sr.j.image) { clog(c, " captured window " + i + "/" + match.length + ': "' + (w.title || "").slice(0, 50) + '" ' + sr.j.width + "x" + sr.j.height); addShot(c.card, sr.j.image, w.title || "window"); }+ else { clog(c, " could not capture: \"" + (w.title || "") + "\""); }+ return next();+ });+ }+ return next();+ });+ }+ return attempt();+ }++ // ---- SSE streaming (open -> wait for window), then capture the window array ----+ function onStream(c, ev, dataStr) {+ var d = {}; try { d = JSON.parse(dataStr); } catch (e) {}+ var t = Math.round((d.elapsedMs || 0) / 1000);+ var u = (d.usuallySec != null) ? (" (usually ~" + d.usuallySec + "s)") : "";+ var k = (d.tookMs != null) ? (" in " + (d.tookMs / 1000).toFixed(1) + "s") : "";+ var w = (d.waitedSec != null ? d.waitedSec : t);+ if (ev === "start") { clog(c, "Adom Bridge accepted the recipe (" + d.steps + " steps). Streaming live:"); return; }+ if (ev === "progress") {+ if (d.state === "running") { setRes(c.res, "pending", d.label + " ...", "running" + u); clog(c, "> step " + d.step + "/" + d.of + ": " + d.label + u + " ..."); }+ else if (d.state === "working") { setRes(c.res, "pending", d.label, w + "s - still working" + u); clog(c, " [" + w + "s] still working" + u + " ..."); }+ else if (d.state === "waiting") { setRes(c.res, "pending", "waiting for the window", w + "s" + u); clog(c, " [" + w + "s] waiting for the window" + u + " ..."); }+ else if (d.state === "appeared") { setRes(c.res, "pending", "window appeared" + k, d.window || ""); clog(c, ' window appeared: "' + (d.window || "") + '"' + k); }+ else if (d.state === "ok") { clog(c, " step " + d.step + " done" + k); }+ else if (d.state === "failed") { setRes(c.res, "err", "step " + d.step + " failed", JSON.stringify(d.result || {}).slice(0, 120)); clog(c, " step " + d.step + " FAILED" + k); }+ return;+ }+ if (ev === "error") { setRes(c.res, "err", "error", d.error || ""); clog(c, " ERROR: " + (d.error || "")); return; }+ if (ev === "done") {+ if (d.ok) { clog(c, " launch confirmed (total " + t + "s). Now grabbing the full window state ..."); if (c._capture) captureWindows(c, c._capture); }+ else { setRes(c.res, "err", "did not complete", d.reason || ("failed step " + d.failedStep)); clog(c, "DONE - did not complete."); }+ return;+ }+ }+ function streamRecipe(c, steps, capture) {+ c._capture = capture;+ c.btn.disabled = true;+ cardLog(c.card).textContent = "";+ var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";+ setRes(c.res, "pending", "opening a live connection ...", "approve the toast, then watch");+ logLine("POST /command/stream " + c.btn.textContent);+ function finish() { c.btn.disabled = false; }+ fetch(AB + "/command/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: c.btn.textContent, steps: steps }) })+ .then(function (resp) {+ if (!resp.ok) { return resp.json().catch(function () { return {}; }).then(function (j) { setRes(c.res, "err", (j.errorCode === "web_control_disabled" ? "web control OFF" : "refused"), j.error || j.errorCode || ("HTTP " + resp.status)); finish(); }); }+ var reader = resp.body.getReader(), dec = new TextDecoder(), buf = "";+ function pump() {+ return reader.read().then(function (rr) {+ if (rr.done) { finish(); return; }+ buf += dec.decode(rr.value, { stream: true }); var idx;+ while ((idx = buf.indexOf("\n\n")) >= 0) {+ var raw = buf.slice(0, idx); buf = buf.slice(idx + 2);+ var ev = "message", data = ""; raw.split("\n").forEach(function (l) { if (l.indexOf("event:") === 0) ev = l.slice(6).trim(); else if (l.indexOf("data:") === 0) data += l.slice(5).trim(); });+ if (data) onStream(c, ev, data);+ }+ return pump();+ });+ }+ return pump();+ })+ .catch(function (e) { setRes(c.res, "err", "blocked", String(e)); finish(); });+ }++ // ---- Bridge-OWNED demo verb: one long-running call, real progress bar ----+ // This is the modern pattern: the BRIDGE owns a "demo" verb that runs the whole+ // tour, reports structured progress (percent/stepLabel/beat/elapsed/estimated),+ // and returns its full window state as inline screenshots. The web page just+ // starts it and polls - no piecing bridge commands together, no title-guessing.+ // (The KiCad bridge shipped kicad_demo in 0.9.92; Fusion's fusion_demo is next.)+ function progressBar(card) {+ var box = card.querySelector("div.pbar");+ if (!box) {+ var meta = document.createElement("div"); meta.className = "pbar-meta"; meta.textContent = "starting ...";+ box = document.createElement("div"); box.className = "pbar";+ var fill = document.createElement("div"); fill.className = "pbar-fill"; box.appendChild(fill);+ // insert right after the .res line+ var res = card.querySelector("div.res");+ if (res && res.nextSibling) { card.insertBefore(meta, res.nextSibling); card.insertBefore(box, meta.nextSibling); }+ else { card.appendChild(meta); card.appendChild(box); }+ }+ return { fill: box.querySelector(".pbar-fill"), meta: card.querySelector(".pbar-meta") };+ }+ function setProgress(card, pct, label, beatIdx, beatCount, elapsed, estimate) {+ var p = progressBar(card);+ p.fill.style.width = Math.max(0, Math.min(100, pct || 0)) + "%";+ var beat = (beatCount ? ' <span class="beat">- step ' + Math.min((beatIdx || 0) + 1, beatCount) + "/" + beatCount + "</span>" : "");+ var timing = (elapsed != null && estimate != null) ? ' <span class="beat">- ' + elapsed + "s of ~" + estimate + "s</span>" : "";+ p.meta.innerHTML = (pct != null ? pct + "% " : "") + esc(label || "") + beat + timing;+ }+ function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&", "<": "<", ">": ">" }[c]; }); }++ // Drive a bridge's demo verb: start (background) -> poll progress -> render the+ // returned window array. cfg = { startCmd, startArgs, pollArgs, stateCmd }.+ function runBridgeDemo(c, cfg) {+ // The demo HONORS the machine picker: pick ConfRoomROG and the tour runs+ // THERE (this page's ab relays the verbs to that machine). Captured ONCE+ // at click so changing the picker mid-run cannot split the run.+ var target = chosenTarget();+ if (target === "all") { setRes(c.res, "err", "pick ONE machine", "a demo streams per-machine progress - fan-out is not supported here"); return; }+ var who = machineLabel(target);+ c._who = who;+ c._bridgeShots = false;+ c.card._numberEvidence = true; c.card._seq = 0; // fresh numbering per run+ c.btn.disabled = true;+ cardLog(c.card).textContent = "";+ var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";+ setProgress(c.card, 0, "starting ...");+ setRes(c.res, "pending", "asking permission (choose “Allow 1 hr”) ...", "runs on " + esc(who) + " - the demo runs many steps; a 1-hour grant keeps the progress silent");+ clog(c, "Target machine: " + who + (target ? " (relayed through this machine's Adom Bridge)" : " (the machine serving this page)"));+ clog(c, "Starting the bridge's own demo verb: " + cfg.startCmd + " " + JSON.stringify(cfg.startArgs || {}));+ // 1) Start the background tour (cross-origin -> ONE consent prompt).+ postVerb(cfg.startCmd, cfg.startArgs || {}, target).then(function (res) {+ if (res.s !== 200 || !res.j || (res.j.started !== true && res.j.success !== true)) {+ if (res.j && res.j.errorCode === "web_control_disabled") { setRes(c.res, "err", "Refused: web control OFF", "re-enable in Settings → Web Control"); }+ else { setRes(c.res, "err", "could not start", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)); }+ c.btn.disabled = false; return;+ }+ var est = res.j.estimatedSec || 100;+ // Thread an id from the START response into the poll args when the bridge+ // hands one back (e.g. Fusion's fusion_demo returns a demoId that+ // fusion_demo_progress needs). KiCad's poll is stateless, so no idFrom.+ var pollArgs = Object.assign({}, cfg.pollArgs || { progress: true });+ if (cfg.idFrom) {+ var idVal = res.j[cfg.idFrom];+ if (idVal == null && res.j.data) idVal = res.j.data[cfg.idFrom];+ if (idVal != null) { pollArgs[cfg.idAs || cfg.idFrom] = idVal; clog(c, " tracking " + (cfg.idAs || cfg.idFrom) + "=" + idVal); }+ }+ clog(c, "Started (bridge estimates ~" + est + "s). Polling for live progress ...");+ setProgress(c.card, res.j.percent || 2, "preparing ...", 0, 0, 0, est);+ setRes(c.res, "pending", "running the demo ...", "usually ~" + est + "s - watch the bar");+ // LIVE PROOF: at every step transition, snap the app's current state via+ // the bridge's state verb and pin it in the card - the user SEES each+ // stage of the tour as it happens, not just text + a final gallery+ // (John, 2026-08-16). One snap in flight at a time; a slow snap skips+ // steps rather than queueing behind the tour.+ var lastStep = null, snapBusy = false;+ function maybeSnap(label) {+ if (!cfg.snap || snapBusy) return;+ snapBusy = true;+ var done = function () { snapBusy = false; };+ // HONEST label: this is the page's own capture taken DURING the step,+ // not the bridge's evidence OF the step - the window shown may lag the+ // label. Bridge-supplied stepShot (above) supersedes these entirely.+ var pin = function (img, title) { addShot(c.card, img, "during “" + label + "”: " + (title || "app window")); clog(c, " [page capture during \"" + label + "\": " + (title || "app window") + "]"); };+ if (cfg.snap.viaHwnd) {+ // fusion_state's own screenshot exceeds the 4MB inline cap (it comes+ // back as a PATH on the far machine), so snap via the hwnd instead:+ // state verb -> hwnd -> desktop_screenshot_base64 (always inline).+ postVerb(cfg.snap.viaHwnd, { screenshot: false }, target).then(function (wr) {+ var wd = (wr.j && (wr.j.data || wr.j)) || {};+ if (!wd.hwnd) { done(); return; }+ return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 640 }, target).then(function (sh) {+ done();+ if (sh.s === 200 && sh.j && sh.j.image) pin(sh.j.image, wd.title);+ });+ }).catch(done);+ return;+ }+ postVerb(cfg.snap.cmd, Object.assign({}, cfg.snap.args || {}), target).then(function (sr) {+ done();+ if (!(sr.s === 200 && sr.j)) return;+ var withImg = findShots(sr.j).filter(function (s) { return s && (s.image || s.base64 || s.png); });+ if (!withImg.length) return;+ var s = withImg[withImg.length - 1]; // the newest window is the step's surface+ pin(s.image || s.base64 || s.png, s.title);+ }).catch(done);+ }+ // 2) Poll progress every 2.5s (the Allow-1hr grant makes these silent).+ var polls = 0, maxPolls = 240; // ~10 min ceiling+ function poll() {+ polls++;+ if (polls > maxPolls) { setRes(c.res, "err", "timed out", "the demo did not finish in time"); c.btn.disabled = false; return; }+ postVerb(cfg.pollCmd || cfg.startCmd, pollArgs, target).then(function (pr) {+ if (pr.s !== 200 || !pr.j) { return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); }+ var d = pr.j;+ if (d.error && d.active === undefined && d.done === undefined) {+ // job already gone - treat as done, go fetch the final state+ clog(c, " progress job ended; fetching final state ...");+ return finishDemo(c, cfg, est, target);+ }+ setProgress(c.card, d.percent, d.stepLabel || d.step, d.beatIndex, d.beatCount, d.elapsedSec, d.estimatedSec || est);+ // BRIDGE-SUPPLIED step evidence is authoritative: the bridge knows+ // the exact moment a step completed and which window is that step's+ // surface, so a `stepShot {step, title, image}` on a progress frame+ // is pinned verbatim and disables the page's own guess-snaps (which+ // race the tour and can label the wrong window - John's catch).+ var ss = d.stepShot || (d.result && d.result.stepShot);+ if (ss && (ss.image || ss.base64)) {+ c._bridgeShots = true;+ addShot(c.card, ss.image || ss.base64, "step: " + (ss.step || ss.title || d.stepLabel || "step"));+ clog(c, " [proof] bridge evidence for \"" + (ss.step || ss.title || d.stepLabel) + "\"");+ }+ // Log + snap only on CHANGE - a 2.5s poll of a 30s step used to spam+ // the same line eight times.+ if (d.stepLabel && d.stepLabel !== lastStep) {+ lastStep = d.stepLabel;+ clog(c, " " + (d.percent != null ? d.percent + "% " : "") + d.stepLabel + (d.beatCount ? " (step " + ((d.beatIndex || 0) + 1) + "/" + d.beatCount + ")" : ""));+ if (!c._bridgeShots) maybeSnap(d.stepLabel);+ }+ if (d.done === true || d.active === false) {+ setProgress(c.card, 100, "done", d.beatCount, d.beatCount, d.elapsedSec, d.estimatedSec || est);+ // The bridge rides its final state on the last poll (result.state.shots).+ // Render it if present; otherwise ask the state verb.+ if (!renderShots(c, d)) { return finishDemo(c, cfg, est, target); }+ return;+ }+ return new Promise(function (r) { setTimeout(r, 2500); }).then(poll);+ }).catch(function (e) { return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); });+ }+ poll();+ }).catch(function (e) { setRes(c.res, "err", "blocked", String(e)); c.btn.disabled = false; });+ }+ // Pull the bridge's full state (the window array) and render it.+ function finishDemo(c, cfg, est, target) {+ if (!cfg.stateCmd) { setRes(c.res, "ok", "Demo finished", "the bridge closed its windows"); c.btn.disabled = false; return; }+ clog(c, "Fetching the bridge's full window state: " + cfg.stateCmd);+ return postVerb(cfg.stateCmd, {}, target).then(function (sr) {+ if (sr.s === 200 && sr.j && renderShots(c, sr.j)) return;+ // The state verb's own capture may not ride the relay inline (fusion's+ // exceeds the 4MB cap and comes back as a far-machine PATH). Same+ // fallback as the live snaps: hwnd -> desktop_screenshot_base64.+ if (cfg.snap && cfg.snap.viaHwnd) {+ clog(c, " no inline captures - falling back to an hwnd screenshot of the app");+ return postVerb(cfg.snap.viaHwnd, { screenshot: false }, target).then(function (wr) {+ var wd = (wr.j && (wr.j.data || wr.j)) || {};+ if (!wd.hwnd) throw new Error("no app window reported");+ return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }, target).then(function (sh) {+ if (!(sh.s === 200 && sh.j && sh.j.image)) throw new Error("screenshot failed");+ addShot(c.card, sh.j.image, wd.title || "app window");+ setRes(c.res, "ok", "Demo done - final window below", "1 window on " + esc(c._who || selfLabel()) + " (generic capture)");+ c.btn.disabled = false;+ });+ }).catch(function (e2) {+ setRes(c.res, "ok", "Demo finished", "the bridge already closed its windows - nothing to show (" + String(e2 && e2.message || e2).slice(0, 60) + ")");+ c.btn.disabled = false;+ });+ }+ setRes(c.res, "ok", "Demo finished", "the bridge already closed its windows - nothing to show");+ clog(c, "DONE - the demo completed; the bridge closed its windows before we captured state.");+ c.btn.disabled = false;+ }).catch(function (e) { setRes(c.res, "err", "state fetch failed", String(e)); c.btn.disabled = false; });+ }+ // Find the bridge's window array wherever it rides: the final poll embeds it at+ // result.state.shots; kicad_state returns it at shots. Handle every known path.+ function findShots(d) {+ // Bridges ride their window array on the final frame in various shapes -+ // KiCad at result.state.shots, Fusion's final fusion_demo_progress poll at+ // (an array called shots OR screenshots, possibly nested under data/result).+ // Check every plausible path so a new bridge's demo renders with no rewrite.+ var cands = [+ d.shots, d.screenshots, d.images, d.windows,+ d.data && d.data.shots, d.data && d.data.screenshots, d.data && d.data.images,+ d.state && d.state.shots, d.state && d.state.screenshots,+ d.result && d.result.shots, d.result && d.result.screenshots,+ d.result && d.result.state && d.result.state.shots,+ d.result && d.result.state && d.result.state.screenshots+ ];+ for (var i = 0; i < cands.length; i++) {+ if (Array.isArray(cands[i]) && cands[i].length) return cands[i];+ }+ // fusion_state (1.9.42+) is a SINGLE-window server-side render: a top-level+ // base64 `image` (with imageAs:"base64") plus title/hwnd. Wrap it so the+ // one-window shape renders through the same path as the arrays.+ var dd = d.data || d;+ var single = dd.image || dd.imageBase64 || dd.screenshotBase64;+ if (typeof single === "string" && single.length > 100) {+ return [{ title: dd.title || "app window", image: single }];+ }+ return [];+ }+ // Render a bridge state payload's screenshots (each item: {title, image, ...}).+ // Returns true if it rendered any window, false if there was nothing to show.+ // pickTitle (optional): show JUST the window(s) whose title contains it — the+ // surface buttons ask for ONE surface ("just the symbol"), and the state verb+ // returns every open window. A zero-match filter falls back to all windows+ // (better to over-show than render nothing when a title changes).+ function renderShots(c, d, pickTitle) {+ var narrate = (d.result && d.result.narrate) || d.narrate;+ var failed = (d.result && d.result.failedSteps) || d.failedSteps;+ if (narrate) clog(c, " bridge says: " + narrate);+ if (Array.isArray(failed) && failed.length) clog(c, " note: the bridge could not open: " + failed.join(", "));+ var shots = findShots(d);+ var withImg = shots.filter(function (s) { return s && (s.image || s.base64 || s.png); });+ if (!withImg.length) return false;+ if (pickTitle) {+ var want = pickTitle.toLowerCase();+ var picked = withImg.filter(function (s) { return ((s.title || s.label || "") + "").toLowerCase().indexOf(want) >= 0; });+ if (picked.length) { withImg = picked; }+ else clog(c, ' (no window titled like "' + pickTitle + '" - showing all ' + withImg.length + ')');+ }+ 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"); });+ 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;+ }++ // Open ONE surface via a bridge's first-class show verb, then render it from the+ // bridge's state verb. The show verbs open in the BACKGROUND by design, so we+ // SHOW the window by screenshotting state - we never foreground the app. On a+ // prerequisite miss the bridge returns a _hint; we surface it.+ // cfg.stage (optional): a function(c) -> Promise run BEFORE the show verb — the+ // staging chain (fetch the wiki part, install its library, generate the demo+ // project). Electronics is a dependency ladder: you cannot show a 3D board+ // without a routed 2D board, which needs a schematic, which needs the part's+ // library installed. stage() owns climbing that ladder idempotently.+ // cfg.showArgs may be a FUNCTION (called after stage) so args can carry paths+ // the staging step just produced.+ // cfg.pickTitle: render just the one window this surface is about.+ // cfg.fallback {cmd,args}: retried when the primary verb is unknown (a not-yet-+ // shipped show verb degrades to today's open verb instead of a dead button).+ function showSurface(c, cfg) {+ c.btn.disabled = true;+ cardLog(c.card).textContent = "";+ var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";+ setRes(c.res, "pending", "asking permission (choose “Allow 1 hr”) ...", "on " + esc(selfLabel()) + " - " + (cfg.note || "opens in the background, then we screenshot it"));+ clog(c, "Target machine: " + selfLabel());+ var staged = cfg.stage ? cfg.stage(c) : Promise.resolve();+ staged.then(function () {+ var args = (typeof cfg.showArgs === "function") ? cfg.showArgs() : (cfg.showArgs || {});+ clog(c, "Calling " + cfg.showCmd + " " + JSON.stringify(args) + (cfg.note ? " (" + cfg.note + ")" : ""));+ return postVerb(cfg.showCmd, args).then(function (res) {+ if (res.j && res.j.windowEtiquette === "background") clog(c, " (opens in the BACKGROUND by design - the bridge's window etiquette; we render it below instead of stealing focus)");+ if (res.j && res.j._hint) clog(c, " bridge hint: " + res.j._hint);+ var unknown = res.j && /unknown command/i.test(res.j.error || "");+ if (unknown && cfg.fallback) {+ var fb = (typeof cfg.fallback === "function") ? cfg.fallback() : cfg.fallback;+ clog(c, " " + cfg.showCmd + " is not shipped yet on this bridge - falling back to " + fb.cmd);+ return postVerb(fb.cmd, fb.args || args).then(function (r2) { return handleShow(r2); });+ }+ 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);+ // 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 || ""))) {+ c._autoStarted = true;+ clog(c, " " + (res.j.error || "app not running") + " - calling " + cfg.startCmd + " (blocks until the add-in is ready) ...");+ setRes(c.res, "pending", "starting the app ...", "cold start can take a couple of minutes");+ return postVerb(cfg.startCmd, {}).then(function (st) {+ if (!(st.s === 200 && st.j && st.j.success !== false)) {+ setRes(c.res, "err", "could not start the app", (st.j && (st.j.error || st.j._hint)) || ("HTTP " + st.s));+ c.btn.disabled = false; return;+ }+ clog(c, " app is ready - retrying " + cfg.showCmd);+ return postVerb(cfg.showCmd, args).then(handleShow);+ });+ }+ 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 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 ...");+ // The show verb MAY already carry shots; else pull the state verb.+ if (renderShots(c, res.j, cfg.pickTitle)) return;+ return settleThenCapture(c, cfg.stateCmd).then(function (sr) {+ if (sr.s === 200 && sr.j && renderShots(c, sr.j, cfg.pickTitle)) return;+ // The state verb may not be shipped on this bridge yet (fusion_state is+ // "next release" per fusion-bridge #28). Fall back to a GENERIC window+ // capture: ask the bridge which window is its app's, then screenshot it.+ if (cfg.windowInfoCmd) {+ clog(c, " " + cfg.stateCmd + " returned no captures - falling back to a window screenshot (" + cfg.windowInfoCmd + " + desktop_screenshot_base64)");+ return postVerb(cfg.windowInfoCmd, {}).then(function (wi) {+ var wd = (wi.j && (wi.j.data || wi.j)) || {};+ if (!wd.hwnd) throw new Error("no app window reported");+ return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }).then(function (sh) {+ if (!(sh.s === 200 && sh.j && sh.j.image)) throw new Error("screenshot failed");+ addShot(c.card, sh.j.image, wd.title || "app window");+ setRes(c.res, "ok", "Done - captured the app window", "generic capture (the bridge's state verb isn't shipped yet)");+ c.btn.disabled = false;+ });+ }).catch(function (e2) {+ setRes(c.res, "ok", "Opened" + (res.j.surface ? " (" + res.j.surface + ")" : ""), "no window captured: " + String(e2 && e2.message || e2).slice(0, 90));+ c.btn.disabled = false;+ });+ }+ setRes(c.res, "ok", "Opened" + (res.j.surface ? " (" + res.j.surface + ")" : ""), "no window captured yet (may need a prerequisite - see the hint)");+ c.btn.disabled = false;+ });+ }+ }).catch(function (e) {+ setRes(c.res, "err", e && e.staged ? "staging failed" : "blocked", String(e && e.message || e).slice(0, 140));+ c.btn.disabled = false;+ });+ }++ // The app may still be mid-load when the show verb returns (KiCad's "Load PCB"+ // progress dialog raced the first capture and produced a bounding-box 3D render).+ // Poll the state verb CHEAPLY (no images) until the window set is stable and no+ // modal dialog remains, then take the real capture. Errors fall through to a+ // direct capture so a bridge without includeImages support still renders.+ function settleThenCapture(c, stateCmd) {+ // ONE capture after a fixed settle delay - NO probe polling. The old loop+ // polled the state verb up to 8x waiting for the window set to stabilize,+ // and under an "Allow once" grant EVERY poll was its own consent prompt:+ // John got NINE toasts for one screenshot (2026-08-17). Each cross-origin+ // call costs one consent, so the page makes as few as possible. The+ // bridges are being asked to own settle+capture inside the show verbs+ // (capture:true) so this becomes zero extra calls.+ clog(c, " waiting ~3.5s for the surface to settle, then ONE capture ...");+ return new Promise(function (r) { setTimeout(r, 3500); })+ .then(function () { return postVerb(stateCmd, { maxWidth: 900 }); });+ }++ // summaries+ function n(x) { return (x === undefined || x === null) ? 0 : x; }+ function bridgesSummary(j) { var d = j.data || j; var a = d.bridges || []; return a.length + " bridge(s): " + a.map(function (b) { return b.name; }).slice(0, 6).join(", "); }+ function targetsSummary(j) { var d = j.data || j; var a = d.targets || d.clients || []; return (a.length || 1) + " machine(s): " + a.map(function (t) { return t.name || t.hostname; }).join(", "); }+ function windowsSummary(j) { var d = j.data || j; var a = d.windows || []; return a.length + " open. Top 3: " + a.slice(0, 3).map(function (w) { return '"' + (w.title || "").slice(0, 26) + '"'; }).join(", "); }+ function browsersSummary(j) { var d = j.data || j; var a = d.browsers || (Array.isArray(d) ? d : []); if (!Array.isArray(a)) a = []; return a.length + " installed. Top 3: " + a.slice(0, 3).map(function (b) { return b.name || b.browser || b.displayName || b.id || "browser"; }).join(", "); }++ // ---- Detect (reads) ----+ var cDetect = card("grp-read", "read", "Detect Adom Bridge");+ cDetect.btn.onclick = function () {+ setRes(cDetect.res, "pending", "checking...", "");+ fetch(AB + "/health").then(function (r) { return r.json(); }).then(function (h) {+ dot.style.background = (h && h.service === "adom-bridge") ? "#2ea043" : "#d29922";+ return fetch(AB + "/status").then(function (r) { return r.json(); }).then(function (s) {+ if (s.machine && (s.machine.clientName || s.machine.hostname)) selfName = s.machine.clientName || s.machine.hostname;+ document.getElementById("ver").textContent = "v" + (s.version || "?") + (selfName ? " on " + selfName : "");+ var wc = (s && s.webControl) || {}; var el = document.getElementById("wc");+ var on = wc.enabled !== false; el.textContent = on ? "ON" : "OFF"; el.style.color = on ? "#2ea043" : "#f85149";+ setRes(cDetect.res, "ok", "Running: v" + (s.version || "?"), "web control " + (on ? "ON" : "OFF"));+ });+ }).catch(function (e) { fetchDied(e); dot.style.background = "#484f58"; setRes(cDetect.res, "err", "not reachable", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : String(e)); });+ };+ var cStatus = card("grp-read", "read", "Read status");+ cStatus.btn.onclick = function () { doGet(cStatus.res, "/status", function (s) { var st = n(s.bridgeUpdates && s.bridgeUpdates.staleCount); return "v" + s.version + " - web control " + ((s.webControl && s.webControl.enabled === false) ? "OFF" : "ON") + (st ? (" - " + st + " bridge update(s)") : ""); }); };++ // ---- Info verbs (with top-3 lists) ----+ var cBridges = card("grp-info", "verb", "List installed bridges");+ cBridges.btn.onclick = function () { doVerb(cBridges.res, "bridge_list", null, undefined, bridgesSummary); };+ var cTargets = card("grp-info", "verb", "List my computers (targets)");+ cTargets.btn.onclick = function () { doVerb(cTargets.res, "targets", null, null, targetsSummary); };+ var cWindows = card("grp-info", "verb", "List open windows (top 3)");+ cWindows.btn.onclick = function () { doVerb(cWindows.res, "desktop_list_windows", null, undefined, windowsSummary); };+ var cApps = card("grp-info", "verb", "Which browsers are installed (top 3)");+ cApps.btn.onclick = function () { doVerb(cApps.res, "desktop_list_browsers", null, undefined, browsersSummary); };++ // ---- Do something: stream the launch, then capture the FULL window array ----+ var cKicad = card("grp-do", "verb", "Run the KiCad demo (bridge-driven, live progress)");+ cKicad.btn.onclick = function () {+ // The KiCad bridge OWNS this demo (kicad_demo, 0.9.92+): it builds a sample+ // project, opens the symbol/footprint/3D/board/schematic views, reports real+ // progress, and returns every window as a screenshot. We just start + poll.+ runBridgeDemo(cKicad, {+ startCmd: "kicad_demo", startArgs: { all: true, background: true },+ pollCmd: "kicad_demo", pollArgs: { progress: true },+ stateCmd: "kicad_state",+ snap: { cmd: "kicad_state", args: { maxWidth: 640 } }+ });+ };+ var cFusion = card("grp-do", "verb", "Run the Fusion demo (bridge-driven, live progress)");+ cFusion.btn.onclick = function () {+ // The Fusion bridge OWNS this demo (fusion_demo, v1.9.9+): an async tour over+ // symbol / footprint / device / 3D-package (+ schematic/2D/3D board) off its+ // bundled sample library. Start returns a demoId that fusion_demo_progress+ // needs, and the final poll carries the screenshot array. Same harness as+ // KiCad, just a demoId to thread through.+ runBridgeDemo(cFusion, {+ startCmd: "fusion_demo", startArgs: { mode: "tour" },+ pollCmd: "fusion_demo_progress", pollArgs: {},+ idFrom: "demoId", idAs: "demoId",+ stateCmd: "fusion_state",+ snap: { viaHwnd: "fusion_state" }+ });+ };+ // ---- The wiki part every surface button shows: Inolux IN-S42ATR ----+ // The part's page publishes the component artifacts (symbol, footprint, STEP,+ // .lbr); the buttons fetch them straight off the wiki (the files API sends+ // Access-Control-Allow-Origin: *), install them through the bridges' install+ // verbs, and only then open the surface. Electronics is a dependency ladder —+ // 3D board needs a routed board, needs a schematic, needs the library — so+ // each stage function climbs exactly as far as its surface requires, and each+ // is MEMOIZED (one staging per page load; every later button reuses it).+ var PART = {+ name: "IN-S42ATR",+ kicadLib: "Adom", // kicad_install_symbol installs into this library+ page: "https://wiki.adom.inc/aravk/in-s42atr",+ filesBase: "https://wiki.adom.inc/api/pages/aravk/in-s42atr/files/",+ dir: "~/Documents/adom-demo-parts/IN-S42ATR/" // staged on the PC via write_file (~ expands)+ };+ function fetchPartText(f) { return fetch(PART.filesBase + f).then(function (r) { if (!r.ok) throw new Error("wiki fetch " + f + ": HTTP " + r.status); return r.text(); }); }+ function fetchPartBytes(f) { return fetch(PART.filesBase + f).then(function (r) { if (!r.ok) throw new Error("wiki fetch " + f + ": HTTP " + r.status); return r.arrayBuffer(); }); }+ function b64Text(s) { return btoa(unescape(encodeURIComponent(s))); }+ function b64Bytes(buf) {+ var u8 = new Uint8Array(buf), out = "", CH = 0x8000;+ for (var i = 0; i < u8.length; i += CH) out += String.fromCharCode.apply(null, u8.subarray(i, i + CH));+ return btoa(out);+ }+ function stageFail(msg) { var e = new Error(msg); e.staged = true; return e; }+ // Write one part file onto the PC via ab's write_file; resolves the ABSOLUTE path.+ function stageFile(c, name, b64) {+ return postSelf("write_file", { path: PART.dir + name, content: b64, encoding: "base64" }).then(function (r) {+ if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("write_file " + name + ": " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));+ clog(c, " staged " + name + " -> " + r.j.path);+ return r.j.path;+ });+ }++ // KiCad component staging (the kicad-bridge's own recipe, adom-bridge-sdk#4+ // reply 1007/1032): fetch symbol+footprint+STEP from the wiki, write all three+ // onto the PC, then install by filePath. kicad_install_footprint's `model`+ // arg (0.9.108) installs the .step into 3dmodels/Adom and WIRES the+ // footprint's (model ...) reference itself — the page's old hand-splice is+ // retired; the bridge owns model wiring now.+ var _kicadPart = null;+ function stageKicadPart(c) {+ if (_kicadPart) { clog(c, "Part already staged this session (reusing)."); return _kicadPart; }+ clog(c, "Staging " + PART.name + " from " + PART.page + " ...");+ _kicadPart = Promise.all([+ fetchPartText(PART.name + ".kicad_sym"),+ fetchPartText(PART.name + ".kicad_mod"),+ fetchPartBytes(PART.name + ".step")+ ]).then(function (got) {+ clog(c, " fetched symbol (" + got[0].length + " B), footprint (" + got[1].length + " B), STEP (" + got[2].byteLength + " B) from the wiki");+ return Promise.all([+ stageFile(c, PART.name + ".kicad_sym", b64Text(got[0])),+ stageFile(c, PART.name + ".kicad_mod", b64Text(got[1])),+ stageFile(c, PART.name + ".step", b64Bytes(got[2]))+ ]);+ }).then(function (paths) {+ 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;+ 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) {+ if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("kicad_install_footprint: " + ((r.j && r.j.error) || r.s));+ clog(c, " installed footprint; the bridge wired the 3D model reference (0.9.108 model arg)");+ });+ });+ _kicadPart.catch(function () { _kicadPart = null; }); // a failed staging retries on the next click+ return _kicadPart;+ }++ // KiCad PROJECT staging — kicad_make_part_project (0.9.108+, closed #29):+ // generates a real project around the part and returns the file paths the+ // project/schematic/board buttons need. 0.9.112: SINGLE-COMPONENT by design+ // (John's ruling — just the part, no companion resistor), refdes from the+ // part's own Reference property, part-dominant tight edge cuts so an 0402+ // fills the 3D view instead of rendering as a speck.+ var _kicadProj = null;+ function stageKicadProject(c) {+ if (_kicadProj) return _kicadProj;+ _kicadProj = stageKicadPart(c).then(function () {+ clog(c, "Generating the part's demo project (kicad_make_part_project) ...");+ return postVerb("kicad_make_part_project", { partName: PART.name }).then(function (r) {+ if (r.s === 200 && r.j && r.j.success) {+ PART.kicadProject = {+ proPath: r.j.projectFile,+ schPath: r.j.schematicFile,+ pcbPath: r.j.boardFile+ };+ clog(c, " project generated (" + (r.j.reference || PART.name) + " on a part-dominant board): " + (r.j.projectFile || "?"));+ return;+ }+ throw stageFail("kicad_make_part_project: " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));+ });+ });+ _kicadProj.catch(function () { _kicadProj = null; });+ return _kicadProj;+ }++ // ---- KiCad surfaces: the eight-surface ladder for the wiki part ----+ // 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: "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: "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: "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: "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" },+ { label: "Schematic (just the " + PART.name + ")", cmd: "kicad_show_schematic", args: function () { return { filePath: (PART.kicadProject || {}).schPath }; }, stage: stageKicadProject, pick: "schematic editor", note: "the generated single-component schematic (kicad_make_part_project)" },+ { label: "2D board (part placed, tight edge cuts)", cmd: "kicad_show_2d_board", args: function () { return { filePath: (PART.kicadProject || {}).pcbPath }; }, stage: stageKicadProject, pick: "pcb editor", note: "the part-dominant board" },+ { label: "3D board (real 3D body on the board)", cmd: "kicad_show_3d_board", args: function () { return { filePath: (PART.kicadProject || {}).pcbPath }; }, stage: stageKicadProject, pick: "3d viewer", note: "the populated board in 3D - the whole ladder underneath it" },+ { label: "Project (top of the tree)", cmd: "kicad_show_project", args: function () { return { filePath: (PART.kicadProject || {}).proPath }; }, stage: stageKicadProject, pick: "kicad 10", note: "the Project Manager: schematic + board hang off it" }+ ].forEach(function (s) {+ var cc = card("grp-kicad", "verb", s.label);+ cc.btn.onclick = function () { showSurface(cc, { showCmd: s.cmd, showArgs: s.args, stage: s.stage, pickTitle: s.pick, stateCmd: "kicad_state", note: s.note }); };+ });+ 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" }); };++ // Fusion component staging: the SAME wiki part's .lbr + .step, written onto the+ // PC (the fusion_show_* verbs take a libraryPath / stepPath).+ var _fusionPart = null;+ function stageFusionPart(c) {+ if (_fusionPart) { clog(c, "Part already staged this session (reusing)."); return _fusionPart; }+ clog(c, "Staging " + PART.name + ".lbr + .step from " + PART.page + " ...");+ _fusionPart = Promise.all([+ fetchPartText(PART.name + ".lbr"),+ fetchPartBytes(PART.name + ".step")+ ]).then(function (got) {+ return Promise.all([+ stageFile(c, PART.name + ".lbr", b64Text(got[0])),+ stageFile(c, PART.name + ".step", b64Bytes(got[1]))+ ]).then(function (paths) { PART.lbrPath = paths[0]; PART.stepPath = paths[1]; });+ });+ _fusionPart.catch(function () { _fusionPart = null; });+ return _fusionPart;+ }+ // Fusion PROJECT staging — the fusion-bridge's own per-part flow (#30 reply,+ // v1.9.41): (1) fusion_bind_library_3d mints the part's cloud 3D urn and binds+ // it into the .lbr (signed-in Autodesk session required; best-effort — the+ // design still stages without it, just with a placeholder 3D body), then+ // (2) fusion_make_test_project generates a SINGLE-COMPONENT design (John's+ // ruling: just the part — one symbol, one element on a small board) with the+ // part's real refdes. After it, schematic / 2D / 3D board show the real design.+ var _fusionProj = null;+ function stageFusionProject(c) {+ if (_fusionProj) return _fusionProj;+ _fusionProj = stageFusionPart(c).then(function () {+ clog(c, "Binding the part's 3D body into the library (fusion_bind_library_3d; needs a signed-in Autodesk session) ...");+ return postVerb("fusion_bind_library_3d", { libraryPath: PART.lbrPath, stepPath: PART.stepPath }).then(function (r) {+ if (r.s === 200 && r.j && r.j.success) clog(c, " 3D body bound into the library");+ else clog(c, " 3D bind skipped (" + ((r.j && (r.j.error || r.j.errorCode)) || r.s) + ") - continuing; the 3D board may show a placeholder body");+ clog(c, "Generating the single-component design (fusion_make_test_project) ...");+ return postVerb("fusion_make_test_project", { libraryPath: PART.lbrPath });+ }).then(function (r) {+ if (r.s === 200 && r.j && r.j.success) { clog(c, " design generated + open (" + (r.j.reference || PART.name) + ")"); return; }+ throw stageFail("fusion_make_test_project: " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));+ });+ });+ _fusionProj.catch(function () { _fusionProj = null; });+ return _fusionProj;+ }++ // ---- Fusion surfaces: same part, same ladder ----+ // All eight fusion_show_* verbs are live (v1.9.41) and open in the BACKGROUND;+ // fusion_state (fusion-bridge #28) is the render path, so pickTitle:"fusion"+ // pulls the surface's window out of the state array.+ [+ { label: "Symbol (" + PART.name + ")", cmd: "fusion_show_symbol", args: function () { return { name: PART.name, libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "full pinout in the symbol editor" },+ { label: "Footprint (" + PART.name + ")", cmd: "fusion_show_footprint", args: function () { return { name: PART.name, libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "pads + layers in the footprint editor" },+ { label: "3D package (" + PART.name + ")", cmd: "fusion_show_3d_package", args: function () { return { name: PART.name, libraryPath: PART.lbrPath, stepPath: PART.stepPath }; }, stage: stageFusionPart, note: "attaches the part's STEP to its footprint" },+ { label: "Library (" + PART.name + ".lbr)", cmd: "fusion_show_library", args: function () { return { libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "the Electronics Library with symbol + footprint + 3D" },+ { label: "Schematic (just the " + PART.name + ")", cmd: "fusion_show_schematic", args: function () { return {}; }, stage: stageFusionProject, note: "the generated single-component design's schematic view" },+ { label: "2D board (part placed)", cmd: "fusion_show_2d_board", args: function () { return {}; }, stage: stageFusionProject, note: "the PCB editor view of the staged design" },+ { label: "3D board (real 3D body)", cmd: "fusion_show_3d_board", args: function () { return {}; }, stage: stageFusionProject, note: "populated board in 3D: the bound urn renders the real component" },+ { label: "Project (top of the design)", cmd: "fusion_show_project", args: function () { return {}; }, stage: stageFusionProject, note: "the generated project's top level" }+ ].forEach(function (s) {+ var cc = card("grp-fusion", "verb", s.label);+ 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 });+ };+ });+ // Single full-screen shot (keep the simple case too).+ var cShot = card("grp-do", "verb", "Take a screenshot (whole screen)");+ cShot.btn.onclick = function () {+ var sb = cShot.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";+ setRes(cShot.res, "pending", "asking permission...", "approve the toast");+ postVerb("desktop_screenshot_base64", { maxWidth: 900 }, chosenTarget()).then(function (res) {+ if (res.s === 200 && res.j.image) { setRes(cShot.res, "ok", "Captured " + res.j.width + "x" + res.j.height, ""); addShot(cShot.card, res.j.image, "whole screen"); }+ else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cShot.res, "err", "Refused: web control OFF", "re-enable in Settings");+ else setRes(cShot.res, "err", "no image", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));+ }).catch(function (e) { setRes(cShot.res, "err", "blocked", String(e)); });+ };+ var cUrl = card("grp-do", "verb", "Open a URL (then screenshot the REAL browser)");+ cUrl.btn.onclick = function () {+ streamRecipe(cUrl, [+ { command: "desktop_open_url", args: { url: "https://wiki.adom.inc" }, label: "Opening wiki.adom.inc in your default browser", usuallySec: 2 },+ { waitForWindow: { titleContains: "Adom Wiki", timeoutSec: 30 }, label: "Waiting for the page to load", usuallySec: 4 }+ ], { include: ["adom wiki", "wiki.adom.inc"], exclude: ["for testing", "127.0.0.1", "harness", "puppeteer"] });+ };+ // Notify + VISIBLE state re-query.+ var cNotify = card("grp-do", "verb", "Notify me (sticky + query state)");+ var notifyId = null;+ cNotify.btn.onclick = function () {+ notifyId = "webdemo-" + (Math.floor(Math.random() * 1e9));+ setRes(cNotify.res, "pending", "asking permission...", "approve the toast");+ postVerb("notify_user", { title: "Hello from a web page", body: "Sticky toast. Click Got it or Dismiss, then Check toast state.", scenario: "reminder", id: notifyId, buttons: [{ label: "Got it" }, { label: "Dismiss" }] }, chosenTarget()).then(function (res) {+ var act = res.j && res.j.action;+ if (res.s === 200 && act && String(act).toLowerCase().indexOf("error") === 0) { setRes(cNotify.res, "err", "notify rejected", String(act).slice(0, 140)); return; }+ if (res.s === 200) {+ if (res.j.id) notifyId = res.j.id;+ setRes(cNotify.res, "ok", "Toast shown (id " + notifyId + ")", "click Got it / Dismiss on it, then Check toast state");+ if (!cNotify.card.querySelector("button.chk")) {+ var cb = document.createElement("button"); cb.className = "chk"; cb.textContent = "Check toast state";+ cb.style.cssText = "margin-top:6px;background:#12181f";+ cb.onclick = function () {+ setRes(cNotify.res, "pending", "re-querying toast state ...", "GET notify_state id=" + notifyId);+ postVerb("notify_state", { id: notifyId }, chosenTarget()).then(function (sr) {+ if (sr.s === 200) {+ var st = sr.j.state || "unknown";+ var extra = sr.j.action ? (" (you clicked: " + sr.j.action + ")") : (sr.j.onScreen ? " (still on screen)" : "");+ setRes(cNotify.res, st === "responded" ? "ok" : "pending", "state: " + st + extra, sr.j.note || "click a toast button, then check again");+ logLine(" notify_state -> " + JSON.stringify(sr.j).slice(0, 200));+ } else setRes(cNotify.res, "err", "state query failed", (sr.j && (sr.j.error || sr.j.errorCode)) || ("HTTP " + sr.s));+ }).catch(function (e) { setRes(cNotify.res, "err", "state query error", String(e)); });+ };+ cNotify.card.appendChild(cb);+ }+ } else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cNotify.res, "err", "Refused: web control OFF", "re-enable in Settings");+ else setRes(cNotify.res, "err", "not approved", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));+ }).catch(function (e) { setRes(cNotify.res, "err", "blocked", String(e)); });+ };++ // ---- Multi-machine ----+ var cNotifyAll = card("grp-multi", "multi", "Notify ALL my computers");+ cNotifyAll.btn.onclick = function () { doVerb(cNotifyAll.res, "notify_user", { title: "Fan-out from the web", body: "Sent to every connected Adom Bridge." }, "all", function (j) { var s = j.summary || (j.data && j.data.summary); return s ? (s.ok + "/" + s.total + " machines notified") : "sent to all machines"; }); };+ var cShotSel = card("grp-multi", "multi", "Screenshot the SELECTED machine");+ cShotSel.btn.onclick = function () {+ var sb = cShotSel.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";+ var who = machineLabel();+ setRes(cShotSel.res, "pending", "asking permission...", "capturing " + who);+ postVerb("desktop_screenshot_base64", { maxWidth: 900 }, chosenTarget()).then(function (res) {+ if (res.s === 200 && res.j.image) { setRes(cShotSel.res, "ok", "Captured " + res.j.width + "x" + res.j.height, "on " + who); addShot(cShotSel.card, res.j.image, who); }+ else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cShotSel.res, "err", "Refused: web control OFF", "re-enable in Settings");+ else setRes(cShotSel.res, "err", "no image", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));+ }).catch(function (e) { setRes(cShotSel.res, "err", "blocked", String(e)); });+ };++ // ---- target picker ----+ document.getElementById("refresh-targets").onclick = function () {+ var btn = this, pill = document.getElementById("target-count");+ // The user must SEE the click did something, instantly - and see the+ // failure in the pill, not buried in the collapsed log (John, 2026-08-16).+ btn.disabled = true; pill.style.color = ""; pill.textContent = "detecting ... (approve the Adom Bridge prompt if one appears)";+ logLine("POST /command {targets}");+ // Resolve the self name first so the dedupe + "self" label are right even+ // when Detect was never clicked (an old ab just returns no machine block).+ (selfName ? Promise.resolve(selfName) : loadSelfName())+ .then(function () { return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: "targets" }) }); })+ .then(function (r) { return r.json(); })+ .then(function (j) {+ btn.disabled = false;+ // The direct API answers with clients[]; the relay-CLI shape is targets[].+ var d = j.data || j; var list = d.targets || d.clients || [];+ // The relay lists THIS machine by name too - that row is redundant with+ // the "self" option, so fold it in (it names the self option instead).+ var peers = list.filter(function (t) {+ var n = (t.name || t.hostname || "").toLowerCase();+ return !(selfName && n === selfName.toLowerCase());+ });+ sel.innerHTML = '<option value="">' + esc(selfOptionLabel()) + '</option>';+ peers.forEach(function (t) { var o = document.createElement("option"); o.value = t.name || t.hostname; o.textContent = (t.name || t.hostname) + verTag(t.version); sel.appendChild(o); });+ if (peers.length >= 1) { var a = document.createElement("option"); a.value = "all"; a.textContent = "ALL machines (fan out)"; sel.appendChild(a); }+ document.getElementById("target-count").textContent = selfLabel() + (peers.length ? " + " + peers.length + " more" : " only") + (latestVersion ? " · latest ab v" + latestVersion : "");+ })+ .catch(function (e) {+ btn.disabled = false; fetchDied(e);+ pill.style.color = "#f85149";+ pill.textContent = SANDBOXED ? "blocked: sandboxed embed (see banner)" : ("detect failed: " + String(e).slice(0, 80));+ logLine("targets failed: " + e);+ });+ };+})();+</script>+</body>+</html>