mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): prefer the bundled renderer, own update prompts (#3341)
This commit is contained in:
1 parent
7a5b9a0ded
commit
5072488f1e
33 files changed
+2197
-113
No files matched your search
@@ -1,8 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {BUILD_CHANNEL, type BuildChannel} from '@electron/common/BuildChannel';
|
||||
import log from 'electron-log';
|
||||
|
||||
const DEFAULT_LOG_FILE_NAME = 'main.log';
|
||||
|
||||
const CHANNEL_LOG_LEVELS: Record<BuildChannel, 'debug' | 'info'> = {
|
||||
stable: 'info',
|
||||
canary: 'debug',
|
||||
@@ -29,3 +32,7 @@ export function createChildLogger(componentName: string): typeof Logger {
|
||||
error: (...args: Array<unknown>) => log.error(prefix, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeLogFilesUnder(logsPath: string): void {
|
||||
log.transports.file.resolvePathFn = (variables) => path.join(logsPath, variables.fileName ?? DEFAULT_LOG_FILE_NAME);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export const MODULE_SYSTEM_BUILD_ENABLED = process.env.FLUXER_MODULES === '1';
|
||||
|
||||
const OFFLINE_RENDERER_DIRECTORY_SEGMENTS: ReadonlyArray<string> = Object.freeze(['..', 'renderer']);
|
||||
const OFFLINE_RENDERER_INDEX_NAME = 'index.html';
|
||||
const OFFLINE_RENDERER_VERSION_NAME = 'version.json';
|
||||
const OFFLINE_RENDERER_VERSION_MAX_BYTES = 4096;
|
||||
|
||||
const MODULE_SYSTEM_ENV = 'FLUXER_MODULE_SYSTEM';
|
||||
const MODULE_SYSTEM_DISABLE_ARGUMENT = '--fluxer-no-module-system';
|
||||
@@ -84,8 +86,22 @@ export function resolveModuleSystemLaunch({
|
||||
}
|
||||
}
|
||||
|
||||
const ASAR_ARCHIVE_SEGMENT = `.asar${path.sep}`;
|
||||
const ASAR_UNPACKED_SEGMENT = `.asar.unpacked${path.sep}`;
|
||||
|
||||
export function resolveAsarUnpackedPath(target: string): string {
|
||||
const index = target.lastIndexOf(ASAR_ARCHIVE_SEGMENT);
|
||||
if (index < 0) {
|
||||
return target;
|
||||
}
|
||||
const unpacked = `${target.slice(0, index)}${ASAR_UNPACKED_SEGMENT}${target.slice(index + ASAR_ARCHIVE_SEGMENT.length)}`;
|
||||
return fs.existsSync(unpacked) ? unpacked : target;
|
||||
}
|
||||
|
||||
export function getOfflineRendererRoot(mainModuleUrl: string): string {
|
||||
return path.join(path.dirname(fileURLToPath(mainModuleUrl)), ...OFFLINE_RENDERER_DIRECTORY_SEGMENTS);
|
||||
return resolveAsarUnpackedPath(
|
||||
path.join(path.dirname(fileURLToPath(mainModuleUrl)), ...OFFLINE_RENDERER_DIRECTORY_SEGMENTS),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasOfflineRenderer(mainModuleUrl: string): boolean {
|
||||
@@ -98,3 +114,27 @@ export function hasOfflineRenderer(mainModuleUrl: string): boolean {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function readBundledRendererVersion(mainModuleUrl: string): string | null {
|
||||
let raw: string;
|
||||
try {
|
||||
const target = path.join(getOfflineRendererRoot(mainModuleUrl), OFFLINE_RENDERER_VERSION_NAME);
|
||||
if (fs.statSync(target).size > OFFLINE_RENDERER_VERSION_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
raw = fs.readFileSync(target, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const version = (parsed as Record<string, unknown>)['version'];
|
||||
return typeof version === 'string' && version.length > 0 ? version : null;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ function loadAppImageUpdate(env = {}, overrides = {}, requireImpl = require) {
|
||||
require: requireImpl,
|
||||
console,
|
||||
Buffer,
|
||||
AbortController,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch,
|
||||
Error,
|
||||
Number,
|
||||
@@ -261,6 +264,29 @@ describe('AppImageUpdate in-place replacement', () => {
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
});
|
||||
|
||||
test('a download that stops sending data fails instead of hanging and leaves nothing staged', async () => {
|
||||
const install = createInstall();
|
||||
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
|
||||
const resolved = appImageUpdate.resolveAppImageTarget();
|
||||
const silent = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(NEW_BYTES.subarray(0, 4));
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
appImageUpdate.stageAppImageUpdate({
|
||||
fetchImpl: async () => new Response(silent, {headers: {'content-length': String(NEW_BYTES.length)}}),
|
||||
target: resolved.target,
|
||||
url: 'https://pkgs.invalid/appimage',
|
||||
expectedSha256: NEW_SHA256,
|
||||
stallMs: 30,
|
||||
}),
|
||||
/sent no data for 30 ms/,
|
||||
);
|
||||
assert.deepEqual(stagingLeftovers(install.applications), []);
|
||||
});
|
||||
|
||||
test('refuses to swap when the download does not match the published checksum', async () => {
|
||||
const install = createInstall();
|
||||
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
|
||||
|
||||
@@ -45,6 +45,7 @@ const APPDIR_ENTRYPOINT = 'AppRun';
|
||||
const APPIMAGE_MODE = 0o755;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const STAGING_FREE_SPACE_MARGIN_BYTES = 16 * 1024 * 1024;
|
||||
export const APPIMAGE_DOWNLOAD_STALL_MS = 30_000;
|
||||
const NON_RETRYABLE_STAGING_ERRNOS = new Set([
|
||||
'ENOSPC',
|
||||
'EDQUOT',
|
||||
@@ -167,12 +168,45 @@ export async function stageAppImageUpdate(options: {
|
||||
expectedSha256: string;
|
||||
onProgress?: (progress: AppImageDownloadProgress) => void;
|
||||
fetchImpl: typeof fetch;
|
||||
stallMs?: number;
|
||||
}): Promise<StagedAppImageUpdate> {
|
||||
const expected = options.expectedSha256.trim().toLowerCase();
|
||||
if (!SHA256_PATTERN.test(expected)) {
|
||||
throw new Error('Update checksum is missing or malformed.');
|
||||
}
|
||||
const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow'});
|
||||
const stallMs = options.stallMs ?? APPIMAGE_DOWNLOAD_STALL_MS;
|
||||
const stalled = new AbortController();
|
||||
let stallTimer: NodeJS.Timeout | null = null;
|
||||
const stopStallTimer = (): void => {
|
||||
if (stallTimer != null) clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
};
|
||||
const restartStallTimer = (): void => {
|
||||
stopStallTimer();
|
||||
stallTimer = setTimeout(() => {
|
||||
stalled.abort(new Error(`Update download sent no data for ${stallMs} ms.`));
|
||||
}, stallMs);
|
||||
};
|
||||
restartStallTimer();
|
||||
try {
|
||||
return await downloadAndStageAppImage(options, expected, stalled.signal, restartStallTimer);
|
||||
} finally {
|
||||
stopStallTimer();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndStageAppImage(
|
||||
options: {
|
||||
target: AppImageTarget;
|
||||
url: string;
|
||||
onProgress?: (progress: AppImageDownloadProgress) => void;
|
||||
fetchImpl: typeof fetch;
|
||||
},
|
||||
expected: string,
|
||||
signal: AbortSignal,
|
||||
onData: () => void,
|
||||
): Promise<StagedAppImageUpdate> {
|
||||
const response = await options.fetchImpl(options.url, {cache: 'no-store', redirect: 'follow', signal});
|
||||
if (!response.ok || response.body == null) {
|
||||
throw new Error(`Update download failed: ${response.status}`);
|
||||
}
|
||||
@@ -200,13 +234,21 @@ export async function stageAppImageUpdate(options: {
|
||||
try {
|
||||
const handle = await open(staged.stagedPath, 'wx', APPIMAGE_MODE);
|
||||
const reader = response.body.getReader();
|
||||
const cancelOnStall = (): void => {
|
||||
void reader.cancel(signal.reason).catch(() => {});
|
||||
};
|
||||
signal.addEventListener('abort', cancelOnStall, {once: true});
|
||||
let transferred = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (signal.aborted) {
|
||||
throw signal.reason;
|
||||
}
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
onData();
|
||||
let written = 0;
|
||||
while (written < value.byteLength) {
|
||||
const result = await handle.write(value, written, value.byteLength - written);
|
||||
@@ -226,6 +268,7 @@ export async function stageAppImageUpdate(options: {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', cancelOnStall);
|
||||
await handle.close();
|
||||
}
|
||||
const digest = await digestFile(staged.stagedPath);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
||||
import {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity';
|
||||
import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan';
|
||||
import {
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
hasOfflineRenderer,
|
||||
ModuleSystemDisableDecision,
|
||||
ModuleSystemLaunchDecision,
|
||||
readBundledRendererVersion,
|
||||
resolveModuleSystemLaunch,
|
||||
} from '@electron/common/ModuleSystem';
|
||||
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
|
||||
@@ -60,11 +64,16 @@ function readDevModulePollInterval(): number | null {
|
||||
}
|
||||
|
||||
const HAS_OFFLINE_RENDERER = hasOfflineRenderer(import.meta.url);
|
||||
const BUNDLED_RENDERER_VERSION = HAS_OFFLINE_RENDERER ? readBundledRendererVersion(import.meta.url) : null;
|
||||
|
||||
const MODULE_SYSTEM_LAUNCH = resolveModuleSystemLaunch({hasOfflineRenderer: HAS_OFFLINE_RENDERER});
|
||||
const CAN_TURN_MODULE_SYSTEM_OFF = HAS_OFFLINE_RENDERER && BUILD_CHANNEL === 'development';
|
||||
|
||||
const MODULE_SYSTEM_LAUNCH = resolveModuleSystemLaunch({hasOfflineRenderer: CAN_TURN_MODULE_SYSTEM_OFF});
|
||||
|
||||
const MODULE_LAUNCH_PERMIT = Symbol('fluxer.desktop.moduleLaunchPermit');
|
||||
|
||||
const PENDING_UPDATE_MARKER_NAME = 'update-pending';
|
||||
const IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS = 45_000;
|
||||
const UPDATE_TAKEOVER_SPLASH_WAIT_MS = 250;
|
||||
const UPDATE_SPLASH_PRELOAD_DELAY_MS = 5000;
|
||||
|
||||
@@ -190,14 +199,14 @@ async function refuseUnsupportedBuild(reason: string): Promise<never> {
|
||||
|
||||
async function runModuleBootstrap(): Promise<void> {
|
||||
const {app, clipboard, session, shell} = await import('electron');
|
||||
const {BUILD_CHANNEL} = await import('@electron/common/BuildChannel');
|
||||
const {createChildLogger} = await import('@electron/common/Logger');
|
||||
const {createChildLogger, writeLogFilesUnder} = await import('@electron/common/Logger');
|
||||
const {loadDesktopConfig} = await import('@electron/common/DesktopConfig');
|
||||
const {appendWindowsGpuDriverWorkaroundSwitches} = await import('@electron/main/ChromiumRuntime');
|
||||
const {applyPreReadyChromiumConfiguration} = await import('@electron/main/PreReadyChromium');
|
||||
const {configureUserDataPath} = await import('@electron/common/UserDataPath');
|
||||
const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache');
|
||||
const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch');
|
||||
const {isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions');
|
||||
const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath');
|
||||
const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol');
|
||||
const {
|
||||
@@ -242,10 +251,13 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
const logger = createChildLogger('Bootstrap');
|
||||
if (MODULE_SYSTEM_LAUNCH.kind === ModuleSystemLaunchDecision.ENABLED_WITH_IGNORED_DISABLE_REQUEST) {
|
||||
logger.warn(
|
||||
'Ignoring the --fluxer-no-module-system override because this build carries no offline renderer, so turning the module system off would leave nothing to render',
|
||||
'Ignoring the --fluxer-no-module-system override, only a development build with a bundled renderer may turn the module system off',
|
||||
);
|
||||
}
|
||||
const userDataConfig = configureUserDataPath();
|
||||
if (userDataConfig.portable) {
|
||||
writeLogFilesUnder(app.getPath('logs'));
|
||||
}
|
||||
armNativeProbeCache(userDataConfig.base);
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
@@ -257,14 +269,14 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
|
||||
const instancePreference = decideModuleSystemDisableRequest(
|
||||
instanceTurnedModulesOff(userDataConfig.base),
|
||||
HAS_OFFLINE_RENDERER,
|
||||
CAN_TURN_MODULE_SYSTEM_OFF,
|
||||
);
|
||||
switch (instancePreference.kind) {
|
||||
case ModuleSystemDisableDecision.NOT_REQUESTED:
|
||||
break;
|
||||
case ModuleSystemDisableDecision.IGNORED_WITHOUT_OFFLINE_RENDERER:
|
||||
logger.warn(
|
||||
'Ignoring the instance desktop module preference because this build carries no offline renderer, so turning the module system off would leave nothing to render',
|
||||
'Ignoring the instance desktop module preference, only a development build with a bundled renderer may turn the module system off',
|
||||
);
|
||||
break;
|
||||
case ModuleSystemDisableDecision.HONOURED:
|
||||
@@ -443,14 +455,37 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
if (store.recoveredUnreadableState != null) {
|
||||
logger.warn('The module state was unreadable, set it aside and started from a clean state', {
|
||||
setAside: store.recoveredUnreadableState,
|
||||
});
|
||||
}
|
||||
if (store.shellVersionChanged) {
|
||||
logger.info('The shell version changed since the last boot, converging the modules before launch');
|
||||
}
|
||||
const pendingUpdateMarker = path.join(store.root, PENDING_UPDATE_MARKER_NAME);
|
||||
const updateWasPending = fs.existsSync(pendingUpdateMarker);
|
||||
if (updateWasPending) {
|
||||
logger.info('An update was waiting when the app last quit, installing it before launch');
|
||||
}
|
||||
const recordPendingUpdate = (check: {readonly shellNewer: boolean; readonly modulesChanged: boolean}): void => {
|
||||
try {
|
||||
if (check.modulesChanged) {
|
||||
fs.writeFileSync(pendingUpdateMarker, '');
|
||||
} else {
|
||||
fs.rmSync(pendingUpdateMarker, {force: true});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to record whether an update is waiting', error);
|
||||
}
|
||||
};
|
||||
const updater = new ModuleUpdater({
|
||||
store,
|
||||
shellVersion: app.getVersion(),
|
||||
hasOfflineRenderer: HAS_OFFLINE_RENDERER,
|
||||
forceStartupUpdate: store.shellVersionChanged,
|
||||
bundledRendererVersion: BUNDLED_RENDERER_VERSION,
|
||||
preferUnversionedBundle: app.isPackaged,
|
||||
forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(),
|
||||
selfUpdateShellFirst:
|
||||
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
|
||||
? async (latestVersion, requiredSecurityUpdate) => {
|
||||
@@ -567,9 +602,12 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
let servedModulesUpdate: Promise<void> = Promise.resolve();
|
||||
const serveModules = (resolveModules: () => Readonly<Record<string, string>>): Promise<void> => {
|
||||
const update = async (): Promise<void> => {
|
||||
const modules = resolveModules();
|
||||
setCommittedModuleFiles(store.storeRoot, await store.buildModuleIndex(modules));
|
||||
servedModules = modules;
|
||||
const selection = await updater.selectServedModules(resolveModules());
|
||||
setCommittedModuleFiles(store.storeRoot, await store.buildModuleIndex(selection.modules));
|
||||
servedModules = selection.modules;
|
||||
logger.info(
|
||||
`Serving the renderer: source=${selection.renderer.source} version=${selection.renderer.version ?? 'unknown'} bundled=${selection.renderer.bundledVersion ?? 'none'}`,
|
||||
);
|
||||
};
|
||||
const started = servedModulesUpdate.then(update, update);
|
||||
servedModulesUpdate = started.catch(() => undefined);
|
||||
@@ -708,6 +746,44 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
reopenMainWindow(launchAttempt);
|
||||
preloadUpdateSplash();
|
||||
},
|
||||
reloadInPlace: async (launchAttempt) => {
|
||||
const windows = takeoverWindows ?? (await import('@electron/main/Window'));
|
||||
takeoverWindows = windows;
|
||||
let settled = false;
|
||||
const stopObservingLaunch = observeRendererLaunchConfirmed(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stopObservingLaunch();
|
||||
clearTimeout(watchdog);
|
||||
void updater.markLaunchSucceeded(launchAttempt).catch((error: unknown) => {
|
||||
logger.error('Failed to record a successful in place module update', error);
|
||||
});
|
||||
});
|
||||
const watchdog = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stopObservingLaunch();
|
||||
void (async () => {
|
||||
const reverted = await updater.revertLaunch(launchAttempt);
|
||||
if (reverted == null) return;
|
||||
logger.error('The updated renderer never confirmed it started, going back to the one before it', {
|
||||
timeoutMs: IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS,
|
||||
});
|
||||
await refreshModuleRoots(reverted);
|
||||
if (!(await windows.reloadMainWindowForUpdate())) {
|
||||
reopenMainWindow(null);
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
logger.error('Failed to go back to the renderer before the update', error);
|
||||
});
|
||||
}, IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS);
|
||||
watchdog.unref();
|
||||
const reloaded = await windows.reloadMainWindowForUpdate();
|
||||
logger.info('Reloaded the main window onto the updated renderer', {reloaded});
|
||||
if (!reloaded) {
|
||||
reopenMainWindow(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const desktopUpdate = new DesktopUpdateRun({
|
||||
@@ -722,7 +798,17 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
return launchAttempt;
|
||||
},
|
||||
takeover: updateTakeover,
|
||||
publish: publishDesktopUpdateCheck,
|
||||
publish: (check) => {
|
||||
recordPendingUpdate(check);
|
||||
publishDesktopUpdateCheck(check);
|
||||
},
|
||||
reportFailure: (failure) => {
|
||||
void import('@electron/main/DesktopUpdatePrompt')
|
||||
.then(({reportDesktopUpdateFailure}) => reportDesktopUpdateFailure(failure))
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Failed to report the desktop update failure', error);
|
||||
});
|
||||
},
|
||||
openDownloadsPage: async () => {
|
||||
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
|
||||
await shell.openExternal(DOWNLOAD_PAGE_URL);
|
||||
@@ -732,6 +818,9 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
|
||||
const permit = await runModuleUpdateLoop();
|
||||
localNetworkHint.disarm();
|
||||
if (!permit.unreachable) {
|
||||
recordPendingUpdate({shellNewer: false, modulesChanged: false});
|
||||
}
|
||||
await refreshModuleRoots(permit.launchAttempt.committed);
|
||||
setOnDemandModuleInstaller(
|
||||
createOnDemandModuleInstaller({
|
||||
@@ -743,7 +832,14 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
}),
|
||||
);
|
||||
markSplashLaunching();
|
||||
armDesktopUpdate({check: () => desktopUpdate.check(), start: () => desktopUpdate.start()});
|
||||
armDesktopUpdate({
|
||||
check: async () => {
|
||||
const check = await desktopUpdate.check();
|
||||
recordPendingUpdate(check);
|
||||
return check;
|
||||
},
|
||||
start: () => desktopUpdate.start(),
|
||||
});
|
||||
armMainWindowHandoff(updater, permit.launchAttempt, () => {
|
||||
desktopUpdate.markLaunchSettled();
|
||||
preloadUpdateSplash();
|
||||
|
||||
@@ -48,10 +48,15 @@ describe('Bootstrap entry point', () => {
|
||||
test('the instance module preference can only be honoured through the offline renderer guard', () => {
|
||||
assert.match(
|
||||
source,
|
||||
/const instancePreference = decideModuleSystemDisableRequest\(\n\t\tinstanceTurnedModulesOff\(userDataConfig\.base\),\n\t\tHAS_OFFLINE_RENDERER,\n\t\);/,
|
||||
/const instancePreference = decideModuleSystemDisableRequest\(\n\t\tinstanceTurnedModulesOff\(userDataConfig\.base\),\n\t\tCAN_TURN_MODULE_SYSTEM_OFF,\n\t\);/,
|
||||
'A server side switch that could turn the module system off on a build with no renderer would brick the install, so it goes through the same guard the launch flag does.',
|
||||
);
|
||||
assert.match(source, /case ModuleSystemDisableDecision\.HONOURED:\n\t\t\tlogger\.warn\(/);
|
||||
assert.match(
|
||||
source,
|
||||
/const CAN_TURN_MODULE_SYSTEM_OFF = HAS_OFFLINE_RENDERER && BUILD_CHANNEL === 'development';/,
|
||||
'Every release shell bundles a renderer now, so the switch and the launch flag would otherwise skip updates, floors and on-demand assets on stable and canary.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the instance module preference is read from the module store root, never from the app store database', () => {
|
||||
@@ -269,17 +274,6 @@ describe('Bootstrap entry point', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a user check works from the first window, a click only after the renderer confirmed its launch', () => {
|
||||
const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
|
||||
const arm = bootstrap.indexOf(
|
||||
'armDesktopUpdate({check: () => desktopUpdate.check(), start: () => desktopUpdate.start()});',
|
||||
);
|
||||
assert.notEqual(arm, -1);
|
||||
assert.ok(arm < bootstrap.indexOf('await launchMainApp(permit, logger);'));
|
||||
assert.match(bootstrap, /\.finally\(onLaunchSettled\)/);
|
||||
assert.match(bootstrap, /desktopUpdate\.markLaunchSettled\(\);/);
|
||||
});
|
||||
|
||||
test('the update splash hides the app only once it is on screen, in its own theme', () => {
|
||||
const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
|
||||
assert.match(bootstrap, /openSplashWindow\(\{darkThemeOnShow: true\}\)/);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APP_PROTOCOL} from '@electron/common/Constants';
|
||||
import {updateFromCommandLine} from '@electron/main/DesktopUpdatePrompt';
|
||||
import {parseJumpListTaskFromArgv} from '@electron/main/JumpList';
|
||||
import {isDesktopUpdateRequested} from '@electron/main/LaunchOptions';
|
||||
import {recordRecentDeepLink} from '@electron/main/RecentDocuments';
|
||||
import {getMainWindow, isMainWindowTakenOver, onMainWindowTakeoverEnded, showWindow} from '@electron/main/Window';
|
||||
import {app, ipcMain} from 'electron';
|
||||
@@ -215,6 +217,10 @@ function isSquirrelOrSyntheticArg(arg: string): boolean {
|
||||
}
|
||||
|
||||
export function handleSecondInstance(argv: Array<string>): void {
|
||||
if (isDesktopUpdateRequested(argv)) {
|
||||
void updateFromCommandLine();
|
||||
return;
|
||||
}
|
||||
const task = parseJumpListTaskFromArgv(argv);
|
||||
if (task) {
|
||||
dispatchJumpListTask(task);
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
TrayActionPayload,
|
||||
TrayRuntimeStatePayload,
|
||||
} from '@electron/common/Types';
|
||||
import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/DesktopUpdateGate';
|
||||
import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt';
|
||||
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
||||
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
||||
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
|
||||
@@ -72,6 +74,9 @@ export function updateTrayRuntimeState(update: Partial<TrayRuntimeStatePayload>,
|
||||
if (webContentsId !== undefined) {
|
||||
trayActionBridgeWebContentsId = webContentsId;
|
||||
}
|
||||
if (typeof update.buildInfo === 'string' && update.buildInfo !== trayState.buildInfo) {
|
||||
logger.info(`The renderer reported its build: ${update.buildInfo}`);
|
||||
}
|
||||
Object.assign(trayState, update);
|
||||
refreshDesktopTrayMenu();
|
||||
schedulePendingTrayActionFlush();
|
||||
@@ -388,9 +393,18 @@ function buildTrayMenu(): Menu {
|
||||
});
|
||||
}
|
||||
menuTemplate.push({type: 'separator'});
|
||||
if (getDesktopUpdateState().available) {
|
||||
menuTemplate.push({
|
||||
label: t('desktop.tray.updateNow', {appName: APP_NAME}),
|
||||
click: () => runTrayMenuAction(startDesktopUpdateFromShell),
|
||||
});
|
||||
}
|
||||
menuTemplate.push({
|
||||
label: t('desktop.tray.checkForUpdates'),
|
||||
click: () => runTrayMenuAction(() => dispatchTrayAction({action: 'check-for-updates'})),
|
||||
click: () =>
|
||||
runTrayMenuAction(() => {
|
||||
void checkForUpdatesFromShell();
|
||||
}),
|
||||
});
|
||||
if (trayState.buildInfo) {
|
||||
menuTemplate.push({
|
||||
@@ -533,6 +547,7 @@ export function initializeDesktopTray(nextController: DesktopTrayController): vo
|
||||
}
|
||||
refreshDesktopTrayMenu();
|
||||
});
|
||||
observeDesktopUpdateState(refreshDesktopTrayMenu);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ function deferred() {
|
||||
|
||||
describe('DesktopUpdateGate', () => {
|
||||
test('nothing is available and nothing starts before the bootstrap arms the gate', async () => {
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false});
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: false});
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false});
|
||||
assert.equal(startDesktopUpdate(), false);
|
||||
});
|
||||
|
||||
test('a check publishes the update and one click runs it exactly once', async () => {
|
||||
const states = [];
|
||||
const stop = observeDesktopUpdateState((state) => states.push(state.available));
|
||||
const stop = observeDesktopUpdateState((state) => states.push(state));
|
||||
const running = deferred();
|
||||
let starts = 0;
|
||||
armDesktopUpdate({
|
||||
@@ -44,18 +44,30 @@ describe('DesktopUpdateGate', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: true});
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: true, updating: false});
|
||||
assert.equal(startDesktopUpdate(), true);
|
||||
assert.equal(startDesktopUpdate(), false, 'a second click while the update runs is a no-op');
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false}, 'the affordance hides the moment it is clicked');
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: false}, 'a poll during the update changes nothing');
|
||||
assert.deepEqual(
|
||||
getDesktopUpdateState(),
|
||||
{available: false, updating: true},
|
||||
'the affordance hides the moment it is clicked',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await checkDesktopUpdateNow(),
|
||||
{available: false, updating: true},
|
||||
'a poll during the update changes nothing',
|
||||
);
|
||||
|
||||
running.resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(starts, 1);
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false});
|
||||
assert.deepEqual(states, [true, false]);
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
|
||||
assert.deepEqual(states, [
|
||||
{available: true, updating: false},
|
||||
{available: false, updating: true},
|
||||
{available: false, updating: false},
|
||||
]);
|
||||
stop();
|
||||
});
|
||||
|
||||
@@ -71,7 +83,7 @@ describe('DesktopUpdateGate', () => {
|
||||
assert.equal(startDesktopUpdate(), true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: true});
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false});
|
||||
assert.equal(startDesktopUpdate(), true);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ interface DesktopUpdateGateState {
|
||||
lastCheck: DesktopUpdateCheck | null;
|
||||
running: Promise<void> | null;
|
||||
notifiedAvailable: boolean;
|
||||
notifiedUpdating: boolean;
|
||||
readonly listeners: Set<DesktopUpdateStateListener>;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = {
|
||||
lastCheck: null,
|
||||
running: null,
|
||||
notifiedAvailable: false,
|
||||
notifiedUpdating: false,
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
@@ -38,15 +40,24 @@ function isAvailable(check: DesktopUpdateCheck | null): boolean {
|
||||
}
|
||||
|
||||
export function getDesktopUpdateState(): DesktopUpdateState {
|
||||
return {available: gateState.running == null && isAvailable(gateState.lastCheck)};
|
||||
return {
|
||||
available: gateState.running == null && isAvailable(gateState.lastCheck),
|
||||
updating: gateState.running != null,
|
||||
};
|
||||
}
|
||||
|
||||
export function desktopUpdateReplacesShell(): boolean {
|
||||
return gateState.lastCheck?.shellNewer === true;
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
const state = getDesktopUpdateState();
|
||||
if (state.available === gateState.notifiedAvailable) {
|
||||
const updating = state.updating === true;
|
||||
if (state.available === gateState.notifiedAvailable && updating === gateState.notifiedUpdating) {
|
||||
return;
|
||||
}
|
||||
gateState.notifiedAvailable = state.available;
|
||||
gateState.notifiedUpdating = updating;
|
||||
for (const listener of Array.from(gateState.listeners)) {
|
||||
try {
|
||||
listener(state);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity';
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import {
|
||||
checkDesktopUpdateNow,
|
||||
desktopUpdateReplacesShell,
|
||||
getDesktopUpdateState,
|
||||
startDesktopUpdate,
|
||||
} from '@electron/main/DesktopUpdateGate';
|
||||
import {t} from '@electron/main/MainI18n';
|
||||
import {openExternalDeduped} from '@electron/main/OpenExternal';
|
||||
import {findNewerManualShell} from '@electron/main/Updater';
|
||||
import {DOWNLOAD_PAGE_URL} from '@electron/main/UpdaterDownloads';
|
||||
import {app, BrowserWindow, dialog, type MessageBoxOptions} from 'electron';
|
||||
|
||||
const logger = createChildLogger('DesktopUpdatePrompt');
|
||||
|
||||
export interface DesktopUpdateFailure {
|
||||
readonly reason: string;
|
||||
readonly detail: string | null;
|
||||
}
|
||||
|
||||
const UpdatePromptChoice = Object.freeze({
|
||||
PRIMARY: 0,
|
||||
SECONDARY: 1,
|
||||
} as const);
|
||||
|
||||
let checking: Promise<void> | null = null;
|
||||
let failurePrompt: Promise<void> | null = null;
|
||||
|
||||
async function ask(options: MessageBoxOptions): Promise<number> {
|
||||
const parent = BrowserWindow.getFocusedWindow();
|
||||
const usable = parent != null && !parent.isDestroyed() && parent.isVisible() ? parent : null;
|
||||
const result = usable == null ? await dialog.showMessageBox(options) : await dialog.showMessageBox(usable, options);
|
||||
return result.response;
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function runUpdateCheck(): Promise<void> {
|
||||
for (;;) {
|
||||
let manualVersion: string | null = null;
|
||||
try {
|
||||
const state = await checkDesktopUpdateNow();
|
||||
if (state.updating === true) {
|
||||
logger.info('A desktop update is already running, leaving it to finish');
|
||||
return;
|
||||
}
|
||||
if (state.available) {
|
||||
await offerDesktopUpdate();
|
||||
return;
|
||||
}
|
||||
manualVersion = (await findNewerManualShell())?.version ?? null;
|
||||
} catch (error) {
|
||||
logger.warn('The update check from a native menu failed', error);
|
||||
const retry = await ask({
|
||||
type: 'warning',
|
||||
message: t('desktop.update.checkFailedMessage'),
|
||||
detail: `${t('desktop.update.checkFailedDetail')}\n\n${describeError(error)}`,
|
||||
buttons: [t('desktop.appLoad.retry'), t('desktop.update.later')],
|
||||
defaultId: UpdatePromptChoice.PRIMARY,
|
||||
cancelId: UpdatePromptChoice.SECONDARY,
|
||||
});
|
||||
if (retry === UpdatePromptChoice.PRIMARY) {
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (manualVersion != null) {
|
||||
const download = await ask({
|
||||
type: 'info',
|
||||
message: t('desktop.update.manualMessage', {version: manualVersion}),
|
||||
detail: t('desktop.update.manualDetail'),
|
||||
buttons: [t('desktop.update.download'), t('desktop.update.later')],
|
||||
defaultId: UpdatePromptChoice.PRIMARY,
|
||||
cancelId: UpdatePromptChoice.SECONDARY,
|
||||
});
|
||||
if (download === UpdatePromptChoice.PRIMARY) {
|
||||
await openExternalDeduped(DOWNLOAD_PAGE_URL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ask({
|
||||
type: 'info',
|
||||
message: t('desktop.update.upToDateMessage'),
|
||||
detail: t('desktop.update.upToDateDetail', {version: app.getVersion()}),
|
||||
buttons: [t('desktop.update.ok')],
|
||||
defaultId: UpdatePromptChoice.PRIMARY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function offerDesktopUpdate(): Promise<void> {
|
||||
const install = await ask({
|
||||
type: 'info',
|
||||
message: t('desktop.update.availableMessage'),
|
||||
detail: t(desktopUpdateReplacesShell() ? 'desktop.update.availableDetail' : 'desktop.update.reloadDetail'),
|
||||
buttons: [t('desktop.update.install'), t('desktop.update.later')],
|
||||
defaultId: UpdatePromptChoice.PRIMARY,
|
||||
cancelId: UpdatePromptChoice.SECONDARY,
|
||||
});
|
||||
if (install === UpdatePromptChoice.PRIMARY) {
|
||||
startDesktopUpdateFromShell();
|
||||
}
|
||||
}
|
||||
|
||||
export function checkForUpdatesFromShell(): Promise<void> {
|
||||
checking ??= runUpdateCheck()
|
||||
.catch((error: unknown) => {
|
||||
logger.error('The native update prompt failed', error);
|
||||
})
|
||||
.finally(() => {
|
||||
checking = null;
|
||||
});
|
||||
return checking;
|
||||
}
|
||||
|
||||
export function startDesktopUpdateFromShell(): void {
|
||||
if (startDesktopUpdate()) {
|
||||
logger.info('Started the desktop update from a native menu');
|
||||
return;
|
||||
}
|
||||
if (getDesktopUpdateState().available) {
|
||||
return;
|
||||
}
|
||||
void checkForUpdatesFromShell();
|
||||
}
|
||||
|
||||
export async function updateFromCommandLine(): Promise<void> {
|
||||
try {
|
||||
const state = await checkDesktopUpdateNow();
|
||||
if (!state.available) {
|
||||
logger.info('The command line asked for an update and none is waiting');
|
||||
return;
|
||||
}
|
||||
logger.info('The command line asked for an update, starting it', {started: startDesktopUpdate()});
|
||||
} catch (error) {
|
||||
logger.warn('The command line asked for an update and the check failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDesktopUpdateFailure(failure: DesktopUpdateFailure): Promise<void> {
|
||||
failurePrompt ??= (async () => {
|
||||
logger.warn('Telling the user the desktop update did not finish', failure);
|
||||
const stillDownloading = failure.reason === 'timed-out';
|
||||
const choice = await ask({
|
||||
type: 'warning',
|
||||
title: DESKTOP_APP_NAME,
|
||||
message: t('desktop.update.failedMessage'),
|
||||
detail: stillDownloading ? t('desktop.update.stillDownloadingDetail') : t('desktop.update.failedDetail'),
|
||||
buttons: [t('desktop.appLoad.retry'), t('desktop.update.download'), t('desktop.update.later')],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (choice === 0) {
|
||||
startDesktopUpdateFromShell();
|
||||
} else if (choice === 1) {
|
||||
await openExternalDeduped(DOWNLOAD_PAGE_URL);
|
||||
}
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
logger.error('The update failure prompt failed', error);
|
||||
})
|
||||
.finally(() => {
|
||||
failurePrompt = null;
|
||||
});
|
||||
return failurePrompt;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ function harness({
|
||||
} = {}) {
|
||||
const steps = [];
|
||||
const published = [];
|
||||
const failures = [];
|
||||
let probeIndex = 0;
|
||||
let shellIndex = 0;
|
||||
const quiet = () => undefined;
|
||||
@@ -55,6 +56,9 @@ function harness({
|
||||
reopen: (launchAttempt) => {
|
||||
steps.push(launchAttempt == null ? 'reopen' : 'reopen-updated');
|
||||
},
|
||||
reloadInPlace: async () => {
|
||||
steps.push('reload-in-place');
|
||||
},
|
||||
},
|
||||
publish: (check) => {
|
||||
published.push(check);
|
||||
@@ -62,10 +66,13 @@ function harness({
|
||||
openDownloadsPage: async () => {
|
||||
steps.push('downloads-page');
|
||||
},
|
||||
reportFailure: (failure) => {
|
||||
failures.push(failure);
|
||||
},
|
||||
logger: {info: quiet, warn: quiet, error: quiet},
|
||||
});
|
||||
if (settled) run.markLaunchSettled();
|
||||
return {run, steps, published};
|
||||
return {run, steps, published, failures};
|
||||
}
|
||||
|
||||
describe('DesktopUpdateRun', () => {
|
||||
@@ -195,3 +202,119 @@ describe('DesktopUpdateRun', () => {
|
||||
assert.deepEqual(steps, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesktopUpdateRun telling the user when a click did not update', () => {
|
||||
const SHELL_AND_MODULES = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: true};
|
||||
|
||||
test('an update that installs reports nothing', async () => {
|
||||
const {run, failures} = harness();
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('a check that fails gives the windows back and says so', async () => {
|
||||
const {run, steps, failures} = harness({probes: [new Error('offline')]});
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe', 'takeover', 'restore']);
|
||||
assert.deepEqual(failures, [{reason: 'check-failed', detail: 'offline'}]);
|
||||
});
|
||||
|
||||
test('a shell update that does not finish is reported once the app is back', async () => {
|
||||
const {run, steps, failures} = harness({
|
||||
probes: [SHELL_AND_MODULES],
|
||||
shellResults: [{reason: 'timed-out', detail: 'the update did not finish'}],
|
||||
});
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.equal(steps.at(-1), 'reopen-updated');
|
||||
assert.deepEqual(failures, [{reason: 'timed-out', detail: 'the update did not finish'}]);
|
||||
});
|
||||
|
||||
test('a feed that has no newer shell after all is not a failure', async () => {
|
||||
const {run, failures} = harness({
|
||||
probes: [SHELL_AND_MODULES],
|
||||
shellResults: [{reason: 'no-update', detail: null}],
|
||||
});
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('a module install that throws is reported with its cause', async () => {
|
||||
const {run, steps, failures} = harness({installFails: true});
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.equal(steps.at(-1), 'reopen');
|
||||
assert.deepEqual(failures, [{reason: 'download-failed', detail: 'disk full'}]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesktopUpdateRun installing a renderer only update in place', () => {
|
||||
const MODULES_ONLY = {shellLatestVersion: '2026.1008.2', shellNewer: false, modulesChanged: true};
|
||||
const SHELL_TOO = {shellLatestVersion: '2026.1008.3', shellNewer: true, modulesChanged: true};
|
||||
|
||||
test('a click after the background check found only modules never shows the splash or closes a window', async () => {
|
||||
const {run, steps, published, failures} = harness({probes: [MODULES_ONLY]});
|
||||
await run.check();
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe', 'probe', 'install', 'reload-in-place']);
|
||||
assert.deepEqual(published, [{shellNewer: false, modulesChanged: false}]);
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('a shell that turned newer since the background check still updates behind the splash', async () => {
|
||||
const {run, steps} = harness({
|
||||
probes: [MODULES_ONLY, SHELL_TOO],
|
||||
shellResults: [{reason: 'no-update', detail: null}],
|
||||
});
|
||||
await run.check();
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe', 'probe', 'takeover', 'close', 'shell-update', 'install', 'reopen-updated']);
|
||||
});
|
||||
|
||||
test('a download that fails leaves the running app alone and says so', async () => {
|
||||
const {run, steps, published, failures} = harness({probes: [MODULES_ONLY], installFails: true});
|
||||
await run.check();
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe', 'probe', 'install']);
|
||||
assert.deepEqual(published, [{shellNewer: false, modulesChanged: true}]);
|
||||
assert.deepEqual(failures, [{reason: 'download-failed', detail: 'disk full'}]);
|
||||
});
|
||||
|
||||
test('a click while offline changes nothing on screen', async () => {
|
||||
const {run, steps, failures} = harness({probes: [MODULES_ONLY, new Error('offline')]});
|
||||
await run.check();
|
||||
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe', 'probe']);
|
||||
assert.deepEqual(failures, [{reason: 'check-failed', detail: 'offline'}]);
|
||||
});
|
||||
|
||||
test('a check works from the first window, a click only after the renderer confirmed its launch', async () => {
|
||||
const {run, steps} = harness({probes: [MODULES_ONLY], settled: false});
|
||||
|
||||
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: true});
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe']);
|
||||
|
||||
run.markLaunchSettled();
|
||||
await run.start();
|
||||
|
||||
assert.deepEqual(steps, ['probe', 'probe', 'install', 'reload-in-place']);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export interface DesktopUpdateTakeover {
|
||||
readonly restore: () => void;
|
||||
readonly closeApp: () => Promise<void>;
|
||||
readonly reopen: (launchAttempt: ModuleLaunchAttempt | null) => void;
|
||||
readonly reloadInPlace: (launchAttempt: ModuleLaunchAttempt) => Promise<void>;
|
||||
}
|
||||
|
||||
interface DesktopUpdateLogger {
|
||||
@@ -35,6 +36,7 @@ interface DesktopUpdateRunOptions {
|
||||
readonly takeover: DesktopUpdateTakeover;
|
||||
readonly publish: (check: DesktopUpdateCheck) => void;
|
||||
readonly openDownloadsPage: () => Promise<void>;
|
||||
readonly reportFailure?: (failure: ShellSelfUpdateResult) => void;
|
||||
readonly logger: DesktopUpdateLogger;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
@@ -44,12 +46,17 @@ interface FilteredProbe {
|
||||
readonly shellLatestVersion: string;
|
||||
}
|
||||
|
||||
function describeFailure(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export class DesktopUpdateRun {
|
||||
private readonly options: DesktopUpdateRunOptions;
|
||||
private readonly now: () => number;
|
||||
private launchSettled = false;
|
||||
private shellFeedBehindVersion: string | null = null;
|
||||
private shellInstallFailed = false;
|
||||
private lastShellFailure: ShellSelfUpdateResult | null = null;
|
||||
private lastCheck: DesktopUpdateCheck | null = null;
|
||||
|
||||
public constructor(options: DesktopUpdateRunOptions) {
|
||||
@@ -66,7 +73,7 @@ export class DesktopUpdateRun {
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
const {logger, takeover} = this.options;
|
||||
const {logger} = this.options;
|
||||
const startedAt = this.now();
|
||||
if (!this.launchSettled) {
|
||||
logger.warn('Ignoring a desktop update start before the renderer confirmed its launch');
|
||||
@@ -77,9 +84,58 @@ export class DesktopUpdateRun {
|
||||
await this.options.openDownloadsPage();
|
||||
return;
|
||||
}
|
||||
if (this.lastCheck != null && !this.lastCheck.shellNewer && this.lastCheck.modulesChanged) {
|
||||
await this.startInPlace(startedAt);
|
||||
return;
|
||||
}
|
||||
logger.info('Starting the desktop update');
|
||||
const probing = this.probe();
|
||||
probing.catch(() => {});
|
||||
await this.startWithTakeover(startedAt, probing);
|
||||
}
|
||||
|
||||
private async startInPlace(startedAt: number): Promise<void> {
|
||||
const {logger, takeover} = this.options;
|
||||
logger.info('Starting the module update in place, the shell stays as it is');
|
||||
let probe: FilteredProbe;
|
||||
try {
|
||||
probe = await this.probe();
|
||||
} catch (error) {
|
||||
logger.warn('The desktop update check failed, leaving the app as it is', error);
|
||||
this.options.reportFailure?.({reason: 'check-failed', detail: describeFailure(error)});
|
||||
return;
|
||||
}
|
||||
const {check} = probe;
|
||||
if (check.shellNewer) {
|
||||
logger.info('A newer shell appeared since the last check, updating it behind the splash');
|
||||
await this.startWithTakeover(startedAt, Promise.resolve(probe));
|
||||
return;
|
||||
}
|
||||
if (!check.modulesChanged) {
|
||||
this.publish(check);
|
||||
return;
|
||||
}
|
||||
let launchAttempt: ModuleLaunchAttempt | null;
|
||||
try {
|
||||
launchAttempt = await this.options.installModules();
|
||||
} catch (error) {
|
||||
logger.error('The module update failed, the app keeps running on the installed modules', error);
|
||||
this.publish(check);
|
||||
this.options.reportFailure?.({reason: 'download-failed', detail: describeFailure(error)});
|
||||
return;
|
||||
}
|
||||
this.publish({shellNewer: false, modulesChanged: false});
|
||||
logger.info('Installed the module update in place', {
|
||||
activated: launchAttempt != null,
|
||||
elapsedMs: this.now() - startedAt,
|
||||
});
|
||||
if (launchAttempt != null) {
|
||||
await takeover.reloadInPlace(launchAttempt);
|
||||
}
|
||||
}
|
||||
|
||||
private async startWithTakeover(startedAt: number, probing: Promise<FilteredProbe>): Promise<void> {
|
||||
const {logger, takeover} = this.options;
|
||||
await takeover.begin();
|
||||
logger.info('The update splash took the app windows over', {elapsedMs: this.now() - startedAt});
|
||||
let probe: FilteredProbe;
|
||||
@@ -88,6 +144,7 @@ export class DesktopUpdateRun {
|
||||
} catch (error) {
|
||||
logger.warn('The desktop update check failed, giving the app windows back', error);
|
||||
takeover.restore();
|
||||
this.options.reportFailure?.({reason: 'check-failed', detail: describeFailure(error)});
|
||||
return;
|
||||
}
|
||||
const {check} = probe;
|
||||
@@ -101,9 +158,11 @@ export class DesktopUpdateRun {
|
||||
logger.info('Closed the app windows for the update', {elapsedMs: this.now() - startedAt});
|
||||
let launchAttempt: ModuleLaunchAttempt | null = null;
|
||||
let remaining = check;
|
||||
let failure: ShellSelfUpdateResult | null = null;
|
||||
try {
|
||||
if (check.shellNewer) {
|
||||
remaining = await this.updateShell(probe);
|
||||
failure = this.lastShellFailure;
|
||||
}
|
||||
if (check.modulesChanged) {
|
||||
launchAttempt = await this.options.installModules();
|
||||
@@ -115,16 +174,21 @@ export class DesktopUpdateRun {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('The desktop update failed, reopening the app on the installed modules', error);
|
||||
failure = {reason: 'download-failed', detail: describeFailure(error)};
|
||||
} finally {
|
||||
this.publish(remaining);
|
||||
takeover.reopen(launchAttempt);
|
||||
}
|
||||
if (failure != null) {
|
||||
this.options.reportFailure?.(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateShell(probe: FilteredProbe): Promise<DesktopUpdateCheck> {
|
||||
const {logger} = this.options;
|
||||
const result = await this.options.runShellSelfUpdate();
|
||||
logger.warn('The shell self update did not restart the app', result);
|
||||
this.lastShellFailure = result.reason === 'no-update' ? null : result;
|
||||
if (result.reason === 'no-update') {
|
||||
this.shellFeedBehindVersion = probe.shellLatestVersion;
|
||||
return {shellNewer: false, modulesChanged: probe.check.modulesChanged};
|
||||
|
||||
@@ -23,6 +23,7 @@ const DISABLE_SPELLCHECK_ARGS = new Set(['--fluxer-disable-spellcheck', '--fluxe
|
||||
const SPELLCHECK_MODE_ARGS = new Set(['--fluxer-spellcheck']);
|
||||
const DISABLE_V8_CODE_CACHE_ARGS = new Set(['--fluxer-disable-v8-code-cache']);
|
||||
const DISABLE_NATIVE_GATEWAY_ARGS = new Set(['--fluxer-disable-native-gateway']);
|
||||
const DESKTOP_UPDATE_ARGS = new Set(['--fluxer-update']);
|
||||
|
||||
function hasFlag(argv: ReadonlyArray<string>, flags: ReadonlySet<string>): boolean {
|
||||
return argv.some((arg) => flags.has(arg) || [...flags].some((flag) => arg.startsWith(`${flag}=`)));
|
||||
@@ -160,3 +161,7 @@ export function describeLaunchDiagnosticOptions(argv: ReadonlyArray<string> = pr
|
||||
nativeGatewayDisableReason: getNativeGatewayDisableReason(argv),
|
||||
};
|
||||
}
|
||||
|
||||
export function isDesktopUpdateRequested(argv: ReadonlyArray<string> = process.argv): boolean {
|
||||
return hasFlag(argv, DESKTOP_UPDATE_ARGS);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {Buffer} from 'node:buffer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {Readable} from 'node:stream';
|
||||
import {resolveAsarUnpackedPath} from '@electron/common/ModuleSystem';
|
||||
import {isServedLocalAppExtension, localAppCacheControl, localAppContentType} from '@electron/main/LocalAppMime';
|
||||
import {isLocalAppURL} from '@electron/main/LocalAppURL';
|
||||
import {app} from 'electron';
|
||||
@@ -429,7 +430,10 @@ export class DesktopLocalAppFiles {
|
||||
}
|
||||
|
||||
private rendererRootPath(): string {
|
||||
return this.configuredRendererRoot ?? path.join(app.getAppPath(), ...RENDERER_DIRECTORY_SEGMENTS);
|
||||
return (
|
||||
this.configuredRendererRoot ??
|
||||
resolveAsarUnpackedPath(path.join(app.getAppPath(), ...RENDERER_DIRECTORY_SEGMENTS))
|
||||
);
|
||||
}
|
||||
|
||||
private resolveRendererRoot(): Promise<string | null> {
|
||||
|
||||
@@ -69,6 +69,24 @@ const FALLBACK_STRINGS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'desktop.tray.disconnectFrom': 'Disconnect from {channel}',
|
||||
'desktop.tray.disconnectVoice': 'Disconnect from voice',
|
||||
'desktop.tray.checkForUpdates': 'Check for updates',
|
||||
'desktop.tray.updateNow': 'Update {appName} now',
|
||||
'desktop.appMenu.checkForUpdates': 'Check for updates...',
|
||||
'desktop.update.availableMessage': 'An update for {appName} is available.',
|
||||
'desktop.update.availableDetail': '{appName} will close, install the update and open again.',
|
||||
'desktop.update.reloadDetail': '{appName} will reload to finish updating.',
|
||||
'desktop.update.install': 'Update now',
|
||||
'desktop.update.later': 'Later',
|
||||
'desktop.update.ok': 'OK',
|
||||
'desktop.update.download': 'Download',
|
||||
'desktop.update.manualMessage': '{appName} {version} is available.',
|
||||
'desktop.update.manualDetail': 'Download the new version to update {appName}.',
|
||||
'desktop.update.upToDateMessage': '{appName} is up to date.',
|
||||
'desktop.update.upToDateDetail': 'You have version {version}.',
|
||||
'desktop.update.checkFailedMessage': "{appName} couldn't check for updates.",
|
||||
'desktop.update.checkFailedDetail': 'Check your internet connection and try again.',
|
||||
'desktop.update.failedMessage': "{appName} couldn't finish updating.",
|
||||
'desktop.update.failedDetail': 'The update did not download or install. Try again or download it yourself.',
|
||||
'desktop.update.stillDownloadingDetail': 'The update is still downloading. Try again in a few minutes.',
|
||||
'desktop.tray.copyBuildInfo': 'Copy build info',
|
||||
'desktop.tray.restart': 'Restart {appName}',
|
||||
'desktop.tray.quit': 'Quit {appName}',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity';
|
||||
import {checkForUpdatesFromShell} from '@electron/main/DesktopUpdatePrompt';
|
||||
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
||||
import {openExternalDeduped} from '@electron/main/OpenExternal';
|
||||
import {buildTroubleshootingMenuItems} from '@electron/main/Troubleshooting';
|
||||
@@ -9,6 +10,15 @@ import {type BaseWindow, BrowserWindow, Menu, type MenuItem, type MenuItemConstr
|
||||
|
||||
const MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT = '\u200C';
|
||||
|
||||
function checkForUpdatesItem(): MenuItemConstructorOptions {
|
||||
return {
|
||||
label: t('desktop.appMenu.checkForUpdates'),
|
||||
click: () => {
|
||||
void checkForUpdatesFromShell();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplate(): Array<MenuItemConstructorOptions> {
|
||||
const appName = DESKTOP_APP_NAME;
|
||||
const isMac = process.platform === 'darwin';
|
||||
@@ -21,6 +31,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
|
||||
role: 'about',
|
||||
label: t('desktop.appMenu.about', {appName}),
|
||||
},
|
||||
checkForUpdatesItem(),
|
||||
{type: 'separator'},
|
||||
{
|
||||
label: t('desktop.appMenu.preferences'),
|
||||
@@ -198,6 +209,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
|
||||
? `${t('desktop.appMenu.help')}${MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT}`
|
||||
: t('desktop.appMenu.help'),
|
||||
submenu: [
|
||||
...(isMac ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
|
||||
{
|
||||
label: t('desktop.appMenu.website'),
|
||||
click: async () => {
|
||||
|
||||
@@ -1227,3 +1227,103 @@ describe('ModuleStore boot checks without rehashing', () => {
|
||||
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleStore beside a renderer bundled in the shell', () => {
|
||||
test('an unreadable state file is set aside and the store starts clean', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
const statePath = path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME);
|
||||
for (const contents of ['{not json', JSON.stringify({state_version: 1})]) {
|
||||
writeFileSync(statePath, contents);
|
||||
|
||||
const recovered = await openStore(userDataPath);
|
||||
|
||||
assert.equal(recovered.recoveredUnreadableState, `${statePath}.unreadable`);
|
||||
assert.equal(readFileSync(`${statePath}.unreadable`, 'utf8'), contents);
|
||||
assert.deepEqual(recovered.getCommitted(), {});
|
||||
assert.equal(readState(userDataPath).state_version, MODULE_STATE_VERSION);
|
||||
}
|
||||
assert.equal(store.recoveredUnreadableState, null);
|
||||
});
|
||||
|
||||
test('retiring a module drops it from the committed and previous sets so it can be collected', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
const first = await install(store, RENDERER_V1);
|
||||
const second = await install(store, RENDERER_V2);
|
||||
await install(store, GRAMMARS_V1);
|
||||
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
await store.commit({fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
|
||||
assert.equal(await store.retireModule('fluxer_renderer'), true);
|
||||
assert.equal(await store.retireModule('fluxer_renderer'), false);
|
||||
|
||||
assert.deepEqual(readState(userDataPath).committed, {fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
assert.deepEqual(readState(userDataPath).previous, {fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
await markLaunched(store);
|
||||
await store.collectGarbage();
|
||||
assert.equal(existsSync(first.directory), false);
|
||||
assert.equal(existsSync(second.directory), false);
|
||||
assert.equal(await store.isInstalled('fluxer_grammars', GRAMMARS_V1.sha256), true);
|
||||
});
|
||||
|
||||
test('two failed boots of a first renderer module fall back to the bundle instead of looping', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
await install(store, RENDERER_V1);
|
||||
await install(store, GRAMMARS_V1);
|
||||
await store.commit({fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V1.sha256});
|
||||
await store.commit({fluxer_renderer: RENDERER_V1.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
const state = readState(userDataPath);
|
||||
writeFileSync(
|
||||
path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME),
|
||||
JSON.stringify({...state, previous: {}, boot_attempt: 2}),
|
||||
);
|
||||
const reopened = await openStore(userDataPath);
|
||||
|
||||
const withoutBundle = await reopened.beginBootAttempt();
|
||||
assert.equal(withoutBundle.rolledBack, false);
|
||||
writeFileSync(
|
||||
path.join(getModuleStoreRoot(userDataPath), MODULE_STATE_FILE_NAME),
|
||||
JSON.stringify({...readState(userDataPath), boot_attempt: 2}),
|
||||
);
|
||||
const bundled = await openStore(userDataPath);
|
||||
|
||||
const attempt = await bundled.beginBootAttempt({bundled: ['fluxer_renderer']});
|
||||
|
||||
assert.equal(attempt.rolledBack, true);
|
||||
assert.deepEqual(attempt.committed, {fluxer_grammars: GRAMMARS_V1.sha256});
|
||||
assert.equal(bundled.isRejected('fluxer_renderer', RENDERER_V1.sha256), true);
|
||||
assert.equal(readState(userDataPath).boot_attempt, 0);
|
||||
});
|
||||
|
||||
test('a renderer update that never confirms is reverted and rejected', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
await install(store, RENDERER_V1);
|
||||
await install(store, RENDERER_V2);
|
||||
await install(store, GRAMMARS_V1);
|
||||
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
|
||||
const attempt = await store.activateMergedForRendererReload({
|
||||
fluxer_renderer: RENDERER_V2.sha256,
|
||||
fluxer_grammars: GRAMMARS_V1.sha256,
|
||||
});
|
||||
|
||||
assert.deepEqual(await store.revertLaunchAttempt(attempt, ['fluxer_renderer']), {
|
||||
fluxer_renderer: RENDERER_V1.sha256,
|
||||
fluxer_grammars: GRAMMARS_V1.sha256,
|
||||
});
|
||||
assert.equal(store.isRejected('fluxer_renderer', RENDERER_V2.sha256), true);
|
||||
assert.equal(store.isRejected('fluxer_grammars', GRAMMARS_V1.sha256), false);
|
||||
assert.equal(await store.revertLaunchAttempt(attempt, ['fluxer_renderer']), null);
|
||||
|
||||
const first = await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V2.sha256});
|
||||
await store.revertLaunchAttempt(first, ['fluxer_renderer']);
|
||||
await store.retireModule('fluxer_renderer');
|
||||
const onBundle = await store.activateMergedForRendererReload({fluxer_renderer: RENDERER_V1.sha256});
|
||||
assert.deepEqual(await store.revertLaunchAttempt(onBundle, ['fluxer_renderer']), {
|
||||
fluxer_grammars: GRAMMARS_V1.sha256,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {isDesktopModuleName} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
const MODULE_STORE_DIRECTORY_NAME = 'modules';
|
||||
export const MODULE_STATE_FILE_NAME = 'state.json';
|
||||
export const MODULE_STATE_VERSION = 3;
|
||||
const MODULE_UNREADABLE_STATE_SUFFIX = '.unreadable';
|
||||
const MODULE_DOWNLOAD_DIRECTORY_NAME = 'download';
|
||||
const MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME = 'incoming';
|
||||
const MODULE_STORE_TREE_DIRECTORY_NAME = 'store';
|
||||
@@ -1179,6 +1180,7 @@ export class ModuleStore {
|
||||
public readonly storeRoot: string;
|
||||
private state: ModuleStoreState;
|
||||
private convergingShellVersion: string | null = null;
|
||||
private unreadableStatePath: string | null = null;
|
||||
private launchSucceeded = false;
|
||||
private activeLaunchAttempt: ModuleLaunchAttempt | null = null;
|
||||
private readonly installCoordinator = new ModuleInstallCoordinator<ModuleInstallation>();
|
||||
@@ -1211,9 +1213,26 @@ export class ModuleStore {
|
||||
await createDirectory(resolved);
|
||||
await createDirectory(path.join(resolved, MODULE_DOWNLOAD_DIRECTORY_NAME, MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME));
|
||||
await createDirectory(path.join(resolved, MODULE_STORE_TREE_DIRECTORY_NAME));
|
||||
const stateFile = await ModuleStore.readState(path.join(resolved, MODULE_STATE_FILE_NAME));
|
||||
const statePath = path.join(resolved, MODULE_STATE_FILE_NAME);
|
||||
let stateFile: ModuleStoreStateFile;
|
||||
let unreadableState: string | null = null;
|
||||
try {
|
||||
stateFile = await ModuleStore.readState(statePath);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof ModuleStoreStateCorruptError) &&
|
||||
!(error instanceof ModuleStoreStateUnsupportedVersionError)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
unreadableState = `${statePath}${MODULE_UNREADABLE_STATE_SUFFIX}`;
|
||||
await removeTree(unreadableState);
|
||||
await renameWithRetry(statePath, unreadableState);
|
||||
stateFile = {status: ModuleStoreStateFileStatus.MISSING};
|
||||
}
|
||||
if (stateFile.status === ModuleStoreStateFileStatus.MISSING) {
|
||||
const store = new ModuleStore(resolved, createInitialModuleStoreState(shellVersion, releaseChannel));
|
||||
store.unreadableStatePath = unreadableState;
|
||||
await store.writeState(store.state);
|
||||
return store;
|
||||
}
|
||||
@@ -1269,6 +1288,10 @@ export class ModuleStore {
|
||||
}
|
||||
}
|
||||
|
||||
public get recoveredUnreadableState(): string | null {
|
||||
return this.unreadableStatePath;
|
||||
}
|
||||
|
||||
public get shellVersionChanged(): boolean {
|
||||
return this.convergingShellVersion != null;
|
||||
}
|
||||
@@ -1500,6 +1523,21 @@ export class ModuleStore {
|
||||
});
|
||||
}
|
||||
|
||||
public async retireModule(moduleName: string): Promise<boolean> {
|
||||
assertModuleName(moduleName);
|
||||
return await this.withStateLock(async () => {
|
||||
if (this.state.committed[moduleName] === undefined && this.state.previous[moduleName] === undefined) {
|
||||
return false;
|
||||
}
|
||||
const committed = {...this.state.committed};
|
||||
const previous = {...this.state.previous};
|
||||
delete committed[moduleName];
|
||||
delete previous[moduleName];
|
||||
await this.writeState({...this.state, committed, previous});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public async mergeCommitted(entries: Readonly<Record<string, string>>): Promise<ModuleStoreState> {
|
||||
return await this.withStateLock(async () => {
|
||||
const added = await this.resolveInstalled(entries);
|
||||
@@ -1588,13 +1626,20 @@ export class ModuleStore {
|
||||
});
|
||||
}
|
||||
|
||||
public async beginBootAttempt(): Promise<ModuleBootAttempt> {
|
||||
public async beginBootAttempt({
|
||||
bundled = [],
|
||||
}: {
|
||||
readonly bundled?: ReadonlyArray<string>;
|
||||
} = {}): Promise<ModuleBootAttempt> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (this.state.boot_attempt < MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) {
|
||||
return {rolledBack: false, bootAttempt: this.state.boot_attempt, committed: this.state.committed};
|
||||
}
|
||||
const revertedCommitted =
|
||||
const previousCommitted =
|
||||
Object.keys(this.state.previous).length === 0 ? this.state.committed : sortModuleMap(this.state.previous);
|
||||
const revertedCommitted = sameModuleMap(this.state.committed, previousCommitted)
|
||||
? Object.fromEntries(Object.entries(previousCommitted).filter(([moduleName]) => !bundled.includes(moduleName)))
|
||||
: previousCommitted;
|
||||
const rolledBack = !sameModuleMap(this.state.committed, revertedCommitted);
|
||||
const rejected: Record<string, string> = {...this.state.rejected};
|
||||
if (rolledBack) {
|
||||
@@ -1607,6 +1652,7 @@ export class ModuleStore {
|
||||
const reverted = await this.writeState({
|
||||
...this.state,
|
||||
committed: revertedCommitted,
|
||||
previous: rolledBack ? revertedCommitted : this.state.previous,
|
||||
rejected,
|
||||
boot_attempt: 0,
|
||||
});
|
||||
@@ -1614,6 +1660,41 @@ export class ModuleStore {
|
||||
});
|
||||
}
|
||||
|
||||
public async revertLaunchAttempt(
|
||||
attempt: ModuleLaunchAttempt,
|
||||
bundled: ReadonlyArray<string> = [],
|
||||
): Promise<Readonly<Record<string, string>> | null> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (this.activeLaunchAttempt !== attempt) {
|
||||
return null;
|
||||
}
|
||||
const rejected: Record<string, string> = {...this.state.rejected};
|
||||
const committed: Record<string, string> = {};
|
||||
for (const [moduleName, sha256] of Object.entries(this.state.previous)) {
|
||||
if (await this.isInstalled(moduleName, sha256)) {
|
||||
committed[moduleName] = sha256;
|
||||
}
|
||||
}
|
||||
for (const [moduleName, sha256] of Object.entries(this.state.committed)) {
|
||||
if (committed[moduleName] === sha256) continue;
|
||||
rejected[moduleName] = sha256;
|
||||
if (committed[moduleName] === undefined && !bundled.includes(moduleName)) {
|
||||
committed[moduleName] = sha256;
|
||||
delete rejected[moduleName];
|
||||
}
|
||||
}
|
||||
this.activeLaunchAttempt = null;
|
||||
const reverted = await this.writeState({
|
||||
...this.state,
|
||||
committed,
|
||||
previous: committed,
|
||||
rejected,
|
||||
boot_attempt: 0,
|
||||
});
|
||||
return reverted.committed;
|
||||
});
|
||||
}
|
||||
|
||||
public async recordLaunchAttempt(): Promise<ModuleLaunchAttempt> {
|
||||
return await this.withStateLock(async () => {
|
||||
const next = await this.writeState({...this.state, boot_attempt: this.state.boot_attempt + 1});
|
||||
|
||||
@@ -80,8 +80,24 @@ function createStore({
|
||||
};
|
||||
}
|
||||
|
||||
function createPlanner(store, {shellVersion = SHELL_VERSION, hasOfflineRenderer = false} = {}) {
|
||||
return new ModuleUpdatePlanner(store, parseModuleVersion(shellVersion, 'desktop shell version'), hasOfflineRenderer);
|
||||
function createPlanner(
|
||||
store,
|
||||
{shellVersion = SHELL_VERSION, hasOfflineRenderer = false, bundledRendererVersion = null} = {},
|
||||
) {
|
||||
return new ModuleUpdatePlanner(
|
||||
store,
|
||||
parseModuleVersion(shellVersion, 'desktop shell version'),
|
||||
hasOfflineRenderer,
|
||||
bundledRendererVersion == null ? null : parseModuleVersion(bundledRendererVersion, 'bundled renderer version'),
|
||||
);
|
||||
}
|
||||
|
||||
function bundledPlanner(store, bundledRendererVersion = SHELL_VERSION) {
|
||||
return createPlanner(store, {hasOfflineRenderer: true, bundledRendererVersion});
|
||||
}
|
||||
|
||||
function installedRenderer(sha256, buildVersion) {
|
||||
return {[`fluxer_renderer:${sha256}`]: {build_version: buildVersion}};
|
||||
}
|
||||
|
||||
describe('ModuleUpdatePlanner.isShellCompatible', () => {
|
||||
@@ -329,3 +345,114 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleUpdatePlanner with a renderer bundled in the shell', () => {
|
||||
const OLDER = '2026.822.9';
|
||||
const NEWER = '2026.824.1';
|
||||
|
||||
test('a feed renderer no newer than the bundle is never planned or pending', async () => {
|
||||
const planner = bundledPlanner(createStore());
|
||||
const feed = manifest({modules: {fluxer_renderer: {sha256: RENDERER_SHA}}});
|
||||
|
||||
assert.equal(planner.bundleCoversModule('fluxer_renderer', feed), true);
|
||||
assert.deepEqual(await planner.plan(feed), {items: [], base: {}});
|
||||
});
|
||||
|
||||
test('a feed renderer newer than the bundle is downloaded but never required to launch', async () => {
|
||||
const planner = bundledPlanner(createStore(), OLDER);
|
||||
const feed = manifest({modules: {fluxer_renderer: {sha256: RENDERER_SHA}}});
|
||||
|
||||
assert.equal(planner.bundleCoversModule('fluxer_renderer', feed), false);
|
||||
const plan = await planner.plan(feed);
|
||||
assert.deepEqual(
|
||||
plan.items.map((item) => [item.module, item.requirement]),
|
||||
[['fluxer_renderer', 'optional']],
|
||||
);
|
||||
});
|
||||
|
||||
test('the bundle only ever stands in for the renderer module', async () => {
|
||||
const planner = bundledPlanner(createStore());
|
||||
const feed = manifest({modules: {fluxer_overlay: {sha256: OVERLAY_SHA}}});
|
||||
|
||||
assert.equal(planner.bundleCoversModule('fluxer_overlay', feed), false);
|
||||
assert.equal((await planner.plan(feed)).items.length, 1);
|
||||
});
|
||||
|
||||
test('a committed renderer older than or equal to the bundle is stale, a newer one is not', async () => {
|
||||
for (const [installedVersion, stale] of [
|
||||
[OLDER, true],
|
||||
[SHELL_VERSION, true],
|
||||
[NEWER, false],
|
||||
]) {
|
||||
const planner = bundledPlanner(
|
||||
createStore({
|
||||
committed: {fluxer_renderer: RENDERER_SHA},
|
||||
installedManifests: installedRenderer(RENDERER_SHA, installedVersion),
|
||||
}),
|
||||
);
|
||||
assert.equal(await planner.staleRendererModule(), stale ? RENDERER_SHA : null, installedVersion);
|
||||
}
|
||||
});
|
||||
|
||||
test('a committed renderer whose installation is unreadable is stale', async () => {
|
||||
const planner = bundledPlanner(createStore({committed: {fluxer_renderer: RENDERER_SHA}}));
|
||||
|
||||
assert.equal(await planner.staleRendererModule(), RENDERER_SHA);
|
||||
});
|
||||
|
||||
test('without a bundle no committed renderer is ever stale', async () => {
|
||||
const planner = createPlanner(
|
||||
createStore({
|
||||
committed: {fluxer_renderer: RENDERER_SHA},
|
||||
installedManifests: installedRenderer(RENDERER_SHA, OLDER),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(await planner.staleRendererModule(), null);
|
||||
});
|
||||
|
||||
test('serving drops an older renderer module and keeps every other module', async () => {
|
||||
const planner = bundledPlanner(createStore({installedManifests: installedRenderer(RENDERER_SHA, OLDER)}));
|
||||
|
||||
assert.deepEqual(await planner.selectServedModules({fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA}), {
|
||||
modules: {fluxer_overlay: OVERLAY_SHA},
|
||||
renderer: {source: 'bundled', version: SHELL_VERSION, bundledVersion: SHELL_VERSION},
|
||||
});
|
||||
});
|
||||
|
||||
test('serving keeps a renderer module newer than the bundle', async () => {
|
||||
const planner = bundledPlanner(createStore({installedManifests: installedRenderer(RENDERER_SHA, NEWER)}));
|
||||
const modules = {fluxer_renderer: RENDERER_SHA};
|
||||
|
||||
assert.deepEqual(await planner.selectServedModules(modules), {
|
||||
modules,
|
||||
renderer: {source: 'module', version: NEWER, bundledVersion: SHELL_VERSION},
|
||||
});
|
||||
});
|
||||
|
||||
test('a renderer floor never holds the launch of a shell that bundles its renderer', async () => {
|
||||
const planner = bundledPlanner(createStore({floor: {fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA}}));
|
||||
|
||||
assert.deepEqual(await planner.modulesBelowFloor(true), ['fluxer_overlay']);
|
||||
});
|
||||
|
||||
test('a fresh install that never reached the feed launches the bundled renderer', async () => {
|
||||
const planner = bundledPlanner(createStore({lastManifestFetch: null}));
|
||||
|
||||
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
|
||||
kind: ModuleUnreachableLaunchDecision.LAUNCH,
|
||||
});
|
||||
});
|
||||
|
||||
test('the bundle meets a Linux security minimum it is at least as new as', async () => {
|
||||
const planner = bundledPlanner(createStore());
|
||||
|
||||
assert.equal(
|
||||
await planner.securityUpdateRequired({
|
||||
version: parseModuleVersion(SHELL_VERSION, 'minimum'),
|
||||
requiredModules: ['fluxer_renderer'],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@electron/main/ModuleManifest';
|
||||
import {MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD, type ModuleStore} from '@electron/main/ModuleStore';
|
||||
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||
import {DESKTOP_RENDERER_MODULE_NAME} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
|
||||
export const ModuleUpdaterBlockReason = Object.freeze({
|
||||
REQUIRED_MODULE_UNAVAILABLE: 'required-module-unavailable',
|
||||
@@ -46,21 +47,136 @@ export type ModuleUnreachableLaunchDecision =
|
||||
| {readonly kind: typeof ModuleUnreachableLaunchDecision.LAUNCH}
|
||||
| {readonly kind: typeof ModuleUnreachableLaunchDecision.BLOCK; readonly reason: ModuleUpdaterBlockReason};
|
||||
|
||||
export const ServedRendererSource = Object.freeze({
|
||||
BUNDLED: 'bundled',
|
||||
MODULE: 'module',
|
||||
NONE: 'none',
|
||||
} as const);
|
||||
|
||||
export type ServedRendererSource = (typeof ServedRendererSource)[keyof typeof ServedRendererSource];
|
||||
|
||||
export interface ServedModuleSelection {
|
||||
readonly modules: Readonly<Record<string, string>>;
|
||||
readonly renderer: {
|
||||
readonly source: ServedRendererSource;
|
||||
readonly version: string | null;
|
||||
readonly bundledVersion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class ModuleUpdatePlanner {
|
||||
private readonly store: ModuleStore;
|
||||
private readonly shellVersion: ModuleVersion;
|
||||
private readonly hasOfflineRenderer: boolean;
|
||||
private readonly bundledRendererVersion: ModuleVersion | null;
|
||||
private readonly bundleOutranksEveryModule: boolean;
|
||||
|
||||
public constructor(store: ModuleStore, shellVersion: ModuleVersion, hasOfflineRenderer: boolean) {
|
||||
public constructor(
|
||||
store: ModuleStore,
|
||||
shellVersion: ModuleVersion,
|
||||
hasOfflineRenderer: boolean,
|
||||
bundledRendererVersion: ModuleVersion | null = null,
|
||||
preferUnversionedBundle = false,
|
||||
) {
|
||||
this.store = store;
|
||||
this.shellVersion = shellVersion;
|
||||
this.hasOfflineRenderer = hasOfflineRenderer;
|
||||
this.bundledRendererVersion = hasOfflineRenderer ? bundledRendererVersion : null;
|
||||
this.bundleOutranksEveryModule = hasOfflineRenderer && bundledRendererVersion == null && preferUnversionedBundle;
|
||||
}
|
||||
|
||||
public hasSomethingToRender(committed: Readonly<Record<string, string>>): boolean {
|
||||
return this.hasOfflineRenderer || Object.keys(committed).length > 0;
|
||||
}
|
||||
|
||||
public hasBundledRenderer(): boolean {
|
||||
return this.bundledRendererVersion != null || this.bundleOutranksEveryModule;
|
||||
}
|
||||
|
||||
public bundledModules(): ReadonlyArray<string> {
|
||||
return this.hasBundledRenderer() ? [DESKTOP_RENDERER_MODULE_NAME] : [];
|
||||
}
|
||||
|
||||
public bundleCoversRendererAt(version: ModuleVersion): boolean {
|
||||
if (this.bundleOutranksEveryModule) {
|
||||
return true;
|
||||
}
|
||||
return this.bundledRendererVersion != null && compareModuleVersions(version, this.bundledRendererVersion) <= 0;
|
||||
}
|
||||
|
||||
public async uninstalledCommittedModules(): Promise<ReadonlyArray<string>> {
|
||||
if (!this.hasBundledRenderer()) {
|
||||
return [];
|
||||
}
|
||||
const missing: Array<string> = [];
|
||||
for (const [moduleName, sha256] of Object.entries(this.store.getCommitted())) {
|
||||
if (!(await this.isInstalled(moduleName, sha256))) {
|
||||
missing.push(moduleName);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
public bundleCoversModule(moduleName: string, manifest: DesktopModuleUpdateManifest): boolean {
|
||||
return moduleName === DESKTOP_RENDERER_MODULE_NAME && this.bundleCoversRendererAt(manifest.buildVersion);
|
||||
}
|
||||
|
||||
private async installedRendererVersion(sha256: string): Promise<ModuleVersion | null> {
|
||||
const installed = await this.store.getInstalledManifest(DESKTOP_RENDERER_MODULE_NAME, sha256);
|
||||
if (installed == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseModuleVersion(installed.build_version, 'installed renderer build version');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async staleRendererModule(): Promise<string | null> {
|
||||
if (!this.hasBundledRenderer()) {
|
||||
return null;
|
||||
}
|
||||
const sha256 = this.store.getCommitted()[DESKTOP_RENDERER_MODULE_NAME];
|
||||
if (sha256 == null) {
|
||||
return null;
|
||||
}
|
||||
const installedVersion = await this.installedRendererVersion(sha256);
|
||||
return installedVersion == null || this.bundleCoversRendererAt(installedVersion) ? sha256 : null;
|
||||
}
|
||||
|
||||
public async selectServedModules(modules: Readonly<Record<string, string>>): Promise<ServedModuleSelection> {
|
||||
const bundledVersion = this.bundledRendererVersion?.source ?? null;
|
||||
const sha256 = modules[DESKTOP_RENDERER_MODULE_NAME];
|
||||
if (sha256 == null) {
|
||||
return {
|
||||
modules,
|
||||
renderer: {
|
||||
source: this.hasOfflineRenderer ? ServedRendererSource.BUNDLED : ServedRendererSource.NONE,
|
||||
version: bundledVersion,
|
||||
bundledVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
const installedVersion = await this.installedRendererVersion(sha256);
|
||||
if (this.hasBundledRenderer() && (installedVersion == null || this.bundleCoversRendererAt(installedVersion))) {
|
||||
const served = {...modules};
|
||||
delete served[DESKTOP_RENDERER_MODULE_NAME];
|
||||
return {
|
||||
modules: served,
|
||||
renderer: {source: ServedRendererSource.BUNDLED, version: bundledVersion, bundledVersion},
|
||||
};
|
||||
}
|
||||
return {
|
||||
modules,
|
||||
renderer: {
|
||||
source: ServedRendererSource.MODULE,
|
||||
version: installedVersion?.source ?? null,
|
||||
bundledVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public isShellCompatible(entry: DesktopModuleManifestEntry): boolean {
|
||||
if (entry.minimumShellVersion != null && compareModuleVersions(this.shellVersion, entry.minimumShellVersion) < 0) {
|
||||
return false;
|
||||
@@ -90,6 +206,9 @@ export class ModuleUpdatePlanner {
|
||||
}
|
||||
const committed = this.store.getCommitted();
|
||||
for (const moduleName of minimum.requiredModules) {
|
||||
if (moduleName === DESKTOP_RENDERER_MODULE_NAME && this.bundleCoversRendererAt(minimum.version)) {
|
||||
continue;
|
||||
}
|
||||
const sha256 = committed[moduleName];
|
||||
if (sha256 == null) {
|
||||
return true;
|
||||
@@ -124,7 +243,7 @@ export class ModuleUpdatePlanner {
|
||||
if (entry == null) {
|
||||
continue;
|
||||
}
|
||||
if (!this.isShellCompatible(entry)) {
|
||||
if (!this.isShellCompatible(entry) || this.bundleCoversModule(moduleName, manifest)) {
|
||||
await this.retainCommitted(base, moduleName);
|
||||
continue;
|
||||
}
|
||||
@@ -139,7 +258,10 @@ export class ModuleUpdatePlanner {
|
||||
items.push({
|
||||
module: moduleName,
|
||||
entry,
|
||||
requirement: blocking.has(moduleName) ? ModulePlanItemRequirement.REQUIRED : ModulePlanItemRequirement.OPTIONAL,
|
||||
requirement:
|
||||
blocking.has(moduleName) && !this.bundledModules().includes(moduleName)
|
||||
? ModulePlanItemRequirement.REQUIRED
|
||||
: ModulePlanItemRequirement.OPTIONAL,
|
||||
});
|
||||
}
|
||||
return {items, base};
|
||||
@@ -162,6 +284,9 @@ export class ModuleUpdatePlanner {
|
||||
if (state.rejected[moduleName] === sha256) {
|
||||
continue;
|
||||
}
|
||||
if (this.bundledModules().includes(moduleName)) {
|
||||
continue;
|
||||
}
|
||||
if (state.committed[moduleName] !== sha256 || !(await this.isInstalled(moduleName, sha256))) {
|
||||
below.push(moduleName);
|
||||
}
|
||||
@@ -187,7 +312,10 @@ export class ModuleUpdatePlanner {
|
||||
securityUpdateRequired: boolean,
|
||||
): Promise<ModuleUnreachableLaunchDecision> {
|
||||
const state = this.store.getState();
|
||||
if (state.last_manifest_fetch == null || !this.hasSomethingToRender(state.committed)) {
|
||||
if (
|
||||
(state.last_manifest_fetch == null && !this.hasBundledRenderer()) ||
|
||||
!this.hasSomethingToRender(state.committed)
|
||||
) {
|
||||
return {kind: ModuleUnreachableLaunchDecision.BLOCK, reason: ModuleUpdaterBlockReason.NOTHING_INSTALLED};
|
||||
}
|
||||
if (state.boot_attempt >= MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) {
|
||||
|
||||
@@ -39,7 +39,7 @@ function createUserData() {
|
||||
return root;
|
||||
}
|
||||
|
||||
function installModuleOnDisk(storeRoot, moduleName, digest, entries) {
|
||||
function installModuleOnDisk(storeRoot, moduleName, digest, entries, buildVersion = SHELL_VERSION) {
|
||||
const directory = path.join(storeRoot, 'store', moduleName, digest);
|
||||
mkdirSync(directory, {recursive: true});
|
||||
const files = entries.map(([relative, body]) => {
|
||||
@@ -51,7 +51,7 @@ function installModuleOnDisk(storeRoot, moduleName, digest, entries) {
|
||||
`${JSON.stringify(
|
||||
{
|
||||
module: moduleName,
|
||||
build_version: SHELL_VERSION,
|
||||
build_version: buildVersion,
|
||||
release_channel: RELEASE_CHANNEL,
|
||||
source_sha: SOURCE_SHA,
|
||||
files,
|
||||
@@ -121,7 +121,8 @@ function createUpdater(store, modules, options = {}) {
|
||||
platform: options.platform ?? PLATFORM,
|
||||
arch: ARCH,
|
||||
packageOrigin: PACKAGE_ORIGIN,
|
||||
hasOfflineRenderer: false,
|
||||
hasOfflineRenderer: options.bundledRendererVersion != null,
|
||||
bundledRendererVersion: options.bundledRendererVersion ?? null,
|
||||
forceStartupUpdate: options.forceStartupUpdate ?? false,
|
||||
selfUpdateShellFirst: options.selfUpdateShellFirst,
|
||||
onState: options.onState,
|
||||
@@ -862,3 +863,209 @@ describe('ModuleUpdater after a crash-loop rollback', () => {
|
||||
assert.equal(store.isRejected('fluxer_renderer', NEXT_RENDERER_SHA), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleUpdater with a renderer bundled in the shell', () => {
|
||||
const OLDER_VERSION = '2026.822.9';
|
||||
|
||||
async function storeWithOlderRenderer() {
|
||||
const store = await openStore(createUserData());
|
||||
installModuleOnDisk(store.root, 'fluxer_renderer', RENDERER_SHA, [['index.html', 'renderer one']], OLDER_VERSION);
|
||||
await store.commit({fluxer_renderer: RENDERER_SHA});
|
||||
await store.recordManifestFetch({
|
||||
etag: null,
|
||||
fetchedAt: new Date(NOW).toISOString(),
|
||||
manifest: {
|
||||
feed: {releaseChannel: RELEASE_CHANNEL, platform: PLATFORM, arch: ARCH},
|
||||
metadataVersion: 1,
|
||||
manifestSha256: 'c'.repeat(64),
|
||||
},
|
||||
floor: {fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
test('a new shell over an older installed renderer launches the bundle with the feed down', async () => {
|
||||
const store = await storeWithOlderRenderer();
|
||||
const {updater, reports} = createUpdater(
|
||||
store,
|
||||
{},
|
||||
{bundledRendererVersion: SHELL_VERSION, forceStartupUpdate: true, respond: unreachableFeed()},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.deepEqual(outcome.committed, {});
|
||||
assert.deepEqual(store.getCommitted(), {});
|
||||
assert.deepEqual(store.getState().previous, {});
|
||||
assert.equal(
|
||||
reports.some((report) => report.type === 'bundled-renderer-preferred'),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual((await updater.selectServedModules(outcome.committed)).renderer, {
|
||||
source: 'bundled',
|
||||
version: SHELL_VERSION,
|
||||
bundledVersion: SHELL_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
test('a feed renderer the bundle already covers is never downloaded', async () => {
|
||||
const store = await storeWithOlderRenderer();
|
||||
const {updater, requests} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{bundledRendererVersion: SHELL_VERSION, forceStartupUpdate: true, metadataVersion: 2},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'launching');
|
||||
assert.deepEqual(outcome.committed, {});
|
||||
assert.equal(
|
||||
requests.some((url) => String(url).endsWith('package.br')),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(store.getState().floor, {});
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: SHELL_VERSION,
|
||||
shellNewer: false,
|
||||
modulesChanged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a feed renderer newer than the bundle is still fetched', async () => {
|
||||
const store = await openStore(createUserData());
|
||||
const {updater, requests} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{bundledRendererVersion: OLDER_VERSION, respond: missingPackages()},
|
||||
);
|
||||
|
||||
await updater.run();
|
||||
|
||||
assert.equal(
|
||||
requests.some((url) => String(url).endsWith(`${NEXT_RENDERER_SHA}/package.br`)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a renderer module that keeps failing to boot is dropped for the bundle, never relaunched forever', async () => {
|
||||
const store = await openStore(createUserData());
|
||||
installModuleOnDisk(store.root, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.html', 'broken']], '2026.900.1');
|
||||
await store.commit({fluxer_renderer: NEXT_RENDERER_SHA});
|
||||
const served = [];
|
||||
for (let launch = 0; launch < 4; launch += 1) {
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{},
|
||||
{bundledRendererVersion: SHELL_VERSION, platform: 'linux', respond: unreachableFeed()},
|
||||
);
|
||||
const outcome = await updater.run();
|
||||
served.push((await updater.selectServedModules(outcome.committed)).renderer.source);
|
||||
}
|
||||
|
||||
assert.deepEqual(served, ['module', 'module', 'bundled', 'bundled']);
|
||||
assert.equal(store.isRejected('fluxer_renderer', NEXT_RENDERER_SHA), true);
|
||||
});
|
||||
|
||||
test('a committed on-demand module whose files are gone never blocks an offline launch', async () => {
|
||||
for (const platform of ['darwin', 'linux']) {
|
||||
const store = await openStore(createUserData());
|
||||
const directory = installModuleOnDisk(store.root, 'fluxer_fonts_jp', OVERLAY_SHA, [['a.woff2', 'font']], '0.0.0');
|
||||
await store.commit({fluxer_fonts_jp: OVERLAY_SHA});
|
||||
await rm(directory, {recursive: true, force: true});
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{},
|
||||
{bundledRendererVersion: SHELL_VERSION, platform, respond: unreachableFeed()},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, platform === 'linux' ? 'launching' : 'unreachable-launch', platform);
|
||||
assert.deepEqual(outcome.committed, {}, platform);
|
||||
}
|
||||
});
|
||||
|
||||
test('a newer feed renderer whose package is missing launches the bundle instead of blocking', async () => {
|
||||
const store = await openStore(createUserData());
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{bundledRendererVersion: OLDER_VERSION, respond: missingPackages()},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'launching');
|
||||
assert.deepEqual(outcome.committed, {});
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: SHELL_VERSION,
|
||||
shellNewer: false,
|
||||
modulesChanged: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('a packaged bundle with no readable version still outranks an installed module', async () => {
|
||||
const store = await openStore(createUserData());
|
||||
installModuleOnDisk(store.root, 'fluxer_renderer', RENDERER_SHA, [['index.html', 'old']], OLDER_VERSION);
|
||||
await store.commit({fluxer_renderer: RENDERER_SHA});
|
||||
const updater = new ModuleUpdater({
|
||||
store,
|
||||
shellVersion: SHELL_VERSION,
|
||||
releaseChannel: RELEASE_CHANNEL,
|
||||
platform: 'linux',
|
||||
arch: ARCH,
|
||||
packageOrigin: PACKAGE_ORIGIN,
|
||||
hasOfflineRenderer: true,
|
||||
bundledRendererVersion: 'dev',
|
||||
preferUnversionedBundle: true,
|
||||
fetch: unreachableFeed(),
|
||||
sleep: async () => {},
|
||||
random: () => 0,
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal((await updater.selectServedModules(outcome.committed)).renderer.source, 'bundled');
|
||||
assert.deepEqual(store.getCommitted(), {});
|
||||
});
|
||||
|
||||
test('a Linux launch never waits on a shell download, a required security update still does', async () => {
|
||||
const attempts = [];
|
||||
const selfUpdateShellFirst = async (latestVersion) => {
|
||||
attempts.push(latestVersion);
|
||||
};
|
||||
const store = await openStore(createUserData());
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{},
|
||||
{
|
||||
bundledRendererVersion: SHELL_VERSION,
|
||||
platform: 'linux',
|
||||
forceStartupUpdate: true,
|
||||
shellLatest: '2026.900.1',
|
||||
shellMinimum: '0.0.0',
|
||||
selfUpdateShellFirst,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal((await updater.run()).status, 'launching');
|
||||
assert.deepEqual(attempts, []);
|
||||
|
||||
const mac = createUpdater(
|
||||
await openStore(createUserData()),
|
||||
{},
|
||||
{
|
||||
bundledRendererVersion: SHELL_VERSION,
|
||||
forceStartupUpdate: true,
|
||||
shellLatest: '2026.900.1',
|
||||
shellMinimum: '0.0.0',
|
||||
selfUpdateShellFirst,
|
||||
},
|
||||
);
|
||||
await mac.updater.run();
|
||||
assert.deepEqual(attempts, ['2026.900.1']);
|
||||
});
|
||||
});
|
||||
@@ -44,11 +44,12 @@ import {
|
||||
ModuleUnreachableLaunchDecision,
|
||||
ModuleUpdatePlanner,
|
||||
ModuleUpdaterBlockReason,
|
||||
type ServedModuleSelection,
|
||||
} from '@electron/main/ModuleUpdatePlanner';
|
||||
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||
import {resolveDesktopPackageOrigin} from '@electron/main/ShellDownloadFormats';
|
||||
import type {DesktopModuleEnsureResult} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
import {DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
import {DESKTOP_RENDERER_MODULE_NAME, DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
|
||||
const MODULE_UPDATER_BACKOFF_BASE_MS = 1000;
|
||||
const MODULE_UPDATER_BACKOFF_CAP_MS = 30000;
|
||||
@@ -82,6 +83,7 @@ const ModuleUpdaterReportType = Object.freeze({
|
||||
STORAGE_ERROR: 'storage-error',
|
||||
ROLLBACK: 'rollback',
|
||||
ROLLBACK_FLOOR_BYPASS: 'rollback-floor-bypass',
|
||||
BUNDLED_RENDERER_PREFERRED: 'bundled-renderer-preferred',
|
||||
BLOCKED: 'blocked',
|
||||
} as const);
|
||||
|
||||
@@ -97,6 +99,7 @@ const MODULE_UPDATER_REPORT_SAMPLE_RATES: Readonly<Record<ModuleUpdaterReportTyp
|
||||
[ModuleUpdaterReportType.STORAGE_ERROR]: 1,
|
||||
[ModuleUpdaterReportType.ROLLBACK]: 1,
|
||||
[ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS]: 1,
|
||||
[ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED]: 1,
|
||||
[ModuleUpdaterReportType.BLOCKED]: 1,
|
||||
});
|
||||
|
||||
@@ -198,6 +201,17 @@ function isUpdateServerUnreachable(error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseBundledRendererVersion(source: string | null): ModuleVersion | null {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseModuleVersion(source, 'bundled renderer version');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
@@ -263,6 +277,8 @@ interface ModuleUpdaterOptions {
|
||||
readonly arch?: NodeJS.Architecture;
|
||||
readonly packageOrigin?: string;
|
||||
readonly hasOfflineRenderer?: boolean;
|
||||
readonly bundledRendererVersion?: string | null;
|
||||
readonly preferUnversionedBundle?: boolean;
|
||||
readonly forceStartupUpdate?: boolean;
|
||||
readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>;
|
||||
readonly onState?: (state: ModuleUpdaterSplashState) => void;
|
||||
@@ -319,7 +335,13 @@ export class ModuleUpdater {
|
||||
this.manifestFeed = {releaseChannel, platform: this.platform, arch};
|
||||
const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, '');
|
||||
this.packageOrigin = packageOrigin;
|
||||
this.planner = new ModuleUpdatePlanner(this.store, this.shellVersion, this.hasOfflineRenderer);
|
||||
this.planner = new ModuleUpdatePlanner(
|
||||
this.store,
|
||||
this.shellVersion,
|
||||
this.hasOfflineRenderer,
|
||||
parseBundledRendererVersion(options.bundledRendererVersion ?? null),
|
||||
options.preferUnversionedBundle ?? false,
|
||||
);
|
||||
this.onState = options.onState ?? (() => {});
|
||||
this.reporter = options.report ?? (() => {});
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
@@ -395,7 +417,12 @@ export class ModuleUpdater {
|
||||
}
|
||||
|
||||
public async run(): Promise<ModuleUpdaterOutcome> {
|
||||
const attempt = await this.store.beginBootAttempt();
|
||||
let attempt = await this.store.beginBootAttempt({bundled: this.planner.bundledModules()});
|
||||
const retiredRenderer = await this.retireRendererCoveredByBundle();
|
||||
const retiredMissing = await this.retireUninstalledModules();
|
||||
if (retiredRenderer || retiredMissing) {
|
||||
attempt = {...attempt, committed: this.store.getCommitted()};
|
||||
}
|
||||
await this.restoreCachedUpdatePolicy();
|
||||
if (attempt.rolledBack) {
|
||||
this.report({
|
||||
@@ -562,6 +589,37 @@ export class ModuleUpdater {
|
||||
return {module: moduleName, status: DesktopModuleEnsureStatus.INSTALLED};
|
||||
}
|
||||
|
||||
private async retireRendererCoveredByBundle(): Promise<boolean> {
|
||||
const stale = await this.planner.staleRendererModule();
|
||||
if (stale == null) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.store.retireModule(DESKTOP_RENDERER_MODULE_NAME))) {
|
||||
return false;
|
||||
}
|
||||
this.report({
|
||||
type: ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED,
|
||||
module: DESKTOP_RENDERER_MODULE_NAME,
|
||||
message: `retired installed renderer ${stale} because the renderer bundled with this shell is at least as new`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async retireUninstalledModules(): Promise<boolean> {
|
||||
let retired = false;
|
||||
for (const moduleName of await this.planner.uninstalledCommittedModules()) {
|
||||
if (await this.store.retireModule(moduleName)) {
|
||||
retired = true;
|
||||
this.report({
|
||||
type: ModuleUpdaterReportType.PACKAGE_MISSING,
|
||||
module: moduleName,
|
||||
message: 'dropped a committed module whose files are gone, the bundled renderer does not need it to launch',
|
||||
});
|
||||
}
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
|
||||
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||
}
|
||||
@@ -571,7 +629,8 @@ export class ModuleUpdater {
|
||||
if (
|
||||
this.selfUpdateShellFirst == null ||
|
||||
compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 ||
|
||||
this.shellFirstAttempted.has(latestVersion)
|
||||
this.shellFirstAttempted.has(latestVersion) ||
|
||||
(this.platform === 'linux' && !this.isSecurityUpdateRequired())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -623,7 +682,7 @@ export class ModuleUpdater {
|
||||
const wanted: Array<ModulePlanItem> = [];
|
||||
for (const moduleName of Array.from(names).sort()) {
|
||||
const entry = manifest.modules[moduleName];
|
||||
if (entry == null || !planner.isShellCompatible(entry)) {
|
||||
if (entry == null || !planner.isShellCompatible(entry) || planner.bundleCoversModule(moduleName, manifest)) {
|
||||
continue;
|
||||
}
|
||||
if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) {
|
||||
@@ -668,6 +727,21 @@ export class ModuleUpdater {
|
||||
return {installed, unresolved};
|
||||
}
|
||||
|
||||
public async selectServedModules(modules: Readonly<Record<string, string>>): Promise<ServedModuleSelection> {
|
||||
return await this.planner.selectServedModules(modules);
|
||||
}
|
||||
|
||||
public async revertLaunch(attempt: ModuleLaunchAttempt): Promise<Readonly<Record<string, string>> | null> {
|
||||
const reverted = await this.store.revertLaunchAttempt(attempt, this.planner.bundledModules());
|
||||
if (reverted != null) {
|
||||
this.report({
|
||||
type: ModuleUpdaterReportType.ROLLBACK,
|
||||
message: 'reverted an in place module update whose renderer never confirmed it started',
|
||||
});
|
||||
}
|
||||
return reverted;
|
||||
}
|
||||
|
||||
public async markLaunchSucceeded(attempt: ModuleLaunchAttempt): Promise<void> {
|
||||
if (!(await this.store.markLaunchAttemptSucceeded(attempt))) {
|
||||
return;
|
||||
@@ -946,6 +1020,9 @@ export class ModuleUpdater {
|
||||
}
|
||||
} else {
|
||||
for (const moduleName of blockingModuleNames(manifest)) {
|
||||
if (this.planner.bundleCoversModule(moduleName, manifest)) {
|
||||
continue;
|
||||
}
|
||||
floor[moduleName] = manifest.modules[moduleName].sha256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ installElectronStub({
|
||||
|
||||
const {
|
||||
getElectronUpdateFeedUrl,
|
||||
resetElectronUpdateSessionForTests,
|
||||
runShellSelfUpdate,
|
||||
SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS,
|
||||
SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS,
|
||||
@@ -242,6 +243,7 @@ beforeEach(() => {
|
||||
rmSync(applyStatePath, {force: true});
|
||||
velopack.constructedWith.length = 0;
|
||||
autoUpdater.handlers.clear();
|
||||
resetElectronUpdateSessionForTests();
|
||||
autoUpdater.feedUrls.length = 0;
|
||||
autoUpdater.checks = 0;
|
||||
autoUpdater.installs = 0;
|
||||
@@ -443,6 +445,34 @@ describe('velopack self update', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('velopack self update across repeated clicks', () => {
|
||||
selfUpdateTest(
|
||||
'a second run for the same version joins the download already in flight',
|
||||
async ({start, fireTimer}) => {
|
||||
velopack.checkResult = {id: 'v3', TargetFullRelease: {Version: '2026.900.0'}};
|
||||
let finishDownload = () => {};
|
||||
velopack.downloadPromise = new Promise((resolve) => {
|
||||
finishDownload = resolve;
|
||||
});
|
||||
const first = start('velopack', createHooks().hooks);
|
||||
await settle();
|
||||
fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS);
|
||||
assert.deepEqual(await first, {reason: 'timed-out', detail: 'the update did not finish'});
|
||||
|
||||
const {calls, hooks} = createHooks();
|
||||
void start('velopack', hooks);
|
||||
await settle();
|
||||
finishDownload();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
assert.equal(velopack.calls.filter((call) => call === 'downloadUpdateAsync').length, 1);
|
||||
assert.deepEqual(calls, [{hook: 'downloading', progress: 0}, {hook: 'restarting'}]);
|
||||
assert.deepEqual(quits, ['quit']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('electron self update', () => {
|
||||
selfUpdateTest('points squirrel at the json feed with an identifying user agent', async ({start}) => {
|
||||
const {hooks} = createHooks();
|
||||
@@ -461,7 +491,7 @@ describe('electron self update', () => {
|
||||
assert.equal(autoUpdater.checks, 1);
|
||||
});
|
||||
|
||||
selfUpdateTest('nothing newer resolves and takes every listener with it', async ({start}) => {
|
||||
selfUpdateTest('nothing newer resolves and later runs reuse the same four listeners', async ({start}) => {
|
||||
const {calls, hooks} = createHooks();
|
||||
const promise = start('electron', hooks);
|
||||
|
||||
@@ -471,7 +501,13 @@ describe('electron self update', () => {
|
||||
|
||||
assert.deepEqual(await promise, {reason: 'no-update', detail: null});
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(autoUpdater.totalListenerCount(), 0);
|
||||
|
||||
const again = start('electron', createHooks().hooks);
|
||||
autoUpdater.emit('update-not-available');
|
||||
await again;
|
||||
|
||||
assert.equal(autoUpdater.totalListenerCount(), 4);
|
||||
assert.equal(autoUpdater.checks, 2);
|
||||
});
|
||||
|
||||
selfUpdateTest(
|
||||
@@ -481,7 +517,12 @@ describe('electron self update', () => {
|
||||
const {hooks} = createHooks();
|
||||
|
||||
assert.deepEqual(await start('electron', hooks), {reason: 'check-failed', detail: 'Update URL is not set'});
|
||||
assert.equal(autoUpdater.totalListenerCount(), 0);
|
||||
|
||||
autoUpdater.checkError = null;
|
||||
const retry = start('electron', createHooks().hooks);
|
||||
autoUpdater.emit('update-not-available');
|
||||
assert.deepEqual(await retry, {reason: 'no-update', detail: null});
|
||||
assert.equal(autoUpdater.checks, 2);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -535,23 +576,68 @@ describe('electron self update', () => {
|
||||
autoUpdater.emit('update-downloaded');
|
||||
|
||||
assert.deepEqual(await promise, {reason: 'install-failed', detail: 'the app is on a read only mount'});
|
||||
assert.equal(autoUpdater.totalListenerCount(), 0);
|
||||
},
|
||||
);
|
||||
|
||||
selfUpdateTest('a silent squirrel hits the check deadline and drops every listener', async ({start, fireTimer}) => {
|
||||
const {hooks} = createHooks();
|
||||
const promise = start('electron', hooks);
|
||||
selfUpdateTest(
|
||||
'a silent squirrel hits the check deadline and a late download installs on the next run',
|
||||
async ({start, fireTimer, stillPending}) => {
|
||||
const {hooks} = createHooks();
|
||||
const promise = start('electron', hooks);
|
||||
|
||||
fireTimer(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS);
|
||||
fireTimer(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS);
|
||||
|
||||
assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'});
|
||||
assert.equal(autoUpdater.totalListenerCount(), 0);
|
||||
assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'});
|
||||
|
||||
autoUpdater.emit('update-downloaded');
|
||||
autoUpdater.emit('update-downloaded');
|
||||
|
||||
assert.equal(autoUpdater.installs, 0);
|
||||
});
|
||||
assert.equal(autoUpdater.installs, 0);
|
||||
|
||||
const {calls, hooks: nextHooks} = createHooks();
|
||||
const next = start('electron', nextHooks);
|
||||
|
||||
assert.deepEqual(calls, [{hook: 'restarting'}]);
|
||||
assert.equal(autoUpdater.installs, 1);
|
||||
assert.equal(autoUpdater.checks, 1);
|
||||
assert.equal(await stillPending(next), true);
|
||||
},
|
||||
);
|
||||
|
||||
selfUpdateTest(
|
||||
'a second run while squirrel is still downloading joins it instead of asking squirrel again',
|
||||
async ({start, fireTimer, stillPending}) => {
|
||||
const first = start('electron', createHooks().hooks);
|
||||
autoUpdater.emit('update-available');
|
||||
fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS);
|
||||
assert.deepEqual(await first, {reason: 'timed-out', detail: 'the update did not finish'});
|
||||
|
||||
const {calls, hooks} = createHooks();
|
||||
const second = start('electron', hooks);
|
||||
|
||||
assert.equal(autoUpdater.checks, 1);
|
||||
assert.deepEqual(calls, [{hook: 'downloading', progress: null}]);
|
||||
assert.equal(await stillPending(second), true);
|
||||
|
||||
autoUpdater.emit('update-downloaded');
|
||||
|
||||
assert.deepEqual(calls, [{hook: 'downloading', progress: null}, {hook: 'restarting'}]);
|
||||
assert.equal(autoUpdater.installs, 1);
|
||||
},
|
||||
);
|
||||
|
||||
selfUpdateTest(
|
||||
'two runs in flight both settle on one squirrel answer and squirrel is asked once',
|
||||
async ({start}) => {
|
||||
const first = start('electron', createHooks().hooks);
|
||||
const second = start('electron', createHooks().hooks);
|
||||
|
||||
autoUpdater.emit('error', new Error('dns'));
|
||||
|
||||
assert.deepEqual(await first, {reason: 'check-failed', detail: 'dns'});
|
||||
assert.deepEqual(await second, {reason: 'check-failed', detail: 'dns'});
|
||||
assert.equal(autoUpdater.checks, 1);
|
||||
},
|
||||
);
|
||||
|
||||
selfUpdateTest('a download that never finishes hits the total deadline', async ({start, fireTimer}) => {
|
||||
const {hooks} = createHooks();
|
||||
@@ -561,6 +647,5 @@ describe('electron self update', () => {
|
||||
fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS);
|
||||
|
||||
assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update did not finish'});
|
||||
assert.equal(autoUpdater.totalListenerCount(), 0);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {createChildLogger} from '@electron/common/Logger';
|
||||
import {
|
||||
type AppImageTarget,
|
||||
applyStagedAppImageUpdate,
|
||||
discardStagedAppImageUpdate,
|
||||
type StagedAppImageUpdate,
|
||||
stageAppImageUpdate,
|
||||
} from '@electron/main/AppImageUpdate';
|
||||
@@ -89,6 +88,37 @@ function getStagedVelopackVersion(staged: UpdateInfo | VelopackAsset): string |
|
||||
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());
|
||||
@@ -120,7 +150,7 @@ async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSel
|
||||
control.clearCheckDeadline();
|
||||
hooks.onDownloading(0);
|
||||
try {
|
||||
await manager.downloadUpdateAsync(available, (percent) => {
|
||||
await downloadVelopackUpdateOnce(manager, available, (percent) => {
|
||||
if (control.isSettled()) return;
|
||||
hooks.onDownloading(percent);
|
||||
});
|
||||
@@ -162,6 +192,46 @@ async function fetchPublishedAppImage(): Promise<{version: string; sha256: strin
|
||||
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,
|
||||
@@ -194,25 +264,19 @@ async function runAppImageSelfUpdate(
|
||||
hooks.onDownloading(0);
|
||||
let staged: StagedAppImageUpdate;
|
||||
try {
|
||||
staged = await stageAppImageUpdate({
|
||||
target,
|
||||
url: `${getUpdateBaseUrl()}/${published.version}/appimage`,
|
||||
expectedSha256: published.sha256,
|
||||
fetchImpl: moduleNetworkFetch,
|
||||
onProgress: ({transferred, total}) => {
|
||||
if (control.isSettled() || total <= 0) return;
|
||||
hooks.onDownloading(Math.min(100, (transferred / total) * 100));
|
||||
},
|
||||
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()) {
|
||||
discardStagedAppImageUpdate(staged);
|
||||
return;
|
||||
}
|
||||
hooks.onRestarting();
|
||||
appImageStaging = null;
|
||||
try {
|
||||
applyStagedAppImageUpdate(target, staged);
|
||||
} catch (error) {
|
||||
@@ -223,17 +287,75 @@ async function runAppImageSelfUpdate(
|
||||
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 {
|
||||
let downloading = false;
|
||||
const onUpdateAvailable = (): void => {
|
||||
downloading = true;
|
||||
control.clearCheckDeadline();
|
||||
hooks.onDownloading(null);
|
||||
};
|
||||
const onUpdateNotAvailable = (): void => {
|
||||
control.settle({reason: 'no-update', detail: null});
|
||||
};
|
||||
const onUpdateDownloaded = (): void => {
|
||||
armElectronUpdateSession();
|
||||
const install = (): void => {
|
||||
control.clearCheckDeadline();
|
||||
hooks.onRestarting();
|
||||
try {
|
||||
@@ -242,20 +364,48 @@ function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdat
|
||||
control.settle({reason: 'install-failed', detail: errorDetail(error)});
|
||||
}
|
||||
};
|
||||
const onError = (error: Error): void => {
|
||||
control.settle({reason: downloading ? 'download-failed' : 'check-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;
|
||||
}
|
||||
};
|
||||
autoUpdater.on('update-available', onUpdateAvailable);
|
||||
autoUpdater.on('update-not-available', onUpdateNotAvailable);
|
||||
autoUpdater.on('update-downloaded', onUpdateDownloaded);
|
||||
autoUpdater.on('error', onError);
|
||||
electronUpdateSession.observers.add(observer);
|
||||
control.addCleanup(() => {
|
||||
autoUpdater.removeListener('update-available', onUpdateAvailable);
|
||||
autoUpdater.removeListener('update-not-available', onUpdateNotAvailable);
|
||||
autoUpdater.removeListener('update-downloaded', onUpdateDownloaded);
|
||||
autoUpdater.removeListener('error', onError);
|
||||
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',
|
||||
@@ -263,6 +413,7 @@ function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdat
|
||||
});
|
||||
autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
electronUpdateSession.phase = ElectronUpdatePhase.IDLE;
|
||||
control.settle({reason: 'check-failed', detail: errorDetail(error)});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,15 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis
|
||||
return info;
|
||||
}
|
||||
|
||||
export async function findNewerManualShell(): Promise<ManualLatestInfo | null> {
|
||||
const plan = resolveShellUpdatePlan();
|
||||
if (plan.capability !== ShellUpdateCapability.MANUAL_DOWNLOAD || plan.reason !== 'platform') {
|
||||
return null;
|
||||
}
|
||||
const latest = await fetchManualLatest({forceRefresh: true});
|
||||
return compareVersions(latest.version, app.getVersion()) > 0 ? latest : null;
|
||||
}
|
||||
|
||||
function sendManualUpdateAvailable(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
context: UpdaterContext,
|
||||
|
||||
@@ -49,6 +49,7 @@ const LIVE_RESIZE_IDLE_MS = 400;
|
||||
const VISIBILITY_MARGIN = 32;
|
||||
const RENDERER_GONE_REPEAT_WINDOW_MS = 30000;
|
||||
const CLOSE_FOR_UPDATE_TIMEOUT_MS = 5000;
|
||||
const UPDATE_RELOAD_COMMIT_TIMEOUT_MS = 8000;
|
||||
const THEME_WINDOW_BACKGROUND_COLORS: Readonly<Record<string, string>> = Object.freeze({
|
||||
dark: '#1a181e',
|
||||
light: '#ebecef',
|
||||
@@ -156,6 +157,7 @@ let themeStudioPopoutWindow: BrowserWindow | null = null;
|
||||
let lastRestorableMainWindowMaximized = false;
|
||||
let mainWindowRendererGone = false;
|
||||
let closingMainWindowForUpdate = false;
|
||||
const SHELL_PAGE_URL_PREFIX = 'file:';
|
||||
let mainWindowTakeover: (() => void) | null = null;
|
||||
const takeoverEndedListeners = new Set<() => void>();
|
||||
let pendingMainWindowReveal: {readonly window: BrowserWindow; readonly requestShow: () => void} | null = null;
|
||||
@@ -1217,6 +1219,45 @@ export function isMainWindowTakenOver(): boolean {
|
||||
return mainWindowTakeover != null;
|
||||
}
|
||||
|
||||
export async function reloadMainWindowForUpdate(): Promise<boolean> {
|
||||
const window = mainWindow;
|
||||
if (!isAliveWindow(window) || window.webContents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
await closeAppWindowsForUpdate(window, {keepShellPages: true});
|
||||
if (window.isDestroyed() || window.webContents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
const committed = waitForReloadCommit(window.webContents);
|
||||
window.webContents.reloadIgnoringCache();
|
||||
if (await committed) {
|
||||
return true;
|
||||
}
|
||||
logger.warn('The main window did not start reloading for the update, replacing it', {
|
||||
timeoutMs: UPDATE_RELOAD_COMMIT_TIMEOUT_MS,
|
||||
});
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function waitForReloadCommit(contents: Electron.WebContents): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const finish = (committed: boolean): void => {
|
||||
clearTimeout(timer);
|
||||
contents.removeListener('did-navigate', onCommit);
|
||||
contents.removeListener('destroyed', onDestroyed);
|
||||
resolve(committed);
|
||||
};
|
||||
const onCommit = (): void => finish(true);
|
||||
const onDestroyed = (): void => finish(false);
|
||||
const timer = setTimeout(() => finish(false), UPDATE_RELOAD_COMMIT_TIMEOUT_MS);
|
||||
contents.once('did-navigate', onCommit);
|
||||
contents.once('destroyed', onDestroyed);
|
||||
});
|
||||
}
|
||||
|
||||
export function hideAppWindowsForUpdate(keep: BrowserWindow): ReadonlyArray<BrowserWindow> {
|
||||
const hidden: Array<BrowserWindow> = [];
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
@@ -1234,8 +1275,17 @@ export function restoreAppWindowsAfterUpdate(hidden: ReadonlyArray<BrowserWindow
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAppWindowsForUpdate(keep: BrowserWindow): Promise<void> {
|
||||
const closing = BrowserWindow.getAllWindows().filter((window) => window !== keep && !window.isDestroyed());
|
||||
function showsShellPage(window: BrowserWindow): boolean {
|
||||
return !window.webContents.isDestroyed() && window.webContents.getURL().startsWith(SHELL_PAGE_URL_PREFIX);
|
||||
}
|
||||
|
||||
export async function closeAppWindowsForUpdate(
|
||||
keep: BrowserWindow,
|
||||
{keepShellPages = false}: {readonly keepShellPages?: boolean} = {},
|
||||
): Promise<void> {
|
||||
const closing = BrowserWindow.getAllWindows().filter(
|
||||
(window) => window !== keep && !window.isDestroyed() && !(keepShellPages && showsShellPage(window)),
|
||||
);
|
||||
closingMainWindowForUpdate = true;
|
||||
try {
|
||||
await Promise.all(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WINDOWS_APP_USER_MODEL_ID,
|
||||
WINDOWS_TOAST_ACTIVATOR_CLSID,
|
||||
} from '@electron/common/DesktopIdentity';
|
||||
import {writeLogFilesUnder} from '@electron/common/Logger';
|
||||
import {configureUserDataPath} from '@electron/common/UserDataPath';
|
||||
import {
|
||||
APP_STORE_ADDON_PACKAGE,
|
||||
@@ -151,8 +152,7 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||
|
||||
const userDataConfig = configureUserDataPath();
|
||||
if (userDataConfig.portable) {
|
||||
const portableLogsPath = app.getPath('logs');
|
||||
log.transports.file.resolvePathFn = (variables) => path.join(portableLogsPath, variables.fileName ?? 'main.log');
|
||||
writeLogFilesUnder(app.getPath('logs'));
|
||||
}
|
||||
armNativeProbeCache(userDataConfig.base);
|
||||
const processConfiguredAt = Date.now();
|
||||
|
||||
Reference in new issue
Block a user