Files
orca/config/scripts/run-electron-vite-dev.mjs
T
Neil 9f3a912c1e fix(terminal): type Option-composed ASCII instead of reporting it as a chord (#14743)
* fix(terminal): preserve Option-composed ASCII input

* fix(terminal): preserve Option keyboard protocol semantics

* fix(terminal): complete Option keyboard event encoding

* fix(terminal): harden Option input encoding

* fix(terminal): close keyboard protocol fallback gaps

* test(terminal): prove Option-composed ASCII reaches the pty end to end

The Option-compose fix had unit coverage only. This drives a live Electron
pane whose kitty flags are armed by the application's own CSI > 1 u and
asserts the bytes at the pty boundary: composed `@` and Shift-layer `\`
arrive as text, configured Option-as-Alt still reports the layout-resolved
chord, and a non-ASCII glyph still reaches the app as its alt hotkey.
Restoring the pre-fix policy fails exactly the two composed-text scenarios.

Also records the ASCII rule's rationale where the rule lives, not only in a
test comment.

* refactor(terminal): drop the unread Option layers from the layout snapshot

The native helper computed an Option and Option+Shift character for every
key, shipped both over IPC, validated them in the parser and cached them in
the renderer — but no production caller ever asked for them. Only the base
and Shift layers are read, and Shift is the one the web layout map cannot
supply, which is why the helper exists at all.

Removing them halves the helper's UCKeyTranslate work per key and drops the
option parameter that six signatures were threading through for nobody.
2026-08-16 12:49:02 -07:00

632 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
// the electron-vite spawn, the Electron binary boots as plain Node and
// require('electron') returns the npm stub instead of the built-in API.
delete process.env.ELECTRON_RUN_AS_NODE
const require = createRequire(import.meta.url)
const repoRoot = path.resolve(import.meta.dirname, '../..')
const STABLE_NAME_FLAG = '--stable-name'
const rawForwardedArgs = process.argv.slice(2)
// Why: keep an escape hatch for tools that key off Electron's stock app name.
// The flag is runner-only and must not leak into Chromium/electron-vite.
const useStableElectronName =
process.env.ORCA_DEV_STABLE_NAME === '1' || rawForwardedArgs.includes(STABLE_NAME_FLAG)
const forwardedRaw = rawForwardedArgs.filter((arg) => arg !== STABLE_NAME_FLAG)
if (useStableElectronName) {
process.env.ORCA_DEV_STABLE_NAME = '1'
}
function readGitValue(args) {
try {
const value = execFileSync('git', ['-C', repoRoot, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return value || null
} catch {
return null
}
}
function lastBranchSegment(value) {
return value.replace(/\\/g, '/').split('/').findLast(Boolean) ?? value
}
function formatDevInstanceLabel(branch, worktreeName) {
if (branch && worktreeName) {
if (branch === worktreeName || lastBranchSegment(branch) === worktreeName) {
return worktreeName
}
return `${worktreeName} @ ${branch}`
}
return branch || worktreeName || null
}
function createDockTitle(branch, label) {
return `Orca: ${branch || label || 'dev'}`
}
function seedDevInstanceIdentityEnv() {
const branch =
process.env.ORCA_DEV_BRANCH ||
readGitValue(['symbolic-ref', '--quiet', '--short', 'HEAD']) ||
readGitValue(['rev-parse', '--short', 'HEAD'])
const worktreeName = process.env.ORCA_DEV_WORKTREE_NAME || path.basename(repoRoot)
const label = process.env.ORCA_DEV_INSTANCE_LABEL || formatDevInstanceLabel(branch, worktreeName)
const identitySeed = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const dockTitle = process.env.ORCA_DEV_DOCK_TITLE || createDockTitle(branch, label)
process.env.ORCA_DEV_REPO_ROOT ||= repoRoot
process.env.ORCA_DEV_INSTANCE_KEY ||= identitySeed
if (branch) {
process.env.ORCA_DEV_BRANCH ||= branch
}
if (worktreeName) {
process.env.ORCA_DEV_WORKTREE_NAME ||= worktreeName
}
if (label) {
// Why: parallel `pn dev` runs need a stable origin label for window titles,
// Dock names, and automation sessions without re-running git in Electron.
process.env.ORCA_DEV_INSTANCE_LABEL ||= label
}
process.env.ORCA_DEV_DOCK_TITLE ||= dockTitle
}
function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
const code = char.charCodeAt(0)
return code < 32 || code === 127 || char === '/' || char === '\\' ? '-' : char
})
.join('')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || 'Orca'
)
}
function prepareMacDevElectronApp() {
if (process.platform !== 'darwin') {
return
}
const sourceAppPath = path.join(repoRoot, 'node_modules', 'electron', 'dist', 'Electron.app')
const electronPackagePath = path.join(repoRoot, 'node_modules', 'electron', 'package.json')
if (!existsSync(sourceAppPath)) {
return
}
let electronVersion = null
try {
electronVersion = JSON.parse(readFileSync(electronPackagePath, 'utf8')).version ?? null
} catch {}
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v10: add the keyboard-layout helper. A stale copy only emits extra fields
// the parser ignores, so narrowing its schema needs no bump.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v10'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
)
.digest('hex')
.slice(0, 12)
const distDir = path.join(repoRoot, 'out', 'electron-dev', hash)
// Why: macOS Dock hover uses the bundle's filesystem display name for
// electron-vite's direct binary launch path, even when Info.plist is patched.
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
// settings deep-link (System Settings can't resolve an id it has no entry
// for and falls back to the root list). macOS keys notification permission
// by bundle id, so a single id also means granting notifications to one dev
// instance covers all of them. Trade-off: when two dev instances run at
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
const helperBundleId = `${bundleId}.helper`
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
null,
2
)
const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Electron')
const requiredResourcePaths = [
path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
),
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
]
function copiedAppIsUsable() {
if (!existsSync(markerPath) || !existsSync(appPath)) {
return false
}
try {
if (readFileSync(markerPath, 'utf8') !== expectedMarker) {
return false
}
} catch {
return false
}
// Why: a previous interrupted copy can leave the marker and executable
// present but miss Chromium framework resources, causing a blank crash.
return (
existsSync(executablePath) &&
requiredResourcePaths.every((resourcePath) => existsSync(resourcePath))
)
}
if (copiedAppIsUsable()) {
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
// Why: Electron.framework uses relative symlinks for its bundle resources;
// resolving them to pnpm-store absolutes breaks Chromium's bundle lookup.
cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true })
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
const helperPlistPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Helper.app',
'Contents',
'Info.plist'
)
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
// API). It must live inside the bundle and carry the dev bundle id as its
// embedded/code-sign identifier — macOS keys notification records to the
// signing identifier. Non-fatal: without swiftc the permission card falls
// back to delivery-probe heuristics.
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
'--bundle-id',
bundleId,
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
)
}
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-keyboard-layout-macos.mjs'),
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] keyboard-layout helper build failed (shifted Option composition stays conservative): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
// error 1 and the app never appears in System Settings > Notifications.
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = executablePath
}
function isSymlink(filePath) {
try {
return lstatSync(filePath).isSymbolicLink()
} catch {
return false
}
}
function ensureRelativeSymlink(linkPath, target) {
if (isSymlink(linkPath)) {
try {
if (readlinkSync(linkPath) === target) {
return
}
} catch {}
}
const targetPath = path.join(path.dirname(linkPath), target)
if (!existsSync(targetPath)) {
return
}
rmSync(linkPath, { recursive: true, force: true })
symlinkSync(target, linkPath)
}
function restoreElectronFrameworkSymlinks(appPath) {
const frameworkPath = path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework')
const versionsPath = path.join(frameworkPath, 'Versions')
if (!existsSync(path.join(versionsPath, 'A'))) {
return
}
// Why: some Electron installs have framework symlinks flattened into
// duplicate directories. Recreate the relative bundle links after copying so
// Chromium resolves resources through the canonical macOS framework layout.
ensureRelativeSymlink(path.join(versionsPath, 'Current'), 'A')
for (const entry of ['Electron Framework', 'Resources', 'Libraries', 'Helpers']) {
ensureRelativeSymlink(path.join(frameworkPath, entry), `Versions/Current/${entry}`)
}
}
function getDevUserDataPath() {
if (process.env.ORCA_DEV_USER_DATA_PATH) {
return process.env.ORCA_DEV_USER_DATA_PATH
}
if (process.platform === 'darwin') {
return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
return path.join(
process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'),
'orca-dev'
)
}
return path.join(
process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'),
'orca-dev'
)
}
function prepareDevCliWrapper() {
const userDataPath = getDevUserDataPath()
const { binDir } = prepareDevCliTerminalWrappers({
repoRoot,
userDataPath,
electronExecutable: getElectronExecutable()
})
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
}
function getElectronExecutable() {
if (process.platform === 'win32') {
return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe')
}
return path.join(repoRoot, 'node_modules', '.bin', 'electron')
}
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
prepareDevCliWrapper()
}
seedDevInstanceIdentityEnv()
if (!useStableElectronName && process.env.ORCA_SKIP_DEV_ELECTRON_APP_PREPARE !== '1') {
prepareMacDevElectronApp()
}
// Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down
// the full child tree without depending on a real electron-vite install.
const electronViteCli =
process.env.ORCA_ELECTRON_VITE_CLI ||
path.join(path.dirname(require.resolve('electron-vite/package.json')), 'bin', 'electron-vite.js')
const viteCli =
process.env.ORCA_VITE_CLI ||
path.join(path.dirname(require.resolve('vite/package.json')), 'bin', 'vite.js')
function getMtimeMs(filePath) {
try {
return statSync(filePath).mtimeMs
} catch {
return 0
}
}
function getDevWebClientIndexPath() {
return path.join(repoRoot, 'out', 'web', 'web-index.html')
}
function latestMtimeMs(targetPath) {
const stat = (() => {
try {
return statSync(targetPath)
} catch {
return null
}
})()
if (!stat) {
return 0
}
if (!stat.isDirectory()) {
return stat.mtimeMs
}
let latest = stat.mtimeMs
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
continue
}
latest = Math.max(latest, latestMtimeMs(path.join(targetPath, entry.name)))
}
return latest
}
function isDevWebClientFresh() {
const outputMtime = getMtimeMs(getDevWebClientIndexPath())
if (outputMtime === 0) {
return false
}
const sourceMtime = Math.max(
latestMtimeMs(path.join(repoRoot, 'vite.web.config.ts')),
latestMtimeMs(path.join(repoRoot, 'src', 'renderer')),
latestMtimeMs(path.join(repoRoot, 'src', 'shared')),
latestMtimeMs(path.join(repoRoot, 'src', 'preload', 'api-types.ts'))
)
return sourceMtime <= outputMtime
}
function prepareDevWebClient() {
if (process.env.ORCA_SKIP_DEV_WEB_PREPARE === '1' || isHelpOrVersion) {
return
}
// Why: fresh worktrees should start Electron immediately; pairing already
// falls back to non-browser URLs when the optional web bundle is unavailable.
if (!existsSync(getDevWebClientIndexPath()) && process.env.ORCA_DEV_WEB_PREPARE !== '1') {
console.error(
'[orca-dev] Web client bundle missing; skipping pairing web build. Run `pnpm run build:web` or set ORCA_DEV_WEB_PREPARE=1 when you need browser pairing.'
)
return
}
if (isDevWebClientFresh()) {
return
}
console.error('[orca-dev] Building web client for pairing...')
execFileSync(
process.execPath,
[viteCli, 'build', '--config', path.join(repoRoot, 'vite.web.config.ts')],
{
cwd: repoRoot,
stdio: 'inherit',
env: process.env
}
)
}
// Why: every `pn dev` should be attachable from agent-browser/playwright-cli
// without manual port juggling. Pick a best-effort deterministic port per
// worktree; falls back to a probe sweep if the deterministic pick or its
// neighbors are busy (multiple worktrees may share a machine).
function isPortFree(port) {
return new Promise((resolve) => {
const srv = net.createServer()
srv.once('error', () => {
// Why: error fires before listen binds; close() may throw — swallow it
// so the handle is released without leaking listeners across 64 probes.
try {
srv.close()
} catch {}
resolve(false)
})
srv.once('listening', () => srv.close(() => resolve(true)))
srv.listen(port, '127.0.0.1')
})
}
async function pickDebugPort() {
// Why: 32 bits of SHA1 (vs 16) reduces truncation bias; modulo 200 still
// collides routinely across many worktrees, hence the probe sweep below.
const seed = Number.parseInt(createHash('sha1').update(repoRoot).digest('hex').slice(0, 8), 16)
const base = 9333 + (seed % 200) // deterministic base in 9333..9532; probe sweeps up to base+63
for (let i = 0; i < 64; i++) {
const p = base + i
if (await isPortFree(p)) {
return p
}
}
return null
}
function parseDebugPortEnv(raw) {
const n = Number.parseInt(raw, 10)
if (!Number.isInteger(n) || n < 1 || n > 65535 || String(n) !== raw.trim()) {
return null
}
return n
}
// Why: exact match (or `=` form) avoids false positives on hypothetical
// `--remote-debugging-port-*` flags; the bare flag also covers the
// space-separated form. `--remote-debugging-pipe` opts into pipe-based
// debugging — don't fight the user's choice by injecting a port.
const userPassedPort = forwardedRaw.some(
(a) =>
a === '--remote-debugging-port' ||
a.startsWith('--remote-debugging-port=') ||
a === '--remote-debugging-pipe'
)
// Why: --help/--version exit immediately; binding a probe socket and printing
// a debug-port line would be noise.
const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) {
console.error(`[orca-dev] Instance: ${process.env.ORCA_DEV_INSTANCE_LABEL}`)
}
let forwardedExtras = []
if (!userPassedPort && !isHelpOrVersion) {
const envPortRaw = process.env.REMOTE_DEBUGGING_PORT
let port = null
if (envPortRaw) {
port = parseDebugPortEnv(envPortRaw)
if (port === null) {
console.error(
`[orca-dev] Ignoring invalid REMOTE_DEBUGGING_PORT=${JSON.stringify(envPortRaw)}; falling back to probe.`
)
}
}
if (port === null) {
port = await pickDebugPort()
}
if (port !== null) {
forwardedExtras = [`--remote-debugging-port=${port}`]
// Why: stderr keeps stdout clean for downstream parsing; log uses
// 127.0.0.1 to match the interface we actually probed (localhost may
// resolve to ::1 on IPv6-first hosts).
console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`)
} else {
console.error(
'[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.'
)
}
}
prepareDevWebClient()
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], {
stdio: 'inherit',
env: process.env,
// Why: electron-vite launches Electron as a descendant process. Giving the
// dev runner its own process group lets Ctrl+C kill the whole tree on macOS
// instead of leaving the Electron app alive after the terminal exits.
detached: process.platform !== 'win32'
})
let isShuttingDown = false
let forcedKillTimer = null
function signalExitCode(signal) {
if (signal === 'SIGINT') {
return 130
}
if (signal === 'SIGTERM') {
return 143
}
return 1
}
function terminateChild(signal) {
if (!child.pid) {
return
}
if (process.platform === 'win32') {
const taskkill = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
taskkill.unref()
return
}
try {
process.kill(-child.pid, signal)
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? error.code : null
if (code !== 'ESRCH') {
throw error
}
}
}
function beginShutdown(signal) {
if (isShuttingDown) {
return
}
isShuttingDown = true
terminateChild(signal)
forcedKillTimer = setTimeout(() => {
terminateChild('SIGKILL')
}, 5000)
}
process.on('SIGINT', () => {
beginShutdown('SIGINT')
})
process.on('SIGTERM', () => {
beginShutdown('SIGTERM')
})
child.on('error', (error) => {
if (forcedKillTimer) {
clearTimeout(forcedKillTimer)
}
console.error(error)
process.exit(1)
})
child.on('exit', (code, signal) => {
if (forcedKillTimer) {
clearTimeout(forcedKillTimer)
}
if (isShuttingDown) {
process.exit(signalExitCode(signal ?? 'SIGINT'))
return
}
if (signal) {
process.exit(signalExitCode(signal))
return
}
process.exit(code ?? 1)
})