Files
OpenTerminal/src/main/updater.ts
T
Bill a0be827650 test(main): cover updater fallback, ipc sender guard, log sanitizer, sftp timeouts
- updater-fallback.mjs (82 assertions): GitHub probe fallback to Gitea,
  timeout budgets, in-flight check never stuck in 'checking'; updater.ts
  gains a setUpdateTimeouts test seam, electron-updater aliased to a stub
- ipc-guard.mjs (43): trusted-frame guard exercised through real
  registerIpc handlers with forged senderFrames; electron-stub now records
  registrations via globalThis so bundle and test share one instance
- log-sanitizer.mjs (71): CSI/OSC/charset state machine, alt-screen fold,
  byte-split fuzz equal to whole-chunk output; fixes a wrong comment
- sftp-timeout.mjs (39): per-op timeouts (metadata 30s, transfer chunk 60s,
  open 10s) evict half-dead channels with one retry, slow-but-progressing
  transfers untouched, late rejections never unhandled
- reservedAccelerators.ts: single pure isReservedAccelerator shared by the
  settings recorder and applyGlobalShortcut (the two tables had drifted —
  main now also refuses Ctrl+=/-/0/PgUp/PgDn legacy values); 56 assertions
- ssh-loopback.mjs loads the real ssh.ts via a bundle (50 assertions):
  TOFU pinning, fail-closed stores, auth gate, connect budget

Offline suite grows 13 -> 17. Renderer test framework evaluated: not
introducing vitest/jsdom; pure logic keeps being extracted and tested
through the existing bundle harness.
2026-10-07 22:57:16 +08:00

293 lines
11 KiB
TypeScript

import { app, ipcMain, net, session } from 'electron'
import { autoUpdater } from 'electron-updater'
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'
/**
* 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.
*/
const budgets = {
probe: 20_000,
check: 30_000,
fetch: 15_000
}
export function setUpdateTimeouts(patch: Partial<typeof budgets>): void {
Object.assign(budgets, patch)
}
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
let activeFeed: 'gitea' | 'github' = 'gitea'
let eventsWired = false
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) =>
setState({ status: 'available', version: info.version, feed: activeFeed, percent: undefined })
)
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) =>
setState({ status: 'downloaded', version: info.version, percent: undefined })
)
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 handleCheck(): Promise<UpdateState> {
if (!app.isPackaged) {
setState({ status: 'dev' })
return state
}
setState({ status: 'checking', error: undefined, percent: undefined })
try {
await checkWithFallback()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
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) })
}
}
/** 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)
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
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
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?.()
}