mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix(macos): add a Full Disk Access nudge to reduce recurring TCC prompts (#9756) macOS shows the "Orca wants to access other apps' data" (kTCCServiceSystemPolicyAppData) prompt and it can keep reappearing. The reappearing loop is not a fixable app bug: it is TCC identity churn — an unsigned local rebuild mints a new code identity each build, so macOS treats each as a new app — and Orca's other-app reads are already gated behind opt-in settings or explicit user actions. The durable remedy for the population we can help (release users) is Full Disk Access, a superset macOS grant that stops these prompts for a stable identity. Surface it with an ambient, dismissable sidebar card that reuses the existing developer-permissions IPC. macOS-only; probes FDA status at most once per renderer session (the probe itself reads protected data, so it must not repeat on focus/remount); "Open System Settings" opens the Full Disk Access pane; permanent localStorage dismissal. * fix(macos): stop the FDA nudge promising macOS will stop asking The card said Full Disk Access makes "macOS stop asking", but the grant covers this app while terminals are spawned by the detached PTY daemon (daemon-init.ts forks execPath with ELECTRON_RUN_AS_NODE + detached:true, reparented to launchd), which macOS treats as its own TCC identity. A user who followed the card would grant FDA and still be prompted from terminals. Scope the claim to reducing prompts and name the terminal caveat. * fix(macos): drop stale focus refreshes in the FDA nudge refreshFullDiskAccessStatus() applied whichever getStatus() round-trip resolved last. Rapid blur/focus puts several in flight, so an earlier pre-grant 'unknown' landing after a newer 'granted' un-hid the card and also wrote 'unknown' into the module-level session cache, re-nagging a user who already has Full Disk Access for the rest of the session. The adjacent FullDiskAccessSetupPrompt already guards this with a refresh sequence; mirror it here. Also unmount React roots in afterEach: clearing document.body left them mounted, leaking each test's window focus listener into later tests. * test(macos): unmount the StrictMode FDA nudge root between tests The afterEach unmount added in5a0f717only covers roots created through renderNudge(). The StrictMode probe test builds its own root, so it was never unmounted and its component stayed live for the rest of the file. Today that component has no window focus listener, so nothing breaks; add a CTA click to it and the same contamination5a0f717fixed comes back — the two tests after it see extra getStatus() calls and fail. Register the root so the fix covers every mount site. * fix(macos): attribute the FDA prompts to agent activity, not Orca's own reads The card said the prompts happen "when this copy of Orca reads protected app data", but Orca's own reads are small and gated; #9756's trigger is agent find/grep sweeps into ~/Library/Containers, which macOS bills to Orca because Orca is the responsible process for every terminal child. Blaming Orca reads as an accusation and hid why FDA works at all — the grant attaches to Orca rather than to each churning child binary. Name agents as the trigger, keep the "reduce" hedge and the terminal caveat, and drop the "this copy of Orca" dev-build hedge that cost a clause. Assert the causation wording so it can't silently regress. * fix(macos): explain the TCC prompts on the settings row, drop the sidebar card The sidebar nudge added in344d466bwas premised on FDA being reachable "only inside onboarding". It isn't: Settings > macOS Permissions has had a full-disk-access row all along (searchable), the Setup Guide hosts the same prompt from both a settings pane and a re-openable modal, and the sidebar already links to that modal via the "Onboarding checklist" entry. The card added a fifth affordance to the same sidebar that already had the fourth, so it bought prominence rather than access - shown to every macOS user without FDA, most of whom never hit #9756. Keep the part that was actually new. The settings row still described the prompts as something projects and worktrees trigger, which is the same misattribution the card carried: the reads come from the agents Orca runs, and macOS names Orca only because it is the responsible process for every terminal child. It also never mentioned that the grant has to cover Orca Helper, or that the preserved daemon keeps stale TCC state until restart. Non-English catalogs get the English string as a placeholder; the bootstrap translators key their cache on the English value, so a changed string is re-translated on the next run. * feat(macos): nudge Full Disk Access only after macOS repeatedly prompts The FDA hint is only worth showing to users macOS is actually prompting. tccd emits one AUTHREQ_PROMPTING line per consent dialog it displays, carrying the service and both identities, so a narrow log-stream predicate detects the real thing without correlating across lines or guessing whether a dialog appeared. Verified against a captured dialog: the predicate matched 1 line out of 1436 TCC lines in ~28s, because routine preflight checks - the overwhelming majority of TCC traffic - do not emit it. Count dialogs where Orca is the responsible process, persist across launches, and tell the renderer on the third one. The event separates the accessing binary from the responsible app, which is the crux of #9756, so the toast can name the tool that triggered it rather than blaming Orca generically. One toast per user, with a permanent opt-out; it deep-links to the FDA row in Settings > macOS Permissions rather than restating the guidance. macOS-only: the watcher no-ops elsewhere, the web client stubs the API, and the child is killed on before-quit since log stream ignores a closed stdout. * test(macos): pin the platform so the TCC watcher tests exercise the darwin path start() is darwin-gated, so on Linux CI it no-opped and the stream/kill assertions passed vacuously against a watcher that never spawned. Pin process.platform per the existing convention (shared/secure-file.test.ts), and cover the gate itself with an explicit non-darwin case. * fix(macos): start the TCC watcher from app bootstrap, not the window wiring attachMainWindowServices is called directly by its own unit test, so wiring initTccPromptNotice there made `vitest src/main/window/` spawn real `log stream` children that outlived the run - two orphaned watchers were left behind by a single test session. Only the IPC handler registration stays there; the spawn moves to the real app bootstrap in index.ts, which tests never execute. Verified: running the suite that leaked now leaves the watcher count unchanged. * fix(macos): clarify repeated permission notice * fix(macos): keep TCC notice lifecycle safe * fix(macos): retain pending TCC notice delivery * fix(macos): acknowledge TCC notice delivery * fix(macos): release failed TCC notice claims * fix(macos): retry transient TCC notice display * fix(macos): contain TCC notice IPC failures * fix(macos): harden TCC notice renderer lifecycle * fix(macos): contain TCC notice dismissal failures * test(macos): satisfy promise executor lint * fix(macos): detect helper-attributed TCC prompts * fix(macos): align TCC watcher lifecycle and helper identity * perf(macos): defer TCC log reader until first paint * fix(macos): recover deferred TCC watcher startup * fix(macos): recover TCC watcher from deferred quit * fix(macos): localize recurring file access notice * fix(macos): preserve TCC watcher and localized guidance * fix(macos): avoid duplicate TCC watcher recovery * fix(macos): wait for locale before TCC notice * perf(macos): isolate TCC notice subscriptions --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
613 lines
19 KiB
JavaScript
613 lines
19 KiB
JavaScript
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
|
||
// v7: give the terminal daemon helper an Orca-specific TCC identity.
|
||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
|
||
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'
|
||
)
|
||
]
|
||
|
||
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}`
|
||
)
|
||
}
|
||
|
||
// 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)
|
||
})
|