Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/ext/content.js

24.7 KiB, 1 run

created by r2519314175:871, 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// Daimond Hands -- the hands.
2//
3// Injected on demand into the isolated world of the managed tab. It builds the
4// accessibility snapshot, performs the actions, and watches for the moment the
5// page starts asking for a credential -- at which point it takes its own hands
6// off and tells the broker.
7//
8// Two rules govern everything below.
9//
10// 1. Refs, not selectors. The model acts on an opaque integer that this
11// script alone can resolve to an element. It never sees, and never
12// invents, a selector.
13// 2. A secret is never serialised. Not redacted, not starred out: the value
14// is simply not put in the object that crosses the boundary.
15//
16// The isolated world is torn down on navigation, so the ref table has exactly
17// the right lifetime for free.
18
19'use strict';
20
21(() => {
22
23 if (!globalThis.__daimond) {
24
25 // -------------------------------------------------------------------
26 // Refs
27 // -------------------------------------------------------------------
28
29 /// ref -> element, weakly, so a detached node does not keep the page alive.
30 const byRef = new Map();
31 /// element -> ref, so an element keeps the same ref between snapshots.
32 const toRef = new WeakMap();
33 let next = 1;
34
35 /// The stable integer for an element, minted on first sight.
36 function refOf(el) {
37 let r = toRef.get(el);
38 if (r === undefined) {
39 r = next++;
40 toRef.set(el, r);
41 byRef.set(r, new WeakRef(el));
42 }
43 return r;
44 }
45
46 /// The element for a ref, or null if it has left the page.
47 function elOf(ref) {
48 const w = byRef.get(ref);
49 if (!w) return null;
50 const el = w.deref();
51 if (!el || !el.isConnected) return null;
52 return el;
53 }
54
55 // -------------------------------------------------------------------
56 // Secrets
57 // -------------------------------------------------------------------
58
59 /// A field whose name smells of a secret has its value withheld even when
60 /// the type does not say so. Cheap, and wrong only in the safe direction.
61 const SECRET_NAME = /pass|pwd|secret|token|otp|2fa|mfa|totp|cvv|cvc|csc|card|iban|sort.?code|routing|ssn|api[-_ ]?key|auth|session|nonce|csrf/i;
62
63 /// Values that look like credentials whatever the field is called.
64 function looksSecret(v) {
65 if (typeof v !== 'string' || v.length < 8) return false;
66 if (/^ey[A-Za-z0-9_-]{8,}\./.test(v)) return true; // JWT
67 if (/^(sk|pk|rk|ghp|gho|xox[abposr])[-_][A-Za-z0-9]{8,}/i.test(v)) return true; // API keys
68 if (/^[A-Za-z0-9_-]{24,}$/.test(v) && /\d/.test(v) && /[A-Za-z]/.test(v)) return true; // Opaque blob
69 const digits = v.replace(/[\s-]/g, '');
70 if (/^\d{13,19}$/.test(digits)) return true; // Card number
71 return false;
72 }
73
74 /// Is this element a place a credential is typed?
75 function isCredentialField(el) {
76 if (el.tagName !== 'INPUT') return false;
77 const t = (el.getAttribute('type') || 'text').toLowerCase();
78 const ac = (el.getAttribute('autocomplete') || '').toLowerCase();
79 if (t === 'password') return true;
80 if (ac.includes('current-password')) return true;
81 if (ac.includes('new-password')) return true;
82 if (ac.includes('webauthn')) return true;
83 if (ac === 'one-time-code') return true;
84 return false;
85 }
86
87 /// The value of a field, or null when it must never cross the boundary.
88 /// Returns undefined when the element has no value at all.
89 function valueOf(el) {
90 const tag = el.tagName;
91 if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') {
92 if (el.isContentEditable) return el.textContent.trim().slice(0, 200);
93 return undefined;
94 }
95
96 const t = (el.getAttribute('type') || 'text').toLowerCase();
97 const ac = (el.getAttribute('autocomplete') || '').toLowerCase();
98
99 if (isCredentialField(el)) return null;
100 if (t === 'hidden') return null;
101 if (ac.startsWith('cc-')) return null;
102
103 const label = [el.name, el.id, el.getAttribute('aria-label'), el.placeholder]
104 .filter(Boolean).join(' ');
105 if (SECRET_NAME.test(label)) return null;
106
107 if (t === 'checkbox' || t === 'radio') return el.checked ? 'checked' : 'unchecked';
108
109 let v;
110 if (tag === 'SELECT') {
111 const opt = el.selectedOptions && el.selectedOptions[0];
112 v = opt ? (opt.label || opt.value || '') : '';
113 } else {
114 v = el.value || '';
115 }
116
117 if (looksSecret(v)) return null;
118 return v.length > 200 ? v.slice(0, 200) + '…' : v;
119 }
120
121 // -------------------------------------------------------------------
122 // Roles and names
123 // -------------------------------------------------------------------
124
125 const LANDMARKS = {
126 NAV: 'navigation',
127 MAIN: 'main',
128 HEADER: 'banner',
129 FOOTER: 'contentinfo',
130 ASIDE: 'complementary',
131 FORM: 'form',
132 SEARCH: 'search',
133 };
134
135 /// The ARIA role: the explicit one if the page gave one, else inferred.
136 function roleOf(el) {
137 const explicit = el.getAttribute('role');
138 if (explicit) return explicit.trim().split(/\s+/)[0].toLowerCase();
139
140 const tag = el.tagName;
141 if (LANDMARKS[tag]) return LANDMARKS[tag];
142 if (/^H[1-6]$/.test(tag)) return 'heading';
143
144 switch (tag) {
145 case 'A': return el.hasAttribute('href') ? 'link' : 'generic';
146 case 'BUTTON': return 'button';
147 case 'SUMMARY': return 'button';
148 case 'SELECT': return el.multiple ? 'listbox' : 'combobox';
149 case 'TEXTAREA': return 'textbox';
150 case 'IMG': return 'img';
151 case 'LI': return 'listitem';
152 case 'TABLE': return 'table';
153 case 'OPTION': return 'option';
154 case 'INPUT': {
155 const t = (el.getAttribute('type') || 'text').toLowerCase();
156 if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return 'button';
157 if (t === 'checkbox') return 'checkbox';
158 if (t === 'radio') return 'radio';
159 if (t === 'range') return 'slider';
160 if (t === 'hidden') return 'hidden';
161 if (t === 'search') return 'searchbox';
162 return 'textbox';
163 }
164 default: return 'text';
165 }
166 }
167
168 /// Text the user can actually see, collapsed.
169 function visibleText(el) {
170 return (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
171 }
172
173 /// The accessible name: aria-label, then a label element, then the
174 /// placeholder, then the trimmed text.
175 function nameOf(el) {
176 const aria = el.getAttribute('aria-label');
177 if (aria && aria.trim()) return aria.trim();
178
179 const by = el.getAttribute('aria-labelledby');
180 if (by) {
181 const txt = by.split(/\s+/)
182 .map((id) => document.getElementById(id))
183 .filter(Boolean)
184 .map(visibleText)
185 .join(' ')
186 .trim();
187 if (txt) return txt.slice(0, 200);
188 }
189
190 if (el.id) {
191 const lab = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
192 if (lab) {
193 const txt = visibleText(lab);
194 if (txt) return txt.slice(0, 200);
195 }
196 }
197 const wrap = el.closest && el.closest('label');
198 if (wrap && wrap !== el) {
199 const txt = visibleText(wrap);
200 if (txt) return txt.slice(0, 200);
201 }
202
203 const ph = el.getAttribute('placeholder');
204 if (ph && ph.trim()) return ph.trim();
205
206 if (el.tagName === 'INPUT') {
207 const t = (el.getAttribute('type') || 'text').toLowerCase();
208 if (t === 'submit' || t === 'button' || t === 'reset') return (el.value || '').trim();
209 if (t === 'hidden') return (el.getAttribute('name') || '').trim();
210 }
211
212 const alt = el.getAttribute('alt');
213 if (alt && alt.trim()) return alt.trim();
214
215 const title = el.getAttribute('title');
216 if (title && title.trim()) return title.trim();
217
218 const txt = visibleText(el);
219 return txt.slice(0, 200);
220 }
221
222 // -------------------------------------------------------------------
223 // The walk
224 // -------------------------------------------------------------------
225
226 const CAP = 300;
227 const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'PATH', 'LINK', 'META', 'HEAD']);
228 const TEXT_TAGS = new Set(['P', 'LI', 'TD', 'TH', 'DT', 'DD', 'SPAN', 'DIV', 'STRONG', 'EM', 'BLOCKQUOTE', 'FIGCAPTION', 'CAPTION', 'SMALL', 'CODE', 'PRE', 'LABEL', 'OUTPUT', 'TIME']);
229 const INTERACTIVE_ROLES = new Set(['link', 'button', 'textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'slider', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'option']);
230
231 /// Is the element rendered at all? A display:none subtree is not worth
232 /// walking, so this rejects the whole branch.
233 function rendered(el) {
234 const cs = getComputedStyle(el);
235 if (cs.display === 'none') return false;
236 if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
237 return true;
238 }
239
240 /// Does the element occupy space on screen? A hidden input never does,
241 /// and is still worth reporting -- without its value.
242 function occupies(el) {
243 const r = el.getBoundingClientRect();
244 return r.width > 0 && r.height > 0;
245 }
246
247 /// The text this element owns itself, not the text of its children.
248 function ownText(el) {
249 let out = '';
250 for (const n of el.childNodes) {
251 if (n.nodeType === Node.TEXT_NODE) out += n.nodeValue;
252 }
253 return out.replace(/\s+/g, ' ').trim();
254 }
255
256 function isInteractive(el, role) {
257 if (INTERACTIVE_ROLES.has(role)) return true;
258 if (el.hasAttribute('onclick')) return true;
259 const ti = el.getAttribute('tabindex');
260 if (ti !== null && ti !== '-1') return true;
261 return false;
262 }
263
264 /// Walks the document and returns the accessibility tree, flattened.
265 /// The rendered TEXT of the page — what a person reads, JavaScript and all.
266 ///
267 /// This is the antidote to the accessibility tree's two failings: it does
268 /// not truncate at a node budget, and it does not depend on how well the
269 /// site marks up its roles. It reads the MAIN content region (a docs page's
270 /// nav and chrome are dropped), returns plain text, and so works for every
271 /// model, vision or not. Snapshot is for ACTING (refs to click); this is
272 /// for READING (a price, a table, an article) -- and reading a page should
273 /// never cost twenty-five rounds of scroll-and-hope.
274 function readText() {
275 const CHARS = 40000;
276 // Prefer the main content; fall back to the body. `innerText` gives the
277 // visually rendered text (respecting display:none and, for tables,
278 // tab/newline-separating cells), which is exactly the reading order.
279 const main = document.querySelector('main, [role="main"], article')
280 || document.body || document.documentElement;
281 let text = (main.innerText || '').replace(/\n{3,}/g, '\n\n').trim();
282 const full = text.length;
283 let truncated = false;
284 if (text.length > CHARS) { text = text.slice(0, CHARS); truncated = true; }
285 return {
286 ok: true,
287 url: location.href,
288 title: document.title,
289 text,
290 chars: full,
291 truncated,
292 };
293 }
294
295 function snapshot() {
296 const nodes = [];
297 let truncated = false;
298 let total = 0;
299
300 // Content first. A docs page or an app puts a huge navigation sidebar
301 // and header in the tree, and walked top-to-bottom they eat the whole
302 // node budget before the actual content -- which is how a pricing table
303 // stayed invisible for twenty-five rounds. When the page marks a main
304 // region, the chrome (nav, header, footer, sidebar) is dropped so the
305 // budget lands on what the model came to read or act on. Where there is
306 // no main region, nothing is dropped.
307 const root = document.querySelector('main, [role="main"]');
308 const hasMain = !!root;
309 const CHROME = 'nav,header,footer,aside,[role="navigation"],[role="banner"],[role="contentinfo"],[role="complementary"]';
310
311 const walker = document.createTreeWalker(
312 root || document.body || document.documentElement,
313 NodeFilter.SHOW_ELEMENT,
314 {
315 acceptNode(el) {
316 if (SKIP_TAGS.has(el.tagName)) return NodeFilter.FILTER_REJECT;
317 if (el.getAttribute('aria-hidden') === 'true') return NodeFilter.FILTER_REJECT;
318 // Drop the page chrome so the content is not starved -- but
319 // only when we are walking the whole body (no main region);
320 // inside a main region there is no chrome to drop.
321 if (!hasMain && el.matches && el.matches(CHROME)) return NodeFilter.FILTER_REJECT;
322 if (el.tagName === 'INPUT' && (el.getAttribute('type') || '').toLowerCase() === 'hidden') {
323 return NodeFilter.FILTER_ACCEPT;
324 }
325 if (!rendered(el)) return NodeFilter.FILTER_REJECT;
326 return NodeFilter.FILTER_ACCEPT;
327 },
328 }
329 );
330
331 for (let el = walker.nextNode(); el; el = walker.nextNode()) {
332
333 const role = roleOf(el);
334 const hidden = role === 'hidden';
335 const inter = isInteractive(el, role);
336 const landmark = Object.values(LANDMARKS).includes(role) && el.tagName in LANDMARKS;
337 const heading = role === 'heading';
338
339 let keep = false;
340 if (hidden) keep = true;
341 else if (!occupies(el)) keep = false;
342 else if (inter || heading || landmark) keep = true;
343 else if (TEXT_TAGS.has(el.tagName)) {
344 const t = ownText(el);
345 keep = t.length >= 2 && t.length <= 400;
346 }
347
348 if (!keep) continue;
349
350 total++;
351 if (nodes.length >= CAP) {
352 truncated = true;
353 continue;
354 }
355
356 const node = {
357 ref: refOf(el),
358 role,
359 name: nameOf(el),
360 };
361
362 const v = valueOf(el);
363 if (v === null) {
364 // A secret. The node exists, so the model can act on it;
365 // the value does not, so the model can never read it.
366 node.redacted = true;
367 } else if (v !== undefined) {
368 node.value = v;
369 }
370
371 if (el.disabled) node.disabled = true;
372
373 nodes.push(node);
374 }
375
376 return { ok: true, nodes, truncated, total };
377 }
378
379 // -------------------------------------------------------------------
380 // Actions
381 // -------------------------------------------------------------------
382
383 /// Describes a click target so the broker can judge whether it is
384 /// consequential. The judgement lives in the broker, which knows the
385 /// grants; the facts live here, which knows the DOM.
386 function describe(ref) {
387 const el = elOf(ref);
388 if (!el) return gone(ref);
389
390 const tag = el.tagName.toLowerCase();
391 const type = (el.getAttribute('type') || '').toLowerCase();
392 const form = el.form || (el.closest && el.closest('form'));
393
394 const isSubmit = !!form && (
395 (tag === 'button' && (type === 'submit' || type === '')) ||
396 (tag === 'input' && (type === 'submit' || type === 'image'))
397 );
398
399 let action = null;
400 if (form) {
401 const raw = el.getAttribute('formaction') || form.getAttribute('action') || location.href;
402 try {
403 action = new URL(raw, location.href).href;
404 } catch (e) {
405 action = location.href;
406 }
407 }
408
409 // The name of the button this field's form would submit through, so a
410 // type(field, submit:true) can be judged by the button it fires --
411 // "Buy now" -- and not by the innocent field it is typed into.
412 let submitName = '';
413 if (form && !isSubmit) {
414 const btn = form.querySelector('button[type=submit], input[type=submit], button:not([type])');
415 if (btn) submitName = nameOf(btn) || (btn.value || '');
416 }
417
418 return {
419 ok: true,
420 ref,
421 tag,
422 type,
423 role: roleOf(el),
424 name: nameOf(el),
425 submitName,
426 href: el.getAttribute('href') ? new URL(el.getAttribute('href'), location.href).href : null,
427 isSubmit,
428 formMethod: form ? (el.getAttribute('formmethod') || form.getAttribute('method') || 'get').toLowerCase() : null,
429 formAction: action,
430 pageUrl: location.href,
431 };
432 }
433
434 function gone(ref) {
435 return {
436 ok: false,
437 error: `There is nothing at ref ${ref} any more. The page has changed. Take a fresh snapshot.`,
438 };
439 }
440
441 function click(ref) {
442 const el = elOf(ref);
443 if (!el) return gone(ref);
444 if (el.disabled) return { ok: false, error: `"${nameOf(el)}" is disabled.` };
445
446 el.scrollIntoView({ block: 'center', inline: 'center' });
447 try {
448 el.focus({ preventScroll: true });
449 } catch (e) {
450 // Not focusable. Clicking still works.
451 }
452 el.click();
453 return { ok: true, url: location.href };
454 }
455
456 /// Sets a value the way the page's own framework will believe.
457 ///
458 /// The native setter is preferred, because React and friends track it and
459 /// ignore a plain assignment. But that setter is grabbed from THIS isolated
460 /// world's prototype, and calling it on the page's element throws "Illegal
461 /// invocation" in some builds -- so a plain assignment is the fallback, not
462 /// a crash. Typing must never fail just because a framework optimisation did.
463 function setValue(el, text) {
464 try {
465 const proto = el instanceof HTMLTextAreaElement
466 ? HTMLTextAreaElement.prototype
467 : HTMLInputElement.prototype;
468 const desc = Object.getOwnPropertyDescriptor(proto, 'value');
469 if (desc && desc.set) { desc.set.call(el, text); return; }
470 } catch (e) { /* the native setter refused; fall back */ }
471 el.value = text;
472 }
473
474 function type(ref, text, submit) {
475 const el = elOf(ref);
476 if (!el) return gone(ref);
477
478 if (isCredentialField(el)) {
479 return {
480 ok: false,
481 error: 'That is a credential field. Only the user types there, and Daimond does not watch while they do.',
482 };
483 }
484 if (el.disabled || el.readOnly) {
485 return { ok: false, error: `"${nameOf(el)}" cannot be typed into.` };
486 }
487
488 el.scrollIntoView({ block: 'center' });
489 try {
490 el.focus({ preventScroll: true });
491 } catch (e) {
492 return { ok: false, error: `"${nameOf(el)}" cannot take the keyboard.` };
493 }
494
495 if (el.isContentEditable) {
496 el.textContent = text;
497 } else {
498 setValue(el, '');
499 el.dispatchEvent(new Event('input', { bubbles: true }));
500 setValue(el, text);
501 el.dispatchEvent(new Event('input', { bubbles: true }));
502 el.dispatchEvent(new Event('change', { bubbles: true }));
503 }
504
505 if (submit) {
506 const form = el.form || (el.closest && el.closest('form'));
507 el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
508 el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
509 if (form && typeof form.requestSubmit === 'function') {
510 const btn = form.querySelector('button[type=submit], input[type=submit], button:not([type])');
511 form.requestSubmit(btn || undefined);
512 } else if (form) {
513 form.submit();
514 }
515 }
516
517 return { ok: true, url: location.href };
518 }
519
520 function scroll(direction, amount) {
521 const step = amount > 0 ? amount : Math.round(window.innerHeight * 0.8);
522 switch ((direction || 'down').toLowerCase()) {
523 case 'up': window.scrollBy(0, -step); break;
524 case 'down': window.scrollBy(0, step); break;
525 case 'left': window.scrollBy(-step, 0); break;
526 case 'right': window.scrollBy(step, 0); break;
527 case 'top': window.scrollTo(0, 0); break;
528 case 'bottom': window.scrollTo(0, document.body.scrollHeight); break;
529 default:
530 return { ok: false, error: `Scroll where? Try up, down, top or bottom, not "${direction}".` };
531 }
532 return { ok: true, y: Math.round(window.scrollY) };
533 }
534
535 // -------------------------------------------------------------------
536 // The detector -- and the detaching
537 // -------------------------------------------------------------------
538
539 let observer = null;
540 let armed = false;
541 let truce = false; // The user has already handed back on this page.
542
543 /// What, if anything, on this page is asking for a credential?
544 function private_() {
545 if (!truce && document.querySelector('input[type=password]')) {
546 return { private: true, reason: 'a password field' };
547 }
548 if (document.querySelector('input[autocomplete*="webauthn"], input[autocomplete="one-time-code"]')) {
549 return { private: true, reason: 'a passkey or one-time-code prompt' };
550 }
551 return { private: false, reason: '' };
552 }
553
554 function tell(reason) {
555 try {
556 chrome.runtime.sendMessage({ type: 'private', reason });
557 } catch (e) {
558 // The worker is asleep or the context is gone. We have already
559 // detached, which is the part that matters.
560 }
561 }
562
563 /// A trusted keystroke into a field means a human is at the wheel.
564 function onKey(ev) {
565 if (!ev.isTrusted) return; // Our own typing is not a user.
566 const t = ev.target;
567 if (!t) return;
568 const tag = t.tagName;
569 const editable = tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable;
570 if (!editable) return;
571 if (ev.key && ev.key.length > 1 && ev.key !== 'Backspace') return; // Tab, arrows, etc.
572 detach();
573 try {
574 chrome.runtime.sendMessage({ type: 'typing' });
575 } catch (e) {
576 // Detached regardless.
577 }
578 }
579
580 function onWebAuthn(ev) {
581 const reason = (ev.detail && ev.detail.reason) || 'a passkey prompt';
582 detach();
583 tell(reason);
584 }
585
586 /// Arms the detector. Returns what it sees right now, so the broker can
587 /// flip the mode before it answers anything.
588 function arm(isTruce) {
589 truce = !!isTruce;
590
591 const seen = private_();
592 if (seen.private) {
593 detach();
594 return seen;
595 }
596
597 if (!armed) {
598 armed = true;
599 document.addEventListener('keydown', onKey, true);
600 document.addEventListener('__daimond_private', onWebAuthn, true);
601 observer = new MutationObserver(() => {
602 const now = private_();
603 if (now.private) {
604 detach();
605 tell(now.reason);
606 }
607 });
608 observer.observe(document.documentElement, {
609 childList: true,
610 subtree: true,
611 attributes: true,
612 attributeFilter: ['type', 'autocomplete'],
613 });
614 }
615
616 return { private: false, reason: '' };
617 }
618
619 /// Takes the hands off the page entirely. No observer, no listeners, no
620 /// refs. There is nothing left here that could see a keystroke.
621 function detach() {
622 if (observer) {
623 observer.disconnect();
624 observer = null;
625 }
626 document.removeEventListener('keydown', onKey, true);
627 document.removeEventListener('__daimond_private', onWebAuthn, true);
628 armed = false;
629 byRef.clear();
630 return true;
631 }
632
633 // -------------------------------------------------------------------
634 // Handing the wheel BACK -- from inside the tab, where the user is
635 // -------------------------------------------------------------------
636 //
637 // The takeover must originate from a real gesture the web page cannot
638 // forge. So the "resume" button is not in the Daimond page -- which is
639 // driven by an agent that may itself have been steered by a web page --
640 // it is HERE, in the tab the user just signed into, rendered in a shadow
641 // root the page cannot see or fake. Its click is a trusted event in this
642 // isolated world, and it messages the broker on the internal channel,
643 // which the page has no way to reach. The page can ask for many things;
644 // it can never ask to stop being watched-not.
645
646 // The label arrives from the broker, which holds the strings. The hands
647 // are injected into any page the user has approved and have no business
648 // carrying a translation table around with them.
649 let resumeHost = null;
650 function showResume(a) {
651 if (resumeHost) return true;
652 const label = (a && a.label) || 'Resume Daimond ▸';
653 const host = document.createElement('div');
654 // `all:initial` FIRST — it resets everything, so anything after it wins.
655 // Put it last and it would reset the fixed positioning back to static.
656 host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;right:16px;bottom:16px';
657 const root = host.attachShadow({ mode: 'closed' });
658 const btn = document.createElement('button');
659 btn.textContent = label;
660 btn.style.cssText = 'font:600 13px system-ui,sans-serif;color:#fff;background:#c0392b;'
661 + 'border:0;border-radius:8px;padding:10px 16px;box-shadow:0 4px 16px rgba(0,0,0,.35);cursor:pointer';
662 btn.addEventListener('click', () => {
663 // A trusted click. The broker will verify it arrived internally.
664 try { chrome.runtime.sendMessage({ type: 'resume' }); } catch (e) { /* worker asleep; it re-checks */ }
665 });
666 root.appendChild(btn);
667 (document.body || document.documentElement).appendChild(host);
668 resumeHost = host;
669 return true;
670 }
671 function hideResume() {
672 if (resumeHost) { resumeHost.remove(); resumeHost = null; }
673 return true;
674 }
675
676 // -------------------------------------------------------------------
677 // The one entry point
678 // -------------------------------------------------------------------
679
680 function handle(cmd, a) {
681 try {
682 switch (cmd) {
683 case 'snapshot': return snapshot();
684 case 'read': return readText();
685 case 'describe': return describe(a.ref);
686 case 'click': return click(a.ref);
687 case 'type': return type(a.ref, a.text, a.submit);
688 case 'scroll': return scroll(a.direction, a.amount);
689 case 'showResume': return showResume(a);
690 case 'hideResume': return hideResume();
691 default: return { ok: false, error: `The hands do not know "${cmd}".` };
692 }
693 } catch (e) {
694 return { ok: false, error: `The page could not be operated: ${(e && e.message) || String(e)}` };
695 }
696 }
697
698 /// What the hands are doing, for the extension's own tests and for anyone
699 /// auditing the claim that 'user' mode really does mean detached.
700 function state() {
701 return { armed, observing: observer !== null, refs: byRef.size, truce };
702 }
703
704 globalThis.__daimond = { arm, detach, handle, state };
705 }
706
707 return true;
708})();