mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Zone.js patches the global Promise with a non-native thenable. When a bare
`new Promise(...)` crosses the Electron executeJavaScript boundary, it's
serialized as-is, losing { page, target } and exposing __zone_symbol__*
fields instead. Wrap in an async IIFE to return a native promise that
Electron always unwraps correctly.
956 lines
32 KiB
TypeScript
956 lines
32 KiB
TypeScript
/* eslint-disable max-lines -- the guest overlay runtime is one self-contained JS string injected atomically; splitting it adds a concat build step for no auditability gain. */
|
|
// Browser Context Grab — builds self-contained JS strings injected into guests via executeJavaScript().
|
|
// Why a string builder not a bundle: guests have no preload/Node; injected code must be plain JS in the page's own world.
|
|
|
|
type GuestScriptAction = 'arm' | 'awaitClick' | 'finalize' | 'extractHover' | 'teardown'
|
|
|
|
/**
|
|
* Build a self-contained JS script for the given grab lifecycle action.
|
|
*
|
|
* - `arm`: install the shadow-root overlay, hover listeners, and extraction logic
|
|
* - `awaitClick`: return a Promise that resolves with the payload when the user clicks
|
|
* - `finalize`: extract the payload for the currently hovered element and return it
|
|
* - `extractHover`: extract the payload for the currently hovered element WITHOUT cleanup
|
|
* - `teardown`: remove the overlay and all listeners
|
|
*/
|
|
export function buildGuestOverlayScript(action: GuestScriptAction): string {
|
|
switch (action) {
|
|
case 'arm':
|
|
return ARM_SCRIPT
|
|
case 'awaitClick':
|
|
return AWAIT_CLICK_SCRIPT
|
|
case 'finalize':
|
|
return FINALIZE_SCRIPT
|
|
case 'extractHover':
|
|
return EXTRACT_HOVER_SCRIPT
|
|
case 'teardown':
|
|
return TEARDOWN_SCRIPT
|
|
}
|
|
}
|
|
|
|
// arm: install the overlay + hover tracking; state lives on window.__orcaGrab so finalize/teardown can reach it.
|
|
const ARM_SCRIPT = `(function() {
|
|
'use strict';
|
|
|
|
// Why: always tear down any pre-existing state before arming. A malicious
|
|
// guest page could predefine window.__orcaGrab with a fake extractPayload
|
|
// function. By tearing down unconditionally we ensure our freshly installed
|
|
// extraction logic is the only code that runs.
|
|
if (window.__orcaGrab) {
|
|
try {
|
|
if (typeof window.__orcaGrab.cleanup === 'function') {
|
|
window.__orcaGrab.cleanup();
|
|
}
|
|
} catch(e) {}
|
|
delete window.__orcaGrab;
|
|
}
|
|
|
|
// --- Budget constants (mirrored from shared types) ---
|
|
var BUDGET = {
|
|
textSnippetMaxLength: 200,
|
|
nearbyTextEntryMaxLength: 200,
|
|
nearbyTextMaxEntries: 10,
|
|
htmlSnippetMaxLength: 4096,
|
|
ancestorPathMaxEntries: 10,
|
|
nearbyElementsMaxEntries: 6,
|
|
nearbyElementMaxLength: 160,
|
|
selectorMaxLength: 700,
|
|
pathMaxLength: 900,
|
|
cssClassesMaxLength: 500,
|
|
selectedTextMaxLength: 500,
|
|
sourceFileMaxLength: 500,
|
|
reactComponentsMaxLength: 500
|
|
};
|
|
var TEXT_NODE_SCAN_LIMIT = 80;
|
|
var NEARBY_ELEMENT_SCAN_LIMIT = 80;
|
|
|
|
// --- Safe attribute names ---
|
|
var SAFE_ATTRS = new Set([
|
|
'id', 'class', 'name', 'type', 'role', 'href', 'src', 'alt',
|
|
'title', 'placeholder', 'for', 'action', 'method'
|
|
]);
|
|
|
|
var SECRET_PATTERNS = [
|
|
'access_token', 'auth_token', 'api_key', 'apikey', 'client_secret',
|
|
'oauth_state', 'x-amz-', 'session_id', 'sessionid', 'csrf',
|
|
'secret', 'password', 'passwd'
|
|
];
|
|
|
|
var SAFE_URL_PROTOCOLS = new Set(['http:', 'https:', 'file:']);
|
|
|
|
var STYLE_PROPS = [
|
|
'display', 'position', 'width', 'height', 'margin', 'padding',
|
|
'color', 'backgroundColor', 'border', 'borderRadius', 'fontFamily',
|
|
'fontSize', 'fontWeight', 'lineHeight', 'textAlign', 'zIndex'
|
|
];
|
|
|
|
// --- Helpers ---
|
|
function clampStr(s, max) {
|
|
if (!s || typeof s !== 'string') return '';
|
|
if (s.length <= max) return s;
|
|
return s.slice(0, max) + ' (truncated)';
|
|
}
|
|
|
|
function containsSecret(value) {
|
|
if (!value) return false;
|
|
var lower = value.toLowerCase();
|
|
for (var i = 0; i < SECRET_PATTERNS.length; i++) {
|
|
if (lower.indexOf(SECRET_PATTERNS[i]) !== -1) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function sanitizeUrl(url) {
|
|
try {
|
|
var u = new URL(url);
|
|
if (u.protocol === 'about:') {
|
|
return u.toString() === 'about:blank' ? 'about:blank' : '';
|
|
}
|
|
if (!SAFE_URL_PROTOCOLS.has(u.protocol)) {
|
|
return '';
|
|
}
|
|
u.search = '';
|
|
u.hash = '';
|
|
return u.toString();
|
|
} catch (e) {
|
|
// Why: returning the raw URL on parse failure could preserve javascript:
|
|
// URIs or other non-http schemes. Return empty string instead.
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function createTextAccumulator() {
|
|
return { text: '', pendingSpace: false };
|
|
}
|
|
|
|
function isWhitespaceCode(code) {
|
|
return code === 32 || (code >= 9 && code <= 13) || code === 160 ||
|
|
code === 5760 || (code >= 8192 && code <= 8202) || code === 8232 ||
|
|
code === 8233 || code === 8239 || code === 8287 || code === 12288 ||
|
|
code === 65279;
|
|
}
|
|
|
|
function appendTextSeparator(acc) {
|
|
if (acc.text.length > 0) acc.pendingSpace = true;
|
|
}
|
|
|
|
function appendNormalizedText(acc, text, max) {
|
|
var limit = max + 20;
|
|
var value = String(text || '');
|
|
for (var i = 0; i < value.length && acc.text.length < limit; i++) {
|
|
var code = value.charCodeAt(i);
|
|
if (isWhitespaceCode(code)) {
|
|
if (acc.text.length > 0) acc.pendingSpace = true;
|
|
continue;
|
|
}
|
|
if (acc.pendingSpace) {
|
|
acc.text += ' ';
|
|
acc.pendingSpace = false;
|
|
if (acc.text.length >= limit) break;
|
|
}
|
|
acc.text += value.charAt(i);
|
|
}
|
|
}
|
|
|
|
function finishAccumulatedText(acc, max) {
|
|
return clampStr(acc.text, max);
|
|
}
|
|
|
|
function getBoundedText(el, max) {
|
|
try {
|
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
var acc = createTextAccumulator();
|
|
var inspected = 0;
|
|
var node = walker.nextNode();
|
|
while (node && acc.text.length < max + 20 && inspected < TEXT_NODE_SCAN_LIMIT) {
|
|
inspected++;
|
|
appendTextSeparator(acc);
|
|
var remaining = max + 20 - acc.text.length - (acc.pendingSpace ? 1 : 0);
|
|
if (remaining <= 0) break;
|
|
appendNormalizedText(acc, (node.nodeValue || '').slice(0, remaining), max);
|
|
node = walker.nextNode();
|
|
}
|
|
return finishAccumulatedText(acc, max);
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function getTextSnippet(el) {
|
|
return getBoundedText(el, BUDGET.textSnippetMaxLength);
|
|
}
|
|
|
|
function getSelectedText() {
|
|
try {
|
|
var selection = window.getSelection ? window.getSelection() : null;
|
|
if (!selection || selection.rangeCount === 0) return '';
|
|
var acc = createTextAccumulator();
|
|
var inspected = 0;
|
|
for (
|
|
var i = 0;
|
|
i < selection.rangeCount && acc.text.length < BUDGET.selectedTextMaxLength + 20;
|
|
i++
|
|
) {
|
|
var range = selection.getRangeAt(i);
|
|
var walkerRoot = range.commonAncestorContainer;
|
|
var walker = document.createTreeWalker(
|
|
walkerRoot,
|
|
NodeFilter.SHOW_TEXT,
|
|
{
|
|
acceptNode: function(node) {
|
|
if (range.intersectsNode && !range.intersectsNode(node)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
}
|
|
);
|
|
var node = walkerRoot.nodeType === Node.TEXT_NODE ? walkerRoot : walker.nextNode();
|
|
while (
|
|
node &&
|
|
acc.text.length < BUDGET.selectedTextMaxLength + 20 &&
|
|
inspected < TEXT_NODE_SCAN_LIMIT
|
|
) {
|
|
inspected++;
|
|
var textNode = node;
|
|
var value = textNode.nodeValue || '';
|
|
appendTextSeparator(acc);
|
|
var remaining =
|
|
BUDGET.selectedTextMaxLength + 20 - acc.text.length - (acc.pendingSpace ? 1 : 0);
|
|
if (remaining <= 0) break;
|
|
if (value) {
|
|
var start = textNode === range.startContainer ? range.startOffset : 0;
|
|
var end = textNode === range.endContainer ? range.endOffset : value.length;
|
|
if (end > start + remaining) {
|
|
end = start + remaining;
|
|
}
|
|
if (textNode === range.startContainer) {
|
|
start = Math.min(start, value.length);
|
|
}
|
|
value = value.slice(start, end);
|
|
appendNormalizedText(acc, value, BUDGET.selectedTextMaxLength);
|
|
}
|
|
node = walker.nextNode();
|
|
}
|
|
}
|
|
return finishAccumulatedText(acc, BUDGET.selectedTextMaxLength);
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function getHtmlSnippet(el) {
|
|
var clone = el.cloneNode(true);
|
|
// Strip script tags for safety
|
|
var scripts = clone.querySelectorAll('script');
|
|
for (var i = 0; i < scripts.length; i++) {
|
|
scripts[i].remove();
|
|
}
|
|
var html = clone.outerHTML || '';
|
|
return clampStr(html, BUDGET.htmlSnippetMaxLength);
|
|
}
|
|
|
|
function getSafeAttributes(el) {
|
|
var attrs = {};
|
|
for (var i = 0; i < el.attributes.length; i++) {
|
|
var attr = el.attributes[i];
|
|
var name = attr.name.toLowerCase();
|
|
var isAria = name.indexOf('aria-') === 0;
|
|
if (!SAFE_ATTRS.has(name) && !isAria) continue;
|
|
var value = attr.value;
|
|
// Redact secret-looking values
|
|
if (containsSecret(value)) {
|
|
attrs[name] = '[redacted]';
|
|
} else if ((name === 'href' || name === 'src' || name === 'action') && value) {
|
|
// Strip query strings and fragments from URL-bearing attributes
|
|
attrs[name] = sanitizeUrl(value);
|
|
} else if (name === 'class') {
|
|
// Cap class list length
|
|
attrs[name] = clampStr(value, 200);
|
|
} else {
|
|
attrs[name] = value;
|
|
}
|
|
}
|
|
return attrs;
|
|
}
|
|
|
|
// Why: guest pages control aria-labelledby; avoid regex splitting huge
|
|
// attributes while extracting grab payload accessibility metadata.
|
|
function getAriaLabelledByIds(value) {
|
|
var ids = [];
|
|
var tokenStart = -1;
|
|
for (var index = 0; index <= value.length; index++) {
|
|
var isEnd = index === value.length;
|
|
if (!isEnd && !isAriaLabelledBySeparator(value.charCodeAt(index))) {
|
|
if (tokenStart === -1) tokenStart = index;
|
|
continue;
|
|
}
|
|
if (tokenStart !== -1) {
|
|
ids.push(value.slice(tokenStart, index));
|
|
tokenStart = -1;
|
|
if (ids.length >= 32) break;
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function isAriaLabelledBySeparator(code) {
|
|
return code === 32 ||
|
|
(code >= 9 && code <= 13) ||
|
|
code === 160 ||
|
|
code === 5760 ||
|
|
(code >= 8192 && code <= 8202) ||
|
|
code === 8232 ||
|
|
code === 8233 ||
|
|
code === 8239 ||
|
|
code === 8287 ||
|
|
code === 12288 ||
|
|
code === 65279;
|
|
}
|
|
|
|
function getAccessibility(el) {
|
|
var role = el.getAttribute('role') || el.tagName.toLowerCase();
|
|
var ariaLabel = el.getAttribute('aria-label') || null;
|
|
var ariaLabelledBy = el.getAttribute('aria-labelledby') || null;
|
|
var accessibleName = null;
|
|
// Attempt to derive accessible name
|
|
if (ariaLabel) {
|
|
accessibleName = ariaLabel;
|
|
} else if (ariaLabelledBy) {
|
|
var parts = getAriaLabelledByIds(ariaLabelledBy);
|
|
var names = [];
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var ref = document.getElementById(parts[i]);
|
|
if (ref) names.push(getBoundedText(ref, 100));
|
|
}
|
|
if (names.length) accessibleName = names.join(' ');
|
|
} else {
|
|
// Fall back to text content for buttons/links
|
|
var tag = el.tagName.toLowerCase();
|
|
if (tag === 'button' || tag === 'a' || tag === 'label') {
|
|
accessibleName = getBoundedText(el, 100);
|
|
} else if (el.getAttribute('title')) {
|
|
accessibleName = el.getAttribute('title');
|
|
} else if (el.getAttribute('alt')) {
|
|
accessibleName = el.getAttribute('alt');
|
|
}
|
|
}
|
|
return {
|
|
role: role,
|
|
accessibleName: accessibleName,
|
|
ariaLabel: ariaLabel,
|
|
ariaLabelledBy: ariaLabelledBy
|
|
};
|
|
}
|
|
|
|
function getComputedStyleSubset(el) {
|
|
var cs = window.getComputedStyle(el);
|
|
var result = {};
|
|
for (var i = 0; i < STYLE_PROPS.length; i++) {
|
|
result[STYLE_PROPS[i]] = cs.getPropertyValue(
|
|
STYLE_PROPS[i].replace(/[A-Z]/g, function(m) { return '-' + m.toLowerCase(); })
|
|
) || '';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function cssEscape(value) {
|
|
if (window.CSS && typeof window.CSS.escape === 'function') {
|
|
return window.CSS.escape(value);
|
|
}
|
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, function(ch) {
|
|
return '\\\\' + ch;
|
|
});
|
|
}
|
|
|
|
function looksHashy(value) {
|
|
return /^[A-Za-z0-9_-]{12,}$/.test(value) && /\\d/.test(value) && /[A-Z]/.test(value);
|
|
}
|
|
|
|
function getStableClasses(el, maxCount) {
|
|
if (!el.classList) return [];
|
|
var result = [];
|
|
for (var i = 0; i < el.classList.length && result.length < maxCount; i++) {
|
|
var cls = el.classList[i];
|
|
if (!cls || cls.length > 60 || containsSecret(cls)) continue;
|
|
if (/^css-[a-z0-9]+$/i.test(cls) || looksHashy(cls)) continue;
|
|
result.push(cls);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function buildSelectorPart(el) {
|
|
var tag = el.tagName.toLowerCase();
|
|
var id = el.id;
|
|
if (id && !containsSecret(id)) {
|
|
return tag + '#' + cssEscape(id);
|
|
}
|
|
var classes = getStableClasses(el, 2);
|
|
if (classes.length > 0) {
|
|
return tag + classes.map(function(cls) { return '.' + cssEscape(cls); }).join('');
|
|
}
|
|
return tag;
|
|
}
|
|
|
|
function isUniqueSelector(selector) {
|
|
try {
|
|
return document.querySelectorAll(selector).length === 1;
|
|
} catch(e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getNthOfTypeSuffix(current) {
|
|
var tag = current.tagName;
|
|
var index = 1;
|
|
var sibling = current.previousElementSibling;
|
|
while (sibling) {
|
|
if (sibling.tagName === tag) index++;
|
|
sibling = sibling.previousElementSibling;
|
|
}
|
|
if (index > 1) return ':nth-of-type(' + index + ')';
|
|
|
|
sibling = current.nextElementSibling;
|
|
while (sibling) {
|
|
if (sibling.tagName === tag) return ':nth-of-type(1)';
|
|
sibling = sibling.nextElementSibling;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function buildSelector(el) {
|
|
var parts = [];
|
|
var current = el;
|
|
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body && parts.length < 10) {
|
|
var part = buildSelectorPart(current);
|
|
var parent = current.parentElement;
|
|
if (parent && !isUniqueSelector(parts.concat([part]).reverse().join(' > '))) {
|
|
part += getNthOfTypeSuffix(current);
|
|
}
|
|
parts.unshift(part);
|
|
var selector = parts.join(' > ');
|
|
if (isUniqueSelector(selector)) {
|
|
return clampStr(selector, BUDGET.selectorMaxLength);
|
|
}
|
|
current = parent;
|
|
}
|
|
return clampStr(parts.join(' > ') || el.tagName.toLowerCase(), BUDGET.selectorMaxLength);
|
|
}
|
|
|
|
function buildReadablePath(el) {
|
|
var parts = [];
|
|
var current = el;
|
|
while (current && current !== document.documentElement && parts.length < 6) {
|
|
var tag = current.tagName.toLowerCase();
|
|
if (tag === 'html' || tag === 'body') break;
|
|
var label = tag;
|
|
var aria = current.getAttribute('aria-label');
|
|
var role = current.getAttribute('role');
|
|
var stableClasses = getStableClasses(current, 1);
|
|
if (current.id && !containsSecret(current.id)) {
|
|
label = '#' + cssEscape(current.id);
|
|
} else if (aria && !containsSecret(aria)) {
|
|
label = tag + '[aria-label="' + clampStr(aria, 40).replace(/"/g, '\\\\"') + '"]';
|
|
} else if (role && !containsSecret(role)) {
|
|
label = tag + '[role="' + clampStr(role, 30).replace(/"/g, '\\\\"') + '"]';
|
|
} else if (stableClasses.length > 0) {
|
|
label = '.' + cssEscape(stableClasses[0]);
|
|
}
|
|
parts.unshift(label);
|
|
current = current.parentElement;
|
|
}
|
|
return clampStr(parts.join(' > '), BUDGET.pathMaxLength);
|
|
}
|
|
|
|
function buildFullPath(el) {
|
|
var parts = [];
|
|
var current = el;
|
|
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement && parts.length < 20) {
|
|
parts.unshift(buildSelectorPart(current));
|
|
current = current.parentElement;
|
|
}
|
|
return clampStr(parts.join(' > '), BUDGET.pathMaxLength);
|
|
}
|
|
|
|
function getNearbyText(el) {
|
|
var results = [];
|
|
var parent = el.parentElement;
|
|
if (!parent) return results;
|
|
|
|
function addSiblingText(sibling) {
|
|
if (!sibling) return;
|
|
var text = getBoundedText(sibling, BUDGET.nearbyTextEntryMaxLength);
|
|
if (text) {
|
|
results.push(clampStr(text, BUDGET.nearbyTextEntryMaxLength));
|
|
}
|
|
}
|
|
|
|
var inspected = 0;
|
|
var previous = el.previousElementSibling;
|
|
var next = el.nextElementSibling;
|
|
while (
|
|
results.length < BUDGET.nearbyTextMaxEntries &&
|
|
inspected < NEARBY_ELEMENT_SCAN_LIMIT &&
|
|
(previous || next)
|
|
) {
|
|
if (previous) {
|
|
var previousSibling = previous;
|
|
previous = previous.previousElementSibling;
|
|
inspected++;
|
|
addSiblingText(previousSibling);
|
|
}
|
|
if (
|
|
next &&
|
|
results.length < BUDGET.nearbyTextMaxEntries &&
|
|
inspected < NEARBY_ELEMENT_SCAN_LIMIT
|
|
) {
|
|
var nextSibling = next;
|
|
next = next.nextElementSibling;
|
|
inspected++;
|
|
addSiblingText(nextSibling);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function getAncestorPath(el) {
|
|
var path = [];
|
|
var current = el.parentElement;
|
|
while (current && current !== document.documentElement && path.length < BUDGET.ancestorPathMaxEntries) {
|
|
var tag = current.tagName.toLowerCase();
|
|
var role = current.getAttribute('role');
|
|
path.push(role ? tag + '[role=' + role + ']' : tag);
|
|
current = current.parentElement;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function getNearbyElements(el) {
|
|
var parent = el.parentElement;
|
|
if (!parent) return [];
|
|
var result = [];
|
|
|
|
function addSibling(sibling) {
|
|
if (!sibling) return;
|
|
if (sibling === el) return;
|
|
var rect = sibling.getBoundingClientRect();
|
|
if (rect.width === 0 && rect.height === 0) return;
|
|
var label = sibling.tagName.toLowerCase();
|
|
var stableClasses = getStableClasses(sibling, 1);
|
|
if (stableClasses.length > 0) label += '.' + stableClasses[0];
|
|
var text = getBoundedText(sibling, 50);
|
|
if (text) label += ' "' + clampStr(text, 50) + '"';
|
|
result.push(clampStr(label, BUDGET.nearbyElementMaxLength));
|
|
}
|
|
var inspected = 0;
|
|
var previous = el.previousElementSibling;
|
|
var next = el.nextElementSibling;
|
|
while (
|
|
result.length < BUDGET.nearbyElementsMaxEntries &&
|
|
inspected < NEARBY_ELEMENT_SCAN_LIMIT &&
|
|
(previous || next)
|
|
) {
|
|
if (previous) {
|
|
var previousSibling = previous;
|
|
previous = previous.previousElementSibling;
|
|
inspected++;
|
|
addSibling(previousSibling);
|
|
}
|
|
if (
|
|
next &&
|
|
result.length < BUDGET.nearbyElementsMaxEntries &&
|
|
inspected < NEARBY_ELEMENT_SCAN_LIMIT
|
|
) {
|
|
var nextSibling = next;
|
|
next = next.nextElementSibling;
|
|
inspected++;
|
|
addSibling(nextSibling);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isElementFixed(el) {
|
|
var current = el;
|
|
while (current && current !== document.body) {
|
|
var position = window.getComputedStyle(current).position;
|
|
if (position === 'fixed' || position === 'sticky') return true;
|
|
current = current.parentElement;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getFiberFromElement(el) {
|
|
var keys = Object.keys(el);
|
|
for (var i = 0; i < keys.length; i++) {
|
|
if (keys[i].indexOf('__reactFiber$') === 0 || keys[i].indexOf('__reactInternalInstance$') === 0) {
|
|
try {
|
|
return el[keys[i]] || null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getComponentNameFromFiber(fiber) {
|
|
if (!fiber) return null;
|
|
var type = fiber.type || fiber.elementType;
|
|
if (!type || typeof type === 'string') return null;
|
|
if (type.displayName || type.name) return type.displayName || type.name;
|
|
if (type.render && (type.render.displayName || type.render.name)) {
|
|
return type.render.displayName || type.render.name;
|
|
}
|
|
if (type.type && (type.type.displayName || type.type.name)) {
|
|
return type.type.displayName || type.type.name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shouldSkipReactName(name) {
|
|
if (!name || name.length <= 2) return true;
|
|
return /^(Fragment|Root|Routes|Route|Outlet|Provider|Consumer|Profiler|Suspense)$/.test(name) ||
|
|
/(?:Boundary|BoundaryHandler|Router|Provider|Consumer|Context|Wrapper)$/.test(name) ||
|
|
/^(Inner|Outer|Client|Server|RSC|Dev|React|Hot)/.test(name);
|
|
}
|
|
|
|
function cleanSourcePath(path) {
|
|
if (!path) return '';
|
|
return String(path)
|
|
.replace(/[?#].*$/, '')
|
|
.replace(/^turbopack:\\/\\/\\/\\[project\\]\\//, '')
|
|
.replace(/^webpack-internal:\\/\\/\\/\\.\\//, '')
|
|
.replace(/^webpack-internal:\\/\\/\\//, '')
|
|
.replace(/^webpack:\\/\\/\\/\\.\\//, '')
|
|
.replace(/^webpack:\\/\\/\\//, '')
|
|
.replace(/^turbopack:\\/\\/\\//, '')
|
|
.replace(/^https?:\\/\\/[^/]+\\//, '')
|
|
.replace(/^file:\\/\\/\\//, '/')
|
|
.replace(/^\\([^)]+\\)\\/\\.\\//, '')
|
|
.replace(/^\\.\\//, '');
|
|
}
|
|
|
|
function getReactMetadata(el) {
|
|
try {
|
|
var fiber = getFiberFromElement(el);
|
|
var components = [];
|
|
var sourceFile = null;
|
|
var depth = 0;
|
|
while (fiber && depth < 35) {
|
|
var name = getComponentNameFromFiber(fiber);
|
|
if (name && !shouldSkipReactName(name) && components.indexOf(name) === -1 && components.length < 6) {
|
|
components.push(name);
|
|
}
|
|
var source = fiber._debugSource || (fiber._debugOwner && fiber._debugOwner._debugSource);
|
|
if (!sourceFile && source && source.fileName && source.lineNumber) {
|
|
sourceFile = cleanSourcePath(source.fileName) + ':' + source.lineNumber +
|
|
(source.columnNumber !== undefined ? ':' + source.columnNumber : '');
|
|
if (containsSecret(sourceFile)) {
|
|
sourceFile = null;
|
|
}
|
|
}
|
|
fiber = fiber.return;
|
|
depth++;
|
|
}
|
|
return {
|
|
reactComponents: components.length > 0
|
|
? clampStr(components.slice().reverse().map(function(c) { return '<' + c + '>'; }).join(' '), BUDGET.reactComponentsMaxLength)
|
|
: null,
|
|
sourceFile: sourceFile ? clampStr(sourceFile, BUDGET.sourceFileMaxLength) : null
|
|
};
|
|
} catch (e) {
|
|
return { reactComponents: null, sourceFile: null };
|
|
}
|
|
}
|
|
|
|
// --- Build full payload for an element ---
|
|
function extractPayload(el) {
|
|
var rect = el.getBoundingClientRect();
|
|
var react = getReactMetadata(el);
|
|
return {
|
|
page: {
|
|
sanitizedUrl: sanitizeUrl(window.location.href),
|
|
title: document.title || '',
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
scrollX: window.scrollX,
|
|
scrollY: window.scrollY,
|
|
devicePixelRatio: window.devicePixelRatio || 1,
|
|
capturedAt: new Date().toISOString()
|
|
},
|
|
target: {
|
|
tagName: el.tagName.toLowerCase(),
|
|
selector: buildSelector(el),
|
|
elementPath: buildReadablePath(el),
|
|
fullPath: buildFullPath(el),
|
|
cssClasses: containsSecret(el.getAttribute('class') || '')
|
|
? '[redacted]'
|
|
: clampStr(el.getAttribute('class') || '', BUDGET.cssClassesMaxLength),
|
|
nearbyElements: getNearbyElements(el),
|
|
selectedText: getSelectedText() || null,
|
|
isFixed: isElementFixed(el),
|
|
reactComponents: react.reactComponents,
|
|
sourceFile: react.sourceFile,
|
|
textSnippet: getTextSnippet(el),
|
|
htmlSnippet: getHtmlSnippet(el),
|
|
attributes: getSafeAttributes(el),
|
|
accessibility: getAccessibility(el),
|
|
rectViewport: {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height
|
|
},
|
|
rectPage: {
|
|
x: rect.x + window.scrollX,
|
|
y: rect.y + window.scrollY,
|
|
width: rect.width,
|
|
height: rect.height
|
|
},
|
|
computedStyles: getComputedStyleSubset(el)
|
|
},
|
|
nearbyText: getNearbyText(el),
|
|
ancestorPath: getAncestorPath(el),
|
|
screenshot: null
|
|
};
|
|
}
|
|
|
|
// --- Overlay UI ---
|
|
// Why: the host element is a full-viewport overlay with pointer-events:all
|
|
// so it acts as a click catcher. This prevents the page from receiving the
|
|
// selection click. The overlay uses elementFromPoint (with itself temporarily
|
|
// hidden) to identify the element underneath the pointer.
|
|
var host = document.createElement('div');
|
|
host.id = '__orca-grab-host';
|
|
host.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:2147483647;pointer-events:all;cursor:crosshair;';
|
|
document.documentElement.appendChild(host);
|
|
|
|
var shadow = host.attachShadow({ mode: 'closed' });
|
|
|
|
// Visual container for highlight/label — pointer-events:none so clicks go to host
|
|
var overlay = document.createElement('div');
|
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647;';
|
|
shadow.appendChild(overlay);
|
|
|
|
// Why: the highlight uses a white border with a dark outer shadow so it
|
|
// reads well on both light and dark page backgrounds.
|
|
var highlightBox = document.createElement('div');
|
|
highlightBox.style.cssText = 'position:fixed;border:2px solid rgba(255,255,255,0.9);border-radius:3px;pointer-events:none;transition:all 0.05s ease-out;display:none;background:rgba(255,255,255,0.08);box-shadow:0 0 0 1px rgba(0,0,0,0.3),0 2px 8px rgba(0,0,0,0.15);';
|
|
overlay.appendChild(highlightBox);
|
|
|
|
// Hover label — dark neutral pill
|
|
var hoverLabel = document.createElement('div');
|
|
hoverLabel.style.cssText = 'position:fixed;padding:3px 8px;background:rgba(30,30,30,0.92);color:#e5e5e5;font:11px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;border-radius:4px;pointer-events:none;white-space:nowrap;display:none;max-width:300px;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,0.3);';
|
|
overlay.appendChild(hoverLabel);
|
|
|
|
var currentEl = null;
|
|
|
|
function updateHighlight(el) {
|
|
if (!el || el === document.documentElement || el === document.body) {
|
|
highlightBox.style.display = 'none';
|
|
hoverLabel.style.display = 'none';
|
|
currentEl = null;
|
|
return;
|
|
}
|
|
currentEl = el;
|
|
var rect = el.getBoundingClientRect();
|
|
highlightBox.style.left = rect.x + 'px';
|
|
highlightBox.style.top = rect.y + 'px';
|
|
highlightBox.style.width = rect.width + 'px';
|
|
highlightBox.style.height = rect.height + 'px';
|
|
highlightBox.style.display = 'block';
|
|
|
|
// Build label text
|
|
var tag = el.tagName.toLowerCase();
|
|
var role = el.getAttribute('role');
|
|
var text = getBoundedText(el, 40);
|
|
if (text.length > 40) text = text.slice(0, 37) + '...';
|
|
var w = Math.round(rect.width);
|
|
var h = Math.round(rect.height);
|
|
var parts = [tag];
|
|
if (role) parts.push('role=' + role);
|
|
if (text) parts.push('"' + text + '"');
|
|
parts.push(w + 'x' + h);
|
|
hoverLabel.textContent = parts.join(' ');
|
|
|
|
// Position label below the element, or above if near bottom
|
|
var labelY = rect.bottom + 6;
|
|
if (labelY + 28 > window.innerHeight) {
|
|
labelY = rect.top - 28;
|
|
}
|
|
hoverLabel.style.left = Math.max(4, rect.x) + 'px';
|
|
hoverLabel.style.top = labelY + 'px';
|
|
hoverLabel.style.display = 'block';
|
|
}
|
|
|
|
function onPointerMove(e) {
|
|
// Temporarily hide the overlay to hit-test the element underneath
|
|
host.style.pointerEvents = 'none';
|
|
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
host.style.pointerEvents = 'all';
|
|
if (el) {
|
|
requestAnimationFrame(function() { updateHighlight(el); });
|
|
}
|
|
}
|
|
|
|
// Why: mousemove on the host (not document) because the host is the
|
|
// full-viewport click catcher that receives all pointer events.
|
|
host.addEventListener('mousemove', onPointerMove);
|
|
|
|
// Store state for awaitClick/finalize/teardown access
|
|
window.__orcaGrab = {
|
|
host: host,
|
|
extractPayload: extractPayload,
|
|
getCurrentElement: function() { return currentEl; },
|
|
// Why: freeze the highlight so the selected element stays outlined while
|
|
// the renderer shows the copy menu. Disabling pointer-events on the host
|
|
// lets the cursor return to normal and prevents the crosshair from showing
|
|
// over the dropdown menu's area in the webview.
|
|
freezeHighlight: function() {
|
|
host.removeEventListener('mousemove', onPointerMove);
|
|
host.style.pointerEvents = 'none';
|
|
host.style.cursor = 'default';
|
|
},
|
|
cleanup: function() {
|
|
host.removeEventListener('mousemove', onPointerMove);
|
|
try { host.remove(); } catch(e) {}
|
|
delete window.__orcaGrab;
|
|
}
|
|
};
|
|
|
|
return true;
|
|
})()`
|
|
|
|
// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page.
|
|
const AWAIT_CLICK_SCRIPT = `(async function() {
|
|
// Why: hand the click result to executeJavaScript through a native (intrinsic)
|
|
// Promise. On pages that replace the global Promise with a non-native thenable
|
|
// — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not
|
|
// recognized as a promise by Electron, so its raw wrapper object (exposing
|
|
// __zone_symbol__state/__value instead of { page, target }) crosses the boundary
|
|
// and main rejects it as an invalid payload structure. An async function's
|
|
// promise comes from the engine intrinsic that page code cannot reassign, so
|
|
// Electron always unwraps it to the resolved payload.
|
|
return await new Promise(function(resolve, reject) {
|
|
'use strict';
|
|
var grab = window.__orcaGrab;
|
|
if (!grab) {
|
|
reject(new Error('Grab not armed'));
|
|
return;
|
|
}
|
|
|
|
function extractSelectedPayload(el) {
|
|
try {
|
|
return grab.extractPayload(el);
|
|
} catch (error) {
|
|
grab.cleanup();
|
|
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function onClick(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
grab.host.removeEventListener('click', onClick, true);
|
|
grab.host.removeEventListener('contextmenu', onContext, true);
|
|
var el = grab.getCurrentElement();
|
|
if (!el) {
|
|
grab.cleanup();
|
|
reject(new Error('cancelled'));
|
|
return;
|
|
}
|
|
var payload = extractSelectedPayload(el);
|
|
if (!payload) return;
|
|
// Why: freeze the highlight instead of removing it so the user sees
|
|
// which element was selected while the copy menu is shown. Teardown
|
|
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
|
grab.freezeHighlight();
|
|
resolve(payload);
|
|
}
|
|
|
|
function onContext(e) {
|
|
// Why: right-click resolves with the payload wrapped in a context-menu
|
|
// marker so the renderer can show the full action dropdown instead of
|
|
// auto-copying. This gives users a deliberate path to screenshot and
|
|
// other secondary actions while keeping left-click as the fast copy path.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
grab.host.removeEventListener('click', onClick, true);
|
|
grab.host.removeEventListener('contextmenu', onContext, true);
|
|
var el = grab.getCurrentElement();
|
|
if (!el) {
|
|
grab.cleanup();
|
|
reject(new Error('cancelled'));
|
|
return;
|
|
}
|
|
var payload = extractSelectedPayload(el);
|
|
if (!payload) return;
|
|
grab.freezeHighlight();
|
|
resolve({ __orcaContextMenu: true, payload: payload });
|
|
}
|
|
|
|
grab.host.addEventListener('click', onClick, true);
|
|
grab.host.addEventListener('contextmenu', onContext, true);
|
|
|
|
// Store cancel hook so teardown can settle the Promise
|
|
grab.cancelAwait = function() {
|
|
grab.host.removeEventListener('click', onClick, true);
|
|
grab.host.removeEventListener('contextmenu', onContext, true);
|
|
grab.cleanup();
|
|
// Why: teardown cancellation is a normal user flow; resolving a marker
|
|
// avoids a noisy guest-console Error while main still treats it as cancel.
|
|
resolve({ __orcaCancelled: true });
|
|
};
|
|
});
|
|
})()`
|
|
|
|
const FINALIZE_SCRIPT = `(function() {
|
|
'use strict';
|
|
var grab = window.__orcaGrab;
|
|
if (!grab) return null;
|
|
var el = grab.getCurrentElement();
|
|
if (!el) return null;
|
|
var payload = null;
|
|
try {
|
|
payload = grab.extractPayload(el);
|
|
} catch (e) {
|
|
grab.cleanup();
|
|
return null;
|
|
}
|
|
grab.cleanup();
|
|
return payload;
|
|
})()`
|
|
|
|
// extractHover: read payload but keep overlay/listeners active so the user can keep picking (C/S shortcut copy, no click).
|
|
const EXTRACT_HOVER_SCRIPT = `(function() {
|
|
'use strict';
|
|
var grab = window.__orcaGrab;
|
|
if (!grab) return null;
|
|
var el = grab.getCurrentElement();
|
|
if (!el) return null;
|
|
try {
|
|
return grab.extractPayload(el);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
})()`
|
|
|
|
const TEARDOWN_SCRIPT = `(function() {
|
|
'use strict';
|
|
var grab = window.__orcaGrab;
|
|
if (!grab) return true;
|
|
// If there's an active awaitClick Promise, cancel it: cancelAwait resolves
|
|
// it with the __orcaCancelled marker so the executeJavaScript call in main
|
|
// settles the grab op as a cancellation.
|
|
if (grab.cancelAwait) {
|
|
grab.cancelAwait();
|
|
} else {
|
|
grab.cleanup();
|
|
}
|
|
return true;
|
|
})()`
|