175 lines
4.7 KiB
TypeScript
175 lines
4.7 KiB
TypeScript
import path from 'node:path'
|
|
|
|
interface FirstRunSetupBackend {
|
|
activeRoot?: string
|
|
kind?: string
|
|
platform?: string
|
|
}
|
|
|
|
interface FirstRunSetupGateOptions {
|
|
isRuntimeAvailable?: (backend: FirstRunSetupBackend) => boolean
|
|
hideChoice?: () => void
|
|
log?: (message: string) => void
|
|
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
|
promptChoice?: (backend: FirstRunSetupBackend) => void
|
|
stuckAfterMs?: number
|
|
}
|
|
|
|
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' | 'runtime-ready'
|
|
|
|
export function createFirstRunSetupGate({
|
|
isRuntimeAvailable,
|
|
hideChoice,
|
|
log,
|
|
onStuck,
|
|
promptChoice,
|
|
stuckAfterMs = 120000
|
|
}: FirstRunSetupGateOptions = {}) {
|
|
let localBootstrapConfirmed = false
|
|
|
|
let waiter: {
|
|
activeRoot?: string
|
|
promise: Promise<FirstRunSetupDecision>
|
|
resolve: (decision: FirstRunSetupDecision) => void
|
|
} | null = null
|
|
|
|
let stuckTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const clearStuckTimer = () => {
|
|
if (stuckTimer) {
|
|
clearTimeout(stuckTimer)
|
|
stuckTimer = null
|
|
}
|
|
}
|
|
|
|
const armStuckTimer = (backend: FirstRunSetupBackend) => {
|
|
clearStuckTimer()
|
|
|
|
if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') {
|
|
return
|
|
}
|
|
|
|
stuckTimer = setTimeout(() => {
|
|
onStuck?.(backend, stuckAfterMs)
|
|
log(
|
|
`[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` +
|
|
`(platform=${backend?.platform || 'unknown'})`
|
|
)
|
|
}, stuckAfterMs)
|
|
|
|
if (typeof stuckTimer.unref === 'function') {
|
|
stuckTimer.unref()
|
|
}
|
|
}
|
|
|
|
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
|
|
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
|
|
|
|
const wait = async (backend?: FirstRunSetupBackend | null) => {
|
|
if (!shouldGate(backend)) {
|
|
return 'continue-local' as const
|
|
}
|
|
|
|
// Recheck synchronously before registering the waiter: another profile
|
|
// may have completed installation before this stale descriptor arrived.
|
|
if (backend && isRuntimeAvailable?.(backend)) {
|
|
if (backend.activeRoot) runtimeAvailable(backend.activeRoot)
|
|
return 'runtime-ready' as const
|
|
}
|
|
|
|
if (waiter) {
|
|
return waiter.promise
|
|
}
|
|
|
|
promptChoice?.(backend)
|
|
armStuckTimer(backend)
|
|
|
|
let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {}
|
|
|
|
const promise = new Promise<FirstRunSetupDecision>(resolve => {
|
|
resolveWaiter = resolve
|
|
})
|
|
|
|
waiter = { activeRoot: backend?.activeRoot, promise, resolve: resolveWaiter }
|
|
|
|
return promise
|
|
}
|
|
|
|
const settleWaiter = (decision: FirstRunSetupDecision) => {
|
|
clearStuckTimer()
|
|
|
|
if (!waiter) {
|
|
return false
|
|
}
|
|
|
|
const activeWaiter = waiter
|
|
waiter = null
|
|
activeWaiter.resolve(decision)
|
|
|
|
return true
|
|
}
|
|
|
|
const continueLocal = () => {
|
|
localBootstrapConfirmed = true
|
|
settleWaiter('continue-local')
|
|
}
|
|
|
|
// A sibling profile may finish installing the SAME shared runtime while the
|
|
// primary boot is awaiting a setup choice. Re-resolve it; do not interpret
|
|
// this as permission for another installation, or reuse the sibling's
|
|
// profile/backend descriptor.
|
|
const runtimeAvailable = (root: string) => {
|
|
const canonical = (value: string) => {
|
|
const absolute = path.resolve(value)
|
|
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
}
|
|
if (!waiter?.activeRoot || canonical(waiter.activeRoot) !== canonical(root)) return false
|
|
settleWaiter('runtime-ready')
|
|
hideChoice?.()
|
|
return true
|
|
}
|
|
|
|
const resetForRetry = () => {
|
|
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
|
// Settle the old boot explicitly so it cannot fall through into local
|
|
// bootstrap and cannot leak a forever-pending connection promise.
|
|
settleWaiter('reset')
|
|
}
|
|
|
|
const resetForRepair = () => {
|
|
resetForRetry()
|
|
localBootstrapConfirmed = false
|
|
}
|
|
|
|
const abandonForRemoteApply = () => {
|
|
// Resume the gated startHermes() with an explicit remote decision. The
|
|
// caller re-resolves the newly-persisted remote config instead of falling
|
|
// through into local bootstrap or leaking the original connection promise.
|
|
const resumedWaiter = settleWaiter('remote-applied')
|
|
|
|
if (!resumedWaiter) {
|
|
return false
|
|
}
|
|
|
|
localBootstrapConfirmed = false
|
|
hideChoice?.()
|
|
|
|
return true
|
|
}
|
|
|
|
const isLocalBootstrapConfirmed = () => localBootstrapConfirmed
|
|
const hasWaiter = () => Boolean(waiter)
|
|
|
|
return {
|
|
abandonForRemoteApply,
|
|
continueLocal,
|
|
hasWaiter,
|
|
isLocalBootstrapConfirmed,
|
|
resetForRepair,
|
|
resetForRetry,
|
|
runtimeAvailable,
|
|
shouldGate,
|
|
wait
|
|
}
|
|
}
|