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:
Neil
2026-08-25 00:31:31 -07:00
committed by GitHub
parent 1cf562deea
commit 83ffc0df24
41 changed files with 4083 additions and 3646 deletions
-4
View File
@@ -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;
})()`
+152
View File
@@ -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()
+19 -929
View File
@@ -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;
})()`
+278
View File
@@ -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
}
}
}
+109
View File
@@ -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
})
}
}
+131
View File
@@ -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()
}
+5
View File
@@ -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
+33
View File
@@ -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')
)
}
+232
View File
@@ -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
})
}
}
+85
View File
@@ -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)
}
+30
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+155
View File
@@ -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
})
})
})
+55
View File
@@ -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("'", "''")}'`
}
+14 -556
View File
@@ -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()
})
}
}
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 }
}
+32
View File
@@ -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')
}
}
+12 -34
View File
@@ -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'
})