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:
1 parent
d3529bb9dc
commit
9cc856fc8f
6 files changed
+371
-27
No files matched your search
+7
-2
@@ -5,12 +5,12 @@ import { pathToFileURL } from 'url'
|
|||||||
import { devRendererUrl } from './devEnv'
|
import { devRendererUrl } from './devEnv'
|
||||||
import { isTrustedRendererUrl, registerIpc } from './ipc'
|
import { isTrustedRendererUrl, registerIpc } from './ipc'
|
||||||
import { killAllPtys, killPtysByOwner } from './pty'
|
import { killAllPtys, killPtysByOwner } from './pty'
|
||||||
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
|
import { applyStartupSystemSettings, loadSettings, setUpdateScheduleApplier } from './settingsStore'
|
||||||
import { getLockController, initLockController } from './lockController'
|
import { getLockController, initLockController } from './lockController'
|
||||||
import { applyMenuLockState } from './lockMenu'
|
import { applyMenuLockState } from './lockMenu'
|
||||||
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
|
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
|
||||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||||
import { configureAutoUpdater, registerUpdateIpc } from './updater'
|
import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||||
import { applyWindowChrome } from './windowChrome'
|
import { applyWindowChrome } from './windowChrome'
|
||||||
import { onLanguageChange, t } from '@shared/i18n'
|
import { onLanguageChange, t } from '@shared/i18n'
|
||||||
import { getThemeById } from '@shared/theme'
|
import { getThemeById } from '@shared/theme'
|
||||||
@@ -90,6 +90,11 @@ if (!gotSingleInstanceLock) {
|
|||||||
// Before the window exists: a renderer-triggered check must not run against
|
// Before the window exists: a renderer-triggered check must not run against
|
||||||
// the updater's defaults (feed, proxy, autoDownload are set in here).
|
// the updater's defaults (feed, proxy, autoDownload are set in here).
|
||||||
configureAutoUpdater()
|
configureAutoUpdater()
|
||||||
|
// Arms the startup + periodic update checks and re-arms them on every
|
||||||
|
// settings save. Injected rather than imported by settingsStore because
|
||||||
|
// updater.ts already imports settingsStore: the reverse import would be a
|
||||||
|
// cycle, arming the timer against a half-initialised updater.
|
||||||
|
setUpdateScheduleApplier(applyUpdateSchedule)
|
||||||
// OS-level effects (login item, sleep blocker) must apply even if the
|
// OS-level effects (login item, sleep blocker) must apply even if the
|
||||||
// settings dialog is never opened this run.
|
// settings dialog is never opened this run.
|
||||||
applyStartupSystemSettings(loadSettings())
|
applyStartupSystemSettings(loadSettings())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
isHighlightCategory,
|
isHighlightCategory,
|
||||||
isLockAutoDelay,
|
isLockAutoDelay,
|
||||||
|
isUpdateCheckInterval,
|
||||||
lockAutoDelayOf,
|
lockAutoDelayOf,
|
||||||
type AppSettings,
|
type AppSettings,
|
||||||
type HighlightRule,
|
type HighlightRule,
|
||||||
@@ -363,6 +364,12 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
|||||||
globalShowHide,
|
globalShowHide,
|
||||||
closeAction,
|
closeAction,
|
||||||
autoCheckUpdate: candidate.autoCheckUpdate !== false,
|
autoCheckUpdate: candidate.autoCheckUpdate !== false,
|
||||||
|
// The interval is a whitelist (UPDATE_CHECK_INTERVALS): an arbitrary
|
||||||
|
// number here would silently rewrite how often we hit the update feed.
|
||||||
|
updateCheckIntervalHours: isUpdateCheckInterval(candidate.updateCheckIntervalHours)
|
||||||
|
? candidate.updateCheckIntervalHours
|
||||||
|
: DEFAULT_SYSTEM.updateCheckIntervalHours,
|
||||||
|
autoDownloadUpdate: candidate.autoDownloadUpdate === true,
|
||||||
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
|
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
|
||||||
restoreSession: candidate.restoreSession !== false,
|
restoreSession: candidate.restoreSession !== false,
|
||||||
shellIntegration: candidate.shellIntegration === true,
|
shellIntegration: candidate.shellIntegration === true,
|
||||||
@@ -395,6 +402,28 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
|||||||
|
|
||||||
let sleepBlockerId: number | undefined
|
let sleepBlockerId: number | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The updater's scheduler, injected rather than imported: `updater.ts` already
|
||||||
|
* imports this module, so importing it back would be a cycle — the timer would be
|
||||||
|
* armed against a half-initialised updater (and, depending on load order, on an
|
||||||
|
* `undefined` binding). `index.ts` wires this once with the updater's scheduler.
|
||||||
|
*/
|
||||||
|
let updateScheduleApplier: ((system: SystemSettings) => void) | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the updater's scheduler. The current settings are handed over immediately
|
||||||
|
* as well, so the schedule is armed no matter where in startup the caller wires
|
||||||
|
* this: `index.ts` wires the updater around `applyStartupSystemSettings`, and the
|
||||||
|
* order of the two is not part of the contract. `loadSettings` is a pure read — it
|
||||||
|
* never calls `applySystemSettings` — so applying here cannot recurse, and the
|
||||||
|
* extra call is idempotent: the applier is a plain "set the timer for this
|
||||||
|
* interval" step that already re-runs on every settings save.
|
||||||
|
*/
|
||||||
|
export function setUpdateScheduleApplier(fn: (system: SystemSettings) => void): void {
|
||||||
|
updateScheduleApplier = fn
|
||||||
|
fn(loadSettings().system)
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply OS-level effects of the system settings (login item, sleep blocker). */
|
/** Apply OS-level effects of the system settings (login item, sleep blocker). */
|
||||||
function applySystemSettings(system: SystemSettings): void {
|
function applySystemSettings(system: SystemSettings): void {
|
||||||
// The main process renders its own strings (tray menu, dialogs), so it tracks
|
// The main process renders its own strings (tray menu, dialogs), so it tracks
|
||||||
@@ -413,6 +442,9 @@ function applySystemSettings(system: SystemSettings): void {
|
|||||||
}
|
}
|
||||||
// Re-register the global show/hide shortcut whenever system settings change.
|
// Re-register the global show/hide shortcut whenever system settings change.
|
||||||
applyGlobalShortcut(system.globalShowHide)
|
applyGlobalShortcut(system.globalShowHide)
|
||||||
|
// Re-arm (or disarm) the periodic update check the same way. Routed through the
|
||||||
|
// injected applier instead of importing the updater, which would be a cycle.
|
||||||
|
updateScheduleApplier?.(system)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply system side effects for the settings loaded at startup. */
|
/** Apply system side effects for the settings loaded at startup. */
|
||||||
|
|||||||
@@ -103,6 +103,21 @@ function hideToTray(win: BrowserWindow): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balloon for background events (update available / downloaded). Windows only;
|
||||||
|
* silent elsewhere, and only when no window is visible — a user looking at the
|
||||||
|
* app already sees the state in 设置 → 关于.
|
||||||
|
*
|
||||||
|
* Deliberately not gated on `balloonShown`: that flag is the one-shot
|
||||||
|
* "minimized to tray" hint, while an update event may legitimately notify once
|
||||||
|
* per version.
|
||||||
|
*/
|
||||||
|
export function notifyUpdate(title: string, content: string): void {
|
||||||
|
if (process.platform !== 'win32' || !tray) return
|
||||||
|
if (BrowserWindow.getAllWindows()[0]?.isVisible()) return
|
||||||
|
tray.displayBalloon({ iconType: 'info', title, content })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Window close interception. Must be called synchronously from the 'close'
|
* Window close interception. Must be called synchronously from the 'close'
|
||||||
* event: preventDefault happens before any await on every branch.
|
* event: preventDefault happens before any await on every branch.
|
||||||
|
|||||||
+277
-24
@@ -1,11 +1,13 @@
|
|||||||
import { app, ipcMain, net, session } from 'electron'
|
import { app, ipcMain, net, session } from 'electron'
|
||||||
import { autoUpdater } from 'electron-updater'
|
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 { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
|
||||||
import { t } from '../shared/i18n'
|
import { t } from '../shared/i18n'
|
||||||
import { broadcast } from './broadcast'
|
import { broadcast } from './broadcast'
|
||||||
import { devUpdateFeedUrl } from './devEnv'
|
import { devUpdateFeedUrl } from './devEnv'
|
||||||
import { loadSettings } from './settingsStore'
|
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
|
* 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.
|
* silence, so a slow trickling response can hold an attempt open forever.
|
||||||
* - `fetch`: changelog / releases-API fetches; a stalled response must not
|
* - `fetch`: changelog / releases-API fetches; a stalled response must not
|
||||||
* hang the About tab.
|
* 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 = {
|
const budgets = {
|
||||||
probe: 20_000,
|
probe: 20_000,
|
||||||
check: 30_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 {
|
export function setUpdateTimeouts(patch: Partial<typeof budgets>): void {
|
||||||
Object.assign(budgets, patch)
|
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 state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
|
||||||
let activeFeed: 'gitea' | 'github' = 'gitea'
|
let activeFeed: 'gitea' | 'github' = 'gitea'
|
||||||
let eventsWired = false
|
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 {
|
function setState(patch: Partial<UpdateState>): void {
|
||||||
state = { ...state, ...patch, currentVersion: app.getVersion() }
|
state = { ...state, ...patch, currentVersion: app.getVersion() }
|
||||||
@@ -83,16 +159,77 @@ function wireEvents(): void {
|
|||||||
if (eventsWired) return
|
if (eventsWired) return
|
||||||
eventsWired = true
|
eventsWired = true
|
||||||
autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined }))
|
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 })
|
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('update-not-available', () => setState({ status: 'latest', version: undefined }))
|
||||||
autoUpdater.on('download-progress', (p) =>
|
autoUpdater.on('download-progress', (p) =>
|
||||||
setState({ status: 'downloading', percent: Math.round(p.percent) })
|
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 })
|
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) =>
|
autoUpdater.on('error', (err) =>
|
||||||
setState({ status: 'error', error: err instanceof Error ? err.message : String(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) {
|
if (!app.isPackaged) {
|
||||||
setState({ status: 'dev' })
|
setState({ status: 'dev', scheduled: source === 'scheduled' })
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
setState({ status: 'checking', error: undefined, percent: undefined })
|
setState({
|
||||||
|
status: 'checking',
|
||||||
|
error: undefined,
|
||||||
|
percent: undefined,
|
||||||
|
scheduled: source === 'scheduled'
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
await checkWithFallback()
|
await checkWithFallback()
|
||||||
} catch (err) {
|
} 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
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(): Promise<void> {
|
/**
|
||||||
if (!app.isPackaged) return
|
* The one check entry point: the IPC handler (manual), the startup timer and the
|
||||||
setState({ status: 'downloading', percent: 0 })
|
* periodic timer all come through here, so the `scheduled` mark cannot be
|
||||||
try {
|
* attributed to the wrong caller. A check already running is shared instead of
|
||||||
await autoUpdater.downloadUpdate()
|
* started twice — electron-updater would de-dupe the two anyway, but it returns
|
||||||
} catch (err) {
|
* its in-flight promise *to whoever asks first*, which is exactly how the source
|
||||||
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
|
* 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). */
|
/** Direct-connection fetch for the domestic update channel (ignores system proxy). */
|
||||||
@@ -259,7 +464,9 @@ async function fetchChangelog(): Promise<ReleaseNote[]> {
|
|||||||
|
|
||||||
export function registerUpdateIpc(): void {
|
export function registerUpdateIpc(): void {
|
||||||
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
|
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, () => {
|
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
|
||||||
if (!app.isPackaged) return
|
if (!app.isPackaged) return
|
||||||
// The close interceptor (tray flow) would swallow this quit — mark first.
|
// The close interceptor (tray flow) would swallow this quit — mark first.
|
||||||
@@ -280,13 +487,59 @@ export function configureAutoUpdater(): void {
|
|||||||
useFeed('gitea')
|
useFeed('gitea')
|
||||||
wireEvents()
|
wireEvents()
|
||||||
autoUpdater.logger = console
|
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.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
// Startup check only when the user left it enabled (设置 → 关于).
|
// The startup check and the periodic one are armed by applyUpdateSchedule
|
||||||
if (loadSettings().system.autoCheckUpdate === false) return
|
// (wired in index.ts through settingsStore's injected applier) instead of
|
||||||
const timer = setTimeout(() => {
|
// here: a settings save must re-arm the schedule, not add a second timer.
|
||||||
void handleCheck()
|
}
|
||||||
}, 5000)
|
|
||||||
timer.unref?.()
|
/**
|
||||||
|
* 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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +262,10 @@ export interface UpdateState {
|
|||||||
error?: string
|
error?: string
|
||||||
/** which feed served the last check: domestic Gitea first, GitHub fallback */
|
/** which feed served the last check: domestic Gitea first, GitHub fallback */
|
||||||
feed?: 'gitea' | 'github'
|
feed?: 'gitea' | 'github'
|
||||||
|
/** the check that produced this state was started by the periodic timer */
|
||||||
|
scheduled?: boolean
|
||||||
|
/** the in-flight/completed download was started by the auto-download setting */
|
||||||
|
auto?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReleaseNote {
|
export interface ReleaseNote {
|
||||||
|
|||||||
+36
-1
@@ -161,6 +161,24 @@ export interface LockSettings {
|
|||||||
lockAtStartup: boolean
|
lockAtStartup: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intervals offered for the periodic update check, in hours. A closed set
|
||||||
|
* rather than a free number, for the same reason as LOCK_AUTO_DELAYS: `0` means
|
||||||
|
* "never" — the startup and the manual check still work — and the updater's
|
||||||
|
* timer, the sanitizer and the settings UI all read this one list, so they cannot
|
||||||
|
* disagree. Without the whitelist a hand-edited settings.json could put the app
|
||||||
|
* on an arbitrary schedule (a check every minute, or a fractional interval the
|
||||||
|
* timer would round unpredictably).
|
||||||
|
*/
|
||||||
|
export type UpdateCheckInterval = 0 | 4 | 12 | 24
|
||||||
|
|
||||||
|
export const UPDATE_CHECK_INTERVALS: UpdateCheckInterval[] = [0, 4, 12, 24]
|
||||||
|
|
||||||
|
/** Only a whitelisted interval survives a stored value; callers fall back themselves. */
|
||||||
|
export function isUpdateCheckInterval(value: unknown): value is UpdateCheckInterval {
|
||||||
|
return UPDATE_CHECK_INTERVALS.includes(value as UpdateCheckInterval)
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
/** register the app to launch at OS login */
|
/** register the app to launch at OS login */
|
||||||
launchAtLogin: boolean
|
launchAtLogin: boolean
|
||||||
@@ -172,6 +190,10 @@ export interface SystemSettings {
|
|||||||
closeAction?: 'ask' | 'tray' | 'exit'
|
closeAction?: 'ask' | 'tray' | 'exit'
|
||||||
/** check for updates shortly after startup (manual check always available) */
|
/** check for updates shortly after startup (manual check always available) */
|
||||||
autoCheckUpdate?: boolean
|
autoCheckUpdate?: boolean
|
||||||
|
/** periodic background update check; 0 = never (startup + manual always work) */
|
||||||
|
updateCheckIntervalHours?: UpdateCheckInterval
|
||||||
|
/** silently download a found update in the background (installing stays manual) */
|
||||||
|
autoDownloadUpdate?: boolean
|
||||||
/**
|
/**
|
||||||
* Restore the previous layout and each pane's working directory on launch
|
* Restore the previous layout and each pane's working directory on launch
|
||||||
* (see the session snapshot). On by default — that is the point of it.
|
* (see the session snapshot). On by default — that is the point of it.
|
||||||
@@ -467,7 +489,20 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
customThemes: [],
|
customThemes: [],
|
||||||
highlightRules: DEFAULT_HIGHLIGHT_RULES,
|
highlightRules: DEFAULT_HIGHLIGHT_RULES,
|
||||||
highlightProfiles: [],
|
highlightProfiles: [],
|
||||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' },
|
// Every 4 hours by default: often enough for a terminal app that runs all day,
|
||||||
|
// and the download stays manual (autoDownloadUpdate) until the user opts in.
|
||||||
|
system: {
|
||||||
|
launchAtLogin: false,
|
||||||
|
preventSleep: false,
|
||||||
|
globalShowHide: '',
|
||||||
|
closeAction: 'tray',
|
||||||
|
autoCheckUpdate: true,
|
||||||
|
updateCheckIntervalHours: 4,
|
||||||
|
autoDownloadUpdate: false,
|
||||||
|
restoreSession: true,
|
||||||
|
shellIntegration: false,
|
||||||
|
language: 'zh-CN'
|
||||||
|
},
|
||||||
// Off until the user sets a password and turns it on: an app that locks
|
// Off until the user sets a password and turns it on: an app that locks
|
||||||
// itself out of the box would be a support ticket, not a feature.
|
// itself out of the box would be a support ticket, not a feature.
|
||||||
lock: { enabled: false, autoLockMinutes: 0, lockAtStartup: false }
|
lock: { enabled: false, autoLockMinutes: 0, lockAtStartup: false }
|
||||||
|
|||||||
Reference in new issue
Block a user