197 lines
9.3 KiB
TypeScript
197 lines
9.3 KiB
TypeScript
/** User-authorized downloads and separate installation of one version-bound Desktop release. */
|
|
|
|
import { existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { app } from 'electron'
|
|
import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater'
|
|
import { gt, valid } from 'semver'
|
|
import type { DesktopUpdateState } from './ipc.ts'
|
|
import { DesktopUpdateHttpExecutor } from './update-http-executor.ts'
|
|
import { DesktopUpdatePreparationError } from './update-error.ts'
|
|
|
|
const { autoUpdater } = electronUpdater
|
|
|
|
/** Owns one updater target until its download and installation settle. */
|
|
export class DesktopUpdateCoordinator {
|
|
private current: DesktopUpdateState = { phase: 'idle' }
|
|
private candidate: string | undefined
|
|
private downloaded = false
|
|
private disposed = false
|
|
private checkOperation: Promise<DesktopUpdateState> | undefined
|
|
private downloadOperation: Promise<DesktopUpdateState> | undefined
|
|
private installOperation: Promise<DesktopUpdateState> | undefined
|
|
|
|
private readonly onProgress = (progress: ProgressInfo): void => {
|
|
if (this.downloadOperation === undefined || this.downloaded) return
|
|
const percent = Math.min(100, Math.max(0, progress.percent))
|
|
this.setState({ phase: percent >= 100 ? 'verifying' : 'downloading', ...this.target(), percent })
|
|
}
|
|
|
|
private readonly onDownloaded = (info: UpdateInfo): void => {
|
|
if (this.downloadOperation === undefined || info.version !== this.candidate) return
|
|
this.downloaded = true
|
|
}
|
|
|
|
private readonly onError = (error: Error): void => {
|
|
// Check/download promises own their failures. Installation can fail after quitAndInstall returns.
|
|
if (this.current.phase === 'installing') {
|
|
this.setState(this.failure(error, 'install'))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param publish - Receives observable states for every Desktop window.
|
|
* @param beforeRestart - Completes task authorization, admission locking, and owned-process shutdown.
|
|
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
|
|
* @param enabled - Whether this process has a packaged update source.
|
|
* @param currentVersion - Actual installed application version.
|
|
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
|
|
*/
|
|
constructor(
|
|
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
|
|
private readonly beforeRestart: () => Promise<boolean>,
|
|
private readonly updater: AppUpdater = autoUpdater,
|
|
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
|
|
private readonly currentVersion: () => string = () => app.getVersion(),
|
|
private readonly downloadResult?: (success: boolean, reason?: string) => void,
|
|
) {
|
|
if (updater === autoUpdater) {
|
|
// electron-updater omits this internal transport property from its public declarations.
|
|
// Real-Electron qualification exercises the pinned dependency integration.
|
|
const transportOwner = updater as AppUpdater & { httpExecutor: DesktopUpdateHttpExecutor }
|
|
transportOwner.httpExecutor = new DesktopUpdateHttpExecutor(
|
|
Number(process.env.DSH_DESKTOP_UPDATE_HTTP_IDLE_TIMEOUT_MS ?? 60_000),
|
|
(authInfo, callback) => { updater.emit('login', authInfo, callback) },
|
|
)
|
|
}
|
|
this.updater.autoDownload = false
|
|
this.updater.autoInstallOnAppQuit = false
|
|
this.updater.channel = 'nightly'
|
|
this.updater.allowPrerelease = true
|
|
// Selecting a channel can enable downgrade in electron-updater.
|
|
this.updater.allowDowngrade = false
|
|
this.updater.on('download-progress', this.onProgress)
|
|
this.updater.on('update-downloaded', this.onDownloaded)
|
|
this.updater.on('error', this.onError)
|
|
}
|
|
|
|
/** Latest observable state; complete download identity remains main-process-owned. */
|
|
get state(): DesktopUpdateState { return this.current }
|
|
|
|
/**
|
|
* Check metadata without downloading, joining any current check.
|
|
* @param manual - Whether a failed check must remain visible in the status indicator.
|
|
* @returns The check result, including a silent automatic failure when applicable.
|
|
*/
|
|
async check(manual = false): Promise<DesktopUpdateState> {
|
|
this.assertLive()
|
|
if (this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current
|
|
if (!manual && this.current.phase === 'error' && this.current.failedOperation === 'download') return this.current
|
|
this.checkOperation ??= Promise.resolve().then(() => this.doCheck())
|
|
.finally(() => { this.checkOperation = undefined })
|
|
const result = await this.checkOperation
|
|
if (manual && result.phase === 'error') this.setState(result)
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* @param version - Version shown in the user's download confirmation.
|
|
* @returns Download readiness or failure, without authorizing installation.
|
|
*/
|
|
async download(version: string): Promise<DesktopUpdateState> {
|
|
this.assertLive()
|
|
if (this.downloaded || this.installOperation !== undefined) return this.current
|
|
this.downloadOperation ??= Promise.resolve().then(async () => {
|
|
await this.checkOperation
|
|
this.assertLive()
|
|
if (this.candidate === undefined) throw new Error('desktop update: no checked update is available')
|
|
if (version !== this.candidate) throw new Error('desktop update: download confirmation is stale')
|
|
this.setState({ phase: 'downloading', version, percent: 0 })
|
|
try {
|
|
await this.updater.downloadUpdate()
|
|
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
|
|
this.downloadResult?.(true)
|
|
return this.setState({ phase: 'ready', version })
|
|
} catch (error) {
|
|
this.downloaded = false
|
|
this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
|
|
return this.setState(this.failure(error, 'download'))
|
|
}
|
|
}).finally(() => { this.downloadOperation = undefined })
|
|
return this.downloadOperation
|
|
}
|
|
|
|
/**
|
|
* Install a prepared target after a separate user confirmation.
|
|
* @param version - Exact version displayed in the confirmation, never a renderer-selected URL.
|
|
* @returns Installation handoff or a recoverable preparation error.
|
|
*/
|
|
async install(version: string): Promise<DesktopUpdateState> {
|
|
this.assertLive()
|
|
if (!this.downloaded || this.downloadOperation !== undefined || version !== this.candidate) throw new Error('desktop update: confirmed target is not ready')
|
|
this.installOperation ??= Promise.resolve().then(async () => {
|
|
this.setState({ phase: 'installing', version })
|
|
try {
|
|
if (!await this.beforeRestart()) return this.setState({ phase: 'ready', version })
|
|
this.assertLive()
|
|
this.updater.quitAndInstall(true, true)
|
|
return this.current
|
|
} catch (error) {
|
|
if (this.current.phase === 'error' && this.current.failedOperation === 'install') return this.current
|
|
return this.setState(this.failure(error, 'install'))
|
|
}
|
|
}).finally(() => { this.installOperation = undefined })
|
|
return this.installOperation
|
|
}
|
|
|
|
/** Remove owned listeners and prevent pending library operations from publishing into closed UI. */
|
|
dispose(): void {
|
|
this.disposed = true
|
|
this.updater.off('download-progress', this.onProgress)
|
|
this.updater.off('update-downloaded', this.onDownloaded)
|
|
// Pending updater promises can still emit EventEmitter errors during shutdown.
|
|
void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation])
|
|
.then(() => { this.updater.off('error', this.onError) })
|
|
}
|
|
|
|
private assertLive(): void {
|
|
if (this.disposed) throw new Error('desktop update: coordinator is disposed')
|
|
}
|
|
|
|
private setState(state: DesktopUpdateState): DesktopUpdateState {
|
|
if (!this.disposed) {
|
|
this.current = state
|
|
this.publish(state)
|
|
}
|
|
return state
|
|
}
|
|
|
|
private failure(error: unknown, failedOperation: 'check' | 'download' | 'install'): DesktopUpdateState {
|
|
return { phase: 'error', ...this.target(), failedOperation,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
...(error instanceof DesktopUpdatePreparationError ? {
|
|
preparationFailure: error.kind,
|
|
...(error.technicalDetails === undefined ? {} : { technicalDetails: error.technicalDetails }),
|
|
} : {}) }
|
|
}
|
|
|
|
private target(): { version?: string } {
|
|
return this.candidate === undefined ? {} : { version: this.candidate }
|
|
}
|
|
|
|
private async doCheck(): Promise<DesktopUpdateState> {
|
|
try {
|
|
this.assertLive()
|
|
if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source')
|
|
const result = await this.updater.checkForUpdates()
|
|
if (result === null) throw new Error('desktop update: no check result was returned')
|
|
const version = result.updateInfo.version
|
|
if (valid(version) === null) throw new Error('desktop update: feed version is invalid')
|
|
this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined
|
|
return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version })
|
|
} catch (error) {
|
|
return this.failure(error, 'check')
|
|
}
|
|
}
|
|
}
|