535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
/* eslint-disable max-lines */
|
|
import { app, BrowserWindow, powerMonitor } from 'electron'
|
|
import { autoUpdater } from 'electron-updater'
|
|
import type { NsisUpdater } from 'electron-updater'
|
|
import { is } from '@electron-toolkit/utils'
|
|
import type { UpdateStatus } from '../shared/types'
|
|
import { killAllPty } from './ipc/pty'
|
|
import {
|
|
beginMacUpdateDownload,
|
|
deferMacQuitUntilInstallerReady,
|
|
markMacQuitAndInstallInFlight
|
|
} from './updater-mac-install'
|
|
import { registerAutoUpdaterHandlers } from './updater-events'
|
|
import { compareVersions, isBenignCheckFailure, statusesEqual } from './updater-fallback'
|
|
import { fetchNudge, shouldApplyNudge } from './updater-nudge'
|
|
|
|
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
const AUTO_UPDATE_RETRY_INTERVAL_MS = 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
|
|
|
|
let mainWindowRef: BrowserWindow | null = null
|
|
let currentStatus: UpdateStatus = { state: 'idle' }
|
|
let userInitiatedCheck = false
|
|
let onBeforeQuitCleanup: (() => void) | null = null
|
|
let autoUpdaterInitialized = false
|
|
// Why: Cmd+Shift-clicking "Check for Updates" opts the user into the RC
|
|
// release channel for the rest of this process. We switch to the GitHub
|
|
// provider with allowPrerelease=true so both the check AND any follow-up
|
|
// download resolve against the same (possibly prerelease) release manifest.
|
|
// Resetting only after the check would leave a downloaded RC pointing at a
|
|
// feed URL that no longer advertises it. See design comment in
|
|
// enableIncludePrerelease.
|
|
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 persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null
|
|
let activeUpdateNudgeId: string | null = null
|
|
let awaitingNudgeCheckOutcome = false
|
|
let nudgeCheckInFlight = false
|
|
let lastNudgeCheckAt = 0
|
|
|
|
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 when both the card and
|
|
// Settings trigger a download before the first download-progress event
|
|
// flips the status to 'downloading'.
|
|
let downloadInFlight = false
|
|
/** Guards against the macOS `activate` handler re-opening the old version
|
|
* while Squirrel's ShipIt is replacing the .app bundle. */
|
|
let quittingForUpdate = false
|
|
|
|
function clearAvailableUpdateContext(): void {
|
|
availableVersion = null
|
|
availableReleaseUrl = null
|
|
}
|
|
|
|
function clearPendingUpdateNudge(): void {
|
|
activeUpdateNudgeId = null
|
|
awaitingNudgeCheckOutcome = false
|
|
_setPendingUpdateNudgeId?.(null)
|
|
}
|
|
|
|
function getPersistedPendingUpdateNudgeId(): string | null {
|
|
return _getPendingUpdateNudgeId?.() ?? null
|
|
}
|
|
|
|
function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
|
|
// Why: only actionable/error states carry the nudge marker so the renderer
|
|
// can tell whether a dismiss should also acknowledge the campaign. Cycle-
|
|
// boundary states (idle, checking, not-available) never need it.
|
|
if (!activeUpdateNudgeId) {
|
|
return status
|
|
}
|
|
if (status.state === 'idle' || status.state === 'checking' || status.state === 'not-available') {
|
|
return status
|
|
}
|
|
return { ...status, activeNudgeId: activeUpdateNudgeId }
|
|
}
|
|
|
|
function sendStatus(status: UpdateStatus): void {
|
|
if (awaitingNudgeCheckOutcome) {
|
|
if (status.state === 'available') {
|
|
awaitingNudgeCheckOutcome = false
|
|
} else if (
|
|
status.state === 'idle' ||
|
|
status.state === 'not-available' ||
|
|
status.state === 'error'
|
|
) {
|
|
// Why: when a nudge-triggered check finds no update (or errors out),
|
|
// move the campaign to dismissed so it doesn't re-fire on the next
|
|
// poll cycle. Without this, a nudge whose version range includes
|
|
// already-up-to-date users would loop every 30 minutes, each time
|
|
// triggering a redundant checkForUpdates() and clearing the persisted
|
|
// dismissedUpdateVersion.
|
|
if (activeUpdateNudgeId) {
|
|
_setDismissedUpdateNudgeId?.(activeUpdateNudgeId)
|
|
}
|
|
clearPendingUpdateNudge()
|
|
}
|
|
}
|
|
|
|
const decoratedStatus = decorateStatusWithActiveNudge(status)
|
|
|
|
// Why: reset the in-flight guard when the status moves past the
|
|
// window where duplicate download() calls are possible.
|
|
if (
|
|
decoratedStatus.state === 'downloading' ||
|
|
decoratedStatus.state === 'error' ||
|
|
decoratedStatus.state === 'idle'
|
|
) {
|
|
downloadInFlight = false
|
|
}
|
|
if (statusesEqual(currentStatus, decoratedStatus)) {
|
|
return
|
|
}
|
|
currentStatus = decoratedStatus
|
|
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
|
|
}
|
|
|
|
function sendErrorStatus(message: string, userInitiated?: boolean): void {
|
|
if (
|
|
currentStatus.state === 'error' &&
|
|
currentStatus.message === message &&
|
|
currentStatus.userInitiated === userInitiated
|
|
) {
|
|
return
|
|
}
|
|
sendStatus({ state: 'error', message, userInitiated })
|
|
}
|
|
|
|
function getKnownReleaseUrl(): string | undefined {
|
|
return availableReleaseUrl ?? undefined
|
|
}
|
|
|
|
function hasNewerDownloadedVersion(): boolean {
|
|
return availableVersion !== null && compareVersions(availableVersion, app.getVersion()) > 0
|
|
}
|
|
|
|
function getPendingInstallVersion(): string {
|
|
if (availableVersion) {
|
|
return availableVersion
|
|
}
|
|
if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') {
|
|
return currentStatus.version
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function performQuitAndInstall(): void {
|
|
if (pendingQuitAndInstallTimer) {
|
|
clearTimeout(pendingQuitAndInstallTimer)
|
|
pendingQuitAndInstallTimer = null
|
|
}
|
|
|
|
markMacQuitAndInstallInFlight()
|
|
|
|
// Set this BEFORE anything else so the `activate` handler in index.ts
|
|
// won't re-open the old version while Squirrel's ShipIt is replacing
|
|
// the .app bundle. Without this guard the quit triggers window
|
|
// destruction → BrowserWindow.getAllWindows().length === 0 → activate
|
|
// fires → openMainWindow() resurrects the old process and ShipIt
|
|
// either can't replace it or the user ends up on the old version.
|
|
quittingForUpdate = true
|
|
|
|
killAllPty()
|
|
onBeforeQuitCleanup?.()
|
|
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
win.removeAllListeners('close')
|
|
}
|
|
|
|
autoUpdater.quitAndInstall(false, true)
|
|
}
|
|
|
|
async function sendCheckFailureStatus(message: string, userInitiated?: boolean): Promise<void> {
|
|
const failureKey = `${userInitiated ? 'user' : 'auto'}:${message}`
|
|
if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) {
|
|
return pendingCheckFailurePromise
|
|
}
|
|
|
|
const handleFailure = async (): Promise<void> => {
|
|
if (isBenignCheckFailure(message)) {
|
|
// Why: release transition failures (missing latest.yml while a new
|
|
// release is being published) and network blips are transient. The
|
|
// previous approach sent 'not-available' for user-initiated checks
|
|
// during a release transition, which falsely told the user "you're
|
|
// on the latest version" — the toast would flash and auto-dismiss,
|
|
// hiding the fact that a newer release is mid-publish. Now all
|
|
// benign failures go to 'idle' uniformly: the toast controller
|
|
// converts a user-initiated checking→idle transition into an honest
|
|
// "currently rolling out" message, and a background retry is
|
|
// always scheduled so the update notification arrives once the
|
|
// release finishes.
|
|
console.warn('[updater] benign check failure:', message)
|
|
clearAvailableUpdateContext()
|
|
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
|
|
// Why: we intentionally do NOT call persistLastUpdateCheckAt here.
|
|
// The check didn't truly complete (the manifest was unreachable due
|
|
// to a release transition or network blip), so recording a timestamp
|
|
// would suppress the next startup check and delay discovery of the
|
|
// new version.
|
|
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
|
|
}
|
|
|
|
export function getUpdateStatus(): UpdateStatus {
|
|
return currentStatus
|
|
}
|
|
|
|
function scheduleAutomaticUpdateCheck(delayMs: number): void {
|
|
if (autoUpdateCheckTimer) {
|
|
clearTimeout(autoUpdateCheckTimer)
|
|
}
|
|
autoUpdateCheckTimer = setTimeout(() => {
|
|
// Why: Orca is often left running for days. A one-shot startup check means
|
|
// users can miss fresh releases entirely, so we always keep the next
|
|
// background attempt scheduled in the main process instead of tying checks
|
|
// to relaunches or renderer lifetime.
|
|
runBackgroundUpdateCheck()
|
|
}, delayMs)
|
|
}
|
|
|
|
function recordCompletedUpdateCheck(): void {
|
|
persistLastUpdateCheckAt?.(Date.now())
|
|
}
|
|
|
|
function runBackgroundUpdateCheck(
|
|
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
|
|
): void {
|
|
if (!app.isPackaged || is.dev) {
|
|
sendStatus({ state: 'not-available' })
|
|
return
|
|
}
|
|
// Why: scope the nudge marker to the updater cycle being launched right now.
|
|
// Setting it here, before any updater events or rejected promises can arrive,
|
|
// prevents later ordinary checks from inheriting an older campaign id. Use
|
|
// the persisted pending id for ordinary background checks so a nudge-driven
|
|
// card can still be dismissed correctly after relaunch or a later 24h check.
|
|
activeUpdateNudgeId = nudgeId
|
|
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
|
|
// and sending it from both places causes duplicate notifications (issue #35).
|
|
autoUpdater.checkForUpdates().catch((err) => {
|
|
void sendCheckFailureStatus(String(err?.message ?? err))
|
|
})
|
|
}
|
|
|
|
export function checkForUpdates(): void {
|
|
runBackgroundUpdateCheck()
|
|
}
|
|
|
|
function enableIncludePrerelease(): void {
|
|
if (includePrereleaseActive) {
|
|
return
|
|
}
|
|
// Why: the default feed points at GitHub's /releases/latest/download/
|
|
// manifest, which is scoped to the most recent non-prerelease release.
|
|
// Switch to the native github provider with allowPrerelease so latest.yml
|
|
// is sourced from the newest release on the repo regardless of the
|
|
// prerelease flag. Staying on this feed for the rest of the process
|
|
// keeps the download manifest consistent with the check result.
|
|
autoUpdater.allowPrerelease = true
|
|
autoUpdater.setFeedURL({
|
|
provider: 'github',
|
|
owner: 'stablyai',
|
|
repo: 'orca'
|
|
})
|
|
includePrereleaseActive = true
|
|
}
|
|
|
|
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
|
|
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
|
|
if (!app.isPackaged || is.dev) {
|
|
sendStatus({ state: 'not-available', userInitiated: true })
|
|
return
|
|
}
|
|
|
|
if (options?.includePrerelease) {
|
|
enableIncludePrerelease()
|
|
}
|
|
|
|
userInitiatedCheck = true
|
|
// Why: a manual check is independent of any active nudge campaign. Reset the
|
|
// nudge marker so the resulting status is not decorated with activeNudgeId,
|
|
// which would cause a later dismiss to consume the campaign by accident.
|
|
activeUpdateNudgeId = null
|
|
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
|
|
// and sending it from both places causes duplicate notifications (issue #35).
|
|
|
|
autoUpdater.checkForUpdates().catch((err) => {
|
|
userInitiatedCheck = false
|
|
void sendCheckFailureStatus(String(err?.message ?? err), true)
|
|
})
|
|
}
|
|
|
|
export function isQuittingForUpdate(): boolean {
|
|
return quittingForUpdate
|
|
}
|
|
|
|
export function quitAndInstall(): void {
|
|
if (pendingQuitAndInstallTimer) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
deferMacQuitUntilInstallerReady(
|
|
currentStatus,
|
|
hasNewerDownloadedVersion(),
|
|
getPendingInstallVersion,
|
|
sendStatus
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Why: every renderer entrypoint reaches this IPC handler from an in-flight
|
|
// click or toast callback. Deferring the actual quit here gives the renderer
|
|
// a moment to flush dismissals/state updates before windows start closing,
|
|
// and centralizing it avoids drift between the toast flow and settings UI.
|
|
pendingQuitAndInstallTimer = setTimeout(() => {
|
|
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()
|
|
}
|
|
}
|
|
|
|
export function setupAutoUpdater(
|
|
mainWindow: BrowserWindow,
|
|
opts?: {
|
|
getLastUpdateCheckAt?: () => number | null
|
|
onBeforeQuit?: () => void
|
|
setLastUpdateCheckAt?: (timestamp: number) => void
|
|
getPendingUpdateNudgeId?: () => string | null
|
|
getDismissedUpdateNudgeId?: () => string | null
|
|
setPendingUpdateNudgeId?: (id: string | null) => void
|
|
setDismissedUpdateNudgeId?: (id: string | null) => void
|
|
}
|
|
): void {
|
|
mainWindowRef = mainWindow
|
|
onBeforeQuitCleanup = opts?.onBeforeQuit ?? null
|
|
persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null
|
|
_getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null
|
|
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
|
|
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
|
|
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
|
|
|
|
if (!app.isPackaged && !is.dev) {
|
|
return
|
|
}
|
|
if (is.dev) {
|
|
return
|
|
}
|
|
|
|
autoUpdater.autoDownload = false
|
|
autoUpdater.autoInstallOnAppQuit = true
|
|
|
|
// Why: no Windows Authenticode certificate exists for this project.
|
|
// electron-builder embeds the code-signing publisherName into the app's
|
|
// bundled app-update.yml at build time. Versions that were incorrectly
|
|
// signed with the macOS Apple Developer ID cert (issue #631) baked in a
|
|
// publisherName whose chain Windows cannot validate, and even after the
|
|
// CI fix the installed app's app-update.yml still contains the stale
|
|
// publisherName. Skip Windows code signing verification — update
|
|
// integrity is still guaranteed by the SHA-512 hash check in latest.yml.
|
|
//
|
|
// TODO: remove this override once a Windows Authenticode certificate is
|
|
// purchased and WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD are added to CI.
|
|
// At that point electron-builder will embed the correct publisherName
|
|
// and the default verification should be re-enabled.
|
|
if (process.platform === 'win32') {
|
|
;(autoUpdater as NsisUpdater).verifyUpdateCodeSignature = () => Promise.resolve(null)
|
|
}
|
|
|
|
// Use the generic provider with GitHub's /releases/latest/download/ URL so
|
|
// electron-updater always fetches the manifest (latest-mac.yml, latest.yml,
|
|
// latest-linux.yml) from the latest non-prerelease release. This sidesteps
|
|
// the broken /releases/latest API endpoint (returns 406) and automatically
|
|
// excludes RC/prerelease versions without client-side filtering.
|
|
autoUpdater.setFeedURL({
|
|
provider: 'generic',
|
|
url: 'https://github.com/stablyai/orca/releases/latest/download'
|
|
})
|
|
|
|
if (autoUpdaterInitialized) {
|
|
return
|
|
}
|
|
autoUpdaterInitialized = true
|
|
|
|
registerAutoUpdaterHandlers({
|
|
clearAvailableUpdateContext,
|
|
getCurrentStatus: () => currentStatus,
|
|
getKnownReleaseUrl,
|
|
getPendingInstallVersion,
|
|
getUserInitiatedCheck: () => userInitiatedCheck,
|
|
hasNewerDownloadedVersion,
|
|
performQuitAndInstall,
|
|
sendCheckFailureStatus,
|
|
sendErrorStatus,
|
|
recordCompletedUpdateCheck,
|
|
sendStatus,
|
|
scheduleAutomaticUpdateCheck,
|
|
setAvailableReleaseUrl: (releaseUrl) => {
|
|
availableReleaseUrl = releaseUrl
|
|
},
|
|
setAvailableVersion: (version) => {
|
|
availableVersion = version
|
|
},
|
|
setUserInitiatedCheck: (value) => {
|
|
userInitiatedCheck = value
|
|
}
|
|
})
|
|
|
|
void checkForUpdateNudge()
|
|
scheduleUpdateNudgeCheck()
|
|
|
|
powerMonitor.on('resume', () => {
|
|
void checkForUpdateNudge()
|
|
})
|
|
app.on('browser-window-focus', () => {
|
|
void checkForUpdateNudge()
|
|
})
|
|
|
|
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 (currentStatus.state !== 'available' || downloadInFlight) {
|
|
return
|
|
}
|
|
downloadInFlight = true
|
|
beginMacUpdateDownload()
|
|
autoUpdater.downloadUpdate().catch((err) => {
|
|
downloadInFlight = false
|
|
sendErrorStatus(String(err?.message ?? err))
|
|
})
|
|
}
|