import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
renameSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
isDevBundleInUse,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { startDevStorageReclaim } from './dev-storage-reclaim.mjs'
import {
announceDevInstance,
dropInheritedDevInstanceEnv,
resolveDevInstancePorts,
resolveDevProfile,
writeWorktreeProfileMarker
} from './dev-instance-isolation.mjs'
import { getDefaultSeedSource, profileHasState, seedDevProfile } from './dev-profile-seed.mjs'
import {
captureDevWebClientInputs,
isDevWebClientFresh,
recordDevWebClientInputs
} from './dev-web-client-freshness.mjs'
import { withRolldownThreadLimit } from './rolldown-thread-limit.mjs'
import { withDevRolldownRuntimeEnv } from './dev-rolldown-runtime-env.mjs'
import { copyPrivateTree } from './space-sharing-copy.mjs'
import { buildDevSwiftCliHelpers } from './swift-cli-helper-macos.mjs'
import {
DEV_BUNDLE_ID,
getDevBundlePlistPatches,
getDevHelperPlistPatches
} from './dev-electron-bundle-identity.mjs'
// Clear inherited ELECTRON_RUN_AS_NODE from Electron-hosted terminals or child boots plain Node and returns npm electron stub.
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)
// Runner-only escape hatch for tools requiring stock Electron name; never forward to Chromium/electron-vite.
const useStableElectronName =
process.env.ORCA_DEV_STABLE_NAME === '1' || rawForwardedArgs.includes(STABLE_NAME_FLAG)
const SHARED_PROFILE_FLAG = '--shared-profile'
const WORKTREE_PROFILE_FLAG = '--worktree-profile'
const SEED_PROFILE_FLAG = '--seed-profile'
const isSeedProfileArg = (arg) =>
arg === SEED_PROFILE_FLAG || arg.startsWith(`${SEED_PROFILE_FLAG}=`)
const seedProfileArg = rawForwardedArgs.findLast(isSeedProfileArg)
// Why: profile flags are runner-only, like --stable-name.
const forwardedRaw = rawForwardedArgs.filter(
(arg) =>
arg !== STABLE_NAME_FLAG &&
arg !== SHARED_PROFILE_FLAG &&
arg !== WORKTREE_PROFILE_FLAG &&
!isSeedProfileArg(arg)
)
const requestedProfileMode = rawForwardedArgs.includes(SHARED_PROFILE_FLAG)
? 'shared'
: rawForwardedArgs.includes(WORKTREE_PROFILE_FLAG)
? 'worktree'
: undefined
// `--seed-profile` uses the default source; `--seed-profile=
` or ORCA_DEV_SEED_PROFILE= pick one.
const seedProfileRequest =
seedProfileArg?.slice(SEED_PROFILE_FLAG.length + 1) ?? process.env.ORCA_DEV_SEED_PROFILE ?? null
if (useStableElectronName) {
process.env.ORCA_DEV_STABLE_NAME = '1'
}
// Why: --help/--version exit immediately; binding probe sockets or printing instance lines is noise.
const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
dropInheritedDevInstanceEnv(process.env, repoRoot)
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) {
// Seed stable per-worktree identity for window/Dock/automation labels without Git calls from 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 pruneStaleDevBundles(distDir) {
const root = path.dirname(distDir)
let bundles
try {
bundles = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME)),
mtimeMs: getMtimeMs(dir)
}
})
} catch {
return
}
if (bundles.length <= 1) {
return
}
const processTable = getDevBundleProcessTable()
if (processTable === null) {
return
}
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: distDir,
processTable,
nowMs: Date.now()
})
for (const dir of stale) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {}
}
}
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
// v11 stabilizes Info.plist/cdhash; ignored stale marker fields permit schema narrowing without another bump.
const bundleLayoutVersion = 'stable-cdhash-dock-name-from-bundle-dir-v11'
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)
// Dock uses .app directory display name outside code signature, preserving per-branch label without varying cdhash.
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, DEV_BUNDLE_MARKER_FILENAME)
// Use one dev bundle ID for shared notification permission/settings entry. Concurrent instances may lose clicks routed to other instance; unknown IDs are dropped.
const bundleId = DEV_BUNDLE_ID
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
// Include patch values in reuse marker; layout version alone can reuse stale app identity.
const expectedMarker = JSON.stringify(
{
title,
appBundleName,
bundleId,
sourceAppPath,
electronVersion,
bundleLayoutVersion,
plistPatches: [...getDevBundlePlistPatches(), ...getDevHelperPlistPatches()]
},
null,
2
)
const executablePath = path.join(appPath, 'Contents', 'MacOS', 'Electron')
// Require Chromium resources to avoid blank crash; optional swiftc keyboard helper only degrades keyboard behavior when absent.
const chromiumResourcePath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework',
'Resources',
'icudtl.dat'
)
const requiredResourcePaths = [
chromiumResourcePath,
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout'),
path.join(appPath, 'Contents', 'MacOS', 'orca-process-footprint')
]
function copiedAppIsUsable() {
if (!existsSync(markerPath) || !existsSync(appPath)) {
return false
}
try {
if (readFileSync(markerPath, 'utf8') !== expectedMarker) {
return false
}
} catch {
return false
}
// Interrupted copy may leave marker/executable but omit Chromium resources, causing blank crash.
return (
existsSync(executablePath) &&
requiredResourcePaths.every((resourcePath) => existsSync(resourcePath))
)
}
if (copiedAppIsUsable()) {
pruneStaleDevBundles(distDir)
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
// Never delete live bundle on unsigned cache miss: re-copy would remove running app mid-session; reuse its current tree.
const rebuildProcessTable = getDevBundleProcessTable()
if (
rebuildProcessTable !== null &&
isDevBundleInUse(distDir, rebuildProcessTable) &&
// Reject live-looking broken bundle despite orphan crashpad PID; test required Chromium resources only, not optional swiftc helper, to avoid deleting usable app.
existsSync(executablePath) &&
existsSync(chromiumResourcePath)
) {
console.warn(
`[orca-dev] Another dev instance is running from this bundle; reusing it instead of rebuilding. Quit the other instance (or delete ${distDir}) to force a rebuild.`
)
process.env.ELECTRON_EXEC_PATH = executablePath
return
}
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
// Clone ~280MB bundle first; only plist/helper/signature bytes vary per branch/version.
copyPrivateTree(sourceAppPath, appPath)
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
const helperPlistPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Helper.app',
'Contents',
'Info.plist'
)
// Keep Info.plist values constant: branch-varying sealed bytes change cdhash and trigger Keychain prompts. Put branch label in .app directory name outside signature.
for (const { key, value } of getDevBundlePlistPatches()) {
setPlistValue(plistPath, key, value)
}
for (const { key, value } of getDevHelperPlistPatches()) {
setPlistValue(helperPlistPath, key, value)
}
// Bundle notification helper with dev signing ID because macOS keys authorization by it; without swiftc use delivery-probe fallback.
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}`
)
}
buildDevSwiftCliHelpers(path.join(appPath, 'Contents', 'MacOS'))
// Re-sign after copy/plist edits or macOS rejects notification registration (UNErrorDomain 1); signing failure stays nonfatal for dev.
let signed = true
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
signed = false
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
// Write reuse marker only after signing: otherwise unsigned cache permanently loses notifications/stable Keychain identity; retry copying next launch instead.
if (signed) {
writeFileSync(markerPath, expectedMarker, 'utf8')
}
pruneStaleDevBundles(distDir)
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
}
// Restore flattened relative framework symlinks after copy so Chromium finds canonical bundle resources.
ensureRelativeSymlink(path.join(versionsPath, 'Current'), 'A')
for (const entry of ['Electron Framework', 'Resources', 'Libraries', 'Helpers']) {
ensureRelativeSymlink(path.join(frameworkPath, entry), `Versions/Current/${entry}`)
}
}
function readGitCommonDir() {
const value = readGitValue(['rev-parse', '--git-common-dir'])
return value ? path.resolve(repoRoot, value) : null
}
async function prepareDevProfile({ kind, userDataPath }) {
if (kind === 'worktree') {
// Why: storage reclaim uses this marker to delete the profile once its worktree is removed.
writeWorktreeProfileMarker(userDataPath, {
repoRoot,
instanceKey: process.env.ORCA_DEV_INSTANCE_KEY,
gitCommonDir: readGitCommonDir()
})
}
if (seedProfileRequest === null || profileHasState(userDataPath)) {
return
}
const sourceDir =
seedProfileRequest === '' || seedProfileRequest === '1'
? getDefaultSeedSource()
: path.resolve(seedProfileRequest)
try {
await seedDevProfile({ sourceDir, targetDir: userDataPath })
} catch (error) {
console.error(`[orca-dev] Profile seed skipped: ${error.message}`)
}
}
function prepareDevCliWrapper(userDataPath) {
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')
}
seedDevInstanceIdentityEnv()
const devProfile = resolveDevProfile({
repoRoot,
instanceKey: process.env.ORCA_DEV_INSTANCE_KEY,
mode: requestedProfileMode
})
// Why: Electron main reads this; without it main falls back to the shared orca-dev profile.
process.env.ORCA_DEV_USER_DATA_PATH = devProfile.userDataPath
if (!isHelpOrVersion) {
await prepareDevProfile(devProfile)
}
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
prepareDevCliWrapper(devProfile.userDataPath)
}
if (!useStableElectronName && process.env.ORCA_SKIP_DEV_ELECTRON_APP_PREPARE !== '1') {
prepareMacDevElectronApp()
}
// Inject fake CLI to test Ctrl+C tree teardown without real electron-vite dependency.
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 prepareDevWebClient() {
if (process.env.ORCA_SKIP_DEV_WEB_PREPARE === '1' || isHelpOrVersion) {
return
}
// Start fresh worktree immediately: pairing can use non-browser URLs while 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(repoRoot)) {
return
}
const inputs = captureDevWebClientInputs(repoRoot)
console.error('[orca-dev] Building web client for pairing in the background...')
// Pairing reads out/web per request, so a stale bundle must not delay Electron startup.
// Stage then swap, so an interrupted build never leaves out/web empty.
const stagingDir = path.join(repoRoot, 'out', '.web-next')
const build = spawn(
process.execPath,
[
viteCli,
'build',
'--config',
path.join(repoRoot, 'vite.web.config.ts'),
'--outDir',
stagingDir,
'--logLevel',
'warn'
],
{ cwd: repoRoot, stdio: 'inherit', env: withRolldownThreadLimit(process.env) }
)
webClientBuild = build
try {
os.setPriority(build.pid, os.constants.priority.PRIORITY_BELOW_NORMAL)
} catch {
// Priority is best effort; the build still runs at normal priority.
}
build.once('error', (error) => {
console.error(`[orca-dev] Web client build failed to start: ${error.message}`)
})
build.once('exit', (code, signal) => {
webClientBuild = null
if (code !== 0) {
if (!isShuttingDown) {
console.error(
`[orca-dev] Web client build failed (${signal ?? code}); pairing serves the previous bundle`
)
}
return
}
try {
const outDir = path.dirname(getDevWebClientIndexPath())
rmSync(outDir, { recursive: true, force: true })
renameSync(stagingDir, outDir)
recordDevWebClientInputs(repoRoot, inputs)
console.error('[orca-dev] Web client for pairing is ready')
} catch (error) {
console.error(`[orca-dev] Could not install the web client build: ${error.message}`)
}
})
}
if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) {
console.error(`[orca-dev] Instance: ${process.env.ORCA_DEV_INSTANCE_LABEL}`)
}
// Log background-launch mode so automation’s focus-preserving behavior is visible.
if (!isHelpOrVersion && process.env.ORCA_BACKGROUND_LAUNCH === '1') {
console.error('[orca-dev] Background launch: window stays off screen; automate through CDP')
}
let forwardedExtras = []
if (!isHelpOrVersion) {
const ports = await resolveDevInstancePorts({
forwardedArgs: forwardedRaw,
instanceKey: process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
})
forwardedExtras = ports.forwardedExtras
announceDevInstance({ repoRoot, profile: devProfile, ...ports })
}
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], {
stdio: 'inherit',
env: withDevRolldownRuntimeEnv(process.env),
// Own process group so Ctrl+C terminates electron-vite’s Electron descendants on macOS too.
detached: process.platform !== 'win32'
})
let webClientBuild = null
let isShuttingDown = false
child.once('spawn', () => {
if (!isHelpOrVersion) {
startDevStorageReclaim({ repoRoot })
}
prepareDevWebClient()
})
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
webClientBuild?.kill()
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)
}
// A half-written out/web must not outlive the session that started it.
webClientBuild?.kill()
if (isShuttingDown) {
process.exit(signalExitCode(signal ?? 'SIGINT'))
return
}
if (signal) {
process.exit(signalExitCode(signal))
return
}
process.exit(code ?? 1)
})