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 SPLASH_PRELOAD_FILE_NAME = 'splash.cjs';
const REQUIRED_RENDERER_ENTRIES = Object.freeze(['index.html', 'assets']); const REQUIRED_RENDERER_ENTRIES = Object.freeze(['index.html', 'assets']);
const BUNDLED_RENDERER_ENTRIES = Object.freeze([...REQUIRED_RENDERER_ENTRIES, 'version.json']); 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 FORBIDDEN_RENDERER_ENTRIES = Object.freeze(['sw.js', 'sw.js.map']);
const SUPPORTED_BUILD_ARGUMENTS = Object.freeze(['--shared-assets', '--use-shared-renderer']); const SUPPORTED_BUILD_ARGUMENTS = Object.freeze(['--shared-assets', '--use-shared-renderer']);
const MAIN_BOOTSTRAP_ENTRY_NAME = 'index'; 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 { class RendererServiceWorkerPresentError extends Error {
constructor(present) { constructor(present) {
super( super(
@@ -847,6 +866,10 @@ function prunePackedRendererModules() {
if (missing.length > 0) { if (missing.length > 0) {
throw new BundledRendererMissingError(RENDERER_DIST_DIR, missing); 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`); 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 // SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {BUILD_CHANNEL, type BuildChannel} from '@electron/common/BuildChannel'; import {BUILD_CHANNEL, type BuildChannel} from '@electron/common/BuildChannel';
import log from 'electron-log'; import log from 'electron-log';
const DEFAULT_LOG_FILE_NAME = 'main.log';
const CHANNEL_LOG_LEVELS: Record<BuildChannel, 'debug' | 'info'> = { const CHANNEL_LOG_LEVELS: Record<BuildChannel, 'debug' | 'info'> = {
stable: 'info', stable: 'info',
canary: 'debug', canary: 'debug',
@@ -29,3 +32,7 @@ export function createChildLogger(componentName: string): typeof Logger {
error: (...args: Array<unknown>) => log.error(prefix, ...args), 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_DIRECTORY_SEGMENTS: ReadonlyArray<string> = Object.freeze(['..', 'renderer']);
const OFFLINE_RENDERER_INDEX_NAME = 'index.html'; 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_ENV = 'FLUXER_MODULE_SYSTEM';
const MODULE_SYSTEM_DISABLE_ARGUMENT = '--fluxer-no-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 { 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 { export function hasOfflineRenderer(mainModuleUrl: string): boolean {
@@ -98,3 +114,27 @@ export function hasOfflineRenderer(mainModuleUrl: string): boolean {
throw error; 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, require: requireImpl,
console, console,
Buffer, Buffer,
AbortController,
setTimeout,
clearTimeout,
fetch, fetch,
Error, Error,
Number, Number,
@@ -261,6 +264,29 @@ describe('AppImageUpdate in-place replacement', () => {
assert.deepEqual(stagingLeftovers(install.applications), []); 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 () => { test('refuses to swap when the download does not match the published checksum', async () => {
const install = createInstall(); const install = createInstall();
const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath}); const appImageUpdate = loadAppImageUpdate({APPIMAGE: install.installedPath});
+44 -1
View File
@@ -45,6 +45,7 @@ const APPDIR_ENTRYPOINT = 'AppRun';
const APPIMAGE_MODE = 0o755; const APPIMAGE_MODE = 0o755;
const SHA256_PATTERN = /^[0-9a-f]{64}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const STAGING_FREE_SPACE_MARGIN_BYTES = 16 * 1024 * 1024; const STAGING_FREE_SPACE_MARGIN_BYTES = 16 * 1024 * 1024;
export const APPIMAGE_DOWNLOAD_STALL_MS = 30_000;
const NON_RETRYABLE_STAGING_ERRNOS = new Set([ const NON_RETRYABLE_STAGING_ERRNOS = new Set([
'ENOSPC', 'ENOSPC',
'EDQUOT', 'EDQUOT',
@@ -167,12 +168,45 @@ export async function stageAppImageUpdate(options: {
expectedSha256: string; expectedSha256: string;
onProgress?: (progress: AppImageDownloadProgress) => void; onProgress?: (progress: AppImageDownloadProgress) => void;
fetchImpl: typeof fetch; fetchImpl: typeof fetch;
stallMs?: number;
}): Promise<StagedAppImageUpdate> { }): Promise<StagedAppImageUpdate> {
const expected = options.expectedSha256.trim().toLowerCase(); const expected = options.expectedSha256.trim().toLowerCase();
if (!SHA256_PATTERN.test(expected)) { if (!SHA256_PATTERN.test(expected)) {
throw new Error('Update checksum is missing or malformed.'); 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) { if (!response.ok || response.body == null) {
throw new Error(`Update download failed: ${response.status}`); throw new Error(`Update download failed: ${response.status}`);
} }
@@ -200,13 +234,21 @@ export async function stageAppImageUpdate(options: {
try { try {
const handle = await open(staged.stagedPath, 'wx', APPIMAGE_MODE); const handle = await open(staged.stagedPath, 'wx', APPIMAGE_MODE);
const reader = response.body.getReader(); const reader = response.body.getReader();
const cancelOnStall = (): void => {
void reader.cancel(signal.reason).catch(() => {});
};
signal.addEventListener('abort', cancelOnStall, {once: true});
let transferred = 0; let transferred = 0;
try { try {
for (;;) { for (;;) {
const {done, value} = await reader.read(); const {done, value} = await reader.read();
if (signal.aborted) {
throw signal.reason;
}
if (done) { if (done) {
break; break;
} }
onData();
let written = 0; let written = 0;
while (written < value.byteLength) { while (written < value.byteLength) {
const result = await handle.write(value, written, value.byteLength - written); const result = await handle.write(value, written, value.byteLength - written);
@@ -226,6 +268,7 @@ export async function stageAppImageUpdate(options: {
await reader.cancel().catch(() => {}); await reader.cancel().catch(() => {});
throw error; throw error;
} finally { } finally {
signal.removeEventListener('abort', cancelOnStall);
await handle.close(); await handle.close();
} }
const digest = await digestFile(staged.stagedPath); const digest = await digestFile(staged.stagedPath);
+108 -12
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import {createRequire} from 'node:module'; 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 {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity';
import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan'; import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan';
import { import {
@@ -8,6 +11,7 @@ import {
hasOfflineRenderer, hasOfflineRenderer,
ModuleSystemDisableDecision, ModuleSystemDisableDecision,
ModuleSystemLaunchDecision, ModuleSystemLaunchDecision,
readBundledRendererVersion,
resolveModuleSystemLaunch, resolveModuleSystemLaunch,
} from '@electron/common/ModuleSystem'; } from '@electron/common/ModuleSystem';
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate'; import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
@@ -60,11 +64,16 @@ function readDevModulePollInterval(): number | null {
} }
const HAS_OFFLINE_RENDERER = hasOfflineRenderer(import.meta.url); 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 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_TAKEOVER_SPLASH_WAIT_MS = 250;
const UPDATE_SPLASH_PRELOAD_DELAY_MS = 5000; const UPDATE_SPLASH_PRELOAD_DELAY_MS = 5000;
@@ -190,14 +199,14 @@ async function refuseUnsupportedBuild(reason: string): Promise<never> {
async function runModuleBootstrap(): Promise<void> { async function runModuleBootstrap(): Promise<void> {
const {app, clipboard, session, shell} = await import('electron'); const {app, clipboard, session, shell} = await import('electron');
const {BUILD_CHANNEL} = await import('@electron/common/BuildChannel'); const {createChildLogger, writeLogFilesUnder} = await import('@electron/common/Logger');
const {createChildLogger} = await import('@electron/common/Logger');
const {loadDesktopConfig} = await import('@electron/common/DesktopConfig'); const {loadDesktopConfig} = await import('@electron/common/DesktopConfig');
const {appendWindowsGpuDriverWorkaroundSwitches} = await import('@electron/main/ChromiumRuntime'); const {appendWindowsGpuDriverWorkaroundSwitches} = await import('@electron/main/ChromiumRuntime');
const {applyPreReadyChromiumConfiguration} = await import('@electron/main/PreReadyChromium'); const {applyPreReadyChromiumConfiguration} = await import('@electron/main/PreReadyChromium');
const {configureUserDataPath} = await import('@electron/common/UserDataPath'); const {configureUserDataPath} = await import('@electron/common/UserDataPath');
const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache'); const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache');
const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch'); const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch');
const {isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions');
const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath'); const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath');
const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol'); const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol');
const { const {
@@ -242,10 +251,13 @@ async function runModuleBootstrap(): Promise<void> {
const logger = createChildLogger('Bootstrap'); const logger = createChildLogger('Bootstrap');
if (MODULE_SYSTEM_LAUNCH.kind === ModuleSystemLaunchDecision.ENABLED_WITH_IGNORED_DISABLE_REQUEST) { if (MODULE_SYSTEM_LAUNCH.kind === ModuleSystemLaunchDecision.ENABLED_WITH_IGNORED_DISABLE_REQUEST) {
logger.warn( 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(); const userDataConfig = configureUserDataPath();
if (userDataConfig.portable) {
writeLogFilesUnder(app.getPath('logs'));
}
armNativeProbeCache(userDataConfig.base); armNativeProbeCache(userDataConfig.base);
if (!app.requestSingleInstanceLock()) { if (!app.requestSingleInstanceLock()) {
@@ -257,14 +269,14 @@ async function runModuleBootstrap(): Promise<void> {
const instancePreference = decideModuleSystemDisableRequest( const instancePreference = decideModuleSystemDisableRequest(
instanceTurnedModulesOff(userDataConfig.base), instanceTurnedModulesOff(userDataConfig.base),
HAS_OFFLINE_RENDERER, CAN_TURN_MODULE_SYSTEM_OFF,
); );
switch (instancePreference.kind) { switch (instancePreference.kind) {
case ModuleSystemDisableDecision.NOT_REQUESTED: case ModuleSystemDisableDecision.NOT_REQUESTED:
break; break;
case ModuleSystemDisableDecision.IGNORED_WITHOUT_OFFLINE_RENDERER: case ModuleSystemDisableDecision.IGNORED_WITHOUT_OFFLINE_RENDERER:
logger.warn( 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; break;
case ModuleSystemDisableDecision.HONOURED: 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) { if (store.shellVersionChanged) {
logger.info('The shell version changed since the last boot, converging the modules before launch'); 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({ const updater = new ModuleUpdater({
store, store,
shellVersion: app.getVersion(), shellVersion: app.getVersion(),
hasOfflineRenderer: HAS_OFFLINE_RENDERER, hasOfflineRenderer: HAS_OFFLINE_RENDERER,
forceStartupUpdate: store.shellVersionChanged, bundledRendererVersion: BUNDLED_RENDERER_VERSION,
preferUnversionedBundle: app.isPackaged,
forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(),
selfUpdateShellFirst: selfUpdateShellFirst:
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
? async (latestVersion, requiredSecurityUpdate) => { ? async (latestVersion, requiredSecurityUpdate) => {
@@ -567,9 +602,12 @@ async function runModuleBootstrap(): Promise<void> {
let servedModulesUpdate: Promise<void> = Promise.resolve(); let servedModulesUpdate: Promise<void> = Promise.resolve();
const serveModules = (resolveModules: () => Readonly<Record<string, string>>): Promise<void> => { const serveModules = (resolveModules: () => Readonly<Record<string, string>>): Promise<void> => {
const update = async (): Promise<void> => { const update = async (): Promise<void> => {
const modules = resolveModules(); const selection = await updater.selectServedModules(resolveModules());
setCommittedModuleFiles(store.storeRoot, await store.buildModuleIndex(modules)); setCommittedModuleFiles(store.storeRoot, await store.buildModuleIndex(selection.modules));
servedModules = 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); const started = servedModulesUpdate.then(update, update);
servedModulesUpdate = started.catch(() => undefined); servedModulesUpdate = started.catch(() => undefined);
@@ -708,6 +746,44 @@ async function runModuleBootstrap(): Promise<void> {
reopenMainWindow(launchAttempt); reopenMainWindow(launchAttempt);
preloadUpdateSplash(); 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({ const desktopUpdate = new DesktopUpdateRun({
@@ -722,7 +798,17 @@ async function runModuleBootstrap(): Promise<void> {
return launchAttempt; return launchAttempt;
}, },
takeover: updateTakeover, 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 () => { openDownloadsPage: async () => {
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads'); const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
await shell.openExternal(DOWNLOAD_PAGE_URL); await shell.openExternal(DOWNLOAD_PAGE_URL);
@@ -732,6 +818,9 @@ async function runModuleBootstrap(): Promise<void> {
const permit = await runModuleUpdateLoop(); const permit = await runModuleUpdateLoop();
localNetworkHint.disarm(); localNetworkHint.disarm();
if (!permit.unreachable) {
recordPendingUpdate({shellNewer: false, modulesChanged: false});
}
await refreshModuleRoots(permit.launchAttempt.committed); await refreshModuleRoots(permit.launchAttempt.committed);
setOnDemandModuleInstaller( setOnDemandModuleInstaller(
createOnDemandModuleInstaller({ createOnDemandModuleInstaller({
@@ -743,7 +832,14 @@ async function runModuleBootstrap(): Promise<void> {
}), }),
); );
markSplashLaunching(); 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, () => { armMainWindowHandoff(updater, permit.launchAttempt, () => {
desktopUpdate.markLaunchSettled(); desktopUpdate.markLaunchSettled();
preloadUpdateSplash(); preloadUpdateSplash();
@@ -48,10 +48,15 @@ describe('Bootstrap entry point', () => {
test('the instance module preference can only be honoured through the offline renderer guard', () => { test('the instance module preference can only be honoured through the offline renderer guard', () => {
assert.match( assert.match(
source, 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.', '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, /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', () => { 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', () => { 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()')); const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
assert.match(bootstrap, /openSplashWindow\(\{darkThemeOnShow: true\}\)/); assert.match(bootstrap, /openSplashWindow\(\{darkThemeOnShow: true\}\)/);
+6
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {APP_PROTOCOL} from '@electron/common/Constants'; import {APP_PROTOCOL} from '@electron/common/Constants';
import {updateFromCommandLine} from '@electron/main/DesktopUpdatePrompt';
import {parseJumpListTaskFromArgv} from '@electron/main/JumpList'; import {parseJumpListTaskFromArgv} from '@electron/main/JumpList';
import {isDesktopUpdateRequested} from '@electron/main/LaunchOptions';
import {recordRecentDeepLink} from '@electron/main/RecentDocuments'; import {recordRecentDeepLink} from '@electron/main/RecentDocuments';
import {getMainWindow, isMainWindowTakenOver, onMainWindowTakeoverEnded, showWindow} from '@electron/main/Window'; import {getMainWindow, isMainWindowTakenOver, onMainWindowTakeoverEnded, showWindow} from '@electron/main/Window';
import {app, ipcMain} from 'electron'; import {app, ipcMain} from 'electron';
@@ -215,6 +217,10 @@ function isSquirrelOrSyntheticArg(arg: string): boolean {
} }
export function handleSecondInstance(argv: Array<string>): void { export function handleSecondInstance(argv: Array<string>): void {
if (isDesktopUpdateRequested(argv)) {
void updateFromCommandLine();
return;
}
const task = parseJumpListTaskFromArgv(argv); const task = parseJumpListTaskFromArgv(argv);
if (task) { if (task) {
dispatchJumpListTask(task); dispatchJumpListTask(task);
+16 -1
View File
@@ -11,6 +11,8 @@ import type {
TrayActionPayload, TrayActionPayload,
TrayRuntimeStatePayload, TrayRuntimeStatePayload,
} from '@electron/common/Types'; } 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 {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
import {onLocaleChange, t} from '@electron/main/MainI18n'; import {onLocaleChange, t} from '@electron/main/MainI18n';
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron'; import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
@@ -72,6 +74,9 @@ export function updateTrayRuntimeState(update: Partial<TrayRuntimeStatePayload>,
if (webContentsId !== undefined) { if (webContentsId !== undefined) {
trayActionBridgeWebContentsId = webContentsId; trayActionBridgeWebContentsId = webContentsId;
} }
if (typeof update.buildInfo === 'string' && update.buildInfo !== trayState.buildInfo) {
logger.info(`The renderer reported its build: ${update.buildInfo}`);
}
Object.assign(trayState, update); Object.assign(trayState, update);
refreshDesktopTrayMenu(); refreshDesktopTrayMenu();
schedulePendingTrayActionFlush(); schedulePendingTrayActionFlush();
@@ -388,9 +393,18 @@ function buildTrayMenu(): Menu {
}); });
} }
menuTemplate.push({type: 'separator'}); menuTemplate.push({type: 'separator'});
if (getDesktopUpdateState().available) {
menuTemplate.push({
label: t('desktop.tray.updateNow', {appName: APP_NAME}),
click: () => runTrayMenuAction(startDesktopUpdateFromShell),
});
}
menuTemplate.push({ menuTemplate.push({
label: t('desktop.tray.checkForUpdates'), label: t('desktop.tray.checkForUpdates'),
click: () => runTrayMenuAction(() => dispatchTrayAction({action: 'check-for-updates'})), click: () =>
runTrayMenuAction(() => {
void checkForUpdatesFromShell();
}),
}); });
if (trayState.buildInfo) { if (trayState.buildInfo) {
menuTemplate.push({ menuTemplate.push({
@@ -533,6 +547,7 @@ export function initializeDesktopTray(nextController: DesktopTrayController): vo
} }
refreshDesktopTrayMenu(); refreshDesktopTrayMenu();
}); });
observeDesktopUpdateState(refreshDesktopTrayMenu);
} }
} }
@@ -25,14 +25,14 @@ function deferred() {
describe('DesktopUpdateGate', () => { describe('DesktopUpdateGate', () => {
test('nothing is available and nothing starts before the bootstrap arms the gate', async () => { test('nothing is available and nothing starts before the bootstrap arms the gate', async () => {
assert.deepEqual(getDesktopUpdateState(), {available: false}); assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
assert.deepEqual(await checkDesktopUpdateNow(), {available: false}); assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false});
assert.equal(startDesktopUpdate(), false); assert.equal(startDesktopUpdate(), false);
}); });
test('a check publishes the update and one click runs it exactly once', async () => { test('a check publishes the update and one click runs it exactly once', async () => {
const states = []; const states = [];
const stop = observeDesktopUpdateState((state) => states.push(state.available)); const stop = observeDesktopUpdateState((state) => states.push(state));
const running = deferred(); const running = deferred();
let starts = 0; let starts = 0;
armDesktopUpdate({ 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(), true);
assert.equal(startDesktopUpdate(), false, 'a second click while the update runs is a no-op'); 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(
assert.deepEqual(await checkDesktopUpdateNow(), {available: false}, 'a poll during the update changes nothing'); 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(); running.resolve();
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.equal(starts, 1); assert.equal(starts, 1);
assert.deepEqual(getDesktopUpdateState(), {available: false}); assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
assert.deepEqual(states, [true, false]); assert.deepEqual(states, [
{available: true, updating: false},
{available: false, updating: true},
{available: false, updating: false},
]);
stop(); stop();
}); });
@@ -71,7 +83,7 @@ describe('DesktopUpdateGate', () => {
assert.equal(startDesktopUpdate(), true); assert.equal(startDesktopUpdate(), true);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(getDesktopUpdateState(), {available: true}); assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false});
assert.equal(startDesktopUpdate(), true); assert.equal(startDesktopUpdate(), true);
}); });
}); });
+13 -2
View File
@@ -22,6 +22,7 @@ interface DesktopUpdateGateState {
lastCheck: DesktopUpdateCheck | null; lastCheck: DesktopUpdateCheck | null;
running: Promise<void> | null; running: Promise<void> | null;
notifiedAvailable: boolean; notifiedAvailable: boolean;
notifiedUpdating: boolean;
readonly listeners: Set<DesktopUpdateStateListener>; readonly listeners: Set<DesktopUpdateStateListener>;
} }
@@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = {
lastCheck: null, lastCheck: null,
running: null, running: null,
notifiedAvailable: false, notifiedAvailable: false,
notifiedUpdating: false,
listeners: new Set(), listeners: new Set(),
}; };
@@ -38,15 +40,24 @@ function isAvailable(check: DesktopUpdateCheck | null): boolean {
} }
export function getDesktopUpdateState(): DesktopUpdateState { 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 { function notify(): void {
const state = getDesktopUpdateState(); const state = getDesktopUpdateState();
if (state.available === gateState.notifiedAvailable) { const updating = state.updating === true;
if (state.available === gateState.notifiedAvailable && updating === gateState.notifiedUpdating) {
return; return;
} }
gateState.notifiedAvailable = state.available; gateState.notifiedAvailable = state.available;
gateState.notifiedUpdating = updating;
for (const listener of Array.from(gateState.listeners)) { for (const listener of Array.from(gateState.listeners)) {
try { try {
listener(state); 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 steps = [];
const published = []; const published = [];
const failures = [];
let probeIndex = 0; let probeIndex = 0;
let shellIndex = 0; let shellIndex = 0;
const quiet = () => undefined; const quiet = () => undefined;
@@ -55,6 +56,9 @@ function harness({
reopen: (launchAttempt) => { reopen: (launchAttempt) => {
steps.push(launchAttempt == null ? 'reopen' : 'reopen-updated'); steps.push(launchAttempt == null ? 'reopen' : 'reopen-updated');
}, },
reloadInPlace: async () => {
steps.push('reload-in-place');
},
}, },
publish: (check) => { publish: (check) => {
published.push(check); published.push(check);
@@ -62,10 +66,13 @@ function harness({
openDownloadsPage: async () => { openDownloadsPage: async () => {
steps.push('downloads-page'); steps.push('downloads-page');
}, },
reportFailure: (failure) => {
failures.push(failure);
},
logger: {info: quiet, warn: quiet, error: quiet}, logger: {info: quiet, warn: quiet, error: quiet},
}); });
if (settled) run.markLaunchSettled(); if (settled) run.markLaunchSettled();
return {run, steps, published}; return {run, steps, published, failures};
} }
describe('DesktopUpdateRun', () => { describe('DesktopUpdateRun', () => {
@@ -195,3 +202,119 @@ describe('DesktopUpdateRun', () => {
assert.deepEqual(steps, []); 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 restore: () => void;
readonly closeApp: () => Promise<void>; readonly closeApp: () => Promise<void>;
readonly reopen: (launchAttempt: ModuleLaunchAttempt | null) => void; readonly reopen: (launchAttempt: ModuleLaunchAttempt | null) => void;
readonly reloadInPlace: (launchAttempt: ModuleLaunchAttempt) => Promise<void>;
} }
interface DesktopUpdateLogger { interface DesktopUpdateLogger {
@@ -35,6 +36,7 @@ interface DesktopUpdateRunOptions {
readonly takeover: DesktopUpdateTakeover; readonly takeover: DesktopUpdateTakeover;
readonly publish: (check: DesktopUpdateCheck) => void; readonly publish: (check: DesktopUpdateCheck) => void;
readonly openDownloadsPage: () => Promise<void>; readonly openDownloadsPage: () => Promise<void>;
readonly reportFailure?: (failure: ShellSelfUpdateResult) => void;
readonly logger: DesktopUpdateLogger; readonly logger: DesktopUpdateLogger;
readonly now?: () => number; readonly now?: () => number;
} }
@@ -44,12 +46,17 @@ interface FilteredProbe {
readonly shellLatestVersion: string; readonly shellLatestVersion: string;
} }
function describeFailure(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class DesktopUpdateRun { export class DesktopUpdateRun {
private readonly options: DesktopUpdateRunOptions; private readonly options: DesktopUpdateRunOptions;
private readonly now: () => number; private readonly now: () => number;
private launchSettled = false; private launchSettled = false;
private shellFeedBehindVersion: string | null = null; private shellFeedBehindVersion: string | null = null;
private shellInstallFailed = false; private shellInstallFailed = false;
private lastShellFailure: ShellSelfUpdateResult | null = null;
private lastCheck: DesktopUpdateCheck | null = null; private lastCheck: DesktopUpdateCheck | null = null;
public constructor(options: DesktopUpdateRunOptions) { public constructor(options: DesktopUpdateRunOptions) {
@@ -66,7 +73,7 @@ export class DesktopUpdateRun {
} }
public async start(): Promise<void> { public async start(): Promise<void> {
const {logger, takeover} = this.options; const {logger} = this.options;
const startedAt = this.now(); const startedAt = this.now();
if (!this.launchSettled) { if (!this.launchSettled) {
logger.warn('Ignoring a desktop update start before the renderer confirmed its launch'); logger.warn('Ignoring a desktop update start before the renderer confirmed its launch');
@@ -77,9 +84,58 @@ export class DesktopUpdateRun {
await this.options.openDownloadsPage(); await this.options.openDownloadsPage();
return; return;
} }
if (this.lastCheck != null && !this.lastCheck.shellNewer && this.lastCheck.modulesChanged) {
await this.startInPlace(startedAt);
return;
}
logger.info('Starting the desktop update'); logger.info('Starting the desktop update');
const probing = this.probe(); const probing = this.probe();
probing.catch(() => {}); 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(); await takeover.begin();
logger.info('The update splash took the app windows over', {elapsedMs: this.now() - startedAt}); logger.info('The update splash took the app windows over', {elapsedMs: this.now() - startedAt});
let probe: FilteredProbe; let probe: FilteredProbe;
@@ -88,6 +144,7 @@ export class DesktopUpdateRun {
} catch (error) { } catch (error) {
logger.warn('The desktop update check failed, giving the app windows back', error); logger.warn('The desktop update check failed, giving the app windows back', error);
takeover.restore(); takeover.restore();
this.options.reportFailure?.({reason: 'check-failed', detail: describeFailure(error)});
return; return;
} }
const {check} = probe; const {check} = probe;
@@ -101,9 +158,11 @@ export class DesktopUpdateRun {
logger.info('Closed the app windows for the update', {elapsedMs: this.now() - startedAt}); logger.info('Closed the app windows for the update', {elapsedMs: this.now() - startedAt});
let launchAttempt: ModuleLaunchAttempt | null = null; let launchAttempt: ModuleLaunchAttempt | null = null;
let remaining = check; let remaining = check;
let failure: ShellSelfUpdateResult | null = null;
try { try {
if (check.shellNewer) { if (check.shellNewer) {
remaining = await this.updateShell(probe); remaining = await this.updateShell(probe);
failure = this.lastShellFailure;
} }
if (check.modulesChanged) { if (check.modulesChanged) {
launchAttempt = await this.options.installModules(); launchAttempt = await this.options.installModules();
@@ -115,16 +174,21 @@ export class DesktopUpdateRun {
} }
} catch (error) { } catch (error) {
logger.error('The desktop update failed, reopening the app on the installed modules', error); logger.error('The desktop update failed, reopening the app on the installed modules', error);
failure = {reason: 'download-failed', detail: describeFailure(error)};
} finally { } finally {
this.publish(remaining); this.publish(remaining);
takeover.reopen(launchAttempt); takeover.reopen(launchAttempt);
} }
if (failure != null) {
this.options.reportFailure?.(failure);
}
} }
private async updateShell(probe: FilteredProbe): Promise<DesktopUpdateCheck> { private async updateShell(probe: FilteredProbe): Promise<DesktopUpdateCheck> {
const {logger} = this.options; const {logger} = this.options;
const result = await this.options.runShellSelfUpdate(); const result = await this.options.runShellSelfUpdate();
logger.warn('The shell self update did not restart the app', result); 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') { if (result.reason === 'no-update') {
this.shellFeedBehindVersion = probe.shellLatestVersion; this.shellFeedBehindVersion = probe.shellLatestVersion;
return {shellNewer: false, modulesChanged: probe.check.modulesChanged}; 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 SPELLCHECK_MODE_ARGS = new Set(['--fluxer-spellcheck']);
const DISABLE_V8_CODE_CACHE_ARGS = new Set(['--fluxer-disable-v8-code-cache']); 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 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 { function hasFlag(argv: ReadonlyArray<string>, flags: ReadonlySet<string>): boolean {
return argv.some((arg) => flags.has(arg) || [...flags].some((flag) => arg.startsWith(`${flag}=`))); 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), 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import {Readable} from 'node:stream'; import {Readable} from 'node:stream';
import {resolveAsarUnpackedPath} from '@electron/common/ModuleSystem';
import {isServedLocalAppExtension, localAppCacheControl, localAppContentType} from '@electron/main/LocalAppMime'; import {isServedLocalAppExtension, localAppCacheControl, localAppContentType} from '@electron/main/LocalAppMime';
import {isLocalAppURL} from '@electron/main/LocalAppURL'; import {isLocalAppURL} from '@electron/main/LocalAppURL';
import {app} from 'electron'; import {app} from 'electron';
@@ -429,7 +430,10 @@ export class DesktopLocalAppFiles {
} }
private rendererRootPath(): string { 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> { 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.disconnectFrom': 'Disconnect from {channel}',
'desktop.tray.disconnectVoice': 'Disconnect from voice', 'desktop.tray.disconnectVoice': 'Disconnect from voice',
'desktop.tray.checkForUpdates': 'Check for updates', '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.copyBuildInfo': 'Copy build info',
'desktop.tray.restart': 'Restart {appName}', 'desktop.tray.restart': 'Restart {appName}',
'desktop.tray.quit': 'Quit {appName}', 'desktop.tray.quit': 'Quit {appName}',
+12
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity'; import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity';
import {checkForUpdatesFromShell} from '@electron/main/DesktopUpdatePrompt';
import {onLocaleChange, t} from '@electron/main/MainI18n'; import {onLocaleChange, t} from '@electron/main/MainI18n';
import {openExternalDeduped} from '@electron/main/OpenExternal'; import {openExternalDeduped} from '@electron/main/OpenExternal';
import {buildTroubleshootingMenuItems} from '@electron/main/Troubleshooting'; 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'; 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> { function buildTemplate(): Array<MenuItemConstructorOptions> {
const appName = DESKTOP_APP_NAME; const appName = DESKTOP_APP_NAME;
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
@@ -21,6 +31,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
role: 'about', role: 'about',
label: t('desktop.appMenu.about', {appName}), label: t('desktop.appMenu.about', {appName}),
}, },
checkForUpdatesItem(),
{type: 'separator'}, {type: 'separator'},
{ {
label: t('desktop.appMenu.preferences'), 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')}${MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT}`
: t('desktop.appMenu.help'), : t('desktop.appMenu.help'),
submenu: [ submenu: [
...(isMac ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
{ {
label: t('desktop.appMenu.website'), label: t('desktop.appMenu.website'),
click: async () => { click: async () => {
@@ -1227,3 +1227,103 @@ describe('ModuleStore boot checks without rehashing', () => {
assert.equal(await store.isInstalled('fluxer_renderer', RENDERER_V1.sha256), false); 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'; const MODULE_STORE_DIRECTORY_NAME = 'modules';
export const MODULE_STATE_FILE_NAME = 'state.json'; export const MODULE_STATE_FILE_NAME = 'state.json';
export const MODULE_STATE_VERSION = 3; export const MODULE_STATE_VERSION = 3;
const MODULE_UNREADABLE_STATE_SUFFIX = '.unreadable';
const MODULE_DOWNLOAD_DIRECTORY_NAME = 'download'; const MODULE_DOWNLOAD_DIRECTORY_NAME = 'download';
const MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME = 'incoming'; const MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME = 'incoming';
const MODULE_STORE_TREE_DIRECTORY_NAME = 'store'; const MODULE_STORE_TREE_DIRECTORY_NAME = 'store';
@@ -1179,6 +1180,7 @@ export class ModuleStore {
public readonly storeRoot: string; public readonly storeRoot: string;
private state: ModuleStoreState; private state: ModuleStoreState;
private convergingShellVersion: string | null = null; private convergingShellVersion: string | null = null;
private unreadableStatePath: string | null = null;
private launchSucceeded = false; private launchSucceeded = false;
private activeLaunchAttempt: ModuleLaunchAttempt | null = null; private activeLaunchAttempt: ModuleLaunchAttempt | null = null;
private readonly installCoordinator = new ModuleInstallCoordinator<ModuleInstallation>(); private readonly installCoordinator = new ModuleInstallCoordinator<ModuleInstallation>();
@@ -1211,9 +1213,26 @@ export class ModuleStore {
await createDirectory(resolved); await createDirectory(resolved);
await createDirectory(path.join(resolved, MODULE_DOWNLOAD_DIRECTORY_NAME, MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME)); await createDirectory(path.join(resolved, MODULE_DOWNLOAD_DIRECTORY_NAME, MODULE_DOWNLOAD_INCOMING_DIRECTORY_NAME));
await createDirectory(path.join(resolved, MODULE_STORE_TREE_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) { if (stateFile.status === ModuleStoreStateFileStatus.MISSING) {
const store = new ModuleStore(resolved, createInitialModuleStoreState(shellVersion, releaseChannel)); const store = new ModuleStore(resolved, createInitialModuleStoreState(shellVersion, releaseChannel));
store.unreadableStatePath = unreadableState;
await store.writeState(store.state); await store.writeState(store.state);
return store; return store;
} }
@@ -1269,6 +1288,10 @@ export class ModuleStore {
} }
} }
public get recoveredUnreadableState(): string | null {
return this.unreadableStatePath;
}
public get shellVersionChanged(): boolean { public get shellVersionChanged(): boolean {
return this.convergingShellVersion != null; 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> { public async mergeCommitted(entries: Readonly<Record<string, string>>): Promise<ModuleStoreState> {
return await this.withStateLock(async () => { return await this.withStateLock(async () => {
const added = await this.resolveInstalled(entries); 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 () => { return await this.withStateLock(async () => {
if (this.state.boot_attempt < MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) { if (this.state.boot_attempt < MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) {
return {rolledBack: false, bootAttempt: this.state.boot_attempt, committed: this.state.committed}; 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); 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 rolledBack = !sameModuleMap(this.state.committed, revertedCommitted);
const rejected: Record<string, string> = {...this.state.rejected}; const rejected: Record<string, string> = {...this.state.rejected};
if (rolledBack) { if (rolledBack) {
@@ -1607,6 +1652,7 @@ export class ModuleStore {
const reverted = await this.writeState({ const reverted = await this.writeState({
...this.state, ...this.state,
committed: revertedCommitted, committed: revertedCommitted,
previous: rolledBack ? revertedCommitted : this.state.previous,
rejected, rejected,
boot_attempt: 0, 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> { public async recordLaunchAttempt(): Promise<ModuleLaunchAttempt> {
return await this.withStateLock(async () => { return await this.withStateLock(async () => {
const next = await this.writeState({...this.state, boot_attempt: this.state.boot_attempt + 1}); 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} = {}) { function createPlanner(
return new ModuleUpdatePlanner(store, parseModuleVersion(shellVersion, 'desktop shell version'), hasOfflineRenderer); 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', () => { 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'; } from '@electron/main/ModuleManifest';
import {MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD, type ModuleStore} from '@electron/main/ModuleStore'; import {MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD, type ModuleStore} from '@electron/main/ModuleStore';
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion'; 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({ export const ModuleUpdaterBlockReason = Object.freeze({
REQUIRED_MODULE_UNAVAILABLE: 'required-module-unavailable', REQUIRED_MODULE_UNAVAILABLE: 'required-module-unavailable',
@@ -46,21 +47,136 @@ export type ModuleUnreachableLaunchDecision =
| {readonly kind: typeof ModuleUnreachableLaunchDecision.LAUNCH} | {readonly kind: typeof ModuleUnreachableLaunchDecision.LAUNCH}
| {readonly kind: typeof ModuleUnreachableLaunchDecision.BLOCK; readonly reason: ModuleUpdaterBlockReason}; | {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 { export class ModuleUpdatePlanner {
private readonly store: ModuleStore; private readonly store: ModuleStore;
private readonly shellVersion: ModuleVersion; private readonly shellVersion: ModuleVersion;
private readonly hasOfflineRenderer: boolean; 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.store = store;
this.shellVersion = shellVersion; this.shellVersion = shellVersion;
this.hasOfflineRenderer = hasOfflineRenderer; this.hasOfflineRenderer = hasOfflineRenderer;
this.bundledRendererVersion = hasOfflineRenderer ? bundledRendererVersion : null;
this.bundleOutranksEveryModule = hasOfflineRenderer && bundledRendererVersion == null && preferUnversionedBundle;
} }
public hasSomethingToRender(committed: Readonly<Record<string, string>>): boolean { public hasSomethingToRender(committed: Readonly<Record<string, string>>): boolean {
return this.hasOfflineRenderer || Object.keys(committed).length > 0; 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 { public isShellCompatible(entry: DesktopModuleManifestEntry): boolean {
if (entry.minimumShellVersion != null && compareModuleVersions(this.shellVersion, entry.minimumShellVersion) < 0) { if (entry.minimumShellVersion != null && compareModuleVersions(this.shellVersion, entry.minimumShellVersion) < 0) {
return false; return false;
@@ -90,6 +206,9 @@ export class ModuleUpdatePlanner {
} }
const committed = this.store.getCommitted(); const committed = this.store.getCommitted();
for (const moduleName of minimum.requiredModules) { for (const moduleName of minimum.requiredModules) {
if (moduleName === DESKTOP_RENDERER_MODULE_NAME && this.bundleCoversRendererAt(minimum.version)) {
continue;
}
const sha256 = committed[moduleName]; const sha256 = committed[moduleName];
if (sha256 == null) { if (sha256 == null) {
return true; return true;
@@ -124,7 +243,7 @@ export class ModuleUpdatePlanner {
if (entry == null) { if (entry == null) {
continue; continue;
} }
if (!this.isShellCompatible(entry)) { if (!this.isShellCompatible(entry) || this.bundleCoversModule(moduleName, manifest)) {
await this.retainCommitted(base, moduleName); await this.retainCommitted(base, moduleName);
continue; continue;
} }
@@ -139,7 +258,10 @@ export class ModuleUpdatePlanner {
items.push({ items.push({
module: moduleName, module: moduleName,
entry, entry,
requirement: blocking.has(moduleName) ? ModulePlanItemRequirement.REQUIRED : ModulePlanItemRequirement.OPTIONAL, requirement:
blocking.has(moduleName) && !this.bundledModules().includes(moduleName)
? ModulePlanItemRequirement.REQUIRED
: ModulePlanItemRequirement.OPTIONAL,
}); });
} }
return {items, base}; return {items, base};
@@ -162,6 +284,9 @@ export class ModuleUpdatePlanner {
if (state.rejected[moduleName] === sha256) { if (state.rejected[moduleName] === sha256) {
continue; continue;
} }
if (this.bundledModules().includes(moduleName)) {
continue;
}
if (state.committed[moduleName] !== sha256 || !(await this.isInstalled(moduleName, sha256))) { if (state.committed[moduleName] !== sha256 || !(await this.isInstalled(moduleName, sha256))) {
below.push(moduleName); below.push(moduleName);
} }
@@ -187,7 +312,10 @@ export class ModuleUpdatePlanner {
securityUpdateRequired: boolean, securityUpdateRequired: boolean,
): Promise<ModuleUnreachableLaunchDecision> { ): Promise<ModuleUnreachableLaunchDecision> {
const state = this.store.getState(); 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}; return {kind: ModuleUnreachableLaunchDecision.BLOCK, reason: ModuleUpdaterBlockReason.NOTHING_INSTALLED};
} }
if (state.boot_attempt >= MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) { if (state.boot_attempt >= MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) {
+210 -3
View File
@@ -39,7 +39,7 @@ function createUserData() {
return root; 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); const directory = path.join(storeRoot, 'store', moduleName, digest);
mkdirSync(directory, {recursive: true}); mkdirSync(directory, {recursive: true});
const files = entries.map(([relative, body]) => { const files = entries.map(([relative, body]) => {
@@ -51,7 +51,7 @@ function installModuleOnDisk(storeRoot, moduleName, digest, entries) {
`${JSON.stringify( `${JSON.stringify(
{ {
module: moduleName, module: moduleName,
build_version: SHELL_VERSION, build_version: buildVersion,
release_channel: RELEASE_CHANNEL, release_channel: RELEASE_CHANNEL,
source_sha: SOURCE_SHA, source_sha: SOURCE_SHA,
files, files,
@@ -121,7 +121,8 @@ function createUpdater(store, modules, options = {}) {
platform: options.platform ?? PLATFORM, platform: options.platform ?? PLATFORM,
arch: ARCH, arch: ARCH,
packageOrigin: PACKAGE_ORIGIN, packageOrigin: PACKAGE_ORIGIN,
hasOfflineRenderer: false, hasOfflineRenderer: options.bundledRendererVersion != null,
bundledRendererVersion: options.bundledRendererVersion ?? null,
forceStartupUpdate: options.forceStartupUpdate ?? false, forceStartupUpdate: options.forceStartupUpdate ?? false,
selfUpdateShellFirst: options.selfUpdateShellFirst, selfUpdateShellFirst: options.selfUpdateShellFirst,
onState: options.onState, onState: options.onState,
@@ -862,3 +863,209 @@ describe('ModuleUpdater after a crash-loop rollback', () => {
assert.equal(store.isRejected('fluxer_renderer', NEXT_RENDERER_SHA), false); 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, ModuleUnreachableLaunchDecision,
ModuleUpdatePlanner, ModuleUpdatePlanner,
ModuleUpdaterBlockReason, ModuleUpdaterBlockReason,
type ServedModuleSelection,
} from '@electron/main/ModuleUpdatePlanner'; } from '@electron/main/ModuleUpdatePlanner';
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion'; import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
import {resolveDesktopPackageOrigin} from '@electron/main/ShellDownloadFormats'; import {resolveDesktopPackageOrigin} from '@electron/main/ShellDownloadFormats';
import type {DesktopModuleEnsureResult} from '@fluxer/desktop_ipc/src/ModuleContract'; 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_BASE_MS = 1000;
const MODULE_UPDATER_BACKOFF_CAP_MS = 30000; const MODULE_UPDATER_BACKOFF_CAP_MS = 30000;
@@ -82,6 +83,7 @@ const ModuleUpdaterReportType = Object.freeze({
STORAGE_ERROR: 'storage-error', STORAGE_ERROR: 'storage-error',
ROLLBACK: 'rollback', ROLLBACK: 'rollback',
ROLLBACK_FLOOR_BYPASS: 'rollback-floor-bypass', ROLLBACK_FLOOR_BYPASS: 'rollback-floor-bypass',
BUNDLED_RENDERER_PREFERRED: 'bundled-renderer-preferred',
BLOCKED: 'blocked', BLOCKED: 'blocked',
} as const); } as const);
@@ -97,6 +99,7 @@ const MODULE_UPDATER_REPORT_SAMPLE_RATES: Readonly<Record<ModuleUpdaterReportTyp
[ModuleUpdaterReportType.STORAGE_ERROR]: 1, [ModuleUpdaterReportType.STORAGE_ERROR]: 1,
[ModuleUpdaterReportType.ROLLBACK]: 1, [ModuleUpdaterReportType.ROLLBACK]: 1,
[ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS]: 1, [ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS]: 1,
[ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED]: 1,
[ModuleUpdaterReportType.BLOCKED]: 1, [ModuleUpdaterReportType.BLOCKED]: 1,
}); });
@@ -198,6 +201,17 @@ function isUpdateServerUnreachable(error: unknown): boolean {
return false; 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> { function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
@@ -263,6 +277,8 @@ interface ModuleUpdaterOptions {
readonly arch?: NodeJS.Architecture; readonly arch?: NodeJS.Architecture;
readonly packageOrigin?: string; readonly packageOrigin?: string;
readonly hasOfflineRenderer?: boolean; readonly hasOfflineRenderer?: boolean;
readonly bundledRendererVersion?: string | null;
readonly preferUnversionedBundle?: boolean;
readonly forceStartupUpdate?: boolean; readonly forceStartupUpdate?: boolean;
readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>; readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>;
readonly onState?: (state: ModuleUpdaterSplashState) => void; readonly onState?: (state: ModuleUpdaterSplashState) => void;
@@ -319,7 +335,13 @@ export class ModuleUpdater {
this.manifestFeed = {releaseChannel, platform: this.platform, arch}; this.manifestFeed = {releaseChannel, platform: this.platform, arch};
const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, ''); const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, '');
this.packageOrigin = packageOrigin; 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.onState = options.onState ?? (() => {});
this.reporter = options.report ?? (() => {}); this.reporter = options.report ?? (() => {});
this.sleep = options.sleep ?? defaultSleep; this.sleep = options.sleep ?? defaultSleep;
@@ -395,7 +417,12 @@ export class ModuleUpdater {
} }
public async run(): Promise<ModuleUpdaterOutcome> { 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(); await this.restoreCachedUpdatePolicy();
if (attempt.rolledBack) { if (attempt.rolledBack) {
this.report({ this.report({
@@ -562,6 +589,37 @@ export class ModuleUpdater {
return {module: moduleName, status: DesktopModuleEnsureStatus.INSTALLED}; 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> { private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument()); return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
} }
@@ -571,7 +629,8 @@ export class ModuleUpdater {
if ( if (
this.selfUpdateShellFirst == null || this.selfUpdateShellFirst == null ||
compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 || compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 ||
this.shellFirstAttempted.has(latestVersion) this.shellFirstAttempted.has(latestVersion) ||
(this.platform === 'linux' && !this.isSecurityUpdateRequired())
) { ) {
return false; return false;
} }
@@ -623,7 +682,7 @@ export class ModuleUpdater {
const wanted: Array<ModulePlanItem> = []; const wanted: Array<ModulePlanItem> = [];
for (const moduleName of Array.from(names).sort()) { for (const moduleName of Array.from(names).sort()) {
const entry = manifest.modules[moduleName]; const entry = manifest.modules[moduleName];
if (entry == null || !planner.isShellCompatible(entry)) { if (entry == null || !planner.isShellCompatible(entry) || planner.bundleCoversModule(moduleName, manifest)) {
continue; continue;
} }
if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) { if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) {
@@ -668,6 +727,21 @@ export class ModuleUpdater {
return {installed, unresolved}; 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> { public async markLaunchSucceeded(attempt: ModuleLaunchAttempt): Promise<void> {
if (!(await this.store.markLaunchAttemptSucceeded(attempt))) { if (!(await this.store.markLaunchAttemptSucceeded(attempt))) {
return; return;
@@ -946,6 +1020,9 @@ export class ModuleUpdater {
} }
} else { } else {
for (const moduleName of blockingModuleNames(manifest)) { for (const moduleName of blockingModuleNames(manifest)) {
if (this.planner.bundleCoversModule(moduleName, manifest)) {
continue;
}
floor[moduleName] = manifest.modules[moduleName].sha256; floor[moduleName] = manifest.modules[moduleName].sha256;
} }
} }
@@ -149,6 +149,7 @@ installElectronStub({
const { const {
getElectronUpdateFeedUrl, getElectronUpdateFeedUrl,
resetElectronUpdateSessionForTests,
runShellSelfUpdate, runShellSelfUpdate,
SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS, SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS,
SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS, SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS,
@@ -242,6 +243,7 @@ beforeEach(() => {
rmSync(applyStatePath, {force: true}); rmSync(applyStatePath, {force: true});
velopack.constructedWith.length = 0; velopack.constructedWith.length = 0;
autoUpdater.handlers.clear(); autoUpdater.handlers.clear();
resetElectronUpdateSessionForTests();
autoUpdater.feedUrls.length = 0; autoUpdater.feedUrls.length = 0;
autoUpdater.checks = 0; autoUpdater.checks = 0;
autoUpdater.installs = 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', () => { describe('electron self update', () => {
selfUpdateTest('points squirrel at the json feed with an identifying user agent', async ({start}) => { selfUpdateTest('points squirrel at the json feed with an identifying user agent', async ({start}) => {
const {hooks} = createHooks(); const {hooks} = createHooks();
@@ -461,7 +491,7 @@ describe('electron self update', () => {
assert.equal(autoUpdater.checks, 1); 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 {calls, hooks} = createHooks();
const promise = start('electron', hooks); const promise = start('electron', hooks);
@@ -471,7 +501,13 @@ describe('electron self update', () => {
assert.deepEqual(await promise, {reason: 'no-update', detail: null}); assert.deepEqual(await promise, {reason: 'no-update', detail: null});
assert.deepEqual(calls, []); 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( selfUpdateTest(
@@ -481,7 +517,12 @@ describe('electron self update', () => {
const {hooks} = createHooks(); const {hooks} = createHooks();
assert.deepEqual(await start('electron', hooks), {reason: 'check-failed', detail: 'Update URL is not set'}); 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'); autoUpdater.emit('update-downloaded');
assert.deepEqual(await promise, {reason: 'install-failed', detail: 'the app is on a read only mount'}); 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}) => { selfUpdateTest(
const {hooks} = createHooks(); 'a silent squirrel hits the check deadline and a late download installs on the next run',
const promise = start('electron', hooks); 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.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'});
assert.equal(autoUpdater.totalListenerCount(), 0);
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}) => { selfUpdateTest('a download that never finishes hits the total deadline', async ({start, fireTimer}) => {
const {hooks} = createHooks(); const {hooks} = createHooks();
@@ -561,6 +647,5 @@ describe('electron self update', () => {
fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS);
assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update did not finish'}); 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 { import {
type AppImageTarget, type AppImageTarget,
applyStagedAppImageUpdate, applyStagedAppImageUpdate,
discardStagedAppImageUpdate,
type StagedAppImageUpdate, type StagedAppImageUpdate,
stageAppImageUpdate, stageAppImageUpdate,
} from '@electron/main/AppImageUpdate'; } from '@electron/main/AppImageUpdate';
@@ -89,6 +88,37 @@ function getStagedVelopackVersion(staged: UpdateInfo | VelopackAsset): string |
return asset.Version ?? null; 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> { async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): Promise<void> {
const {UpdateManager} = requireModule('velopack') as typeof import('velopack'); const {UpdateManager} = requireModule('velopack') as typeof import('velopack');
const manager = new UpdateManager(getUpdateBaseUrl()); const manager = new UpdateManager(getUpdateBaseUrl());
@@ -120,7 +150,7 @@ async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSel
control.clearCheckDeadline(); control.clearCheckDeadline();
hooks.onDownloading(0); hooks.onDownloading(0);
try { try {
await manager.downloadUpdateAsync(available, (percent) => { await downloadVelopackUpdateOnce(manager, available, (percent) => {
if (control.isSettled()) return; if (control.isSettled()) return;
hooks.onDownloading(percent); 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}; 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( async function runAppImageSelfUpdate(
control: SelfUpdateControl, control: SelfUpdateControl,
hooks: ShellSelfUpdateHooks, hooks: ShellSelfUpdateHooks,
@@ -194,25 +264,19 @@ async function runAppImageSelfUpdate(
hooks.onDownloading(0); hooks.onDownloading(0);
let staged: StagedAppImageUpdate; let staged: StagedAppImageUpdate;
try { try {
staged = await stageAppImageUpdate({ staged = await stageAppImageUpdateOnce(target, published.version, published.sha256, (percent) => {
target, if (control.isSettled()) return;
url: `${getUpdateBaseUrl()}/${published.version}/appimage`, hooks.onDownloading(percent);
expectedSha256: published.sha256,
fetchImpl: moduleNetworkFetch,
onProgress: ({transferred, total}) => {
if (control.isSettled() || total <= 0) return;
hooks.onDownloading(Math.min(100, (transferred / total) * 100));
},
}); });
} catch (error) { } catch (error) {
control.settle({reason: 'download-failed', detail: errorDetail(error)}); control.settle({reason: 'download-failed', detail: errorDetail(error)});
return; return;
} }
if (control.isSettled()) { if (control.isSettled()) {
discardStagedAppImageUpdate(staged);
return; return;
} }
hooks.onRestarting(); hooks.onRestarting();
appImageStaging = null;
try { try {
applyStagedAppImageUpdate(target, staged); applyStagedAppImageUpdate(target, staged);
} catch (error) { } catch (error) {
@@ -223,17 +287,75 @@ async function runAppImageSelfUpdate(
app.quit(); 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 { function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): void {
let downloading = false; armElectronUpdateSession();
const onUpdateAvailable = (): void => { const install = (): void => {
downloading = true;
control.clearCheckDeadline();
hooks.onDownloading(null);
};
const onUpdateNotAvailable = (): void => {
control.settle({reason: 'no-update', detail: null});
};
const onUpdateDownloaded = (): void => {
control.clearCheckDeadline(); control.clearCheckDeadline();
hooks.onRestarting(); hooks.onRestarting();
try { try {
@@ -242,20 +364,48 @@ function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdat
control.settle({reason: 'install-failed', detail: errorDetail(error)}); control.settle({reason: 'install-failed', detail: errorDetail(error)});
} }
}; };
const onError = (error: Error): void => { const observer = (event: ElectronUpdateEvent): void => {
control.settle({reason: downloading ? 'download-failed' : 'check-failed', detail: errorDetail(error)}); 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); electronUpdateSession.observers.add(observer);
autoUpdater.on('update-not-available', onUpdateNotAvailable);
autoUpdater.on('update-downloaded', onUpdateDownloaded);
autoUpdater.on('error', onError);
control.addCleanup(() => { control.addCleanup(() => {
autoUpdater.removeListener('update-available', onUpdateAvailable); electronUpdateSession.observers.delete(observer);
autoUpdater.removeListener('update-not-available', onUpdateNotAvailable);
autoUpdater.removeListener('update-downloaded', onUpdateDownloaded);
autoUpdater.removeListener('error', onError);
}); });
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 { try {
electronUpdateSession.phase = ElectronUpdatePhase.CHECKING;
autoUpdater.setFeedURL({ autoUpdater.setFeedURL({
url: getElectronUpdateFeedUrl(), url: getElectronUpdateFeedUrl(),
serverType: 'json', serverType: 'json',
@@ -263,6 +413,7 @@ function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdat
}); });
autoUpdater.checkForUpdates(); autoUpdater.checkForUpdates();
} catch (error) { } catch (error) {
electronUpdateSession.phase = ElectronUpdatePhase.IDLE;
control.settle({reason: 'check-failed', detail: errorDetail(error)}); control.settle({reason: 'check-failed', detail: errorDetail(error)});
} }
} }
+9
View File
@@ -136,6 +136,15 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis
return info; 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( function sendManualUpdateAvailable(
getMainWindow: () => BrowserWindow | null, getMainWindow: () => BrowserWindow | null,
context: UpdaterContext, context: UpdaterContext,
+52 -2
View File
@@ -49,6 +49,7 @@ const LIVE_RESIZE_IDLE_MS = 400;
const VISIBILITY_MARGIN = 32; const VISIBILITY_MARGIN = 32;
const RENDERER_GONE_REPEAT_WINDOW_MS = 30000; const RENDERER_GONE_REPEAT_WINDOW_MS = 30000;
const CLOSE_FOR_UPDATE_TIMEOUT_MS = 5000; 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({ const THEME_WINDOW_BACKGROUND_COLORS: Readonly<Record<string, string>> = Object.freeze({
dark: '#1a181e', dark: '#1a181e',
light: '#ebecef', light: '#ebecef',
@@ -156,6 +157,7 @@ let themeStudioPopoutWindow: BrowserWindow | null = null;
let lastRestorableMainWindowMaximized = false; let lastRestorableMainWindowMaximized = false;
let mainWindowRendererGone = false; let mainWindowRendererGone = false;
let closingMainWindowForUpdate = false; let closingMainWindowForUpdate = false;
const SHELL_PAGE_URL_PREFIX = 'file:';
let mainWindowTakeover: (() => void) | null = null; let mainWindowTakeover: (() => void) | null = null;
const takeoverEndedListeners = new Set<() => void>(); const takeoverEndedListeners = new Set<() => void>();
let pendingMainWindowReveal: {readonly window: BrowserWindow; readonly requestShow: () => void} | null = null; let pendingMainWindowReveal: {readonly window: BrowserWindow; readonly requestShow: () => void} | null = null;
@@ -1217,6 +1219,45 @@ export function isMainWindowTakenOver(): boolean {
return mainWindowTakeover != null; 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> { export function hideAppWindowsForUpdate(keep: BrowserWindow): ReadonlyArray<BrowserWindow> {
const hidden: Array<BrowserWindow> = []; const hidden: Array<BrowserWindow> = [];
for (const window of BrowserWindow.getAllWindows()) { for (const window of BrowserWindow.getAllWindows()) {
@@ -1234,8 +1275,17 @@ export function restoreAppWindowsAfterUpdate(hidden: ReadonlyArray<BrowserWindow
} }
} }
export async function closeAppWindowsForUpdate(keep: BrowserWindow): Promise<void> { function showsShellPage(window: BrowserWindow): boolean {
const closing = BrowserWindow.getAllWindows().filter((window) => window !== keep && !window.isDestroyed()); 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; closingMainWindowForUpdate = true;
try { try {
await Promise.all( await Promise.all(
+2 -2
View File
@@ -9,6 +9,7 @@ import {
WINDOWS_APP_USER_MODEL_ID, WINDOWS_APP_USER_MODEL_ID,
WINDOWS_TOAST_ACTIVATOR_CLSID, WINDOWS_TOAST_ACTIVATOR_CLSID,
} from '@electron/common/DesktopIdentity'; } from '@electron/common/DesktopIdentity';
import {writeLogFilesUnder} from '@electron/common/Logger';
import {configureUserDataPath} from '@electron/common/UserDataPath'; import {configureUserDataPath} from '@electron/common/UserDataPath';
import { import {
APP_STORE_ADDON_PACKAGE, APP_STORE_ADDON_PACKAGE,
@@ -151,8 +152,7 @@ process.on('unhandledRejection', (reason, promise) => {
const userDataConfig = configureUserDataPath(); const userDataConfig = configureUserDataPath();
if (userDataConfig.portable) { if (userDataConfig.portable) {
const portableLogsPath = app.getPath('logs'); writeLogFilesUnder(app.getPath('logs'));
log.transports.file.resolvePathFn = (variables) => path.join(portableLogsPath, variables.fileName ?? 'main.log');
} }
armNativeProbeCache(userDataConfig.base); armNativeProbeCache(userDataConfig.base);
const processConfiguredAt = Date.now(); const processConfiguredAt = Date.now();
+1
View File
@@ -65,6 +65,7 @@
"src/preload/index.ts", "src/preload/index.ts",
"src/preload/LegacyHarvestPreload.ts", "src/preload/LegacyHarvestPreload.ts",
"src/main/**/*.test.{ts,mjs}", "src/main/**/*.test.{ts,mjs}",
"scripts/release-check.mjs",
"native/*/index.{js,d.ts}", "native/*/index.{js,d.ts}",
"native/*/loader-diagnostics.cjs", "native/*/loader-diagnostics.cjs",
"native/*/*.test.mjs", "native/*/*.test.mjs",
+3 -2
View File
@@ -15,15 +15,15 @@ export const DESKTOP_UPDATE_EVENTS = Object.freeze({
} as const); } as const);
const DESKTOP_MODULE_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u; const DESKTOP_MODULE_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u;
export const DESKTOP_GRAMMAR_MODULE_NAME = 'fluxer_grammars'; export const DESKTOP_RENDERER_MODULE_NAME = 'fluxer_renderer';
export const DESKTOP_TWEMOJI_MODULE_NAME = 'fluxer_twemoji'; export const DESKTOP_TWEMOJI_MODULE_NAME = 'fluxer_twemoji';
export const DESKTOP_DEEP_FILTER_MODULE_NAME = 'fluxer_deepfilter'; export const DESKTOP_DEEP_FILTER_MODULE_NAME = 'fluxer_deepfilter';
export const DESKTOP_CAMERA_EFFECTS_MODULE_NAME = 'fluxer_camera_effects';
export const DESKTOP_EMOJI_SPRITES_MODULE_NAME = 'fluxer_emoji_sprites'; export const DESKTOP_EMOJI_SPRITES_MODULE_NAME = 'fluxer_emoji_sprites';
export const DESKTOP_EXTRAS_MODULE_NAME = 'fluxer_extras'; export const DESKTOP_EXTRAS_MODULE_NAME = 'fluxer_extras';
export const DESKTOP_SOURCEMAP_MODULE_NAME = 'fluxer_sourcemaps'; export const DESKTOP_SOURCEMAP_MODULE_NAME = 'fluxer_sourcemaps';
export const DESKTOP_FONT_MODULE_NAMES = Object.freeze({ export const DESKTOP_FONT_MODULE_NAMES = Object.freeze({
'non-latin': 'fluxer_fonts_nonlatin',
sc: 'fluxer_fonts_sc', sc: 'fluxer_fonts_sc',
tc: 'fluxer_fonts_tc', tc: 'fluxer_fonts_tc',
jp: 'fluxer_fonts_jp', jp: 'fluxer_fonts_jp',
@@ -60,6 +60,7 @@ export interface DesktopModuleAPI {
export interface DesktopUpdateState { export interface DesktopUpdateState {
readonly available: boolean; readonly available: boolean;
readonly updating?: boolean;
} }
export interface DesktopUpdateAPI { export interface DesktopUpdateAPI {
+15 -3
View File
@@ -456,6 +456,14 @@ pub fn build_desktop(
let dist_renderer = DESKTOP_DIR.join("dist/renderer"); let dist_renderer = DESKTOP_DIR.join("dist/renderer");
remove_path(&dist_renderer)?; remove_path(&dist_renderer)?;
copy_tree(&renderer, &dist_renderer, &|_| false)?; copy_tree(&renderer, &dist_renderer, &|_| false)?;
if delivery == RendererDelivery::Modules {
let version_file = dist_renderer.join("version.json");
fs::write(
&version_file,
serde_json::to_vec(&serde_json::json!({ "version": version }))?,
)
.with_context(|| format!("failed to write {}", version_file.display()))?;
}
{ {
let _guard = BuildChannelFileGuard::capture(); let _guard = BuildChannelFileGuard::capture();
run_command( run_command(
@@ -575,7 +583,7 @@ pub fn electron_builder_args(
"-c.mac.notarize=false".to_owned(), "-c.mac.notarize=false".to_owned(),
]; ];
if *signing == MacSigning::AdHoc { if *signing == MacSigning::AdHoc {
args.push("-c.mac.identity=-".to_owned()); args.push("-c.mac.sign.identity=-".to_owned());
} }
if !extra_args if !extra_args
.iter() .iter()
@@ -1217,9 +1225,13 @@ mod tests {
assert!(args.contains(&"-c.mac.notarize=false".to_owned())); assert!(args.contains(&"-c.mac.notarize=false".to_owned()));
assert!(args.contains(&"-c.directories.output=/tmp/out".to_owned())); assert!(args.contains(&"-c.directories.output=/tmp/out".to_owned()));
assert!(args.contains(&"--arm64".to_owned())); assert!(args.contains(&"--arm64".to_owned()));
assert!(!args.iter().any(|arg| arg.starts_with("-c.mac.identity"))); assert!(
!args
.iter()
.any(|arg| arg.starts_with("-c.mac.sign.identity"))
);
let ad_hoc = electron_builder_args(Path::new("/tmp/out"), "x64", &MacSigning::AdHoc, &[]); let ad_hoc = electron_builder_args(Path::new("/tmp/out"), "x64", &MacSigning::AdHoc, &[]);
assert!(ad_hoc.contains(&"-c.mac.identity=-".to_owned())); assert!(ad_hoc.contains(&"-c.mac.sign.identity=-".to_owned()));
assert!(ad_hoc.contains(&"--x64".to_owned())); assert!(ad_hoc.contains(&"--x64".to_owned()));
let linux = electron_builder_args( let linux = electron_builder_args(
Path::new("/tmp/out"), Path::new("/tmp/out"),