diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 18022c5adce..6db719c20d3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/main/browser/grab-guest-content-script.ts b/src/main/browser/grab-guest-content-script.ts new file mode 100644 index 00000000000..fdea867d089 --- /dev/null +++ b/src/main/browser/grab-guest-content-script.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 + }; + } + +` diff --git a/src/main/browser/grab-guest-element-context-script.ts b/src/main/browser/grab-guest-element-context-script.ts new file mode 100644 index 00000000000..eb74cf875f7 --- /dev/null +++ b/src/main/browser/grab-guest-element-context-script.ts @@ -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; + } + +` diff --git a/src/main/browser/grab-guest-foundation-script.ts b/src/main/browser/grab-guest-foundation-script.ts new file mode 100644 index 00000000000..59d905337e0 --- /dev/null +++ b/src/main/browser/grab-guest-foundation-script.ts @@ -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); + } + +` diff --git a/src/main/browser/grab-guest-overlay-script.ts b/src/main/browser/grab-guest-overlay-script.ts new file mode 100644 index 00000000000..a7c2b097264 --- /dev/null +++ b/src/main/browser/grab-guest-overlay-script.ts @@ -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; +})()` diff --git a/src/main/browser/grab-guest-react-script.ts b/src/main/browser/grab-guest-react-script.ts new file mode 100644 index 00000000000..49884b22bc5 --- /dev/null +++ b/src/main/browser/grab-guest-react-script.ts @@ -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. +` diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index a86764a5733..c64e2c52cf3 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -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() diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index d1d089be1af..b6ef3f481a0 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -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('') diff --git a/src/main/browser/grab-guest-selection-scripts.ts b/src/main/browser/grab-guest-selection-scripts.ts new file mode 100644 index 00000000000..9eb3651ca67 --- /dev/null +++ b/src/main/browser/grab-guest-selection-scripts.ts @@ -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; +})()` diff --git a/src/main/cli/cli-command-inspection.ts b/src/main/cli/cli-command-inspection.ts new file mode 100644 index 00000000000..93712201fc5 --- /dev/null +++ b/src/main/cli/cli-command-inspection.ts @@ -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 { + 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 { + 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 { + 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 + } + } +} diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts new file mode 100644 index 00000000000..a3f38778197 --- /dev/null +++ b/src/main/cli/cli-command-installation.ts @@ -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 { + 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 { + 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 { + 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 { + await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') + } + + protected async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { + // 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 + }) + } +} diff --git a/src/main/cli/cli-dev-launcher.ts b/src/main/cli/cli-dev-launcher.ts new file mode 100644 index 00000000000..62884b2e027 --- /dev/null +++ b/src/main/cli/cli-dev-launcher.ts @@ -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 { + 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() +} diff --git a/src/main/cli/cli-install-constants.ts b/src/main/cli/cli-install-constants.ts new file mode 100644 index 00000000000..7769be0bcc1 --- /dev/null +++ b/src/main/cli/cli-install-constants.ts @@ -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 diff --git a/src/main/cli/cli-install-errors.ts b/src/main/cli/cli-install-errors.ts new file mode 100644 index 00000000000..a42b753e7c2 --- /dev/null +++ b/src/main/cli/cli-install-errors.ts @@ -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') + ) +} diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts new file mode 100644 index 00000000000..0d0321df34a --- /dev/null +++ b/src/main/cli/cli-install-location.ts @@ -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 + 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 + protected readonly userPathReader: () => Promise + protected readonly userPathMutationReader: () => Promise + protected readonly userPathWriter: (value: string) => Promise + 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 { + 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 { + 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 { + 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 + }) + } +} diff --git a/src/main/cli/cli-install-path-format.ts b/src/main/cli/cli-install-path-format.ts new file mode 100644 index 00000000000..1192594c270 --- /dev/null +++ b/src/main/cli/cli-install-path-format.ts @@ -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() + 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 { + 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) +} diff --git a/src/main/cli/cli-installer-contracts.ts b/src/main/cli/cli-installer-contracts.ts new file mode 100644 index 00000000000..5bb3bb99d7e --- /dev/null +++ b/src/main/cli/cli-installer-contracts.ts @@ -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 + userPathReader?: () => Promise + userPathMutationReader?: () => Promise + userPathWriter?: (value: string) => Promise + 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 +} diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 4bf23fc4190..bc1f8c452b2 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -1,130 +1,10 @@ -/* eslint-disable max-lines -- Why: this file centralizes cross-platform CLI install state, launcher resolution, and PATH registration so the public shell command stays consistent across packaged and development builds. */ -import { getAppEnvironment } from '../../shared/app-environment' -import { execFile } from 'node:child_process' -import { constants, existsSync } from 'node:fs' -import { - access, - lstat, - mkdir, - readFile, - readlink, - stat, - symlink, - unlink, - writeFile -} from 'node:fs/promises' -import { homedir } from 'node:os' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { promisify } from 'node:util' -import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' -import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' -import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' -import { - invalidateWindowsUserPathRegistryCache, - readFreshWindowsUserPathRegistry, - readWindowsUserPathRegistry, - type WindowsUserPathReadResult -} from './windows-user-path-registry' - -const execFileAsync = promisify(execFile) -const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' -const DEV_COMMAND_NAME = 'orca-dev' -const LEGACY_LINUX_COMMAND_NAME = 'orca' -const DEV_LAUNCHER_DIR = ['cli', 'bin'] -const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 - -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 - userPathReader?: () => Promise - userPathMutationReader?: () => Promise - userPathWriter?: (value: string) => Promise - 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 -} - -type InstallSpec = { - commandPath: string - installMethod: CliInstallMethod -} - -export class CliInstaller { - private readonly platform: NodeJS.Platform - private readonly isPackaged: boolean - private readonly userDataPath: string - private readonly resourcesPath: string - private readonly execPathValue: string - private readonly appPathValue: string - private readonly homePath: string - private readonly localAppDataPath: string - private readonly processPathEnv: string | null - private readonly commandPathOverride: string | null - private readonly macCommandPath: string - private readonly privilegedRunner: (command: string) => Promise - private readonly userPathReader: () => Promise - private readonly userPathMutationReader: () => Promise - private readonly userPathWriter: (value: string) => Promise - private readonly userPathCacheInvalidator: () => void - private readonly windowsEnvironment: NodeJS.ProcessEnv - private readonly appImagePath: string | null - - private 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 - } +import { mkdir, unlink } from 'node:fs/promises' +import { dirname } from 'node:path' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { CliPathRegistration } from './cli-path-registration' +export class CliInstaller extends CliPathRegistration { async getStatus(): Promise { const defaultSpec = this.resolveInstallSpec() if (!defaultSpec) { @@ -244,960 +124,6 @@ export class CliInstaller { return this.getStatus() } - - private 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 - } - - private async resolveActiveInstallSpec( - defaultSpec: InstallSpec, - launcherPath: string - ): Promise { - 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 - } - - private async findActivePathCommand( - launcherPath: string, - defaultCommandPath: string - ): Promise { - 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 - } - - private 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) - } - - private 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 - } - - private async resolveLauncherPath(): Promise { - 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 - }) - } - - private async installSymlink(status: CliInstallStatus): Promise { - 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)}` - ) - } - } - - private async removeSymlink(commandPath: string): Promise { - 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` - ) - } - } - - private async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise { - 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 - } - } - - private 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) - } - - private async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { - await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') - } - - private async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { - // 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 - }) - } - - private async inspectAppImageWrapper( - commandPath: string, - appImagePath: string - ): Promise { - 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 - } - } - - private async inspectSymlink( - commandPath: string, - launcherPath: string - ): Promise { - 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 - } - } - - private 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 - } - - private 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) - ) - } - - private isLinuxAppImage(): boolean { - return this.platform === 'linux' && Boolean(this.appImagePath) - } - - private isWindowsPackagedBundledCommand( - commandPath: string | null, - launcherPath: string | null - ): boolean { - return ( - this.platform === 'win32' && - this.isPackaged && - commandPath !== null && - launcherPath !== null && - samePathEntry('win32', commandPath, launcherPath) - ) - } - - private async inspectWindowsWrapper( - commandPath: string, - launcherPath: string - ): Promise { - 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 - } - } - - private 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 - } - } - - private 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 - } - } - - private 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.` - } - } - - private async ensureWindowsPathEntry(pathDirectory: string): Promise { - 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') - } - - private async removeWindowsPathEntry(pathDirectory: string): Promise { - 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') - } - - private 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). - private async writeWindowsUserPathEntry( - value: string, - pathDirectory: string, - action: 'add' | 'remove' - ): Promise { - 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 } - ) - } - } -} - -async function ensureDevLauncher(args: { - platform: NodeJS.Platform - userDataPath: string - execPath: string - cliEntryPath: string - commandName: string -}): Promise { - 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 -} - -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" "$@" -` -} - -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%" %* -` -} - -function buildWindowsForwarder(launcherPath: string): string { - return `@echo off -setlocal -set "ORCA_LAUNCHER=${escapeWindowsBatchValue(launcherPath)}" -"%ORCA_LAUNCHER%" %* -` -} - -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 -} - -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() -} - -function splitPathEntries(platform: NodeJS.Platform, value: string | null): string[] { - if (!value) { - return [] - } - return value - .split(platform === 'win32' ? ';' : ':') - .map((entry) => entry.trim()) - .filter(Boolean) -} - -function uniquePathEntries(platform: NodeJS.Platform, entries: string[]): string[] { - const seen = new Set() - 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 -} - -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 -} - -function isPathInsideOrEqual(parentPath: string, childPath: string): boolean { - const childRelative = relative(parentPath, childPath) - return childRelative === '' || (!childRelative.startsWith('..') && !isAbsolute(childRelative)) -} - -async function isExecutableFile(commandPath: string): Promise { - try { - const stats = await stat(commandPath) - if (!stats.isFile()) { - return false - } - await access(commandPath, constants.X_OK) - return true - } catch { - return false - } -} - -function normalizeWindowsPath( - value: string, - env: NodeJS.ProcessEnv = process.env, - expandEnvironmentVariables = true -): string { - return (expandEnvironmentVariables ? expandWindowsEnvironmentVariables(value, env) : value) - .replaceAll('/', '\\') - .replace(/\\+$/, '') - .toLowerCase() -} - -function escapeWindowsBatchValue(value: string): string { - return value.replaceAll('"', '""') -} - -function isPermissionError(error: unknown): boolean { - return ( - error instanceof Error && - 'code' in error && - ((error as NodeJS.ErrnoException).code === 'EACCES' || - (error as NodeJS.ErrnoException).code === 'EPERM') - ) -} - -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. -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') - ) -} - -function quoteShell(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'` -} - -async function runMacPrivilegedCommand(command: string): Promise { - await execFileAsync('osascript', [ - '-e', - `do shell script ${quoteAppleScript(command)} with administrator privileges` - ]) -} - -function quoteAppleScript(value: string): string { - return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` -} - -function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolean { - if (platform === 'win32') { - return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') - } - return isAbsolute(value) -} - -async function writeWindowsUserPath(value: string): Promise { - await runWindowsPathCommand([ - '-NoProfile', - '-Command', - // Why: user-scoped PATH avoids requiring elevation or mutating machine-wide state. - `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')` - ]) -} - -function runWindowsPathCommand(args: string[]): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error: Error | null, stdout = ''): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - return - } - resolve(stdout) - } - - // Why: bound wedged PowerShell so PATH reads/writes can't leave CLI registration pending forever. - const timeout = setTimeout(() => { - child?.kill() - finish(new Error(`Windows PATH command timed out after ${WINDOWS_PATH_WRITE_TIMEOUT_MS}ms.`)) - }, WINDOWS_PATH_WRITE_TIMEOUT_MS) - - try { - child = execFile( - 'powershell', - args, - { encoding: 'utf8', timeout: WINDOWS_PATH_WRITE_TIMEOUT_MS }, - (error, stdout) => { - finish(error ?? null, stdout) - } - ) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } - }) -} - -function quotePowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'` } export { getBundledLauncherPath } diff --git a/src/main/cli/cli-path-registration.ts b/src/main/cli/cli-path-registration.ts new file mode 100644 index 00000000000..4040d45bd05 --- /dev/null +++ b/src/main/cli/cli-path-registration.ts @@ -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 { + 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 { + 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 { + 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 } + ) + } + } +} diff --git a/src/main/cli/cli-privileged-processes.test.ts b/src/main/cli/cli-privileged-processes.test.ts new file mode 100644 index 00000000000..e391df0bc6b --- /dev/null +++ b/src/main/cli/cli-privileged-processes.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/cli/cli-privileged-processes.ts b/src/main/cli/cli-privileged-processes.ts new file mode 100644 index 00000000000..70e9e3cfb70 --- /dev/null +++ b/src/main/cli/cli-privileged-processes.ts @@ -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 { + 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 { + await runWindowsPathCommand([ + '-NoProfile', + '-Command', + `[Environment]::SetEnvironmentVariable('Path', ${quotePowerShell(value)}, 'User')` + ]) +} + +export async function runWindowsPathCommand(args: string[]): Promise { + 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("'", "''")}'` +} diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index f183805cddd..8d3cf1bb1de 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -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 - 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 { - 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 @@ -159,6 +35,7 @@ export class ModelManager { private progressCallbacks = new Set() 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 { - 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 { - 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 | 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 { - 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) } } diff --git a/src/main/speech/speech-model-download-cleanup.ts b/src/main/speech/speech-model-download-cleanup.ts new file mode 100644 index 00000000000..6406de23e70 --- /dev/null +++ b/src/main/speech/speech-model-download-cleanup.ts @@ -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 + } +} diff --git a/src/main/speech/speech-model-download-response.test.ts b/src/main/speech/speech-model-download-response.test.ts new file mode 100644 index 00000000000..2ea9a5cd2eb --- /dev/null +++ b/src/main/speech/speech-model-download-response.test.ts @@ -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) + }) +}) diff --git a/src/main/speech/speech-model-download-response.ts b/src/main/speech/speech-model-download-response.ts new file mode 100644 index 00000000000..ac1a3f09ecf --- /dev/null +++ b/src/main/speech/speech-model-download-response.ts @@ -0,0 +1,119 @@ +export type DownloadIncomingMessage = Electron.IncomingMessage & + NodeJS.ReadableStream & { + headers: Record + 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 { + 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 }) + }) +} diff --git a/src/main/speech/speech-model-download-transport.ts b/src/main/speech/speech-model-download-transport.ts new file mode 100644 index 00000000000..fcdc1fd150f --- /dev/null +++ b/src/main/speech/speech-model-download-transport.ts @@ -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 { + 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 { + 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) + }) + } +} diff --git a/src/main/speech/speech-model-http-download.ts b/src/main/speech/speech-model-http-download.ts new file mode 100644 index 00000000000..ae2bae9648e --- /dev/null +++ b/src/main/speech/speech-model-http-download.ts @@ -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 { + 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 | 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() + }) + } +} diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index dac90bec357..85bd70c8748 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -1,221 +1,35 @@ -/* oxlint-disable max-lines */ -import { - app, - BrowserWindow, - ipcMain, - Menu, - nativeTheme, - Notification, - powerMonitor, - screen -} from 'electron' -import { join } from 'node:path' +import { BrowserWindow, nativeTheme, powerMonitor, screen } from 'electron' import { is } from '@electron-toolkit/utils' -import type { Store } from '../persistence' +import { join } from 'node:path' import { getAppIconPath } from '../app-icon' import { browserManager } from '../browser/browser-manager' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { translateMain } from '../i18n/main-i18n' -import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' -import { isCrashReportReason } from '../../shared/crash-reporting' import { markSystemSessionEnding } from '../crash-reporting/expected-teardown-state' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' -import { - DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES, - DEFAULT_RENDERER_RECOVERY_WINDOW_MS, - RendererRecoveryCircuitBreaker -} from '../crash-reporting/renderer-recovery-circuit-breaker' -import { - getWindowShortcutActionId, - matchesRecentTabSwitcherChord, - nativeZoomCommandMatchesKeybindings, - resolveWindowShortcutAction, - windowShortcutActionCapturesTerminal, - type WindowShortcutAction -} from '../../shared/window-shortcut-policy' -import { - ModifierDoubleTapDetector, - toModifierDoubleTapEvent -} from '../../shared/modifier-double-tap-detector' -import { - normalizeTerminalShortcutPolicy, - type KeybindingMatchOptions, - type KeybindingOverrides -} from '../../shared/keybindings' -import { getMainE2EConfig } from '../e2e-config' -import { - buildEditableContextMenuTemplate, - matchingRichMarkdownContextMenuTableTarget, - parseRichMarkdownContextMenuTableTarget -} from './editable-context-menu' -import { - richMarkdownContextMenuTargetChannel, - type RichMarkdownContextMenuTableTarget -} from '../../shared/rich-markdown-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' -import { resolveWindowCloseAction } from './window-close-decision' -import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' +import type { Store } from '../persistence' import { closeDashboardPopout } from './dashboard-popout-window' -import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' -import { isMacosTahoeOrNewer } from './macos-tahoe-release' -import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { + installMainWindowCloseLifecycle, + WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS +} from './main-window-close-lifecycle' +import type { CreateMainWindowOptions } from './main-window-contracts' +import { installMainWindowFocusLifecycle } from './main-window-focus-lifecycle' +import { installMainWindowShortcutRouting } from './main-window-shortcut-routing' +import { installMainWindowStateLifecycle } from './main-window-state-lifecycle' +import { + forceRepaint, + installMacosVisibilityRepaint, + MIN_HEIGHT, + MIN_WIDTH, + TITLEBAR_CSS_CENTER, + TRAFFIC_LIGHT_RADIUS, + TRAFFIC_LIGHT_X +} from './main-window-visual-lifecycle' +import { installMainWindowWebviewSecurity } from './main-window-webview-security' +import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' import { installWindowsPathRegistryChangeListener } from '../pty/windows-path-registry-change' -// Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. -const activeRepaintJiggles = new WeakSet() -export const WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS = 10_000 - -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) -} - -function installMacosVisibilityRepaint(window: BrowserWindow): void { - let delayedRepaintTimer: ReturnType | 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) - }) -} - -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. -const TITLEBAR_CSS_CENTER = 18 -const TRAFFIC_LIGHT_RADIUS = 6 -const TRAFFIC_LIGHT_X = 16 -const MIN_WIDTH = 600 -const MIN_HEIGHT = 400 - -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 }) -} - -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 -} +export { WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS } export function loadMainWindow(mainWindow: BrowserWindow): void { if (is.dev && process.env.ELECTRON_RENDERER_URL) { @@ -341,845 +155,39 @@ export function createMainWindow( } powerMonitor.on('resume', onSystemResume) - 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) - } + const state = installMainWindowStateLifecycle({ + mainWindow, + revealOnDidFinishLoad: opts?.revealOnDidFinishLoad === true, + savedMaximized, + store + }) + installMainWindowWebviewSecurity(mainWindow) + const focus = installMainWindowFocusLifecycle({ + isWindowClosing: state.isWindowClosing, + mainWindow, + opts, + reloadMainWindow: () => loadMainWindow(mainWindow), + rendererWebContentsId + }) + installMainWindowShortcutRouting({ focus, mainWindow, opts, store }) + const closeLifecycle = installMainWindowCloseLifecycle({ + focus, + mainWindow, + opts, + rendererWebContentsId, + state, + store }) - // 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 | 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 (opts?.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 | 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) - }) - - 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).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) - }) - - // 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 | 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) || - windowClosing || - opts?.getIsQuitting?.() || - opts?.shouldRecoverRenderer?.(details, rendererWebContentsId) === false || - mainWindow.isDestroyed() - ) { - return - } - rendererRecoveryTimer = setTimeout(() => { - rendererRecoveryTimer = null - if ( - windowClosing || - 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) - loadMainWindow(mainWindow) - }, 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 (!windowClosing) { - // Why: the recorder owns crash classification; filtering here made expected-teardown evidence unreachable. - opts?.onRendererProcessGone?.(details, rendererWebContentsId) - } - if (!windowClosing) { - 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 doubleTapDetector = new ModifierDoubleTapDetector() - - // Why: one mapping of action → IPC/side effect, shared by the keydown and double-tap paths so they can't drift. - const sendResolvedWindowShortcutAction = (action: WindowShortcutAction): 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': - opts?.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) - } - } - - const dispatchResolvedWindowShortcutAction = ( - event: Electron.Event, - action: WindowShortcutAction, - options: { - isAutoRepeat: boolean - focusedShortcutContext: KeybindingMatchOptions - } - ): boolean => { - const { focusedShortcutContext, isAutoRepeat } = options - if ( - floatingTerminalInputFocused && - (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 (floatingPanelFocused && 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(action) - return true - } - - mainWindow.webContents.on('before-input-event', (event, input) => { - if (shortcutRecorderFocused) { - 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: terminalInputFocused || floatingTerminalInputFocused ? '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 ( - markdownEditorFocused && - 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: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app', - terminalShortcutPolicy: normalizeTerminalShortcutPolicy( - store?.getSettings().terminalShortcutPolicy - ) - } - ) - ) { - return - } - event.preventDefault() - mainWindow.webContents.send('terminal:zoom', zoomDirection) - }) - - // 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 | 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') - 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' || - rendererProcessGone || - 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, - 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). - windowClosing = true - if (boundsTimer) { - clearTimeout(boundsTimer) - boundsTimer = null - } - 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. - windowClosing = false - 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) mainWindow.on('closed', () => { - // Why: the dashboard pop-out is a companion of the main window — close it - // alongside so it never orphans as a lone window after the app window is - // gone (e.g. on macOS where the app stays alive after the window closes). closeDashboardPopout() - clearInitialRevealFallbackTimer() - clearQuitRendererAckTimer() - // Why: default-deny the Cmd+B carve-out after the window is gone so a stale-true flag can't leak into later state. - markdownEditorFocused = false - terminalInputFocused = false - floatingTerminalInputFocused = false - floatingPanelFocused = false - shortcutRecorderFocused = false - clearRendererRecoveryTimer() - ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) - ipcMain.removeListener(minimizeChannel, onMinimize) - ipcMain.removeListener(maximizeChannel, onMaximize) + state.clearInitialRevealFallbackTimer() + closeLifecycle.dispose() + focus.dispose() browserManager.setDictationShortcutForwardingPredicate(null) - ipcMain.removeListener(requestCloseChannel, onRequestClose) - ipcMain.removeListener(popupMenuChannel, onPopupMenu) - ipcMain.removeHandler(isMaximizedChannel) - ipcMain.removeListener(confirmCloseChannel, onConfirmClose) - ipcMain.removeListener(closeRequestReceivedChannel, onCloseRequestReceived) - ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused) - ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) - ipcMain.removeListener(floatingFocusChannel, onFloatingFocus) - ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) - ipcMain.removeListener(richMarkdownContextMenuTargetChannel, onRichMarkdownContextMenuTarget) - // Why: powerMonitor is app-global; without this the resume relay leaks and fires against a destroyed webContents. powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) - // Why: on updater shutdown 'closed' can fire after webContents is destroyed, so don't touch mainWindow.webContents here. - app.removeListener('before-quit', freezeBoundsOnQuit) + state.dispose() }) if (!opts?.deferLoad) { diff --git a/src/main/window/main-window-close-lifecycle.ts b/src/main/window/main-window-close-lifecycle.ts new file mode 100644 index 00000000000..cbfeafd1357 --- /dev/null +++ b/src/main/window/main-window-close-lifecycle.ts @@ -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 | 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 } +} diff --git a/src/main/window/main-window-contracts.ts b/src/main/window/main-window-contracts.ts new file mode 100644 index 00000000000..ce5c6cfe0b2 --- /dev/null +++ b/src/main/window/main-window-contracts.ts @@ -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 +} diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts new file mode 100644 index 00000000000..c83acc87d13 --- /dev/null +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -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 | 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 + } +} diff --git a/src/main/window/main-window-shortcut-actions.ts b/src/main/window/main-window-shortcut-actions.ts new file mode 100644 index 00000000000..40f5920254a --- /dev/null +++ b/src/main/window/main-window-shortcut-actions.ts @@ -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) + } +} diff --git a/src/main/window/main-window-shortcut-routing.ts b/src/main/window/main-window-shortcut-routing.ts new file mode 100644 index 00000000000..71e1cb44451 --- /dev/null +++ b/src/main/window/main-window-shortcut-routing.ts @@ -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) + }) +} diff --git a/src/main/window/main-window-state-lifecycle.ts b/src/main/window/main-window-state-lifecycle.ts new file mode 100644 index 00000000000..74c14bf1895 --- /dev/null +++ b/src/main/window/main-window-state-lifecycle.ts @@ -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 | 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 | 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 + } +} diff --git a/src/main/window/main-window-visual-lifecycle.ts b/src/main/window/main-window-visual-lifecycle.ts new file mode 100644 index 00000000000..f26b823d60d --- /dev/null +++ b/src/main/window/main-window-visual-lifecycle.ts @@ -0,0 +1,123 @@ +import { ipcMain, type BrowserWindow } from 'electron' +import { isMacosTahoeOrNewer } from './macos-tahoe-release' + +const activeRepaintJiggles = new WeakSet() +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 | 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 }) +} diff --git a/src/main/window/main-window-webview-security.test.ts b/src/main/window/main-window-webview-security.test.ts new file mode 100644 index 00000000000..40ece6f1dbc --- /dev/null +++ b/src/main/window/main-window-webview-security.test.ts @@ -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 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 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 = { + 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$/) + }) +}) diff --git a/src/main/window/main-window-webview-security.ts b/src/main/window/main-window-webview-security.ts new file mode 100644 index 00000000000..86f2eb3edaa --- /dev/null +++ b/src/main/window/main-window-webview-security.ts @@ -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).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) + }) +} diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 5dffe626906..d0933a1203e 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -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 diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 0d275d272db..227f3742761 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -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 diff --git a/src/shared/child-process/bounded-output-sink.ts b/src/shared/child-process/bounded-output-sink.ts new file mode 100644 index 00000000000..c231234ef1a --- /dev/null +++ b/src/shared/child-process/bounded-output-sink.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') + } +} diff --git a/src/shared/child-process/run-process.ts b/src/shared/child-process/run-process.ts index c00b8024648..bd0507608a3 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/shared/child-process/run-process.ts @@ -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 { 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' })