← Commit history

Web-control test harness (served directly; drives ab's loopback API)

John Lauer ·535a6955ab ·2mo ago ·parent 0e3c410
1 file changed +451
web-control-demo.htmladded+451
@@ -0,0 +1,451 @@+<!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>+<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; }+  .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>&nbsp; Adom Bridge web control - local test harness</h1>+  <div class="sub">+    Page origin: <code id="origin">...</code> &middot; API: <code>http://127.0.0.1:24680</code>+    &middot; ab: <span id="ver" class="pill">unknown</span>+    &middot; web control: <span id="wc" class="pill">?</span>+  </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">1 known</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">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 &rarr; wait for its window &rarr; 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)";+  function logLine(m) { log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }+  function chosenTarget() { return sel.value || null; }+  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 { "<": "&lt;", ">": "&gt;", "&": "&amp;" }[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) { setRes(el, "err", "unreachable", 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;+    setRes(el, "pending", "asking permission...", "approve the Adom Bridge toast");+    logLine("POST /command " + JSON.stringify(body));+    fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(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) setRes(el, "ok", "Allowed &amp; ran", summarize ? summarize(flat(res.j)) : "");+        else if (res.j && res.j.errorCode === "web_control_disabled") setRes(el, "err", "Refused: web control OFF", "re-enable in Settings &rarr; Web Control");+        else setRes(el, "err", "Not approved", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));+      })+      .catch(function (e) { setRes(el, "err", "blocked", 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;+    logLine("POST /command " + JSON.stringify(body));+    return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(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);+    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) ...");+    return windowsList().then(function (wins) {+      var inc = (spec.include || []).map(function (s) { return s.toLowerCase(); });+      var exc = (spec.exclude || []).map(function (s) { return s.toLowerCase(); });+      var match = 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; }); });+      if (!match.length) { clog(c, "  no windows matched - is the app actually open?"); setRes(c.res, "err", "no windows to capture", "the app may not have opened"); 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();+    });+  }++  // ---- 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(); });+  }++  // 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 || []; 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) {+        document.getElementById("ver").textContent = "v" + (s.version || "?");+        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) { dot.style.background = "#484f58"; setRes(cDetect.res, "err", "not reachable", 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", "Open KiCad (watched)");+  cKicad.btn.onclick = function () {+    streamRecipe(cKicad, [+      { command: "kicad_open_symbol_editor", args: {}, label: "Launching KiCad", usuallySec: 30 },+      { waitForWindow: { titleContains: "Symbol Editor", timeoutSec: 90 }, label: "Waiting for KiCad to come up", usuallySec: 8 }+    ], { include: ["kicad", "symbol editor", "pcb editor", "schematic editor", "footprint editor"], exclude: ["for testing", "harness", "adom bridge web", "adom wiki"] });+  };+  var cFusion = card("grp-do", "verb", "Open Fusion 360");+  cFusion.btn.onclick = function () {+    streamRecipe(cFusion, [+      { command: "fusion_start", args: {}, label: "Launching Autodesk Fusion", usuallySec: 90 },+      { waitForWindow: { titleContains: "Fusion", timeoutSec: 280 }, label: "Waiting for Fusion to come up", usuallySec: 120 }+    ], { include: ["fusion", "autodesk"], exclude: ["for testing", "harness"] });+  };+  var cSym = card("grp-do", "verb", "Open KiCad (Symbol Editor)");+  cSym.btn.onclick = function () {+    streamRecipe(cSym, [+      { command: "kicad_open_symbol_editor", args: {}, label: "Opening the KiCad Symbol Editor", usuallySec: 30 },+      { waitForWindow: { titleContains: "Symbol Editor", timeoutSec: 90 }, label: "Waiting for the Symbol Editor", usuallySec: 8 }+    ], { include: ["kicad", "symbol editor", "pcb editor", "schematic editor"], exclude: ["for testing", "harness", "adom bridge web", "adom wiki"] });+  };+  // 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 a button on it (or ignore it), then hit 'Check toast state'.", scenario: "reminder", id: notifyId, buttons: [{ text: "Got it", id: "ack" }, { text: "Dismiss", id: "dismiss" }] }, chosenTarget()).then(function (res) {+      if (res.s === 200) {+        if (res.j.id) notifyId = res.j.id;+        setRes(cNotify.res, "ok", "Toast shown (id " + notifyId + ")", "click a button 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 = chosenTarget() || "this machine";+    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 () {+    logLine("POST /command {targets}");+    fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: "targets" }) })+      .then(function (r) { return r.json(); })+      .then(function (j) {+        var d = j.data || j; var list = d.targets || [];+        sel.innerHTML = '<option value="">this machine (self)</option>';+        list.forEach(function (t) { var o = document.createElement("option"); o.value = t.name || t.hostname; o.textContent = (t.name || t.hostname) + (t.version ? " (v" + t.version + ")" : ""); sel.appendChild(o); });+        if (list.length > 1) { var a = document.createElement("option"); a.value = "all"; a.textContent = "ALL machines (fan out)"; sel.appendChild(a); }+        document.getElementById("target-count").textContent = (list.length || 1) + " known";+      })+      .catch(function (e) { logLine("targets failed: " + e); });+  };+})();+</script>+</body>+</html>