mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
497 lines
15 KiB
TypeScript
497 lines
15 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {createRequire} from 'node:module';
|
|
import {createChildLogger} from '@electron/common/Logger';
|
|
import {
|
|
type AppImageTarget,
|
|
applyStagedAppImageUpdate,
|
|
type StagedAppImageUpdate,
|
|
stageAppImageUpdate,
|
|
} from '@electron/main/AppImageUpdate';
|
|
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
|
import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch';
|
|
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
|
|
import {getUpdateBaseUrl} from '@electron/main/ShellDownloadFormats';
|
|
import type {ShellUpdateCapability, ShellUpdatePlan} from '@electron/main/ShellUpdateCapability';
|
|
import {
|
|
clearVelopackApplyAttempt,
|
|
readVelopackApplyAttempt,
|
|
recordVelopackApplyAttempt,
|
|
} from '@electron/main/UpdaterApplyState';
|
|
import {app, autoUpdater, net} from 'electron';
|
|
import type {UpdateInfo, VelopackAsset} from 'velopack';
|
|
|
|
const requireModule = createRequire(import.meta.url);
|
|
const logger = createChildLogger('ShellSelfUpdate');
|
|
|
|
export const SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS = 30_000;
|
|
export const SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS = 600_000;
|
|
|
|
export function getElectronUpdateFeedUrl(): string {
|
|
return `${getUpdateBaseUrl()}/RELEASES.json`;
|
|
}
|
|
|
|
type ShellSelfUpdateReason = 'no-update' | 'check-failed' | 'download-failed' | 'install-failed' | 'timed-out';
|
|
|
|
interface ShellSelfUpdateFailure {
|
|
readonly reason: ShellSelfUpdateReason;
|
|
readonly detail: string | null;
|
|
}
|
|
|
|
interface ShellSelfUpdateHooks {
|
|
readonly onDownloading: (progress: number | null) => void;
|
|
readonly onRestarting: () => void;
|
|
}
|
|
|
|
interface SelfUpdateControl {
|
|
readonly settle: (failure: ShellSelfUpdateFailure) => void;
|
|
readonly isSettled: () => boolean;
|
|
readonly clearCheckDeadline: () => void;
|
|
readonly addCleanup: (cleanup: () => void) => void;
|
|
}
|
|
|
|
function errorDetail(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function compareShellVersions(left: string, right: string): number {
|
|
return compareModuleVersions(parseModuleVersion(left, 'shell version'), parseModuleVersion(right, 'shell version'));
|
|
}
|
|
|
|
function feedUserAgent(): string {
|
|
return `Fluxer/${app.getVersion()} (${process.platform}: ${process.arch})`;
|
|
}
|
|
|
|
type VelopackUpdateManager = InstanceType<typeof import('velopack').UpdateManager>;
|
|
|
|
function readInstalledVelopackVersion(manager: VelopackUpdateManager): string {
|
|
try {
|
|
const version = manager.getCurrentVersion();
|
|
return typeof version === 'string' && version.length > 0 ? version : app.getVersion();
|
|
} catch {
|
|
return app.getVersion();
|
|
}
|
|
}
|
|
|
|
function hasUnlandedVelopackApply(manager: VelopackUpdateManager): boolean {
|
|
const attempt = readVelopackApplyAttempt();
|
|
if (attempt == null) return false;
|
|
if (compareShellVersions(readInstalledVelopackVersion(manager), attempt.version) >= 0) {
|
|
clearVelopackApplyAttempt();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function getStagedVelopackVersion(staged: UpdateInfo | VelopackAsset): string | null {
|
|
const asset = 'TargetFullRelease' in staged ? staged.TargetFullRelease : staged;
|
|
return asset.Version ?? null;
|
|
}
|
|
|
|
let velopackDownload: {
|
|
readonly version: string;
|
|
readonly settled: Promise<void>;
|
|
readonly observers: Set<(percent: number) => void>;
|
|
} | null = null;
|
|
|
|
function downloadVelopackUpdateOnce(
|
|
manager: VelopackUpdateManager,
|
|
available: UpdateInfo,
|
|
onProgress: (percent: number) => void,
|
|
): Promise<void> {
|
|
const version = getStagedVelopackVersion(available);
|
|
if (version == null) {
|
|
return manager.downloadUpdateAsync(available, onProgress);
|
|
}
|
|
if (velopackDownload != null && velopackDownload.version === version) {
|
|
velopackDownload.observers.add(onProgress);
|
|
return velopackDownload.settled;
|
|
}
|
|
const observers = new Set([onProgress]);
|
|
const settled = manager
|
|
.downloadUpdateAsync(available, (percent) => {
|
|
for (const observer of Array.from(observers)) observer(percent);
|
|
})
|
|
.finally(() => {
|
|
if (velopackDownload?.settled === settled) velopackDownload = null;
|
|
});
|
|
velopackDownload = {version, settled, observers};
|
|
return settled;
|
|
}
|
|
|
|
async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): Promise<void> {
|
|
const {UpdateManager} = requireModule('velopack') as typeof import('velopack');
|
|
const manager = new UpdateManager(getUpdateBaseUrl());
|
|
if (hasUnlandedVelopackApply(manager)) {
|
|
control.settle({reason: 'install-failed', detail: 'the last downloaded update was never installed'});
|
|
return;
|
|
}
|
|
let staged: UpdateInfo | VelopackAsset | null;
|
|
try {
|
|
staged = manager.getUpdatePendingRestart();
|
|
} catch (error) {
|
|
control.settle({reason: 'check-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
if (control.isSettled()) return;
|
|
if (staged == null) {
|
|
let available: UpdateInfo | null;
|
|
try {
|
|
available = await manager.checkForUpdatesAsync();
|
|
} catch (error) {
|
|
control.settle({reason: 'check-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
if (control.isSettled()) return;
|
|
if (available == null) {
|
|
control.settle({reason: 'no-update', detail: null});
|
|
return;
|
|
}
|
|
control.clearCheckDeadline();
|
|
hooks.onDownloading(0);
|
|
try {
|
|
await downloadVelopackUpdateOnce(manager, available, (percent) => {
|
|
if (control.isSettled()) return;
|
|
hooks.onDownloading(percent);
|
|
});
|
|
} catch (error) {
|
|
control.settle({reason: 'download-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
if (control.isSettled()) return;
|
|
staged = available;
|
|
}
|
|
control.clearCheckDeadline();
|
|
hooks.onRestarting();
|
|
const stagedVersion = getStagedVelopackVersion(staged);
|
|
if (stagedVersion != null) {
|
|
recordVelopackApplyAttempt(stagedVersion);
|
|
}
|
|
try {
|
|
manager.waitExitThenApplyUpdate(staged, true, true);
|
|
} catch (error) {
|
|
control.settle({reason: 'install-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
app.quit();
|
|
}
|
|
|
|
async function fetchPublishedAppImage(): Promise<{version: string; sha256: string | null}> {
|
|
const response = await net.fetch(`${getUpdateBaseUrl()}/latest`, {
|
|
cache: 'no-store',
|
|
headers: {Accept: 'application/json', 'Cache-Control': 'no-cache'},
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Latest version request failed: ${response.status}`);
|
|
}
|
|
const payload = (await response.json()) as {version?: unknown; files?: {appimage?: {sha256?: unknown}}};
|
|
if (typeof payload.version !== 'string' || payload.version.length === 0) {
|
|
throw new Error('Latest version response missing version string');
|
|
}
|
|
const sha256 = payload.files?.appimage?.sha256;
|
|
return {version: payload.version, sha256: typeof sha256 === 'string' ? sha256 : null};
|
|
}
|
|
|
|
let appImageStaging: {
|
|
readonly version: string;
|
|
readonly staged: Promise<StagedAppImageUpdate>;
|
|
readonly observers: Set<(percent: number) => void>;
|
|
} | null = null;
|
|
|
|
async function stageAppImageUpdateOnce(
|
|
target: AppImageTarget,
|
|
version: string,
|
|
expectedSha256: string,
|
|
onProgress: (percent: number) => void,
|
|
): Promise<StagedAppImageUpdate> {
|
|
if (appImageStaging == null || appImageStaging.version !== version) {
|
|
const observers = new Set<(percent: number) => void>();
|
|
const staged = stageAppImageUpdate({
|
|
target,
|
|
url: `${getUpdateBaseUrl()}/${version}/appimage`,
|
|
expectedSha256,
|
|
fetchImpl: moduleNetworkFetch,
|
|
onProgress: ({transferred, total}) => {
|
|
if (total <= 0) return;
|
|
const percent = Math.min(100, (transferred / total) * 100);
|
|
for (const observer of Array.from(observers)) observer(percent);
|
|
},
|
|
});
|
|
const staging = {version, staged, observers};
|
|
appImageStaging = staging;
|
|
staged.catch(() => {
|
|
if (appImageStaging === staging) appImageStaging = null;
|
|
});
|
|
}
|
|
const staging = appImageStaging;
|
|
staging.observers.add(onProgress);
|
|
try {
|
|
return await staging.staged;
|
|
} finally {
|
|
staging.observers.delete(onProgress);
|
|
}
|
|
}
|
|
|
|
async function runAppImageSelfUpdate(
|
|
control: SelfUpdateControl,
|
|
hooks: ShellSelfUpdateHooks,
|
|
target: AppImageTarget,
|
|
): Promise<void> {
|
|
let published: {version: string; sha256: string | null};
|
|
try {
|
|
published = await fetchPublishedAppImage();
|
|
} catch (error) {
|
|
control.settle({reason: 'check-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
if (control.isSettled()) return;
|
|
let newer: boolean;
|
|
try {
|
|
newer = compareShellVersions(published.version, app.getVersion()) > 0;
|
|
} catch (error) {
|
|
control.settle({reason: 'check-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
if (!newer) {
|
|
control.settle({reason: 'no-update', detail: null});
|
|
return;
|
|
}
|
|
if (published.sha256 == null) {
|
|
control.settle({reason: 'check-failed', detail: 'the published AppImage has no checksum'});
|
|
return;
|
|
}
|
|
control.clearCheckDeadline();
|
|
hooks.onDownloading(0);
|
|
let staged: StagedAppImageUpdate;
|
|
try {
|
|
staged = await stageAppImageUpdateOnce(target, published.version, published.sha256, (percent) => {
|
|
if (control.isSettled()) return;
|
|
hooks.onDownloading(percent);
|
|
});
|
|
} catch (error) {
|
|
control.settle({reason: 'download-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
if (control.isSettled()) {
|
|
return;
|
|
}
|
|
hooks.onRestarting();
|
|
appImageStaging = null;
|
|
try {
|
|
applyStagedAppImageUpdate(target, staged);
|
|
} catch (error) {
|
|
control.settle({reason: 'install-failed', detail: errorDetail(error)});
|
|
return;
|
|
}
|
|
relaunchStableLaunchPath();
|
|
app.quit();
|
|
}
|
|
|
|
const ElectronUpdatePhase = Object.freeze({
|
|
IDLE: 'idle',
|
|
CHECKING: 'checking',
|
|
DOWNLOADING: 'downloading',
|
|
DOWNLOADED: 'downloaded',
|
|
} as const);
|
|
|
|
type ElectronUpdatePhase = (typeof ElectronUpdatePhase)[keyof typeof ElectronUpdatePhase];
|
|
|
|
type ElectronUpdateEvent =
|
|
| {readonly type: 'available'}
|
|
| {readonly type: 'not-available'}
|
|
| {readonly type: 'downloaded'}
|
|
| {readonly type: 'error'; readonly error: Error; readonly phase: ElectronUpdatePhase};
|
|
|
|
const electronUpdateSession: {
|
|
phase: ElectronUpdatePhase;
|
|
armed: boolean;
|
|
readonly observers: Set<(event: ElectronUpdateEvent) => void>;
|
|
} = {
|
|
phase: ElectronUpdatePhase.IDLE,
|
|
armed: false,
|
|
observers: new Set(),
|
|
};
|
|
|
|
function publishElectronUpdateEvent(event: ElectronUpdateEvent): void {
|
|
for (const observer of Array.from(electronUpdateSession.observers)) {
|
|
try {
|
|
observer(event);
|
|
} catch (error) {
|
|
logger.error('A shell self update observer threw', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function armElectronUpdateSession(): void {
|
|
if (electronUpdateSession.armed) return;
|
|
electronUpdateSession.armed = true;
|
|
autoUpdater.on('update-available', () => {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.DOWNLOADING;
|
|
publishElectronUpdateEvent({type: 'available'});
|
|
});
|
|
autoUpdater.on('update-not-available', () => {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.IDLE;
|
|
publishElectronUpdateEvent({type: 'not-available'});
|
|
});
|
|
autoUpdater.on('update-downloaded', () => {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.DOWNLOADED;
|
|
logger.info('The shell update finished downloading');
|
|
publishElectronUpdateEvent({type: 'downloaded'});
|
|
});
|
|
autoUpdater.on('error', (error: Error) => {
|
|
const phase = electronUpdateSession.phase;
|
|
if (phase !== ElectronUpdatePhase.DOWNLOADED) {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.IDLE;
|
|
}
|
|
publishElectronUpdateEvent({type: 'error', error, phase});
|
|
});
|
|
}
|
|
|
|
export function resetElectronUpdateSessionForTests(): void {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.IDLE;
|
|
electronUpdateSession.armed = false;
|
|
electronUpdateSession.observers.clear();
|
|
}
|
|
|
|
function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): void {
|
|
armElectronUpdateSession();
|
|
const install = (): void => {
|
|
control.clearCheckDeadline();
|
|
hooks.onRestarting();
|
|
try {
|
|
autoUpdater.quitAndInstall();
|
|
} catch (error) {
|
|
control.settle({reason: 'install-failed', detail: errorDetail(error)});
|
|
}
|
|
};
|
|
const observer = (event: ElectronUpdateEvent): void => {
|
|
switch (event.type) {
|
|
case 'available':
|
|
control.clearCheckDeadline();
|
|
hooks.onDownloading(null);
|
|
return;
|
|
case 'not-available':
|
|
control.settle({reason: 'no-update', detail: null});
|
|
return;
|
|
case 'downloaded':
|
|
install();
|
|
return;
|
|
case 'error':
|
|
control.settle({
|
|
reason: event.phase === ElectronUpdatePhase.DOWNLOADING ? 'download-failed' : 'check-failed',
|
|
detail: errorDetail(event.error),
|
|
});
|
|
return;
|
|
}
|
|
};
|
|
electronUpdateSession.observers.add(observer);
|
|
control.addCleanup(() => {
|
|
electronUpdateSession.observers.delete(observer);
|
|
});
|
|
switch (electronUpdateSession.phase) {
|
|
case ElectronUpdatePhase.DOWNLOADED:
|
|
logger.info('The shell update was already downloaded this session, installing it');
|
|
install();
|
|
return;
|
|
case ElectronUpdatePhase.DOWNLOADING:
|
|
logger.info('The shell update is already downloading this session, waiting for it');
|
|
control.clearCheckDeadline();
|
|
hooks.onDownloading(null);
|
|
return;
|
|
case ElectronUpdatePhase.CHECKING:
|
|
logger.info('The shell update check is already running this session, waiting for it');
|
|
return;
|
|
case ElectronUpdatePhase.IDLE:
|
|
break;
|
|
}
|
|
try {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.CHECKING;
|
|
autoUpdater.setFeedURL({
|
|
url: getElectronUpdateFeedUrl(),
|
|
serverType: 'json',
|
|
headers: {'User-Agent': feedUserAgent()},
|
|
});
|
|
autoUpdater.checkForUpdates();
|
|
} catch (error) {
|
|
electronUpdateSession.phase = ElectronUpdatePhase.IDLE;
|
|
control.settle({reason: 'check-failed', detail: errorDetail(error)});
|
|
}
|
|
}
|
|
|
|
type ShellSelfUpdatePlan = Extract<ShellUpdatePlan, {capability: typeof ShellUpdateCapability.SELF_UPDATE}>;
|
|
|
|
export function runShellSelfUpdate(
|
|
plan: ShellSelfUpdatePlan,
|
|
hooks: ShellSelfUpdateHooks,
|
|
): Promise<ShellSelfUpdateFailure> {
|
|
return new Promise<ShellSelfUpdateFailure>((resolve) => {
|
|
const cleanups: Array<() => void> = [];
|
|
let settled = false;
|
|
let checkTimer: NodeJS.Timeout | null = null;
|
|
let totalTimer: NodeJS.Timeout | null = null;
|
|
|
|
const clearCheckDeadline = (): void => {
|
|
if (checkTimer == null) return;
|
|
clearTimeout(checkTimer);
|
|
checkTimer = null;
|
|
};
|
|
|
|
const settle = (failure: ShellSelfUpdateFailure): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearCheckDeadline();
|
|
if (totalTimer != null) {
|
|
clearTimeout(totalTimer);
|
|
totalTimer = null;
|
|
}
|
|
for (const cleanup of cleanups.splice(0)) {
|
|
try {
|
|
cleanup();
|
|
} catch (error) {
|
|
logger.error('A shell self update cleanup threw', error);
|
|
}
|
|
}
|
|
logger.warn('The shell self update did not complete', {updater: plan.updater, ...failure});
|
|
resolve(failure);
|
|
};
|
|
|
|
const control: SelfUpdateControl = {
|
|
settle,
|
|
isSettled: () => settled,
|
|
clearCheckDeadline,
|
|
addCleanup: (cleanup) => {
|
|
cleanups.push(cleanup);
|
|
},
|
|
};
|
|
|
|
checkTimer = setTimeout(() => {
|
|
checkTimer = null;
|
|
settle({reason: 'timed-out', detail: 'the update check produced no answer'});
|
|
}, SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS);
|
|
checkTimer.unref();
|
|
totalTimer = setTimeout(() => {
|
|
totalTimer = null;
|
|
settle({reason: 'timed-out', detail: 'the update did not finish'});
|
|
}, SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS);
|
|
totalTimer.unref();
|
|
|
|
if (plan.updater === 'appimage') {
|
|
runAppImageSelfUpdate(control, hooks, plan.target).catch((error: unknown) => {
|
|
settle({reason: 'install-failed', detail: errorDetail(error)});
|
|
});
|
|
return;
|
|
}
|
|
if (plan.updater === 'velopack') {
|
|
runVelopackSelfUpdate(control, hooks).catch((error: unknown) => {
|
|
settle({reason: 'install-failed', detail: errorDetail(error)});
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
runElectronSelfUpdate(control, hooks);
|
|
} catch (error) {
|
|
settle({reason: 'check-failed', detail: errorDetail(error)});
|
|
}
|
|
});
|
|
}
|