feat(desktop): prefer the bundled renderer, own update prompts (#3341)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 03:01:15 +02:00
1 parent 7a5b9a0ded
commit 5072488f1e
33 files changed
+2197 -113

No files matched your search

+23
View File
@@ -23,6 +23,7 @@ const SPLASH_DIST_DIR = path.join(DIST_DIR, 'splash');
const SPLASH_PRELOAD_FILE_NAME = 'splash.cjs';
const REQUIRED_RENDERER_ENTRIES = Object.freeze(['index.html', 'assets']);
const BUNDLED_RENDERER_ENTRIES = Object.freeze([...REQUIRED_RENDERER_ENTRIES, 'version.json']);
const BUNDLED_RENDERER_VERSION_PATTERN = /^\d+\.\d+\.\d+$/u;
const FORBIDDEN_RENDERER_ENTRIES = Object.freeze(['sw.js', 'sw.js.map']);
const SUPPORTED_BUILD_ARGUMENTS = Object.freeze(['--shared-assets', '--use-shared-renderer']);
const MAIN_BOOTSTRAP_ENTRY_NAME = 'index';
@@ -97,6 +98,24 @@ class BundledRendererMissingError extends Error {
}
}
class BundledRendererVersionError extends Error {
constructor(bundledVersion, buildVersion) {
super(
`The bundled renderer reports version ${JSON.stringify(bundledVersion)} and this shell is ${JSON.stringify(buildVersion)}. A shell that loads modules must bundle the renderer built for the same numeric version, because it serves whichever of the two is newer.`,
);
this.name = 'BundledRendererVersionError';
}
}
function readBundledRendererVersion() {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(RENDERER_DIST_DIR, 'version.json'), 'utf8'));
return typeof parsed?.version === 'string' ? parsed.version : '';
} catch {
return '';
}
}
class RendererServiceWorkerPresentError extends Error {
constructor(present) {
super(
@@ -847,6 +866,10 @@ function prunePackedRendererModules() {
if (missing.length > 0) {
throw new BundledRendererMissingError(RENDERER_DIST_DIR, missing);
}
const bundledVersion = readBundledRendererVersion();
if (!BUNDLED_RENDERER_VERSION_PATTERN.test(bundledVersion) || bundledVersion !== embeddedBuildVersion) {
throw new BundledRendererVersionError(bundledVersion, embeddedBuildVersion);
}
console.log(` Bundled renderer pruned in ${Date.now() - startedAt}ms`);
}
+384
View File
@@ -0,0 +1,384 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {execFileSync, spawn} from 'node:child_process';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import {parseArgs} from 'node:util';
const CHANNEL_DATA_DIRECTORIES = Object.freeze({
stable: 'fluxer',
canary: 'fluxercanary',
development: 'fluxerdevelopment',
});
const SERVED_RENDERER_PATTERN = /Serving the renderer: source=(\S+) version=(\S+) bundled=(\S+)/gu;
const REPORTED_BUILD_PATTERN = /The renderer reported its build: (.+)$/gmu;
const REPORTED_WEB_VERSION_PATTERN = /\bWeb (\d+\.\d+\.\d+)\b/u;
const POLL_INTERVAL_MS = 500;
const QUIT_GRACE_MS = 8000;
const USAGE = `Usage: node scripts/release-check.mjs --app <packaged app> --channel <stable|canary|development> --expect-renderer <version>
Runs a packaged desktop shell in a throwaway portable profile and fails unless the renderer
it serves, and the renderer that reports back from the window, are both the expected version.
--app <path> A macOS .app bundle, an unpacked app directory or its executable
--channel <name> The channel the shell was built for
--expect-renderer <ver> The renderer build version the user must end up on
--expect-source <name> bundled or module, when the source also has to match
--seed-profile <dir> A portable profile to start from, for example the previous release's
--package-origin <url> The package feed to use instead of the one the shell was built for
--update Ask the running shell to update through its own command line path
--update-from-renderer Start the update the way the in app update button does instead
--expect-initial <ver> With --update, the renderer version before the update
--before-update <command> With --update, a shell command to run once the app is up, for example a publish
--app-arg <arg> Extra argument for the shell, repeatable
--timeout-seconds <n> How long each stage may take, default 180
--workdir <dir> Where to create the sandbox, default the system temp directory
--keep Leave the sandbox in place for inspection
`;
class ReleaseCheckError extends Error {
constructor(message) {
super(message);
this.name = 'ReleaseCheckError';
}
}
function fail(message) {
throw new ReleaseCheckError(message);
}
function compareVersions(left, right) {
const a = left.split('.').map((part) => Number.parseInt(part, 10));
const b = right.split('.').map((part) => Number.parseInt(part, 10));
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
const difference = (a[index] ?? 0) - (b[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
function readOptions() {
const {values} = parseArgs({
options: {
app: {type: 'string'},
channel: {type: 'string'},
'expect-renderer': {type: 'string'},
'expect-source': {type: 'string'},
'expect-initial': {type: 'string'},
'before-update': {type: 'string'},
'seed-profile': {type: 'string'},
'package-origin': {type: 'string'},
update: {type: 'boolean', default: false},
'update-from-renderer': {type: 'boolean', default: false},
'app-arg': {type: 'string', multiple: true, default: []},
'timeout-seconds': {type: 'string', default: '180'},
workdir: {type: 'string'},
keep: {type: 'boolean', default: false},
help: {type: 'boolean', default: false},
},
});
if (values.help) {
process.stdout.write(USAGE);
process.exit(0);
}
for (const required of ['app', 'channel', 'expect-renderer']) {
if (!values[required]) fail(`--${required} is required\n\n${USAGE}`);
}
if (!Object.hasOwn(CHANNEL_DATA_DIRECTORIES, values.channel)) {
fail(`--channel must be one of ${Object.keys(CHANNEL_DATA_DIRECTORIES).join(', ')}`);
}
if (values['expect-source'] != null && !['bundled', 'module'].includes(values['expect-source'])) {
fail('--expect-source must be bundled or module');
}
const timeoutSeconds = Number.parseInt(values['timeout-seconds'], 10);
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds <= 0) {
fail('--timeout-seconds must be a positive integer');
}
return {
app: path.resolve(values.app),
channel: values.channel,
expectRenderer: values['expect-renderer'],
expectSource: values['expect-source'] ?? null,
expectInitial: values['expect-initial'] ?? null,
beforeUpdate: values['before-update'] ?? null,
seedProfile: values['seed-profile'] == null ? null : path.resolve(values['seed-profile']),
packageOrigin: values['package-origin'] ?? null,
update: values.update || values['update-from-renderer'],
updateFromRenderer: values['update-from-renderer'],
appArgs: values['app-arg'],
timeoutMs: timeoutSeconds * 1000,
workdir: values.workdir == null ? os.tmpdir() : path.resolve(values.workdir),
keep: values.keep,
};
}
function copyTree(source, target) {
if (process.platform === 'darwin') {
execFileSync('cp', ['-cR', source, target]);
return;
}
fs.cpSync(source, target, {recursive: true, verbatimSymlinks: true});
}
function resolveMacExecutable(appBundle) {
const macOSDirectory = path.join(appBundle, 'Contents', 'MacOS');
const candidates = fs.readdirSync(macOSDirectory).filter((name) => !name.startsWith('.'));
const named = candidates.find((name) => name === path.basename(appBundle, '.app'));
const chosen = named ?? candidates[0];
if (chosen == null) fail(`${macOSDirectory} holds no executable`);
return path.join(macOSDirectory, chosen);
}
function stageApp(options, sandbox) {
const source = fs.statSync(options.app);
if (options.app.endsWith('.app') && source.isDirectory()) {
const staged = path.join(sandbox, path.basename(options.app));
copyTree(options.app, staged);
return {executable: resolveMacExecutable(staged), portableRoot: path.join(sandbox, 'data')};
}
if (source.isDirectory()) {
const staged = path.join(sandbox, 'app');
copyTree(options.app, staged);
const executable = fs
.readdirSync(staged)
.map((name) => path.join(staged, name))
.find((candidate) => {
const stats = fs.statSync(candidate);
return stats.isFile() && (stats.mode & 0o111) !== 0 && /^fluxer/iu.test(path.basename(candidate));
});
if (executable == null) fail(`${options.app} holds no fluxer executable`);
return {executable, portableRoot: path.join(staged, 'data')};
}
const staged = path.join(sandbox, path.basename(options.app));
fs.copyFileSync(options.app, staged);
fs.chmodSync(staged, 0o755);
return {executable: staged, portableRoot: path.join(sandbox, 'data')};
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const {port} = server.address();
server.close(() => resolve(port));
});
});
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function readLog(logPath) {
try {
return fs.readFileSync(logPath, 'utf8');
} catch {
return '';
}
}
function lastMatch(text, pattern) {
let last = null;
for (const match of text.matchAll(pattern)) {
last = match;
}
return last;
}
function observeLog(logPath) {
const text = readLog(logPath);
const served = lastMatch(text, SERVED_RENDERER_PATTERN);
const reported = lastMatch(text, REPORTED_BUILD_PATTERN);
const reportedVersion = reported == null ? null : (REPORTED_WEB_VERSION_PATTERN.exec(reported[1])?.[1] ?? null);
return {
served: served == null ? null : {source: served[1], version: served[2], bundled: served[3]},
reportedBuild: reported?.[1] ?? null,
reportedVersion,
};
}
async function evaluateInPage(debugPort, expression) {
let targets;
try {
targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json();
} catch {
return null;
}
const page = targets.find((target) => target.type === 'page' && String(target.url).startsWith('fluxer-app://app'));
if (page == null) return null;
return await new Promise((resolve) => {
const socket = new WebSocket(page.webSocketDebuggerUrl);
const timer = setTimeout(() => {
socket.close();
resolve(null);
}, 5000);
socket.addEventListener('open', () => {
socket.send(
JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: {expression, awaitPromise: true, returnByValue: true},
}),
);
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== 1) return;
clearTimeout(timer);
socket.close();
resolve(message.result?.result?.value ?? null);
});
socket.addEventListener('error', () => {
clearTimeout(timer);
resolve(null);
});
});
}
async function servedVersionInPage(debugPort) {
const value = await evaluateInPage(
debugPort,
"fetch('/version.json', {cache: 'no-store'}).then((response) => response.json()).then((body) => body.version).catch(() => null)",
);
return typeof value === 'string' ? value : null;
}
async function startUpdateFromRenderer(debugPort, timeoutMs) {
const deadline = Date.now() + timeoutMs;
await evaluateInPage(debugPort, "window.electron.updaterCheck?.('user').then(() => true, () => false)");
while (Date.now() < deadline) {
const available = await evaluateInPage(
debugPort,
'window.electron.desktopUpdate.state().then((state) => state.available === true, () => false)',
);
if (available === true) {
await evaluateInPage(debugPort, 'window.electron.desktopUpdate.start().then(() => true, () => false)');
return;
}
await sleep(POLL_INTERVAL_MS);
}
fail('update: the shell never told the renderer that an update is available');
}
async function waitForRenderer({logPath, debugPort, version, timeoutMs, label}) {
const deadline = Date.now() + timeoutMs;
let observed = {log: observeLog(logPath), page: null};
while (Date.now() < deadline) {
observed = {log: observeLog(logPath), page: await servedVersionInPage(debugPort)};
if (
observed.page === version &&
observed.log.reportedVersion === version &&
observed.log.served?.version === version
) {
return observed;
}
await sleep(POLL_INTERVAL_MS);
}
fail(
`${label}: expected renderer ${version}, the shell serves ${observed.log.served?.version ?? 'nothing'} from ${
observed.log.served?.source ?? 'nowhere'
}, the window loads ${observed.page ?? 'nothing'} and reports ${observed.log.reportedBuild ?? 'nothing'}`,
);
}
async function stop(child) {
if (child.exitCode != null || child.signalCode != null) return;
const exited = new Promise((resolve) => {
child.once('exit', resolve);
});
child.kill('SIGTERM');
const graceful = await Promise.race([exited.then(() => true), sleep(QUIT_GRACE_MS).then(() => false)]);
if (!graceful) {
child.kill('SIGKILL');
await exited;
}
}
async function run() {
const options = readOptions();
fs.mkdirSync(options.workdir, {recursive: true});
const sandbox = fs.mkdtempSync(path.join(options.workdir, 'fluxer-release-check-'));
const {executable, portableRoot} = stageApp(options, sandbox);
const profile = path.join(portableRoot, CHANNEL_DATA_DIRECTORIES[options.channel]);
if (options.seedProfile != null) {
fs.mkdirSync(portableRoot, {recursive: true});
copyTree(options.seedProfile, profile);
for (const stale of ['SingletonLock', 'SingletonCookie', 'SingletonSocket', 'DevToolsActivePort']) {
fs.rmSync(path.join(profile, stale), {force: true});
}
fs.rmSync(path.join(profile, 'logs'), {force: true, recursive: true});
}
fs.mkdirSync(profile, {recursive: true});
const logPath = path.join(profile, 'logs', 'main.log');
const debugPort = await freePort();
const launchArguments = ['--fluxer-portable', `--remote-debugging-port=${debugPort}`, ...options.appArgs];
const environment = {...process.env};
if (options.packageOrigin != null) {
environment.FLUXER_DESKTOP_PACKAGE_ORIGIN = options.packageOrigin;
}
const child = spawn(executable, launchArguments, {env: environment, stdio: 'ignore'});
const result = {
sandbox,
executable,
profile,
expectRenderer: options.expectRenderer,
stages: [],
};
try {
const firstVersion =
options.update && options.expectInitial != null ? options.expectInitial : options.expectRenderer;
const first = await waitForRenderer({
logPath,
debugPort,
version: firstVersion,
timeoutMs: options.timeoutMs,
label: 'launch',
});
result.stages.push({stage: 'launch', ...first});
let final = first;
if (options.update) {
if (options.beforeUpdate != null) {
execFileSync(options.beforeUpdate, {shell: true, stdio: 'inherit'});
}
if (options.updateFromRenderer) {
await startUpdateFromRenderer(debugPort, options.timeoutMs);
} else {
execFileSync(executable, ['--fluxer-portable', '--fluxer-update'], {env: environment, stdio: 'ignore'});
}
final = await waitForRenderer({
logPath,
debugPort,
version: options.expectRenderer,
timeoutMs: options.timeoutMs,
label: 'update',
});
result.stages.push({stage: 'update', ...final});
}
const served = final.log.served;
if (options.expectSource != null && served.source !== options.expectSource) {
fail(`the shell serves the ${served.source} renderer, expected the ${options.expectSource} one`);
}
if (served.bundled !== 'none' && compareVersions(served.version, served.bundled) < 0) {
fail(`the shell serves renderer ${served.version}, older than the ${served.bundled} it bundles`);
}
result.shellPid = child.pid;
result.shellStillRunning = child.exitCode == null && child.signalCode == null;
result.ok = true;
} finally {
await stop(child);
if (!options.keep) {
fs.rmSync(sandbox, {recursive: true, force: true});
}
}
process.stdout.write(`${JSON.stringify(result, null, '\t')}\n`);
}
run().catch((error) => {
process.stderr.write(`${error instanceof ReleaseCheckError ? error.message : (error?.stack ?? String(error))}\n`);
process.exit(1);
});
+7
View File
@@ -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);
}
+41 -1
View File
@@ -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});
+44 -1
View File
@@ -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);
+108 -12
View File
@@ -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\}\)/);
+6
View File
@@ -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);
+16 -1
View File
@@ -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);
});
});
+13 -2
View File
@@ -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']);
});
});
+65 -1
View File
@@ -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};
+5
View File
@@ -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> {
+18
View File
@@ -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}',
+12
View File
@@ -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,
});
});
});
+84 -3
View File
@@ -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,
);
});
});
+132 -4
View File
@@ -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) {
+210 -3
View File
@@ -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']);
});
});
+82 -5
View File
@@ -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);
});
});
+183 -32
View File
@@ -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)});
}
}
+9
View File
@@ -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,
+52 -2
View File
@@ -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(
+2 -2
View File
@@ -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();