Files
OpenTerminal/src/main/updater.ts
T
2026-10-16 00:53:00 +08:00

567 lines
25 KiB
TypeScript

import { app, ipcMain, net, session } from 'electron'
import { autoUpdater } from 'electron-updater'
import type { SystemSettings, UpdateCheckInterval } from '@shared/settings'
import { isUpdateCheckInterval } from '@shared/settings'
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
import { t } from '../shared/i18n'
import { broadcast } from './broadcast'
import { devUpdateFeedUrl } from './devEnv'
import { loadSettings } from './settingsStore'
import { markQuitting, notifyUpdate } from './tray'
/**
* Update feeds: GitHub releases is tried first (system proxy, gated by a
* 20s connectivity probe so proxy-less users don't hang); the domestic
* Gitea generic package is the fallback (forced direct). Both feeds carry
* the NSIS .exe — electron-updater does NOT support MSI auto-updates, MSI
* is manual only.
*/
const GITEA_FEED =
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
const GITEA_RELEASES_API =
'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal/releases?limit=10'
const GITHUB_REPO = { owner: 'billowliu2', repo: 'OpenTerminal' }
const GITHUB_RELEASES_API =
'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10'
const GITHUB_PROBE_URL =
'https://api.github.com/repos/billowliu2/OpenTerminal/releases/latest'
/**
* Time budgets, kept in one mutable object so the offline harness
* (tests/updater-fallback.mjs) can drive the timeout and fallback paths without
* waiting out the production values. Nothing in the app calls
* `setUpdateTimeouts`; the numbers below are the shipping ones.
*
* - `probe`: GitHub connectivity probe. Short enough that a proxy-less user
* falls back to Gitea instead of hanging on a dead proxy/DNS.
* - `check`: overall budget for ONE check attempt — see withTimeout, which
* exists because electron-updater's own socket idle timeout only fires on
* silence, so a slow trickling response can hold an attempt open forever.
* - `fetch`: changelog / releases-API fetches; a stalled response must not
* hang the About tab.
* - `startupDelay`: how long after launch the startup check runs.
* - `hour`: what one settings "hour" is worth in milliseconds. The periodic
* check multiplies the configured interval (4/12/24) by it, so the harness can
* drive a whole cadence in milliseconds instead of waiting out real hours.
*/
const budgets = {
probe: 20_000,
check: 30_000,
fetch: 15_000,
startupDelay: 5_000,
hour: 3_600_000
}
export function setUpdateTimeouts(patch: Partial<typeof budgets>): void {
Object.assign(budgets, patch)
}
/** Interval used when settings carry no whitelisted value (a hand-built object). */
const FALLBACK_CHECK_INTERVAL_HOURS: UpdateCheckInterval = 4
/** Which entry point asked for a check; surfaced as `UpdateState.scheduled`. */
export type CheckSource = 'manual' | 'startup' | 'scheduled'
/** Which entry point asked for a download; surfaced as `UpdateState.auto`. */
type DownloadSource = 'manual' | 'auto'
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
let activeFeed: 'gitea' | 'github' = 'gitea'
let eventsWired = false
/**
* In-flight check / download. Both electron-updater calls de-dupe concurrent
* callers internally by handing back the same promise, which would make the
* second caller silently inherit the first one's source marks — guarding here
* keeps `scheduled` / `auto` honest, and keeps a background tick from hijacking
* a check the user just started by hand.
*/
let checkInFlight: Promise<UpdateState> | undefined
let downloadInFlight: Promise<void> | undefined
/** The periodic check timer; reassigned on every settings save. */
let intervalTimer: NodeJS.Timeout | undefined
/** Once per run: the startup check is armed the first time the schedule is
* applied, and a later settings save must not schedule a second one. */
let startupArmed = false
/**
* What has already been done for ONE release, so an app left running for days
* does not deal with the same version over and over: every 4 hours the periodic
* check re-reports the same availability, and without this record each cycle
* walked electron-updater through another `downloadUpdate()` — its cache makes
* that cheap, but the "update downloaded" balloon fired again every time — or,
* with auto-download off, repeated the "an update is available" balloon.
*
* One record at a time, deliberately: suppression is about the release the last
* check reported, and any DIFFERENT version replaces the whole record. A new
* release must be announced and downloaded as if this were a fresh launch, so
* the record must not outlive the release it describes (which also means a feed
* flapping between two versions cannot leave the older one suppressed, and that
* the memory here is bounded by construction).
*/
type ActedRelease = {
version: string
/** The "an update is available" balloon was already considered for this version. */
announced: boolean
/** A download of this version has been started (and not failed) or already
* finished — no later cycle needs that transfer again. Says nothing about
* whether the transfer ENDED: it is set when a download starts. */
fetchHandled: boolean
/** The package for this version is on disk (`update-downloaded` was seen).
* Separate from `fetchHandled` on purpose — a download in flight must not be
* mistaken for a finished one, or `downloading` would be skipped over. */
downloaded: boolean
/** The "the update has been downloaded" balloon was already considered. */
downloadedAnnounced: boolean
}
let actedRelease: ActedRelease | undefined
/** The record for `version`, replaced wholesale when it names a different release. */
function actedFor(version: string): ActedRelease {
if (actedRelease === undefined || actedRelease.version !== version) {
actedRelease = {
version,
announced: false,
fetchHandled: false,
downloaded: false,
downloadedAnnounced: false
}
}
return actedRelease
}
function setState(patch: Partial<UpdateState>): void {
state = { ...state, ...patch, currentVersion: app.getVersion() }
broadcast(Ipc.UPDATE_STATE, state)
}
function useFeed(feed: 'gitea' | 'github'): void {
activeFeed = feed
if (feed === 'gitea') {
// Domestic feed: always direct — a system proxy only breaks it.
// OT_UPDATE_URL overrides the feed in dev builds only; OT_UPDATE_TOKEN is
// read from the environment in every build, which is only safe because the
// packaged URL is the hardcoded GITEA_FEED — making it configurable again
// would turn the token into a credential sent to whatever host it names.
void autoUpdater.netSession.setProxy({ mode: 'direct' })
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
url: devUpdateFeedUrl() ?? GITEA_FEED,
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
} else {
// GitHub usually needs the system proxy (when one is enabled).
void autoUpdater.netSession.setProxy({ mode: 'system' })
autoUpdater.setFeedURL({ provider: 'github', ...GITHUB_REPO })
}
}
function wireEvents(): void {
if (eventsWired) return
eventsWired = true
autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined }))
autoUpdater.on('update-available', (info) => {
const seen = actedFor(info.version)
// A release whose package already sits on disk keeps the `downloaded` state.
// Reporting `available` again would fold the About tab's "install" button back
// into "download" — for a user who never installed, every 4-hour cycle, for
// as long as the app stays open. The record is dropped before a manual check
// looks at it, so a hand-started check reports the truth again.
//
// The state cannot simply be left alone here: the check that found this
// release put it on 'checking', so an early return with no write would park
// the About tab on a spinner forever. Rebuild the equivalent 'downloaded'
// state instead — same fields `update-downloaded` writes, and `setState`
// merges, so `auto` (which the About tab reads for the automatic wording) and
// `scheduled` survive untouched.
if (seen.downloaded) {
setState({ status: 'downloaded', version: info.version, feed: activeFeed, percent: undefined })
return
}
setState({ status: 'available', version: info.version, feed: activeFeed, percent: undefined })
// The write above reports what the check just found; the suppression below is
// about what has already been done for that release, so a 4-hour cycle that
// keeps finding the same one does not restart its download or repeat its
// balloon. (A release that already finished downloading never gets here — it
// was answered above.)
// With auto-download on there is no "an update is available" balloon: the
// balloon that matters is the one saying the download finished. Either way
// nothing installs itself — installing stays a user action, and
// autoInstallOnAppQuit covers quitting.
if (loadSettings().system.autoDownloadUpdate === true) {
if (seen.fetchHandled) return
void handleDownload('auto')
return
}
if (seen.announced) return
seen.announced = true
notifyUpdate(
t('main.updater.notifyAvailableTitle'),
t('main.updater.notifyAvailableContent', { version: info.version })
)
})
// The suppression record survives this on purpose: a check that reports
// nothing (or fails) must not make the next cycle fetch a release it has
// already dealt with.
autoUpdater.on('update-not-available', () => setState({ status: 'latest', version: undefined }))
autoUpdater.on('download-progress', (p) =>
setState({ status: 'downloading', percent: Math.round(p.percent) })
)
autoUpdater.on('update-downloaded', (info) => {
// Read before setState: `auto` says who started this download, and the state
// keeps it (the About tab shows the automatic variant for it).
const wasAuto = state.auto === true
setState({ status: 'downloaded', version: info.version, percent: undefined })
const seen = actedFor(info.version)
// The package is on disk now, whoever fetched it (the user from the About
// tab, or the auto-download above): the next cycle has nothing left to fetch
// for this version.
seen.fetchHandled = true
// …and the release has reached its final state, which is what keeps a later
// `update-available` for it from downgrading the UI back to 'available'.
seen.downloaded = true
// Only the silent background download has to be announced; a download the
// user started from the About tab is already on screen. And only once per
// version — the balloon is not a progress log.
if (wasAuto && !seen.downloadedAnnounced) {
seen.downloadedAnnounced = true
notifyUpdate(
t('main.updater.notifyDownloadedTitle'),
t('main.updater.notifyDownloadedContent', { version: info.version })
)
}
})
autoUpdater.on('error', (err) =>
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
)
}
/**
* Bound one updater call with an overall timeout. electron-updater's own socket
* idle timeout only fires on silence, so a slow trickling response can hold an
* attempt — and the "checking" state the UI shows — open indefinitely.
*/
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout | undefined
const timeout = new Promise<never>((_, reject) => {
const handle = setTimeout(() => reject(new Error(t('main.updater.checkTimeout'))), ms)
handle.unref?.()
timer = handle
})
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer)
})
}
/**
* Check updates: GitHub releases first (system proxy, gated by a
* connectivity probe), domestic Gitea generic package as fallback.
*
* The feed is decided BEFORE touching the updater: a check abandoned on timeout
* cannot be cancelled, and electron-updater de-dupes concurrent checks by
* handing back the in-flight promise — so the fallback attempt below shares the
* abandoned check's fate instead of racing a second request.
*/
async function checkWithFallback(): Promise<void> {
// The feed choice is per attempt: a transient GitHub failure must not pin
// every later check to Gitea (and its forced direct connection) — or vice
// versa — for the whole session.
let githubErr: string | undefined
if (await probeGithub()) {
useFeed('github')
try {
await withTimeout(autoUpdater.checkForUpdates(), budgets.check)
return
} catch (err) {
console.warn('[updater] github feed failed, falling back to gitea:', err)
githubErr = err instanceof Error ? err.message : String(err)
}
} else {
console.warn('[updater] github probe failed or timed out, using gitea feed directly')
}
useFeed('gitea')
try {
// Bounded as well: a still-stuck GitHub check is handed back to us here, and
// it must not leave the state at "checking" forever.
await withTimeout(autoUpdater.checkForUpdates(), budgets.check)
} catch (err) {
if (githubErr === undefined) throw err
const giteaErr = err instanceof Error ? err.message : String(err)
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: githubErr }))
}
}
async function performCheck(source: CheckSource): Promise<UpdateState> {
if (!app.isPackaged) {
setState({ status: 'dev', scheduled: source === 'scheduled' })
return state
}
setState({
status: 'checking',
error: undefined,
percent: undefined,
scheduled: source === 'scheduled'
})
try {
await checkWithFallback()
} catch (err) {
setState({
status: 'error',
error: err instanceof Error ? err.message : String(err),
scheduled: source === 'scheduled'
})
}
return state
}
/**
* The one check entry point: the IPC handler (manual), the startup timer and the
* periodic timer all come through here, so the `scheduled` mark cannot be
* attributed to the wrong caller. A check already running is shared instead of
* started twice — electron-updater would de-dupe the two anyway, but it returns
* its in-flight promise *to whoever asks first*, which is exactly how the source
* mark would end up on the wrong attempt.
*/
export function runCheck(source: CheckSource = 'manual'): Promise<UpdateState> {
// A manual check is an explicit act, so whatever it finds counts as news even
// if this run has already announced that version: the announcement half of the
// record is dropped here — before the in-flight guard, so a click that joins a
// check already running gets the same treatment. The DOWNLOADED half is kept:
// for a release whose package sits on disk, "downloaded" *is* the truthful
// answer, and dropping it made update-available report `available` again —
// folding the About tab's install button back into "download" for a manual
// check on an already-fetched release. (A manual DOWNLOAD needs no equivalent:
// its entry point never consults the record.)
if (source === 'manual') {
actedRelease = actedRelease?.downloaded ? actedRelease : undefined
}
if (checkInFlight) return checkInFlight
const task = performCheck(source)
checkInFlight = task
const release = (): void => {
if (checkInFlight === task) checkInFlight = undefined
}
// Released on rejection too: performCheck reports failures as state rather
// than throwing, but a stuck slot would silently stop every later check.
task.then(release, release)
return task
}
/** IPC entry point (设置 → 关于 "check now"): always a manual check. */
function handleCheck(): Promise<UpdateState> {
return runCheck('manual')
}
/**
* Download the update the last check found. `auto` marks a download started by
* the `autoDownloadUpdate` setting; it is what the balloon and the About tab key
* off. A failure lands on `status: 'error'` and is never retried here — the next
* check (or the user) starts the next attempt, which is exactly why the claim
* below is rolled back: a permanent claim would turn one failure into "auto
* update never happens again for this release".
*/
async function handleDownload(source: DownloadSource = 'manual'): Promise<void> {
// One transfer at a time: a second call would restart the same download.
if (downloadInFlight) return downloadInFlight
if (!app.isPackaged) return
// Claim the version before the transfer starts, so another `update-available`
// for it (the 4-hour cycle) cannot start a second one — and so a manual
// download of that version already on disk is not re-fetched in the
// background later. Rolled back on failure: a download that never finished
// must be retried by the next check, and claiming it permanently would leave
// auto-download silently dead.
const seen = state.version === undefined ? undefined : actedFor(state.version)
if (seen) seen.fetchHandled = true
const task = (async (): Promise<void> => {
setState({ status: 'downloading', percent: 0, auto: source === 'auto' })
try {
await autoUpdater.downloadUpdate()
} catch (err) {
if (seen) seen.fetchHandled = false
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
})()
downloadInFlight = task
const release = (): void => {
if (downloadInFlight === task) downloadInFlight = undefined
}
task.then(release, release)
return task
}
/** Direct-connection fetch for the domestic update channel (ignores system proxy). */
async function directFetch(url: string): Promise<Response> {
const s = session.fromPartition('openterminal-update-direct', { cache: false })
await s.setProxy({ mode: 'direct' })
// Bounded: the changelog is on screen, so a stalled channel must fall through
// to the releases APIs instead of leaving the view spinning.
return s.fetch(url, {
headers: { 'User-Agent': 'OpenTerminal' },
signal: AbortSignal.timeout(budgets.fetch)
})
}
/**
* Connectivity probe for the GitHub feed: dedicated session in the same
* proxy mode as the feed (system), hard timeout so proxy-less users fall
* back to Gitea instead of hanging on a dead proxy/DNS.
*/
async function probeGithub(): Promise<boolean> {
try {
const s = session.fromPartition('openterminal-github-probe', { cache: false })
await s.setProxy({ mode: 'system' })
const resp = await s.fetch(GITHUB_PROBE_URL, {
headers: { 'User-Agent': 'OpenTerminal' },
signal: AbortSignal.timeout(budgets.probe)
})
return resp.ok
} catch {
return false
}
}
/**
* Changelog sources, in order:
* 1. update channel's release-notes.md — the Gitea repo itself is private
* (anonymous API reads 404), but the generic package is publicly readable.
* 2. Gitea / GitHub releases APIs (full history when reachable).
*/
async function fetchChangelog(): Promise<ReleaseNote[]> {
try {
const [notesResp, ymlResp] = await Promise.all([
directFetch(`${GITEA_FEED}release-notes.md`),
directFetch(`${GITEA_FEED}latest.yml`)
])
if (notesResp.ok) {
const body = await notesResp.text()
const yml = ymlResp.ok ? await ymlResp.text() : ''
const version = yml.match(/^version:\s*(\S+)/m)?.[1] ?? ''
const date = yml.match(/^releaseDate:\s*'?(\S+?)'?$/m)?.[1]?.slice(0, 10) ?? ''
if (body.trim()) return [{ version: version ? `v${version}` : 'latest', date, body }]
}
} catch {
// fall through to releases APIs
}
for (const url of [GITEA_RELEASES_API, GITHUB_RELEASES_API]) {
try {
const resp = await net.fetch(url, {
headers: { 'User-Agent': 'OpenTerminal' },
signal: AbortSignal.timeout(budgets.fetch)
})
if (!resp.ok) continue
const data = (await resp.json()) as Array<{
tag_name?: string
name?: string
published_at?: string
body?: string
}>
return data
.map((r) => ({
version: r.tag_name ?? r.name ?? '',
date: (r.published_at ?? '').slice(0, 10),
body: r.body ?? ''
}))
.filter((r) => r.version)
} catch {
// try next source
}
}
return []
}
export function registerUpdateIpc(): void {
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
// Wrapped, not passed directly: an ipcMain handler receives the event as its
// first argument, which would land in `source`.
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, () => handleDownload('manual'))
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
if (!app.isPackaged) return
// The close interceptor (tray flow) would swallow this quit — mark first.
markQuitting()
// (isSilent, isForceRunAfter): relaunch the installed build, otherwise the
// app would just disappear on "restart to update".
autoUpdater.quitAndInstall(false, true)
})
ipcMain.handle(Ipc.UPDATE_CHANGELOG, fetchChangelog)
ipcMain.handle(Ipc.UPDATE_STATE_GET, () => state)
}
export function configureAutoUpdater(): void {
// Update checks only run in packaged builds; no-op during development.
if (!app.isPackaged) {
return
}
useFeed('gitea')
wireEvents()
autoUpdater.logger = console
// Both halves of the update stay under our control: `autoDownload` is driven
// by the `autoDownloadUpdate` setting (one source of truth, read in the
// update-available handler) rather than by electron-updater, and nothing here
// ever installs on its own — autoInstallOnAppQuit only covers quitting.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// Differential download is off because it cannot succeed against either of
// our feeds, and every failed attempt costs the user a second full transfer:
// electron-updater's differential path fetches the old and new blockmaps and
// then multi-range-requests the changed chunks, falling back to a plain whole-
// file download when any of that fails. GitHub serves the release assets from
// Azure Blob, which answers multi-range requests with `501 Unsupported client
// range` (measured), and the Gitea channel keeps only the newest exe + blockmap
// (`pruneChannel` deletes the previous release's), so an older client's
// blockmap 404s. Even where both were available the NSIS payload is LZMA solid
// compression: a small version-to-version change shifts everything after it,
// making the differential range nearly the whole file. The user then watches
// the progress bar run twice — worst case for roughly double the traffic — so
// going straight to the full download is the only behavior that is actually
// correct here.
autoUpdater.disableDifferentialDownload = true
// The startup check and the periodic one are armed by applyUpdateSchedule
// (wired in index.ts through settingsStore's injected applier) instead of
// here: a settings save must re-arm the schedule, not add a second timer.
}
/**
* Arm (or re-arm) the update schedule from the current settings. Called by
* settingsStore's injected applier on startup and after every settings save, so
* it must be idempotent: the previous interval timer is dropped first, and the
* startup check is armed at most once per run — a settings save two seconds into
* the run must not schedule a second "startup" check.
*
* Dev builds schedule nothing (there is no feed), which is also why the startup
* flag is not set on the way out.
*/
export function applyUpdateSchedule(system: SystemSettings): void {
if (intervalTimer) {
clearInterval(intervalTimer)
intervalTimer = undefined
}
if (!app.isPackaged) return
// Startup check only when the user left it enabled (设置 → 关于). Left alone by
// later calls, and unref'd like every other timer here: a pending check must
// never keep the app (or a test process) alive.
if (!startupArmed && system.autoCheckUpdate !== false) {
startupArmed = true
const startup = setTimeout(() => void runCheck('startup'), budgets.startupDelay)
startup.unref?.()
}
// 0 = never: the startup and the manual check are all that is left. Any other
// value is a whitelisted interval (settingsStore sanitizes it); the fallback
// covers a hand-built SystemSettings.
const hours = isUpdateCheckInterval(system.updateCheckIntervalHours)
? system.updateCheckIntervalHours
: FALLBACK_CHECK_INTERVAL_HOURS
if (hours > 0) {
const timer = setInterval(() => void runCheck('scheduled'), hours * budgets.hour)
timer.unref?.()
intervalTimer = timer
}
// Auto-download switched on while an update was already found: start now, the
// next tick could be hours away.
if (system.autoDownloadUpdate === true && state.status === 'available') {
void handleDownload('auto')
}
}