Files
orca/config/scripts/run-electron-vite-dev.mjs
T
NeilandOrca 25ea2bbfd1 onboarding: seamless macOS notification permission step with live state detection (#7684)
* feat(onboarding): state-aware macOS notification permission step

The Set up notifications step showed a one-size-fits-all 'Open Mac
Settings' button that simultaneously fired the macOS permission prompt
and opened System Settings — two competing system UIs, with System
Settings unnecessary for the common fresh-install case.

Electron exposes no API to read macOS notification authorization, but
scheduling outcomes do reveal it: a silent probe notification's 'show'
event means permission is granted, 'failed' means delivery is blocked.
A new notifications:probeDelivery IPC runs that probe (cached via
passive delivery evidence and a persisted confirmation flag), and the
onboarding card now renders the real state:

- fresh install: the probe itself pops the native Allow dialog the
  moment the step opens; the card flips to 'Notifications are enabled'
  automatically when the user clicks Allow (silent 2.5s re-probes)
- blocked: amber card with an Open System Settings deep-link, which
  also self-heals once the user flips the toggle
- granted: green confirmation card

The test-notification button now feeds the same card instead of the
ambiguous 'if no banner appeared…' toast during onboarding.

Co-authored-by: Orca <help@stably.ai>

* fix: don't log expected probe rejections while polling for permission

Co-authored-by: Orca <help@stably.ai>

* fix: amber warning styling + single stable dev bundle id for notifications

- Blocked card now uses the app's shipped amber idiom (tinted surface with
  amber title/body) instead of white-on-amber-wash, which read muddy in
  dark mode; macOS permission card split into its own module to stay under
  the max-lines budget.
- Dev instances previously minted a unique macOS bundle id per
  branch x Electron version, registering a new Notification Settings entry
  every time ('Orca: <branch>' rows piling up forever) and pointing the
  settings deep-link at ids System Settings can't resolve. All dev
  instances now share com.stablyai.orca.dev: one Notification Center
  entry, one permission grant covering every dev build.

Co-authored-by: Orca <help@stably.ai>

* fix: tighten macOS permission card copy

Body copy was one long sentence; now a single short instruction with
'Updates automatically.' as a separate dimmer line. Also repairs locale
catalog parity for keys introduced by commits rebased into this branch.

Co-authored-by: Orca <help@stably.ai>

* fix: drop 'Updates automatically.' line; ad-hoc sign dev app copies

The extra line read as confusing filler — the cards now carry one short
instruction each.

Dev Electron copies had broken code signatures (the Info.plist identity
edits invalidate the ad-hoc seal), which macOS punishes by refusing
Notification Center registration outright: every dev notification failed
with UNErrorDomain error 1, the app never appeared in System Settings >
Notifications, and the settings deep-link had nothing to land on. The dev
runner now ad-hoc re-signs the copied bundle after the plist edits
(bundleLayoutVersion bumped so stale unsigned copies are recreated).
Verified end-to-end: runner-built copy passes codesign --verify --deep,
probe delivery returns delivered, the onboarding card flips green in dev,
and the deep link opens the dev app's own notifications pane.

Co-authored-by: Orca <help@stably.ai>

* fix: drop confusing copy line; session-only permission evidence

Removes the 'Updates automatically.' line from both permission cards.

Also drops the persisted notificationDeliveryConfirmed flag: OS-level
permission changes between sessions, and a stale positive rendered a
false green card. Delivery evidence is now session-scoped only.

Documented detection ceiling (verified empirically on macOS 26): while
the permission dialog is unanswered — and when notifications are toggled
off in System Settings after being authorized — macOS accepts requests
and silently swallows them, with no public API (Notification Center
delivered-history and legacy ncprefs both included) able to distinguish
that from real delivery. 'failed' remains definitive for unsigned builds
and dialog-level denials.

Co-authored-by: Orca <help@stably.ai>

* feat: real macOS notification permission readout via native helper

Electron has no API for UNUserNotificationCenter authorization, and every
observable fallback lies: scheduling succeeds (and getHistory lists the
notification) even while macOS silently swallows display because the
permission dialog is unanswered or notifications were toggled off in
System Settings. The onboarding card therefore showed 'enabled' after the
user disabled notifications.

Adds native/notification-status-macos: a tiny Swift binary that prints
the app's real authorization status. It runs from inside the app bundle
(NSBundle resolves the bundle by walking up from the executable) and
embeds the app's CFBundleIdentifier in a __TEXT,__info_plist section so
every codesign --force pass — electron-builder's signing or the dev
runner's ad-hoc deep sign — derives the identifier macOS keys
notification records to. Spawning it from the app returns authorized /
denied / not-determined exactly matching System Settings.

notifications:probeDelivery now prefers this readout (authoritative,
silent), firing at most one dialog-trigger probe per session while the
decision is pending, and falls back to the previous delivery-probe
heuristics when the helper is unavailable. The card polls the readout
silently in every state, so toggling Allow notifications in System
Settings flips the card within a poll — both directions, verified live.
Test notifications also consult the readout so 'delivered' is no longer
claimed for swallowed notifications.

Packaged builds ship the helper via extraResources and sign it in
afterPack like the computer-use helper; dev copies compile it on demand
(swiftc, non-fatal when missing) with the shared dev bundle id.

Co-authored-by: Orca <help@stably.ai>

* feat: in-app fallback for swallowed notifications + permission card in Settings

- Dispatch now consults the authorization readout before creating a
  native notification: when macOS would silently swallow it (denied or
  prompt unanswered) it returns reason 'blocked-by-system' instead of
  piling invisible notifications into Notification Center. The terminal
  notification path surfaces that as a once-per-session in-app toast
  with an Open System Settings action. Mobile fan-out is unaffected.
- Settings > Notifications now shows the same live permission card as
  onboarding (moved to components/notifications/), polling the readout
  so System Settings changes reflect within seconds, and the test
  button updates it inline.
- Test sends that are blocked at the OS level now show the
  settings-pointing failure toast instead of a generic error.

Co-authored-by: Orca <help@stably.ai>

* fix: hide macOS permission card while Orca notifications are disabled

A green 'Notifications are enabled' card next to a disabled Enable
Notifications toggle read as a contradiction — the card now renders (and
the readout polls) only while Orca's own notifications setting is on.
Also single-flights the authorization helper so simultaneous agent
completions share one readout process.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-08 22:21:40 -07:00

628 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 {
chmodSync,
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'
// 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
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
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'
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'
)
]
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')
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// 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}`
)
}
// 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 binDir = path.join(repoRoot, 'out', 'bin')
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
const electronBin = getElectronExecutable()
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'orca-dev.cmd'),
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
'utf8'
)
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
mkdirSync(userDataBinDir, { recursive: true })
for (const commandName of ['orca-dev', 'orca']) {
const userDataWrapperPath = path.join(userDataBinDir, commandName)
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
// `orca` alias prevents stale global/userData wrappers from hijacking
// Orca-owned commands such as `orca claude-teams`.
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
chmodSync(userDataWrapperPath, 0o755)
}
}
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)
})