diff --git a/src/main/index.ts b/src/main/index.ts index 6320653..437e833 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,12 +5,12 @@ import { pathToFileURL } from 'url' import { devRendererUrl } from './devEnv' import { isTrustedRendererUrl, registerIpc } from './ipc' import { killAllPtys, killPtysByOwner } from './pty' -import { applyStartupSystemSettings, loadSettings } from './settingsStore' +import { applyStartupSystemSettings, loadSettings, setUpdateScheduleApplier } from './settingsStore' import { getLockController, initLockController } from './lockController' import { applyMenuLockState } from './lockMenu' import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts' import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray' -import { configureAutoUpdater, registerUpdateIpc } from './updater' +import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater' import { applyWindowChrome } from './windowChrome' import { onLanguageChange, t } from '@shared/i18n' import { getThemeById } from '@shared/theme' @@ -90,6 +90,11 @@ if (!gotSingleInstanceLock) { // Before the window exists: a renderer-triggered check must not run against // the updater's defaults (feed, proxy, autoDownload are set in here). 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 // settings dialog is never opened this run. applyStartupSystemSettings(loadSettings()) diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index f157317..52d1a32 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -7,6 +7,7 @@ import { DEFAULT_SETTINGS, isHighlightCategory, isLockAutoDelay, + isUpdateCheckInterval, lockAutoDelayOf, type AppSettings, type HighlightRule, @@ -363,6 +364,12 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { globalShowHide, closeAction, 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". restoreSession: candidate.restoreSession !== false, shellIntegration: candidate.shellIntegration === true, @@ -395,6 +402,28 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { 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). */ function applySystemSettings(system: SystemSettings): void { // 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. 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. */ diff --git a/src/main/tray.ts b/src/main/tray.ts index fec1517..ee77208 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -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' * event: preventDefault happens before any await on every branch. diff --git a/src/main/updater.ts b/src/main/updater.ts index a5abb26..7802a3a 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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): 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 | undefined +let downloadInFlight: Promise | 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): 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 { } } -async function handleCheck(): Promise { +async function performCheck(source: CheckSource): Promise { 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 { - 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 { + // 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 { + 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 { + // 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 => { + 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 { 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') + } } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a1a83eb..acc86ec 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -262,6 +262,10 @@ export interface UpdateState { error?: string /** which feed served the last check: domestic Gitea first, GitHub fallback */ 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 { diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 7c69ae6..5a5c858 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -161,6 +161,24 @@ export interface LockSettings { 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 { /** register the app to launch at OS login */ launchAtLogin: boolean @@ -172,6 +190,10 @@ export interface SystemSettings { closeAction?: 'ask' | 'tray' | 'exit' /** check for updates shortly after startup (manual check always available) */ 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 * (see the session snapshot). On by default — that is the point of it. @@ -467,7 +489,20 @@ export const DEFAULT_SETTINGS: AppSettings = { customThemes: [], highlightRules: DEFAULT_HIGHLIGHT_RULES, 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 // itself out of the box would be a support ticket, not a feature. lock: { enabled: false, autoLockMinutes: 0, lockAtStartup: false }