mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor Electron facilities modules (#16333)
* refactor oversized Electron facilities * fix interactive process timeout and shortcut repeat guard * chore(child-process): drop stale cli-installer allowlist entry cli-installer.ts now routes privileged spawns through runProcess via cli-privileged-processes.ts, so the shrink-only ratchet flags it as stale. * refactor(child-process): extract the bounded output sink runProcess's timeoutMs opt-out (required to preserve the unbounded osascript admin prompt) pushed run-process.ts past the 300-line cap. Move createOutputSink to its own module rather than add a max-lines bypass, which AGENTS.md forbids. Moved verbatim; no behavior change.
This commit is contained in:
@@ -11,9 +11,7 @@ inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
@@ -48,7 +46,6 @@ inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
@@ -56,7 +53,6 @@ inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/git-handler.ts
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
export const GRAB_GUEST_CONTENT_SCRIPT = ` 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
|
||||
};
|
||||
}
|
||||
|
||||
`
|
||||
@@ -0,0 +1,228 @@
|
||||
export const GRAB_GUEST_ELEMENT_CONTEXT_SCRIPT = ` 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;
|
||||
}
|
||||
|
||||
`
|
||||
@@ -0,0 +1,152 @@
|
||||
export const GRAB_GUEST_FOUNDATION_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);
|
||||
}
|
||||
|
||||
`
|
||||
@@ -0,0 +1,100 @@
|
||||
export const GRAB_GUEST_OVERLAY_SCRIPT = ` 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;
|
||||
})()`
|
||||
@@ -0,0 +1,152 @@
|
||||
export const GRAB_GUEST_REACT_SCRIPT = ` 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.
|
||||
`
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { types } from 'node:util'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -5,6 +6,18 @@ import { buildGuestOverlayScript } from './grab-guest-script'
|
||||
import { clampGrabPayload } from './browser-grab-payload'
|
||||
|
||||
describe('buildGuestOverlayScript', () => {
|
||||
it.each([
|
||||
['arm', '07cffca05c4c9dab10bdcf301deab24e033edd07c6cd235bb364e1a139720a0a'],
|
||||
['awaitClick', 'b6b65b2b53c8719f1d10f93954cf867d99e43e14dbd1ca0a92e5067b168a126c'],
|
||||
['finalize', '91bd9836b0536c9579e0d4648d30679c0b4a5893d9a43110a70e67d6804fd291'],
|
||||
['extractHover', 'cf0ee3ac61669daefa7db9389233c1abfe9f0fb9e7257300c761987aac914b02'],
|
||||
['teardown', '732efde1022745f26dd4250d2891a663023eecafdf025fd66dde87781a985d81']
|
||||
] as const)('preserves the serialized %s guest script', (action, expectedSha256) => {
|
||||
expect(createHash('sha256').update(buildGuestOverlayScript(action)).digest('hex')).toBe(
|
||||
expectedSha256
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a non-empty string for arm action', () => {
|
||||
const script = buildGuestOverlayScript('arm')
|
||||
expect(script).toBeTruthy()
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/* 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.
|
||||
import { GRAB_GUEST_CONTENT_SCRIPT } from './grab-guest-content-script'
|
||||
import { GRAB_GUEST_ELEMENT_CONTEXT_SCRIPT } from './grab-guest-element-context-script'
|
||||
import { GRAB_GUEST_FOUNDATION_SCRIPT } from './grab-guest-foundation-script'
|
||||
import { GRAB_GUEST_OVERLAY_SCRIPT } from './grab-guest-overlay-script'
|
||||
import { GRAB_GUEST_REACT_SCRIPT } from './grab-guest-react-script'
|
||||
import {
|
||||
AWAIT_CLICK_SCRIPT,
|
||||
EXTRACT_HOVER_SCRIPT,
|
||||
FINALIZE_SCRIPT,
|
||||
TEARDOWN_SCRIPT
|
||||
} from './grab-guest-selection-scripts'
|
||||
|
||||
type GuestScriptAction = 'arm' | 'awaitClick' | 'finalize' | 'extractHover' | 'teardown'
|
||||
|
||||
/**
|
||||
* Build a self-contained JS script for the given grab lifecycle action.
|
||||
* Guest-page scripts for element grab mode. Executed via webContents.executeJavaScript.
|
||||
*
|
||||
* - `arm`: install the shadow-root overlay, hover listeners, and extraction logic
|
||||
* - `awaitClick`: return a Promise that resolves with the payload when the user clicks
|
||||
@@ -28,928 +36,10 @@ export function buildGuestOverlayScript(action: GuestScriptAction): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
})()`
|
||||
const ARM_SCRIPT = [
|
||||
GRAB_GUEST_FOUNDATION_SCRIPT,
|
||||
GRAB_GUEST_CONTENT_SCRIPT,
|
||||
GRAB_GUEST_ELEMENT_CONTEXT_SCRIPT,
|
||||
GRAB_GUEST_REACT_SCRIPT,
|
||||
GRAB_GUEST_OVERLAY_SCRIPT
|
||||
].join('')
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
export 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 });
|
||||
};
|
||||
});
|
||||
})()`
|
||||
|
||||
export 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).
|
||||
export 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;
|
||||
}
|
||||
})()`
|
||||
|
||||
export 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;
|
||||
})()`
|
||||
@@ -0,0 +1,278 @@
|
||||
import { lstat, readFile, readlink } from 'node:fs/promises'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
|
||||
import { DEV_COMMAND_NAME, DEV_LAUNCHER_DIR } from './cli-install-constants'
|
||||
import { buildWindowsForwarder, extractManagedUnixLauncherTarget } from './cli-dev-launcher'
|
||||
import { isMissingError } from './cli-install-errors'
|
||||
import { CliInstallLocation } from './cli-install-location'
|
||||
import { isPathInsideOrEqual, samePathEntry } from './cli-install-path-format'
|
||||
|
||||
export class CliCommandInspection extends CliInstallLocation {
|
||||
protected async inspectAppImageWrapper(
|
||||
commandPath: string,
|
||||
appImagePath: string
|
||||
): Promise<CliInstallStatus> {
|
||||
try {
|
||||
const stats = await lstat(commandPath)
|
||||
if (!stats.isFile()) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath: appImagePath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'conflict',
|
||||
currentTarget: null,
|
||||
detail: `${commandPath} exists but is not an Orca launcher script.`
|
||||
})
|
||||
}
|
||||
|
||||
const currentContent = await readFile(commandPath, 'utf8')
|
||||
const expectedContent = buildAppImageCliWrapper(appImagePath)
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath: appImagePath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: currentContent === expectedContent ? 'installed' : 'stale',
|
||||
currentTarget: appImagePath,
|
||||
detail:
|
||||
currentContent === expectedContent
|
||||
? `Registered at ${commandPath}.`
|
||||
: `${commandPath} points to a different launcher.`
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath: appImagePath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
detail: `Register ${commandPath} to use Orca from the terminal.`
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected async inspectSymlink(
|
||||
commandPath: string,
|
||||
launcherPath: string
|
||||
): Promise<CliInstallStatus> {
|
||||
try {
|
||||
const stats = await lstat(commandPath)
|
||||
if (!stats.isSymbolicLink()) {
|
||||
if (stats.isFile()) {
|
||||
const currentContent = await readFile(commandPath, 'utf8')
|
||||
const managedTarget = extractManagedUnixLauncherTarget(currentContent)
|
||||
if (managedTarget) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: 'stale',
|
||||
currentTarget: managedTarget,
|
||||
detail: `${commandPath} contains an older Orca launcher.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: 'conflict',
|
||||
currentTarget: null,
|
||||
detail: `${commandPath} exists but is not an Orca symlink.`
|
||||
})
|
||||
}
|
||||
|
||||
const currentTarget = await readlink(commandPath)
|
||||
const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget)
|
||||
const resolvedLauncher = resolve(launcherPath)
|
||||
const isInstalled = resolvedCurrentTarget === resolvedLauncher
|
||||
const isManagedStaleTarget =
|
||||
!isInstalled && this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath)
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: isInstalled ? 'installed' : isManagedStaleTarget ? 'stale' : 'conflict',
|
||||
currentTarget: resolvedCurrentTarget,
|
||||
detail: isInstalled
|
||||
? `Registered at ${commandPath}.`
|
||||
: isManagedStaleTarget
|
||||
? `${commandPath} points to an older Orca launcher.`
|
||||
: `${commandPath} points to a non-Orca launcher.`
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'symlink',
|
||||
supported: true,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
detail: `Register ${commandPath} to use Orca from the terminal.`
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected isManagedSymlinkTarget(resolvedTarget: string, launcherPath: string): boolean {
|
||||
const expectedName = basename(launcherPath)
|
||||
if (this.isPackaged && this.isSiblingDevLauncherTarget(resolvedTarget, expectedName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (basename(resolvedTarget) !== expectedName) {
|
||||
return false
|
||||
}
|
||||
|
||||
const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR)
|
||||
if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.platform === 'darwin') {
|
||||
// Why: reclaim symlinks to an older Orca.app launcher, but never replace arbitrary user-owned symlinks.
|
||||
return /(?:^|[/\\])[^/\\]+\.app[/\\]Contents[/\\]Resources[/\\]bin[/\\][^/\\]+$/.test(
|
||||
resolvedTarget
|
||||
)
|
||||
}
|
||||
|
||||
if (this.platform === 'linux') {
|
||||
return /(?:^|[/\\])resources[/\\]bin[/\\][^/\\]+$/.test(resolvedTarget)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
protected isSiblingDevLauncherTarget(
|
||||
resolvedTarget: string,
|
||||
packagedLauncherName: string
|
||||
): boolean {
|
||||
if (![packagedLauncherName, DEV_COMMAND_NAME].includes(basename(resolvedTarget))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const packagedUserDataPath = resolve(this.userDataPath)
|
||||
const siblingDevUserDataPath = `${packagedUserDataPath}-dev`
|
||||
const siblingDevLauncherDir = resolve(siblingDevUserDataPath, ...DEV_LAUNCHER_DIR)
|
||||
|
||||
// Why: dev builds generate launchers under the sibling `*-dev` profile; packaged Orca must reclaim that command.
|
||||
return (
|
||||
basename(siblingDevUserDataPath) === `${basename(packagedUserDataPath)}-dev` &&
|
||||
isPathInsideOrEqual(siblingDevLauncherDir, resolvedTarget)
|
||||
)
|
||||
}
|
||||
|
||||
protected isLinuxAppImage(): boolean {
|
||||
return this.platform === 'linux' && Boolean(this.appImagePath)
|
||||
}
|
||||
|
||||
protected isWindowsPackagedBundledCommand(
|
||||
commandPath: string | null,
|
||||
launcherPath: string | null
|
||||
): boolean {
|
||||
return (
|
||||
this.platform === 'win32' &&
|
||||
this.isPackaged &&
|
||||
commandPath !== null &&
|
||||
launcherPath !== null &&
|
||||
samePathEntry('win32', commandPath, launcherPath)
|
||||
)
|
||||
}
|
||||
|
||||
protected async inspectWindowsWrapper(
|
||||
commandPath: string,
|
||||
launcherPath: string
|
||||
): Promise<CliInstallStatus> {
|
||||
try {
|
||||
const stats = await lstat(commandPath)
|
||||
if (!stats.isFile()) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'conflict',
|
||||
currentTarget: null,
|
||||
detail: `${commandPath} exists but is not an Orca launcher script.`
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isWindowsPackagedBundledCommand(commandPath, launcherPath)) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'installed',
|
||||
currentTarget: launcherPath,
|
||||
detail: `Registered at ${commandPath}.`
|
||||
})
|
||||
}
|
||||
|
||||
const currentContent = await readFile(commandPath, 'utf8')
|
||||
const expectedContent = buildWindowsForwarder(launcherPath)
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: currentContent === expectedContent ? 'installed' : 'stale',
|
||||
currentTarget: launcherPath,
|
||||
detail:
|
||||
currentContent === expectedContent
|
||||
? `Registered at ${commandPath}.`
|
||||
: `${commandPath} points to a different launcher.`
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
return this.buildStatus({
|
||||
commandPath,
|
||||
launcherPath,
|
||||
installMethod: 'wrapper',
|
||||
supported: true,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
detail: `Register ${commandPath} to use Orca from Command Prompt or PowerShell.`
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected buildStatus(args: {
|
||||
commandPath: string
|
||||
launcherPath: string
|
||||
installMethod: CliInstallMethod
|
||||
supported: boolean
|
||||
state: CliInstallStatus['state']
|
||||
currentTarget: string | null
|
||||
detail: string | null
|
||||
}): CliInstallStatus {
|
||||
return {
|
||||
platform: this.platform,
|
||||
commandName: this.commandName,
|
||||
commandPath: args.commandPath,
|
||||
pathDirectory: dirname(args.commandPath),
|
||||
pathConfigured: false,
|
||||
launcherPath: args.launcherPath,
|
||||
installMethod: args.installMethod,
|
||||
supported: args.supported,
|
||||
state: args.state,
|
||||
currentTarget: args.currentTarget,
|
||||
unsupportedReason: null,
|
||||
detail: args.detail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { lstat, mkdir, readlink, symlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
|
||||
import { CliCommandInspection } from './cli-command-inspection'
|
||||
import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants'
|
||||
import { buildWindowsForwarder } from './cli-dev-launcher'
|
||||
import { isMissingError, isPermissionError } from './cli-install-errors'
|
||||
import { quoteShell } from './cli-install-path-format'
|
||||
|
||||
export class CliCommandInstallation extends CliCommandInspection {
|
||||
protected async installSymlink(status: CliInstallStatus): Promise<void> {
|
||||
try {
|
||||
if (status.state === 'installed') {
|
||||
return
|
||||
}
|
||||
if (status.state === 'stale') {
|
||||
await unlink(status.commandPath as string)
|
||||
}
|
||||
// Why: mkdir stays here (not install()) so an EACCES falls into the privileged-runner catch below.
|
||||
await mkdir(dirname(status.commandPath as string), { recursive: true })
|
||||
await symlink(status.launcherPath as string, status.commandPath as string)
|
||||
} catch (error) {
|
||||
if (this.platform !== 'darwin' || !isPermissionError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Why: fall back to an elevated shell to place the /usr/local/bin symlink (VS Code-style) when direct write is denied.
|
||||
await this.privilegedRunner(
|
||||
`mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` +
|
||||
`ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected async removeSymlink(commandPath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(commandPath)
|
||||
} catch (error) {
|
||||
if (this.platform !== 'darwin' || !isPermissionError(error)) {
|
||||
throw error
|
||||
}
|
||||
await this.privilegedRunner(
|
||||
`if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise<void> {
|
||||
if (this.platform !== 'linux' || this.commandPathOverride || !launcherPath) {
|
||||
return
|
||||
}
|
||||
|
||||
const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME)
|
||||
try {
|
||||
const stats = await lstat(legacyCommandPath)
|
||||
if (!stats.isSymbolicLink()) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentTarget = await readlink(legacyCommandPath)
|
||||
const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget)
|
||||
if (!this.isManagedLegacyLinuxTarget(resolvedCurrentTarget, launcherPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: after the Linux command rename, the old `orca` symlink would keep shadowing GNOME Orca.
|
||||
await unlink(legacyCommandPath)
|
||||
} catch (error) {
|
||||
if (isMissingError(error)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected isManagedLegacyLinuxTarget(resolvedTarget: string, launcherPath: string): boolean {
|
||||
const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME)
|
||||
if (resolvedTarget === legacyLauncherPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (basename(resolvedTarget) !== LEGACY_LINUX_COMMAND_NAME) {
|
||||
return false
|
||||
}
|
||||
|
||||
const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR)
|
||||
const devRelative = relative(devLauncherDir, resolvedTarget)
|
||||
if (devRelative && !devRelative.startsWith('..') && !isAbsolute(devRelative)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: AppImage upgrades can strand a legacy symlink into a now-gone FUSE mount that isn't a sibling of the stable path.
|
||||
return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget)
|
||||
}
|
||||
|
||||
protected async installWindowsWrapper(commandPath: string, launcherPath: string): Promise<void> {
|
||||
await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8')
|
||||
}
|
||||
|
||||
protected async installAppImageWrapper(commandPath: string, appImagePath: string): Promise<void> {
|
||||
// Why: the AppImage command dir is user-writable, so create it before writing the wrapper.
|
||||
await mkdir(dirname(commandPath), { recursive: true })
|
||||
await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o755
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { DEV_COMMAND_NAME, DEV_LAUNCHER_DIR } from './cli-install-constants'
|
||||
import {
|
||||
escapeWindowsBatchValue,
|
||||
isAbsoluteForPlatform,
|
||||
quoteShell
|
||||
} from './cli-install-path-format'
|
||||
|
||||
export async function ensureDevLauncher(args: {
|
||||
platform: NodeJS.Platform
|
||||
userDataPath: string
|
||||
execPath: string
|
||||
cliEntryPath: string
|
||||
commandName: string
|
||||
}): Promise<string | null> {
|
||||
if (
|
||||
!isAbsoluteForPlatform(args.platform, args.execPath) ||
|
||||
!isAbsolute(args.cliEntryPath) ||
|
||||
!existsSync(args.cliEntryPath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const launcherPath = join(
|
||||
args.userDataPath,
|
||||
...DEV_LAUNCHER_DIR,
|
||||
args.platform === 'win32' ? `${args.commandName}.cmd` : args.commandName
|
||||
)
|
||||
await mkdir(dirname(launcherPath), { recursive: true })
|
||||
|
||||
// Why: dev builds lack the packaged resources/bin launcher, so generate one in userData to validate the flow.
|
||||
const content =
|
||||
args.platform === 'win32'
|
||||
? buildWindowsDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
|
||||
: buildUnixDevLauncher(args.execPath, args.cliEntryPath, args.userDataPath)
|
||||
await writeFile(launcherPath, content, {
|
||||
encoding: 'utf8',
|
||||
mode: args.platform === 'win32' ? undefined : 0o755
|
||||
})
|
||||
if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') {
|
||||
// Why: dev PTYs prepend this dir to PATH, so keep a local `orca` alias without claiming the global command.
|
||||
await writeFile(join(dirname(launcherPath), 'orca'), content, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o755
|
||||
})
|
||||
}
|
||||
return launcherPath
|
||||
}
|
||||
|
||||
export function buildUnixDevLauncher(
|
||||
execPathValue: string,
|
||||
cliEntryPath: string,
|
||||
userDataPath: string
|
||||
): string {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ELECTRON=${quoteShell(execPathValue)}
|
||||
CLI=${quoteShell(cliEntryPath)}
|
||||
export ORCA_USER_DATA_PATH=${quoteShell(userDataPath)}
|
||||
if [ -z "\${ORCA_APP_EXECUTABLE:-}" ]; then
|
||||
export ORCA_APP_EXECUTABLE="$ELECTRON"
|
||||
export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1
|
||||
fi
|
||||
export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}"
|
||||
export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}"
|
||||
unset NODE_OPTIONS
|
||||
unset NODE_REPL_EXTERNAL_MODULE
|
||||
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
export function buildWindowsDevLauncher(
|
||||
execPathValue: string,
|
||||
cliEntryPath: string,
|
||||
userDataPath: string
|
||||
): string {
|
||||
return `@echo off
|
||||
setlocal
|
||||
set "ELECTRON=${escapeWindowsBatchValue(execPathValue)}"
|
||||
set "CLI=${escapeWindowsBatchValue(cliEntryPath)}"
|
||||
set "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"
|
||||
if not defined ORCA_APP_EXECUTABLE (
|
||||
set "ORCA_APP_EXECUTABLE=%ELECTRON%"
|
||||
set "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"
|
||||
)
|
||||
set "ORCA_NODE_OPTIONS=%NODE_OPTIONS%"
|
||||
set "ORCA_NODE_REPL_EXTERNAL_MODULE=%NODE_REPL_EXTERNAL_MODULE%"
|
||||
set NODE_OPTIONS=
|
||||
set NODE_REPL_EXTERNAL_MODULE=
|
||||
set ELECTRON_RUN_AS_NODE=1
|
||||
"%ELECTRON%" "%CLI%" %*
|
||||
`
|
||||
}
|
||||
|
||||
export function buildWindowsForwarder(launcherPath: string): string {
|
||||
return `@echo off
|
||||
setlocal
|
||||
set "ORCA_LAUNCHER=${escapeWindowsBatchValue(launcherPath)}"
|
||||
"%ORCA_LAUNCHER%" %*
|
||||
`
|
||||
}
|
||||
|
||||
export function extractManagedUnixLauncherTarget(content: string): string | null {
|
||||
if (
|
||||
!content.includes('ELECTRON_RUN_AS_NODE=1') ||
|
||||
!content.includes('ORCA_NODE_OPTIONS') ||
|
||||
!content.includes('NODE_REPL_EXTERNAL_MODULE')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cliPath = extractShellAssignment(content, 'CLI')
|
||||
if (!cliPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: only Orca's compiled CLI entrypoints count as managed; arbitrary Electron-launching scripts stay conflicts.
|
||||
return /(?:^|[/\\])(?:out|app\.asar\.unpacked[/\\]out)[/\\]cli[/\\]index\.js$/.test(cliPath)
|
||||
? cliPath
|
||||
: null
|
||||
}
|
||||
|
||||
export function extractShellAssignment(content: string, name: string): string | null {
|
||||
const match = new RegExp(`^${name}=('([^']*)'|"([^"]*)"|([^\\n]+))$`, 'm').exec(content)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return (match[2] ?? match[3] ?? match[4] ?? '').trim()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca'
|
||||
export const DEV_COMMAND_NAME = 'orca-dev'
|
||||
export const LEGACY_LINUX_COMMAND_NAME = 'orca'
|
||||
export const DEV_LAUNCHER_DIR = ['cli', 'bin'] as const
|
||||
export const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000
|
||||
@@ -0,0 +1,33 @@
|
||||
export function isPermissionError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
((error as NodeJS.ErrnoException).code === 'EACCES' ||
|
||||
(error as NodeJS.ErrnoException).code === 'EPERM')
|
||||
)
|
||||
}
|
||||
|
||||
export function isMissingError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
// Why: localized permission errors keep these .NET/ACL markers even when the PowerShell text is mojibake.
|
||||
export function isWindowsUserPathPermissionError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const stderr =
|
||||
'stderr' in error && typeof (error as { stderr?: unknown }).stderr === 'string'
|
||||
? (error as { stderr: string }).stderr
|
||||
: ''
|
||||
const haystack = `${error.message}\n${stderr}`
|
||||
return (
|
||||
haystack.includes('UnauthorizedAccessException') ||
|
||||
haystack.includes('SecurityException') ||
|
||||
haystack.includes('Requested registry access is not allowed') ||
|
||||
haystack.includes('Access is denied') ||
|
||||
haystack.includes('Access to the registry key')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { DEFAULT_MAC_COMMAND_PATH, DEV_COMMAND_NAME } from './cli-install-constants'
|
||||
import { ensureDevLauncher } from './cli-dev-launcher'
|
||||
import type { CliInstallerOptions, InstallSpec } from './cli-installer-contracts'
|
||||
import {
|
||||
isExecutableFile,
|
||||
samePathEntry,
|
||||
splitPathEntries,
|
||||
uniquePathEntries
|
||||
} from './cli-install-path-format'
|
||||
import { runMacPrivilegedCommand, writeWindowsUserPath } from './cli-privileged-processes'
|
||||
import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path'
|
||||
import {
|
||||
invalidateWindowsUserPathRegistryCache,
|
||||
readFreshWindowsUserPathRegistry,
|
||||
readWindowsUserPathRegistry,
|
||||
type WindowsUserPathReadResult
|
||||
} from './windows-user-path-registry'
|
||||
|
||||
export abstract class CliInstallLocation {
|
||||
protected abstract inspectSymlink(
|
||||
commandPath: string,
|
||||
launcherPath: string
|
||||
): Promise<CliInstallStatus>
|
||||
protected abstract isLinuxAppImage(): boolean
|
||||
|
||||
protected readonly platform: NodeJS.Platform
|
||||
protected readonly isPackaged: boolean
|
||||
protected readonly userDataPath: string
|
||||
protected readonly resourcesPath: string
|
||||
protected readonly execPathValue: string
|
||||
protected readonly appPathValue: string
|
||||
protected readonly homePath: string
|
||||
protected readonly localAppDataPath: string
|
||||
protected readonly processPathEnv: string | null
|
||||
protected readonly commandPathOverride: string | null
|
||||
protected readonly macCommandPath: string
|
||||
protected readonly privilegedRunner: (command: string) => Promise<void>
|
||||
protected readonly userPathReader: () => Promise<WindowsUserPathReadResult>
|
||||
protected readonly userPathMutationReader: () => Promise<WindowsUserPathReadResult>
|
||||
protected readonly userPathWriter: (value: string) => Promise<void>
|
||||
protected readonly userPathCacheInvalidator: () => void
|
||||
protected readonly windowsEnvironment: NodeJS.ProcessEnv
|
||||
protected readonly appImagePath: string | null
|
||||
|
||||
protected get commandName(): string {
|
||||
if (!this.isPackaged && !this.commandPathOverride) {
|
||||
// Why: development builds must not claim the production shell command.
|
||||
return DEV_COMMAND_NAME
|
||||
}
|
||||
// Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca.
|
||||
return this.platform === 'linux' ? LINUX_CLI_COMMAND_NAME : 'orca'
|
||||
}
|
||||
|
||||
constructor(options: CliInstallerOptions = {}) {
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.isPackaged = options.isPackaged ?? getAppEnvironment().isPackaged()
|
||||
this.userDataPath = options.userDataPath ?? getAppEnvironment().getPath('userData')
|
||||
this.resourcesPath = options.resourcesPath ?? process.resourcesPath
|
||||
this.execPathValue = options.execPath ?? process.execPath
|
||||
this.appPathValue = options.appPath ?? getAppEnvironment().getAppPath()
|
||||
this.homePath = options.homePath ?? homedir()
|
||||
this.localAppDataPath =
|
||||
options.localAppDataPath ??
|
||||
process.env.LOCALAPPDATA ??
|
||||
join(this.homePath, 'AppData', 'Local')
|
||||
this.processPathEnv = options.processPathEnv ?? process.env.PATH ?? process.env.Path ?? null
|
||||
this.commandPathOverride =
|
||||
options.commandPathOverride ?? process.env.ORCA_CLI_INSTALL_PATH ?? null
|
||||
// Why: resolved once here (getStatus is hot); /usr/local/bin is absent on Apple Silicon, so fall back to user-writable ~/.local/bin.
|
||||
const candidateMacPath = options.defaultMacCommandPath ?? DEFAULT_MAC_COMMAND_PATH
|
||||
this.macCommandPath = existsSync(dirname(candidateMacPath))
|
||||
? candidateMacPath
|
||||
: join(this.homePath, '.local', 'bin', 'orca')
|
||||
this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand
|
||||
this.userPathReader = options.userPathReader ?? readWindowsUserPathRegistry
|
||||
this.userPathMutationReader =
|
||||
options.userPathMutationReader ?? options.userPathReader ?? readFreshWindowsUserPathRegistry
|
||||
this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value))
|
||||
this.userPathCacheInvalidator =
|
||||
options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache
|
||||
this.windowsEnvironment = options.windowsEnvironment ?? process.env
|
||||
this.appImagePath =
|
||||
this.platform === 'linux' && this.isPackaged
|
||||
? (options.appImagePath ?? process.env.APPIMAGE ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
protected resolveInstallSpec(): InstallSpec | null {
|
||||
const commandPath = this.resolveCommandPath()
|
||||
if (!commandPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.platform === 'darwin' || this.platform === 'linux') {
|
||||
return {
|
||||
commandPath,
|
||||
installMethod: this.isLinuxAppImage() ? 'wrapper' : 'symlink'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
return {
|
||||
commandPath,
|
||||
installMethod: 'wrapper'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected async resolveActiveInstallSpec(
|
||||
defaultSpec: InstallSpec,
|
||||
launcherPath: string
|
||||
): Promise<InstallSpec> {
|
||||
if (
|
||||
this.commandPathOverride ||
|
||||
this.platform !== 'darwin' ||
|
||||
defaultSpec.installMethod !== 'symlink'
|
||||
) {
|
||||
return defaultSpec
|
||||
}
|
||||
|
||||
const activeCommandPath = await this.findActivePathCommand(
|
||||
launcherPath,
|
||||
defaultSpec.commandPath
|
||||
)
|
||||
return activeCommandPath
|
||||
? {
|
||||
commandPath: activeCommandPath,
|
||||
installMethod: defaultSpec.installMethod
|
||||
}
|
||||
: defaultSpec
|
||||
}
|
||||
|
||||
protected async findActivePathCommand(
|
||||
launcherPath: string,
|
||||
defaultCommandPath: string
|
||||
): Promise<string | null> {
|
||||
let reachedDefaultCommandPath = false
|
||||
for (const commandPath of this.getPathCommandCandidates(defaultCommandPath)) {
|
||||
const isDefaultCommandPath = samePathEntry(this.platform, commandPath, defaultCommandPath)
|
||||
reachedDefaultCommandPath ||= isDefaultCommandPath
|
||||
|
||||
if (!(await isExecutableFile(commandPath))) {
|
||||
continue
|
||||
}
|
||||
|
||||
const status = await this.inspectSymlink(commandPath, launcherPath)
|
||||
if (status.state !== 'not_installed') {
|
||||
if (reachedDefaultCommandPath && !isDefaultCommandPath && status.state === 'conflict') {
|
||||
// Why: a non-Orca command after an empty default slot can be shadowed by installing there; no user file replaced.
|
||||
continue
|
||||
}
|
||||
// Why: PATH lookup is first-match-wins; return the command the shell will actually run, preserving shadowing conflicts.
|
||||
return commandPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected getPathCommandCandidates(defaultCommandPath: string): string[] {
|
||||
const commandName = basename(defaultCommandPath)
|
||||
const pathCandidates = splitPathEntries(this.platform, this.processPathEnv ?? '').map((entry) =>
|
||||
join(entry, commandName)
|
||||
)
|
||||
return uniquePathEntries(this.platform, pathCandidates)
|
||||
}
|
||||
|
||||
protected resolveCommandPath(): string | null {
|
||||
if (this.commandPathOverride) {
|
||||
return this.commandPathOverride
|
||||
}
|
||||
|
||||
if (!this.isPackaged) {
|
||||
// Why: dev uses a separate command; tests/diagnostics still reach production paths via commandPathOverride.
|
||||
if (this.platform === 'darwin') {
|
||||
return `/usr/local/bin/${DEV_COMMAND_NAME}`
|
||||
}
|
||||
if (this.platform === 'linux') {
|
||||
return join(this.homePath, '.local', 'bin', DEV_COMMAND_NAME)
|
||||
}
|
||||
if (this.platform === 'win32') {
|
||||
return join(this.localAppDataPath, 'Programs', 'Orca Dev', 'bin', `${DEV_COMMAND_NAME}.cmd`)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.platform === 'darwin') {
|
||||
return this.macCommandPath
|
||||
}
|
||||
|
||||
if (this.platform === 'linux') {
|
||||
// Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir.
|
||||
// Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader.
|
||||
return join(this.homePath, '.local', 'bin', LINUX_CLI_COMMAND_NAME)
|
||||
}
|
||||
|
||||
if (this.platform === 'win32') {
|
||||
// Why: NSIS /D installs can live outside LOCALAPPDATA, so use the packaged resources dir as authoritative.
|
||||
return getBundledLauncherPath(this.platform, this.resourcesPath)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
protected async resolveLauncherPath(): Promise<string | null> {
|
||||
if (!['darwin', 'linux', 'win32'].includes(this.platform)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.isLinuxAppImage()) {
|
||||
return this.appImagePath && existsSync(this.appImagePath) ? this.appImagePath : null
|
||||
}
|
||||
|
||||
if (this.isPackaged) {
|
||||
const bundledPath = getBundledLauncherPath(this.platform, this.resourcesPath)
|
||||
return bundledPath && existsSync(bundledPath) ? bundledPath : null
|
||||
}
|
||||
|
||||
return ensureDevLauncher({
|
||||
platform: this.platform,
|
||||
userDataPath: this.userDataPath,
|
||||
execPath: this.execPathValue,
|
||||
cliEntryPath: join(this.appPathValue, 'out', 'cli', 'index.js'),
|
||||
commandName: this.commandName
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, relative } from 'node:path'
|
||||
import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion'
|
||||
|
||||
export function splitPathEntries(platform: NodeJS.Platform, value: string | null): string[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.split(platform === 'win32' ? ';' : ':')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function uniquePathEntries(platform: NodeJS.Platform, entries: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const entry of entries) {
|
||||
const key = platform === 'win32' ? normalizeWindowsPath(entry) : entry
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
result.push(entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function samePathEntry(
|
||||
platform: NodeJS.Platform,
|
||||
left: string,
|
||||
right: string,
|
||||
windowsEnvironment: NodeJS.ProcessEnv = process.env,
|
||||
expandWindowsVariables = true
|
||||
): boolean {
|
||||
return platform === 'win32'
|
||||
? normalizeWindowsPath(left, windowsEnvironment, expandWindowsVariables) ===
|
||||
normalizeWindowsPath(right, windowsEnvironment, expandWindowsVariables)
|
||||
: left === right
|
||||
}
|
||||
|
||||
export function isPathInsideOrEqual(parentPath: string, childPath: string): boolean {
|
||||
const childRelative = relative(parentPath, childPath)
|
||||
return childRelative === '' || (!childRelative.startsWith('..') && !isAbsolute(childRelative))
|
||||
}
|
||||
|
||||
export async function isExecutableFile(commandPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await stat(commandPath)
|
||||
if (!stats.isFile()) {
|
||||
return false
|
||||
}
|
||||
await access(commandPath, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWindowsPath(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
expandEnvironmentVariables = true
|
||||
): string {
|
||||
return (expandEnvironmentVariables ? expandWindowsEnvironmentVariables(value, env) : value)
|
||||
.replaceAll('/', '\\')
|
||||
.replace(/\\+$/, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function escapeWindowsBatchValue(value: string): string {
|
||||
return value.replaceAll('"', '""')
|
||||
}
|
||||
|
||||
export function quoteShell(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`
|
||||
}
|
||||
|
||||
export function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolean {
|
||||
if (platform === 'win32') {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
||||
}
|
||||
return isAbsolute(value)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CliInstallMethod } from '../../shared/cli-install-types'
|
||||
import type { WindowsUserPathReadResult } from './windows-user-path-registry'
|
||||
|
||||
export type CliInstallerOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
isPackaged?: boolean
|
||||
userDataPath?: string
|
||||
resourcesPath?: string
|
||||
execPath?: string
|
||||
appPath?: string
|
||||
homePath?: string
|
||||
localAppDataPath?: string
|
||||
processPathEnv?: string | null
|
||||
commandPathOverride?: string | null
|
||||
/** Feeds into the /usr/local/bin existence check at construction time; used in tests to simulate absent /usr/local/bin on arm64 without relying on real filesystem state. */
|
||||
defaultMacCommandPath?: string
|
||||
privilegedRunner?: (command: string) => Promise<void>
|
||||
userPathReader?: () => Promise<WindowsUserPathReadResult>
|
||||
userPathMutationReader?: () => Promise<WindowsUserPathReadResult>
|
||||
userPathWriter?: (value: string) => Promise<void>
|
||||
userPathCacheInvalidator?: () => void
|
||||
windowsEnvironment?: NodeJS.ProcessEnv
|
||||
/** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */
|
||||
appImagePath?: string | null
|
||||
}
|
||||
|
||||
export type InstallSpec = {
|
||||
commandPath: string
|
||||
installMethod: CliInstallMethod
|
||||
}
|
||||
+6
-1080
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { CliCommandInstallation } from './cli-command-installation'
|
||||
import { isWindowsUserPathPermissionError } from './cli-install-errors'
|
||||
import { samePathEntry, splitPathEntries } from './cli-install-path-format'
|
||||
|
||||
export class CliPathRegistration extends CliCommandInstallation {
|
||||
protected async probePathConfiguration(
|
||||
pathDirectory: string
|
||||
): Promise<{ configured: boolean | null; detail: string | null }> {
|
||||
if (this.platform !== 'win32') {
|
||||
return {
|
||||
configured: splitPathEntries(this.platform, this.processPathEnv ?? '').some((entry) =>
|
||||
samePathEntry(this.platform, entry, pathDirectory)
|
||||
),
|
||||
detail: null
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.userPathReader()
|
||||
if (result.state === 'unknown') {
|
||||
return { configured: null, detail: result.detail }
|
||||
}
|
||||
return {
|
||||
configured: splitPathEntries('win32', result.value).some((entry) =>
|
||||
samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, result.expandable)
|
||||
),
|
||||
detail: null
|
||||
}
|
||||
}
|
||||
|
||||
protected withPathInfo(
|
||||
status: CliInstallStatus,
|
||||
pathDirectory: string,
|
||||
pathProbe: { configured: boolean | null; detail: string | null }
|
||||
): CliInstallStatus {
|
||||
const { configured: pathConfigured } = pathProbe
|
||||
if (
|
||||
this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath) &&
|
||||
status.state === 'installed' &&
|
||||
pathConfigured === false
|
||||
) {
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured,
|
||||
state: 'not_installed',
|
||||
currentTarget: null,
|
||||
detail: `Register ${status.commandPath} to use Orca from Command Prompt or PowerShell.`
|
||||
}
|
||||
}
|
||||
|
||||
if (pathConfigured === null) {
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured,
|
||||
detail:
|
||||
pathProbe.detail ??
|
||||
'The Orca launcher exists, but Orca could not check your Windows user PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
if (status.state !== 'installed') {
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
|
||||
if (pathConfigured) {
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...status,
|
||||
pathDirectory,
|
||||
pathConfigured,
|
||||
detail:
|
||||
this.platform === 'linux'
|
||||
? `${status.commandPath} is registered, but ${pathDirectory} is not on PATH for this shell.`
|
||||
: `${status.commandPath} is registered. Restart your shell if the command is not visible yet.`
|
||||
}
|
||||
}
|
||||
|
||||
protected async ensureWindowsPathEntry(pathDirectory: string): Promise<void> {
|
||||
const current = await this.readWindowsUserPathForMutation()
|
||||
const entries = splitPathEntries('win32', current.value)
|
||||
if (
|
||||
entries.some((entry) =>
|
||||
samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, current.expandable)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
entries.push(pathDirectory)
|
||||
await this.writeWindowsUserPathEntry(entries.join(';'), pathDirectory, 'add')
|
||||
}
|
||||
|
||||
protected async removeWindowsPathEntry(pathDirectory: string): Promise<void> {
|
||||
if (this.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const current = await this.readWindowsUserPathForMutation()
|
||||
const entries = splitPathEntries('win32', current.value)
|
||||
const nextEntries = entries.filter(
|
||||
(entry) =>
|
||||
!samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, current.expandable)
|
||||
)
|
||||
if (nextEntries.length === entries.length) {
|
||||
return
|
||||
}
|
||||
await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove')
|
||||
}
|
||||
|
||||
protected async readWindowsUserPathForMutation(): Promise<{
|
||||
value: string | null
|
||||
expandable: boolean
|
||||
}> {
|
||||
const result = await this.userPathMutationReader()
|
||||
if (result.state === 'success') {
|
||||
return { value: result.value, expandable: result.expandable }
|
||||
}
|
||||
// Why: PATH is read-modify-write; continuing after a failed read could clobber the user's PATH with a partial value.
|
||||
throw new Error(`${result.detail} No PATH changes were made.`)
|
||||
}
|
||||
|
||||
// Why: raw PowerShell errors reach the UI, so translate denied PATH writes (keeping the original as cause).
|
||||
protected async writeWindowsUserPathEntry(
|
||||
value: string,
|
||||
pathDirectory: string,
|
||||
action: 'add' | 'remove'
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.userPathWriter(value)
|
||||
this.userPathCacheInvalidator()
|
||||
} catch (error) {
|
||||
if (!isWindowsUserPathPermissionError(error)) {
|
||||
throw error
|
||||
}
|
||||
const guidance =
|
||||
action === 'add'
|
||||
? `Add this folder to your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.`
|
||||
: `Remove this folder from your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.`
|
||||
throw new Error(
|
||||
`Windows blocked updating your user PATH (access denied). This usually means your PATH environment variable is managed by Group Policy or your organization's device management. ${guidance}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const runProcessMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
import { runMacPrivilegedCommand, runWindowsPathCommand } from './cli-privileged-processes'
|
||||
|
||||
describe('Windows CLI PATH process boundary', () => {
|
||||
beforeEach(() => runProcessMock.mockReset())
|
||||
|
||||
it('uses the canonical process wrapper with the bounded timeout', async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await expect(runWindowsPathCommand(['-NoProfile'])).resolves.toBe('ok')
|
||||
expect(runProcessMock).toHaveBeenCalledWith({
|
||||
program: 'powershell',
|
||||
args: ['-NoProfile'],
|
||||
timeoutMs: 5_000
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the timeout error presented by CLI registration', async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: null,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: true
|
||||
})
|
||||
|
||||
await expect(runWindowsPathCommand([])).rejects.toThrow(
|
||||
'Windows PATH command timed out after 5000ms.'
|
||||
)
|
||||
})
|
||||
|
||||
it('retains stderr for permission classification', async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'UnauthorizedAccessException',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await expect(runWindowsPathCommand([])).rejects.toMatchObject({
|
||||
code: 1,
|
||||
message: 'UnauthorizedAccessException',
|
||||
stderr: 'UnauthorizedAccessException'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('macOS CLI privileged process boundary', () => {
|
||||
beforeEach(() => runProcessMock.mockReset())
|
||||
|
||||
it('uses the canonical process wrapper without a shell', async () => {
|
||||
runProcessMock.mockResolvedValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await runMacPrivilegedCommand("ln -s 'source' 'target'")
|
||||
|
||||
expect(runProcessMock).toHaveBeenCalledWith({
|
||||
program: 'osascript',
|
||||
args: ['-e', "do shell script \"ln -s 'source' 'target'\" with administrator privileges"],
|
||||
timeoutMs: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { WINDOWS_PATH_WRITE_TIMEOUT_MS } from './cli-install-constants'
|
||||
|
||||
export async function runMacPrivilegedCommand(command: string): Promise<void> {
|
||||
const result = await runProcess({
|
||||
program: 'osascript',
|
||||
args: ['-e', `do shell script ${quoteAppleScript(command)} with administrator privileges`],
|
||||
// Why: the OS authorization prompt is user-paced and previously had no deadline.
|
||||
timeoutMs: null
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw processFailure('osascript', result)
|
||||
}
|
||||
}
|
||||
|
||||
function quoteAppleScript(value: string): string {
|
||||
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
export async function writeWindowsUserPath(value: string): Promise<void> {
|
||||
await runWindowsPathCommand([
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')`
|
||||
])
|
||||
}
|
||||
|
||||
export async function runWindowsPathCommand(args: string[]): Promise<string> {
|
||||
const result = await runProcess({
|
||||
program: 'powershell',
|
||||
args,
|
||||
timeoutMs: WINDOWS_PATH_WRITE_TIMEOUT_MS
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`Windows PATH command timed out after ${WINDOWS_PATH_WRITE_TIMEOUT_MS}ms.`)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw processFailure('powershell', result)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function processFailure(
|
||||
program: string,
|
||||
result: { code: number | null; stderr: string; stdout: string }
|
||||
): Error {
|
||||
const detail = result.stderr || result.stdout
|
||||
const error = new Error(detail || `${program} exited with code ${result.code ?? 'unknown'}`)
|
||||
Object.assign(error, { code: result.code, stderr: result.stderr })
|
||||
return error
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: model download, checksum, retry, and cleanup share one state machine so progress/error transitions stay coupled. */
|
||||
import { app, net } from 'electron'
|
||||
import { join, resolve, relative } from 'node:path'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
createWriteStream,
|
||||
createReadStream,
|
||||
rmSync,
|
||||
statSync
|
||||
} from 'node:fs'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
|
||||
import { rename, rm } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import type {
|
||||
SpeechModelManifest,
|
||||
SpeechModelState,
|
||||
@@ -24,133 +14,19 @@ import {
|
||||
migrateSpeechModelCacheIfNeeded,
|
||||
type SpeechModelCacheDir
|
||||
} from './model-cache-path'
|
||||
import { SpeechModelDownloadTransport } from './speech-model-download-transport'
|
||||
import {
|
||||
removeModelDownloadFiles,
|
||||
removeModelDownloadStaging
|
||||
} from './speech-model-download-cleanup'
|
||||
|
||||
type DownloadHandle = {
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
type ProgressCallback = (modelId: string, progress: number) => void
|
||||
type DownloadIncomingMessage = Electron.IncomingMessage &
|
||||
NodeJS.ReadableStream & {
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
destroy?: () => void
|
||||
}
|
||||
type HttpStatusError = Error & {
|
||||
httpStatusCode?: number
|
||||
retryAfterMs?: number
|
||||
retryable?: boolean
|
||||
}
|
||||
type DownloadTotals = {
|
||||
totalBytes: number
|
||||
completedBytes: number
|
||||
modelTotalBytes: number
|
||||
}
|
||||
type ContentRange = { start: number; end: number; totalBytes?: number }
|
||||
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
|
||||
// Why: flaky networks/proxies often kill long CDN transfers near the end; Range-resume lets them finish.
|
||||
const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
// Why: count only CONSECUTIVE no-progress attempts, so a download still advancing across drops is never abandoned.
|
||||
const MAX_NO_PROGRESS_ATTEMPTS = DOWNLOAD_RETRY_DELAYS_MS.length + 1
|
||||
// Why: absolute backstop against a tiny-segment server; 4096 covers the ~1GB model even at a proxy's ~256KB min range.
|
||||
const MAX_TOTAL_DOWNLOAD_REQUESTS = 4_096
|
||||
// Why: cap honored Retry-After; a longer server window is surfaced for manual retry, not a multi-minute stall.
|
||||
const MAX_RETRY_AFTER_MS = 120_000
|
||||
const RETRYABLE_NET_ERROR =
|
||||
/net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/
|
||||
const RETRYABLE_HTTP_STATUSES = new Set([408, 416, 425, 429, 500, 502, 503, 504])
|
||||
|
||||
function isRetryableDownloadError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const downloadError = error as HttpStatusError
|
||||
if (downloadError.retryable === true) {
|
||||
return true
|
||||
}
|
||||
const statusCode = downloadError.httpStatusCode
|
||||
if (statusCode !== undefined) {
|
||||
return RETRYABLE_HTTP_STATUSES.has(statusCode)
|
||||
}
|
||||
return (
|
||||
RETRYABLE_NET_ERROR.test(error.message) || error.message.includes('without network activity')
|
||||
)
|
||||
}
|
||||
|
||||
function getHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
function parseContentRange(value: string | string[] | undefined): ContentRange | null {
|
||||
const match = getHeaderValue(value)
|
||||
?.trim()
|
||||
.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const start = Number.parseInt(match[1], 10)
|
||||
const end = Number.parseInt(match[2], 10)
|
||||
const totalBytes = match[3] === '*' ? undefined : Number.parseInt(match[3], 10)
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
end < start ||
|
||||
(totalBytes !== undefined && (!Number.isSafeInteger(totalBytes) || totalBytes <= end))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { start, end, totalBytes }
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value: string | string[] | undefined): number | undefined {
|
||||
const header = getHeaderValue(value)?.trim()
|
||||
if (!header) {
|
||||
return undefined
|
||||
}
|
||||
if (/^\d+$/.test(header)) {
|
||||
const seconds = Number.parseInt(header, 10)
|
||||
const delayMs = seconds * 1_000
|
||||
return Number.isSafeInteger(delayMs) ? delayMs : undefined
|
||||
}
|
||||
const retryAt = Date.parse(header)
|
||||
return Number.isNaN(retryAt) ? undefined : Math.max(0, retryAt - Date.now())
|
||||
}
|
||||
|
||||
function describeInterruptedDownload(
|
||||
cause: unknown,
|
||||
receivedBytes: number,
|
||||
totalBytes: number,
|
||||
attempts: number
|
||||
): Error {
|
||||
const causeMessage = cause instanceof Error ? cause.message : String(cause)
|
||||
const received =
|
||||
totalBytes > 0
|
||||
? `${Math.min(99, Math.floor((receivedBytes / totalBytes) * 100))}% (${receivedBytes} of ${totalBytes} bytes)`
|
||||
: `${receivedBytes} bytes`
|
||||
return new Error(
|
||||
`Model download interrupted at ${received} after ${attempts} attempts: ${causeMessage}`
|
||||
)
|
||||
}
|
||||
|
||||
function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export class ModelManager {
|
||||
export class ModelManager extends SpeechModelDownloadTransport {
|
||||
private modelsDir: string
|
||||
private migrationSourceDir: string | null
|
||||
private migrationReady: Promise<void>
|
||||
@@ -159,6 +35,7 @@ export class ModelManager {
|
||||
private progressCallbacks = new Set<ProgressCallback>()
|
||||
|
||||
constructor(customModelsDir?: string) {
|
||||
super()
|
||||
const requestedModelsDir = customModelsDir || join(app.getPath('userData'), 'speech-models')
|
||||
const prepared = this.prepareModelsDir(requestedModelsDir)
|
||||
this.modelsDir = prepared.modelsDir
|
||||
@@ -336,14 +213,14 @@ export class ModelManager {
|
||||
console.error('[speech] Model download failed:', modelId, err)
|
||||
this.updateState(modelId, 'error', undefined, String(err))
|
||||
}
|
||||
this.removeModelDownloadFiles(modelDir, stagingDir, legacyArchivePath)
|
||||
removeModelDownloadFiles(modelDir, stagingDir, legacyArchivePath)
|
||||
if (!aborted) {
|
||||
// Why: the settings UI awaits this to surface failures; stay quiet on cancellation, rethrow real errors.
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
this.activeDownloads.delete(modelId)
|
||||
this.removeModelDownloadStaging(stagingDir, legacyArchivePath)
|
||||
removeModelDownloadStaging(stagingDir, legacyArchivePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,426 +332,7 @@ export class ModelManager {
|
||||
}
|
||||
}
|
||||
|
||||
private getPartialDownloadBytes(filePath: string): number {
|
||||
try {
|
||||
return statSync(filePath).size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFileWithRetry(
|
||||
url: string,
|
||||
filePath: string,
|
||||
expectedSize: number,
|
||||
modelId: string,
|
||||
isAborted: () => boolean,
|
||||
signal: AbortSignal,
|
||||
completedBytes = 0,
|
||||
modelTotalBytes = expectedSize
|
||||
): Promise<void> {
|
||||
let requestCount = 0
|
||||
let noProgressStreak = 0
|
||||
const totals: DownloadTotals = { totalBytes: expectedSize, completedBytes, modelTotalBytes }
|
||||
for (;;) {
|
||||
requestCount += 1
|
||||
const offset = this.getPartialDownloadBytes(filePath)
|
||||
// Why: transport can fail after the last byte hits disk; the SHA-256 check is the real completion test.
|
||||
if (offset === totals.totalBytes) {
|
||||
return
|
||||
}
|
||||
// Why: absolute backstop against a server that never lets the download finish.
|
||||
if (requestCount > MAX_TOTAL_DOWNLOAD_REQUESTS) {
|
||||
throw describeInterruptedDownload(
|
||||
new Error('too many download requests'),
|
||||
offset,
|
||||
totals.totalBytes,
|
||||
requestCount - 1
|
||||
)
|
||||
}
|
||||
try {
|
||||
// Why: restart from the canonical URL, not the last redirect, because signed CDN redirect URLs expire.
|
||||
await this.downloadFile(
|
||||
url,
|
||||
filePath,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
signal,
|
||||
0,
|
||||
offset,
|
||||
totals
|
||||
)
|
||||
const receivedBytes = this.getPartialDownloadBytes(filePath)
|
||||
if (receivedBytes === totals.totalBytes) {
|
||||
return
|
||||
}
|
||||
if (receivedBytes > totals.totalBytes) {
|
||||
throw new Error(
|
||||
`Model download exceeded its expected size (${receivedBytes} of ${totals.totalBytes} bytes)`
|
||||
)
|
||||
}
|
||||
const incompleteResponse = new Error(
|
||||
`Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes`
|
||||
)
|
||||
if (receivedBytes > offset) {
|
||||
// Why: some proxies cap each range segment; request the next immediately and reset the stall counter.
|
||||
noProgressStreak = 0
|
||||
continue
|
||||
}
|
||||
const retryableIncompleteResponse = incompleteResponse as HttpStatusError
|
||||
retryableIncompleteResponse.retryable = true
|
||||
throw retryableIncompleteResponse
|
||||
} catch (err) {
|
||||
if (isAborted() || signal.aborted) {
|
||||
throw err
|
||||
}
|
||||
const receivedBytes = this.getPartialDownloadBytes(filePath)
|
||||
if (receivedBytes === totals.totalBytes) {
|
||||
return
|
||||
}
|
||||
noProgressStreak = receivedBytes > offset ? 0 : noProgressStreak + 1
|
||||
if (!isRetryableDownloadError(err)) {
|
||||
throw err
|
||||
}
|
||||
// Why: give up only on a genuine stall; a download still advancing across drops keeps going.
|
||||
if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) {
|
||||
throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount)
|
||||
}
|
||||
const retryAfterMs = (err as HttpStatusError).retryAfterMs
|
||||
if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
|
||||
const statusCode = (err as HttpStatusError).httpStatusCode
|
||||
throw new Error(
|
||||
`HTTP ${statusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds`
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
`[speech] Model download attempt ${requestCount} failed, retrying:`,
|
||||
modelId,
|
||||
err
|
||||
)
|
||||
await sleepUnlessAborted(
|
||||
retryAfterMs ??
|
||||
DOWNLOAD_RETRY_DELAYS_MS[
|
||||
Math.min(Math.max(0, noProgressStreak - 1), DOWNLOAD_RETRY_DELAYS_MS.length - 1)
|
||||
],
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private downloadFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
expectedSize: number,
|
||||
modelId: string,
|
||||
isAborted: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
redirectCount = 0,
|
||||
resumeOffset = 0,
|
||||
totals?: DownloadTotals
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error('Aborted'))
|
||||
return
|
||||
}
|
||||
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
reject(new Error('Invalid download URL'))
|
||||
return
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
reject(new Error('Model downloads must use HTTPS'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let request: Electron.ClientRequest | null = null
|
||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const onSignalAbort = (): void => {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Aborted'))
|
||||
activeRequest?.abort()
|
||||
}
|
||||
const clearIdleTimeout = (): void => {
|
||||
if (idleTimeout) {
|
||||
clearTimeout(idleTimeout)
|
||||
idleTimeout = null
|
||||
}
|
||||
}
|
||||
const cleanupRequestListeners = (): void => {
|
||||
const activeRequest = request
|
||||
clearIdleTimeout()
|
||||
if (!activeRequest) {
|
||||
return
|
||||
}
|
||||
activeRequest.off('error', onRequestError)
|
||||
activeRequest.off('response', onResponse)
|
||||
activeRequest.off('redirect', onRedirect)
|
||||
signal?.removeEventListener('abort', onSignalAbort)
|
||||
request = null
|
||||
}
|
||||
const resetIdleTimeout = (): void => {
|
||||
clearIdleTimeout()
|
||||
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||
}
|
||||
const resolveOnce = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanupRequestListeners()
|
||||
resolve()
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanupRequestListeners()
|
||||
reject(error)
|
||||
}
|
||||
const onRequestError = (error: Error): void => rejectOnce(error)
|
||||
const onRequestTimeout = (): void => {
|
||||
const activeRequest = request
|
||||
rejectOnce(
|
||||
new Error(
|
||||
`Model download timed out after ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds without network activity`
|
||||
)
|
||||
)
|
||||
activeRequest?.abort()
|
||||
}
|
||||
const onRedirect = (_statusCode: number, _method: string, redirectUrl: string): void => {
|
||||
if (redirectCount >= 5) {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Too many redirects'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
let resolvedRedirect: URL
|
||||
try {
|
||||
resolvedRedirect = new URL(redirectUrl, parsedUrl)
|
||||
} catch {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Invalid redirect URL'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
if (resolvedRedirect.protocol !== 'https:') {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Model download redirect must use HTTPS'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
const activeRequest = request
|
||||
cleanupRequestListeners()
|
||||
activeRequest?.abort()
|
||||
this.downloadFile(
|
||||
resolvedRedirect.toString(),
|
||||
dest,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
signal,
|
||||
redirectCount + 1,
|
||||
resumeOffset,
|
||||
totals
|
||||
)
|
||||
.then(resolveOnce)
|
||||
.catch(rejectOnce)
|
||||
}
|
||||
const onResponse = (incoming: Electron.IncomingMessage): void => {
|
||||
const response = incoming as DownloadIncomingMessage
|
||||
const contentLength = response.headers['content-length']
|
||||
const headerLength = Number.parseInt(getHeaderValue(contentLength) || '0', 10)
|
||||
const parsedLength =
|
||||
Number.isSafeInteger(headerLength) && headerLength > 0 ? headerLength : 0
|
||||
const contentRange = parseContentRange(response.headers['content-range'])
|
||||
const resumed =
|
||||
resumeOffset > 0 &&
|
||||
response.statusCode === 206 &&
|
||||
contentRange?.start === resumeOffset &&
|
||||
(parsedLength <= 0 || parsedLength === contentRange.end - contentRange.start + 1)
|
||||
|
||||
if (resumeOffset > 0 && response.statusCode === 206 && !resumed) {
|
||||
// Why: appending an unverified range can silently corrupt the file; discard and retry from byte zero.
|
||||
try {
|
||||
rmSync(dest)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
const activeRequest = request
|
||||
const rangeError: HttpStatusError = new Error(
|
||||
`Invalid Content-Range for resume at byte ${resumeOffset}`
|
||||
)
|
||||
rangeError.retryable = true
|
||||
rejectOnce(rangeError)
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200 && !resumed) {
|
||||
if (response.statusCode === 416) {
|
||||
// Why: 416 means the server rejected our resume offset; drop the partial to restart from scratch.
|
||||
try {
|
||||
rmSync(dest)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
const activeRequest = request
|
||||
const statusError: HttpStatusError = new Error(`HTTP ${response.statusCode}`)
|
||||
statusError.httpStatusCode = response.statusCode
|
||||
statusError.retryAfterMs = parseRetryAfterMs(response.headers['retry-after'])
|
||||
rejectOnce(statusError)
|
||||
// Why: abort so a retry doesn't leave the error-response body draining unowned.
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Why: a 200 to our Range request means the server restarted from byte zero, so overwrite the partial.
|
||||
const progressBase = resumed ? resumeOffset : 0
|
||||
// Why: Content-Length on a 206 is only this segment; on Content-Range '*' keep the known full size.
|
||||
const totalSize = resumed
|
||||
? (contentRange?.totalBytes ?? totals?.totalBytes ?? expectedSize)
|
||||
: parsedLength > 0
|
||||
? parsedLength
|
||||
: expectedSize
|
||||
if (totals) {
|
||||
totals.totalBytes = totalSize
|
||||
}
|
||||
let downloaded = 0
|
||||
|
||||
const fileStream = createWriteStream(dest, { flags: resumed ? 'a' : 'w' })
|
||||
|
||||
const cleanupResponseProgressListener = (): void => {
|
||||
response.off('data', onResponseData)
|
||||
}
|
||||
const onResponseData = (chunk: Buffer): void => {
|
||||
resetIdleTimeout()
|
||||
if (isAborted()) {
|
||||
request?.abort()
|
||||
response.destroy?.()
|
||||
fileStream.destroy()
|
||||
return
|
||||
}
|
||||
downloaded += chunk.length
|
||||
const progress = Math.min(
|
||||
0.9,
|
||||
((totals?.completedBytes ?? 0) + progressBase + downloaded) /
|
||||
(totals?.modelTotalBytes ?? totalSize)
|
||||
)
|
||||
this.updateState(modelId, 'downloading', progress)
|
||||
}
|
||||
|
||||
response.on('data', onResponseData)
|
||||
pipeline(response, fileStream)
|
||||
.then(() => {
|
||||
cleanupResponseProgressListener()
|
||||
if (isAborted()) {
|
||||
rejectOnce(new Error('Aborted'))
|
||||
} else {
|
||||
resolveOnce()
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
cleanupResponseProgressListener()
|
||||
rejectOnce(error)
|
||||
})
|
||||
}
|
||||
|
||||
request = net.request({ method: 'GET', url: parsedUrl.toString() })
|
||||
if (resumeOffset > 0) {
|
||||
request.setHeader('Range', `bytes=${resumeOffset}-`)
|
||||
}
|
||||
|
||||
// Why: Electron net honors app proxy settings (unlike Node https) but exposes no setTimeout, so time out manually.
|
||||
resetIdleTimeout()
|
||||
request.on('error', onRequestError)
|
||||
request.on('response', onResponse)
|
||||
request.on('redirect', onRedirect)
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', onSignalAbort, { once: true })
|
||||
}
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
private verifyFileSha256(filePath: string, expectedSha256: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(filePath)
|
||||
let settled = false
|
||||
|
||||
const cleanup = (): void => {
|
||||
stream.off('data', onData)
|
||||
stream.off('error', onError)
|
||||
stream.off('end', onEnd)
|
||||
}
|
||||
const settleResolve = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const settleReject = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
hash.update(chunk)
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
settleReject(error)
|
||||
}
|
||||
const onEnd = (): void => {
|
||||
const actualSha256 = hash.digest('hex')
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
// Why: model artifacts feed native runtimes, so verify every downloaded file before installation.
|
||||
settleReject(new Error('Downloaded model file failed integrity verification'))
|
||||
return
|
||||
}
|
||||
settleResolve()
|
||||
}
|
||||
|
||||
stream.on('data', onData)
|
||||
stream.on('error', onError)
|
||||
stream.on('end', onEnd)
|
||||
})
|
||||
}
|
||||
|
||||
private removeModelDownloadStaging(stagingDir: string, legacyArchivePath: string): void {
|
||||
for (const path of [stagingDir, legacyArchivePath]) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeModelDownloadFiles(
|
||||
modelDir: string,
|
||||
stagingDir: string,
|
||||
legacyArchivePath: string
|
||||
): void {
|
||||
this.removeModelDownloadStaging(stagingDir, legacyArchivePath)
|
||||
try {
|
||||
rmSync(modelDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
protected reportDownloadProgress(modelId: string, progress: number): void {
|
||||
this.updateState(modelId, 'downloading', progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
|
||||
export function removeModelDownloadStaging(stagingDir: string, legacyArchivePath: string): void {
|
||||
for (const path of [stagingDir, legacyArchivePath]) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeModelDownloadFiles(
|
||||
modelDir: string,
|
||||
stagingDir: string,
|
||||
legacyArchivePath: string
|
||||
): void {
|
||||
removeModelDownloadStaging(stagingDir, legacyArchivePath)
|
||||
try {
|
||||
rmSync(modelDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isRetryableDownloadError,
|
||||
parseContentRange,
|
||||
parseRetryAfterMs
|
||||
} from './speech-model-download-response'
|
||||
|
||||
describe('speech model download response contracts', () => {
|
||||
it('accepts only internally consistent byte ranges', () => {
|
||||
expect(parseContentRange('bytes 10-19/20')).toEqual({ start: 10, end: 19, totalBytes: 20 })
|
||||
expect(parseContentRange('bytes 10-20/20')).toBeNull()
|
||||
expect(parseContentRange('bytes 20-10/21')).toBeNull()
|
||||
expect(parseContentRange('not-a-range')).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds numeric retry delays to safe integers', () => {
|
||||
expect(parseRetryAfterMs('12')).toBe(12_000)
|
||||
expect(parseRetryAfterMs(String(Number.MAX_SAFE_INTEGER))).toBeUndefined()
|
||||
expect(parseRetryAfterMs('invalid')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retries only allowlisted HTTP and network failures', () => {
|
||||
expect(
|
||||
isRetryableDownloadError(Object.assign(new Error('HTTP 503'), { httpStatusCode: 503 }))
|
||||
).toBe(true)
|
||||
expect(isRetryableDownloadError(new Error('net::ERR_CONNECTION_RESET'))).toBe(true)
|
||||
expect(isRetryableDownloadError(new Error('certificate rejected'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
export type DownloadIncomingMessage = Electron.IncomingMessage &
|
||||
NodeJS.ReadableStream & {
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
destroy?: () => void
|
||||
}
|
||||
export type HttpStatusError = Error & {
|
||||
httpStatusCode?: number
|
||||
retryAfterMs?: number
|
||||
retryable?: boolean
|
||||
}
|
||||
export type DownloadTotals = {
|
||||
totalBytes: number
|
||||
completedBytes: number
|
||||
modelTotalBytes: number
|
||||
}
|
||||
export type ContentRange = { start: number; end: number; totalBytes?: number }
|
||||
|
||||
export const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
|
||||
// Why: flaky networks/proxies often kill long CDN transfers near the end; Range-resume lets them finish.
|
||||
export const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
// Why: count only CONSECUTIVE no-progress attempts, so a download still advancing across drops is never abandoned.
|
||||
export const MAX_NO_PROGRESS_ATTEMPTS = DOWNLOAD_RETRY_DELAYS_MS.length + 1
|
||||
// Why: absolute backstop against a tiny-segment server; 4096 covers the ~1GB model even at a proxy's ~256KB min range.
|
||||
export const MAX_TOTAL_DOWNLOAD_REQUESTS = 4_096
|
||||
// Why: cap honored Retry-After; a longer server window is surfaced for manual retry, not a multi-minute stall.
|
||||
export const MAX_RETRY_AFTER_MS = 120_000
|
||||
export const RETRYABLE_NET_ERROR =
|
||||
/net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/
|
||||
export const RETRYABLE_HTTP_STATUSES = new Set([408, 416, 425, 429, 500, 502, 503, 504])
|
||||
|
||||
export function isRetryableDownloadError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const downloadError = error as HttpStatusError
|
||||
if (downloadError.retryable === true) {
|
||||
return true
|
||||
}
|
||||
const statusCode = downloadError.httpStatusCode
|
||||
if (statusCode !== undefined) {
|
||||
return RETRYABLE_HTTP_STATUSES.has(statusCode)
|
||||
}
|
||||
return (
|
||||
RETRYABLE_NET_ERROR.test(error.message) || error.message.includes('without network activity')
|
||||
)
|
||||
}
|
||||
|
||||
export function getHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
}
|
||||
|
||||
export function parseContentRange(value: string | string[] | undefined): ContentRange | null {
|
||||
const match = getHeaderValue(value)
|
||||
?.trim()
|
||||
.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const start = Number.parseInt(match[1], 10)
|
||||
const end = Number.parseInt(match[2], 10)
|
||||
const totalBytes = match[3] === '*' ? undefined : Number.parseInt(match[3], 10)
|
||||
if (
|
||||
!Number.isSafeInteger(start) ||
|
||||
!Number.isSafeInteger(end) ||
|
||||
end < start ||
|
||||
(totalBytes !== undefined && (!Number.isSafeInteger(totalBytes) || totalBytes <= end))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { start, end, totalBytes }
|
||||
}
|
||||
|
||||
export function parseRetryAfterMs(value: string | string[] | undefined): number | undefined {
|
||||
const header = getHeaderValue(value)?.trim()
|
||||
if (!header) {
|
||||
return undefined
|
||||
}
|
||||
if (/^\d+$/.test(header)) {
|
||||
const seconds = Number.parseInt(header, 10)
|
||||
const delayMs = seconds * 1_000
|
||||
return Number.isSafeInteger(delayMs) ? delayMs : undefined
|
||||
}
|
||||
const retryAt = Date.parse(header)
|
||||
return Number.isNaN(retryAt) ? undefined : Math.max(0, retryAt - Date.now())
|
||||
}
|
||||
|
||||
export function describeInterruptedDownload(
|
||||
cause: unknown,
|
||||
receivedBytes: number,
|
||||
totalBytes: number,
|
||||
attempts: number
|
||||
): Error {
|
||||
const causeMessage = cause instanceof Error ? cause.message : String(cause)
|
||||
const received =
|
||||
totalBytes > 0
|
||||
? `${Math.min(99, Math.floor((receivedBytes / totalBytes) * 100))}% (${receivedBytes} of ${totalBytes} bytes)`
|
||||
: `${receivedBytes} bytes`
|
||||
return new Error(
|
||||
`Model download interrupted at ${received} after ${attempts} attempts: ${causeMessage}`
|
||||
)
|
||||
}
|
||||
|
||||
export function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, statSync } from 'node:fs'
|
||||
import {
|
||||
DOWNLOAD_RETRY_DELAYS_MS,
|
||||
MAX_NO_PROGRESS_ATTEMPTS,
|
||||
MAX_RETRY_AFTER_MS,
|
||||
MAX_TOTAL_DOWNLOAD_REQUESTS,
|
||||
describeInterruptedDownload,
|
||||
isRetryableDownloadError,
|
||||
sleepUnlessAborted,
|
||||
type DownloadTotals,
|
||||
type HttpStatusError
|
||||
} from './speech-model-download-response'
|
||||
import { SpeechModelHttpDownload } from './speech-model-http-download'
|
||||
|
||||
export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownload {
|
||||
protected getPartialDownloadBytes(filePath: string): number {
|
||||
try {
|
||||
return statSync(filePath).size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
protected async downloadFileWithRetry(
|
||||
url: string,
|
||||
filePath: string,
|
||||
expectedSize: number,
|
||||
modelId: string,
|
||||
isAborted: () => boolean,
|
||||
signal: AbortSignal,
|
||||
completedBytes = 0,
|
||||
modelTotalBytes = expectedSize
|
||||
): Promise<void> {
|
||||
let requestCount = 0
|
||||
let noProgressStreak = 0
|
||||
const totals: DownloadTotals = { totalBytes: expectedSize, completedBytes, modelTotalBytes }
|
||||
for (;;) {
|
||||
requestCount += 1
|
||||
const offset = this.getPartialDownloadBytes(filePath)
|
||||
// Why: transport can fail after the last byte hits disk; the SHA-256 check is the real completion test.
|
||||
if (offset === totals.totalBytes) {
|
||||
return
|
||||
}
|
||||
// Why: absolute backstop against a server that never lets the download finish.
|
||||
if (requestCount > MAX_TOTAL_DOWNLOAD_REQUESTS) {
|
||||
throw describeInterruptedDownload(
|
||||
new Error('too many download requests'),
|
||||
offset,
|
||||
totals.totalBytes,
|
||||
requestCount - 1
|
||||
)
|
||||
}
|
||||
try {
|
||||
// Why: restart from the canonical URL, not the last redirect, because signed CDN redirect URLs expire.
|
||||
await this.downloadFile(
|
||||
url,
|
||||
filePath,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
signal,
|
||||
0,
|
||||
offset,
|
||||
totals
|
||||
)
|
||||
const receivedBytes = this.getPartialDownloadBytes(filePath)
|
||||
if (receivedBytes === totals.totalBytes) {
|
||||
return
|
||||
}
|
||||
if (receivedBytes > totals.totalBytes) {
|
||||
throw new Error(
|
||||
`Model download exceeded its expected size (${receivedBytes} of ${totals.totalBytes} bytes)`
|
||||
)
|
||||
}
|
||||
const incompleteResponse = new Error(
|
||||
`Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes`
|
||||
)
|
||||
if (receivedBytes > offset) {
|
||||
// Why: some proxies cap each range segment; request the next immediately and reset the stall counter.
|
||||
noProgressStreak = 0
|
||||
continue
|
||||
}
|
||||
const retryableIncompleteResponse = incompleteResponse as HttpStatusError
|
||||
retryableIncompleteResponse.retryable = true
|
||||
throw retryableIncompleteResponse
|
||||
} catch (err) {
|
||||
if (isAborted() || signal.aborted) {
|
||||
throw err
|
||||
}
|
||||
const receivedBytes = this.getPartialDownloadBytes(filePath)
|
||||
if (receivedBytes === totals.totalBytes) {
|
||||
return
|
||||
}
|
||||
noProgressStreak = receivedBytes > offset ? 0 : noProgressStreak + 1
|
||||
if (!isRetryableDownloadError(err)) {
|
||||
throw err
|
||||
}
|
||||
// Why: give up only on a genuine stall; a download still advancing across drops keeps going.
|
||||
if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) {
|
||||
throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount)
|
||||
}
|
||||
const retryAfterMs = (err as HttpStatusError).retryAfterMs
|
||||
if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
|
||||
const statusCode = (err as HttpStatusError).httpStatusCode
|
||||
throw new Error(
|
||||
`HTTP ${statusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds`
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
`[speech] Model download attempt ${requestCount} failed, retrying:`,
|
||||
modelId,
|
||||
err
|
||||
)
|
||||
await sleepUnlessAborted(
|
||||
retryAfterMs ??
|
||||
DOWNLOAD_RETRY_DELAYS_MS[
|
||||
Math.min(Math.max(0, noProgressStreak - 1), DOWNLOAD_RETRY_DELAYS_MS.length - 1)
|
||||
],
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected verifyFileSha256(filePath: string, expectedSha256: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(filePath)
|
||||
let settled = false
|
||||
|
||||
const cleanup = (): void => {
|
||||
stream.off('data', onData)
|
||||
stream.off('error', onError)
|
||||
stream.off('end', onEnd)
|
||||
}
|
||||
const settleResolve = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const settleReject = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
hash.update(chunk)
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
settleReject(error)
|
||||
}
|
||||
const onEnd = (): void => {
|
||||
const actualSha256 = hash.digest('hex')
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
// Why: model artifacts feed native runtimes, so verify every downloaded file before installation.
|
||||
settleReject(new Error('Downloaded model file failed integrity verification'))
|
||||
return
|
||||
}
|
||||
settleResolve()
|
||||
}
|
||||
|
||||
stream.on('data', onData)
|
||||
stream.on('error', onError)
|
||||
stream.on('end', onEnd)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { createWriteStream, rmSync } from 'node:fs'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { net } from 'electron'
|
||||
import {
|
||||
DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||
getHeaderValue,
|
||||
parseContentRange,
|
||||
parseRetryAfterMs,
|
||||
type DownloadIncomingMessage,
|
||||
type DownloadTotals,
|
||||
type HttpStatusError
|
||||
} from './speech-model-download-response'
|
||||
|
||||
export abstract class SpeechModelHttpDownload {
|
||||
protected abstract reportDownloadProgress(modelId: string, progress: number): void
|
||||
|
||||
protected downloadFile(
|
||||
url: string,
|
||||
dest: string,
|
||||
expectedSize: number,
|
||||
modelId: string,
|
||||
isAborted: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
redirectCount = 0,
|
||||
resumeOffset = 0,
|
||||
totals?: DownloadTotals
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error('Aborted'))
|
||||
return
|
||||
}
|
||||
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
reject(new Error('Invalid download URL'))
|
||||
return
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
reject(new Error('Model downloads must use HTTPS'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let request: Electron.ClientRequest | null = null
|
||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const onSignalAbort = (): void => {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Aborted'))
|
||||
activeRequest?.abort()
|
||||
}
|
||||
const clearIdleTimeout = (): void => {
|
||||
if (idleTimeout) {
|
||||
clearTimeout(idleTimeout)
|
||||
idleTimeout = null
|
||||
}
|
||||
}
|
||||
const cleanupRequestListeners = (): void => {
|
||||
const activeRequest = request
|
||||
clearIdleTimeout()
|
||||
if (!activeRequest) {
|
||||
return
|
||||
}
|
||||
activeRequest.off('error', onRequestError)
|
||||
activeRequest.off('response', onResponse)
|
||||
activeRequest.off('redirect', onRedirect)
|
||||
signal?.removeEventListener('abort', onSignalAbort)
|
||||
request = null
|
||||
}
|
||||
const resetIdleTimeout = (): void => {
|
||||
clearIdleTimeout()
|
||||
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||
}
|
||||
const resolveOnce = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanupRequestListeners()
|
||||
resolve()
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanupRequestListeners()
|
||||
reject(error)
|
||||
}
|
||||
const onRequestError = (error: Error): void => rejectOnce(error)
|
||||
const onRequestTimeout = (): void => {
|
||||
const activeRequest = request
|
||||
rejectOnce(
|
||||
new Error(
|
||||
`Model download timed out after ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds without network activity`
|
||||
)
|
||||
)
|
||||
activeRequest?.abort()
|
||||
}
|
||||
const onRedirect = (_statusCode: number, _method: string, redirectUrl: string): void => {
|
||||
if (redirectCount >= 5) {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Too many redirects'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
let resolvedRedirect: URL
|
||||
try {
|
||||
resolvedRedirect = new URL(redirectUrl, parsedUrl)
|
||||
} catch {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Invalid redirect URL'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
if (resolvedRedirect.protocol !== 'https:') {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Model download redirect must use HTTPS'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
const activeRequest = request
|
||||
cleanupRequestListeners()
|
||||
activeRequest?.abort()
|
||||
this.downloadFile(
|
||||
resolvedRedirect.toString(),
|
||||
dest,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
signal,
|
||||
redirectCount + 1,
|
||||
resumeOffset,
|
||||
totals
|
||||
)
|
||||
.then(resolveOnce)
|
||||
.catch(rejectOnce)
|
||||
}
|
||||
const onResponse = (incoming: Electron.IncomingMessage): void => {
|
||||
const response = incoming as DownloadIncomingMessage
|
||||
const contentLength = response.headers['content-length']
|
||||
const headerLength = Number.parseInt(getHeaderValue(contentLength) || '0', 10)
|
||||
const parsedLength =
|
||||
Number.isSafeInteger(headerLength) && headerLength > 0 ? headerLength : 0
|
||||
const contentRange = parseContentRange(response.headers['content-range'])
|
||||
const resumed =
|
||||
resumeOffset > 0 &&
|
||||
response.statusCode === 206 &&
|
||||
contentRange?.start === resumeOffset &&
|
||||
(parsedLength <= 0 || parsedLength === contentRange.end - contentRange.start + 1)
|
||||
|
||||
if (resumeOffset > 0 && response.statusCode === 206 && !resumed) {
|
||||
// Why: appending an unverified range can silently corrupt the file; discard and retry from byte zero.
|
||||
try {
|
||||
rmSync(dest)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
const activeRequest = request
|
||||
const rangeError: HttpStatusError = new Error(
|
||||
`Invalid Content-Range for resume at byte ${resumeOffset}`
|
||||
)
|
||||
rangeError.retryable = true
|
||||
rejectOnce(rangeError)
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200 && !resumed) {
|
||||
if (response.statusCode === 416) {
|
||||
// Why: 416 means the server rejected our resume offset; drop the partial to restart from scratch.
|
||||
try {
|
||||
rmSync(dest)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
const activeRequest = request
|
||||
const statusError: HttpStatusError = new Error(`HTTP ${response.statusCode}`)
|
||||
statusError.httpStatusCode = response.statusCode
|
||||
statusError.retryAfterMs = parseRetryAfterMs(response.headers['retry-after'])
|
||||
rejectOnce(statusError)
|
||||
// Why: abort so a retry doesn't leave the error-response body draining unowned.
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Why: a 200 to our Range request means the server restarted from byte zero, so overwrite the partial.
|
||||
const progressBase = resumed ? resumeOffset : 0
|
||||
// Why: Content-Length on a 206 is only this segment; on Content-Range '*' keep the known full size.
|
||||
const totalSize = resumed
|
||||
? (contentRange?.totalBytes ?? totals?.totalBytes ?? expectedSize)
|
||||
: parsedLength > 0
|
||||
? parsedLength
|
||||
: expectedSize
|
||||
if (totals) {
|
||||
totals.totalBytes = totalSize
|
||||
}
|
||||
let downloaded = 0
|
||||
|
||||
const fileStream = createWriteStream(dest, { flags: resumed ? 'a' : 'w' })
|
||||
|
||||
const cleanupResponseProgressListener = (): void => {
|
||||
response.off('data', onResponseData)
|
||||
}
|
||||
const onResponseData = (chunk: Buffer): void => {
|
||||
resetIdleTimeout()
|
||||
if (isAborted()) {
|
||||
request?.abort()
|
||||
response.destroy?.()
|
||||
fileStream.destroy()
|
||||
return
|
||||
}
|
||||
downloaded += chunk.length
|
||||
const progress = Math.min(
|
||||
0.9,
|
||||
((totals?.completedBytes ?? 0) + progressBase + downloaded) /
|
||||
(totals?.modelTotalBytes ?? totalSize)
|
||||
)
|
||||
this.reportDownloadProgress(modelId, progress)
|
||||
}
|
||||
|
||||
response.on('data', onResponseData)
|
||||
pipeline(response, fileStream)
|
||||
.then(() => {
|
||||
cleanupResponseProgressListener()
|
||||
if (isAborted()) {
|
||||
rejectOnce(new Error('Aborted'))
|
||||
} else {
|
||||
resolveOnce()
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
cleanupResponseProgressListener()
|
||||
rejectOnce(error)
|
||||
})
|
||||
}
|
||||
|
||||
request = net.request({ method: 'GET', url: parsedUrl.toString() })
|
||||
if (resumeOffset > 0) {
|
||||
request.setHeader('Range', `bytes=${resumeOffset}-`)
|
||||
}
|
||||
|
||||
// Why: Electron net honors app proxy settings (unlike Node https) but exposes no setTimeout, so time out manually.
|
||||
resetIdleTimeout()
|
||||
request.on('error', onRequestError)
|
||||
request.on('response', onResponse)
|
||||
request.on('redirect', onRedirect)
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', onSignalAbort, { once: true })
|
||||
}
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
+49
-1041
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
import { ipcMain, Menu, Notification, type BrowserWindow } from 'electron'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
import type { Store } from '../persistence'
|
||||
import { resolveWindowCloseAction } from './window-close-decision'
|
||||
import type { CreateMainWindowOptions } from './main-window-contracts'
|
||||
import type { MainWindowFocusLifecycle } from './main-window-focus-lifecycle'
|
||||
import type { MainWindowStateLifecycle } from './main-window-state-lifecycle'
|
||||
import { syncTrafficLightPosition } from './main-window-visual-lifecycle'
|
||||
|
||||
export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = 10_000
|
||||
|
||||
export function installMainWindowCloseLifecycle(args: {
|
||||
focus: MainWindowFocusLifecycle
|
||||
mainWindow: BrowserWindow
|
||||
opts?: CreateMainWindowOptions
|
||||
rendererWebContentsId: number
|
||||
state: MainWindowStateLifecycle
|
||||
store: Store | null
|
||||
}): { dispose: () => void } {
|
||||
const { focus, mainWindow, opts, rendererWebContentsId, state, store } = args
|
||||
// Intercept close so the renderer can confirm killing running-process terminals (replies window:confirm-close to proceed).
|
||||
let windowCloseConfirmed = false
|
||||
const confirmCloseChannel = 'window:confirm-close'
|
||||
const closeRequestReceivedChannel = 'window:close-request-received'
|
||||
let closeRequestSequence = 0
|
||||
let quitRendererAckRequestId: number | null = null
|
||||
let quitRendererAckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const clearQuitRendererAckTimer = (): void => {
|
||||
quitRendererAckRequestId = null
|
||||
if (quitRendererAckTimer) {
|
||||
clearTimeout(quitRendererAckTimer)
|
||||
quitRendererAckTimer = null
|
||||
}
|
||||
}
|
||||
const armQuitRendererAckTimer = (requestId: number): void => {
|
||||
quitRendererAckRequestId = requestId
|
||||
if (quitRendererAckTimer) {
|
||||
return
|
||||
}
|
||||
// Why: will-quit cannot run until the renderer-backed window closes; an
|
||||
// already-frozen renderer otherwise makes Force Quit the only escape.
|
||||
quitRendererAckTimer = setTimeout(() => {
|
||||
quitRendererAckTimer = null
|
||||
quitRendererAckRequestId = null
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
console.warn('[window] Renderer did not acknowledge quit; destroying unresponsive window')
|
||||
state.freezeBoundsOnQuit()
|
||||
mainWindow.destroy()
|
||||
}, WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS)
|
||||
quitRendererAckTimer.unref?.()
|
||||
}
|
||||
const onCloseRequestReceived = (event: Electron.IpcMainEvent, requestId: number): void => {
|
||||
if (event.sender.id === rendererWebContentsId && requestId === quitRendererAckRequestId) {
|
||||
clearQuitRendererAckTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// Windows minimize-to-tray: hide instead of close when enabled; returns true when it hid so callers skip their close path.
|
||||
const hideToTrayIfEnabled = (): boolean => {
|
||||
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
focus.isRendererProcessGone() ||
|
||||
isRendererCrashed ||
|
||||
opts?.getIsQuitting?.() === true ||
|
||||
store?.getSettings().minimizeToTrayOnClose !== true
|
||||
) {
|
||||
return false
|
||||
}
|
||||
mainWindow.hide()
|
||||
// Why: notify once that closing only hid the window; the persisted flag stops it repeating on every later minimize.
|
||||
if (store.getUI().trayMinimizeNoticeShown !== true) {
|
||||
try {
|
||||
new Notification({
|
||||
title: 'Orca',
|
||||
body: translateMain(
|
||||
'tray.minimizeNotice.body',
|
||||
'Orca is still running in the system tray'
|
||||
)
|
||||
}).show()
|
||||
} catch {
|
||||
// Notification is best-effort — never block hiding the window.
|
||||
}
|
||||
store.updateUI({ trayMinimizeNoticeShown: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
// Why: Alt+F4/programmatic closes hit the native event; apply the same minimize-to-tray guard the renderer-drawn X uses.
|
||||
if (!windowCloseConfirmed && hideToTrayIfEnabled()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
|
||||
// Why: only a gone/crashed renderer (can't answer) may bypass close confirmation; a hung-but-alive one still must (#5787).
|
||||
const closeAction = resolveWindowCloseAction({
|
||||
windowCloseConfirmed,
|
||||
rendererProcessGone: focus.isRendererProcessGone(),
|
||||
isRendererCrashed
|
||||
})
|
||||
if (closeAction !== 'request-confirmation') {
|
||||
// allow-confirmed: renderer already replied and re-entered close().
|
||||
// bypass-gone: a gone renderer can't answer window:close-requested, so let OS close complete rather than trap a blank window.
|
||||
if (closeAction === 'allow-confirmed') {
|
||||
windowCloseConfirmed = false
|
||||
}
|
||||
// Why: window teardown emits resize/move/unmaximize; freeze bounds persistence so they can't clobber saved size (v1.3.26-rc2).
|
||||
state.freezeBoundsOnQuit()
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
const isQuitting = opts?.getIsQuitting?.() ?? false
|
||||
const requestId = ++closeRequestSequence
|
||||
if (isQuitting) {
|
||||
armQuitRendererAckTimer(requestId)
|
||||
}
|
||||
// Why: renderer owns the close decision; the always-mounted App root subscription lets even pre-workspace states reply (#5144).
|
||||
mainWindow.webContents.send('window:close-requested', {
|
||||
isQuitting,
|
||||
requestId
|
||||
})
|
||||
})
|
||||
mainWindow.webContents.on('will-prevent-unload', () => {
|
||||
// Why: a prevented beforeunload cancels the quit; release the bounds-persistence freeze so later resizing still saves.
|
||||
state.resumeBoundsPersistence()
|
||||
clearQuitRendererAckTimer()
|
||||
opts?.onQuitAborted?.()
|
||||
mainWindow.webContents.send('window:unload-prevented')
|
||||
})
|
||||
|
||||
const onConfirmClose = (): void => {
|
||||
clearQuitRendererAckTimer()
|
||||
windowCloseConfirmed = true
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.close()
|
||||
}
|
||||
}
|
||||
const trafficLightChannel = 'ui:sync-traffic-lights'
|
||||
const onSyncTrafficLights = (_event: Electron.IpcMainEvent, zoomFactor: number): void => {
|
||||
syncTrafficLightPosition(mainWindow, zoomFactor)
|
||||
}
|
||||
ipcMain.on(trafficLightChannel, onSyncTrafficLights)
|
||||
|
||||
// Why: renderer-drawn window controls on Windows/Linux replicate the native title-bar buttons hidden by custom chrome.
|
||||
const minimizeChannel = 'window:minimize'
|
||||
const onMinimize = (): void => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.minimize()
|
||||
}
|
||||
}
|
||||
const maximizeChannel = 'window:maximize'
|
||||
const onMaximize = (): void => {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize()
|
||||
} else {
|
||||
mainWindow.maximize()
|
||||
}
|
||||
}
|
||||
// Why: mainWindow.close() from an IPC handler on Windows can make 'close' misfire, so send window:close-requested directly.
|
||||
const requestCloseChannel = 'window:request-close'
|
||||
const onRequestClose = (): void => {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: renderer-drawn X routes here (not the native close event), so the minimize-to-tray guard must also run here.
|
||||
if (hideToTrayIfEnabled()) {
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
|
||||
}
|
||||
// Why: renderer-drawn title-bar ··· menu button replicates the Alt-key reveal autoHideMenuBar provides (Windows/Linux).
|
||||
const popupMenuChannel = 'menu:popup'
|
||||
const onPopupMenu = (): void => {
|
||||
Menu.getApplicationMenu()?.popup({ window: mainWindow })
|
||||
}
|
||||
// Why: WindowControls mounts after window:maximize-changed already fired, so expose a synchronous getter to init its icon.
|
||||
const isMaximizedChannel = 'window:isMaximized'
|
||||
const onIsMaximized = (): boolean => {
|
||||
return !mainWindow.isDestroyed() && mainWindow.isMaximized()
|
||||
}
|
||||
ipcMain.on(minimizeChannel, onMinimize)
|
||||
ipcMain.on(maximizeChannel, onMaximize)
|
||||
ipcMain.on(requestCloseChannel, onRequestClose)
|
||||
ipcMain.on(popupMenuChannel, onPopupMenu)
|
||||
ipcMain.handle(isMaximizedChannel, onIsMaximized)
|
||||
|
||||
ipcMain.on(confirmCloseChannel, onConfirmClose)
|
||||
ipcMain.on(closeRequestReceivedChannel, onCloseRequestReceived)
|
||||
|
||||
const dispose = (): void => {
|
||||
clearQuitRendererAckTimer()
|
||||
ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights)
|
||||
ipcMain.removeListener(minimizeChannel, onMinimize)
|
||||
ipcMain.removeListener(maximizeChannel, onMaximize)
|
||||
ipcMain.removeListener(requestCloseChannel, onRequestClose)
|
||||
ipcMain.removeListener(popupMenuChannel, onPopupMenu)
|
||||
ipcMain.removeHandler(isMaximizedChannel)
|
||||
ipcMain.removeListener(confirmCloseChannel, onConfirmClose)
|
||||
ipcMain.removeListener(closeRequestReceivedChannel, onCloseRequestReceived)
|
||||
}
|
||||
return { dispose }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
|
||||
export type CreateMainWindowOptions = {
|
||||
/** Returns true when a manual app.quit() (Cmd+Q) is in progress, so the renderer skips the running-process confirm dialog. */
|
||||
getIsQuitting?: () => boolean
|
||||
/** Notifies the caller when the renderer vetoes unload, so the quit latch clears — a prevented beforeunload cancels the in-flight app.quit(). */
|
||||
onQuitAborted?: () => void
|
||||
onRendererProcessGone?: (
|
||||
details: Electron.RenderProcessGoneDetails,
|
||||
webContentsId: number
|
||||
) => void
|
||||
/** Returns true when Orca should reload after renderer loss; update-relaunch/quit tear down children intentionally, so don't fight shutdown. */
|
||||
shouldRecoverRenderer?: (
|
||||
details: Electron.RenderProcessGoneDetails,
|
||||
webContentsId: number
|
||||
) => boolean
|
||||
/** Called when consecutive auto-recoveries hit the circuit-breaker limit so the host can prompt instead of crash-looping. */
|
||||
onRendererRecoveryExhausted?: (info: {
|
||||
details: Electron.RenderProcessGoneDetails
|
||||
webContentsId: number
|
||||
recentRecoveryCount: number
|
||||
}) => void
|
||||
/** Defer renderer load until IPC handlers are registered, or eager renderer calls race into missing channels. */
|
||||
deferLoad?: boolean
|
||||
/** Reveal after load instead of first paint when startup must show the shell before slower renderer work. */
|
||||
revealOnDidFinishLoad?: boolean
|
||||
title?: string
|
||||
getKeybindings?: () => KeybindingOverrides | undefined
|
||||
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
|
||||
/** Marks the in-place recovery reload so did-finish-load's PTY orphan sweep spares live sessions until restore re-attaches (#5787). */
|
||||
onBeforeRecoveryReload?: (webContentsId: number) => void
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { ipcMain, Menu, type BrowserWindow } from 'electron'
|
||||
import { isCrashReportReason } from '../../shared/crash-reporting'
|
||||
import {
|
||||
richMarkdownContextMenuTargetChannel,
|
||||
type RichMarkdownContextMenuTableTarget
|
||||
} from '../../shared/rich-markdown-context-menu'
|
||||
import {
|
||||
DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES,
|
||||
DEFAULT_RENDERER_RECOVERY_WINDOW_MS,
|
||||
RendererRecoveryCircuitBreaker
|
||||
} from '../crash-reporting/renderer-recovery-circuit-breaker'
|
||||
import {
|
||||
buildEditableContextMenuTemplate,
|
||||
matchingRichMarkdownContextMenuTableTarget,
|
||||
parseRichMarkdownContextMenuTableTarget
|
||||
} from './editable-context-menu'
|
||||
import type { CreateMainWindowOptions } from './main-window-contracts'
|
||||
|
||||
export type MainWindowFocusLifecycle = {
|
||||
dispose: () => void
|
||||
isFloatingPanelFocused: () => boolean
|
||||
isFloatingTerminalInputFocused: () => boolean
|
||||
isMarkdownEditorFocused: () => boolean
|
||||
isRendererProcessGone: () => boolean
|
||||
isShortcutRecorderFocused: () => boolean
|
||||
isTerminalInputFocused: () => boolean
|
||||
}
|
||||
|
||||
export function installMainWindowFocusLifecycle(args: {
|
||||
isWindowClosing: () => boolean
|
||||
mainWindow: BrowserWindow
|
||||
opts?: CreateMainWindowOptions
|
||||
reloadMainWindow: () => void
|
||||
rendererWebContentsId: number
|
||||
}): MainWindowFocusLifecycle {
|
||||
const { isWindowClosing, mainWindow, opts, reloadMainWindow, rendererWebContentsId } = args
|
||||
// Why: mirror markdown-editor focus so before-input-event skips Cmd/Ctrl+B while TipTap owns focus (docs/markdown-cmd-b-bold-design.md).
|
||||
let markdownEditorFocused = false
|
||||
let terminalInputFocused = false
|
||||
// floatingTerminalInputFocused: textarea-only (terminal keybinding context). floatingPanelFocused: superset for routing ownership.
|
||||
let floatingTerminalInputFocused = false
|
||||
let floatingPanelFocused = false
|
||||
let shortcutRecorderFocused = false
|
||||
|
||||
const markdownFocusChannel = 'ui:setMarkdownEditorFocused'
|
||||
// Why: strict-bool + sender check so a guest/webview or malformed IPC payload can't disable the Cmd+B sidebar carve-out.
|
||||
const onMarkdownEditorFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
markdownEditorFocused = focused === true
|
||||
}
|
||||
ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused)
|
||||
const terminalInputFocusChannel = 'ui:setTerminalInputFocused'
|
||||
// Why: before-input-event resolves shortcuts before renderer keydown; mirror xterm focus so Terminal-first lets shells own app chords.
|
||||
const onTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
terminalInputFocused = focused === true
|
||||
}
|
||||
ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused)
|
||||
const floatingFocusChannel = 'ui:setFloatingFocus'
|
||||
// Why: one atomic payload for both bits so before-input-event never reads a torn terminal=true/panel=false state.
|
||||
// terminalFocused drives the Ctrl+B/L terminal-context carve-out; panelFocused is the routing-ownership superset (panel ⊇ terminal).
|
||||
const onFloatingFocus = (event: Electron.IpcMainEvent, state: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
const payload = (state ?? {}) as { panelFocused?: unknown; terminalFocused?: unknown }
|
||||
const terminal = payload.terminalFocused === true
|
||||
floatingTerminalInputFocused = terminal
|
||||
// Re-assert the invariant defensively in case a sender ever emits panel=false with terminal=true.
|
||||
floatingPanelFocused = payload.panelFocused === true || terminal
|
||||
}
|
||||
ipcMain.on(floatingFocusChannel, onFloatingFocus)
|
||||
const shortcutRecorderFocusChannel = 'ui:setShortcutRecorderFocused'
|
||||
// Why: the Settings recorder must receive app shortcuts to rebind them; before-input-event would otherwise consume the key first.
|
||||
const onShortcutRecorderFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
shortcutRecorderFocused = focused === true
|
||||
}
|
||||
ipcMain.on(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
|
||||
|
||||
let pendingRichMarkdownContextMenuTableTarget: RichMarkdownContextMenuTableTarget | null = null
|
||||
const onRichMarkdownContextMenuTarget = (event: Electron.IpcMainEvent, value: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
pendingRichMarkdownContextMenuTableTarget = parseRichMarkdownContextMenuTableTarget(value)
|
||||
}
|
||||
ipcMain.on(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget)
|
||||
const onMainContextMenu = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
|
||||
const tableTarget = matchingRichMarkdownContextMenuTableTarget(
|
||||
params,
|
||||
pendingRichMarkdownContextMenuTableTarget
|
||||
)
|
||||
pendingRichMarkdownContextMenuTableTarget = null
|
||||
const template = buildEditableContextMenuTemplate(params, mainWindow.webContents, {
|
||||
tableTarget
|
||||
})
|
||||
if (template.length === 0 || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
// Why: the context-menu event can precede our focus-mirror update; trust Electron's editable params, not markdownEditorFocused.
|
||||
Menu.buildFromTemplate(template).popup({ window: mainWindow, x: params.x, y: params.y })
|
||||
}
|
||||
mainWindow.webContents.on('context-menu', onMainContextMenu)
|
||||
|
||||
// Why: a dead renderer can't clear its focus mirror; default-deny carve-outs so it can't disable app shortcuts in a later lifecycle.
|
||||
const resetMarkdownEditorFocus = (): void => {
|
||||
markdownEditorFocused = false
|
||||
pendingRichMarkdownContextMenuTableTarget = null
|
||||
}
|
||||
const resetTerminalInputFocus = (): void => {
|
||||
terminalInputFocused = false
|
||||
}
|
||||
const resetFloatingTerminalInputFocus = (): void => {
|
||||
floatingTerminalInputFocused = false
|
||||
floatingPanelFocused = false
|
||||
}
|
||||
const resetShortcutRecorderFocus = (): void => {
|
||||
shortcutRecorderFocused = false
|
||||
}
|
||||
let rendererProcessGone = false
|
||||
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: stop a deterministic per-load renderer fault from auto-reloading forever; breaker opens after too many recoveries in a rolling window.
|
||||
const rendererRecoveryCircuitBreaker = new RendererRecoveryCircuitBreaker({
|
||||
windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS,
|
||||
maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES
|
||||
})
|
||||
const clearRendererRecoveryTimer = (): void => {
|
||||
if (rendererRecoveryTimer) {
|
||||
clearTimeout(rendererRecoveryTimer)
|
||||
rendererRecoveryTimer = null
|
||||
}
|
||||
}
|
||||
const scheduleRendererRecovery = (details: Electron.RenderProcessGoneDetails): void => {
|
||||
if (
|
||||
rendererRecoveryTimer ||
|
||||
!details ||
|
||||
!isCrashReportReason(details.reason) ||
|
||||
isWindowClosing() ||
|
||||
opts?.getIsQuitting?.() ||
|
||||
opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false ||
|
||||
mainWindow.isDestroyed()
|
||||
) {
|
||||
return
|
||||
}
|
||||
rendererRecoveryTimer = setTimeout(() => {
|
||||
rendererRecoveryTimer = null
|
||||
if (
|
||||
isWindowClosing() ||
|
||||
opts?.getIsQuitting?.() ||
|
||||
opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false ||
|
||||
mainWindow.isDestroyed()
|
||||
) {
|
||||
return
|
||||
}
|
||||
const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now())
|
||||
if (!recovery.allowed) {
|
||||
// Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt.
|
||||
opts?.onRendererRecoveryExhausted?.({
|
||||
details,
|
||||
webContentsId: rendererWebContentsId,
|
||||
recentRecoveryCount: recovery.recentRecoveryCount
|
||||
})
|
||||
return
|
||||
}
|
||||
// Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover.
|
||||
// Why: mark this in-place reload so the did-finish-load orphan sweep spares live PTYs until session restore (#5787).
|
||||
opts?.onBeforeRecoveryReload?.(mainWindow.webContents.id)
|
||||
reloadMainWindow()
|
||||
}, 250)
|
||||
}
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rendererProcessGone = true
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
resetShortcutRecorderFocus()
|
||||
// Why: macOS reports BrowserWindow teardown as renderer killed/SIGKILL after close — window noise, not a crash.
|
||||
if (!isWindowClosing()) {
|
||||
// Why: the recorder owns crash classification; filtering here made expected-teardown evidence unreachable.
|
||||
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
|
||||
}
|
||||
if (!isWindowClosing()) {
|
||||
console.error('[window] Renderer process gone; close confirmation will be bypassed', details)
|
||||
}
|
||||
scheduleRendererRecovery(details)
|
||||
})
|
||||
mainWindow.webContents.on('destroyed', () => {
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
resetShortcutRecorderFocus()
|
||||
})
|
||||
mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
resetShortcutRecorderFocus()
|
||||
}
|
||||
})
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
rendererProcessGone = false
|
||||
clearRendererRecoveryTimer()
|
||||
})
|
||||
|
||||
const dispose = (): void => {
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
resetShortcutRecorderFocus()
|
||||
clearRendererRecoveryTimer()
|
||||
ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused)
|
||||
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
|
||||
ipcMain.removeListener(floatingFocusChannel, onFloatingFocus)
|
||||
ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused)
|
||||
ipcMain.removeListener(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget)
|
||||
}
|
||||
return {
|
||||
dispose,
|
||||
isFloatingPanelFocused: () => floatingPanelFocused,
|
||||
isFloatingTerminalInputFocused: () => floatingTerminalInputFocused,
|
||||
isMarkdownEditorFocused: () => markdownEditorFocused,
|
||||
isRendererProcessGone: () => rendererProcessGone,
|
||||
isShortcutRecorderFocused: () => shortcutRecorderFocused,
|
||||
isTerminalInputFocused: () => terminalInputFocused
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { WindowShortcutAction } from '../../shared/window-shortcut-policy'
|
||||
|
||||
export function sendResolvedWindowShortcutAction(
|
||||
mainWindow: BrowserWindow,
|
||||
action: WindowShortcutAction,
|
||||
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
|
||||
): void {
|
||||
switch (action.type) {
|
||||
// The renderer's DictationController re-checks enabled/sttModel and ignores hold mode, so this path needs no voice guards.
|
||||
case 'dictationKeyDown':
|
||||
mainWindow.webContents.send('ui:dictationKeyDown')
|
||||
return
|
||||
case 'zoom':
|
||||
mainWindow.webContents.send('terminal:zoom', action.direction)
|
||||
return
|
||||
case 'openSettings':
|
||||
mainWindow.webContents.send('ui:openSettings')
|
||||
return
|
||||
case 'forceReload':
|
||||
onBeforeReload?.({ ignoreCache: true, webContentsId: mainWindow.webContents.id })
|
||||
mainWindow.webContents.reloadIgnoringCache()
|
||||
return
|
||||
case 'toggleLeftSidebar':
|
||||
mainWindow.webContents.send('ui:toggleLeftSidebar')
|
||||
return
|
||||
case 'toggleRightSidebar':
|
||||
mainWindow.webContents.send('ui:toggleRightSidebar')
|
||||
return
|
||||
case 'toggleWorktreePalette':
|
||||
mainWindow.webContents.send('ui:toggleWorktreePalette')
|
||||
return
|
||||
case 'toggleFloatingTerminal':
|
||||
mainWindow.webContents.send('ui:toggleFloatingTerminal')
|
||||
return
|
||||
case 'openQuickOpen':
|
||||
mainWindow.webContents.send('ui:openQuickOpen')
|
||||
return
|
||||
case 'toggleQuickCommandsMenu':
|
||||
mainWindow.webContents.send('ui:toggleQuickCommandsMenu')
|
||||
return
|
||||
case 'openNewWorkspace':
|
||||
mainWindow.webContents.send('ui:openNewWorkspace')
|
||||
return
|
||||
case 'deleteCurrentWorkspace':
|
||||
mainWindow.webContents.send('ui:deleteCurrentWorkspace')
|
||||
return
|
||||
case 'openWorkspaceBoard':
|
||||
mainWindow.webContents.send('ui:openWorkspaceBoard')
|
||||
return
|
||||
case 'openTasks':
|
||||
mainWindow.webContents.send('ui:openTasks')
|
||||
return
|
||||
case 'toggleAgentDashboard':
|
||||
mainWindow.webContents.send('ui:toggleAgentDashboard')
|
||||
return
|
||||
case 'switchRecentTab':
|
||||
mainWindow.webContents.send('ui:switchRecentTab')
|
||||
return
|
||||
case 'jumpToWorktreeIndex':
|
||||
mainWindow.webContents.send('ui:jumpToWorktreeIndex', action.index)
|
||||
return
|
||||
case 'jumpToTabIndex':
|
||||
mainWindow.webContents.send('ui:jumpToTabIndex', action.index)
|
||||
return
|
||||
case 'worktreeHistoryNavigate':
|
||||
mainWindow.webContents.send('ui:worktreeHistoryNavigate', action.direction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
} from '../../shared/modifier-double-tap-detector'
|
||||
import {
|
||||
normalizeTerminalShortcutPolicy,
|
||||
type KeybindingMatchOptions
|
||||
} from '../../shared/keybindings'
|
||||
import {
|
||||
getWindowShortcutActionId,
|
||||
matchesRecentTabSwitcherChord,
|
||||
nativeZoomCommandMatchesKeybindings,
|
||||
resolveWindowShortcutAction,
|
||||
windowShortcutActionCapturesTerminal,
|
||||
type WindowShortcutAction
|
||||
} from '../../shared/window-shortcut-policy'
|
||||
import type { Store } from '../persistence'
|
||||
import type { CreateMainWindowOptions } from './main-window-contracts'
|
||||
import type { MainWindowFocusLifecycle } from './main-window-focus-lifecycle'
|
||||
import { sendResolvedWindowShortcutAction } from './main-window-shortcut-actions'
|
||||
import { isMacAppPasteInput } from './main-window-visual-lifecycle'
|
||||
|
||||
export function installMainWindowShortcutRouting(args: {
|
||||
focus: MainWindowFocusLifecycle
|
||||
mainWindow: BrowserWindow
|
||||
opts?: CreateMainWindowOptions
|
||||
store: Store | null
|
||||
}): void {
|
||||
const { focus, mainWindow, opts, store } = args
|
||||
const doubleTapDetector = new ModifierDoubleTapDetector()
|
||||
|
||||
const dispatchResolvedWindowShortcutAction = (
|
||||
event: Electron.Event,
|
||||
action: WindowShortcutAction,
|
||||
options: {
|
||||
isAutoRepeat: boolean
|
||||
focusedShortcutContext: KeybindingMatchOptions
|
||||
}
|
||||
): boolean => {
|
||||
const { focusedShortcutContext, isAutoRepeat } = options
|
||||
if (
|
||||
focus.isFloatingTerminalInputFocused() &&
|
||||
(action.type === 'toggleLeftSidebar' || action.type === 'toggleRightSidebar')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isIndexJump = action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex'
|
||||
if (isIndexJump && isAutoRepeat) {
|
||||
// Contain held-key repeats in main — every renderer index path skips e.repeat, so yielding a
|
||||
// repeat would leak a raw key to xterm/DOM, and re-firing the jump is never what a hold means.
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
// While the floating panel owns the keyboard, yield indexed switch chords to the renderer
|
||||
// so L2 selects a floating tab instead of switching the main workspace behind the panel.
|
||||
if (focus.isFloatingPanelFocused() && isIndexJump) {
|
||||
return false
|
||||
}
|
||||
|
||||
const capturedTerminalActionId =
|
||||
focusedShortcutContext.context === 'terminal' &&
|
||||
focusedShortcutContext.terminalShortcutPolicy === 'orca-first' &&
|
||||
windowShortcutActionCapturesTerminal(action)
|
||||
? getWindowShortcutActionId(action)
|
||||
: null
|
||||
|
||||
// Why: hold-mode dictation needs renderer keyup events, so main only consumes single-keydown dictation toggles.
|
||||
if (action.type === 'dictationKeyDown') {
|
||||
const voiceSettings = store?.getSettings().voice
|
||||
if (!voiceSettings?.enabled || !voiceSettings.sttModel) {
|
||||
return false
|
||||
}
|
||||
const dictationMode = voiceSettings.dictationMode ?? 'toggle'
|
||||
if (dictationMode === 'hold') {
|
||||
return false
|
||||
}
|
||||
if (isAutoRepeat) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
mainWindow.webContents.send('ui:dictationKeyDown')
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
(action.type === 'toggleQuickCommandsMenu' || action.type === 'deleteCurrentWorkspace') &&
|
||||
isAutoRepeat
|
||||
) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
|
||||
sendResolvedWindowShortcutAction(mainWindow, action, opts?.onBeforeReload)
|
||||
return true
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (focus.isShortcutRecorderFocused()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.type === 'keyDown' && is.dev && input.code === 'F12') {
|
||||
event.preventDefault()
|
||||
if (mainWindow.webContents.isDevToolsOpened()) {
|
||||
mainWindow.webContents.closeDevTools()
|
||||
} else {
|
||||
mainWindow.webContents.openDevTools({ mode: 'undocked' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isMacAppPasteInput(input)) {
|
||||
// Why: chat/terminal panes hold focus without native editable controls, so route Cmd+V through Orca's paste ownership.
|
||||
event.preventDefault()
|
||||
mainWindow.webContents.send('ui:appMenuPaste')
|
||||
return
|
||||
}
|
||||
|
||||
const keybindings = opts?.getKeybindings?.()
|
||||
const terminalShortcutContext: KeybindingMatchOptions = {
|
||||
context:
|
||||
focus.isTerminalInputFocused() || focus.isFloatingTerminalInputFocused()
|
||||
? 'terminal'
|
||||
: 'app',
|
||||
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
|
||||
store?.getSettings().terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
const appShortcutContext: KeybindingMatchOptions = {
|
||||
context: 'app',
|
||||
terminalShortcutPolicy: terminalShortcutContext.terminalShortcutPolicy
|
||||
}
|
||||
|
||||
// Why: bare modifiers emit no terminal bytes, so double-tap detection on the raw key stream never steals readline input.
|
||||
if (input.type === 'keyDown' || input.type === 'keyUp') {
|
||||
const detected = doubleTapDetector.process(
|
||||
toModifierDoubleTapEvent({
|
||||
type: input.type,
|
||||
code: input.code,
|
||||
key: input.key,
|
||||
shift: input.shift,
|
||||
control: input.control,
|
||||
alt: input.alt,
|
||||
meta: input.meta,
|
||||
isAutoRepeat: input.isAutoRepeat
|
||||
}),
|
||||
Date.now()
|
||||
)
|
||||
if (detected) {
|
||||
const doubleTapAction = resolveWindowShortcutAction(
|
||||
{ type: 'keyDown', doubleTapModifier: detected.modifier },
|
||||
process.platform,
|
||||
keybindings,
|
||||
appShortcutContext
|
||||
)
|
||||
if (
|
||||
doubleTapAction &&
|
||||
dispatchResolvedWindowShortcutAction(event, doubleTapAction, {
|
||||
isAutoRepeat: false,
|
||||
focusedShortcutContext: terminalShortcutContext
|
||||
})
|
||||
) {
|
||||
// preventDefault only the emitting keydown so the renderer detector can't also fire for the same gesture.
|
||||
return
|
||||
}
|
||||
// No allowlisted action: let the keydown reach the renderer, whose detector completes and dispatches inline.
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.type === 'keyDown' &&
|
||||
matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext)
|
||||
) {
|
||||
// Why: the held switcher commits on modifier keyup; preventing the keydown here can suppress the keyup and strand the overlay.
|
||||
return
|
||||
}
|
||||
|
||||
// Why: TipTap owns bare Cmd/Ctrl+B for bold in the markdown editor; skip interception for the bare chord only.
|
||||
// See docs/markdown-cmd-b-bold-design.md.
|
||||
const modForBold = process.platform === 'darwin' ? input.meta : input.control
|
||||
if (
|
||||
focus.isMarkdownEditorFocused() &&
|
||||
input.code === 'KeyB' &&
|
||||
!input.alt &&
|
||||
!input.shift &&
|
||||
modForBold
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: keep interception an explicit allowlist so readline control chords reach the PTY instead of being silently stolen.
|
||||
const action = resolveWindowShortcutAction(
|
||||
input,
|
||||
process.platform,
|
||||
keybindings,
|
||||
terminalShortcutContext
|
||||
)
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
|
||||
dispatchResolvedWindowShortcutAction(event, action, {
|
||||
isAutoRepeat: Boolean(input.isAutoRepeat),
|
||||
focusedShortcutContext: terminalShortcutContext
|
||||
})
|
||||
})
|
||||
|
||||
// Why: mid-gesture focus loss must not leave the detector armed, or the next modifier press completes a phantom double-tap.
|
||||
mainWindow.on('blur', () => doubleTapDetector.reset())
|
||||
|
||||
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
|
||||
// Why: some layouts fire Electron's zoom command without before-input-event; honor it only while the zoom action is still bound.
|
||||
if (zoomDirection !== 'in' && zoomDirection !== 'out') {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!nativeZoomCommandMatchesKeybindings(
|
||||
zoomDirection,
|
||||
process.platform,
|
||||
opts?.getKeybindings?.(),
|
||||
{
|
||||
context:
|
||||
focus.isTerminalInputFocused() || focus.isFloatingTerminalInputFocused()
|
||||
? 'terminal'
|
||||
: 'app',
|
||||
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
|
||||
store?.getSettings().terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
mainWindow.webContents.send('terminal:zoom', zoomDirection)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { app, type BrowserWindow } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { getMainE2EConfig } from '../e2e-config'
|
||||
import { MIN_HEIGHT, MIN_WIDTH, syncTrafficLightPosition } from './main-window-visual-lifecycle'
|
||||
|
||||
export type MainWindowStateLifecycle = {
|
||||
clearInitialRevealFallbackTimer: () => void
|
||||
dispose: () => void
|
||||
freezeBoundsOnQuit: () => void
|
||||
isWindowClosing: () => boolean
|
||||
resumeBoundsPersistence: () => void
|
||||
}
|
||||
|
||||
export function installMainWindowStateLifecycle(args: {
|
||||
mainWindow: BrowserWindow
|
||||
revealOnDidFinishLoad: boolean
|
||||
savedMaximized: boolean
|
||||
store: Store | null
|
||||
}): MainWindowStateLifecycle {
|
||||
const { mainWindow, revealOnDidFinishLoad, savedMaximized, store } = args
|
||||
mainWindow.webContents.on('dom-ready', () => {
|
||||
const level = store?.getUI().uiZoomLevel ?? 0
|
||||
mainWindow.webContents.setZoomLevel(level)
|
||||
// Why: native traffic lights don't scale with CSS zoom; reposition on startup to stay aligned with the zoomed titlebar.
|
||||
if (process.platform === 'darwin') {
|
||||
syncTrafficLightPosition(mainWindow, 1.2 ** level)
|
||||
}
|
||||
})
|
||||
|
||||
// Why: macOS+Electron 41 re-emits ready-to-show on webview-guest creation; a one-shot guard stops re-running maximize() after resize (#591).
|
||||
let handledInitialReadyToShow = false
|
||||
let initialRevealFallbackTimer: ReturnType<typeof setTimeout> | null =
|
||||
process.platform === 'win32' || process.platform === 'linux'
|
||||
? setTimeout(() => {
|
||||
// Why: GPU/driver failures on Windows/Linux can prevent ready-to-show forever, hiding the only app window (#8421).
|
||||
initialRevealFallbackTimer = null
|
||||
revealInitialWindow()
|
||||
}, 10_000)
|
||||
: null
|
||||
initialRevealFallbackTimer?.unref?.()
|
||||
|
||||
const clearInitialRevealFallbackTimer = (): void => {
|
||||
if (initialRevealFallbackTimer) {
|
||||
clearTimeout(initialRevealFallbackTimer)
|
||||
initialRevealFallbackTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const revealInitialWindow = (): void => {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
clearInitialRevealFallbackTimer()
|
||||
return
|
||||
}
|
||||
if (handledInitialReadyToShow) {
|
||||
return
|
||||
}
|
||||
handledInitialReadyToShow = true
|
||||
clearInitialRevealFallbackTimer()
|
||||
|
||||
// Why: in E2E headless mode keep the window hidden (Playwright drives via CDP) so tests don't steal focus.
|
||||
const e2eConfig = getMainE2EConfig()
|
||||
if (e2eConfig.headless) {
|
||||
return
|
||||
}
|
||||
if (savedMaximized) {
|
||||
mainWindow.maximize()
|
||||
}
|
||||
mainWindow.show()
|
||||
}
|
||||
mainWindow.on('ready-to-show', revealInitialWindow)
|
||||
if (revealOnDidFinishLoad === true) {
|
||||
mainWindow.webContents.on('did-finish-load', revealInitialWindow)
|
||||
}
|
||||
|
||||
// Why: persist window bounds to restore last position/size; debounce to avoid hammering persistence during resize drags.
|
||||
let boundsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: teardown still emits resize/move/unmaximize at near-min bounds; freeze persistence once closing so they can't clobber the saved size.
|
||||
let windowClosing = false
|
||||
const saveBounds = (): void => {
|
||||
if (boundsTimer) {
|
||||
clearTimeout(boundsTimer)
|
||||
}
|
||||
boundsTimer = setTimeout(() => {
|
||||
boundsTimer = null
|
||||
if (windowClosing || mainWindow.isDestroyed() || mainWindow.isFullScreen()) {
|
||||
return
|
||||
}
|
||||
// Why: persist windowMaximized and windowBounds atomically; the near-min guard must not leave them a mismatched pair.
|
||||
const isMaximized = mainWindow.isMaximized()
|
||||
if (isMaximized) {
|
||||
store?.updateUI({ windowMaximized: true })
|
||||
return
|
||||
}
|
||||
const bounds = mainWindow.getBounds()
|
||||
// Why: never persist shrink-to-min bounds (teardown race past the freeze, PR #1269); fall back to defaultBounds next launch.
|
||||
if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) {
|
||||
console.warn('[window] Skipping persist of near-minimum windowBounds:', bounds)
|
||||
store?.updateUI({ windowMaximized: false })
|
||||
return
|
||||
}
|
||||
store?.updateUI({ windowMaximized: false, windowBounds: bounds })
|
||||
}, 500)
|
||||
}
|
||||
mainWindow.on('resize', saveBounds)
|
||||
mainWindow.on('move', saveBounds)
|
||||
|
||||
// Why: the auto-updater calls removeAllListeners('close') before quitting, so latch on app 'before-quit' too to freeze bounds during teardown.
|
||||
const freezeBoundsOnQuit = (): void => {
|
||||
windowClosing = true
|
||||
if (boundsTimer) {
|
||||
clearTimeout(boundsTimer)
|
||||
boundsTimer = null
|
||||
}
|
||||
}
|
||||
app.on('before-quit', freezeBoundsOnQuit)
|
||||
|
||||
mainWindow.on('maximize', () => {
|
||||
if (windowClosing) {
|
||||
return
|
||||
}
|
||||
store?.updateUI({ windowMaximized: true })
|
||||
mainWindow.webContents.send('window:maximize-changed', true)
|
||||
})
|
||||
mainWindow.on('unmaximize', () => {
|
||||
if (windowClosing) {
|
||||
return
|
||||
}
|
||||
mainWindow.webContents.send('window:maximize-changed', false)
|
||||
const bounds = mainWindow.getBounds()
|
||||
// Why: mirror the saveBounds guard — unmaximize during teardown can land at min size; don't persist that as remembered size.
|
||||
if (bounds.width <= MIN_WIDTH || bounds.height <= MIN_HEIGHT) {
|
||||
console.warn('[window] Skipping unmaximize-time persist of near-min bounds:', bounds)
|
||||
store?.updateUI({ windowMaximized: false })
|
||||
return
|
||||
}
|
||||
store?.updateUI({ windowMaximized: false, windowBounds: bounds })
|
||||
})
|
||||
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
mainWindow.webContents.send('window:fullscreen-changed', true)
|
||||
})
|
||||
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
mainWindow.webContents.send('window:fullscreen-changed', false)
|
||||
})
|
||||
|
||||
const resumeBoundsPersistence = (): void => {
|
||||
windowClosing = false
|
||||
}
|
||||
return {
|
||||
clearInitialRevealFallbackTimer,
|
||||
dispose: () => app.removeListener('before-quit', freezeBoundsOnQuit),
|
||||
freezeBoundsOnQuit,
|
||||
isWindowClosing: () => windowClosing,
|
||||
resumeBoundsPersistence
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ipcMain, type BrowserWindow } from 'electron'
|
||||
import { isMacosTahoeOrNewer } from './macos-tahoe-release'
|
||||
|
||||
const activeRepaintJiggles = new WeakSet<BrowserWindow>()
|
||||
export function forceRepaint(window: BrowserWindow): void {
|
||||
// Why: webContents can be destroyed a beat before the BrowserWindow during close, and this runs from timers/focus events in that gap.
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
window.webContents.invalidate()
|
||||
// Why: macOS 26 scene-backed windows deadlock on frame mutation, and device emulation can
|
||||
// strand the compositor after wake. The native shell no longer relies on dvh reflow.
|
||||
if (isMacosTahoeOrNewer()) {
|
||||
return
|
||||
}
|
||||
if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) {
|
||||
return
|
||||
}
|
||||
activeRepaintJiggles.add(window)
|
||||
// Why: show/restore fire from inside AppKit's window-state dispatch; mutating the frame there re-enters scene handling, so nudge on a fresh turn.
|
||||
setTimeout(() => {
|
||||
if (window.isDestroyed()) {
|
||||
activeRepaintJiggles.delete(window)
|
||||
return
|
||||
}
|
||||
const [width, height] = window.getSize()
|
||||
// Why: if the nudge throws mid-flight the WeakSet entry must still clear, or this window
|
||||
// never repaints again.
|
||||
try {
|
||||
window.setSize(width + 1, height)
|
||||
} catch {
|
||||
activeRepaintJiggles.delete(window)
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (!window.isDestroyed()) {
|
||||
const [currentWidth, currentHeight] = window.getSize()
|
||||
// Why: a real user resize during the jiggle owns the final bounds.
|
||||
if (currentWidth === width + 1 && currentHeight === height) {
|
||||
window.setSize(width, height)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeRepaintJiggles.delete(window)
|
||||
}
|
||||
}, 32)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function installMacosVisibilityRepaint(window: BrowserWindow): void {
|
||||
let delayedRepaintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const repaintAfterVisibilityTransition = (): void => {
|
||||
forceRepaint(window)
|
||||
if (delayedRepaintTimer) {
|
||||
clearTimeout(delayedRepaintTimer)
|
||||
}
|
||||
// Why: macOS may restore compositor layers after the show/restore event; a second paint catches late black-surface recovery.
|
||||
delayedRepaintTimer = setTimeout(() => {
|
||||
delayedRepaintTimer = null
|
||||
forceRepaint(window)
|
||||
}, 250)
|
||||
}
|
||||
const clearDelayedRepaint = (): void => {
|
||||
if (delayedRepaintTimer) {
|
||||
clearTimeout(delayedRepaintTimer)
|
||||
delayedRepaintTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: occlusion reveal can fire no restore/show, so preserve the renderer relay without
|
||||
// trusting events from another window.
|
||||
const onRendererRevealed = (event: Electron.IpcMainEvent): void => {
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (event.sender !== window.webContents) {
|
||||
return
|
||||
}
|
||||
forceRepaint(window)
|
||||
}
|
||||
ipcMain.on('ui:window-revealed', onRendererRevealed)
|
||||
|
||||
window.on('restore', repaintAfterVisibilityTransition)
|
||||
window.on('show', repaintAfterVisibilityTransition)
|
||||
// Why: occlusion-uncover can fire only focus; invalidate without resizing terminals on Cmd+Tab.
|
||||
window.on('focus', () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.invalidate()
|
||||
}
|
||||
})
|
||||
window.on('closed', () => {
|
||||
clearDelayedRepaint()
|
||||
ipcMain.removeListener('ui:window-revealed', onRendererRevealed)
|
||||
})
|
||||
}
|
||||
|
||||
export function isMacAppPasteInput(input: Electron.Input): boolean {
|
||||
return (
|
||||
process.platform === 'darwin' &&
|
||||
input.type === 'keyDown' &&
|
||||
input.meta &&
|
||||
!input.control &&
|
||||
!input.alt &&
|
||||
!input.shift &&
|
||||
(input.code === 'KeyV' || input.key.toLowerCase() === 'v')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: titlebar content center sits ~18 CSS px from top (×zoom); traffic lights are ~12px tall, so top edge = center − 6.
|
||||
export const TITLEBAR_CSS_CENTER = 18
|
||||
export const TRAFFIC_LIGHT_RADIUS = 6
|
||||
export const TRAFFIC_LIGHT_X = 16
|
||||
export const MIN_WIDTH = 600
|
||||
export const MIN_HEIGHT = 400
|
||||
|
||||
export function syncTrafficLightPosition(win: BrowserWindow, zoomFactor: number): void {
|
||||
if (process.platform !== 'darwin' || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const y = Math.round(TITLEBAR_CSS_CENTER * zoomFactor - TRAFFIC_LIGHT_RADIUS)
|
||||
win.setWindowButtonPosition({ x: TRAFFIC_LIGHT_X, y })
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
attachGuestPolicies: vi.fn(),
|
||||
installNavigationPolicy: vi.fn(),
|
||||
isAllowedPartition: vi.fn(),
|
||||
registerPluginGuard: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../browser/browser-manager', () => ({
|
||||
browserManager: { attachGuestPolicies: mocks.attachGuestPolicies }
|
||||
}))
|
||||
vi.mock('../browser/browser-session-registry', () => ({
|
||||
browserSessionRegistry: { isAllowedPartition: mocks.isAllowedPartition }
|
||||
}))
|
||||
vi.mock('../plugins/plugin-panel-navigation-guard', () => ({
|
||||
registerPluginPanelNavigationGuard: mocks.registerPluginGuard
|
||||
}))
|
||||
vi.mock('./privileged-window-navigation', () => ({
|
||||
installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy
|
||||
}))
|
||||
|
||||
import { installMainWindowWebviewSecurity } from './main-window-webview-security'
|
||||
|
||||
describe('main window webview security', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('fails closed before applying hardened guest preferences', () => {
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers[event] = handler
|
||||
})
|
||||
}
|
||||
installMainWindowWebviewSecurity({ webContents } as never)
|
||||
mocks.isAllowedPartition.mockReturnValue(false)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault } as never,
|
||||
{ partition: 'persist:untrusted', preload: 'attacker.js' } as never,
|
||||
{ src: 'https://example.com', preload: 'attacker.js' } as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(mocks.installNavigationPolicy).toHaveBeenCalledWith(webContents)
|
||||
expect(mocks.registerPluginGuard).toHaveBeenCalledWith(webContents)
|
||||
})
|
||||
|
||||
it('removes renderer preload input and restores every hardened preference', () => {
|
||||
const handlers: Record<string, (...args: never[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
|
||||
handlers[event] = handler
|
||||
})
|
||||
}
|
||||
installMainWindowWebviewSecurity({ webContents } as never)
|
||||
mocks.isAllowedPartition.mockReturnValue(true)
|
||||
const params = { src: 'https://example.com', preload: 'attacker.js' }
|
||||
const preferences: Record<string, unknown> = {
|
||||
partition: 'persist:orca-browser',
|
||||
preload: 'attacker.js',
|
||||
preloadURL: 'attacker.js',
|
||||
sandbox: false
|
||||
}
|
||||
|
||||
handlers['will-attach-webview']?.(
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
preferences as never,
|
||||
params as never
|
||||
)
|
||||
|
||||
expect(params).not.toHaveProperty('preload')
|
||||
expect(preferences).toMatchObject({
|
||||
...ORCA_BROWSER_GUEST_WEB_PREFERENCES,
|
||||
partition: 'persist:orca-browser',
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
sandbox: true,
|
||||
webSecurity: true
|
||||
})
|
||||
expect(preferences).not.toHaveProperty('preloadURL')
|
||||
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
|
||||
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
|
||||
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
|
||||
|
||||
export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): void {
|
||||
installPrivilegedWindowNavigationPolicy(mainWindow.webContents)
|
||||
// Why: containment must be listening before any plugin panel frame is created,
|
||||
// so register it with the window's other navigation policy.
|
||||
registerPluginPanelNavigationGuard(mainWindow.webContents)
|
||||
|
||||
const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js')
|
||||
mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => {
|
||||
const src = typeof params.src === 'string' ? params.src : ''
|
||||
const normalizedSrc = normalizeBrowserNavigationUrl(src)
|
||||
const partition = typeof webPreferences.partition === 'string' ? webPreferences.partition : ''
|
||||
|
||||
// Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest.
|
||||
if (!normalizedSrc || !browserSessionRegistry.isAllowedPartition(partition)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
delete params.preload
|
||||
// Why: preload runs in the page's main world before inline scripts can call window.close().
|
||||
webPreferences.preload = browserWindowClosePreload
|
||||
// Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge.
|
||||
delete (webPreferences as Record<string, unknown>).preloadURL
|
||||
webPreferences.nodeIntegration = false
|
||||
webPreferences.nodeIntegrationInSubFrames = false
|
||||
webPreferences.enableBlinkFeatures = ''
|
||||
webPreferences.disableBlinkFeatures = ''
|
||||
webPreferences.webSecurity = true
|
||||
webPreferences.allowRunningInsecureContent = false
|
||||
webPreferences.contextIsolation = true
|
||||
webPreferences.sandbox = true
|
||||
// Why: force the browser guest policy even if host markup omits or misspells a preference.
|
||||
Object.assign(webPreferences, ORCA_BROWSER_GUEST_WEB_PREFERENCES)
|
||||
// Why: keep the registry-validated partition so isolated session profiles use their own storage while other hardening stays intact.
|
||||
webPreferences.partition = partition
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
|
||||
// Why: attach guest popup/nav policy at creation; waiting for renderer registration races target=_blank/early redirects past it.
|
||||
browserManager.attachGuestPolicies(guest)
|
||||
})
|
||||
}
|
||||
@@ -30,7 +30,6 @@ src/main/automations/precheck-runner.ts
|
||||
src/main/browser/agent-browser-bridge.ts
|
||||
src/main/browser/browser-cookie-import.ts
|
||||
src/main/claude-accounts/keychain.ts
|
||||
src/main/cli/cli-installer.ts
|
||||
src/main/codex-accounts/runtime-home-service.ts
|
||||
src/main/codex-accounts/service.ts
|
||||
src/main/codex/codex-app-server-client.ts
|
||||
|
||||
@@ -12,7 +12,6 @@ main/automations/external-manager.ts
|
||||
main/automations/precheck-runner.ts
|
||||
main/browser/browser-cookie-import.ts
|
||||
main/claude-accounts/keychain.ts
|
||||
main/cli/cli-installer.ts
|
||||
main/computer/macos-computer-use-permission-status.ts
|
||||
main/computer/macos-computer-use-permissions.ts
|
||||
main/computer/macos-native-provider-transport.ts
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
/**
|
||||
* Collects output up to a cap, so a chatty child cannot grow the heap.
|
||||
*
|
||||
* Accepts strings as well as buffers: a stream someone called `setEncoding` on
|
||||
* emits strings, and concatenating those as buffers throws inside a `data`
|
||||
* handler, where the rejection has nowhere to go and the caller just hangs.
|
||||
*/
|
||||
export function createOutputSink(maxBytes: number): {
|
||||
write: (chunk: Buffer | string) => void
|
||||
text: () => string
|
||||
} {
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
return {
|
||||
write(raw) {
|
||||
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw)
|
||||
const remaining = maxBytes - bytes
|
||||
if (remaining <= 0) {
|
||||
return
|
||||
}
|
||||
chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk)
|
||||
bytes += chunk.length
|
||||
},
|
||||
text: () => Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line'
|
||||
import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination'
|
||||
|
||||
import { createOutputSink } from './bounded-output-sink'
|
||||
|
||||
export type ChildProcessHandle = ChildProcess
|
||||
|
||||
export type SpawnedProcess = ChildProcess
|
||||
@@ -37,7 +39,7 @@ export type ProcessSpec = {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Kill the process (and, on Windows, its console) after this long. */
|
||||
timeoutMs?: number
|
||||
timeoutMs?: number | null
|
||||
/** Written to stdin then closed. Omit to leave stdin empty and closed. */
|
||||
input?: string
|
||||
/** Cap on captured stdout/stderr; output past it is discarded. */
|
||||
@@ -151,33 +153,6 @@ export function spawnProcess(spec: ProcessSpec): ChildProcess {
|
||||
return nodeSpawn(resolved.file, [...resolved.args], resolved.options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects output up to a cap, so a chatty child cannot grow the heap.
|
||||
*
|
||||
* Accepts strings as well as buffers: a stream someone called `setEncoding` on
|
||||
* emits strings, and concatenating those as buffers throws inside a `data`
|
||||
* handler, where the rejection has nowhere to go and the caller just hangs.
|
||||
*/
|
||||
function createOutputSink(maxBytes: number): {
|
||||
write: (chunk: Buffer | string) => void
|
||||
text: () => string
|
||||
} {
|
||||
const chunks: Buffer[] = []
|
||||
let bytes = 0
|
||||
return {
|
||||
write(raw) {
|
||||
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw)
|
||||
const remaining = maxBytes - bytes
|
||||
if (remaining <= 0) {
|
||||
return
|
||||
}
|
||||
chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk)
|
||||
bytes += chunk.length
|
||||
},
|
||||
text: () => Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a child process to completion and capture its output.
|
||||
*
|
||||
@@ -355,11 +330,14 @@ export function runProcess(spec: ProcessSpec): Promise<ProcessResult> {
|
||||
graceTimer.unref?.()
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
stopAndSettle()
|
||||
}, spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS)
|
||||
timer.unref?.()
|
||||
const timer =
|
||||
spec.timeoutMs === null
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timedOut = true
|
||||
stopAndSettle()
|
||||
}, spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS)
|
||||
timer?.unref?.()
|
||||
|
||||
// Why the same escalation: an aborted caller has stopped waiting, so an
|
||||
// unkillable child must not keep the promise alive on their behalf either.
|
||||
@@ -430,7 +408,7 @@ export function runProcessSync(spec: ProcessSpec): ProcessResult {
|
||||
const result = nodeSpawnSync(resolved.file, [...resolved.args], {
|
||||
...resolved.options,
|
||||
input: spec.input,
|
||||
timeout: spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS,
|
||||
timeout: spec.timeoutMs === null ? undefined : (spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS),
|
||||
maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
||||
encoding: 'buffer'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user