- upload: per-chunk buffer (ssh2 re-reads the overflow tail after the ACK; a reused buffer silently corrupted every file >= ~254KB) - close the cached SFTP channel on eviction, attach an 'error' handler, close the download handle, time out execQuiet, fail partial deletes - per-session StringDecoder for the ssh data plane (CJK mojibake), real exit codes, safe replay truncation, zmodem abort/counter/timer fixes - sysinfo: idempotent poll end, error routing, per-poll watchdog, proc(5) CPU total; expand cd ~/$HOME/%USERPROFILE% paths; log sanitizer fixes - security: will-navigate guard, central IPC sender check, scheme allowlist for openExternal, single-instance else branch, layout id and log-name whitelists, custom theme sanitizing, atomic JSON writes with EPERM retry in store.writeJson - updater: per-attempt feed choice, quitAndInstall relaunch, dev guard, update-state getter
198 lines
7.1 KiB
TypeScript
198 lines
7.1 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 { loadSettings } from './settingsStore'
|
|
import { markQuitting } from './tray'
|
|
|
|
/**
|
|
* Update feeds: domestic Gitea generic package is tried first (fast in
|
|
* China); GitHub releases is the fallback. 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'
|
|
|
|
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.
|
|
void autoUpdater.netSession.setProxy({ mode: 'direct' })
|
|
const token = process.env.OT_UPDATE_TOKEN
|
|
autoUpdater.setFeedURL({
|
|
provider: 'generic',
|
|
url: process.env.OT_UPDATE_URL || 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) })
|
|
)
|
|
}
|
|
|
|
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
|
|
async function checkWithFallback(): Promise<void> {
|
|
// The feed choice is per attempt: a transient Gitea failure must not pin
|
|
// every later check to GitHub (and its system proxy) for the whole session.
|
|
useFeed('gitea')
|
|
try {
|
|
await autoUpdater.checkForUpdates()
|
|
} catch (err) {
|
|
console.warn('[updater] gitea feed failed, falling back to github:', err)
|
|
const giteaErr = err instanceof Error ? err.message : String(err)
|
|
useFeed('github')
|
|
try {
|
|
await autoUpdater.checkForUpdates()
|
|
} catch (err2) {
|
|
const ghErr = err2 instanceof Error ? err2.message : String(err2)
|
|
throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: ghErr }))
|
|
}
|
|
}
|
|
}
|
|
|
|
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' })
|
|
return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
|
|
}
|
|
|
|
/**
|
|
* 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' } })
|
|
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?.()
|
|
}
|