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 | })(); |