feat(updater): 定时检查更新与后台自动下载

设置 → 系统新增两个开关:定时检查更新(从不/4/12/24 小时,默认 4)与
自动下载更新(默认关)。安装时机仍交给用户——绝不因下载完成自动退出,
autoInstallOnAppQuit 只覆盖用户自己退出时装上。

- runCheck(source) 统一三条来源(启动/周期/手动),UpdateState.scheduled
  标记来源;已有在途检查返回同一 promise,避免 electron-updater 的去重把
  来源标记记到错的调用方
- 周期调度器经 settingsStore.setUpdateScheduleApplier 注入(updater 已
  import settingsStore,反向 import 成环);幂等重排,启动检查由
  startupArmed 保证每进程只武装一次;所有 timer 带 unref
- 自动下载由 settings.autoDownloadUpdate 驱动,autoUpdater.autoDownload
  保持 false(单一事实源在 settings);失败不重试,等下一周期
- 同版本抑制(actedRelease 单槽,actedFor 版本不同即替换):否则长开机
  的应用每 4 小时重复弹气泡、重复走一遍 downloadUpdate;手动检查无条件
  清空记录
- downloaded 与 fetchHandled 分开:前者仅在 update-downloaded 置位,用于
  阻止后续 update-available 把状态从 downloaded 翻回 available(否则「关于」
  页按钮会从「重启安装」退回「下载」)
- tray.notifyUpdate 弹 Windows 托盘气泡;非 win32 / 无 tray / 有可见窗口
  都不弹
This commit is contained in:
Bill committed 2026-10-10 13:36:53 +08:00
1 parent d3529bb9dc
commit 9cc856fc8f
6 files changed
+371 -27

No files matched your search

+277 -24
View File
@@ -1,11 +1,13 @@
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 } from './tray'
import { markQuitting, notifyUpdate } from './tray'
/**
* Update feeds: GitHub releases is tried first (system proxy, gated by a
@@ -37,20 +39,94 @@ const GITHUB_PROBE_URL =
* 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
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() }
@@ -83,16 +159,77 @@ function wireEvents(): void {
if (eventsWired) return
eventsWired = true
autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined }))
autoUpdater.on('update-available', (info) =>
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) =>
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) })
)
@@ -153,28 +290,96 @@ async function checkWithFallback(): Promise<void> {
}
}
async function handleCheck(): Promise<UpdateState> {
async function performCheck(source: CheckSource): Promise<UpdateState> {
if (!app.isPackaged) {
setState({ status: 'dev' })
setState({ status: 'dev', scheduled: source === 'scheduled' })
return state
}
setState({ status: 'checking', error: undefined, percent: undefined })
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) })
setState({
status: 'error',
error: err instanceof Error ? err.message : String(err),
scheduled: source === 'scheduled'
})
}
return state
}
async function handleDownload(): Promise<void> {
if (!app.isPackaged) return
setState({ status: 'downloading', percent: 0 })
try {
await autoUpdater.downloadUpdate()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
/**
* 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 dealt with that version: the suppression record is
// dropped here — before the in-flight guard, so a click that joins a check
// already running gets the same treatment. (A manual DOWNLOAD needs no
// equivalent: its entry point never consults the record.)
if (source === 'manual') 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). */
@@ -259,7 +464,9 @@ async function fetchChangelog(): Promise<ReleaseNote[]> {
export function registerUpdateIpc(): void {
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
// 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.
@@ -280,13 +487,59 @@ export function configureAutoUpdater(): void {
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
// Startup check only when the user left it enabled (设置 → 关于).
if (loadSettings().system.autoCheckUpdate === false) return
const timer = setTimeout(() => {
void handleCheck()
}, 5000)
timer.unref?.()
// 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')
}
}