Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/web.js

56.1 KiB, 1 run

created by r2519314175:1477, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* web.js — the Web panel's driver.
2 *
3 * `window.DaimondWeb` is the ONE interface the wasm tools call. It hides which
4 * driver is attached, so the model's tools do not change when the extension
5 * appears.
6 *
7 * There are three drivers, and the difference between them is not a detail —
8 * it is the whole security model:
9 *
10 * 'none' nothing open.
11 *
12 * 'frame' the page is in an iframe. It can be SHOWN, never operated. A
13 * cross-origin document is opaque to us: we cannot read its DOM,
14 * cannot dispatch a click into it, cannot photograph it. That is the
15 * same-origin policy, and it is not a gap to be engineered around —
16 * it is the boundary the whole web rests on. The exception is a
17 * SAME-ORIGIN page (one we serve ourselves, or a file the agent has
18 * just written into the workspace), which is fully operable, and is
19 * how the agent tests a page it has built.
20 *
21 * 'ext' Daimond Hands is installed. The page is a real tab with the user's
22 * real session, and the panel mirrors it. Everything works.
23 *
24 * The point that makes the whole design cohere: a page CANNOT both hide the
25 * user's password from the agent and let the agent drive. Those are the same
26 * capability seen from two sides. The isolation that protects the credential is
27 * the isolation that blocks the automation. So the privilege has to leave the
28 * page — and once it has, credential-safety must be re-established by a
29 * mechanism we build and the user can audit. That mechanism is the handoff:
30 * while the user is signing in, the extension sends NOTHING. Not redacted —
31 * not sent.
32 */
33(function () {
34 'use strict';
35
36 /// What the app says. Model-facing strings -- the notes a tool call returns,
37 /// which the daimon reads and acts on -- are NOT here: they stay in the
38 /// language the system prompt is written in. These are the ones a person
39 /// reads in the panel.
40 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
41
42 var state = {
43 driver: 'none', // 'none' | 'frame' | 'local' | 'ext'
44 url: '',
45 title: '',
46 mode: 'idle', // 'idle' | 'user' | 'agent'
47 reason: '', // why the current page is view-only, if it is
48 extId: '',
49 };
50
51 var els = {};
52 var deps = {}; // { onOpen, onClose, note } — supplied by daimond.js
53 var mirrorTimer = null;
54 var mirrorTick = null; // the running tick, so a button can ask for a picture at once
55 var mirrorOn = false; // the user has asked for the live picture
56 var mirrorWhy = ''; // '' a picture is arriving | 'wait' | 'silent' | 'refused'
57
58 // ── The extension bridge ────────────────────────────────────────
59
60 /// The wait ran out. This is NOT a refusal — the question may still be on
61 /// screen — so it is marked, and everything that reads it treats "nobody has
62 /// answered yet" differently from "they said no".
63 function slowOpen() {
64 var e = new Error('Daimond is asking the user to approve this site. It cannot be opened '
65 + 'until they do. Tell them what you want to do there, and try web_open '
66 + 'again once they have said yes — or read the page with web_fetch instead.');
67 e.timeout = true;
68 return e;
69 }
70
71 /// Ask the extension something. Resolves its reply, or rejects with a plain
72 /// sentence the model can act on.
73 ///
74 /// `limit` overrides the default bound, which is what an `open` uses: its own
75 /// wait is the whole late window (see below), because the reply is worth
76 /// having long after the caller has stopped waiting for it.
77 function ext(cmd, extra, limit) {
78 return new Promise(function (resolve, reject) {
79 if (!state.extId || !window.chrome || !chrome.runtime || !chrome.runtime.sendMessage) {
80 reject(new Error(NO_DRIVER));
81 return;
82 }
83 // Opening a site the user has not approved puts a question to a HUMAN,
84 // and a human may be making a cup of tea. Every call is bounded, because
85 // a tool call that never returns is a model that never speaks again — it
86 // simply hangs, with the user watching a spinner and no idea why.
87 var done = false;
88 if (!limit) limit = (cmd === 'open') ? OPEN_WAIT : 12000;
89 var timer = setTimeout(function () {
90 if (done) return;
91 done = true;
92 if (cmd === 'open') { reject(slowOpen()); return; }
93 var e = new Error('Daimond Hands did not answer in time. The page may be busy; try again.');
94 e.timeout = true;
95 reject(e);
96 }, limit);
97 function settle(fn, v) {
98 if (done) return;
99 done = true;
100 clearTimeout(timer);
101 fn(v);
102 }
103 var msg = Object.assign({ cmd: cmd }, extra || {});
104 try {
105 chrome.runtime.sendMessage(state.extId, msg, function (reply) {
106 // A missing extension surfaces here, not as a throw.
107 if (chrome.runtime.lastError || !reply) { settle(reject, new Error(NO_DRIVER)); return; }
108 if (reply.ok === false) {
109 var e = new Error(reply.error || 'Daimond Hands refused that.');
110 e.confirm = !!reply.confirm;
111 settle(reject, e);
112 return;
113 }
114 settle(resolve, reply);
115 });
116 } catch (e) { settle(reject, new Error(NO_DRIVER)); }
117 });
118 }
119
120 var NO_DRIVER = 'No driver is attached, so this page can be shown but not '
121 + 'operated. Ask the user to install Daimond Hands, or use web_fetch to read it instead.';
122 var NO_PAGE = 'No page is open. Call web_open first.';
123 var NOT_YOURS = 'You are not driving. The user is entering something private, '
124 + 'and Daimond is not watching. Wait for them to hand back the wheel.';
125
126 // ── A late answer ───────────────────────────────────────────────
127 //
128 // The wait above is bounded so the model is never left hanging. The
129 // EXTENSION, though, does not give up when we do: the grant window stays on
130 // screen, the toolbar keeps the question, and when the user finally clicks
131 // Allow the tab really does open and really is driven. That reply used to be
132 // dropped on the floor — so a user who answered slowly watched Daimond drive
133 // a real tab while the panel said the site was never approved and the model
134 // was told to try again. Two things follow.
135 //
136 // First, the REQUEST outlives our wait: it is bounded by the whole late
137 // window, and whatever it finally answers is reconciled with what the panel
138 // is showing — the page is adopted, or the stale note is replaced by the real
139 // refusal. Then, after the late window, we genuinely give up: the record is
140 // dropped and a further reply changes nothing.
141 //
142 // Second, a retry while the question is still on screen ATTACHES to the
143 // answer already coming rather than sending a second `open`. The extension
144 // asks per request, so two requests would be two grant windows stacked on the
145 // user — and the attach is what lets a retried web_open pick up the slow
146 // answer as its own proper tool result.
147
148 var OPEN_WAIT = 45000; // what a caller waits for a human to answer
149 var LATE_WAIT = 240000; // how much longer the request is kept alive, then dropped
150
151 /// The one `open` the extension has not answered yet.
152 var liveOpen = null; // { url, seq, settled, waiters }
153
154 /// What the panel was last ASKED to show. A late answer may touch the panel
155 /// only while this still names its page: anything newer — another site, the
156 /// guide, a close — owns the panel now, and repainting over it would be the
157 /// panel lying about what is on screen.
158 var claim = { seq: 0, url: '' };
159
160 /// The failed-open note now on screen, and which request left it there.
161 var stale = null; // { seq, url }
162
163 /// A line to put above the driving note, when the panel had already given up.
164 var lateLead = '';
165
166 /// Claim the panel for a page. Returns the sequence number the caller must
167 /// carry, so anything that answers later can tell whether it is still wanted.
168 function claimPage(url) {
169 stale = null;
170 lateLead = '';
171 claim = { seq: claim.seq + 1, url: url || '' };
172 return claim.seq;
173 }
174
175 /// Has the panel moved on since this page was asked for?
176 function superseded(seq, url) {
177 return claim.seq !== seq && !sameTarget(claim.url, url);
178 }
179
180 /// Whether two addresses name the same page. Deliberately literal: this is
181 /// about the request the panel made, not about where the site redirected to.
182 function sameTarget(a, b) {
183 if (!a || !b) return false;
184 return norm(a) === norm(b);
185 function norm(u) {
186 try {
187 var x = new URL(u, location.href);
188 return x.origin + (x.pathname === '/' ? '' : x.pathname) + x.search;
189 } catch (e) { return String(u); }
190 }
191 }
192
193 /// Ask the extension to open a page, and keep the request alive past our own
194 /// wait. Rejects with a marked timeout error when the human has not answered
195 /// in time; the request itself carries on.
196 function openViaExt(url, seq) {
197 var rec = (liveOpen && !liveOpen.settled && sameTarget(liveOpen.url, url))
198 ? liveOpen : startLive(url);
199 rec.seq = seq; // the newest asker owns the outcome
200 return new Promise(function (resolve, reject) {
201 var w = { done: false, timer: null };
202 w.settle = function (ok, v) {
203 if (w.done) return;
204 w.done = true;
205 clearTimeout(w.timer);
206 if (ok) resolve(v); else reject(v);
207 };
208 w.timer = setTimeout(function () {
209 if (w.done) return;
210 w.done = true;
211 reject(slowOpen());
212 }, OPEN_WAIT);
213 rec.waiters.push(w);
214 });
215 }
216
217 function startLive(url) {
218 var rec = { url: url, seq: claim.seq, settled: false, waiters: [] };
219 liveOpen = rec;
220 ext('open', { url: url }, OPEN_WAIT + LATE_WAIT).then(
221 function (r) { settleLive(rec, true, r); },
222 function (e) { settleLive(rec, false, e); });
223 return rec;
224 }
225
226 /// The extension answered — or the late window closed. Anyone still waiting
227 /// gets it as their own tool result; if nobody is, the panel is reconciled.
228 function settleLive(rec, ok, v) {
229 if (rec.settled) return;
230 rec.settled = true;
231 if (liveOpen === rec) liveOpen = null;
232 var heard = false;
233 for (var i = 0; i < rec.waiters.length; i++) {
234 if (!rec.waiters[i].done) heard = true;
235 rec.waiters[i].settle(ok, v);
236 }
237 rec.waiters = [];
238 if (!heard) lateAnswer(rec, ok, v);
239 }
240
241 /// The answer came after everyone had stopped waiting.
242 function lateAnswer(rec, ok, v) {
243 if (superseded(rec.seq, rec.url)) return;
244 // Only the note this very request left behind may be replaced. If it is
245 // gone, the panel is showing something else and this is no longer its
246 // business.
247 if (!stale || stale.seq !== rec.seq) return;
248 if (!ok) {
249 // Our own giving up is not an answer; only a real refusal is.
250 if (v && v.timeout) return;
251 notApproved(rec.url, (v && v.message) || '', true);
252 render();
253 return;
254 }
255 showPanel();
256 adoptPage(v, rec.url, t('web.approved_late', { host: esc(hostOf(rec.url)) }));
257 }
258
259 /// Take the page the extension has opened as the panel's own.
260 function adoptPage(r, url, lead) {
261 stale = null;
262 lateLead = lead || '';
263 state.driver = 'ext';
264 state.url = r.url || url;
265 state.title = r.title || '';
266 state.mode = r.mode || 'agent';
267 state.reason = '';
268 hideText();
269 note('');
270 startMirror();
271 render();
272 }
273
274 /// The panel's word on an open that did not happen. `late` marks an answer
275 /// that arrived after Daimond had stopped waiting, which is the difference
276 /// between "nobody answered" and "they answered, slowly".
277 function notApproved(url, msg, late) {
278 note(t('web.not_approved', { host: esc(hostOf(url)) })
279 + (late ? t('web.answer_late') : '')
280 + (/not approved|declined/i.test(msg) ? t('web.approval_closed') : esc(msg)));
281 }
282
283 /// Look for the hands. They are optional, and their absence is a normal state,
284 /// not an error.
285 ///
286 /// The extension stamps its own id on <html> — the page hard-codes nothing, so
287 /// a rebuilt extension with a different id still finds its way home, and the
288 /// ABSENCE of the stamp is exactly how we know there is no driver.
289 async function detect() {
290 async function adopt(id) {
291 if (!id || id === state.extId) return;
292 state.extId = id;
293 try { await ext('ping'); } catch (e) { state.extId = ''; }
294 render();
295 }
296 // It may have stamped before we ran, or it may be about to.
297 window.addEventListener('daimond-hands', function (e) {
298 adopt(e.detail && e.detail.id);
299 });
300 await adopt(document.documentElement.dataset.daimondHands || '');
301 }
302 function hasExt() { return !!state.extId; }
303
304 // ── Same-origin detection ───────────────────────────────────────
305
306 /// Can we reach into this frame? Only if it is ours. Everything else is
307 /// opaque, and pretending otherwise would be the one lie in the app.
308 function sameOrigin(url) {
309 try {
310 var u = new URL(url, location.href);
311 return u.origin === location.origin || u.protocol === 'blob:';
312 } catch (e) { return false; }
313 }
314
315 // ── The gateway ─────────────────────────────────────────────────
316
317 async function gw(path, body) {
318 var r = await fetch(path, {
319 method: 'POST',
320 headers: { 'content-type': 'application/json' },
321 credentials: 'same-origin',
322 body: JSON.stringify(body),
323 });
324 var j = null;
325 try { j = await r.json(); } catch (e) { /* not JSON */ }
326 if (!r.ok) {
327 throw new Error((j && j.error) || 'The web service could not reach that page.');
328 }
329 // Fetching a page costs credits, and the reply says what is left. One place owns that
330 // number; this hands it over rather than letting the header go stale.
331 if (window.DaimondGateway && DaimondGateway.noteBalance) DaimondGateway.noteBalance(j);
332 return j;
333 }
334
335 // ── The interface ───────────────────────────────────────────────
336
337 function status() {
338 // `reason` says WHY a page is view-only (cross-origin, no driver, a
339 // private address), so a caller need not guess from an empty `mode`.
340 return { driver: state.driver, url: state.url, title: state.title,
341 mode: state.mode, reason: state.reason || '' };
342 }
343
344 /// Bring the panel forward. Guarded, because the driver is loaded on pages
345 /// that have no layout engine (the harness), and an open that throws before
346 /// it has started is worse than one that cannot reveal itself.
347 function showPanel() {
348 if (!window.DaimondPanels) return;
349 DaimondPanels.show('web');
350 DaimondPanels.reflow();
351 }
352
353 /// Show a page. Under the extension this is a real tab with the user's real
354 /// session; otherwise it is an iframe, which many sites simply refuse. We ask
355 /// the gateway FIRST whether the site will frame, because a parent page
356 /// cannot detect frame refusal reliably — the load event fires either way.
357 async function open(url) {
358 url = String(url || '').trim();
359 // Whatever was shown before — a read-only text copy especially — is torn
360 // down before the new page, so the panel never shows one site through
361 // the remains of another.
362 hideText();
363 state.reason = ''; // cleared each open; set only where a page is view-only
364 // A blob: URL is how a page the agent has just WRITTEN gets rendered, and it
365 // is same-origin — so it is not merely allowed here, it is the one case
366 // where the agent can drive the page it made. Rejecting it as "not a web
367 // address" would have shut the door on the only automation that needs no
368 // extension at all.
369 var blob = /^blob:/i.test(url);
370 // A page the agent built in the workspace is opened by its path, not a
371 // URL. That is the headline local-driver case -- "drive the page you
372 // made" -- and it used to be lost: `page.html` matched the bare-domain
373 // rule and was rewritten to `https://page.html`, a dead cross-origin
374 // frame. An HTML file path is now read from the workspace and rendered in
375 // the sandboxed local driver instead.
376 if (!blob && !/^https?:\/\//i.test(url) && /\.x?html?$/i.test(url) && deps.readFile) {
377 var html;
378 try { html = await deps.readFile(url); }
379 catch (e) { throw new Error('No such page in the workspace: ' + url); }
380 showPanel();
381 stopMirror();
382 claimPage(url);
383 state.driver = 'local';
384 state.url = url;
385 state.title = url.split('/').pop();
386 state.mode = 'agent';
387 els.frame.src = URL.createObjectURL(new Blob([wrap(html)], { type: 'text/html' }));
388 note('');
389 render();
390 return {
391 url: url, framed: true, driver: 'local', title: state.title, mode: 'agent',
392 note: 'This page is Daimond\'s own, built in the workspace, so it can be '
393 + 'operated: snapshot it and click.',
394 };
395 }
396 if (!blob && !/^https?:\/\//i.test(url)) {
397 if (/^[\w.-]+\.[a-z]{2,}(\/|$)/i.test(url)) url = 'https://' + url;
398 else throw new Error('That is not a web address. Give a full http(s) URL.');
399 }
400 showPanel();
401 // From here the panel is claimed for this page, and anything still
402 // speaking for the last one has been superseded.
403 var seq = claimPage(url);
404
405 if (hasExt()) {
406 // Opening a site the user has not approved before pops up a small
407 // Daimond Hands window asking them to allow it — a separate window
408 // that is easy to miss. Say it is coming, so the panel is not just a
409 // blank wait while an approval window sits unnoticed behind it.
410 //
411 // And say what will happen AFTER they allow it, before it happens: the
412 // page arrives as a browser window of its own, and a user who was not
413 // told that reads it as Daimond having launched a browser instead of
414 // using the panel.
415 state.driver = 'ext';
416 state.url = url;
417 // The last page's title and mode do not describe this one. Left as they
418 // were, a site opened while the user still had the wheel on the previous
419 // tab drew the blindfold over the new site's opening note — the panel
420 // saying "you're driving" about a page nobody has opened yet.
421 state.title = '';
422 state.mode = 'idle';
423 note(t('web.opening', { host: esc(hostOf(url)) }) + '<br>' + t('web.real_tab'));
424 render();
425 var r;
426 try {
427 r = await openViaExt(url, seq);
428 } catch (e) {
429 // Declined, closed, or timed out: say what to do, not just the raw
430 // error. The note is stamped with this request, so a late answer can
431 // tell whether it is still the one on screen.
432 stale = { seq: seq, url: url };
433 notApproved(url, e.message, false);
434 render();
435 throw e;
436 }
437 // The wait is long, and the user or the model may have moved on. The
438 // model is still told its page is open — that is true, and the tab is
439 // there — but the panel now belongs to whatever was asked for since.
440 if (superseded(seq, url)) {
441 return { url: r.url || url, framed: false, driver: 'ext',
442 title: r.title || '', mode: r.mode || 'agent' };
443 }
444 adoptPage(r, url);
445 return { url: state.url, framed: false, driver: 'ext', title: state.title, mode: state.mode };
446 }
447
448 // No hands. Show it if the site allows framing, and say so plainly if not.
449 // Our own blob has nothing to ask the gateway about.
450 var framable = true, why = '';
451 try {
452 if (blob) throw new Error('skip');
453 var h = await gw('/api/web/head', { url: url });
454 framable = !!h.framable;
455 if (!framable) why = 'refuses to be shown inside another page';
456 } catch (e) {
457 // The gateway is optional, so a frame is tried even without it — but
458 // never for a loopback or private-network address. Those are the
459 // gateway's own control port and the machine's neighbours, not the
460 // public web, and framing one on a failed probe once loaded them
461 // straight into the panel.
462 if (isPrivateHost(url)) {
463 framable = false;
464 why = 'is a private address Daimond will not load';
465 } else {
466 framable = true;
467 }
468 }
469 stopMirror();
470 var ours = sameOrigin(url);
471 state.driver = ours ? 'local' : 'frame';
472 state.url = url;
473 state.title = '';
474 state.mode = ours ? 'agent' : 'idle';
475 // An operable page has no reason to give; a shown-only one records why, so
476 // status() can tell the user rather than leaving them to guess.
477 state.reason = ours ? ''
478 : 'This page is cross-origin' + (why ? ' — it ' + why : '')
479 + ', so it can be shown but not operated. Read it with web_fetch, '
480 + 'or install Daimond Hands to drive it.';
481 if (ours) {
482 // Our page, wrapped with the bridge and dropped into the SANDBOX. It runs
483 // in an opaque origin — it cannot touch our localStorage or our OPFS —
484 // and it answers us over postMessage. Isolated and operable at once.
485 els.frame.src = await localPage(url);
486 note('');
487 } else if (framable) {
488 els.frame.src = url;
489 note('');
490 } else {
491 els.frame.removeAttribute('src');
492 blocked(url);
493 }
494 render();
495 return {
496 url: url, framed: framable, driver: state.driver, title: '',
497 mode: state.mode,
498 // The model is told, in the tool result, exactly what it may do next.
499 note: ours
500 ? 'This page is Daimond\'s own, so it can be operated: snapshot it and click.'
501 : 'This page is cross-origin. It is SHOWN to the user, but it cannot be '
502 + 'operated from the browser. Use web_fetch to read it, or ask the user '
503 + 'to install Daimond Hands to drive it.',
504 };
505 }
506
507 async function close() {
508 stopMirror();
509 // The live view was a choice about a page that is now gone. It survives an
510 // open — see the mirror section — but not a close, which is the user
511 // putting the whole thing away.
512 mirrorOn = false;
513 mirrorWhy = '';
514 claimPage(''); // nothing still coming may repaint this panel
515 if (hasExt() && state.driver === 'ext') { try { await ext('close'); } catch (e) { /* gone */ } }
516 // The frame is NAVIGATED away from the closed page, not emptied.
517 //
518 // `removeAttribute('src')` DESTROYS the frame's browsing context, and
519 // `render` below then puts a new one straight back: it sees `driver ===
520 // 'none'`, and its idle branch calls `guide('index.html', true)`, which
521 // assigns `els.frame.src`. Torn down and re-created in the same task, at
522 // the same URL -- Chromium reuses the frame id for the new one and
523 // Playwright's frame manager asserts on the re-attach, killing the page.
524 // Measured 2026-08-14 in world 18: `DaimondWeb.close()` twice in a row,
525 // and `dev/verify_webwatch.mjs` had been dying at case C on the close
526 // after case A ever since it was written, for the same reason.
527 //
528 // Letting `render` navigate the frame to the guide discards the closed
529 // page's document just as thoroughly, and the frame survives it. The only
530 // case that still has to blank the frame by hand is a panel that was
531 // never built, where there is no `render` to put anything back.
532 //
533 // The disowning above is NOT what changed: an open may still be in flight,
534 // and `claimPage('')` is what stops its late answer painting a panel the
535 // user has closed.
536 if (!els.url) els.frame.removeAttribute('src');
537 state.driver = 'none';
538 state.url = '';
539 state.title = '';
540 state.mode = 'idle';
541 note('');
542 DaimondPanels.hide('web');
543 render();
544 return { ok: true };
545 }
546
547 /// Show Daimond's own user guide — a real static site at `/guide`, shown here in the panel.
548 ///
549 /// It is our own trusted page, not something the agent drives, so it needs none of the
550 /// sandbox-bridge machinery an external site gets: it is loaded straight into the frame and
551 /// navigates between its own pages by itself. Reachable directly at `/guide` too.
552 function guide(sub, noShow) {
553 stopMirror();
554 claimPage(''); // the guide owns the panel now
555 state.driver = 'guide';
556 state.url = 'guide/';
557 state.title = t('web.guide');
558 state.mode = 'idle';
559 note('');
560 els.frame.style.visibility = '';
561 // The RESTING call does not re-navigate a frame that is already there.
562 //
563 // Re-assigning the src a frame already holds is not a no-op: the document
564 // is torn down and loaded again, so the reader's place in the guide is
565 // thrown away. `render`'s idle branch makes that call on every close, and
566 // nobody asked for it. An EXPLICIT `guide()` -- the header "?" -- still
567 // re-assigns, because asking for the guide while looking at it means
568 // reload; dev/verify_webback.mjs depends on exactly that to get a fresh
569 // document into the frame after it has taken the sandbox off.
570 var want = 'guide/' + (sub || 'index.html');
571 if (!noShow || els.frame.getAttribute('src') !== want) els.frame.src = want;
572 render();
573 // `noShow` is set when the guide is loaded as the panel's own resting
574 // content (see render), where forcing the panel open would be wrong. The
575 // header "?" and an explicit request leave it unset, and do open the panel.
576 if (!noShow && window.DaimondPanels) DaimondPanels.show('web');
577 return { ok: true };
578 }
579
580 /// Read any page, whatever it does about framing. This goes through the
581 /// gateway, so it is the one route that always works — and the one route the
582 /// user must never log in through, because a page served from our origin is a
583 /// page we can read.
584 async function fetchPage(url) {
585 var j = await gw('/api/web/fetch', { url: String(url || '').trim() });
586 return {
587 url: j.url, title: j.title, text: j.text, bytes: j.bytes,
588 readOnly: true,
589 note: 'Read through the gateway. This is a copy of the page, not a session on it — '
590 + 'do not try to sign in here.',
591 };
592 }
593
594 /// The accessibility tree, with refs to act on. Never raw HTML: page text is
595 /// the least trustworthy string in the application, and a model that acts on
596 /// a selector it invented from page text is a model a page can steer.
597 async function snapshot() {
598 if (state.driver === 'none') throw new Error(NO_PAGE);
599 if (state.driver === 'ext') {
600 if (state.mode === 'user') throw new Error(NOT_YOURS);
601 var r = await ext('snapshot');
602 state.url = r.url || state.url;
603 state.title = r.title || state.title;
604 render();
605 return { url: r.url, title: r.title, nodes: r.nodes, truncated: !!r.truncated };
606 }
607 if (state.driver !== 'local') throw new Error(NO_DRIVER);
608 var r = await bridge('snapshot');
609 state.title = r.title || '';
610 return { url: state.url, title: r.title, nodes: r.nodes, truncated: !!r.truncated };
611 }
612
613 /// The rendered TEXT of the page being driven — the reliable way to READ a
614 /// page (a price, a table, an article), as opposed to snapshot, which is for
615 /// acting. Works on a real tab (ext) and on Daimond's own pages (local); a
616 /// cross-origin page merely SHOWN can only be read through web_fetch.
617 async function read() {
618 if (state.driver === 'none') throw new Error(NO_PAGE);
619 if (state.driver === 'ext') {
620 if (state.mode === 'user') throw new Error(NOT_YOURS);
621 var r = await ext('read');
622 return { url: r.url, title: r.title, text: r.text, chars: r.chars, truncated: !!r.truncated };
623 }
624 if (state.driver !== 'local') {
625 throw new Error('This page is only being shown, not driven, so its rendered '
626 + 'text is out of reach. Use web_fetch to read a cross-origin page.');
627 }
628 var b = await bridge('read');
629 return { url: state.url, title: b.title, text: b.text, chars: b.chars, truncated: !!b.truncated };
630 }
631
632 /// A consequential action — a purchase, a send, a POST to a new origin — is
633 /// put to the USER, not confirmed by the model. A prompt-injected model that
634 /// could confirm its own action would have no gate at all; the whole point is
635 /// that a human, not the agent, says yes. So when the extension flags an
636 /// action, Daimond asks the user here and only re-issues it on their word.
637 async function confirmAndRetry(cmd, args, reason) {
638 var ok = false;
639 if (deps.confirm) ok = await deps.confirm(reason);
640 if (!ok) {
641 // The model is told the human declined — it must not try again.
642 throw new Error('The user was asked to confirm this and declined, so it '
643 + 'was not done. Do not retry it; move on or ask them what to do instead.');
644 }
645 return await ext(cmd, Object.assign({}, args, { confirmed: true }));
646 }
647
648 async function click(ref) {
649 if (state.driver === 'none') throw new Error(NO_PAGE);
650 if (state.driver === 'ext') {
651 if (state.mode === 'user') throw new Error(NOT_YOURS);
652 var r;
653 try {
654 r = await ext('click', { ref: ref });
655 } catch (e) {
656 if (e && e.confirm) r = await confirmAndRetry('click', { ref: ref }, e.message.replace(/^CONFIRM:\s*/, ''));
657 else throw e;
658 }
659 state.url = r.url || state.url;
660 render();
661 return { ok: true, url: state.url };
662 }
663 if (state.driver !== 'local') throw new Error(NO_DRIVER);
664 await bridge('click', { ref: ref });
665 return { ok: true, url: state.url };
666 }
667
668 async function type(ref, text, submit) {
669 if (state.driver === 'none') throw new Error(NO_PAGE);
670 if (state.driver === 'ext') {
671 if (state.mode === 'user') throw new Error(NOT_YOURS);
672 try {
673 await ext('type', { ref: ref, text: text, submit: !!submit });
674 } catch (e) {
675 // Typing that SUBMITS a consequential form is gated exactly as a
676 // click on that form's button — and the user, not the model, decides.
677 if (e && e.confirm) await confirmAndRetry('type', { ref: ref, text: text, submit: !!submit }, e.message.replace(/^CONFIRM:\s*/, ''));
678 else throw e;
679 }
680 return { ok: true };
681 }
682 if (state.driver !== 'local') throw new Error(NO_DRIVER);
683 await bridge('type', { ref: ref, text: text, submit: !!submit });
684 return { ok: true };
685 }
686
687 /// `amount` is in SCREENS, not pixels — that is what the model's tool
688 /// description promises it, and the two have to agree or it will scroll a
689 /// long page six hundred pixels at a time and conclude the page is stuck.
690 async function scroll(dir, amount) {
691 if (state.driver === 'none') throw new Error(NO_PAGE);
692 var screens = Math.max(0.1, Math.min(10, Number(amount) || 1));
693 if (state.driver === 'ext') {
694 if (state.mode === 'user') throw new Error(NOT_YOURS);
695 await ext('scroll', { direction: dir, amount: screens });
696 return { ok: true };
697 }
698 if (state.driver !== 'local') throw new Error(NO_DRIVER);
699 await bridge('scroll', { direction: dir, amount: screens });
700 return { ok: true };
701 }
702
703 // ── The local driver: isolate by sandbox, talk by postMessage ───
704 //
705 // The agent writes a page into the workspace and Daimond renders it, so the
706 // agent can drive the page it just built and see whether it works. The naive
707 // way to do that is a same-origin iframe we reach into. It is also a hole
708 // straight through the app.
709 //
710 // A blob: URL INHERITS OUR ORIGIN. A page rendered that way, unsandboxed,
711 // runs as us: it can read localStorage — where the user's API key lives — and
712 // reach OPFS. And the page is written BY THE AGENT, which may itself have been
713 // steered by a web page it read a moment ago. That is the whole prompt-
714 // injection chain, ending in exfiltration, and it would have been our own
715 // preview feature that closed it.
716 //
717 // So the frame keeps its sandbox and never gets `allow-same-origin`: it runs
718 // in an opaque origin with no access to our storage at all. We cannot reach
719 // into it — and we do not need to. We control the HTML we put in it, so we
720 // wrap it with a small BRIDGE that walks its own DOM and clicks its own
721 // buttons, and answers us over postMessage. Isolation and automation at once,
722 // which is exactly what the cross-origin case cannot have.
723
724 var pending = {}; // id -> {resolve, reject}
725 var msgSeq = 0;
726
727 /// The script wrapped around a page we render, so it can be driven from
728 /// outside without being trusted from inside.
729 function bridgeSource() {
730 return '(' + function () {
731 var refs = [];
732 function vis(el) {
733 if (el.hidden) return false;
734 var r = el.getBoundingClientRect();
735 if (!r.width && !r.height) return false;
736 var st = getComputedStyle(el);
737 return st.visibility !== 'hidden' && st.display !== 'none';
738 }
739 function inter(el) { return /^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/.test(el.tagName); }
740 function secret(el) {
741 var ty = (el.type || '').toLowerCase();
742 if (ty === 'password' || ty === 'hidden') return true;
743 var ac = (el.getAttribute('autocomplete') || '').toLowerCase();
744 return /^cc-|password|one-time-code/.test(ac);
745 }
746 function role(el) {
747 var explicit = el.getAttribute('role');
748 if (explicit) return explicit;
749 switch (el.tagName) {
750 case 'A': return 'link';
751 case 'BUTTON': return 'button';
752 case 'SELECT': return 'combobox';
753 case 'TEXTAREA': return 'textbox';
754 case 'SUMMARY': return 'summary';
755 case 'LI': return 'listitem';
756 case 'P': return 'text';
757 case 'H1': case 'H2': case 'H3': case 'H4': return 'heading';
758 case 'LABEL': return 'label';
759 case 'INPUT':
760 var ty = (el.type || 'text').toLowerCase();
761 if (ty === 'submit' || ty === 'button') return 'button';
762 if (ty === 'checkbox') return 'checkbox';
763 if (ty === 'radio') return 'radio';
764 if (ty === 'password') return 'password';
765 return 'textbox';
766 }
767 return '';
768 }
769 function name(el) {
770 var n = el.getAttribute('aria-label')
771 || (el.labels && el.labels[0] && el.labels[0].textContent)
772 || el.getAttribute('placeholder') || el.getAttribute('title')
773 || el.getAttribute('alt')
774 || (el.tagName === 'INPUT' && el.type === 'submit' ? el.value : '')
775 || el.textContent || '';
776 return String(n).replace(/\s+/g, ' ').trim();
777 }
778 function snapshot() {
779 refs = [];
780 var nodes = [], CAP = 200;
781 var all = document.body.querySelectorAll(
782 'a[href],button,input,select,textarea,[role],h1,h2,h3,h4,li,p,label,summary');
783 for (var i = 0; i < all.length && nodes.length < CAP; i++) {
784 var el = all[i];
785 if (!vis(el)) continue;
786 var r = role(el);
787 if (!r) continue;
788 var nm = name(el);
789 if (!nm && !inter(el)) continue;
790 var ref = refs.push(el) - 1;
791 var n = { ref: ref, role: r, name: nm.slice(0, 160) };
792 // A password is never serialised, even here, where we are the ones
793 // driving. There is no reason a model needs it.
794 if (inter(el) && 'value' in el && !secret(el)) n.value = String(el.value || '').slice(0, 160);
795 nodes.push(n);
796 }
797 return { title: document.title, nodes: nodes, truncated: all.length > CAP };
798 }
799 addEventListener('message', function (e) {
800 var m = e.data;
801 if (!m || m.dw !== 1 || !m.cmd) return;
802 var out = { dw: 1, id: m.id, ok: true };
803 try {
804 if (m.cmd === 'snapshot') {
805 var s = snapshot();
806 out.title = s.title; out.nodes = s.nodes; out.truncated = s.truncated;
807 } else if (m.cmd === 'read') {
808 var main = document.querySelector('main, [role="main"], article') || document.body;
809 var txt = (main.innerText || '').replace(/\n{3,}/g, '\n\n').trim();
810 out.title = document.title; out.chars = txt.length;
811 out.truncated = txt.length > 40000;
812 out.text = txt.slice(0, 40000);
813 } else if (m.cmd === 'click') {
814 var el = refs[m.ref];
815 if (!el) throw new Error('There is no element with ref ' + m.ref
816 + '. Snapshot the page again — refs go stale when the page changes.');
817 el.click();
818 } else if (m.cmd === 'type') {
819 var f = refs[m.ref];
820 if (!f) throw new Error('There is no element with ref ' + m.ref + '. Snapshot the page again.');
821 f.focus();
822 f.value = m.text;
823 f.dispatchEvent(new Event('input', { bubbles: true }));
824 f.dispatchEvent(new Event('change', { bubbles: true }));
825 if (m.submit && f.form) {
826 f.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
827 }
828 } else if (m.cmd === 'scroll') {
829 scrollBy(0, (m.direction === 'up' ? -1 : 1) * (m.amount || 1) * innerHeight * 0.9);
830 } else {
831 throw new Error('Unknown command.');
832 }
833 } catch (err) {
834 out.ok = false;
835 out.error = String((err && err.message) || err);
836 }
837 parent.postMessage(out, '*');
838 });
839 parent.postMessage({ dw: 1, ready: 1, title: document.title }, '*');
840 } + ')()';
841 }
842
843 /// Ask the page in the frame to do something. It answers, or it refuses; it
844 /// never gets to reach back out.
845 function bridge(cmd, extra) {
846 return new Promise(function (resolve, reject) {
847 var win = els.frame.contentWindow;
848 if (!win) { reject(new Error(NO_PAGE)); return; }
849 var id = ++msgSeq;
850 pending[id] = { resolve: resolve, reject: reject };
851 win.postMessage(Object.assign({ dw: 1, id: id, cmd: cmd }, extra || {}), '*');
852 setTimeout(function () {
853 if (!pending[id]) return;
854 delete pending[id];
855 reject(new Error('The page did not answer. It may still be loading, or it '
856 + 'may be a page Daimond cannot operate — try web_fetch to read it instead.'));
857 }, 5000);
858 });
859 }
860
861 /// The frame's only way back to us. We ONLY listen when the frame is our own
862 /// bridged page: a cross-origin site we are merely displaying gets `allow-
863 /// scripts`, so it too can `postMessage` at us, and without this gate a page
864 /// we promised only to SHOW could push a forged `ready` title into the
865 /// model's context. The driver being `local` is the proof the frame is ours.
866 /// Beyond that we check the sender is the frame, and never eval or trust a
867 /// word of what it says.
868 function onBridgeMessage(e) {
869 if (state.driver !== 'local') return;
870 if (!els.frame || e.source !== els.frame.contentWindow) return;
871 var m = e.data;
872 if (!m || m.dw !== 1) return;
873 if (m.ready) { state.title = m.title || ''; render(); return; }
874 var p = pending[m.id];
875 if (!p) return;
876 delete pending[m.id];
877 if (m.ok === false) p.reject(new Error(m.error || 'The page refused.'));
878 else p.resolve(m);
879 }
880
881 /// Wrap a page we are about to render with the bridge. The page stays exactly
882 /// what the agent wrote; the bridge is appended, and it is the only script we
883 /// add.
884 function wrap(html) {
885 var b = '<script>' + bridgeSource() + '<\/script>';
886 return /<\/body>/i.test(html) ? html.replace(/<\/body>/i, b + '</body>') : html + b;
887 }
888
889 /// A page Daimond itself is rendering — from the workspace, or from anywhere
890 /// else we control. Returns a blob: URL for the SANDBOXED frame.
891 async function localPage(url) {
892 var r = await window.fetch(url); // our own blob; no network
893 var html = await r.text();
894 return URL.createObjectURL(new Blob([wrap(html)], { type: 'text/html' }));
895 }
896
897
898 // ── The mirror ──────────────────────────────────────────────────
899 // Under the extension the real page is a real tab. The panel can show a LIVE
900 // PICTURE of that tab — but photographing a tab needs Chrome's broadest
901 // permission ("all your data on all websites"), so it is strictly OPT-IN.
902 // It is NEVER asked for automatically: a user who just approved one site
903 // should not be hit with the scariest prompt in the browser a second later,
904 // and polling for a picture we may not photograph is what turned into an
905 // endless stream of permission windows. By default the tab is simply open
906 // and visible — the user watches it there — and a button pulls the live view
907 // into the panel if they want it.
908 //
909 // The answer to that button belongs to the SESSION, not to the page.
910 // `mirrorOn` used to be cleared on every open, so a user who had already
911 // pulled the picture in was put back to the note at the next site with no
912 // word about why — and, having watched a browser window open, had every
913 // reason to read that as the panel not working. Turning it off is what stops
914 // the polling; opening a page is not.
915 //
916 // `mirrorWhy` is the other half. A panel with no picture must say WHICH kind
917 // of no it is: nobody has answered yet, or this tab cannot be photographed
918 // at all. Blank is the one thing it may not be.
919
920 function startMirror() {
921 stopMirror();
922 // A new page has no picture yet, however the last one ended.
923 mirrorWhy = mirrorOn ? 'wait' : '';
924 var inFlight = false; // one tick at a time; never overlap
925 mirrorTick = tick;
926 mirrorTimer = setInterval(tick, 1200);
927 extNote();
928 tick();
929 /// Ask only `status` — the tab's URL and mode, nothing off the page and no
930 /// permission needed. This is all we poll unless the live view is on.
931 async function pollStatus() {
932 try {
933 var st = await ext('status');
934 // Keep the broker's reason for the wheel being with the user, so the
935 // blindfold can name the cause ("the sign-in page for …") rather than
936 // only ever saying it stopped.
937 if (st && typeof st.reason === 'string') state.reason = st.reason;
938 if (st && st.mode && st.mode !== state.mode) {
939 state.mode = st.mode;
940 // The wheel handed back is a panel with no picture yet, whatever was
941 // on screen before the user took it.
942 if (mirrorOn && state.mode === 'agent') mirrorWhy = 'wait';
943 render();
944 extNote();
945 }
946 if (st && st.url) { state.url = st.url; state.title = st.title || state.title; render(); }
947 } catch (e) { /* no hands; nothing to poll */ }
948 }
949 async function tick() {
950 if (inFlight) return; // the previous tick is still resolving
951 inFlight = true;
952 try {
953 // Poll pictures ONLY when the user has turned the live view on and we
954 // are not blindfolded. Otherwise poll status alone — no permission,
955 // no popup.
956 if (state.mode === 'user' || !mirrorOn) { await pollStatus(); return; }
957 try {
958 var r = await ext('frame');
959 if (r && r.png) {
960 mirrorWhy = '';
961 els.mirror.src = r.png;
962 els.mirror.style.display = '';
963 els.frame.style.display = 'none';
964 note('');
965 }
966 if (r && r.mode && r.mode !== state.mode) { state.mode = r.mode; render(); }
967 if (r && r.url) { state.url = r.url; state.title = r.title || state.title; render(); }
968 } catch (e) {
969 var msg = (e && e.message) || '';
970 if (/not driving/i.test(msg)) { state.mode = 'user'; render(); }
971 // The mirror was declined, or Chrome will not grant it. Stop asking
972 // — the button is how it is asked for again — and say why, rather
973 // than leaving the last photograph up as though it were live.
974 else if (/mirror|photograph/i.test(msg)) { mirrorOn = false; mirrorWhy = 'refused'; extNote(); }
975 // Anything else is silence, not a refusal: the hands may be busy,
976 // or gone. Nobody having answered is not the same as this being
977 // impossible, and the panel has to tell the two apart — this branch
978 // used to swallow the error, which left the panel blank or, worse,
979 // still showing the last photograph.
980 else { mirrorWhy = 'silent'; extNote(); }
981 }
982 } finally { inFlight = false; }
983 }
984 }
985
986 /// What the panel says while the extension is driving a real tab.
987 ///
988 /// EVERY ext state a person can see comes through here, because a browser
989 /// window appearing beside a silent panel is indistinguishable from Daimond
990 /// having simply launched a browser and stopped. The one state it does not
991 /// own is the blindfold: there the user has the wheel, and render() speaks.
992 function extNote() {
993 if (state.driver !== 'ext') return;
994 // Under the extension the iframe is never the page. It still holds
995 // whatever it last showed — the guide, usually — and the guide under a
996 // header naming a site is the panel lying about what it displays.
997 els.frame.style.display = 'none';
998 if (state.mode === 'user') return;
999 if (mirrorOn && !mirrorWhy) return; // a live picture is on screen
1000 els.mirror.style.display = 'none';
1001 els.note.className = 'web-note on';
1002 els.note.innerHTML = '';
1003 function line(html) {
1004 var d = document.createElement('div');
1005 d.innerHTML = html; // built here, from our own strings
1006 els.note.appendChild(d);
1007 }
1008 // When the panel had already given up on this page, say so above the rest:
1009 // the user was last told the site was not approved, and it is.
1010 if (lateLead) line(lateLead);
1011 line(t('web.driving_tab', { host: esc(hostOf(state.url)) }));
1012 // The relationship, in one sentence: the window that just appeared IS the
1013 // page, and this panel is its mirror.
1014 line(t('web.real_tab'));
1015 if (mirrorWhy === 'wait') line(t('web.mirror_wait'));
1016 else if (mirrorWhy === 'silent') line(t('web.mirror_silent'));
1017 else if (mirrorWhy === 'refused') line(t('web.mirror_refused'));
1018 if (mirrorOn) return; // already asked for; the button would say nothing
1019 var btn = document.createElement('button');
1020 btn.textContent = t('web.show_live');
1021 btn.addEventListener('click', function () {
1022 // The panel says it is waiting BEFORE the ask, and asks at once rather
1023 // than at the next tick: the next thing the user sees may be Chrome's
1024 // permission window, and an empty panel behind it is what made this
1025 // look broken.
1026 mirrorOn = true;
1027 mirrorWhy = 'wait';
1028 extNote();
1029 if (mirrorTick) mirrorTick();
1030 });
1031 els.note.appendChild(btn);
1032 }
1033 function stopMirror() {
1034 if (mirrorTimer) { clearInterval(mirrorTimer); mirrorTimer = null; }
1035 mirrorTick = null;
1036 els.mirror.style.display = 'none';
1037 els.frame.style.display = '';
1038 }
1039
1040 /// The panel's own control cannot take the wheel — only a trusted gesture in
1041 /// the tab (the Resume overlay) or the extension popup can, and this page is
1042 /// neither. So the button just re-checks whether the user has already resumed
1043 /// there, in case the 1.2s poll has not yet caught up.
1044 async function takeover() {
1045 try {
1046 var st = await ext('status');
1047 state.mode = (st && st.mode) || state.mode;
1048 render();
1049 } catch (e) { /* no hands; nothing to check */ }
1050 }
1051
1052 // ── The panel ───────────────────────────────────────────────────
1053
1054 /// Can the Back control actually do anything? It is drawn only where it can.
1055 ///
1056 /// A control that does nothing when it is pressed teaches the reader to
1057 /// distrust every control on the panel, so this decides whether `#web-back` is
1058 /// on screen at all. There are three separate reasons it cannot work, and all
1059 /// three hold in the app as it ships:
1060 ///
1061 /// * UNDER HANDS THE FRAME IS NOT THE PAGE. `extNote` hides the frame and
1062 /// leaves whatever it last held -- the guide, usually -- inside it. A Back
1063 /// that "worked" there would walk the GUIDE's history behind a header
1064 /// naming a live site.
1065 /// * A CROSS-ORIGIN PAGE'S HISTORY IS NOT OURS TO WALK. That is the
1066 /// same-origin policy: reading `contentWindow.history` throws.
1067 /// * OUR OWN PAGES ARE SANDBOXED WITHOUT `allow-same-origin`. See the
1068 /// `sandbox` attribute on `#web-frame` in index.html, and the note at the
1069 /// top of `guide/frame.js` explaining why it is deliberate: a page an agent
1070 /// wrote must not be able to reach the user's keys. An opaque origin is
1071 /// cross-origin to us too, so the guide and the local driver are no more
1072 /// reachable than anybody else's site.
1073 ///
1074 /// PROBED rather than decided from a table of drivers. The last reason is the
1075 /// one that could plausibly change -- a guide frame of its own, or a
1076 /// back-channel through `guide/frame.js` -- and asking the frame means the
1077 /// control returns by itself when it can work again, instead of waiting for
1078 /// somebody to remember this line.
1079 function canGoBack() {
1080 // Not a question about the frame: under the extension it is not the page,
1081 // and an idle panel has nothing in it.
1082 if (state.driver === 'ext' || state.driver === 'none') return false;
1083 try {
1084 return !!(els.frame && els.frame.contentWindow && els.frame.contentWindow.history);
1085 } catch (e) {
1086 return false; // opaque or cross-origin: not ours
1087 }
1088 }
1089
1090 /// Draw the Back control, or take it away.
1091 ///
1092 /// Called from `render` and from the frame's own `load`: what the frame HOLDS
1093 /// is what decides, and that changes when a document arrives rather than when
1094 /// one is asked for. Painting only on render left the control judging the
1095 /// document it was about to replace.
1096 function paintBack() {
1097 if (els.back) els.back.style.display = canGoBack() ? '' : 'none';
1098 }
1099
1100 function render() {
1101 if (!els.url) return;
1102 // An empty iframe is a blank white rectangle, which reads as a broken panel
1103 // rather than an idle one. Say what the panel is for instead.
1104 var idle = (state.driver === 'none');
1105 if (idle) {
1106 // The panel opens straight onto the guide, not a prompt to open it.
1107 // guide() flips the driver off 'none', so the follow-up render is not
1108 // idle and this does not recurse; noShow keeps a background render from
1109 // forcing the panel open.
1110 guide('index.html', true);
1111 return;
1112 }
1113 els.frame.style.visibility = '';
1114 // The header names what is on screen. Our own guide says "Guide"; an external page shows
1115 // its host and path; an idle panel says what the panel is FOR, rather than "No page",
1116 // which read as broken.
1117 els.url.textContent = state.driver === 'guide' ? t('web.guide')
1118 : state.url ? hostOf(state.url) + pathOf(state.url)
1119 : t('web.panel_for');
1120 els.url.title = state.driver === 'guide' ? t('web.guide_title') : (state.url || '');
1121 var m = els.mode;
1122 m.className = 'web-mode' + (state.mode === 'user' ? ' user' : state.mode === 'agent' ? ' agent' : '');
1123 m.textContent = state.mode === 'user' ? t('web.who_you')
1124 : state.mode === 'agent' ? t('web.who_daimond')
1125 : (hasExt() ? t('web.who_ready') : t('web.who_view_only'));
1126 m.title = state.mode === 'user'
1127 ? t('web.who_you_help')
1128 : state.mode === 'agent'
1129 ? t('web.who_daimond_help')
1130 : hasExt() ? t('web.who_ready_help') : t('web.who_view_only_help');
1131 // WHY THERE ARE TWO OF THEM, said for as long as there are two of them.
1132 // Only under the extension: with a frame there is no second window to
1133 // explain, and a panel that claimed one would be lying.
1134 if (els.live) {
1135 els.live.hidden = state.driver !== 'ext';
1136 if (!els.live.hidden) els.live.textContent = t('web.real_tab');
1137 }
1138 paintBack(); // shown only where it can act
1139 els.blind.style.display = (state.mode === 'user') ? 'flex' : 'none';
1140 // Name why the wheel is with the user, when the broker told us. A specific
1141 // cause ("stopped at the sign-in page for …") reassures far more than the
1142 // generic "I'm not watching", so the title carries it whenever it is known.
1143 if (state.mode === 'user') {
1144 var bt = els.blind.querySelector('.web-blind-title');
1145 if (bt) bt.textContent = state.reason
1146 ? t('web.blind_title_at', { where: state.reason })
1147 : t('web.blind_title');
1148 }
1149 }
1150
1151 function note(html) {
1152 els.note.innerHTML = '';
1153 if (!html) { els.note.className = 'web-note'; return; }
1154 els.note.className = 'web-note on';
1155 var p = document.createElement('div');
1156 p.innerHTML = html; // built here, from our own strings
1157 els.note.appendChild(p);
1158 }
1159
1160 /// A site that will not be embedded — which is MOST of them. This is not an
1161 /// error; it is the web's own clickjacking defence (X-Frame-Options / CSP),
1162 /// and it is the same in every browser. Say so plainly, and offer the three
1163 /// things that actually work: open it in a real tab, read its text, or — with
1164 /// Daimond Hands — drive it live.
1165 function blocked(url) {
1166 els.note.innerHTML = '';
1167 els.note.className = 'web-note on';
1168 var msg = document.createElement('div');
1169 msg.innerHTML = t('web.blocked', { host: esc(hostOf(url)) }) + ' '
1170 + (hasExt() ? t('web.blocked_hands') : t('web.blocked_install'));
1171 els.note.appendChild(msg);
1172
1173 var row = document.createElement('div');
1174 row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:center';
1175
1176 var tab = document.createElement('button');
1177 tab.textContent = t('web.open_new_tab'); // free, no gateway, no extension
1178 tab.addEventListener('click', function () { window.open(url, '_blank', 'noopener'); });
1179 row.appendChild(tab);
1180
1181 var read = document.createElement('button');
1182 read.textContent = t('web.read_as_text');
1183 read.addEventListener('click', async function () {
1184 read.disabled = true;
1185 read.textContent = t('web.reading');
1186 try {
1187 var j = await fetchPage(url);
1188 note('');
1189 els.frame.removeAttribute('src');
1190 showText(j.title, j.text);
1191 } catch (e) {
1192 read.disabled = false;
1193 read.textContent = t('web.read_as_text');
1194 msg.innerHTML = t('web.read_failed', { reason: esc(e.message) });
1195 }
1196 });
1197 row.appendChild(read);
1198 els.note.appendChild(row);
1199 }
1200
1201 /// Tear down the read-only text copy, if one is showing. A new page must
1202 /// never be seen through the text of the last one: the copy overlays the
1203 /// frame, so leaving it up made the panel show one site's words under
1204 /// another site's header and URL — the panel lying about what it displays.
1205 function hideText() {
1206 var pre = document.getElementById('web-text');
1207 if (pre) { pre.style.display = 'none'; pre.innerHTML = ''; }
1208 els.frame.style.display = '';
1209 }
1210
1211 /// A page the gateway read for us, rendered as text. It is a copy, not a
1212 /// session — the panel says so, because a user who mistakes it for the real
1213 /// site might try to sign in to it.
1214 function showText(title, text) {
1215 els.mirror.style.display = 'none';
1216 els.frame.style.display = 'none';
1217 var pre = document.getElementById('web-text');
1218 if (!pre) {
1219 pre = document.createElement('div');
1220 pre.id = 'web-text';
1221 pre.className = 'web-text';
1222 els.body.appendChild(pre);
1223 }
1224 pre.style.display = '';
1225 pre.innerHTML = '';
1226 var badge = document.createElement('div');
1227 badge.className = 'web-readonly';
1228 badge.textContent = t('web.readonly_badge');
1229 var b = document.createElement('div');
1230 b.className = 'web-text-body';
1231 b.textContent = text || ''; // text, never markup
1232 pre.appendChild(badge);
1233 pre.appendChild(b);
1234 }
1235
1236 /// Whether a URL names a loopback or private-network host — the addresses a
1237 /// browser can reach but the public web cannot, and so the ones the panel
1238 /// must not be tricked into loading. A rough check on the literal host is
1239 /// enough here; the gateway does the authoritative resolve-and-vet.
1240 function isPrivateHost(u) {
1241 var h;
1242 try { h = new URL(u).hostname.toLowerCase(); } catch (e) { return false; }
1243 if (h === 'localhost' || h.endsWith('.localhost')) return true;
1244 if (h === '0.0.0.0' || h === '::1' || h === '[::1]') return true;
1245 if (/^127\./.test(h)) return true;
1246 if (/^10\./.test(h)) return true;
1247 if (/^192\.168\./.test(h)) return true;
1248 if (/^169\.254\./.test(h)) return true; // link-local
1249 if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // 172.16–31
1250 return false;
1251 }
1252
1253 function hostOf(u) { try { return new URL(u).host; } catch (e) { return u; } }
1254 function pathOf(u) { try { var p = new URL(u).pathname; return p === '/' ? '' : p; } catch (e) { return ''; } }
1255 function esc(s) {
1256 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1257 }
1258
1259 function init(d) {
1260 deps = d || {};
1261 els.frame = document.getElementById('web-frame');
1262 els.mirror = document.getElementById('web-mirror');
1263 els.note = document.getElementById('web-note');
1264 els.blind = document.getElementById('web-blind');
1265 els.live = document.getElementById('web-live');
1266 els.url = document.getElementById('web-url');
1267 els.mode = document.getElementById('web-mode');
1268 els.body = document.getElementById('web-body');
1269 els.back = document.getElementById('web-back');
1270 if (!els.frame) return;
1271
1272 // A document arriving is the moment Back's answer can change. See paintBack.
1273 els.frame.addEventListener('load', paintBack);
1274 window.addEventListener('message', onBridgeMessage);
1275 document.getElementById('web-takeover').addEventListener('click', takeover);
1276 document.getElementById('web-reload').addEventListener('click', function () {
1277 if (state.url) open(state.url);
1278 });
1279 document.getElementById('web-pop').addEventListener('click', function () {
1280 if (state.url) window.open(state.url, '_blank', 'noopener');
1281 });
1282 els.back.addEventListener('click', function () {
1283 // `canGoBack` has already decided this control is on screen, so the
1284 // catch is a guard against the frame changing under a press rather than
1285 // the ordinary case it used to swallow.
1286 if (!canGoBack()) return;
1287 try { els.frame.contentWindow.history.back(); } catch (e) { /* gone since */ }
1288 });
1289 detect();
1290 render();
1291 }
1292
1293 // The panel's header names what is on screen and who has the wheel, and it
1294 // stays on screen, so a language change has to redraw it where it stands.
1295 if (window.DaimondI18n) {
1296 DaimondI18n.onChange(function () { if (els.url) render(); });
1297 }
1298
1299 window.DaimondWeb = {
1300 init: init,
1301 status: status,
1302 open: open,
1303 guide: guide,
1304 close: close,
1305 fetch: fetchPage,
1306 snapshot: snapshot,
1307 read: read,
1308 click: click,
1309 type: type,
1310 scroll: scroll,
1311 hasHands: hasExt,
1312 /// Test only. The late-answer rule is minutes long by design, and a test
1313 /// cannot spend minutes proving it; shortening the two waits changes
1314 /// nothing else. Same-origin callers only, and the worst a caller can do
1315 /// with it is make its own opens give up sooner.
1316 _setWaitsForTest: function (o) {
1317 if (o && o.open > 0) OPEN_WAIT = o.open;
1318 if (o && o.late > 0) LATE_WAIT = o.late;
1319 return { open: OPEN_WAIT, late: LATE_WAIT };
1320 },
1321 };
1322})();