mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
* fix(startup): stop the PATH seed pinning nvm to its newest install patchPackagedProcessPath prepends the newest nvm version dir to process.env.PATH, then hydrateShellPath probes the login shell with that same env. nvm's startup `use` honors whatever node is already on PATH instead of the user's `default` alias, so the probe returns a PATH pinned to the newest install and every terminal pane inherits it. A user whose newest nvm node is a bare install then loses every global CLI (codex, claude, gemini, vercel...) inside Orca while they still resolve in Ghostty/Terminal, which start from the bare GUI PATH and fall through to `default`. Probe with the PATH the process launched with. Windows already gets this through WindowsShellPathOwnership. * test(startup): pin the platform in the probe-env test shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed for anyone running the suite on Windows. Matches the convention in hydrate-shell-path.windows.test.ts. Also narrows the win32 exemption comment: WindowsShellPathOwnership snapshots its baseline after the seeds land, so it does not unwind them. The exemption holds because Windows keys PATH as `Path` and no Windows seed pins a node version. * fix(startup): give win32 the same probe insulation, keyed by Path The previous commit exempted win32 on the stated grounds that no Windows seed pins a node version. That is wrong: getVersionManagerDirectories calls getNvmVersionDirectories on every platform, so a Git Bash user whose nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir seeded on Windows too, and the -ilc Git Bash probe inherits it. Record the PATH key alongside the value and overwrite that entry in place, so Windows never carries both `Path` and `PATH` — which was the only real reason to skip win32. * fix(startup): snapshot the launch PATH at module load, log probe failures Three loose ends from the review, folded in rather than deferred. The probe's clean PATH was handed over by an explicit recordLaunchPath call from the seeding site, so the invariant lived across three files and a refactor that moved the seed call would silently re-pin nvm. Snapshot PATH during module init instead: that runs while the import graph is evaluated, strictly before any statement in main's body, so it cannot observe the seeds and there is no call ordering left to break. All seven importers are static, so no lazy import can defeat it. A test asserts the probe ignores later process.env mutation, and fails if the live read is reintroduced. A failed startup probe leaves the seeded newest-nvm dir in front and said nothing, so the population whose rc files blow the 5s budget hit the original symptom with no diagnosable trace. Log the failureReason. The probe is an interactive login shell, so rc files that exec into a multiplexer or start a heavy prompt can outrun that budget with no way to opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path. * fix(startup): drop the other-cased PATH key from the Windows probe env Caught by running the suite on a real Windows machine, not a mocked platform. The spread of process.env is a plain, case-sensitive object, while Windows resolves env names case-insensitively. Writing the captured `Path` back onto it left the seeded value still live under `PATH`, so the probe shell could read either one — the exact duplicate-key hazard the win32 branch was supposed to prevent. Drop any other-cased variant of the key before writing. * fix(startup): preserve launch PATH across app restarts
2354 lines
86 KiB
TypeScript
2354 lines
86 KiB
TypeScript
/* eslint-disable max-lines */
|
|
import { app, BrowserWindow, powerMonitor } from 'electron'
|
|
import { is } from '@electron-toolkit/utils'
|
|
import type {
|
|
LinuxPackageInstallInstructions,
|
|
LinuxPackageInstallRecovery,
|
|
UpdateCheckOptions,
|
|
UpdateSource,
|
|
UpdateStatus
|
|
} from '../shared/update-status-types'
|
|
import type {
|
|
RemoteServerUpdateInstallResult,
|
|
RemoteServerUpdaterSnapshot,
|
|
RemoteServerUpdateSupport
|
|
} from '../shared/remote-server-update'
|
|
import {
|
|
isWindowsSignatureCheckUnavailableFailure,
|
|
isWindowsSignatureMismatchFailure
|
|
} from '../shared/updater-windows-signature-check'
|
|
import { killAllPty } from './ipc/pty'
|
|
import { withUpdaterSpan } from './observability/instrumentation'
|
|
import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader'
|
|
import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe'
|
|
import { runWithLaunchPath } from './startup/hydrate-shell-path'
|
|
import {
|
|
beginMacUpdateDownload,
|
|
deferMacQuitUntilInstallerReady,
|
|
isMacInstallerReady,
|
|
markMacQuitAndInstallInFlight,
|
|
resetMacInstallState
|
|
} from './updater-mac-install'
|
|
import {
|
|
armUpdateInstallExitWatchdog,
|
|
disarmUpdateInstallExitWatchdog
|
|
} from './update-install-exit-watchdog'
|
|
import { registerAutoUpdaterHandlers } from './updater-events'
|
|
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
|
import { getLinuxRootPackageType } from './linux-update-package-type'
|
|
import {
|
|
beginLinuxPackageInstallDiagnosticCapture,
|
|
createUpdaterDiagnosticLogger,
|
|
endLinuxPackageInstallDiagnosticCapture,
|
|
getLinuxPackageInstallDiagnostic,
|
|
parseLinuxPackageInstallExitCode,
|
|
redactLinuxPackageInstallText,
|
|
type LinuxPackageInstallDiagnostic
|
|
} from './linux-package-install-diagnostic'
|
|
import {
|
|
clearTrackedLinuxPackageArtifact,
|
|
getTrackedLinuxPackageArtifact,
|
|
resolveLinuxPackageInstallInstructions,
|
|
revalidateLinuxPackageForInstall,
|
|
revealLinuxPackage,
|
|
type LinuxPackageArtifact,
|
|
type LinuxPackageRecoveryUnavailableReason
|
|
} from './linux-package-update-recovery'
|
|
import {
|
|
compareVersions,
|
|
isBenignCheckFailure,
|
|
isMissingUpdateManifestFailure,
|
|
isPrereleaseVersion,
|
|
statusesEqual
|
|
} from './updater-fallback'
|
|
import {
|
|
fetchNewerReleaseTagsWithReadiness,
|
|
getReleaseDownloadUrl
|
|
} from './updater-prerelease-feed'
|
|
import { fetchNudge, shouldApplyNudge } from './updater-nudge'
|
|
import {
|
|
failServeUpdateHandoff,
|
|
getServeUpdateHandoffFailure,
|
|
hasServeUpdateSupervisor,
|
|
requestServeUpdateHandoff
|
|
} from './serve-update-handoff'
|
|
import type { LocalBuildFeed } from './local-builds/local-build-feed-server'
|
|
import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds'
|
|
import {
|
|
DEV_CHANNEL_PLATFORM_LABEL,
|
|
getVersionChannel,
|
|
hasDedicatedReleaseRepo,
|
|
isChannelSupportedOnPlatform,
|
|
RELEASE_CHANNEL_LABELS,
|
|
requiresManualDevChannelInstall,
|
|
type ReleaseBuild,
|
|
type ReleaseChannel
|
|
} from '../shared/release-channel'
|
|
|
|
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
|
|
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
|
|
type PrimaryEventSuppression = { failureKey: string; error: unknown }
|
|
type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
|
|
type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready'
|
|
// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text.
|
|
class ReleaseFeedPreflightError extends Error {
|
|
constructor(
|
|
readonly reason: ReleaseFeedPreflightFailure,
|
|
readonly releaseChannel: UpdateCheckVariant,
|
|
message: string
|
|
) {
|
|
super(message)
|
|
this.name = 'ReleaseFeedPreflightError'
|
|
}
|
|
}
|
|
type ReleaseFeedPreflightResult = 'ready' | 'not-available'
|
|
export type UpdateInstallMode =
|
|
| 'interactive'
|
|
| 'supervised-headless-serve'
|
|
| 'unsupported-headless-serve'
|
|
|
|
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
|
// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets.
|
|
const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000
|
|
const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000
|
|
const QUIT_AND_INSTALL_DELAY_MS = 100
|
|
const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500
|
|
const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000
|
|
const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000
|
|
|
|
let mainWindowRef: BrowserWindow | null = null
|
|
let currentStatus: UpdateStatus = { state: 'idle' }
|
|
let userInitiatedCheck = false
|
|
let onBeforeQuitCleanup: (() => void | Promise<void>) | null = null
|
|
let autoUpdaterInitialized = false
|
|
// Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped.
|
|
let includePrereleaseActive = false
|
|
let availableVersion: string | null = null
|
|
let availableReleaseUrl: string | null = null
|
|
let pendingCheckFailureKey: string | null = null
|
|
let pendingCheckFailurePromise: Promise<void> | null = null
|
|
let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
|
|
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
|
|
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
|
|
let quitAndInstallInProgress = false
|
|
// Why: the pre-install digest re-proof streams the whole package, so a second install request can
|
|
// arrive while it runs — after the quit timer was cleared but before the handoff owns the process.
|
|
let linuxPackageRevalidationInFlight = false
|
|
let updateInstallMode: UpdateInstallMode = 'interactive'
|
|
let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null }
|
|
// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer.
|
|
let updateInstallCommitted = false
|
|
// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery.
|
|
let quitAndInstallNativeInvoked = false
|
|
// Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it.
|
|
let lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null
|
|
let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null
|
|
let _getLastUpdateCheckAt: (() => number | null) | null = null
|
|
let backgroundCheckLaunchPending = false
|
|
// Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch.
|
|
let backgroundCheckPromotedToUserInitiated = false
|
|
let updateCheckStallTimer: ReturnType<typeof setTimeout> | null = null
|
|
let updateCheckSilentSettleTimer: ReturnType<typeof setTimeout> | null = null
|
|
let updateCheckAttemptSequence = 0
|
|
let activeUpdateCheckAttemptId: number | null = null
|
|
let activeUpdateCheckLaunchAttemptId: number | null = null
|
|
let activeUpdateCheckEventAttemptId: number | null = null
|
|
let updateAvailableEventPendingAttemptId: number | null = null
|
|
let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null
|
|
let activeUpdateNudgeId: string | null = null
|
|
let awaitingNudgeCheckOutcome = false
|
|
let nudgeCheckInFlight = false
|
|
let lastNudgeCheckAt = 0
|
|
let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null
|
|
let pendingPrereleaseFallback: {
|
|
primaryTag: string
|
|
fallbackTag: string
|
|
// Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global.
|
|
userInitiated: boolean
|
|
suppressedPrimaryPromiseFailureKey: string | null
|
|
suppressedPrimaryEventFailure: PrimaryEventSuppression | null
|
|
suppressedFallbackPromiseFailureKey: string | null
|
|
suppressedFallbackEventFailureKey: string | null
|
|
fallbackResultHandled: boolean
|
|
fallbackCheckingForUpdateSeen: boolean
|
|
retryLaunched: boolean
|
|
} | null = null
|
|
|
|
let _getPendingUpdateNudgeId: (() => string | null) | null = null
|
|
let _getDismissedUpdateNudgeId: (() => string | null) | null = null
|
|
let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null
|
|
let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null
|
|
// Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'.
|
|
let downloadInFlight = false
|
|
/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */
|
|
let quittingForUpdate = false
|
|
let autoUpdater: ElectronAutoUpdater | null = null
|
|
let activeUpdateSource: 'release' | UpdateSource = 'release'
|
|
let activeLocalBuildFeed: LocalBuildFeed | null = null
|
|
let localBuildSelectionInProgress = false
|
|
// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade
|
|
// like local builds — but off a real release feed, not a loopback server.
|
|
let pinnedBuildSelectionInProgress = false
|
|
// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a
|
|
// deliberate downgrade, so newer-only gates must yield to it too.
|
|
let isPinnedBuildActive = false
|
|
let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null
|
|
|
|
function getAutoUpdater(): ElectronAutoUpdater {
|
|
if (!autoUpdater) {
|
|
autoUpdater = loadElectronAutoUpdater()
|
|
}
|
|
return autoUpdater
|
|
}
|
|
|
|
function clearAvailableUpdateContext(): void {
|
|
availableVersion = null
|
|
availableReleaseUrl = null
|
|
}
|
|
|
|
function closeLocalBuildFeed(): void {
|
|
const feed = activeLocalBuildFeed
|
|
activeLocalBuildFeed = null
|
|
if (feed) {
|
|
void feed.close()
|
|
}
|
|
}
|
|
|
|
function restoreReleaseUpdateSource(): void {
|
|
closeLocalBuildFeed()
|
|
activeUpdateSource = 'release'
|
|
isPinnedBuildActive = false
|
|
if (autoUpdater) {
|
|
autoUpdater.allowDowngrade = false
|
|
autoUpdater.disableDifferentialDownload = false
|
|
// Why: a pinned jump forces allowPrerelease on; leaving it set would opt
|
|
// every later background check into the RC channel behind the user's back.
|
|
autoUpdater.allowPrerelease = includePrereleaseActive
|
|
}
|
|
}
|
|
|
|
function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void {
|
|
clearAvailableUpdateContext()
|
|
if (
|
|
currentStatus.state !== 'error' ||
|
|
currentStatus.message !== message ||
|
|
currentStatus.userInitiated !== userInitiated ||
|
|
currentStatus.source !== 'local'
|
|
) {
|
|
sendStatus({ state: 'error', message, userInitiated, source: 'local' })
|
|
}
|
|
restoreReleaseUpdateSource()
|
|
}
|
|
|
|
function clearPrereleaseFallbackContext(): void {
|
|
pendingPrereleaseFallback = null
|
|
}
|
|
|
|
function clearPendingUpdateNudge(): void {
|
|
activeUpdateNudgeId = null
|
|
awaitingNudgeCheckOutcome = false
|
|
_setPendingUpdateNudgeId?.(null)
|
|
}
|
|
|
|
function deferPendingUpdateNudgeUntilRetry(): void {
|
|
activeUpdateNudgeId = null
|
|
awaitingNudgeCheckOutcome = false
|
|
}
|
|
|
|
function clearPublishingWindowLastGoodCheck(): void {
|
|
publishingWindowLastGoodCheck = null
|
|
}
|
|
|
|
function getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null {
|
|
return publishingWindowLastGoodCheck
|
|
}
|
|
|
|
function getPersistedPendingUpdateNudgeId(): string | null {
|
|
return _getPendingUpdateNudgeId?.() ?? null
|
|
}
|
|
|
|
function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
|
|
// Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it.
|
|
if (!activeUpdateNudgeId) {
|
|
return status
|
|
}
|
|
if (status.state === 'idle' || status.state === 'checking' || status.state === 'not-available') {
|
|
return status
|
|
}
|
|
return { ...status, activeNudgeId: activeUpdateNudgeId }
|
|
}
|
|
|
|
/** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */
|
|
function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void {
|
|
const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight
|
|
const shouldLaunchPendingUserInitiatedCheck =
|
|
pendingUserInitiatedCheckVariant !== null &&
|
|
(status.state === 'idle' ||
|
|
status.state === 'not-available' ||
|
|
status.state === 'available' ||
|
|
status.state === 'error')
|
|
const shouldPreserveNudgeForPublishingWindow =
|
|
publishingWindowLastGoodCheck !== null &&
|
|
(status.state === 'idle' ||
|
|
status.state === 'not-available' ||
|
|
status.state === 'available' ||
|
|
status.state === 'error')
|
|
if (awaitingNudgeCheckOutcome) {
|
|
if (status.state === 'available') {
|
|
if (shouldPreserveNudgeForPublishingWindow) {
|
|
// Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign.
|
|
deferPendingUpdateNudgeUntilRetry()
|
|
} else {
|
|
awaitingNudgeCheckOutcome = false
|
|
}
|
|
} else if (
|
|
status.state === 'idle' ||
|
|
status.state === 'not-available' ||
|
|
status.state === 'error'
|
|
) {
|
|
if (shouldPreserveNudgeForPublishingWindow) {
|
|
// Why: last-good checks can say "not available" while the campaign's newest release is still publishing.
|
|
deferPendingUpdateNudgeUntilRetry()
|
|
} else {
|
|
// Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll.
|
|
if (activeUpdateNudgeId) {
|
|
_setDismissedUpdateNudgeId?.(activeUpdateNudgeId)
|
|
}
|
|
clearPendingUpdateNudge()
|
|
}
|
|
}
|
|
}
|
|
|
|
const sourcedStatus: UpdateStatus =
|
|
activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource }
|
|
const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus)
|
|
|
|
if (isUpdateCheckResultState(status.state)) {
|
|
finishActiveUpdateCheckAttempt()
|
|
}
|
|
|
|
if (
|
|
status.state === 'idle' ||
|
|
status.state === 'not-available' ||
|
|
status.state === 'available' ||
|
|
status.state === 'error'
|
|
) {
|
|
clearPublishingWindowLastGoodCheck()
|
|
}
|
|
|
|
// Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible.
|
|
if (
|
|
decoratedStatus.state === 'downloading' ||
|
|
decoratedStatus.state === 'error' ||
|
|
decoratedStatus.state === 'idle'
|
|
) {
|
|
downloadInFlight = false
|
|
}
|
|
if (shouldLaunchPendingUserInitiatedCheck) {
|
|
// Why: a forced status must still land before the queued check restarts the cycle.
|
|
if (options?.force) {
|
|
currentStatus = decoratedStatus
|
|
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
|
|
}
|
|
launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant)
|
|
return
|
|
}
|
|
if (!options?.force && statusesEqual(currentStatus, decoratedStatus)) {
|
|
return
|
|
}
|
|
currentStatus = decoratedStatus
|
|
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
|
|
}
|
|
|
|
function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions {
|
|
switch (variant) {
|
|
case 'perf':
|
|
return { includePrerelease: true, includePerfPrerelease: true }
|
|
case 'prerelease':
|
|
return { includePrerelease: true }
|
|
case 'default':
|
|
return { includePrerelease: false }
|
|
}
|
|
}
|
|
|
|
function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant {
|
|
if (options?.includePerfPrerelease) {
|
|
return 'perf'
|
|
}
|
|
if (options?.includePrerelease) {
|
|
return 'prerelease'
|
|
}
|
|
// Why: a persisted 'rc' override makes every routine check follow the RC series
|
|
// without the user re-holding shift; the dev channels need an explicit tag, so
|
|
// neither is a routine-check variant.
|
|
if (getReleaseChannelOverride?.() === 'rc') {
|
|
return 'prerelease'
|
|
}
|
|
return 'default'
|
|
}
|
|
|
|
function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void {
|
|
pendingUserInitiatedCheckAfterInFlight = null
|
|
setTimeout(() => {
|
|
// Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one.
|
|
if (currentStatus.state === 'checking') {
|
|
currentStatus = { state: 'idle' }
|
|
}
|
|
checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant))
|
|
}, 0)
|
|
}
|
|
|
|
function clearBackgroundCheckLaunchPending(): void {
|
|
backgroundCheckLaunchPending = false
|
|
}
|
|
|
|
function clearUpdateCheckStallTimer(): void {
|
|
if (!updateCheckStallTimer) {
|
|
return
|
|
}
|
|
clearTimeout(updateCheckStallTimer)
|
|
updateCheckStallTimer = null
|
|
}
|
|
|
|
function clearUpdateCheckSilentSettleTimer(): void {
|
|
if (!updateCheckSilentSettleTimer) {
|
|
return
|
|
}
|
|
clearTimeout(updateCheckSilentSettleTimer)
|
|
updateCheckSilentSettleTimer = null
|
|
}
|
|
|
|
function clearUpdateCheckTimers(): void {
|
|
clearUpdateCheckStallTimer()
|
|
clearUpdateCheckSilentSettleTimer()
|
|
}
|
|
|
|
function finishActiveUpdateCheckAttempt(): void {
|
|
activeUpdateCheckAttemptId = null
|
|
activeUpdateCheckLaunchAttemptId = null
|
|
activeUpdateCheckEventAttemptId = null
|
|
clearUpdateCheckTimers()
|
|
}
|
|
|
|
function getActiveUpdateCheckEventAttemptId(): number | null {
|
|
if (activeUpdateCheckAttemptId === null) {
|
|
return null
|
|
}
|
|
if (activeUpdateCheckEventAttemptId !== activeUpdateCheckAttemptId) {
|
|
return null
|
|
}
|
|
return activeUpdateCheckAttemptId
|
|
}
|
|
|
|
function isActiveUpdateCheckAttempt(attemptId: number): boolean {
|
|
return activeUpdateCheckAttemptId === attemptId
|
|
}
|
|
|
|
function markUpdateCheckEventAttempt(): boolean {
|
|
if (activeUpdateCheckAttemptId === null) {
|
|
return false
|
|
}
|
|
if (activeUpdateCheckLaunchAttemptId !== activeUpdateCheckAttemptId) {
|
|
return false
|
|
}
|
|
activeUpdateCheckEventAttemptId = activeUpdateCheckAttemptId
|
|
return true
|
|
}
|
|
|
|
function markUpdateCheckLaunched(attemptId: number): void {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
activeUpdateCheckLaunchAttemptId = attemptId
|
|
}
|
|
|
|
function markUpdateAvailableEventPending(attemptId: number | null): void {
|
|
updateAvailableEventPendingAttemptId = attemptId
|
|
}
|
|
|
|
function clearUpdateAvailableEventPending(attemptId: number | null): void {
|
|
if (updateAvailableEventPendingAttemptId !== attemptId) {
|
|
return
|
|
}
|
|
updateAvailableEventPendingAttemptId = null
|
|
}
|
|
|
|
function armUpdateCheckStallTimer(attemptId: number): void {
|
|
clearUpdateCheckStallTimer()
|
|
updateCheckStallTimer = setTimeout(() => {
|
|
updateCheckStallTimer = null
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
const wasUserInitiated = getSettledCheckUserInitiated()
|
|
if (currentStatus.state === 'checking') {
|
|
finishActiveUpdateCheckAttempt()
|
|
backgroundCheckLaunchPending = false
|
|
backgroundCheckPromotedToUserInitiated = false
|
|
userInitiatedCheck = false
|
|
void sendCheckFailureStatus(
|
|
'Update check timed out. Try again in a few minutes.',
|
|
wasUserInitiated,
|
|
'promise'
|
|
)
|
|
return
|
|
}
|
|
if (backgroundCheckLaunchPending) {
|
|
finishActiveUpdateCheckAttempt()
|
|
backgroundCheckLaunchPending = false
|
|
backgroundCheckPromotedToUserInitiated = false
|
|
userInitiatedCheck = false
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
|
}
|
|
}, UPDATE_CHECK_STALL_TIMEOUT_MS)
|
|
}
|
|
|
|
function beginUpdateCheckAttempt(): number {
|
|
finishActiveUpdateCheckAttempt()
|
|
updateAvailableEventPendingAttemptId = null
|
|
updateCheckAttemptSequence += 1
|
|
activeUpdateCheckAttemptId = updateCheckAttemptSequence
|
|
armUpdateCheckStallTimer(activeUpdateCheckAttemptId)
|
|
// Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater.
|
|
writeMainThreadDiagnosticMarker('updater-check-attempt')
|
|
return activeUpdateCheckAttemptId
|
|
}
|
|
|
|
function rearmActiveUpdateCheckStallTimer(): void {
|
|
if (activeUpdateCheckAttemptId === null) {
|
|
return
|
|
}
|
|
armUpdateCheckStallTimer(activeUpdateCheckAttemptId)
|
|
}
|
|
|
|
function getSettledCheckUserInitiated(): boolean | undefined {
|
|
return userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined
|
|
}
|
|
|
|
function isUpdateCheckResultState(state: UpdateStatus['state']): boolean {
|
|
return (
|
|
state === 'idle' ||
|
|
state === 'not-available' ||
|
|
state === 'available' ||
|
|
state === 'error' ||
|
|
state === 'downloading' ||
|
|
state === 'downloaded'
|
|
)
|
|
}
|
|
|
|
function consumeSilentCheckShortRetryReason(): boolean {
|
|
if (publishingWindowLastGoodCheck !== null) {
|
|
return true
|
|
}
|
|
return consumeMissingManifestPrereleaseFallbackResult() !== null
|
|
}
|
|
|
|
function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean {
|
|
const shouldRetrySoon = consumeSilentCheckShortRetryReason()
|
|
clearAvailableUpdateContext()
|
|
if (shouldRetrySoon) {
|
|
// Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry.
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
|
return true
|
|
}
|
|
recordCompletedUpdateCheck()
|
|
if (!userInitiated) {
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
if (updateAvailableEventPendingAttemptId === attemptId) {
|
|
return
|
|
}
|
|
if (currentStatus.state !== 'checking') {
|
|
if (backgroundCheckLaunchPending) {
|
|
finishActiveUpdateCheckAttempt()
|
|
clearBackgroundCheckLaunchPending()
|
|
backgroundCheckPromotedToUserInitiated = false
|
|
userInitiatedCheck = false
|
|
const shouldRetrySoon = completeSilentUpdateCheck(userInitiated)
|
|
if (awaitingNudgeCheckOutcome) {
|
|
if (shouldRetrySoon) {
|
|
deferPendingUpdateNudgeUntilRetry()
|
|
return
|
|
}
|
|
sendStatus({ state: 'not-available', userInitiated })
|
|
}
|
|
}
|
|
return
|
|
}
|
|
finishActiveUpdateCheckAttempt()
|
|
clearBackgroundCheckLaunchPending()
|
|
backgroundCheckPromotedToUserInitiated = false
|
|
userInitiatedCheck = false
|
|
completeSilentUpdateCheck(userInitiated)
|
|
sendStatus({ state: 'not-available', userInitiated })
|
|
}
|
|
|
|
function handleSettledUpdateCheckPromise(attemptId: number): void {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
clearUpdateCheckSilentSettleTimer()
|
|
// Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one.
|
|
updateCheckSilentSettleTimer = setTimeout(() => {
|
|
updateCheckSilentSettleTimer = null
|
|
settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated())
|
|
}, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS)
|
|
}
|
|
|
|
function shouldHandleUpdaterErrorEvent(): boolean {
|
|
if (getActiveUpdateCheckEventAttemptId() !== null) {
|
|
return true
|
|
}
|
|
// Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them.
|
|
return (
|
|
downloadInFlight ||
|
|
currentStatus.state === 'downloading' ||
|
|
currentStatus.state === 'downloaded'
|
|
)
|
|
}
|
|
|
|
function sendErrorStatus(message: string, userInitiated?: boolean): void {
|
|
if (
|
|
currentStatus.state === 'error' &&
|
|
currentStatus.message === message &&
|
|
currentStatus.userInitiated === userInitiated
|
|
) {
|
|
return
|
|
}
|
|
// Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes.
|
|
if (isWindowsSignatureCheckUnavailableFailure(message)) {
|
|
recordUpdaterLifecycle('windows_signature_check_blocked', undefined, {
|
|
level: 'warn',
|
|
message: 'Windows update signature check could not run'
|
|
})
|
|
}
|
|
sendStatus({ state: 'error', message, userInitiated })
|
|
}
|
|
|
|
function getKnownReleaseUrl(): string | undefined {
|
|
return availableReleaseUrl ?? undefined
|
|
}
|
|
|
|
function hasInstallableDownloadedVersion(): boolean {
|
|
return (
|
|
availableVersion !== null &&
|
|
// Why: local builds and pinned dev jumps may intentionally move backwards.
|
|
(activeUpdateSource !== 'release' ||
|
|
isPinnedBuildActive ||
|
|
compareVersions(availableVersion, app.getVersion()) > 0)
|
|
)
|
|
}
|
|
|
|
function getPendingInstallVersion(): string {
|
|
if (availableVersion) {
|
|
return availableVersion
|
|
}
|
|
if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') {
|
|
return currentStatus.version
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean {
|
|
if (updateInstallMode !== 'unsupported-headless-serve') {
|
|
return false
|
|
}
|
|
const diagnosticVersion = version || 'unknown'
|
|
if (lastInstallDeferralVersion[phase] !== diagnosticVersion) {
|
|
lastInstallDeferralVersion[phase] = diagnosticVersion
|
|
recordUpdaterLifecycle(
|
|
'headless_serve_install_deferred',
|
|
{ phase, version: version || null },
|
|
{
|
|
level: 'warn',
|
|
message: 'Update install deferred while hosting orca serve'
|
|
}
|
|
)
|
|
}
|
|
sendErrorStatus(
|
|
'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.',
|
|
true
|
|
)
|
|
return true
|
|
}
|
|
|
|
export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode {
|
|
if (!isServeMode) {
|
|
return 'interactive'
|
|
}
|
|
return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve'
|
|
}
|
|
|
|
function getCheckFailureKey(message: string, userInitiated?: boolean): string {
|
|
return `${userInitiated ? 'user' : 'auto'}:${message}`
|
|
}
|
|
|
|
function clearPrereleaseFallbackContextIfSettled(): void {
|
|
if (
|
|
pendingPrereleaseFallback?.fallbackResultHandled &&
|
|
!pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey &&
|
|
!pendingPrereleaseFallback.suppressedPrimaryEventFailure &&
|
|
!pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey &&
|
|
!pendingPrereleaseFallback.suppressedFallbackEventFailureKey
|
|
) {
|
|
clearPrereleaseFallbackContext()
|
|
}
|
|
}
|
|
|
|
async function performQuitAndInstall(): Promise<void> {
|
|
if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) {
|
|
recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' })
|
|
return
|
|
}
|
|
|
|
if (pendingQuitAndInstallTimer) {
|
|
clearTimeout(pendingQuitAndInstallTimer)
|
|
pendingQuitAndInstallTimer = null
|
|
}
|
|
|
|
const pendingVersion = getPendingInstallVersion()
|
|
if (deferHeadlessServeInstall('install', pendingVersion)) {
|
|
return
|
|
}
|
|
// Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about
|
|
// to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a
|
|
// swapped or vanished package aborts instead of being installed as root. The synchronous guard
|
|
// keeps every non-Linux install on its existing timing.
|
|
if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) {
|
|
// Why: the renderer armed its restart before invoking, and it infers the abort from the error
|
|
// status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where
|
|
// it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt.
|
|
mainWindowRef?.webContents.send('updater:quitAndInstallAborted')
|
|
return
|
|
}
|
|
quitAndInstallInProgress = true
|
|
|
|
markMacQuitAndInstallInFlight()
|
|
|
|
// Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle.
|
|
quittingForUpdate = true
|
|
|
|
try {
|
|
await withUpdaterSpan({ stage: 'install' }, async (span) => {
|
|
span.setAttribute('updater.version', pendingVersion || 'unknown')
|
|
span.setAttribute('updater.platform', process.platform)
|
|
span.setAttribute(
|
|
'updater.macosInstallerReady',
|
|
process.platform === 'darwin' ? isMacInstallerReady() : true
|
|
)
|
|
recordUpdaterLifecycle('quit_and_install_started', {
|
|
version: pendingVersion || null,
|
|
macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true
|
|
})
|
|
span.addEvent('pre_quit_cleanup_start')
|
|
await runBeforeUpdateQuitCleanup()
|
|
span.addEvent('pre_quit_cleanup_done')
|
|
|
|
if (
|
|
updateInstallMode === 'supervised-headless-serve' &&
|
|
!requestServeUpdateHandoff(pendingVersion)
|
|
) {
|
|
recordUpdaterLifecycle(
|
|
'headless_serve_handoff_failed',
|
|
{ version: pendingVersion || null },
|
|
{
|
|
level: 'warn',
|
|
message: 'Could not persist supervised serve update handoff'
|
|
}
|
|
)
|
|
sendErrorStatus(
|
|
'Could not prepare the supervised server restart. Orca remains running.',
|
|
true
|
|
)
|
|
resetQuitForUpdateState()
|
|
// Why: a bare return would exit this span Success and hide the aborted install from tracing.
|
|
span.fail('Could not persist the supervised serve update handoff')
|
|
return
|
|
}
|
|
|
|
recordUpdaterLifecycle('quit_and_install_invoking_native', {
|
|
version: pendingVersion || null
|
|
})
|
|
// Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff.
|
|
if (!quitAndInstallInProgress) {
|
|
return
|
|
}
|
|
// Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure.
|
|
quitAndInstallNativeInvoked = true
|
|
// Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact.
|
|
const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve'
|
|
// Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call.
|
|
beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null)
|
|
try {
|
|
runWithLaunchPath(() =>
|
|
getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch)
|
|
)
|
|
} finally {
|
|
const diagnostic = endLinuxPackageInstallDiagnosticCapture()
|
|
// Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one.
|
|
lastInstallAttemptDiagnostic = quitAndInstallInProgress ? diagnostic : null
|
|
}
|
|
span.addEvent('native_quit_and_install_invoked')
|
|
|
|
// Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran.
|
|
if (!quitAndInstallInProgress) {
|
|
// Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success.
|
|
span.fail(
|
|
currentStatus.state === 'error'
|
|
? currentStatus.message
|
|
: 'quitAndInstall returned without invoking the installer'
|
|
)
|
|
return
|
|
}
|
|
|
|
// Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the
|
|
// package is installed. Commit here or a throw in the cleanup below is reported as an install
|
|
// failure — offering a recovery card, and stale stderr, for an update that actually succeeded.
|
|
if (getLinuxRootPackageType() !== null) {
|
|
updateInstallCommitted = true
|
|
armUpdateInstallExitWatchdog()
|
|
}
|
|
|
|
killAllPty()
|
|
span.addEvent('local_pty_kill_all')
|
|
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
win.removeAllListeners('close')
|
|
}
|
|
span.addEvent('window_close_listeners_removed', {
|
|
windowCount: BrowserWindow.getAllWindows().length
|
|
})
|
|
|
|
// Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover.
|
|
if (!updateInstallCommitted && (process.platform !== 'darwin' || isMacInstallerReady())) {
|
|
updateInstallCommitted = true
|
|
// Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438).
|
|
armUpdateInstallExitWatchdog()
|
|
}
|
|
})
|
|
} catch (error) {
|
|
// Why: on Linux the package is already installed once quitAndInstall returns, and the installer is
|
|
// waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear
|
|
// quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded.
|
|
if (updateInstallCommitted) {
|
|
recordUpdaterLifecycle(
|
|
'post_commit_cleanup_failed',
|
|
{ errorType: error instanceof Error ? error.name : typeof error },
|
|
{
|
|
level: 'warn',
|
|
message: 'Update install cleanup failed after commit; install already applied'
|
|
}
|
|
)
|
|
return
|
|
}
|
|
// Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one.
|
|
const quitAndInstallNativeInvokedBeforeReset = quitAndInstallNativeInvoked
|
|
const recoveryStatus =
|
|
quitAndInstallNativeInvokedBeforeReset && !updateInstallCommitted
|
|
? buildLinuxPackageInstallFailureStatus(error)
|
|
: null
|
|
failServeUpdateHandoff('Could not invoke the native updater.')
|
|
resetQuitForUpdateState()
|
|
recordUpdaterLifecycle(
|
|
'quit_and_install_failed',
|
|
{ errorType: error instanceof Error ? error.name : typeof error },
|
|
{
|
|
level: 'warn',
|
|
message: 'Could not start update install'
|
|
}
|
|
)
|
|
sendInstallFailureStatus(
|
|
recoveryStatus ?? {
|
|
state: 'error',
|
|
// Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart.
|
|
// A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have.
|
|
message: quitAndInstallNativeInvokedBeforeReset
|
|
? withInstallFailureCause(getPreCommitInstallFailureMessage(), error)
|
|
: 'Could not restart to install the update. Quit and reopen Orca, then try again.'
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
function resetQuitForUpdateState(): void {
|
|
quitAndInstallInProgress = false
|
|
quittingForUpdate = false
|
|
updateInstallCommitted = false
|
|
quitAndInstallNativeInvoked = false
|
|
lastInstallAttemptDiagnostic = null
|
|
disarmUpdateInstallExitWatchdog()
|
|
resetMacInstallState()
|
|
}
|
|
|
|
/**
|
|
* On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage
|
|
* it — so keep that advice there. Everywhere else a restart is not known to help.
|
|
*/
|
|
function getPreCommitInstallFailureMessage(): string {
|
|
return process.platform === 'darwin'
|
|
? 'Could not restart to install the update. Quit and reopen Orca, then try again.'
|
|
: 'Could not start the update installer. Orca remains open.'
|
|
}
|
|
|
|
/**
|
|
* Sends an install-failure status even when it repeats the current one. "Try Automatic Install
|
|
* Again" usually fails identically, and a deduped status would never reach the preload abort relay,
|
|
* leaving the renderer stuck in its restart checkpoint.
|
|
*/
|
|
function sendInstallFailureStatus(status: UpdateStatus): void {
|
|
sendStatus(status, { force: true })
|
|
}
|
|
|
|
const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200
|
|
|
|
/**
|
|
* Appends the updater's own text to the generic install-failure copy. Without it the only record of
|
|
* why the install never started is destroyed — on Linux that text carries the exact `dpkg -i <path>`
|
|
* command the user has to run by hand, and remote clients get nothing but "it didn't come back".
|
|
*/
|
|
function withInstallFailureCause(baseMessage: string, error: unknown): string {
|
|
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
|
// Why: the retained-package card runs its text through this same sanitizer, so a home directory,
|
|
// user name, or terminal escape must not reach the card merely because no artifact was tracked.
|
|
const redacted =
|
|
redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? ''
|
|
const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH)
|
|
if (!cause || cause === 'Unknown error') {
|
|
return baseMessage
|
|
}
|
|
// Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice.
|
|
if (
|
|
isWindowsSignatureCheckUnavailableFailure(cause) ||
|
|
isWindowsSignatureMismatchFailure(cause)
|
|
) {
|
|
return cause
|
|
}
|
|
return `${baseMessage} (${cause})`
|
|
}
|
|
|
|
/**
|
|
* The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can
|
|
* recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic.
|
|
*/
|
|
function buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null {
|
|
const artifact = getTrackedLinuxPackageArtifact()
|
|
if (!artifact) {
|
|
return null
|
|
}
|
|
const pendingVersion = getPendingInstallVersion()
|
|
if (pendingVersion && pendingVersion !== artifact.version) {
|
|
return null
|
|
}
|
|
const diagnostic = getLinuxPackageInstallDiagnostic() ?? lastInstallAttemptDiagnostic
|
|
// Why: the reason was classified from the original output, before redaction could rewrite a match.
|
|
const reason = diagnostic?.reason ?? 'package-install-failed'
|
|
// Durable data carries classification only — never the package path, home path, command, or stderr.
|
|
const exitCode = parseLinuxPackageInstallExitCode(error)
|
|
recordUpdaterLifecycle(
|
|
'linux_package_install_failed',
|
|
{
|
|
packageType: artifact.packageType,
|
|
reason,
|
|
// Omitted rather than null when the child status could not be parsed.
|
|
...(exitCode === null ? {} : { exitCode }),
|
|
version: artifact.version,
|
|
errorType: error instanceof Error ? error.name : typeof error
|
|
},
|
|
{ level: 'warn', message: 'Linux package install failed; cached package retained' }
|
|
)
|
|
// Why: this text is shown in the card, so it gets the same redaction as retained stderr.
|
|
const message =
|
|
diagnostic?.message ??
|
|
(error instanceof Error ? redactLinuxPackageInstallText(error.message, artifact.path) : null) ??
|
|
'The system package installer did not start.'
|
|
return {
|
|
state: 'error',
|
|
message,
|
|
recovery: {
|
|
kind: 'linux-package-install',
|
|
packageType: artifact.packageType,
|
|
reason,
|
|
version: artifact.version
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer.
|
|
function handleQuitAndInstallFailure(error?: unknown): boolean {
|
|
if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) {
|
|
return false
|
|
}
|
|
const recoveryStatus = buildLinuxPackageInstallFailureStatus(error)
|
|
failServeUpdateHandoff('The native updater rejected the install request.')
|
|
resetQuitForUpdateState()
|
|
// Durable data carries classification only — the cause text stays on the status the user can read.
|
|
recordUpdaterLifecycle(
|
|
'quit_and_install_failed_via_event',
|
|
{ errorType: error instanceof Error ? error.name : typeof error },
|
|
{
|
|
level: 'warn',
|
|
message: 'Update install could not start; recovered app state'
|
|
}
|
|
)
|
|
sendInstallFailureStatus(
|
|
recoveryStatus ?? {
|
|
state: 'error',
|
|
message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error)
|
|
}
|
|
)
|
|
return true
|
|
}
|
|
|
|
// Why: while quit-and-install owns the process, general check/download error UI must not run.
|
|
function isQuitAndInstallHandoffActive(): boolean {
|
|
return quitAndInstallInProgress
|
|
}
|
|
|
|
async function runBeforeUpdateQuitCleanup(): Promise<void> {
|
|
if (!onBeforeQuitCleanup) {
|
|
return
|
|
}
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
const cleanup = Promise.resolve()
|
|
.then(() => onBeforeQuitCleanup?.())
|
|
.catch((error) => {
|
|
recordUpdaterLifecycle(
|
|
'pre_quit_cleanup_failed',
|
|
{ errorType: error instanceof Error ? error.name : typeof error },
|
|
{
|
|
level: 'warn',
|
|
message: 'Pre-quit cleanup failed; continuing update install'
|
|
}
|
|
)
|
|
})
|
|
const timeoutResult = new Promise<'timeout'>((resolve) => {
|
|
timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS)
|
|
})
|
|
|
|
const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult])
|
|
if (result === 'timeout') {
|
|
recordUpdaterLifecycle(
|
|
'pre_quit_cleanup_timeout',
|
|
{ timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS },
|
|
{
|
|
level: 'warn',
|
|
message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install`
|
|
}
|
|
)
|
|
return
|
|
}
|
|
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
|
|
async function sendCheckFailureStatus(
|
|
message: string,
|
|
userInitiated?: boolean,
|
|
source: CheckFailureSource = 'promise',
|
|
sourceError?: unknown
|
|
): Promise<void> {
|
|
if (activeUpdateSource === 'local') {
|
|
sendLocalBuildErrorAndRestore(message, userInitiated)
|
|
return
|
|
}
|
|
if (isPinnedBuildActive) {
|
|
// Why: a failed pinned jump must hand the feed back before surfacing the
|
|
// error, or the pin blocks background checks for the process lifetime.
|
|
clearAvailableUpdateContext()
|
|
restoreReleaseUpdateSource()
|
|
sendStatus({ state: 'error', message, userInitiated })
|
|
return
|
|
}
|
|
const failureKey = getCheckFailureKey(message, userInitiated)
|
|
if (
|
|
source === 'promise' &&
|
|
pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey
|
|
) {
|
|
pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null
|
|
clearPrereleaseFallbackContextIfSettled()
|
|
return
|
|
}
|
|
if (
|
|
source === 'fallback-promise' &&
|
|
pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey
|
|
) {
|
|
pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null
|
|
clearPrereleaseFallbackContextIfSettled()
|
|
return
|
|
}
|
|
|
|
if (
|
|
retryPrereleaseFallbackAfterMissingManifest(
|
|
message,
|
|
userInitiated,
|
|
source,
|
|
failureKey,
|
|
sourceError
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) {
|
|
return pendingCheckFailurePromise
|
|
}
|
|
|
|
const handleFailure = async (): Promise<void> => {
|
|
if (isBenignCheckFailure(message) || isRetryableReleaseFeedPreflightFailure(sourceError)) {
|
|
// Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check).
|
|
console.warn('[updater] benign check failure:', message)
|
|
clearAvailableUpdateContext()
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
|
if (userInitiated) {
|
|
// Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures.
|
|
sendErrorStatus(
|
|
isStableReleaseNotReadyFailure(sourceError)
|
|
? "A newer release isn't available for this device yet. Check again later."
|
|
: "Couldn't reach the update server. Try again in a few minutes.",
|
|
true
|
|
)
|
|
} else {
|
|
if (isRetryableReleaseFeedPreflightFailure(sourceError)) {
|
|
// Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it.
|
|
deferPendingUpdateNudgeUntilRetry()
|
|
}
|
|
sendStatus({ state: 'idle' })
|
|
}
|
|
return
|
|
}
|
|
|
|
clearAvailableUpdateContext()
|
|
persistLastUpdateCheckAt?.(Date.now())
|
|
if (!userInitiated) {
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
|
}
|
|
sendErrorStatus(message, userInitiated)
|
|
}
|
|
|
|
pendingCheckFailureKey = failureKey
|
|
pendingCheckFailurePromise = handleFailure().finally(() => {
|
|
if (pendingCheckFailureKey === failureKey) {
|
|
pendingCheckFailureKey = null
|
|
pendingCheckFailurePromise = null
|
|
}
|
|
})
|
|
return pendingCheckFailurePromise
|
|
}
|
|
|
|
function isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean {
|
|
return (
|
|
sourceError instanceof ReleaseFeedPreflightError &&
|
|
(sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable')
|
|
)
|
|
}
|
|
|
|
function isStableReleaseNotReadyFailure(sourceError: unknown): boolean {
|
|
return (
|
|
sourceError instanceof ReleaseFeedPreflightError &&
|
|
sourceError.reason === 'release-not-ready' &&
|
|
sourceError.releaseChannel === 'default'
|
|
)
|
|
}
|
|
|
|
export function getUpdateStatus(): UpdateStatus {
|
|
return currentStatus
|
|
}
|
|
|
|
export function getRemoteServerUpdateSupport(): RemoteServerUpdateSupport {
|
|
if (!app.isPackaged || is.dev) {
|
|
return {
|
|
installMode: updateInstallMode,
|
|
automatic: false,
|
|
reason: 'unpackaged-build'
|
|
}
|
|
}
|
|
if (!autoUpdaterInitialized) {
|
|
return {
|
|
installMode: updateInstallMode,
|
|
automatic: false,
|
|
reason: 'updater-unavailable'
|
|
}
|
|
}
|
|
if (updateInstallMode === 'unsupported-headless-serve') {
|
|
return {
|
|
installMode: updateInstallMode,
|
|
automatic: false,
|
|
reason: 'manual-service-update-required'
|
|
}
|
|
}
|
|
return { installMode: updateInstallMode, automatic: true, reason: 'available' }
|
|
}
|
|
|
|
export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot {
|
|
return {
|
|
appVersion: app.getVersion(),
|
|
runtimeId,
|
|
support: getRemoteServerUpdateSupport(),
|
|
status: getUpdateStatus()
|
|
}
|
|
}
|
|
|
|
function assertRemoteServerUpdateAvailable(): void {
|
|
if (!getRemoteServerUpdateSupport().automatic) {
|
|
throw new Error('remote_update_manual_required')
|
|
}
|
|
}
|
|
|
|
export function checkForRemoteServerUpdate(
|
|
runtimeId: string,
|
|
options?: UpdateCheckOptions
|
|
): RemoteServerUpdaterSnapshot {
|
|
assertRemoteServerUpdateAvailable()
|
|
checkForUpdatesFromMenu(options)
|
|
return getRemoteServerUpdaterSnapshot(runtimeId)
|
|
}
|
|
|
|
export function downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot {
|
|
assertRemoteServerUpdateAvailable()
|
|
if (currentStatus.state !== 'available') {
|
|
throw new Error('remote_update_not_available')
|
|
}
|
|
downloadUpdate()
|
|
return getRemoteServerUpdaterSnapshot(runtimeId)
|
|
}
|
|
|
|
export function installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult {
|
|
assertRemoteServerUpdateAvailable()
|
|
if (currentStatus.state !== 'downloaded') {
|
|
throw new Error('remote_update_not_downloaded')
|
|
}
|
|
const targetVersion = currentStatus.version
|
|
const result: RemoteServerUpdateInstallResult = {
|
|
accepted: true,
|
|
fromVersion: app.getVersion(),
|
|
targetVersion,
|
|
runtimeId
|
|
}
|
|
quitAndInstall()
|
|
return result
|
|
}
|
|
|
|
let consecutiveAutomaticRetrySchedules = 0
|
|
|
|
function scheduleAutomaticUpdateCheck(delayMs: number): void {
|
|
let effectiveDelayMs = delayMs
|
|
// All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site.
|
|
if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) {
|
|
effectiveDelayMs = Math.min(
|
|
AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules,
|
|
MAX_AUTO_UPDATE_RETRY_INTERVAL_MS
|
|
)
|
|
consecutiveAutomaticRetrySchedules += 1
|
|
}
|
|
if (autoUpdateCheckTimer) {
|
|
clearTimeout(autoUpdateCheckTimer)
|
|
}
|
|
autoUpdateCheckTimer = setTimeout(() => {
|
|
// Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime.
|
|
if (!runBackgroundUpdateCheck()) {
|
|
// Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime.
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
|
|
}
|
|
}, effectiveDelayMs)
|
|
}
|
|
|
|
function recordCompletedUpdateCheck(): void {
|
|
consecutiveAutomaticRetrySchedules = 0
|
|
persistLastUpdateCheckAt?.(Date.now())
|
|
}
|
|
|
|
function getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null {
|
|
if (
|
|
!pendingPrereleaseFallback?.retryLaunched ||
|
|
pendingPrereleaseFallback.fallbackResultHandled
|
|
) {
|
|
return null
|
|
}
|
|
return pendingPrereleaseFallback.userInitiated
|
|
}
|
|
|
|
function markMissingManifestPrereleaseFallbackChecking(): void {
|
|
if (
|
|
!pendingPrereleaseFallback?.retryLaunched ||
|
|
pendingPrereleaseFallback.fallbackResultHandled
|
|
) {
|
|
return
|
|
}
|
|
pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true
|
|
}
|
|
|
|
function consumeMissingManifestPrereleaseFallbackResult(): MissingManifestPrereleaseFallbackResult | null {
|
|
if (
|
|
!pendingPrereleaseFallback?.retryLaunched ||
|
|
pendingPrereleaseFallback.fallbackResultHandled
|
|
) {
|
|
return null
|
|
}
|
|
const result = { userInitiated: pendingPrereleaseFallback.userInitiated }
|
|
pendingPrereleaseFallback.fallbackResultHandled = true
|
|
clearPrereleaseFallbackContextIfSettled()
|
|
return result
|
|
}
|
|
|
|
function suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void {
|
|
if (
|
|
!pendingPrereleaseFallback?.retryLaunched ||
|
|
pendingPrereleaseFallback.fallbackResultHandled
|
|
) {
|
|
return
|
|
}
|
|
pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = getCheckFailureKey(
|
|
message,
|
|
pendingPrereleaseFallback.userInitiated
|
|
)
|
|
}
|
|
|
|
function shouldSuppressMissingManifestPrereleaseFallbackEvent(
|
|
message: string,
|
|
error: unknown
|
|
): boolean {
|
|
if (!pendingPrereleaseFallback?.retryLaunched) {
|
|
return false
|
|
}
|
|
const failureKey = getCheckFailureKey(message, pendingPrereleaseFallback.userInitiated)
|
|
const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure
|
|
if (primaryEventSuppression?.failureKey === failureKey) {
|
|
const isPrimaryPromisePair = primaryEventSuppression.error === error
|
|
// Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe.
|
|
if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) {
|
|
pendingPrereleaseFallback.suppressedPrimaryEventFailure = null
|
|
clearPrereleaseFallbackContextIfSettled()
|
|
return true
|
|
}
|
|
}
|
|
if (pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) {
|
|
pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null
|
|
clearPrereleaseFallbackContextIfSettled()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void {
|
|
if (
|
|
!pendingPrereleaseFallback?.retryLaunched ||
|
|
pendingPrereleaseFallback.fallbackResultHandled
|
|
) {
|
|
return
|
|
}
|
|
pendingPrereleaseFallback.suppressedFallbackEventFailureKey = getCheckFailureKey(
|
|
message,
|
|
pendingPrereleaseFallback.userInitiated
|
|
)
|
|
}
|
|
|
|
async function pinDefaultReleaseFeed(
|
|
variant: UpdateCheckVariant = 'default'
|
|
): Promise<ReleaseFeedPreflightResult> {
|
|
const autoUpdater = getAutoUpdater()
|
|
// Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable).
|
|
const currentVersion = app.getVersion()
|
|
const isPerfCheck = variant === 'perf'
|
|
const includePrerelease =
|
|
isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion)
|
|
const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness(
|
|
currentVersion,
|
|
includePrerelease ? 2 : 1,
|
|
{
|
|
includePrerelease,
|
|
...(isPerfCheck ? { releaseFilter: 'perf' as const } : {})
|
|
}
|
|
)
|
|
const newerTag = releaseTagsResult.tags[0] ?? null
|
|
const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null
|
|
pendingPrereleaseFallback =
|
|
includePrerelease && newerTag && fallbackTag
|
|
? {
|
|
primaryTag: newerTag,
|
|
fallbackTag,
|
|
userInitiated: false,
|
|
suppressedPrimaryPromiseFailureKey: null,
|
|
suppressedPrimaryEventFailure: null,
|
|
suppressedFallbackPromiseFailureKey: null,
|
|
suppressedFallbackEventFailureKey: null,
|
|
fallbackResultHandled: false,
|
|
fallbackCheckingForUpdateSeen: false,
|
|
retryLaunched: false
|
|
}
|
|
: null
|
|
// Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater.
|
|
if (newerTag) {
|
|
clearPublishingWindowLastGoodCheck()
|
|
const url = getReleaseDownloadUrl(newerTag)
|
|
console.info(
|
|
`[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
|
|
)
|
|
autoUpdater.setFeedURL({ provider: 'generic', url })
|
|
return 'ready'
|
|
} else if (releaseTagsResult.state === 'not-ready') {
|
|
clearPrereleaseFallbackContext()
|
|
if (releaseTagsResult.lastGoodTag) {
|
|
// Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result.
|
|
const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag)
|
|
console.info(
|
|
`[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
|
|
)
|
|
publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag }
|
|
autoUpdater.setFeedURL({ provider: 'generic', url })
|
|
return 'ready'
|
|
}
|
|
clearPublishingWindowLastGoodCheck()
|
|
console.info(
|
|
`[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready`
|
|
)
|
|
throw new ReleaseFeedPreflightError(
|
|
'release-not-ready',
|
|
isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default',
|
|
'Latest release artifacts are not ready'
|
|
)
|
|
} else if (
|
|
releaseTagsResult.state === 'unavailable' &&
|
|
releaseTagsResult.unavailableReason === 'manifest' &&
|
|
!includePrerelease
|
|
) {
|
|
clearPrereleaseFallbackContext()
|
|
clearPublishingWindowLastGoodCheck()
|
|
throw new ReleaseFeedPreflightError(
|
|
'manifest-unavailable',
|
|
'default',
|
|
'Unable to find latest version on GitHub'
|
|
)
|
|
} else if (isPerfCheck) {
|
|
clearPrereleaseFallbackContext()
|
|
clearPublishingWindowLastGoodCheck()
|
|
if (releaseTagsResult.state === 'no-newer') {
|
|
console.info(
|
|
`[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}`
|
|
)
|
|
return 'not-available'
|
|
}
|
|
throw new Error('Could not resolve perf update feed')
|
|
} else {
|
|
clearPrereleaseFallbackContext()
|
|
clearPublishingWindowLastGoodCheck()
|
|
const url = 'https://github.com/stablyai/orca/releases/latest/download'
|
|
console.info(
|
|
`[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
|
|
)
|
|
autoUpdater.setFeedURL({ provider: 'generic', url })
|
|
return 'ready'
|
|
}
|
|
}
|
|
|
|
function retryPrereleaseFallbackAfterMissingManifest(
|
|
message: string,
|
|
userInitiated: boolean | undefined,
|
|
source: CheckFailureSource,
|
|
failureKey: string,
|
|
sourceError?: unknown
|
|
): boolean {
|
|
if (
|
|
!pendingPrereleaseFallback ||
|
|
pendingPrereleaseFallback.retryLaunched ||
|
|
!isMissingUpdateManifestFailure(message)
|
|
) {
|
|
return false
|
|
}
|
|
const attemptId = activeUpdateCheckAttemptId
|
|
if (attemptId === null) {
|
|
return false
|
|
}
|
|
|
|
// Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result.
|
|
pendingPrereleaseFallback.retryLaunched = true
|
|
pendingPrereleaseFallback.userInitiated = Boolean(userInitiated)
|
|
pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey =
|
|
source === 'event' ? failureKey : null
|
|
pendingPrereleaseFallback.suppressedPrimaryEventFailure =
|
|
source === 'promise' ? { failureKey, error: sourceError } : null
|
|
pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false
|
|
const { primaryTag, fallbackTag } = pendingPrereleaseFallback
|
|
const url = getReleaseDownloadUrl(fallbackTag)
|
|
console.info(
|
|
`[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}`
|
|
)
|
|
const autoUpdater = getAutoUpdater()
|
|
autoUpdater.setFeedURL({ provider: 'generic', url })
|
|
userInitiatedCheck = Boolean(userInitiated)
|
|
backgroundCheckLaunchPending = !userInitiated
|
|
armUpdateCheckStallTimer(attemptId)
|
|
markUpdateCheckLaunched(attemptId)
|
|
void autoUpdater
|
|
.checkForUpdates()
|
|
.then(() => handleSettledUpdateCheckPromise(attemptId))
|
|
.catch((err) => {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
const message = String(err?.message ?? err)
|
|
if (userInitiated) {
|
|
userInitiatedCheck = false
|
|
} else {
|
|
backgroundCheckLaunchPending = false
|
|
}
|
|
markMissingManifestPrereleaseFallbackPromiseHandled(message)
|
|
consumeMissingManifestPrereleaseFallbackResult()
|
|
void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err)
|
|
})
|
|
return true
|
|
}
|
|
|
|
/** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */
|
|
function runBackgroundUpdateCheck(
|
|
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
|
|
): boolean {
|
|
// Why: a pinned dev jump owns the feed until it settles; a background check
|
|
// would repoint it mid-flight and download the wrong build.
|
|
if (
|
|
activeUpdateSource !== 'release' ||
|
|
isPinnedBuildActive ||
|
|
localBuildSelectionInProgress ||
|
|
pinnedBuildSelectionInProgress
|
|
) {
|
|
return false
|
|
}
|
|
if (backgroundCheckLaunchPending || currentStatus.state === 'checking') {
|
|
return false
|
|
}
|
|
if (!app.isPackaged || is.dev) {
|
|
sendStatus({ state: 'not-available' })
|
|
return false
|
|
}
|
|
// Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch.
|
|
activeUpdateNudgeId = nudgeId
|
|
// Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap.
|
|
backgroundCheckLaunchPending = true
|
|
backgroundCheckPromotedToUserInitiated = false
|
|
const attemptId = beginUpdateCheckAttempt()
|
|
// Don't send 'checking' here — the 'checking-for-update' handler does; sending from both dupes notifications (issue #35).
|
|
const autoUpdater = getAutoUpdater()
|
|
const launch = (): Promise<unknown> | undefined => {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return undefined
|
|
}
|
|
markUpdateCheckLaunched(attemptId)
|
|
return autoUpdater.checkForUpdates()
|
|
}
|
|
const run = pinDefaultReleaseFeed().then(launch)
|
|
void Promise.resolve(run)
|
|
.then(() => handleSettledUpdateCheckPromise(attemptId))
|
|
.catch((err) => {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
const wasUserInitiated = getSettledCheckUserInitiated()
|
|
backgroundCheckLaunchPending = false
|
|
backgroundCheckPromotedToUserInitiated = false
|
|
if (wasUserInitiated) {
|
|
userInitiatedCheck = false
|
|
}
|
|
void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err)
|
|
})
|
|
return true
|
|
}
|
|
|
|
export function checkForUpdates(): void {
|
|
// Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate.
|
|
void withUpdaterSpan({ stage: 'check' }, async (span) => {
|
|
span.setAttribute('updater.outcome', 'launched')
|
|
runBackgroundUpdateCheck()
|
|
})
|
|
}
|
|
|
|
function enablePrereleaseManifestChecks(): void {
|
|
getAutoUpdater().allowPrerelease = true
|
|
}
|
|
|
|
function enableIncludePrerelease(): void {
|
|
if (includePrereleaseActive) {
|
|
return
|
|
}
|
|
// Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets.
|
|
enablePrereleaseManifestChecks()
|
|
includePrereleaseActive = true
|
|
}
|
|
|
|
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
|
|
export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
|
|
if (!app.isPackaged || is.dev) {
|
|
sendStatus({ state: 'not-available', userInitiated: true })
|
|
return
|
|
}
|
|
if (options?.localBuild) {
|
|
void checkForLocalBuildFromMenu()
|
|
return
|
|
}
|
|
if (options?.targetTag && options.channel) {
|
|
void checkForPinnedBuild(options.channel, options.targetTag)
|
|
return
|
|
}
|
|
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) {
|
|
return
|
|
}
|
|
if (
|
|
activeUpdateSource !== 'release' &&
|
|
(currentStatus.state === 'checking' || currentStatus.state === 'downloading')
|
|
) {
|
|
return
|
|
}
|
|
restoreReleaseUpdateSource()
|
|
|
|
const checkVariant = getUpdateCheckVariant(options)
|
|
if (checkVariant === 'prerelease') {
|
|
clearPrereleaseFallbackContext()
|
|
enableIncludePrerelease()
|
|
} else if (checkVariant === 'perf') {
|
|
clearPrereleaseFallbackContext()
|
|
// Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel.
|
|
enablePrereleaseManifestChecks()
|
|
}
|
|
|
|
const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
|
|
userInitiatedCheck = true
|
|
// Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident.
|
|
activeUpdateNudgeId = null
|
|
// Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below.
|
|
sendStatus({ state: 'checking', userInitiated: true })
|
|
if (checkAlreadyInFlight) {
|
|
backgroundCheckPromotedToUserInitiated = true
|
|
rearmActiveUpdateCheckStallTimer()
|
|
if (checkVariant !== 'default') {
|
|
// Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result.
|
|
pendingUserInitiatedCheckAfterInFlight = checkVariant
|
|
}
|
|
return
|
|
}
|
|
|
|
const attemptId = beginUpdateCheckAttempt()
|
|
const autoUpdater = getAutoUpdater()
|
|
const launch = (): Promise<unknown> | undefined => {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return undefined
|
|
}
|
|
markUpdateCheckLaunched(attemptId)
|
|
return autoUpdater.checkForUpdates()
|
|
}
|
|
const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => {
|
|
if (preflightResult === 'not-available') {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return false
|
|
}
|
|
userInitiatedCheck = false
|
|
finishActiveUpdateCheckAttempt()
|
|
recordCompletedUpdateCheck()
|
|
sendStatus({ state: 'not-available', userInitiated: true })
|
|
return false
|
|
}
|
|
return launch()
|
|
})
|
|
void Promise.resolve(run)
|
|
.then((launchResult) => {
|
|
if (launchResult === false) {
|
|
return
|
|
}
|
|
handleSettledUpdateCheckPromise(attemptId)
|
|
})
|
|
.catch((err) => {
|
|
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
|
return
|
|
}
|
|
userInitiatedCheck = false
|
|
void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err)
|
|
})
|
|
}
|
|
|
|
async function checkForLocalBuildFromMenu(): Promise<void> {
|
|
if (process.platform !== 'darwin') {
|
|
sendLocalBuildErrorAndRestore(
|
|
'Local build switching is currently available only on macOS.',
|
|
true
|
|
)
|
|
return
|
|
}
|
|
if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') {
|
|
return
|
|
}
|
|
if (localBuildSelectionInProgress) {
|
|
return
|
|
}
|
|
localBuildSelectionInProgress = true
|
|
try {
|
|
const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([
|
|
import('./local-builds/local-build-switch'),
|
|
import('./local-builds/local-build-feed-server')
|
|
])
|
|
const candidate = await chooseLocalBuild(mainWindowRef)
|
|
if (!candidate) {
|
|
return
|
|
}
|
|
closeLocalBuildFeed()
|
|
const feed = await startLocalBuildFeed(candidate)
|
|
activeLocalBuildFeed = feed
|
|
activeUpdateSource = 'local'
|
|
clearPrereleaseFallbackContext()
|
|
clearPublishingWindowLastGoodCheck()
|
|
clearAvailableUpdateContext()
|
|
activeUpdateNudgeId = null
|
|
userInitiatedCheck = true
|
|
sendStatus({ state: 'checking', userInitiated: true })
|
|
|
|
const updater = getAutoUpdater()
|
|
updater.allowDowngrade = true
|
|
updater.disableDifferentialDownload = true
|
|
updater.setFeedURL({ provider: 'generic', url: feed.url })
|
|
const attemptId = beginUpdateCheckAttempt()
|
|
markUpdateCheckLaunched(attemptId)
|
|
await updater.checkForUpdates()
|
|
handleSettledUpdateCheckPromise(attemptId)
|
|
} catch (error) {
|
|
userInitiatedCheck = false
|
|
sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true)
|
|
} finally {
|
|
localBuildSelectionInProgress = false
|
|
}
|
|
}
|
|
|
|
export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise<ReleaseBuild[]> {
|
|
return listReleaseBuilds(channel)
|
|
}
|
|
|
|
/**
|
|
* Pins the updater at one exact release tag and checks it, so a dev can move to
|
|
* any published build on any channel — including an older one.
|
|
*
|
|
* Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's
|
|
* hourly" is a downgrade by semver. The pin is torn down as soon as the attempt
|
|
* settles so ordinary background checks never inherit it.
|
|
*/
|
|
async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise<void> {
|
|
if (!app.isPackaged || is.dev) {
|
|
sendStatus({ state: 'not-available', userInitiated: true })
|
|
return
|
|
}
|
|
// Why here as well as in the picker: the renderer disables the option, but IPC
|
|
// is reachable regardless, and there is no artifact to install on a platform
|
|
// the dev workflows do not build for.
|
|
if (!isChannelSupportedOnPlatform(channel, process.platform)) {
|
|
sendStatus({
|
|
state: 'error',
|
|
message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`,
|
|
userInitiated: true
|
|
})
|
|
return
|
|
}
|
|
// Why: electron-updater would otherwise take this all the way to a download
|
|
// and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead
|
|
// — the installer is run by hand once, and in-app updates work from there on.
|
|
if (
|
|
requiresManualDevChannelInstall({
|
|
platform: process.platform,
|
|
runningChannel: getVersionChannel(app.getVersion()),
|
|
targetChannel: channel
|
|
})
|
|
) {
|
|
sendStatus({
|
|
state: 'error',
|
|
message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`,
|
|
userInitiated: true
|
|
})
|
|
return
|
|
}
|
|
if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') {
|
|
return
|
|
}
|
|
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) {
|
|
return
|
|
}
|
|
pinnedBuildSelectionInProgress = true
|
|
try {
|
|
const target = resolveTargetBuild(channel, tag)
|
|
if (compareVersions(target.version, app.getVersion()) === 0) {
|
|
sendStatus({ state: 'not-available', userInitiated: true })
|
|
return
|
|
}
|
|
closeLocalBuildFeed()
|
|
activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release'
|
|
isPinnedBuildActive = true
|
|
clearPrereleaseFallbackContext()
|
|
clearPublishingWindowLastGoodCheck()
|
|
clearAvailableUpdateContext()
|
|
activeUpdateNudgeId = null
|
|
userInitiatedCheck = true
|
|
sendStatus({ state: 'checking', userInitiated: true })
|
|
|
|
const updater = getAutoUpdater()
|
|
// Why: an intentional jump to an older tag must not be filtered out as "not newer".
|
|
updater.allowDowngrade = true
|
|
updater.disableDifferentialDownload = true
|
|
updater.allowPrerelease = true
|
|
console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`)
|
|
updater.setFeedURL({ provider: 'generic', url: target.feedUrl })
|
|
availableReleaseUrl = target.feedUrl
|
|
const attemptId = beginUpdateCheckAttempt()
|
|
markUpdateCheckLaunched(attemptId)
|
|
await updater.checkForUpdates()
|
|
handleSettledUpdateCheckPromise(attemptId)
|
|
} catch (error) {
|
|
userInitiatedCheck = false
|
|
clearAvailableUpdateContext()
|
|
restoreReleaseUpdateSource()
|
|
sendStatus({
|
|
state: 'error',
|
|
message: String((error as Error)?.message ?? error),
|
|
userInitiated: true
|
|
})
|
|
} finally {
|
|
pinnedBuildSelectionInProgress = false
|
|
}
|
|
}
|
|
|
|
export function isQuittingForUpdate(): boolean {
|
|
return quittingForUpdate
|
|
}
|
|
|
|
function getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null {
|
|
if (currentStatus.state !== 'error') {
|
|
return null
|
|
}
|
|
return currentStatus.recovery?.kind === 'linux-package-install' ? currentStatus.recovery : null
|
|
}
|
|
|
|
const LINUX_PACKAGE_RECOVERY_MESSAGES: Record<LinuxPackageRecoveryUnavailableReason, string> = {
|
|
missing:
|
|
'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.',
|
|
// Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type.
|
|
'not-regular':
|
|
'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.',
|
|
'hash-mismatch':
|
|
'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.',
|
|
'read-failed':
|
|
'Orca could not read the downloaded package. Download the update again, or get it from the official release page.',
|
|
'no-sudo':
|
|
'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.',
|
|
'no-package-manager':
|
|
'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.',
|
|
// Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state.
|
|
'invalid-package-path':
|
|
'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.'
|
|
}
|
|
|
|
// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too.
|
|
const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [
|
|
'missing',
|
|
'not-regular',
|
|
'hash-mismatch'
|
|
]
|
|
|
|
function recordLinuxPackageRecoveryUnavailable(
|
|
recovery: LinuxPackageInstallRecovery,
|
|
reason: LinuxPackageRecoveryUnavailableReason
|
|
): void {
|
|
recordUpdaterLifecycle(
|
|
'linux_package_recovery_unavailable',
|
|
{ reason, packageType: recovery.packageType, version: recovery.version },
|
|
{ level: 'warn', message: 'Linux package recovery action unavailable' }
|
|
)
|
|
}
|
|
|
|
function failLinuxPackageRecovery(
|
|
recovery: LinuxPackageInstallRecovery,
|
|
reason: LinuxPackageRecoveryUnavailableReason
|
|
): never {
|
|
recordLinuxPackageRecoveryUnavailable(recovery, reason)
|
|
const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason]
|
|
// Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would
|
|
// destroy the newer artifact and clobber whatever card replaced this one.
|
|
const active = getActiveLinuxPackageRecovery()
|
|
const stillCurrent =
|
|
active?.version === recovery.version && active?.packageType === recovery.packageType
|
|
if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) {
|
|
clearTrackedLinuxPackageArtifact()
|
|
sendStatus({ state: 'error', message })
|
|
}
|
|
throw new Error(message)
|
|
}
|
|
|
|
/**
|
|
* Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash
|
|
* can be dropped when a newer cycle already replaced the card it would otherwise overwrite.
|
|
*/
|
|
function getInstallCycleSignature(): string {
|
|
const recovery = getActiveLinuxPackageRecovery()
|
|
if (recovery) {
|
|
return `recovery:${recovery.packageType}:${recovery.version}`
|
|
}
|
|
return currentStatus.state === 'downloaded'
|
|
? `downloaded:${currentStatus.version}`
|
|
: `state:${currentStatus.state}`
|
|
}
|
|
|
|
/**
|
|
* Re-proves the retained package before the install starts. Returns false when the install must be
|
|
* abandoned; the artifact is only re-read here, so callers still own every teardown decision.
|
|
*/
|
|
async function proveRetainedLinuxPackage(pendingVersion: string): Promise<boolean> {
|
|
const artifact = getTrackedLinuxPackageArtifact()
|
|
if (!artifact) {
|
|
return true
|
|
}
|
|
// Why: an artifact retained from another cycle says nothing about the file electron-updater is
|
|
// about to install, so proving it would block a legitimate install on an unrelated digest.
|
|
if (pendingVersion && pendingVersion !== artifact.version) {
|
|
return true
|
|
}
|
|
const recovery = getActiveLinuxPackageRecovery()
|
|
const cycle = getInstallCycleSignature()
|
|
const reason = await revalidateRetainedLinuxPackage(artifact)
|
|
if (!reason) {
|
|
return true
|
|
}
|
|
reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle })
|
|
return false
|
|
}
|
|
|
|
/** The failing reason, or null when the retained package still matches its release digest. */
|
|
async function revalidateRetainedLinuxPackage(
|
|
artifact: LinuxPackageArtifact
|
|
): Promise<LinuxPackageRecoveryUnavailableReason | null> {
|
|
linuxPackageRevalidationInFlight = true
|
|
try {
|
|
const verdict = await revalidateLinuxPackageForInstall(artifact)
|
|
return verdict.ok ? null : verdict.reason
|
|
} catch (error) {
|
|
recordUpdaterLifecycle(
|
|
'linux_package_revalidation_errored',
|
|
{ errorType: error instanceof Error ? error.name : typeof error },
|
|
{ level: 'warn', message: 'Could not re-verify the retained update package' }
|
|
)
|
|
// Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer.
|
|
return 'read-failed'
|
|
} finally {
|
|
// Why: the invariant every install path depends on — a wedged flag would make quitAndInstall
|
|
// early-return for the rest of the session.
|
|
linuxPackageRevalidationInFlight = false
|
|
}
|
|
}
|
|
|
|
function reportLinuxPackageRevalidationFailure({
|
|
artifact,
|
|
recovery,
|
|
reason,
|
|
cycle
|
|
}: {
|
|
artifact: LinuxPackageArtifact
|
|
recovery: LinuxPackageInstallRecovery | null
|
|
reason: LinuxPackageRecoveryUnavailableReason
|
|
cycle: string
|
|
}): void {
|
|
recordUpdaterLifecycle(
|
|
'linux_package_revalidation_failed',
|
|
{
|
|
action: recovery ? 'retry-automatic' : 'restart-to-install',
|
|
packageType: artifact.packageType,
|
|
version: artifact.version,
|
|
reason
|
|
},
|
|
{ level: 'warn', message: 'Retained update package failed its pre-install digest check' }
|
|
)
|
|
// Why: a package proven bad must not stay tracked, but a download that landed during the hash
|
|
// owns the slot now and destroying it would force a needless 160 MB redownload.
|
|
const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason)
|
|
if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) {
|
|
clearTrackedLinuxPackageArtifact()
|
|
}
|
|
// Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been
|
|
// replaced must not clobber whatever card the user is looking at now.
|
|
if (getInstallCycleSignature() !== cycle) {
|
|
return
|
|
}
|
|
sendInstallFailureStatus({
|
|
state: 'error',
|
|
message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason],
|
|
// Why: an unreadable file is not evidence the bytes changed, so the recovery card and its
|
|
// Copy/Show actions survive a transient I/O failure exactly as they do elsewhere.
|
|
...(recovery && !clearsArtifact ? { recovery } : {})
|
|
})
|
|
}
|
|
|
|
export async function getLinuxPackageInstallInstructions(): Promise<LinuxPackageInstallInstructions> {
|
|
const recovery = getActiveLinuxPackageRecovery()
|
|
if (!recovery) {
|
|
throw new Error('No package install recovery is available.')
|
|
}
|
|
recordUpdaterLifecycle('linux_package_recovery_requested', {
|
|
action: 'copy-command',
|
|
packageType: recovery.packageType,
|
|
version: recovery.version
|
|
})
|
|
const result = await resolveLinuxPackageInstallInstructions(recovery)
|
|
if (!result.ok) {
|
|
// Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote
|
|
// Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts).
|
|
if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') {
|
|
recordLinuxPackageRecoveryUnavailable(recovery, result.reason)
|
|
return {
|
|
ok: false,
|
|
reason: result.reason,
|
|
message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason]
|
|
}
|
|
}
|
|
failLinuxPackageRecovery(recovery, result.reason)
|
|
}
|
|
return { ok: true, command: result.command, packageFileName: result.packageFileName }
|
|
}
|
|
|
|
export async function showLinuxPackage(): Promise<void> {
|
|
const recovery = getActiveLinuxPackageRecovery()
|
|
if (!recovery) {
|
|
throw new Error('No package install recovery is available.')
|
|
}
|
|
recordUpdaterLifecycle('linux_package_recovery_requested', {
|
|
action: 'show-package',
|
|
packageType: recovery.packageType,
|
|
version: recovery.version
|
|
})
|
|
const result = await revealLinuxPackage(recovery)
|
|
if (!result.ok) {
|
|
failLinuxPackageRecovery(recovery, result.reason)
|
|
}
|
|
}
|
|
|
|
export function quitAndInstall(): void {
|
|
if (
|
|
localBuildSelectionInProgress ||
|
|
pinnedBuildSelectionInProgress ||
|
|
pendingQuitAndInstallTimer ||
|
|
quitAndInstallInProgress ||
|
|
// Why: the quit timer is already cleared while the pre-install digest re-proof streams, so
|
|
// without this a second click would schedule a parallel install of the same package.
|
|
linuxPackageRevalidationInFlight
|
|
) {
|
|
return
|
|
}
|
|
|
|
const retriedRecovery = getActiveLinuxPackageRecovery()
|
|
if (retriedRecovery) {
|
|
recordUpdaterLifecycle('linux_package_recovery_requested', {
|
|
action: 'retry-automatic',
|
|
packageType: retriedRecovery.packageType,
|
|
version: retriedRecovery.version
|
|
})
|
|
}
|
|
|
|
if (deferHeadlessServeInstall('install', getPendingInstallVersion())) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
deferMacQuitUntilInstallerReady(
|
|
currentStatus,
|
|
hasInstallableDownloadedVersion(),
|
|
getPendingInstallVersion,
|
|
sendStatus
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing.
|
|
pendingQuitAndInstallTimer = setTimeout(() => {
|
|
void performQuitAndInstall()
|
|
}, QUIT_AND_INSTALL_DELAY_MS)
|
|
}
|
|
|
|
async function checkForUpdateNudge(): Promise<void> {
|
|
if (!app.isPackaged || is.dev) {
|
|
return
|
|
}
|
|
if (nudgeCheckInFlight) {
|
|
return
|
|
}
|
|
|
|
const now = Date.now()
|
|
if (now - lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) {
|
|
return
|
|
}
|
|
lastNudgeCheckAt = now
|
|
|
|
nudgeCheckInFlight = true
|
|
try {
|
|
const nudge = await fetchNudge()
|
|
if (!nudge) {
|
|
return
|
|
}
|
|
|
|
if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') {
|
|
return
|
|
}
|
|
|
|
const appVersion = app.getVersion()
|
|
const pendingUpdateNudgeId = _getPendingUpdateNudgeId?.() ?? null
|
|
const dismissedUpdateNudgeId = _getDismissedUpdateNudgeId?.() ?? null
|
|
|
|
if (
|
|
shouldApplyNudge({
|
|
nudge,
|
|
appVersion,
|
|
pendingUpdateNudgeId,
|
|
dismissedUpdateNudgeId
|
|
})
|
|
) {
|
|
awaitingNudgeCheckOutcome = true
|
|
_setPendingUpdateNudgeId?.(nudge.id)
|
|
mainWindowRef?.webContents.send('updater:clearDismissal')
|
|
runBackgroundUpdateCheck(nudge.id)
|
|
}
|
|
} finally {
|
|
nudgeCheckInFlight = false
|
|
}
|
|
}
|
|
|
|
function scheduleUpdateNudgeCheck(): void {
|
|
if (nudgeCheckTimer) {
|
|
clearTimeout(nudgeCheckTimer)
|
|
}
|
|
nudgeCheckTimer = setTimeout(() => {
|
|
void checkForUpdateNudge()
|
|
scheduleUpdateNudgeCheck()
|
|
}, NUDGE_POLL_INTERVAL_MS)
|
|
}
|
|
|
|
export function dismissNudge(): void {
|
|
const pendingId = activeUpdateNudgeId ?? _getPendingUpdateNudgeId?.() ?? null
|
|
if (pendingId) {
|
|
_setDismissedUpdateNudgeId?.(pendingId)
|
|
clearPendingUpdateNudge()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The user closed an offered update without taking it. For a local build or a
|
|
* pinned dev jump that ends the session: nothing will consume that feed now, so
|
|
* release checks must stop being deferred.
|
|
*/
|
|
export function dismissAvailableUpdate(): void {
|
|
if (activeUpdateSource === 'release' && !isPinnedBuildActive) {
|
|
return
|
|
}
|
|
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) {
|
|
return
|
|
}
|
|
// Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade.
|
|
if (currentStatus.state !== 'available') {
|
|
return
|
|
}
|
|
clearAvailableUpdateContext()
|
|
restoreReleaseUpdateSource()
|
|
// Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed.
|
|
sendStatus({ state: 'idle' })
|
|
}
|
|
|
|
export function setupAutoUpdater(
|
|
mainWindow: BrowserWindow,
|
|
opts?: {
|
|
getLastUpdateCheckAt?: () => number | null
|
|
onBeforeQuit?: () => void | Promise<void>
|
|
setLastUpdateCheckAt?: (timestamp: number) => void
|
|
getPendingUpdateNudgeId?: () => string | null
|
|
getDismissedUpdateNudgeId?: () => string | null
|
|
setPendingUpdateNudgeId?: (id: string | null) => void
|
|
setDismissedUpdateNudgeId?: (id: string | null) => void
|
|
getReleaseChannelOverride?: () => ReleaseChannel | null
|
|
installMode?: UpdateInstallMode
|
|
}
|
|
): void {
|
|
mainWindowRef = mainWindow
|
|
onBeforeQuitCleanup = opts?.onBeforeQuit ?? null
|
|
persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null
|
|
_getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null
|
|
_getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null
|
|
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
|
|
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
|
|
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
|
|
getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null
|
|
updateInstallMode = opts?.installMode ?? 'interactive'
|
|
lastInstallDeferralVersion = { download: null, install: null }
|
|
|
|
const serveHandoffFailure = getServeUpdateHandoffFailure()
|
|
if (serveHandoffFailure) {
|
|
recordUpdaterLifecycle(
|
|
'headless_serve_handoff_failed',
|
|
{ reason: serveHandoffFailure },
|
|
{ level: 'warn', message: 'Supervised serve update did not complete' }
|
|
)
|
|
sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true)
|
|
}
|
|
|
|
if (!app.isPackaged && !is.dev) {
|
|
return
|
|
}
|
|
if (is.dev) {
|
|
return
|
|
}
|
|
|
|
const autoUpdater = getAutoUpdater()
|
|
autoUpdater.autoDownload = false
|
|
if (activeUpdateSource === 'release') {
|
|
autoUpdater.allowDowngrade = false
|
|
autoUpdater.disableDifferentialDownload = false
|
|
}
|
|
// Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly.
|
|
// Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface.
|
|
autoUpdater.autoInstallOnAppQuit =
|
|
updateInstallMode === 'interactive' && getLinuxRootPackageType() === null
|
|
// Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner.
|
|
autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive'
|
|
|
|
// Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible.
|
|
// The adapter also retains the redacted child stderr that BaseUpdater logs but drops from the 'error' event.
|
|
autoUpdater.logger = createUpdaterDiagnosticLogger() as never
|
|
|
|
// Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer.
|
|
|
|
// Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download/<tag>/ URL avoids /latest redirect drift between check and download.
|
|
if (activeUpdateSource === 'release') {
|
|
autoUpdater.setFeedURL({
|
|
provider: 'generic',
|
|
url: 'https://github.com/stablyai/orca/releases/latest/download'
|
|
})
|
|
}
|
|
|
|
if (autoUpdaterInitialized) {
|
|
return
|
|
}
|
|
autoUpdaterInitialized = true
|
|
|
|
registerAutoUpdaterHandlers({
|
|
autoUpdater,
|
|
clearAvailableUpdateContext,
|
|
consumeMissingManifestPrereleaseFallbackResult,
|
|
getMissingManifestPrereleaseFallbackUserInitiated,
|
|
getPublishingWindowLastGoodCheck,
|
|
getActiveUpdateCheckEventAttemptId,
|
|
getCurrentStatus: () => currentStatus,
|
|
getKnownReleaseUrl,
|
|
getPendingInstallVersion,
|
|
getUserInitiatedCheck: () => userInitiatedCheck,
|
|
handleQuitAndInstallFailure,
|
|
isQuitAndInstallHandoffActive,
|
|
hasInstallableDownloadedVersion,
|
|
isLocalBuildCheck: () => activeUpdateSource === 'local',
|
|
// Why: pinned jumps are deliberate, so update-available/-downloaded must not
|
|
// reject them for being older than the running version.
|
|
isPinnedBuildCheck: () => isPinnedBuildActive,
|
|
shouldHandleUpdaterErrorEvent,
|
|
performQuitAndInstall,
|
|
clearUpdateAvailableEventPending,
|
|
isActiveUpdateCheckAttempt,
|
|
markUpdateCheckEventAttempt,
|
|
markUpdateAvailableEventPending,
|
|
sendCheckFailureStatus,
|
|
sendErrorStatus,
|
|
markMissingManifestPrereleaseFallbackChecking,
|
|
shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive',
|
|
shouldSuppressMissingManifestPrereleaseFallbackEvent,
|
|
suppressMissingManifestPrereleaseFallbackPromiseFailure,
|
|
recordCompletedUpdateCheck,
|
|
restoreReleaseUpdateSource,
|
|
sendStatus,
|
|
scheduleAutomaticUpdateCheck,
|
|
clearBackgroundCheckLaunchPending,
|
|
setAvailableReleaseUrl: (releaseUrl) => {
|
|
availableReleaseUrl = releaseUrl
|
|
},
|
|
setAvailableVersion: (version) => {
|
|
availableVersion = version
|
|
},
|
|
setUserInitiatedCheck: (value) => {
|
|
userInitiatedCheck = value
|
|
}
|
|
})
|
|
|
|
void checkForUpdateNudge()
|
|
scheduleUpdateNudgeCheck()
|
|
|
|
const checkDailyOnWake = () => {
|
|
void checkForUpdateNudge()
|
|
if (
|
|
backgroundCheckLaunchPending ||
|
|
currentStatus.state === 'checking' ||
|
|
currentStatus.state === 'downloading'
|
|
) {
|
|
return
|
|
}
|
|
const lastCheck = _getLastUpdateCheckAt?.() ?? null
|
|
const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck
|
|
if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) {
|
|
runBackgroundUpdateCheck()
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
powerMonitor.on('resume', checkDailyOnWake)
|
|
app.on('browser-window-focus', checkDailyOnWake)
|
|
|
|
const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null
|
|
const msSinceLastCheck =
|
|
lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt
|
|
|
|
if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) {
|
|
runBackgroundUpdateCheck()
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
|
|
} else {
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck)
|
|
}
|
|
}
|
|
|
|
export function downloadUpdate(): void {
|
|
if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) {
|
|
return
|
|
}
|
|
// Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works.
|
|
const canStart =
|
|
currentStatus.state === 'available' ||
|
|
(currentStatus.state === 'error' && hasInstallableDownloadedVersion())
|
|
if (!canStart) {
|
|
return
|
|
}
|
|
const version = currentStatus.state === 'available' ? currentStatus.version : availableVersion
|
|
if (!version) {
|
|
return
|
|
}
|
|
if (deferHeadlessServeInstall('download', version)) {
|
|
return
|
|
}
|
|
downloadInFlight = true
|
|
const localBuildDownload = activeUpdateSource === 'local'
|
|
beginMacUpdateDownload()
|
|
// Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert.
|
|
sendStatus({ state: 'downloading', percent: 0, version })
|
|
getAutoUpdater()
|
|
.downloadUpdate()
|
|
.catch((err) => {
|
|
downloadInFlight = false
|
|
const message = String(err?.message ?? err)
|
|
if (localBuildDownload) {
|
|
sendLocalBuildErrorAndRestore(message)
|
|
} else {
|
|
sendErrorStatus(message)
|
|
}
|
|
})
|
|
}
|