fix(desktop): shell-compatible module updates, no linux walls (#3452)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 22:07:00 +02:00
1 parent f501d34179
commit 959b03b5f4
73 files changed
+4142 -4311

No files matched your search

+12 -27
View File
@@ -104,7 +104,6 @@ const SPLASH_STATUS_BY_UPDATER_STATUS: Readonly<Record<ModuleUpdaterStatus, Spla
function toSplashState(state: ModuleUpdaterSplashState): SplashState {
return {
status: state.stalled ? 'download-stalled' : SPLASH_STATUS_BY_UPDATER_STATUS[state.status],
requiredSecurityUpdate: state.requiredSecurityUpdate,
current: state.current,
total: state.total,
progress: state.progress,
@@ -339,19 +338,17 @@ async function runModuleBootstrap(): Promise<void> {
};
armSplashActions();
const runShellSelfUpdateOnSplash = async (
requiredSecurityUpdate: boolean,
): Promise<{readonly reason: string; readonly detail: string | null}> => {
const runShellSelfUpdateOnSplash = async (): Promise<{readonly reason: string; readonly detail: string | null}> => {
if (shellUpdatePlan.capability !== ShellUpdateCapability.SELF_UPDATE) {
return {reason: 'unsupported', detail: null};
}
const {runShellSelfUpdate} = await import('@electron/main/ShellSelfUpdate');
return await runShellSelfUpdate(shellUpdatePlan, {
onDownloading: (progress) => {
setSplashState({status: 'shell-update-downloading', requiredSecurityUpdate, progress});
setSplashState({status: 'shell-update-downloading', progress});
},
onRestarting: () => {
setSplashState({status: 'shell-update-restarting', requiredSecurityUpdate});
setSplashState({status: 'shell-update-restarting'});
},
});
};
@@ -404,7 +401,7 @@ async function runModuleBootstrap(): Promise<void> {
}
const recordPendingUpdate = (check: {readonly shellNewer: boolean; readonly modulesChanged: boolean}): void => {
try {
if (check.modulesChanged) {
if (check.modulesChanged || check.shellNewer) {
fs.writeFileSync(pendingUpdateMarker, '');
} else {
fs.rmSync(pendingUpdateMarker, {force: true});
@@ -422,13 +419,14 @@ async function runModuleBootstrap(): Promise<void> {
forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(),
selfUpdateShellFirst:
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
? async (latestVersion, requiredSecurityUpdate) => {
? async (latestVersion) => {
logger.info('A newer shell is available, updating it before the modules', {latestVersion});
const result = await runShellSelfUpdateOnSplash(requiredSecurityUpdate);
const result = await runShellSelfUpdateOnSplash();
logger.warn('The shell self update did not restart the app, converging the modules for this shell', {
latestVersion,
...result,
});
return result;
}
: undefined,
fetch: moduleNetworkFetch,
@@ -461,11 +459,7 @@ async function runModuleBootstrap(): Promise<void> {
outcome = await updater.run();
} catch (error) {
logger.error('The module updater failed', error);
return await blockUntilRelaunch(
updater.getLastState().requiredSecurityUpdate
? 'blocked-security-update-required'
: 'blocked-update-required',
);
return await blockUntilRelaunch('blocked-update-required');
}
switch (outcome.status) {
case 'launching':
@@ -490,12 +484,8 @@ async function runModuleBootstrap(): Promise<void> {
minimumVersion: outcome.minimumVersion,
capability: shellUpdatePlan.capability,
});
if (shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE) {
const failure = await runShellSelfUpdateOnSplash(outcome.requiredSecurityUpdate);
logger.error('The shell self update failed, falling back to a manual download', failure);
}
openSplashWindow();
armBlockedShellUpdate(shellUpdatePlan, outcome.latestVersion, outcome.requiredSecurityUpdate);
armBlockedShellUpdate();
setSecondInstanceSink(focusSplashWindow);
return await new Promise<never>(() => {});
}
@@ -507,17 +497,12 @@ async function runModuleBootstrap(): Promise<void> {
updateServerUnreachable: outcome.updateServerUnreachable,
});
if (outcome.updateServerUnreachable) {
showBlockedSplash(
outcome.requiredSecurityUpdate ? 'blocked-security-update-required' : 'blocked-update-unreachable',
UPDATE_SERVER_UNREACHABLE_MESSAGE,
);
showBlockedSplash('blocked-update-unreachable', UPDATE_SERVER_UNREACHABLE_MESSAGE);
await updateServerRetry.holdUntilNextAttempt();
logger.info('Retrying the update server');
return null;
}
return await blockUntilRelaunch(
outcome.requiredSecurityUpdate ? 'blocked-security-update-required' : 'blocked-update-required',
);
return await blockUntilRelaunch('blocked-update-required');
default:
return unhandledModuleUpdaterOutcome(outcome);
}
@@ -723,7 +708,7 @@ async function runModuleBootstrap(): Promise<void> {
const desktopUpdate = new DesktopUpdateRun({
probe: () => updater.checkForUpdate(),
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(false),
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(),
installModules: async () => {
const launchAttempt = await updater.installPending();
if (launchAttempt != null) {
+14 -22
View File
@@ -68,19 +68,20 @@ describe('Bootstrap entry point', () => {
'Relaunching runs the same binary, refetches the same manifest and blocks again. On Linux, portable and Flatpak the shell cannot self update at all, so a retry is an unescapable loop.',
);
assert.match(source, /const shellUpdatePlan = resolveShellUpdatePlan\(\);/);
assert.match(source, /armBlockedShellUpdate\(\);/);
});
test('a shell that can update itself tries to before the modules, and the block only arms the download page', () => {
assert.match(
source,
/armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
/shellUpdatePlan\.capability === ShellUpdateCapability\.SELF_UPDATE\n\t+\? async \(latestVersion\) => \{/,
);
});
test('a shell that can update itself tries to, and still falls through to the manual screen when it cannot', () => {
assert.match(source, /if \(shellUpdatePlan\.capability === ShellUpdateCapability\.SELF_UPDATE\) \{/);
assert.match(source, /return await runShellSelfUpdate\(shellUpdatePlan, \{/);
assert.match(
source,
/logger\.error\('The shell self update failed, falling back to a manual download', failure\);\n\t{5}\}\n\t{5}openSplashWindow\(\);\n\t{5}armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
'The manual screen is the fallback, so arming it must sit after the self update attempt rather than inside its else branch.',
/capability: shellUpdatePlan\.capability,\n\t{5}\}\);\n\t{5}openSplashWindow\(\);\n\t{5}armBlockedShellUpdate\(\);/,
'The updater already attempted the self update before it returned the block, so the block must not try again.',
);
assert.doesNotMatch(source, /The shell self update failed, falling back to a manual download/);
});
test('the self update is imported inside the update loop, never at the top level', () => {
assert.doesNotMatch(
@@ -92,16 +93,13 @@ describe('Bootstrap entry point', () => {
});
test('the self update paints its own progress rather than leaving the checking screen up', () => {
assert.match(source, /setSplashState\(\{status: 'shell-update-downloading', requiredSecurityUpdate, progress\}\);/);
assert.match(source, /setSplashState\(\{status: 'shell-update-restarting', requiredSecurityUpdate\}\);/);
assert.match(source, /await runShellSelfUpdateOnSplash\(outcome\.requiredSecurityUpdate\)/);
assert.match(source, /setSplashState\(\{status: 'shell-update-downloading', progress\}\);/);
assert.match(source, /setSplashState\(\{status: 'shell-update-restarting'\}\);/);
assert.match(source, /const result = await runShellSelfUpdateOnSplash\(\);/);
});
test('the retry affordance still belongs to the module update block', () => {
assert.match(source, /setSplashState\(\{status, action: SplashAction\.RETRY, message\}\);/);
assert.match(
source,
/return await blockUntilRelaunch\(\n\t+outcome\.requiredSecurityUpdate \? 'blocked-security-update-required' : 'blocked-update-required',\n\t+\);/,
);
assert.match(source, /return await blockUntilRelaunch\('blocked-update-required'\);/);
});
test('the retry listener is registered once, beside the quit listener, never inside the block helper', () => {
@@ -170,16 +168,13 @@ describe('Bootstrap entry point', () => {
/setSplashState\(\{status, action: SplashAction\.RETRY, message\}\);\n\t+setSecondInstanceSink\(focusSplashWindow\);/,
'blockUntilRelaunch never settles, so index.ts never installs handleSecondInstance and the buffered launches would sit there forever. Pointing the sink at the splash is the only response left.',
);
assert.match(
source,
/armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);\n\t+setSecondInstanceSink\(focusSplashWindow\);/,
);
assert.match(source, /armBlockedShellUpdate\(\);\n\t+setSecondInstanceSink\(focusSplashWindow\);/);
});
test('an unreachable update server is retried in process instead of waiting for a click', () => {
const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
assert.match(
bootstrap,
/if \(outcome\.updateServerUnreachable\) \{\n\t+showBlockedSplash\([^;]*UPDATE_SERVER_UNREACHABLE_MESSAGE,\n\t+\);\n\t+await updateServerRetry\.holdUntilNextAttempt\(\);\n[^\n]*\n\t+return null;/,
/if \(outcome\.updateServerUnreachable\) \{\n\t+showBlockedSplash\('blocked-update-unreachable', UPDATE_SERVER_UNREACHABLE_MESSAGE\);\n\t+await updateServerRetry\.holdUntilNextAttempt\(\);\n[^\n]*\n\t+return null;/,
'blockUntilRelaunch never settles, so a splash that reached it while the network was down stayed there after the network came back until the user pressed Retry now.',
);
assert.match(bootstrap, /onSplashNetworkOnline\(\(\) => \{\n[^\n]*\n\t+updateServerRetry\.networkReturned\(\);/);
@@ -232,10 +227,7 @@ describe('Bootstrap entry point', () => {
/const showBlockedSplash = \(status: SplashStatus, message: string \| null = null\): void => \{\n\t\topenSplashWindow\(\);\n\t\tsetSplashState\(\{status, action: SplashAction\.RETRY, message\}\);/,
'A blocked or failed boot never reaches the main window, so a minimized login launch would otherwise sit invisible forever with nothing in the tray to bring it back.',
);
assert.match(
source,
/openSplashWindow\(\);\n\t+armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
);
assert.match(source, /openSplashWindow\(\);\n\t+armBlockedShellUpdate\(\);/);
});
test('the update splash hides the app only once it is on screen, in its own theme', () => {
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {describe, test} from 'node:test';
import {fileURLToPath} from 'node:url';
import vm from 'node:vm';
const require = createRequire(import.meta.url);
const esbuild = require('esbuild');
const sourcePath = fileURLToPath(new URL('./DesktopUpdatePrompt.ts', import.meta.url));
const transformedSource = esbuild.transformSync(readFileSync(sourcePath, 'utf8'), {
loader: 'ts',
format: 'cjs',
platform: 'node',
target: 'node20',
}).code;
const DOWNLOAD_PAGE_URL = 'https://fluxer.app/download';
const FLATPAK_APP_ID = 'app.fluxer.FluxerDesktop';
const NEWER_SHELL = '2026.1013.15209';
function loadPrompt({plan, flatpakAppId = null, response = 0}) {
const dialogs = [];
const opened = [];
const logger = {info: () => {}, warn: () => {}, error: () => {}};
function requireStub(specifier) {
switch (specifier) {
case '@electron/common/DesktopIdentity':
return {DESKTOP_APP_NAME: 'Fluxer'};
case '@electron/common/ErrorMessage':
return {errorMessage: (error) => String(error)};
case '@electron/common/Logger':
return {createChildLogger: () => logger};
case '@electron/main/DesktopUpdateGate':
return {
checkDesktopUpdateNow: async () => ({available: false}),
desktopUpdateReplacesShell: () => false,
getDesktopUpdateState: () => ({available: false}),
startDesktopUpdate: () => false,
};
case '@electron/main/LinuxSandbox':
return {getFlatpakAppId: () => flatpakAppId, isFlatpakRuntime: () => flatpakAppId != null};
case '@electron/main/MainI18n':
return {t: (key) => key};
case '@electron/main/OpenExternal':
return {
openExternalDeduped: async (url) => {
opened.push(url);
},
};
case '@electron/main/ShellUpdateCapability':
return {
resolveShellUpdatePlan: () => plan,
ShellUpdateCapability: {
SELF_UPDATE: 'self-update',
MANUAL_DOWNLOAD: 'manual-download',
MANAGED_PACKAGE: 'managed-package',
},
};
case '@electron/main/Updater':
return {findNewerManualShell: async () => ({version: NEWER_SHELL})};
case '@electron/main/UpdaterDownloads':
return {DOWNLOAD_PAGE_URL};
case 'electron':
return {
app: {getVersion: () => '2026.1006.171735'},
BrowserWindow: {getFocusedWindow: () => null},
dialog: {
showMessageBox: async (options) => {
dialogs.push(options);
return {response};
},
},
};
default:
throw new Error(`Unexpected import: ${specifier}`);
}
}
const module = {exports: {}};
vm.runInContext(
transformedSource,
vm.createContext({require: requireStub, module, exports: module.exports, console}),
{filename: sourcePath},
);
return {prompt: module.exports, dialogs, opened};
}
describe('the native update prompt for a shell that cannot update itself', () => {
test('a Flatpak with a newer shell offers the software center', async () => {
const {prompt, dialogs, opened} = loadPrompt({
plan: {capability: 'managed-package'},
flatpakAppId: FLATPAK_APP_ID,
});
await prompt.checkForUpdatesFromShell();
assert.equal(dialogs.length, 1);
assert.equal(dialogs[0].message, 'desktop.update.manualMessage');
assert.equal(dialogs[0].detail, 'desktop.update.flatpakDetail');
assert.deepEqual([...dialogs[0].buttons], ['desktop.update.openSoftwareCenter', 'desktop.update.later']);
assert.deepEqual(opened, [`appstream://${FLATPAK_APP_ID}`]);
});
test('a deb install with a newer shell offers the download page', async () => {
const {prompt, dialogs, opened} = loadPrompt({plan: {capability: 'managed-package'}});
await prompt.checkForUpdatesFromShell();
assert.equal(dialogs[0].detail, 'desktop.update.managedDetail');
assert.deepEqual([...dialogs[0].buttons], ['desktop.update.download', 'desktop.update.later']);
assert.deepEqual(opened, [DOWNLOAD_PAGE_URL]);
});
test('a portable build keeps the plain download wording', async () => {
const {prompt, dialogs, opened} = loadPrompt({plan: {capability: 'manual-download', reason: 'platform'}});
await prompt.checkForUpdatesFromShell();
assert.equal(dialogs[0].detail, 'desktop.update.manualDetail');
assert.deepEqual(opened, [DOWNLOAD_PAGE_URL]);
});
test('choosing later opens nothing', async () => {
const {prompt, opened} = loadPrompt({
plan: {capability: 'managed-package'},
flatpakAppId: FLATPAK_APP_ID,
response: 1,
});
await prompt.checkForUpdatesFromShell();
assert.deepEqual(opened, []);
});
});
+39 -11
View File
@@ -9,8 +9,10 @@ import {
getDesktopUpdateState,
startDesktopUpdate,
} from '@electron/main/DesktopUpdateGate';
import {getFlatpakAppId, isFlatpakRuntime} from '@electron/main/LinuxSandbox';
import {t} from '@electron/main/MainI18n';
import {openExternalDeduped} from '@electron/main/OpenExternal';
import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability';
import {findNewerManualShell} from '@electron/main/Updater';
import {DOWNLOAD_PAGE_URL} from '@electron/main/UpdaterDownloads';
import {app, BrowserWindow, dialog, type MessageBoxOptions} from 'electron';
@@ -67,17 +69,7 @@ async function runUpdateCheck(): Promise<void> {
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);
}
await offerManualUpdate(manualVersion);
return;
}
await ask({
@@ -91,6 +83,42 @@ async function runUpdateCheck(): Promise<void> {
}
}
interface ManualUpdateOffer {
readonly detail: string;
readonly button: string;
readonly url: string;
}
function resolveManualUpdateOffer(): ManualUpdateOffer {
if (resolveShellUpdatePlan().capability !== ShellUpdateCapability.MANAGED_PACKAGE) {
return {detail: t('desktop.update.manualDetail'), button: t('desktop.update.download'), url: DOWNLOAD_PAGE_URL};
}
const flatpakAppId = isFlatpakRuntime() ? getFlatpakAppId() : null;
if (flatpakAppId != null) {
return {
detail: t('desktop.update.flatpakDetail'),
button: t('desktop.update.openSoftwareCenter'),
url: `appstream://${flatpakAppId}`,
};
}
return {detail: t('desktop.update.managedDetail'), button: t('desktop.update.download'), url: DOWNLOAD_PAGE_URL};
}
async function offerManualUpdate(version: string): Promise<void> {
const offer = resolveManualUpdateOffer();
const choice = await ask({
type: 'info',
message: t('desktop.update.manualMessage', {version}),
detail: offer.detail,
buttons: [offer.button, t('desktop.update.later')],
defaultId: UpdatePromptChoice.PRIMARY,
cancelId: UpdatePromptChoice.SECONDARY,
});
if (choice === UpdatePromptChoice.PRIMARY) {
await openExternalDeduped(offer.url);
}
}
async function offerDesktopUpdate(): Promise<void> {
const install = await ask({
type: 'info',
+3
View File
@@ -80,6 +80,9 @@ const FALLBACK_STRINGS: Readonly<Record<string, string>> = Object.freeze({
'desktop.update.download': 'Download',
'desktop.update.manualMessage': '{appName} {version} is available.',
'desktop.update.manualDetail': 'Download the new version to update {appName}.',
'desktop.update.managedDetail': 'Update {appName} with your package manager, or download the new version.',
'desktop.update.flatpakDetail': 'Update {appName} from your software center.',
'desktop.update.openSoftwareCenter': 'Open software center',
'desktop.update.upToDateMessage': '{appName} is up to date.',
'desktop.update.upToDateDetail': 'You have version {version}.',
'desktop.update.checkFailedMessage': "{appName} couldn't check for updates.",
@@ -82,15 +82,34 @@ describe('parseModuleUpdateManifest', () => {
);
});
test('refuses linux_security_minimum.required_modules naming an Object.prototype property', () => {
test('linux_security_minimum is ignored, even one shipped shells would enforce', () => {
const bytes = manifestBytes({
platform: 'linux',
linux_security_minimum: {version: SHELL_VERSION, required_modules: ['constructor']},
shell: {latest_version: '2026.900.1', minimum_version: '0.0.0'},
linux_security_minimum: {version: '2026.900.1', required_modules: ['constructor']},
});
assert.throws(() => parseModuleUpdateManifest(bytes, {...EXPECTED_TARGET, platform: 'linux'}), {
name: 'ModuleManifestMalformedError',
message: /linux_security_minimum\.required_modules names an undeclared module: constructor/u,
const manifest = parseModuleUpdateManifest(bytes, {...EXPECTED_TARGET, platform: 'linux'});
assert.equal(Object.hasOwn(manifest, 'linuxSecurityMinimum'), false);
assert.equal(manifest.shell.minimumVersion.source, '0.0.0');
});
test('a manifest carrying shell_compatibility parses', () => {
const bytes = manifestBytes({
shell_compatibility: {
minimum_shell_version: '0.0.0',
source_sha: 'b'.repeat(40),
previous_source_sha: 'c'.repeat(40),
floor_source_sha: null,
decision: 'compatible',
contract_base_sha: 'c'.repeat(40),
contract_changes: ['fluxer_desktop/src/preload/index.ts'],
},
});
const manifest = parseModuleUpdateManifest(bytes, EXPECTED_TARGET);
assert.equal(manifest.modules.fluxer_renderer.sha256, RENDERER_SHA);
});
});
+1 -66
View File
@@ -2,12 +2,7 @@ import {isRecord} from '@fluxer/record_utils/src/RecordGuards';
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Buffer} from 'node:buffer';
import {
compareModuleVersions,
type ModuleVersion,
ModuleVersionMalformedError,
parseModuleVersion,
} from '@electron/main/ModuleVersion';
import {type ModuleVersion, ModuleVersionMalformedError, parseModuleVersion} from '@electron/main/ModuleVersion';
import {isDesktopModuleName} from '@fluxer/desktop_ipc/src/ModuleContract';
const MODULE_MANIFEST_VERSION = 1;
@@ -36,11 +31,6 @@ export interface DesktopModuleShellRequirement {
readonly minimumVersion: ModuleVersion;
}
export interface DesktopLinuxSecurityMinimum {
readonly version: ModuleVersion;
readonly requiredModules: ReadonlyArray<string>;
}
export interface DesktopModuleUpdateManifest {
readonly manifestVersion: number;
readonly releaseChannel: string;
@@ -52,7 +42,6 @@ export interface DesktopModuleUpdateManifest {
readonly shell: DesktopModuleShellRequirement;
readonly modules: Readonly<Record<string, DesktopModuleManifestEntry>>;
readonly requiredModules: ReadonlyArray<string>;
readonly linuxSecurityMinimum: DesktopLinuxSecurityMinimum | null;
}
class ModuleManifestMalformedError extends Error {
@@ -228,51 +217,6 @@ export function parseModuleUpdateManifest(
}
const buildVersion = requireBuildVersion(parsed);
const shell = parseShellRequirement(parsed['shell']);
const rawLinuxSecurityMinimum = parsed['linux_security_minimum'];
let linuxSecurityMinimum: DesktopLinuxSecurityMinimum | null = null;
if (rawLinuxSecurityMinimum != null) {
if (!isRecord(rawLinuxSecurityMinimum)) {
throw new ModuleManifestMalformedError('manifest linux_security_minimum must be an object or null');
}
const rawSecurityModules = rawLinuxSecurityMinimum['required_modules'];
if (!Array.isArray(rawSecurityModules)) {
throw new ModuleManifestMalformedError('manifest linux_security_minimum.required_modules must be an array');
}
const securityModules: Array<string> = [];
const seenSecurityModules = new Set<string>();
for (const moduleName of rawSecurityModules) {
if (typeof moduleName !== 'string' || !Object.hasOwn(modules, moduleName)) {
throw new ModuleManifestMalformedError(
`linux_security_minimum.required_modules names an undeclared module: ${String(moduleName)}`,
);
}
if (seenSecurityModules.has(moduleName)) {
throw new ModuleManifestMalformedError(
`linux_security_minimum.required_modules contains a duplicate module: ${moduleName}`,
);
}
seenSecurityModules.add(moduleName);
securityModules.push(moduleName);
}
securityModules.sort();
linuxSecurityMinimum = Object.freeze({
version: requireVersion(rawLinuxSecurityMinimum, 'version', 'manifest linux_security_minimum.version'),
requiredModules: Object.freeze(securityModules),
});
}
if (platform !== 'linux' && linuxSecurityMinimum != null) {
throw new ModuleManifestMalformedError('manifest linux security minimum is only valid for Linux');
}
if (linuxSecurityMinimum != null) {
if (compareModuleVersions(buildVersion, linuxSecurityMinimum.version) < 0) {
throw new ModuleManifestMalformedError('manifest build_version cannot be below linux_security_minimum.version');
}
if (compareModuleVersions(shell.minimumVersion, linuxSecurityMinimum.version) < 0) {
throw new ModuleManifestMalformedError(
'manifest shell.minimum_version cannot be below linux_security_minimum.version',
);
}
}
return Object.freeze({
manifestVersion,
releaseChannel,
@@ -284,14 +228,5 @@ export function parseModuleUpdateManifest(
shell,
modules: Object.freeze(modules),
requiredModules: Object.freeze(requiredModules),
linuxSecurityMinimum,
});
}
export function blockingModuleNames(manifest: DesktopModuleUpdateManifest): ReadonlySet<string> {
const names = new Set(manifest.requiredModules);
for (const moduleName of manifest.linuxSecurityMinimum?.requiredModules ?? []) {
names.add(moduleName);
}
return names;
}
@@ -85,6 +85,7 @@ function createInstaller({respond, resumeFrom}) {
installModule: async ({download}) => {
downloads.push(await collect(await download(resumeFrom)));
},
recordShellRanges: async () => {},
},
packageOrigin: PACKAGE_ORIGIN,
fetch: async (url, init) => {
@@ -201,6 +201,14 @@ export class ModulePackageInstaller {
sha256: item.entry.sha256,
download: (resumeFrom) => this.openPackageStream(item, current, total, resumeFrom),
});
await this.store.recordShellRanges([
{
module: item.module,
sha256: item.entry.sha256,
minimum: item.entry.minimumShellVersion?.source ?? null,
maximum: item.entry.maximumShellVersion?.source ?? null,
},
]);
this.onProgress({
phase: ModulePackageInstallPhase.INSTALLING,
current,
@@ -1037,6 +1037,112 @@ describe('ModuleStore rejected module sets', () => {
});
});
describe('ModuleStore shell ranges beside older shells', () => {
test('state written by this shell stays readable by shells that require linux_security_minimum', async () => {
const userDataPath = createUserData();
const store = await openStore(userDataPath);
await install(store, RENDERER_V1);
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
const written = readState(userDataPath);
assert.equal(Object.hasOwn(written, 'linux_security_minimum'), true);
assert.equal(written.linux_security_minimum, null);
assert.equal(written.state_version, MODULE_STATE_VERSION);
});
test('state that persisted a security minimum still opens', async () => {
const userDataPath = createUserData();
const store = await openStore(userDataPath);
await install(store, RENDERER_V1);
await store.commit({fluxer_renderer: RENDERER_V1.sha256});
const legacy = readState(userDataPath);
legacy.linux_security_minimum = {version: '2026.900.1', required_modules: ['fluxer_renderer']};
delete legacy.shell_ranges;
writeFileSync(path.join(store.root, MODULE_STATE_FILE_NAME), JSON.stringify(legacy));
const reopened = await openStore(userDataPath);
assert.equal(reopened.recoveredUnreadableState, null);
assert.deepEqual(reopened.getCommitted(), {fluxer_renderer: RENDERER_V1.sha256});
assert.deepEqual(reopened.getState().shell_ranges, {});
await reopened.commit({fluxer_renderer: RENDERER_V1.sha256});
assert.equal(readState(userDataPath).linux_security_minimum, null);
});
test('shell ranges survive a reopen and garbage collection prunes them', async () => {
const userDataPath = createUserData();
const earlierRun = await openStore(userDataPath);
await install(earlierRun, RENDERER_V1);
await earlierRun.commit({fluxer_renderer: RENDERER_V1.sha256});
const orphan = await install(earlierRun, GRAMMARS_V1);
await earlierRun.recordShellRanges([
{module: 'fluxer_renderer', sha256: RENDERER_V1.sha256, minimum: SHELL_VERSION, maximum: null},
{module: 'fluxer_grammars', sha256: GRAMMARS_V1.sha256, minimum: null, maximum: '2026.900.1'},
{module: 'fluxer_renderer', sha256: 'd'.repeat(64), minimum: '2026.900.1', maximum: null},
]);
const store = await openStore(userDataPath);
assert.deepEqual(store.getShellRange('fluxer_renderer', RENDERER_V1.sha256), {
minimum_shell_version: SHELL_VERSION,
maximum_shell_version: null,
});
assert.deepEqual(store.getShellRange('fluxer_grammars', GRAMMARS_V1.sha256), {
minimum_shell_version: null,
maximum_shell_version: '2026.900.1',
});
await markLaunched(store);
await store.collectGarbage();
assert.equal(existsSync(orphan.directory), false);
assert.deepEqual(Object.keys(readState(userDataPath).shell_ranges), [`fluxer_renderer/${RENDERER_V1.sha256}`]);
assert.equal(store.getShellRange('fluxer_grammars', GRAMMARS_V1.sha256), null);
});
test('a recorded range that no longer parses is dropped instead of discarding the state', async () => {
const userDataPath = createUserData();
const store = await openStore(userDataPath);
const state = readState(userDataPath);
state.shell_ranges = {
[`fluxer_renderer/${'d'.repeat(64)}`]: {minimum_shell_version: 'not a version', maximum_shell_version: null},
[`fluxer_renderer/${'e'.repeat(64)}`]: {minimum_shell_version: SHELL_VERSION, maximum_shell_version: null},
};
writeFileSync(path.join(store.root, MODULE_STATE_FILE_NAME), JSON.stringify(state));
const reopened = await openStore(userDataPath);
assert.equal(reopened.recoveredUnreadableState, null);
assert.deepEqual(Object.keys(reopened.getState().shell_ranges), [`fluxer_renderer/${'e'.repeat(64)}`]);
});
test('retireIncompatible drops non-runnable pairs from committed, previous and floor', 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});
await store.commit({fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256});
await store.recordManifestFetch({
etag: null,
fetchedAt: MANIFEST_FETCHED_AT,
manifest: manifestObservation(1, 'a'.repeat(64)),
floor: {fluxer_renderer: RENDERER_V2.sha256, fluxer_grammars: GRAMMARS_V1.sha256},
});
const retired = await store.retireIncompatible(
(moduleName, digest) => !(moduleName === 'fluxer_renderer' && digest === RENDERER_V2.sha256),
);
assert.deepEqual(retired, ['fluxer_renderer']);
assert.deepEqual(store.getCommitted(), {fluxer_grammars: GRAMMARS_V1.sha256});
assert.deepEqual(store.getState().previous, {fluxer_renderer: RENDERER_V1.sha256});
assert.deepEqual(store.getState().floor, {fluxer_grammars: GRAMMARS_V1.sha256});
assert.deepEqual(await store.retireIncompatible(() => true), []);
});
});
describe('ModuleStore on Windows sharing violations', () => {
test('a rename that an antivirus scanner briefly locks is retried instead of failing the install', async () => {
const userDataPath = createUserData();
+154 -91
View File
@@ -24,7 +24,7 @@ import {
parseDesktopModuleManifest,
verifyAndExtract,
} from '@electron/main/ModulePackage';
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
import {parseModuleVersion} from '@electron/main/ModuleVersion';
import {isDesktopModuleName} from '@fluxer/desktop_ipc/src/ModuleContract';
const MODULE_STORE_DIRECTORY_NAME = 'modules';
@@ -131,9 +131,16 @@ export class ModuleStoreGarbageCollectionOrderError extends ModuleStoreError {
}
}
export interface LinuxModuleSecurityMinimum {
readonly version: string;
readonly requiredModules: ReadonlyArray<string>;
export interface ModuleShellRange {
readonly minimum_shell_version: string | null;
readonly maximum_shell_version: string | null;
}
export interface ModuleShellRangeRecord {
readonly module: string;
readonly sha256: string;
readonly minimum: string | null;
readonly maximum: string | null;
}
export interface ModuleManifestFeedIdentity {
@@ -161,7 +168,7 @@ interface ModuleStoreState {
readonly previous: Readonly<Record<string, string>>;
readonly rejected: Readonly<Record<string, string>>;
readonly floor: Readonly<Record<string, string>>;
readonly linux_security_minimum: LinuxModuleSecurityMinimum | null;
readonly shell_ranges: Readonly<Record<string, ModuleShellRange>>;
readonly manifest_high_water: ReadonlyArray<ModuleManifestHighWater>;
readonly last_manifest_etag: string | null;
readonly last_manifest_fetch: string | null;
@@ -379,6 +386,15 @@ async function isRegularFile(target: string): Promise<boolean> {
}
}
async function isDirectory(target: string): Promise<boolean> {
try {
const stats = await fs.stat(target);
return stats.isDirectory();
} catch {
return false;
}
}
function isMissingInstallationError(error: unknown): boolean {
return (
typeof error === 'object' &&
@@ -885,63 +901,66 @@ function retainRejected(
return retained;
}
function parseLinuxSecurityMinimum(value: unknown): LinuxModuleSecurityMinimum | null | undefined {
if (value === undefined || value === null) {
return null;
}
if (typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const version = record['version'];
const rawRequiredModules = record['required_modules'];
if (typeof version !== 'string' || !Array.isArray(rawRequiredModules)) {
return undefined;
}
try {
parseModuleVersion(version, 'persisted Linux security minimum');
} catch {
return undefined;
}
const requiredModules: Array<string> = [];
const seen = new Set<string>();
for (const moduleName of rawRequiredModules) {
if (!isDesktopModuleName(moduleName) || seen.has(moduleName)) {
return undefined;
}
seen.add(moduleName);
requiredModules.push(moduleName);
}
return {version, requiredModules: Object.freeze(requiredModules.sort())};
function shellRangeKey(moduleName: string, sha256: string): string {
return `${moduleName}/${sha256}`;
}
function advanceLinuxSecurityMinimum(
current: LinuxModuleSecurityMinimum | null,
candidate: LinuxModuleSecurityMinimum,
): LinuxModuleSecurityMinimum {
const version = parseModuleVersion(candidate.version, 'Linux security minimum');
const requiredModules = Array.from(new Set(candidate.requiredModules)).sort();
if (requiredModules.length !== candidate.requiredModules.length) {
throw new ModuleStoreError('Linux security minimum contains duplicate required modules');
function isShellRangeVersion(value: unknown): value is string | null {
if (value === null) {
return true;
}
for (const moduleName of requiredModules) {
assertModuleName(moduleName);
if (typeof value !== 'string') {
return false;
}
if (current != null) {
const comparison = compareModuleVersions(
version,
parseModuleVersion(current.version, 'persisted Linux security minimum'),
);
if (comparison < 0) {
throw new ModuleStoreError(
`refusing to lower Linux security minimum from ${current.version} to ${candidate.version}`,
);
try {
parseModuleVersion(value, 'persisted module shell range');
return true;
} catch {
return false;
}
}
function isShellRangeKey(key: string): boolean {
const separator = key.indexOf('/');
return separator > 0 && isDesktopModuleName(key.slice(0, separator)) && SHA256_PATTERN.test(key.slice(separator + 1));
}
function sortShellRanges(
value: Readonly<Record<string, ModuleShellRange>>,
): Readonly<Record<string, ModuleShellRange>> {
const sorted: Record<string, ModuleShellRange> = {};
for (const key of Object.keys(value).sort()) {
sorted[key] = Object.freeze({...value[key]});
}
return Object.freeze(sorted);
}
function parseShellRanges(value: unknown): Readonly<Record<string, ModuleShellRange>> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return Object.freeze({});
}
const parsed: Record<string, ModuleShellRange> = {};
for (const [key, rawRange] of Object.entries(value as Record<string, unknown>)) {
if (!isShellRangeKey(key) || typeof rawRange !== 'object' || rawRange === null || Array.isArray(rawRange)) {
continue;
}
if (comparison === 0 && requiredModules.join('\0') !== current.requiredModules.join('\0')) {
throw new ModuleStoreError(`Linux security minimum ${candidate.version} changed its required modules`);
const range = rawRange as Record<string, unknown>;
const minimum = range['minimum_shell_version'];
const maximum = range['maximum_shell_version'];
if (!isShellRangeVersion(minimum) || !isShellRangeVersion(maximum)) {
continue;
}
parsed[key] = {minimum_shell_version: minimum, maximum_shell_version: maximum};
}
return {version: candidate.version, requiredModules: Object.freeze(requiredModules)};
return sortShellRanges(parsed);
}
function sameShellRange(left: ModuleShellRange | undefined, right: ModuleShellRange): boolean {
return (
left != null &&
left.minimum_shell_version === right.minimum_shell_version &&
left.maximum_shell_version === right.maximum_shell_version
);
}
function parseOptionalString(value: unknown): string | null | undefined {
@@ -990,19 +1009,8 @@ function parseModuleStoreState(bytes: Buffer, statePath: string): ModuleStoreSta
const previous = parseModuleMap(record['previous']);
const rejected = record['rejected'] === undefined ? {} : parseModuleMap(record['rejected']);
const floor = parseModuleMap(record['floor']);
const rawLinuxSecurityMinimum = record['linux_security_minimum'];
if (rawLinuxSecurityMinimum === undefined) {
throw new ModuleStoreStateCorruptError(statePath, 'linux_security_minimum is required');
}
const linuxSecurityMinimum = parseLinuxSecurityMinimum(rawLinuxSecurityMinimum);
if (
committed === null ||
previous === null ||
rejected === null ||
floor === null ||
linuxSecurityMinimum === undefined
) {
throw new ModuleStoreStateCorruptError(statePath, 'module maps or Linux security minimum are invalid');
if (committed === null || previous === null || rejected === null || floor === null) {
throw new ModuleStoreStateCorruptError(statePath, 'module maps are invalid');
}
const manifestHighWater = parseManifestHighWater(record['manifest_high_water']);
if (manifestHighWater === null) {
@@ -1025,7 +1033,7 @@ function parseModuleStoreState(bytes: Buffer, statePath: string): ModuleStoreSta
previous,
rejected,
floor,
linux_security_minimum: linuxSecurityMinimum,
shell_ranges: parseShellRanges(record['shell_ranges']),
manifest_high_water: manifestHighWater,
last_manifest_etag: lastManifestEtag,
last_manifest_fetch: lastManifestFetch,
@@ -1042,7 +1050,7 @@ function createInitialModuleStoreState(shellVersion: string, releaseChannel: str
previous: {},
rejected: {},
floor: {},
linux_security_minimum: null,
shell_ranges: {},
manifest_high_water: [],
last_manifest_etag: null,
last_manifest_fetch: null,
@@ -1060,13 +1068,8 @@ function serializeModuleStoreState(state: ModuleStoreState): string {
previous: sortModuleMap(state.previous),
rejected: sortModuleMap(state.rejected),
floor: sortModuleMap(state.floor),
linux_security_minimum:
state.linux_security_minimum == null
? null
: {
version: state.linux_security_minimum.version,
required_modules: state.linux_security_minimum.requiredModules,
},
linux_security_minimum: null,
shell_ranges: sortShellRanges(state.shell_ranges),
manifest_high_water: state.manifest_high_water.map((entry) => ({
release_channel: entry.releaseChannel,
platform: entry.platform,
@@ -1510,14 +1513,12 @@ export class ModuleStore {
fetchedAt,
manifest,
floor,
linuxSecurityMinimum,
advertised,
}: {
readonly etag: string | null;
readonly fetchedAt: string;
readonly manifest: ModuleManifestFeedObservation;
readonly floor?: Readonly<Record<string, string>>;
readonly linuxSecurityMinimum?: LinuxModuleSecurityMinimum;
readonly advertised?: Readonly<Record<string, string>>;
}): Promise<ModuleStoreState> {
return await this.withStateLock(async () => {
@@ -1528,15 +1529,81 @@ export class ModuleStore {
last_manifest_etag: etag,
last_manifest_fetch: fetchedAt,
floor: floor == null ? this.state.floor : sortModuleMap(floor),
linux_security_minimum:
linuxSecurityMinimum == null
? this.state.linux_security_minimum
: advanceLinuxSecurityMinimum(this.state.linux_security_minimum, linuxSecurityMinimum),
manifest_high_water: manifestHighWater,
});
});
}
public getShellRange(moduleName: string, sha256: string): ModuleShellRange | null {
return this.state.shell_ranges[shellRangeKey(moduleName, sha256)] ?? null;
}
public async recordShellRanges(ranges: ReadonlyArray<ModuleShellRangeRecord>): Promise<void> {
for (const range of ranges) {
assertModuleName(range.module);
assertSha256(range.sha256);
}
await this.withStateLock(async () => {
const shellRanges: Record<string, ModuleShellRange> = {...this.state.shell_ranges};
let changed = false;
for (const range of ranges) {
const key = shellRangeKey(range.module, range.sha256);
const next = {minimum_shell_version: range.minimum, maximum_shell_version: range.maximum};
if (sameShellRange(shellRanges[key], next)) {
continue;
}
shellRanges[key] = next;
changed = true;
}
if (changed) {
await this.writeState({...this.state, shell_ranges: shellRanges});
}
});
}
public async retireIncompatible(
isRunnable: (moduleName: string, sha256: string) => boolean,
): Promise<ReadonlyArray<string>> {
return await this.withStateLock(async () => {
const retired = new Set<string>();
const keepRunnable = (map: Readonly<Record<string, string>>): Record<string, string> => {
const kept: Record<string, string> = {};
for (const [moduleName, sha256] of Object.entries(map)) {
if (isRunnable(moduleName, sha256)) {
kept[moduleName] = sha256;
} else {
retired.add(moduleName);
}
}
return kept;
};
const committed = keepRunnable(this.state.committed);
const previous = keepRunnable(this.state.previous);
const floor = keepRunnable(this.state.floor);
if (retired.size === 0) {
return [];
}
await this.writeState({...this.state, committed, previous, floor});
return [...retired].sort();
});
}
private async pruneShellRanges(): Promise<void> {
await this.withStateLock(async () => {
const shellRanges: Record<string, ModuleShellRange> = {};
for (const [key, range] of Object.entries(this.state.shell_ranges)) {
const separator = key.indexOf('/');
const directory = this.getModuleDirectory(key.slice(0, separator), key.slice(separator + 1));
if (await isDirectory(directory)) {
shellRanges[key] = range;
}
}
if (Object.keys(shellRanges).length !== Object.keys(this.state.shell_ranges).length) {
await this.writeState({...this.state, shell_ranges: shellRanges});
}
});
}
public async beginBootAttempt({
bundled = [],
}: {
@@ -1666,7 +1733,9 @@ export class ModuleStore {
if (!this.launchSucceeded) {
throw new ModuleStoreGarbageCollectionOrderError();
}
return await this.withStoreTreeLock(() => this.collectGarbageExclusively(now));
const result = await this.withStoreTreeLock(() => this.collectGarbageExclusively(now));
await this.pruneShellRanges();
return result;
}
private async collectGarbageExclusively(now: number): Promise<ModuleGarbageCollectionResult> {
@@ -1791,13 +1860,7 @@ export class ModuleStore {
previous: sortModuleMap(next.previous),
rejected: sortModuleMap(next.rejected),
floor: sortModuleMap(next.floor),
linux_security_minimum:
next.linux_security_minimum == null
? null
: {
version: next.linux_security_minimum.version,
requiredModules: Object.freeze([...next.linux_security_minimum.requiredModules]),
},
shell_ranges: sortShellRanges(next.shell_ranges),
manifest_high_water: sortManifestHighWater(next.manifest_high_water),
};
for (const [moduleName, sha256] of Object.entries(this.state.committed)) {
@@ -63,6 +63,7 @@ function createStore({
bootAttempt = 0,
missing = [],
installedManifests = {},
shellRanges = {},
} = {}) {
const absent = new Set(missing);
return {
@@ -77,6 +78,7 @@ function createStore({
isRejected: (moduleName, sha256) => rejected[moduleName] === sha256,
isInstalled: async (moduleName, sha256) => !absent.has(`${moduleName}:${sha256}`),
getInstalledManifest: async (moduleName, sha256) => installedManifests[`${moduleName}:${sha256}`] ?? null,
getShellRange: (moduleName, sha256) => shellRanges[`${moduleName}/${sha256}`] ?? null,
};
}
@@ -237,13 +239,109 @@ describe('ModuleUpdatePlanner.plan', () => {
assert.deepEqual(plan.items, []);
assert.deepEqual(plan.base, {fluxer_renderer: RENDERER_SHA});
});
test('plan keeps the committed renderer and assets when the feed renderer needs a newer shell', async () => {
const dictionarySha = 'd'.repeat(64);
const store = createStore({committed: {fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA}});
const planner = createPlanner(store);
const plan = await planner.plan(
manifest({
modules: {
fluxer_renderer: {sha256: 'c'.repeat(64), minimumShellVersion: '2026.900.1'},
fluxer_overlay: {sha256: 'e'.repeat(64)},
fluxer_dict_en: {sha256: dictionarySha},
},
required: ['fluxer_renderer', 'fluxer_dict_en'],
}),
);
assert.deepEqual(
plan.items.map((item) => [item.module, item.entry.sha256, item.requirement]),
[['fluxer_dict_en', dictionarySha, 'required']],
);
assert.deepEqual(plan.base, {fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA});
});
});
describe('ModuleUpdatePlanner.compatibleEntry', () => {
test('a shell below the manifest shell floor gets no entry even when the entry has no floor', () => {
const planner = createPlanner(createStore());
const feed = manifest({shellMinimum: '2026.900.1', modules: {fluxer_renderer: {sha256: RENDERER_SHA}}});
assert.equal(planner.compatibleEntry(feed, 'fluxer_renderer'), null);
});
test('an entry inside its shell range is returned, one outside or missing is not', () => {
const planner = createPlanner(createStore());
const feed = manifest({
shellMinimum: '0.0.0',
modules: {
fluxer_renderer: {sha256: RENDERER_SHA, minimumShellVersion: SHELL_VERSION},
fluxer_overlay: {sha256: OVERLAY_SHA, minimumShellVersion: '2026.900.1'},
},
});
assert.equal(planner.compatibleEntry(feed, 'fluxer_renderer')?.sha256, RENDERER_SHA);
assert.equal(planner.compatibleEntry(feed, 'fluxer_overlay'), null);
assert.equal(planner.compatibleEntry(feed, 'toString'), null);
});
});
describe('ModuleUpdatePlanner.isRunnable', () => {
const cachedFeed = () =>
manifest({
shellMinimum: '0.0.0',
modules: {fluxer_renderer: {sha256: RENDERER_SHA, minimumShellVersion: '2026.900.1'}},
});
test('a module with no record and no cached entry is runnable', () => {
const planner = createPlanner(createStore());
assert.equal(planner.isRunnable('fluxer_renderer', RENDERER_SHA, null), true);
});
test('a recorded range this shell is outside of is not runnable', () => {
const planner = createPlanner(
createStore({
shellRanges: {
[`fluxer_renderer/${RENDERER_SHA}`]: {minimum_shell_version: '2026.900.1', maximum_shell_version: null},
[`fluxer_overlay/${OVERLAY_SHA}`]: {minimum_shell_version: null, maximum_shell_version: '2026.823.0'},
},
}),
);
assert.equal(planner.isRunnable('fluxer_renderer', RENDERER_SHA, null), false);
assert.equal(planner.isRunnable('fluxer_overlay', OVERLAY_SHA, null), false);
});
test('a recorded range that ends on this shell is runnable', () => {
const planner = createPlanner(
createStore({
shellRanges: {
[`fluxer_renderer/${RENDERER_SHA}`]: {
minimum_shell_version: SHELL_VERSION,
maximum_shell_version: SHELL_VERSION,
},
},
}),
);
assert.equal(planner.isRunnable('fluxer_renderer', RENDERER_SHA, cachedFeed()), true);
});
test('the cached manifest stands in for a missing record only for the same sha', () => {
const planner = createPlanner(createStore());
assert.equal(planner.isRunnable('fluxer_renderer', RENDERER_SHA, cachedFeed()), false);
assert.equal(planner.isRunnable('fluxer_renderer', 'f'.repeat(64), cachedFeed()), true);
});
});
describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
test('a store that never reached the manifest feed blocks with nothing-installed', async () => {
const planner = createPlanner(createStore({committed: {fluxer_renderer: RENDERER_SHA}, lastManifestFetch: null}));
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.NOTHING_INSTALLED,
});
@@ -252,7 +350,7 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
test('an empty committed set with no offline renderer blocks with nothing-installed', async () => {
const planner = createPlanner(createStore({committed: {}}));
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.NOTHING_INSTALLED,
});
@@ -261,25 +359,11 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
test('an offline renderer carries an empty committed set past the nothing-installed block', async () => {
const planner = createPlanner(createStore({committed: {}}), {hasOfflineRenderer: true});
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
kind: ModuleUnreachableLaunchDecision.LAUNCH,
});
});
test('boot attempts at the rollback threshold block with rollback-exhausted', async () => {
const planner = createPlanner(
createStore({
committed: {fluxer_renderer: RENDERER_SHA},
bootAttempt: MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD,
}),
);
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.ROLLBACK_EXHAUSTED,
});
});
test('one boot attempt below the threshold still launches', async () => {
const planner = createPlanner(
createStore({
@@ -288,20 +372,11 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
}),
);
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
kind: ModuleUnreachableLaunchDecision.LAUNCH,
});
});
test('a pending security update blocks with unreachable-below-floor', async () => {
const planner = createPlanner(createStore({committed: {fluxer_renderer: RENDERER_SHA}}));
assert.deepEqual(await planner.evaluateUnreachableLaunch(false, true), {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.UNREACHABLE_BELOW_FLOOR,
});
});
test('a committed set behind the floor blocks only while startup updates are enforced', async () => {
const store = createStore({
committed: {fluxer_renderer: RENDERER_SHA},
@@ -309,11 +384,11 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
});
const planner = createPlanner(store);
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.UNREACHABLE_BELOW_FLOOR,
});
assert.deepEqual(await planner.evaluateUnreachableLaunch(false, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(false), {
kind: ModuleUnreachableLaunchDecision.LAUNCH,
});
});
@@ -326,7 +401,7 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
}),
);
assert.deepEqual(await planner.evaluateUnreachableLaunch(false, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(false), {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.UNREACHABLE_BELOW_FLOOR,
});
@@ -340,7 +415,7 @@ describe('ModuleUpdatePlanner.evaluateUnreachableLaunch', () => {
}),
);
assert.deepEqual(await planner.evaluateUnreachableLaunch(true, false), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
kind: ModuleUnreachableLaunchDecision.LAUNCH,
});
});
@@ -439,20 +514,8 @@ describe('ModuleUpdatePlanner with a renderer bundled in the shell', () => {
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), {
assert.deepEqual(await planner.evaluateUnreachableLaunch(true), {
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,
);
});
});
+63 -52
View File
@@ -1,12 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
blockingModuleNames,
type DesktopLinuxSecurityMinimum,
type DesktopModuleManifestEntry,
type DesktopModuleUpdateManifest,
} from '@electron/main/ModuleManifest';
import {MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD, type ModuleStore} from '@electron/main/ModuleStore';
import type {DesktopModuleManifestEntry, DesktopModuleUpdateManifest} from '@electron/main/ModuleManifest';
import type {ModuleShellRange, ModuleStore} from '@electron/main/ModuleStore';
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
import {DESKTOP_RENDERER_MODULE_NAME} from '@fluxer/desktop_ipc/src/ModuleContract';
@@ -15,7 +10,6 @@ export const ModuleUpdaterBlockReason = Object.freeze({
UNREACHABLE_BELOW_FLOOR: 'unreachable-below-floor',
BELOW_FLOOR: 'below-floor',
NOTHING_INSTALLED: 'nothing-installed',
ROLLBACK_EXHAUSTED: 'rollback-exhausted',
} as const);
export type ModuleUpdaterBlockReason = (typeof ModuleUpdaterBlockReason)[keyof typeof ModuleUpdaterBlockReason];
@@ -178,20 +172,35 @@ export class ModuleUpdatePlanner {
}
public isShellCompatible(entry: DesktopModuleManifestEntry): boolean {
if (entry.minimumShellVersion != null && compareModuleVersions(this.shellVersion, entry.minimumShellVersion) < 0) {
return this.shellInRange(entry.minimumShellVersion, entry.maximumShellVersion);
}
private shellInRange(minimum: ModuleVersion | null, maximum: ModuleVersion | null): boolean {
if (minimum != null && compareModuleVersions(this.shellVersion, minimum) < 0) {
return false;
}
if (entry.maximumShellVersion != null && compareModuleVersions(this.shellVersion, entry.maximumShellVersion) > 0) {
if (maximum != null && compareModuleVersions(this.shellVersion, maximum) > 0) {
return false;
}
return true;
}
private shellInRecordedRange(range: ModuleShellRange): boolean {
return this.shellInRange(
range.minimum_shell_version == null
? null
: parseModuleVersion(range.minimum_shell_version, 'recorded module minimum shell version'),
range.maximum_shell_version == null
? null
: parseModuleVersion(range.maximum_shell_version, 'recorded module maximum shell version'),
);
}
public shellUpdateRequired(manifest: DesktopModuleUpdateManifest): boolean {
if (compareModuleVersions(this.shellVersion, manifest.shell.minimumVersion) < 0) {
return true;
}
for (const moduleName of blockingModuleNames(manifest)) {
for (const moduleName of manifest.requiredModules) {
const entry = manifest.modules[moduleName];
if (entry != null && !this.isShellCompatible(entry)) {
return true;
@@ -200,32 +209,47 @@ export class ModuleUpdatePlanner {
return false;
}
public async securityUpdateRequired(minimum: DesktopLinuxSecurityMinimum): Promise<boolean> {
if (compareModuleVersions(this.shellVersion, minimum.version) < 0) {
return true;
public compatibleEntry(manifest: DesktopModuleUpdateManifest, moduleName: string): DesktopModuleManifestEntry | null {
if (!Object.hasOwn(manifest.modules, moduleName)) {
return null;
}
const committed = this.store.getCommitted();
for (const moduleName of minimum.requiredModules) {
if (moduleName === DESKTOP_RENDERER_MODULE_NAME && this.bundleCoversRendererAt(minimum.version)) {
continue;
}
const sha256 = committed[moduleName];
if (sha256 == null) {
return true;
}
const installed = await this.store.getInstalledManifest(moduleName, sha256);
if (installed == null) {
return true;
}
const installedVersion = parseModuleVersion(
installed.build_version,
`installed module ${moduleName} build version`,
);
if (compareModuleVersions(installedVersion, minimum.version) < 0) {
return true;
}
const entry = manifest.modules[moduleName];
if (compareModuleVersions(this.shellVersion, manifest.shell.minimumVersion) < 0 || !this.isShellCompatible(entry)) {
return null;
}
return false;
return entry;
}
public isRendererAssetModule(moduleName: string): boolean {
return moduleName !== DESKTOP_RENDERER_MODULE_NAME && !moduleName.startsWith('fluxer_dict_');
}
public rendererFollowsFeed(manifest: DesktopModuleUpdateManifest): boolean {
return (
manifest.modules[DESKTOP_RENDERER_MODULE_NAME] == null ||
this.bundleCoversModule(DESKTOP_RENDERER_MODULE_NAME, manifest) ||
this.compatibleEntry(manifest, DESKTOP_RENDERER_MODULE_NAME) != null
);
}
public skipsFeedEntry(manifest: DesktopModuleUpdateManifest, moduleName: string): boolean {
return (
this.compatibleEntry(manifest, moduleName) == null ||
this.bundleCoversModule(moduleName, manifest) ||
(this.isRendererAssetModule(moduleName) && !this.rendererFollowsFeed(manifest))
);
}
public isRunnable(moduleName: string, sha256: string, cached: DesktopModuleUpdateManifest | null): boolean {
const recorded = this.store.getShellRange(moduleName, sha256);
if (recorded != null) {
return this.shellInRecordedRange(recorded);
}
const entry = cached?.modules[moduleName];
if (entry != null && entry.sha256 === sha256) {
return this.isShellCompatible(entry);
}
return true;
}
public async isInstalled(moduleName: string, sha256: string): Promise<boolean> {
@@ -234,8 +258,7 @@ export class ModuleUpdatePlanner {
public async plan(manifest: DesktopModuleUpdateManifest): Promise<ModulePlan> {
const committed = this.store.getCommitted();
const blocking = blockingModuleNames(manifest);
const wanted = new Set([...Object.keys(committed), ...blocking]);
const wanted = new Set([...Object.keys(committed), ...manifest.requiredModules]);
const items: Array<ModulePlanItem> = [];
const base: Record<string, string> = {};
for (const moduleName of Array.from(wanted).sort()) {
@@ -243,7 +266,7 @@ export class ModuleUpdatePlanner {
if (entry == null) {
continue;
}
if (!this.isShellCompatible(entry) || this.bundleCoversModule(moduleName, manifest)) {
if (this.skipsFeedEntry(manifest, moduleName)) {
await this.retainCommitted(base, moduleName);
continue;
}
@@ -259,7 +282,7 @@ export class ModuleUpdatePlanner {
module: moduleName,
entry,
requirement:
blocking.has(moduleName) && !this.bundledModules().includes(moduleName)
manifest.requiredModules.includes(moduleName) && !this.bundledModules().includes(moduleName)
? ModulePlanItemRequirement.REQUIRED
: ModulePlanItemRequirement.OPTIONAL,
});
@@ -307,10 +330,7 @@ export class ModuleUpdatePlanner {
return true;
}
public async evaluateUnreachableLaunch(
startupUpdateEnforced: boolean,
securityUpdateRequired: boolean,
): Promise<ModuleUnreachableLaunchDecision> {
public async evaluateUnreachableLaunch(startupUpdateEnforced: boolean): Promise<ModuleUnreachableLaunchDecision> {
const state = this.store.getState();
if (
(state.last_manifest_fetch == null && !this.hasBundledRenderer()) ||
@@ -318,15 +338,6 @@ export class ModuleUpdatePlanner {
) {
return {kind: ModuleUnreachableLaunchDecision.BLOCK, reason: ModuleUpdaterBlockReason.NOTHING_INSTALLED};
}
if (state.boot_attempt >= MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD) {
return {kind: ModuleUnreachableLaunchDecision.BLOCK, reason: ModuleUpdaterBlockReason.ROLLBACK_EXHAUSTED};
}
if (securityUpdateRequired) {
return {
kind: ModuleUnreachableLaunchDecision.BLOCK,
reason: ModuleUpdaterBlockReason.UNREACHABLE_BELOW_FLOOR,
};
}
if ((await this.modulesBelowFloor(startupUpdateEnforced)).length > 0) {
return {
kind: ModuleUnreachableLaunchDecision.BLOCK,
+418 -32
View File
@@ -72,6 +72,8 @@ function manifestBytes(
bounds = {},
metadataVersion = 1,
platform = PLATFORM,
buildVersion = SHELL_VERSION,
extra = {},
} = {},
) {
return Buffer.from(
@@ -80,7 +82,7 @@ function manifestBytes(
release_channel: RELEASE_CHANNEL,
platform,
arch: ARCH,
build_version: SHELL_VERSION,
build_version: buildVersion,
pub_date: '2026-08-23T00:00:00.000Z',
metadata_version: metadataVersion,
shell: {latest_version: shellLatest, minimum_version: shellMinimum},
@@ -96,15 +98,16 @@ function manifestBytes(
]),
),
required_modules: required ?? Object.keys(modules),
...extra,
}),
'utf8',
);
}
async function openStore(userDataPath) {
async function openStore(userDataPath, shellVersion = SHELL_VERSION) {
return await ModuleStore.open({
root: getModuleStoreRoot(userDataPath),
shellVersion: SHELL_VERSION,
shellVersion,
releaseChannel: RELEASE_CHANNEL,
});
}
@@ -116,7 +119,7 @@ function createUpdater(store, modules, options = {}) {
const reports = [];
const updater = new ModuleUpdater({
store,
shellVersion: SHELL_VERSION,
shellVersion: options.shellVersion ?? SHELL_VERSION,
releaseChannel: RELEASE_CHANNEL,
platform: options.platform ?? PLATFORM,
arch: ARCH,
@@ -343,7 +346,6 @@ describe('ModuleUpdater.run', () => {
assert.equal(outcome.status, 'blocked-shell-update');
assert.equal(outcome.latestVersion, '2026.824.0');
assert.equal(outcome.minimumVersion, '2026.824.0');
assert.equal(outcome.requiredSecurityUpdate, false);
assert.deepEqual(requests, [`${PACKAGE_ORIGIN}/desktop/${RELEASE_CHANNEL}/${PLATFORM}/${ARCH}/modules.json`]);
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
});
@@ -811,6 +813,7 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
forceStartupUpdate: true,
selfUpdateShellFirst: async (latestVersion) => {
steps.push(`shell ${latestVersion}`);
return {reason: 'install-failed'};
},
},
);
@@ -833,6 +836,7 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
forceStartupUpdate: true,
selfUpdateShellFirst: async (latestVersion) => {
steps.push(`shell ${latestVersion}`);
return {reason: 'install-failed'};
},
},
);
@@ -865,7 +869,7 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
assert.ok(requests.some((url) => String(url).endsWith('/modules.json')));
});
test('a Linux boot forced only by a shell change launches the installed set at once when the download fails', async () => {
test('a forced Linux boot with a transient package error launches the committed set', async () => {
const {store} = await storeOnRenderer();
const {updater, sleeps} = createUpdater(
store,
@@ -1145,41 +1149,423 @@ describe('ModuleUpdater with a renderer bundled in the shell', () => {
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);
describe('ModuleUpdater keeps every shell on modules it can run', () => {
const REPORTER_SHELL = '2026.1008.32323';
const LIVE_SHELL = '2026.1009.45608';
const DICTIONARY_SHA = sha256('dictionary-one');
const NEXT_DICTIONARY_SHA = sha256('dictionary-two');
const FONT_SHA = sha256('font-one');
const AHEAD = {fluxer_renderer: {minimum_shell_version: LIVE_SHELL}};
function feedAhead(overrides = {}) {
return {
shellVersion: REPORTER_SHELL,
shellLatest: LIVE_SHELL,
shellMinimum: '0.0.0',
buildVersion: LIVE_SHELL,
bounds: AHEAD,
...overrides,
};
const store = await openStore(createUserData());
}
async function storeFor(shellVersion = REPORTER_SHELL) {
const userDataPath = createUserData();
const store = await openStore(userDataPath, shellVersion);
return {store, storeRoot: getModuleStoreRoot(userDataPath)};
}
test('a Linux shell below the renderer floor launches its bundled renderer instead of asking for a shell update', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_dict_en', DICTIONARY_SHA, [['en.dic', 'words']], LIVE_SHELL);
seedCachedPackage(store, DICTIONARY_SHA);
const statuses = [];
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_dict_en: DICTIONARY_SHA},
feedAhead({
platform: 'linux',
bundledRendererVersion: REPORTER_SHELL,
forceStartupUpdate: true,
onState: (state) => statuses.push(state.status),
}),
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_dict_en: DICTIONARY_SHA});
assert.equal(
requests.some((url) => String(url).includes(NEXT_RENDERER_SHA)),
false,
);
assert.equal(statuses.includes('blocked-shell-update'), false);
assert.equal((await updater.selectServedModules(outcome.committed)).renderer.source, 'bundled');
});
test('a Flatpak shell Flathub has not updated yet keeps its renderer and still takes compatible modules', async () => {
const flathubShell = '2026.1012.154028';
const liveShell = '2026.1013.15209';
const {store, storeRoot} = await storeFor(flathubShell);
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], flathubShell);
installModuleOnDisk(storeRoot, 'fluxer_dict_en', DICTIONARY_SHA, [['en.dic', 'words']], flathubShell);
installModuleOnDisk(storeRoot, 'fluxer_dict_en', NEXT_DICTIONARY_SHA, [['en.dic', 'more words']], liveShell);
seedCachedPackage(store, NEXT_DICTIONARY_SHA);
await store.commit({fluxer_renderer: RENDERER_SHA, fluxer_dict_en: DICTIONARY_SHA});
const statuses = [];
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_dict_en: NEXT_DICTIONARY_SHA},
feedAhead({
shellVersion: flathubShell,
shellLatest: liveShell,
buildVersion: liveShell,
bounds: {fluxer_renderer: {minimum_shell_version: liveShell}},
platform: 'linux',
forceStartupUpdate: true,
required: ['fluxer_renderer'],
onState: (state) => statuses.push(state.status),
}),
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_dict_en: NEXT_DICTIONARY_SHA, fluxer_renderer: RENDERER_SHA});
assert.equal(
requests.some((url) => String(url).includes(NEXT_RENDERER_SHA)),
false,
);
assert.equal(statuses.includes('blocked-shell-update'), false);
assert.deepEqual(await updater.checkForUpdate(), {
shellLatestVersion: liveShell,
shellNewer: true,
modulesChanged: false,
});
});
test('a Linux shell behind the floor keeps launching its committed compatible renderer', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA});
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
feedAhead({platform: 'linux', forceStartupUpdate: true}),
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.equal(
requests.some((url) => String(url).endsWith('package.br')),
false,
);
});
test('a renderer asset module is not replaced while the served renderer is older than the feed', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
installModuleOnDisk(storeRoot, 'fluxer_overlay', OVERLAY_SHA, [['overlay.js', 'overlay']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA, fluxer_overlay: OVERLAY_SHA});
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_overlay: NEXT_OVERLAY_SHA},
feedAhead({platform: 'linux', forceStartupUpdate: true, required: ['fluxer_renderer']}),
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: RENDERER_SHA});
assert.equal(
requests.some((url) => String(url).endsWith('package.br')),
false,
);
assert.deepEqual(await updater.checkForUpdate(), {
shellLatestVersion: LIVE_SHELL,
shellNewer: true,
modulesChanged: false,
});
});
test('an on-demand renderer asset is unavailable while the served renderer is older than the feed', async () => {
const {store} = await storeFor();
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_fonts_jp: FONT_SHA},
feedAhead({platform: 'linux', bundledRendererVersion: REPORTER_SHELL, required: ['fluxer_renderer']}),
);
assert.deepEqual(await updater.ensureModule('fluxer_fonts_jp'), {module: 'fluxer_fonts_jp', status: 'unavailable'});
assert.equal(
requests.some((url) => String(url).endsWith('package.br')),
false,
);
});
test('a committed renderer asset stays installed while the served renderer is older than the feed', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
installModuleOnDisk(storeRoot, 'fluxer_fonts_jp', FONT_SHA, [['font.woff2', 'font']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA, fluxer_fonts_jp: FONT_SHA});
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_fonts_jp: sha256('font-two')},
feedAhead({platform: 'linux', required: ['fluxer_renderer']}),
);
assert.deepEqual(await updater.ensureModule('fluxer_fonts_jp'), {
module: 'fluxer_fonts_jp',
status: 'already-installed',
});
});
test('an on-demand dictionary installs while the renderer needs a newer shell', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_dict_en', DICTIONARY_SHA, [['en.dic', 'words']], LIVE_SHELL);
seedCachedPackage(store, DICTIONARY_SHA);
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_dict_en: DICTIONARY_SHA},
feedAhead({platform: 'linux', bundledRendererVersion: REPORTER_SHELL, required: ['fluxer_renderer']}),
);
assert.deepEqual(await updater.ensureModule('fluxer_dict_en'), {module: 'fluxer_dict_en', status: 'installed'});
assert.deepEqual(store.getCommitted(), {fluxer_dict_en: DICTIONARY_SHA});
assert.deepEqual(store.getShellRange('fluxer_dict_en', DICTIONARY_SHA), {
minimum_shell_version: null,
maximum_shell_version: null,
});
});
test('an AppImage updates its shell first on a forced boot and launches the compatible set when that fails', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA});
const attempts = [];
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
feedAhead({
platform: 'linux',
forceStartupUpdate: true,
selfUpdateShellFirst: async (latestVersion) => {
attempts.push(latestVersion);
return {reason: 'download-failed'};
},
}),
);
const outcome = await updater.run();
assert.deepEqual(attempts, [LIVE_SHELL]);
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
});
test('a Windows shell below the floor launches the compatible set when the shell feed has no newer shell', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA});
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
feedAhead({platform: 'win32', selfUpdateShellFirst: async () => ({reason: 'no-update'})}),
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.deepEqual(store.getState().floor, {});
});
test('a Windows shell below the floor installs a compatible module and launches when the shell feed has no newer shell', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
installModuleOnDisk(storeRoot, 'fluxer_dict_en', DICTIONARY_SHA, [['en.dic', 'words']], LIVE_SHELL);
seedCachedPackage(store, DICTIONARY_SHA);
await store.commit({fluxer_renderer: RENDERER_SHA});
const attempts = [];
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_dict_en: DICTIONARY_SHA},
feedAhead({
platform: 'win32',
selfUpdateShellFirst: async (latestVersion) => {
attempts.push(latestVersion);
return {reason: 'no-update'};
},
}),
);
const outcome = await updater.run();
assert.deepEqual(attempts, [LIVE_SHELL]);
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_dict_en: DICTIONARY_SHA, fluxer_renderer: RENDERER_SHA});
});
test('a Windows shell below the floor whose self update fails is blocked', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA});
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
feedAhead({platform: 'win32', selfUpdateShellFirst: async () => ({reason: 'download-failed'})}),
);
const outcome = await updater.run();
assert.equal(outcome.status, 'blocked-shell-update');
assert.equal(outcome.latestVersion, LIVE_SHELL);
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
});
test('a Windows floor keeps the renderer it already enforced when the feed raises it past this shell', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
seedCachedPackage(store, RENDERER_SHA);
const first = createUpdater(
store,
{fluxer_renderer: RENDERER_SHA},
feedAhead({
platform: 'win32',
shellLatest: REPORTER_SHELL,
buildVersion: REPORTER_SHELL,
bounds: {},
required: ['fluxer_renderer'],
}),
);
assert.equal((await first.updater.run()).status, 'launching');
assert.deepEqual(store.getState().floor, {fluxer_renderer: RENDERER_SHA});
const raised = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
feedAhead({
platform: 'win32',
metadataVersion: 2,
required: ['fluxer_renderer'],
selfUpdateShellFirst: async () => ({reason: 'download-failed'}),
}),
);
assert.equal((await raised.updater.run()).status, 'blocked-shell-update');
assert.deepEqual(store.getState().floor, {fluxer_renderer: RENDERER_SHA});
});
test('a renderer installed for a newer shell is retired after a shell downgrade and the bundle serves', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer']], LIVE_SHELL);
await store.commit({fluxer_renderer: NEXT_RENDERER_SHA});
await store.recordShellRanges([
{module: 'fluxer_renderer', sha256: NEXT_RENDERER_SHA, minimum: LIVE_SHELL, maximum: null},
]);
const {updater, reports} = createUpdater(
store,
{},
{shellVersion: REPORTER_SHELL, platform: 'linux', bundledRendererVersion: REPORTER_SHELL},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {});
assert.deepEqual(
reports.filter((report) => report.type === 'shell-incompatible-retired').map((report) => report.module),
['fluxer_renderer'],
);
assert.equal((await updater.selectServedModules(outcome.committed)).renderer.source, 'bundled');
});
test('a renderer with no recorded range is retired when the cached manifest says this shell cannot run it', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer']], LIVE_SHELL);
await store.commit({fluxer_renderer: NEXT_RENDERER_SHA});
writeFileSync(
path.join(store.root, 'manifest.json'),
manifestBytes({fluxer_renderer: NEXT_RENDERER_SHA}, feedAhead({platform: 'linux'})),
);
const {updater, reports} = createUpdater(
store,
{},
{shellVersion: REPORTER_SHELL, platform: 'linux', bundledRendererVersion: REPORTER_SHELL},
);
const outcome = await updater.run();
assert.deepEqual(outcome.committed, {});
assert.equal(
reports.some((report) => report.type === 'shell-incompatible-retired'),
true,
);
});
test('a crash-loop rollback never restores a renderer this shell cannot run', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'ahead']], LIVE_SHELL);
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'current']], LIVE_SHELL);
await store.commit({fluxer_renderer: NEXT_RENDERER_SHA});
await store.commit({fluxer_renderer: RENDERER_SHA});
await store.recordShellRanges([
{module: 'fluxer_renderer', sha256: NEXT_RENDERER_SHA, minimum: LIVE_SHELL, maximum: null},
]);
await store.recordLaunchAttempt();
await store.recordLaunchAttempt();
const {updater} = createUpdater(
store,
{},
{
bundledRendererVersion: SHELL_VERSION,
{shellVersion: REPORTER_SHELL, platform: 'linux', bundledRendererVersion: REPORTER_SHELL},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.equal(outcome.committed.fluxer_renderer, undefined);
assert.equal(store.getState().previous.fluxer_renderer, undefined);
assert.equal(store.isRejected('fluxer_renderer', RENDERER_SHA), true);
});
test('a check offers compatible modules while the renderer needs a newer shell', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_dict_en', DICTIONARY_SHA, [['en.dic', 'words']], REPORTER_SHELL);
await store.commit({fluxer_dict_en: DICTIONARY_SHA});
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA, fluxer_dict_en: NEXT_DICTIONARY_SHA},
feedAhead({platform: 'linux', bundledRendererVersion: REPORTER_SHELL}),
);
assert.deepEqual(await updater.checkForUpdate(), {
shellLatestVersion: LIVE_SHELL,
shellNewer: true,
modulesChanged: true,
});
});
test('a Linux launch never blocks on linux_security_minimum in the manifest', async () => {
const {store, storeRoot} = await storeFor();
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']], REPORTER_SHELL);
await store.commit({fluxer_renderer: RENDERER_SHA});
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
feedAhead({
platform: 'linux',
forceStartupUpdate: true,
shellLatest: '2026.900.1',
shellMinimum: '0.0.0',
selfUpdateShellFirst,
},
shellMinimum: LIVE_SHELL,
extra: {linux_security_minimum: {version: LIVE_SHELL, required_modules: ['fluxer_renderer']}},
}),
);
assert.equal((await updater.run()).status, 'launching');
assert.deepEqual(attempts, []);
const outcome = await updater.run();
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']);
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.equal(Object.hasOwn(updater.getLastState(), 'requiredSecurityUpdate'), false);
});
});
+93 -181
View File
@@ -6,12 +6,7 @@ import {errorMessage} from '@electron/common/ErrorMessage';
import {jitteredExponentialDelay} from '@electron/common/ExponentialBackoff';
import type {DesktopUpdateProbe} from '@electron/main/DesktopUpdateRun';
import {ModuleDownloadRate} from '@electron/main/ModuleDownloadRate';
import {
blockingModuleNames,
type DesktopLinuxSecurityMinimum,
type DesktopModuleUpdateManifest,
resolveDesktopModuleArchitecture,
} from '@electron/main/ModuleManifest';
import {type DesktopModuleUpdateManifest, resolveDesktopModuleArchitecture} from '@electron/main/ModuleManifest';
import {
type ModuleManifestDocument,
ModuleManifestFetchError,
@@ -29,7 +24,6 @@ import {
ModulePackageInstallPhase,
} from '@electron/main/ModulePackageInstaller';
import {
type LinuxModuleSecurityMinimum,
MODULE_BOOT_ATTEMPT_ROLLBACK_THRESHOLD,
type ModuleLaunchAttempt,
ModuleManifestEquivocationError,
@@ -86,6 +80,7 @@ const ModuleUpdaterReportType = Object.freeze({
ROLLBACK: 'rollback',
ROLLBACK_FLOOR_BYPASS: 'rollback-floor-bypass',
BUNDLED_RENDERER_PREFERRED: 'bundled-renderer-preferred',
SHELL_INCOMPATIBLE_RETIRED: 'shell-incompatible-retired',
BLOCKED: 'blocked',
} as const);
@@ -102,6 +97,7 @@ const MODULE_UPDATER_REPORT_SAMPLE_RATES: Readonly<Record<ModuleUpdaterReportTyp
[ModuleUpdaterReportType.ROLLBACK]: 1,
[ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS]: 1,
[ModuleUpdaterReportType.BUNDLED_RENDERER_PREFERRED]: 1,
[ModuleUpdaterReportType.SHELL_INCOMPATIBLE_RETIRED]: 1,
[ModuleUpdaterReportType.BLOCKED]: 1,
});
@@ -109,7 +105,6 @@ export {ModuleUpdaterBlockReason};
export interface ModuleUpdaterSplashState {
readonly status: ModuleUpdaterStatus;
readonly requiredSecurityUpdate: boolean;
readonly current: number | null;
readonly total: number | null;
readonly progress: number | null;
@@ -155,14 +150,12 @@ export type ModuleUpdaterOutcome =
readonly reason: ModuleUpdaterBlockReason;
readonly modules: ReadonlyArray<string>;
readonly message: string;
readonly requiredSecurityUpdate: boolean;
readonly updateServerUnreachable: boolean;
}
| {
readonly status: typeof ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE;
readonly latestVersion: string;
readonly minimumVersion: string;
readonly requiredSecurityUpdate: boolean;
};
class ModuleUpdaterStorageFullError extends Error {
@@ -256,14 +249,6 @@ interface ModulePlanResult {
readonly blockedError: unknown;
}
const ModuleStartupUpdatePolicy = Object.freeze({
REQUIRED: 'required',
OPTIONAL: 'optional',
REQUIRED_SECURITY: 'required-security',
} as const);
type ModuleStartupUpdatePolicy = (typeof ModuleStartupUpdatePolicy)[keyof typeof ModuleStartupUpdatePolicy];
const ModuleManifestObservationMode = Object.freeze({
READ_ONLY: 'read-only',
RECORD_FETCH: 'record-fetch',
@@ -282,7 +267,7 @@ interface ModuleUpdaterOptions {
readonly bundledRendererVersion?: string | null;
readonly preferUnversionedBundle?: boolean;
readonly forceStartupUpdate?: boolean;
readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>;
readonly selfUpdateShellFirst?: (latestVersion: string) => Promise<{readonly reason: string}>;
readonly onState?: (state: ModuleUpdaterSplashState) => void;
readonly report?: (report: ModuleUpdaterReport) => void;
readonly fetch: typeof globalThis.fetch;
@@ -301,10 +286,8 @@ export class ModuleUpdater {
private readonly shellVersion: ModuleVersion;
private readonly hasOfflineRenderer: boolean;
private readonly forceStartupUpdate: boolean;
private readonly selfUpdateShellFirst:
| ((latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>)
| null;
private readonly shellFirstAttempted = new Set<string>();
private readonly selfUpdateShellFirst: ((latestVersion: string) => Promise<{readonly reason: string}>) | null;
private readonly shellFirstAttempts = new Map<string, {readonly reason: string} | null>();
private readonly manifestFeed: ModuleManifestFeedIdentity;
private readonly manifestRepository: ModuleManifestRepository;
private readonly planner: ModuleUpdatePlanner;
@@ -321,7 +304,7 @@ export class ModuleUpdater {
private lastError: string | null = null;
private lastErrorAt: number | null = null;
private ensureBlockedUntil = 0;
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
private readonly startupUpdateEnforced: boolean;
private manifestObservationTail: Promise<void> = Promise.resolve();
private recordedManifestSha256: string | null = null;
private lastAccepted: {readonly document: ModuleManifestDocument; readonly metadataVersion: number} | null = null;
@@ -381,11 +364,9 @@ export class ModuleUpdater {
},
report: (report) => this.handlePackageInstallerReport(report),
});
this.startupUpdatePolicy =
this.platform === 'linux' ? ModuleStartupUpdatePolicy.OPTIONAL : ModuleStartupUpdatePolicy.REQUIRED;
this.startupUpdateEnforced = this.platform !== 'linux';
this.lastState = {
status: ModuleUpdaterStatus.CHECKING,
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
@@ -423,10 +404,10 @@ export class ModuleUpdater {
let attempt = await this.store.beginBootAttempt({bundled: this.planner.bundledModules()});
const retiredRenderer = await this.retireRendererCoveredByBundle();
const retiredMissing = await this.retireUninstalledModules();
if (retiredRenderer || retiredMissing) {
const retiredIncompatible = await this.retireIncompatibleModules();
if (retiredRenderer || retiredMissing || retiredIncompatible) {
attempt = {...attempt, committed: this.store.getCommitted()};
}
await this.restoreCachedUpdatePolicy();
if (attempt.rolledBack) {
this.report({
type: ModuleUpdaterReportType.ROLLBACK,
@@ -450,25 +431,22 @@ export class ModuleUpdater {
const document = await this.manifestRepository.fetchLatest();
fetched = true;
const manifest = await this.acceptManifest(document);
const startupUpdateRequired = await this.startupUpdateRequired();
if (!startupUpdateRequired) {
if (!(await this.startupUpdateRequired())) {
return await this.launchIfAtFloor();
}
if (this.planner.shellUpdateRequired(manifest) && (await this.onlyForcedByShellChange())) {
return await this.launchIfAtFloor();
}
if (this.planner.shellUpdateRequired(manifest)) {
const shellFirst = await this.updateShellFirst(manifest);
if (
this.startupUpdateEnforced &&
this.planner.shellUpdateRequired(manifest) &&
shellFirst?.reason !== 'no-update'
) {
this.emit(ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE);
return {
status: ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE,
latestVersion: manifest.shell.latestVersion.source,
minimumVersion: manifest.shell.minimumVersion.source,
requiredSecurityUpdate: this.isSecurityUpdateRequired(),
};
}
if (await this.updateShellFirst(manifest)) {
this.emit(ModuleUpdaterStatus.CHECKING);
}
const plan = await this.planner.plan(manifest);
if (plan.items.length === 0) {
await this.commitIfChanged(plan.base);
@@ -487,7 +465,6 @@ export class ModuleUpdater {
this.emit(ModuleUpdaterStatus.VERIFYING, {current: plan.items.length, total: plan.items.length});
}
await this.commitIfChanged(executed.desired);
await this.applyUpdatePolicy(manifest);
if (executed.installed.length === 0) {
return await this.launchIfAtFloor();
}
@@ -505,12 +482,9 @@ export class ModuleUpdater {
const exhausted =
backoff.reachedCeiling ||
error instanceof ModuleManifestEquivocationError ||
(await this.onlyForcedByShellChange());
(await this.forcedButLaunchable());
if (exhausted || !fetched) {
const decision = await this.planner.evaluateUnreachableLaunch(
this.isStartupUpdateEnforced(),
this.isSecurityUpdateRequired(),
);
const decision = await this.planner.evaluateUnreachableLaunch(this.startupUpdateEnforced);
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
@@ -558,20 +532,17 @@ export class ModuleUpdater {
this.reportFailure(error);
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
}
if (this.planner.shellUpdateRequired(manifest)) {
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
}
if (!Object.hasOwn(manifest.modules, moduleName)) {
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
}
const entry = manifest.modules[moduleName];
if (!this.planner.isShellCompatible(entry)) {
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
}
const committed = this.store.getCommitted()[moduleName];
if (committed != null && (await this.planner.isInstalled(moduleName, committed))) {
return {module: moduleName, status: DesktopModuleEnsureStatus.ALREADY_INSTALLED};
}
const entry = this.planner.compatibleEntry(manifest, moduleName);
if (
entry == null ||
(this.planner.isRendererAssetModule(moduleName) && !this.planner.rendererFollowsFeed(manifest))
) {
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
}
if (this.store.isRejected(moduleName, entry.sha256)) {
return {module: moduleName, status: DesktopModuleEnsureStatus.UNAVAILABLE};
}
@@ -625,23 +596,50 @@ export class ModuleUpdater {
return retired;
}
private async retireIncompatibleModules(): Promise<boolean> {
const cached = await this.readCachedManifest();
const retired = await this.store.retireIncompatible((moduleName, sha256) =>
this.planner.isRunnable(moduleName, sha256, cached),
);
for (const moduleName of retired) {
this.report({
type: ModuleUpdaterReportType.SHELL_INCOMPATIBLE_RETIRED,
module: moduleName,
message: `retired module ${moduleName} because shell ${this.shellVersion.source} cannot run it`,
});
}
return retired.length > 0;
}
private async readCachedManifest(): Promise<DesktopModuleUpdateManifest | null> {
try {
const document = await this.manifestRepository.readCached();
return document == null ? null : this.manifestRepository.parse(document);
} catch {
return null;
}
}
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
return this.acceptManifest(await this.manifestRepository.resolveMemoizedDocument());
}
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<{readonly reason: string} | null> {
const latestVersion = manifest.shell.latestVersion.source;
if (
this.selfUpdateShellFirst == null ||
compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 ||
this.shellFirstAttempted.has(latestVersion) ||
(this.platform === 'linux' && !this.isSecurityUpdateRequired())
compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0
) {
return false;
return null;
}
this.shellFirstAttempted.add(latestVersion);
await this.selfUpdateShellFirst(latestVersion, this.isSecurityUpdateRequired());
return true;
if (this.shellFirstAttempts.has(latestVersion)) {
return this.shellFirstAttempts.get(latestVersion) ?? null;
}
this.shellFirstAttempts.set(latestVersion, null);
const result = await this.selfUpdateShellFirst(latestVersion);
this.shellFirstAttempts.set(latestVersion, result);
this.emit(ModuleUpdaterStatus.CHECKING);
return result;
}
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
@@ -658,9 +656,6 @@ export class ModuleUpdater {
: manifest.shell.latestVersion;
const shellLatestVersion = shellLatest.source;
const shellNewer = compareModuleVersions(shellLatest, this.shellVersion) > 0;
if (this.planner.shellUpdateRequired(manifest)) {
return {shellLatestVersion, shellNewer, modulesChanged: false};
}
return {
shellLatestVersion,
shellNewer,
@@ -672,9 +667,6 @@ export class ModuleUpdater {
this.emit(ModuleUpdaterStatus.CHECKING);
this.downloadRate.reset();
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.RECORD_FETCH);
if (this.planner.shellUpdateRequired(manifest)) {
return null;
}
const items = await this.collectPendingItems(manifest, this.planner);
const {installed, unresolved} = await this.installItems(items);
if (Object.keys(installed).length > 0) {
@@ -711,11 +703,11 @@ export class ModuleUpdater {
planner: ModuleUpdatePlanner,
): Promise<Array<ModulePlanItem>> {
const committed = this.store.getCommitted();
const names = new Set<string>([...Object.keys(committed), ...blockingModuleNames(manifest)]);
const names = new Set<string>([...Object.keys(committed), ...manifest.requiredModules]);
const wanted: Array<ModulePlanItem> = [];
for (const moduleName of Array.from(names).sort()) {
const entry = manifest.modules[moduleName];
if (entry == null || !planner.isShellCompatible(entry) || planner.bundleCoversModule(moduleName, manifest)) {
if (entry == null || planner.skipsFeedEntry(manifest, moduleName)) {
continue;
}
if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) {
@@ -783,14 +775,7 @@ export class ModuleUpdater {
}
private async launchIfAtFloor(): Promise<ModuleUpdaterOutcome> {
if (this.isSecurityUpdateRequired()) {
return this.blockUpdateRequired(
ModuleUpdaterBlockReason.BELOW_FLOOR,
[],
'the installed shell or module set is below the required Linux security minimum',
);
}
const below = await this.planner.modulesBelowFloor(this.isStartupUpdateEnforced());
const below = await this.planner.modulesBelowFloor(this.startupUpdateEnforced);
if (below.length > 0) {
return this.blockUpdateRequired(
ModuleUpdaterBlockReason.BELOW_FLOOR,
@@ -820,9 +805,6 @@ export class ModuleUpdater {
private async launchRevertedSetBypassingFloor(
committed: Readonly<Record<string, string>>,
): Promise<ModuleUpdaterOutcome | null> {
if (this.isSecurityUpdateRequired()) {
return null;
}
const names = Object.keys(committed);
if (names.length === 0) {
return null;
@@ -832,7 +814,7 @@ export class ModuleUpdater {
return null;
}
}
const belowFloor = await this.planner.modulesBelowFloor(this.isStartupUpdateEnforced());
const belowFloor = await this.planner.modulesBelowFloor(this.startupUpdateEnforced);
if (belowFloor.length > 0) {
this.report({
type: ModuleUpdaterReportType.ROLLBACK_FLOOR_BYPASS,
@@ -867,87 +849,10 @@ export class ModuleUpdater {
reason,
modules,
message,
requiredSecurityUpdate: this.isSecurityUpdateRequired(),
updateServerUnreachable,
};
}
private async applyUpdatePolicy(manifest: DesktopModuleUpdateManifest): Promise<void> {
if (this.platform !== 'linux') {
this.startupUpdatePolicy = ModuleStartupUpdatePolicy.REQUIRED;
return;
}
await this.applyLinuxUpdatePolicy(manifest.linuxSecurityMinimum);
}
private persistedLinuxSecurityMinimum(): DesktopLinuxSecurityMinimum | null {
const persisted = this.store.getState().linux_security_minimum;
if (persisted == null) {
return null;
}
return {
version: parseModuleVersion(persisted.version, 'persisted Linux security minimum'),
requiredModules: persisted.requiredModules,
};
}
private effectiveLinuxSecurityMinimum(
manifestMinimum: DesktopLinuxSecurityMinimum | null,
): DesktopLinuxSecurityMinimum | null {
const persisted = this.persistedLinuxSecurityMinimum();
if (manifestMinimum == null) {
return persisted;
}
if (persisted == null) {
return manifestMinimum;
}
const comparison = compareModuleVersions(manifestMinimum.version, persisted.version);
if (comparison < 0) {
throw new Error(
`manifest attempts to lower the Linux security minimum from ${persisted.version.source} to ${manifestMinimum.version.source}`,
);
}
if (comparison === 0 && manifestMinimum.requiredModules.join('\0') !== persisted.requiredModules.join('\0')) {
throw new Error(`manifest changes the required modules at Linux security minimum ${persisted.version.source}`);
}
return manifestMinimum;
}
private async applyLinuxUpdatePolicy(manifestMinimum: DesktopLinuxSecurityMinimum | null): Promise<void> {
const minimum = this.effectiveLinuxSecurityMinimum(manifestMinimum);
if (minimum != null) {
this.startupUpdatePolicy = (await this.planner.securityUpdateRequired(minimum))
? ModuleStartupUpdatePolicy.REQUIRED_SECURITY
: ModuleStartupUpdatePolicy.OPTIONAL;
return;
}
this.startupUpdatePolicy = ModuleStartupUpdatePolicy.OPTIONAL;
}
private isStartupUpdateEnforced(): boolean {
return this.startupUpdatePolicy !== ModuleStartupUpdatePolicy.OPTIONAL;
}
private isSecurityUpdateRequired(): boolean {
return this.startupUpdatePolicy === ModuleStartupUpdatePolicy.REQUIRED_SECURITY;
}
private async restoreCachedUpdatePolicy(): Promise<void> {
if (this.platform !== 'linux') {
return;
}
await this.applyLinuxUpdatePolicy(null);
const document = await this.manifestRepository.readCached();
if (document == null) {
return;
}
try {
await this.observeManifest(document, ModuleManifestObservationMode.READ_ONLY);
} catch (error) {
this.reportFailure(error);
}
}
private observeManifest(
document: ModuleManifestDocument,
mode: ModuleManifestObservationMode = ModuleManifestObservationMode.RECORD_FETCH,
@@ -964,7 +869,7 @@ export class ModuleUpdater {
manifestSha256: createHash('sha256').update(document.bytes).digest('hex'),
};
this.store.requireManifestFresh(observation);
await this.applyUpdatePolicy(manifest);
await this.backfillShellRanges(manifest);
if (
mode === ModuleManifestObservationMode.RECORD_FETCH &&
this.recordedManifestSha256 !== observation.manifestSha256
@@ -983,16 +888,29 @@ export class ModuleUpdater {
return observed;
}
private async onlyForcedByShellChange(): Promise<boolean> {
return (
this.forceStartupUpdate && !this.isStartupUpdateEnforced() && (await this.planner.canLaunchCommittedModules())
);
private async backfillShellRanges(manifest: DesktopModuleUpdateManifest): Promise<void> {
const ranges = [];
for (const [moduleName, sha256] of Object.entries(this.store.getCommitted())) {
const entry = manifest.modules[moduleName];
if (entry?.sha256 !== sha256) {
continue;
}
ranges.push({
module: moduleName,
sha256,
minimum: entry.minimumShellVersion?.source ?? null,
maximum: entry.maximumShellVersion?.source ?? null,
});
}
await this.store.recordShellRanges(ranges);
}
private async forcedButLaunchable(): Promise<boolean> {
return this.forceStartupUpdate && !this.startupUpdateEnforced && (await this.planner.canLaunchCommittedModules());
}
private async startupUpdateRequired(): Promise<boolean> {
return (
this.forceStartupUpdate || this.isStartupUpdateEnforced() || !(await this.planner.canLaunchCommittedModules())
);
return this.forceStartupUpdate || this.startupUpdateEnforced || !(await this.planner.canLaunchCommittedModules());
}
private async execute(plan: ModulePlan): Promise<ModulePlanResult> {
@@ -1041,20 +959,16 @@ export class ModuleUpdater {
for (const [moduleName, entry] of Object.entries(manifest.modules)) {
advertised[moduleName] = entry.sha256;
}
let linuxSecurityMinimum: LinuxModuleSecurityMinimum | undefined;
if (this.platform === 'linux') {
if (manifest.linuxSecurityMinimum != null) {
linuxSecurityMinimum = {
version: manifest.linuxSecurityMinimum.version.source,
requiredModules: manifest.linuxSecurityMinimum.requiredModules,
};
}
} else {
for (const moduleName of blockingModuleNames(manifest)) {
if (this.startupUpdateEnforced) {
const enforced = this.store.getState().floor;
for (const moduleName of manifest.requiredModules) {
if (this.planner.bundleCoversModule(moduleName, manifest)) {
continue;
}
floor[moduleName] = manifest.modules[moduleName].sha256;
const sha256 = this.planner.compatibleEntry(manifest, moduleName)?.sha256 ?? enforced[moduleName];
if (sha256 != null) {
floor[moduleName] = sha256;
}
}
}
await this.manifestRepository.persist(document);
@@ -1063,7 +977,6 @@ export class ModuleUpdater {
fetchedAt: new Date(this.now()).toISOString(),
manifest: observation,
floor,
linuxSecurityMinimum,
advertised,
});
}
@@ -1143,7 +1056,6 @@ export class ModuleUpdater {
): void {
this.lastState = {
status,
requiredSecurityUpdate: this.isSecurityUpdateRequired(),
current: patch.current ?? null,
total: patch.total ?? null,
progress: patch.progress ?? null,
@@ -31,35 +31,3 @@ export function getUpdateBaseUrl(platform: NodeJS.Platform = process.platform):
export const MANUAL_DESKTOP_FORMATS = ['setup', 'dmg', 'zip', 'appimage', 'deb', 'rpm', 'tar_gz'] as const;
export type ManualDesktopFormat = (typeof MANUAL_DESKTOP_FORMATS)[number];
export type LinuxManualDesktopFormat = Extract<ManualDesktopFormat, 'appimage' | 'deb' | 'rpm' | 'tar_gz'>;
export const LINUX_MANUAL_FORMAT_EXTENSIONS: Record<LinuxManualDesktopFormat, string> = {
appimage: '.AppImage',
deb: '.deb',
rpm: '.rpm',
tar_gz: '.tar.gz',
};
export const LINUX_MANUAL_ARCH_TOKENS: Record<LinuxManualDesktopFormat, Record<DesktopDownloadArch, string>> = {
appimage: {x64: 'x86_64', arm64: 'arm64'},
deb: {x64: 'amd64', arm64: 'arm64'},
rpm: {x64: 'x86_64', arm64: 'aarch64'},
tar_gz: {x64: 'x64', arm64: 'arm64'},
};
export const SPLASH_LINUX_FORMAT_ORDER: ReadonlyArray<LinuxManualDesktopFormat> = ['deb', 'rpm', 'appimage', 'tar_gz'];
export const SPLASH_LINUX_FORMAT_LABELS: Record<LinuxManualDesktopFormat, string> = {
deb: 'Debian (deb)',
rpm: 'Fedora (rpm)',
appimage: 'Linux (AppImage)',
tar_gz: 'Linux (tar.gz)',
};
export function isLinuxManualDesktopFormat(format: ManualDesktopFormat): format is LinuxManualDesktopFormat {
return format === 'appimage' || format === 'deb' || format === 'rpm' || format === 'tar_gz';
}
export function buildManualLatestDownloadUrl(format: ManualDesktopFormat): string {
return `${getUpdateBaseUrl()}/latest/${format}`;
}
@@ -105,35 +105,17 @@ installElectronStub({
const {openExternalDeduped, shouldOpenExternalUrl} = await import('@electron/main/OpenExternal');
const {openSplashWindow} = await import('@electron/main/SplashWindow');
const {buildManualLatestDownloadUrl, DESKTOP_DOWNLOAD_ARCH, getDesktopDownloadArch, getUpdateBaseUrl} = await import(
'@electron/main/ShellDownloadFormats'
);
const {getDesktopDownloadArch} = await import('@electron/main/ShellDownloadFormats');
const {BUILD_CHANNEL} = await import('@electron/common/BuildChannel');
const {decideShellUpdatePlan, resolveShellUpdatePlan, ShellUpdateCapability} = await import(
'@electron/main/ShellUpdateCapability'
);
const {decideShellUpdatePlan, resolveShellUpdatePlan} = await import('@electron/main/ShellUpdateCapability');
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
const {armBlockedShellUpdate, getBlockedShellUpdate, SPLASH_MANUAL_UPDATE_MESSAGE} = await import(
'@electron/main/ShellUpdateSplash'
);
const {armBlockedShellUpdate} = await import('@electron/main/ShellUpdateSplash');
const SELF_UPDATE_PLAN = {capability: ShellUpdateCapability.SELF_UPDATE, updater: 'velopack'};
const MANUAL_PLATFORM_PLAN = {capability: ShellUpdateCapability.MANUAL_DOWNLOAD, reason: 'platform'};
const MANUAL_UNPACKAGED_PLAN = {capability: ShellUpdateCapability.MANUAL_DOWNLOAD, reason: 'unpackaged'};
const MANAGED_PLAN = {capability: ShellUpdateCapability.MANAGED_PACKAGE};
const APPIMAGE_TARGET = {
installedPath: '/home/user/Applications/Fluxer.AppImage',
directory: '/home/user/Applications',
};
const EXPECTED_PICKER_OPTIONS = [
{value: 'deb', label: 'Debian (deb)', buttonLabel: 'Download', kind: 'download'},
{value: 'rpm', label: 'Fedora (rpm)', buttonLabel: 'Download', kind: 'download'},
{value: 'appimage', label: 'Linux (AppImage)', buttonLabel: 'Download', kind: 'download'},
{value: 'tar_gz', label: 'Linux (tar.gz)', buttonLabel: 'Download', kind: 'download'},
{value: 'nope', label: "I'll figure it out", buttonLabel: 'Okay', kind: 'quit'},
];
function emitIpc(channel, event, payload) {
for (const registration of ipcRegistrations) {
if (registration.channel === channel) {
@@ -148,24 +130,6 @@ function settle() {
});
}
function withProperty(target, name, value, run) {
const original = Object.getOwnPropertyDescriptor(target, name);
Object.defineProperty(target, name, {value, configurable: true, writable: true});
try {
return run();
} finally {
if (original == null) {
delete target[name];
} else {
Object.defineProperty(target, name, original);
}
}
}
function withPlatform(platform, run) {
return withProperty(process, 'platform', platform, run);
}
function planFor(runtime) {
return decideShellUpdatePlan({
channel: 'canary',
@@ -178,10 +142,6 @@ function planFor(runtime) {
});
}
function hasAffordance(blocked) {
return blocked.action != null || blocked.options != null;
}
describe('shell update capability', () => {
test('every runtime signal picks one updater, first match wins', () => {
assert.deepEqual(planFor({packaged: false, platform: 'win32'}), {
@@ -197,10 +157,26 @@ describe('shell update capability', () => {
updater: 'appimage',
target: APPIMAGE_TARGET,
});
assert.deepEqual(planFor({platform: 'linux'}), {capability: 'manual-download', reason: 'platform'});
assert.deepEqual(planFor({platform: 'linux'}), {capability: 'managed-package'});
assert.deepEqual(planFor({platform: 'freebsd'}), {capability: 'manual-download', reason: 'platform'});
});
test('only a writable AppImage updates itself on Linux, every other install belongs to its package manager', () => {
assert.deepEqual(planFor({platform: 'linux', appImage: APPIMAGE_TARGET}).capability, 'self-update');
for (const runtime of [
{platform: 'linux'},
{platform: 'linux', portable: true},
{platform: 'linux', portable: true, appImage: APPIMAGE_TARGET},
{platform: 'linux', appImage: null},
{platform: 'linux', flatpak: true},
]) {
assert.deepEqual(planFor(runtime), {capability: 'managed-package'}, JSON.stringify(runtime));
}
for (const platform of ['win32', 'darwin']) {
assert.deepEqual(planFor({platform, portable: true}), {capability: 'manual-download', reason: 'platform'});
}
});
test('a development shell never self-updates, because no feed publishes development builds', () => {
for (const platform of ['darwin', 'win32', 'linux']) {
assert.deepEqual(planFor({channel: 'development', platform, appImage: APPIMAGE_TARGET}), {
@@ -217,8 +193,7 @@ describe('shell update capability', () => {
test('a store runtime outranks a portable marker, so a sandboxed install is never offered a deb or rpm it cannot apply', () => {
assert.deepEqual(planFor({portable: true, flatpak: true, platform: 'linux'}), {capability: 'managed-package'});
assert.deepEqual(planFor({portable: true, appImage: APPIMAGE_TARGET, platform: 'linux'}), {
capability: 'manual-download',
reason: 'platform',
capability: 'managed-package',
});
assert.deepEqual(planFor({packaged: false, flatpak: true, platform: 'linux'}), {
capability: 'manual-download',
@@ -242,114 +217,11 @@ describe('shell update capability', () => {
});
describe('blocked shell update states', () => {
test('every plan lands on an affordance that can resolve the block', () => {
for (const plan of [SELF_UPDATE_PLAN, MANUAL_PLATFORM_PLAN, MANUAL_UNPACKAGED_PLAN, MANAGED_PLAN]) {
for (const platform of ['linux', 'darwin', 'win32']) {
const blocked = withPlatform(platform, () => getBlockedShellUpdate(plan, '2026.823.1'));
assert.ok(hasAffordance(blocked), `${plan.capability} on ${platform} has no way out`);
assert.notEqual(
blocked.action?.kind,
'retry',
`${plan.capability} cannot escape a relaunch: the same binary refetches the same manifest and blocks again`,
);
for (const label of [blocked.action?.label, ...(blocked.options ?? []).map((option) => option.label)]) {
assert.ok(label == null || label.length > 0);
}
}
}
});
test('a shell that could have updated itself and did not falls back to the download page', () => {
const blocked = withPlatform('win32', () => getBlockedShellUpdate(SELF_UPDATE_PLAN, '2026.823.1'));
assert.deepEqual(blocked, {
layout: 'splash',
status: 'blocked-shell-update',
action: {kind: 'download', label: 'Download update'},
message: null,
versionLabel: null,
options: null,
downloadUrl: DOWNLOAD_PAGE_URL,
downloadUrls: new Map(),
});
});
test('a packaged linux install gets the picker, with the version main already knows', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.equal(blocked.layout, 'manual-update');
assert.equal(blocked.status, 'blocked-shell-update');
assert.equal(blocked.action, null);
assert.equal(blocked.message, SPLASH_MANUAL_UPDATE_MESSAGE);
assert.equal(blocked.versionLabel, 'Version 2026.823.1 available');
assert.deepEqual(blocked.options, EXPECTED_PICKER_OPTIONS);
assert.equal(blocked.downloadUrl, null);
});
test('an unknown latest version leaves the bottom line off rather than inventing one', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, null));
assert.equal(blocked.versionLabel, null);
assert.deepEqual(blocked.options, EXPECTED_PICKER_OPTIONS);
});
test('a dev run from source is never handed a distro artifact', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_UNPACKAGED_PLAN, '2026.823.1'));
assert.equal(blocked.layout, 'splash');
assert.equal(blocked.options, null);
assert.deepEqual(blocked.action, {kind: 'download', label: 'Download update'});
assert.equal(blocked.downloadUrl, DOWNLOAD_PAGE_URL);
});
test('a portable win32 or darwin build gets the page, never the linux picker', () => {
for (const platform of ['win32', 'darwin']) {
const blocked = withPlatform(platform, () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.equal(blocked.layout, 'splash', platform);
assert.equal(blocked.options, null, platform);
assert.equal(blocked.downloadUrl, DOWNLOAD_PAGE_URL, platform);
}
});
test('a package manager owned install is offered no download url at all', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANAGED_PLAN, '2026.823.1'));
assert.equal(blocked.downloadUrl, null);
assert.equal(blocked.downloadUrls.size, 0);
assert.equal(blocked.options, null);
assert.equal(blocked.status, 'blocked-shell-update-managed');
assert.deepEqual(blocked.action, {kind: 'quit', label: 'Quit'});
});
test('the download page is a channel aware page, never an artifact', () => {
assert.equal(shouldOpenExternalUrl(DOWNLOAD_PAGE_URL), true);
assert.equal(new URL(DOWNLOAD_PAGE_URL).pathname, '/download');
});
test('every picker entry resolves to the latest linux artifact route for the running arch', () => {
const blocked = withPlatform('linux', () => getBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.deepEqual(
[...blocked.downloadUrls.keys()],
EXPECTED_PICKER_OPTIONS.filter((option) => option.kind === 'download').map((option) => option.value),
);
for (const [format, url] of blocked.downloadUrls) {
assert.equal(url, `${getUpdateBaseUrl('linux')}/latest/${format}`);
assert.equal(new URL(url).pathname.endsWith(`/linux/${DESKTOP_DOWNLOAD_ARCH}/latest/${format}`), true, url);
assert.equal(shouldOpenExternalUrl(url), true);
}
assert.equal(blocked.downloadUrls.has('nope'), false);
});
test('the artifact route reads the platform at call time, so a darwin runner still proves the linux shape', () => {
const linux = withPlatform('linux', () => buildManualLatestDownloadUrl('deb'));
const win32 = withPlatform('win32', () => buildManualLatestDownloadUrl('setup'));
assert.equal(linux, `${getUpdateBaseUrl('linux')}/latest/deb`);
assert.ok(linux.includes('/linux/'), linux);
assert.ok(win32.includes('/win32/'), win32);
});
test('the download arch collapses to the two we publish', () => {
assert.equal(getDesktopDownloadArch('arm64'), 'arm64');
assert.equal(getDesktopDownloadArch('x64'), 'x64');
@@ -359,50 +231,16 @@ describe('blocked shell update states', () => {
});
describe('arming the blocked shell update', () => {
test('a package manager owned install arms nothing that could open a url', async () => {
const window = openSplashWindow();
events.length = 0;
armBlockedShellUpdate(MANAGED_PLAN, '2026.823.1');
assert.deepEqual(window.webContents.sent.at(-1), {
channel: 'desktop-splash:state',
payload: {
layout: 'splash',
status: 'blocked-shell-update-managed',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: {kind: 'quit', label: 'Quit'},
message: null,
versionLabel: null,
options: null,
},
});
emitIpc('desktop-splash:open-download', {sender: window.webContents});
await settle();
assert.deepEqual(events, []);
});
test('the download action opens the allowlisted page and then quits', async () => {
const window = openSplashWindow();
events.length = 0;
withPlatform('win32', () => armBlockedShellUpdate(SELF_UPDATE_PLAN, '2026.823.1'));
armBlockedShellUpdate();
assert.deepEqual(window.webContents.sent.at(-1), {
channel: 'desktop-splash:state',
payload: {
layout: 'splash',
status: 'blocked-shell-update',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
@@ -412,8 +250,6 @@ describe('arming the blocked shell update', () => {
bytesPerSecond: null,
action: {kind: 'download', label: 'Download update'},
message: null,
versionLabel: null,
options: null,
},
});
@@ -423,58 +259,13 @@ describe('arming the blocked shell update', () => {
assert.deepEqual(events, [`open:${DOWNLOAD_PAGE_URL}`, 'quit']);
});
test('the picker sends the whole manual layout and opens the artifact the user chose', async () => {
const window = openSplashWindow();
events.length = 0;
const blocked = withPlatform('linux', () => armBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
assert.deepEqual(window.webContents.sent.at(-1), {
channel: 'desktop-splash:state',
payload: {
layout: 'manual-update',
status: 'blocked-shell-update',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: SPLASH_MANUAL_UPDATE_MESSAGE,
versionLabel: 'Version 2026.823.1 available',
options: EXPECTED_PICKER_OPTIONS,
},
});
emitIpc('desktop-splash:open-download', {sender: window.webContents}, 'rpm');
await settle();
assert.deepEqual(events, [`open:${blocked.downloadUrls.get('rpm')}`, 'quit']);
});
test('a token that was never armed is dropped instead of being turned into a url', async () => {
const window = openSplashWindow();
withPlatform('linux', () => armBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
events.length = 0;
for (const value of ['setup', 'dmg', 'nope', 'unknown_format']) {
emitIpc('desktop-splash:open-download', {sender: window.webContents}, value);
}
await settle();
assert.deepEqual(events, []);
});
test('a download that cannot be opened still resolves the screen instead of stranding the user', async () => {
const window = openSplashWindow();
withPlatform('linux', () => armBlockedShellUpdate(MANUAL_PLATFORM_PLAN, '2026.823.1'));
armBlockedShellUpdate();
events.length = 0;
openExternalFailure = new Error('no handler for https');
emitIpc('desktop-splash:open-download', {sender: window.webContents}, 'appimage');
emitIpc('desktop-splash:open-download', {sender: window.webContents});
await settle();
assert.deepEqual(events, ['quit']);
@@ -40,6 +40,12 @@ export function decideShellUpdatePlan(runtime: ShellUpdateRuntime): ShellUpdateP
if (runtime.flatpak) {
return {capability: ShellUpdateCapability.MANAGED_PACKAGE};
}
if (runtime.platform === 'linux') {
if (!runtime.portable && runtime.appImage != null) {
return {capability: ShellUpdateCapability.SELF_UPDATE, updater: 'appimage', target: runtime.appImage};
}
return {capability: ShellUpdateCapability.MANAGED_PACKAGE};
}
if (runtime.portable) {
return {capability: ShellUpdateCapability.MANUAL_DOWNLOAD, reason: 'platform'};
}
@@ -49,9 +55,6 @@ export function decideShellUpdatePlan(runtime: ShellUpdateRuntime): ShellUpdateP
if (runtime.platform === 'darwin') {
return {capability: ShellUpdateCapability.SELF_UPDATE, updater: 'electron'};
}
if (runtime.platform === 'linux' && runtime.appImage != null) {
return {capability: ShellUpdateCapability.SELF_UPDATE, updater: 'appimage', target: runtime.appImage};
}
return {capability: ShellUpdateCapability.MANUAL_DOWNLOAD, reason: 'platform'};
}
+11 -140
View File
@@ -2,161 +2,32 @@
import {createChildLogger} from '@electron/common/Logger';
import {openExternalDeduped} from '@electron/main/OpenExternal';
import {
buildManualLatestDownloadUrl,
SPLASH_LINUX_FORMAT_LABELS,
SPLASH_LINUX_FORMAT_ORDER,
} from '@electron/main/ShellDownloadFormats';
import {ShellUpdateCapability, type ShellUpdatePlan} from '@electron/main/ShellUpdateCapability';
import {
onSplashOpenDownload,
SplashAction,
type SplashActionDescriptor,
SplashActionKind,
type SplashDownloadOption,
SplashLayout,
SplashStatus,
setSplashState,
} from '@electron/main/SplashWindow';
import {onSplashOpenDownload, SplashAction, SplashStatus, setSplashState} from '@electron/main/SplashWindow';
import {DOWNLOAD_PAGE_URL} from '@electron/main/UpdaterDownloads';
import {app} from 'electron';
export const SPLASH_MANUAL_UPDATE_MESSAGE = 'A new version of Fluxer is ready to install.';
const SPLASH_REQUIRED_SECURITY_UPDATE_MESSAGE = 'A required security update must be installed before Fluxer can start.';
const SPLASH_DECLINE_OPTION_VALUE = 'nope';
const SPLASH_DECLINE_OPTION_LABEL = "I'll figure it out";
const SPLASH_DOWNLOAD_BUTTON_LABEL = 'Download';
const SPLASH_DECLINE_BUTTON_LABEL = 'Okay';
interface BlockedShellUpdate {
readonly layout: SplashLayout;
readonly status: SplashStatus;
readonly action: SplashActionDescriptor | null;
readonly message: string | null;
readonly versionLabel: string | null;
readonly options: ReadonlyArray<SplashDownloadOption> | null;
readonly downloadUrl: string | null;
readonly downloadUrls: ReadonlyMap<string, string>;
}
const logger = createChildLogger('ShellUpdateSplash');
let armedDownloadUrl: string | null = null;
const armedDownloadUrls = new Map<string, string>();
let disarmDownloadListener: (() => void) | null = null;
function buildLinuxDownloadOptions(): Array<SplashDownloadOption> {
const options: Array<SplashDownloadOption> = SPLASH_LINUX_FORMAT_ORDER.map((format) => ({
value: format,
label: SPLASH_LINUX_FORMAT_LABELS[format],
buttonLabel: SPLASH_DOWNLOAD_BUTTON_LABEL,
kind: SplashActionKind.DOWNLOAD,
}));
options.push({
value: SPLASH_DECLINE_OPTION_VALUE,
label: SPLASH_DECLINE_OPTION_LABEL,
buttonLabel: SPLASH_DECLINE_BUTTON_LABEL,
kind: SplashActionKind.QUIT,
});
return options;
}
function buildLinuxDownloadUrls(): Map<string, string> {
return new Map(SPLASH_LINUX_FORMAT_ORDER.map((format) => [format, buildManualLatestDownloadUrl(format)]));
}
function usesManualUpdatePicker(plan: ShellUpdatePlan, platform: NodeJS.Platform): boolean {
return (
plan.capability === ShellUpdateCapability.MANUAL_DOWNLOAD && plan.reason === 'platform' && platform === 'linux'
);
}
export function getBlockedShellUpdate(
plan: ShellUpdatePlan,
latestVersion: string | null,
requiredSecurityUpdate = false,
): BlockedShellUpdate {
if (plan.capability === ShellUpdateCapability.MANAGED_PACKAGE) {
return {
layout: SplashLayout.SPLASH,
status: requiredSecurityUpdate
? SplashStatus.BLOCKED_SECURITY_UPDATE_MANAGED
: SplashStatus.BLOCKED_SHELL_UPDATE_MANAGED,
action: SplashAction.QUIT,
message: null,
versionLabel: null,
options: null,
downloadUrl: null,
downloadUrls: new Map(),
};
}
if (usesManualUpdatePicker(plan, process.platform)) {
return {
layout: SplashLayout.MANUAL_UPDATE,
status: requiredSecurityUpdate
? SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED
: SplashStatus.BLOCKED_SHELL_UPDATE,
action: null,
message: requiredSecurityUpdate ? SPLASH_REQUIRED_SECURITY_UPDATE_MESSAGE : SPLASH_MANUAL_UPDATE_MESSAGE,
versionLabel: latestVersion == null ? null : `Version ${latestVersion} available`,
options: buildLinuxDownloadOptions(),
downloadUrl: null,
downloadUrls: buildLinuxDownloadUrls(),
};
}
return {
layout: SplashLayout.SPLASH,
status: requiredSecurityUpdate ? SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED : SplashStatus.BLOCKED_SHELL_UPDATE,
action: SplashAction.DOWNLOAD,
message: null,
versionLabel: null,
options: null,
downloadUrl: DOWNLOAD_PAGE_URL,
downloadUrls: new Map(),
};
}
function openArmedDownload(value: string | null): void {
const url = value == null ? armedDownloadUrl : (armedDownloadUrls.get(value) ?? null);
if (url == null) {
logger.warn('Ignoring a splash download request that matches no armed download', {value});
return;
}
void openExternalDeduped(url).then(
function openDownloadPage(): void {
void openExternalDeduped(DOWNLOAD_PAGE_URL).then(
() => {
logger.info('Opened the download for a required shell update, quitting', {url});
logger.info('Opened the download for a required shell update, quitting', {url: DOWNLOAD_PAGE_URL});
app.quit();
},
(error: unknown) => {
logger.error('Failed to open the download for a required shell update, quitting anyway', {url, error});
logger.error('Failed to open the download for a required shell update, quitting anyway', {
url: DOWNLOAD_PAGE_URL,
error,
});
app.quit();
},
);
}
export function armBlockedShellUpdate(
plan: ShellUpdatePlan,
latestVersion: string | null,
requiredSecurityUpdate = false,
): BlockedShellUpdate {
const blocked = getBlockedShellUpdate(plan, latestVersion, requiredSecurityUpdate);
armedDownloadUrl = blocked.downloadUrl;
armedDownloadUrls.clear();
for (const [value, url] of blocked.downloadUrls) {
armedDownloadUrls.set(value, url);
}
export function armBlockedShellUpdate(): void {
disarmDownloadListener?.();
disarmDownloadListener = null;
if (armedDownloadUrl != null || armedDownloadUrls.size > 0) {
disarmDownloadListener = onSplashOpenDownload(openArmedDownload);
}
setSplashState({
layout: blocked.layout,
status: blocked.status,
action: blocked.action,
message: blocked.message,
versionLabel: blocked.versionLabel,
options: blocked.options,
});
return blocked;
disarmDownloadListener = onSplashOpenDownload(openDownloadPage);
setSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE, action: SplashAction.DOWNLOAD});
}
+9 -277
View File
@@ -10,16 +10,6 @@ import {installElectronStub} from './LocalAppTestSupport.test.mjs';
const MODULE_URL = new URL('./SplashWindow.ts', import.meta.url).href;
const PRELOAD_PATH = fileURLToPath(new URL('../preload/splash.cjs', import.meta.url));
const PRELOAD_SOURCE = readFileSync(PRELOAD_PATH, 'utf8');
const STYLESHEET_PATH = fileURLToPath(new URL('../splash/index.css', import.meta.url));
const STYLESHEET_SOURCE = readFileSync(STYLESHEET_PATH, 'utf8');
const MANUAL_UPDATE_OPTIONS = Object.freeze([
{value: 'deb', label: 'Debian (deb)', buttonLabel: 'Download', kind: 'download'},
{value: 'rpm', label: 'Fedora (rpm)', buttonLabel: 'Download', kind: 'download'},
{value: 'appimage', label: 'Linux (AppImage)', buttonLabel: 'Download', kind: 'download'},
{value: 'tar_gz', label: 'Linux (tar.gz)', buttonLabel: 'Download', kind: 'download'},
{value: 'nope', label: "I'll figure it out", buttonLabel: 'Okay', kind: 'quit'},
]);
const quitCalls = [];
const ipcRegistrations = [];
@@ -512,9 +502,7 @@ describe('splash state serialisation', () => {
progress: 42.5,
}),
{
layout: 'splash',
status: 'downloading-updates',
requiredSecurityUpdate: false,
current: 3,
total: 7,
progress: 42.5,
@@ -524,8 +512,6 @@ describe('splash state serialisation', () => {
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
options: null,
},
);
});
@@ -542,9 +528,7 @@ describe('splash state serialisation', () => {
seconds: -4,
}),
{
layout: 'splash',
status: 'update-failure',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: 0,
@@ -554,8 +538,6 @@ describe('splash state serialisation', () => {
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
options: null,
},
);
});
@@ -572,17 +554,6 @@ describe('splash state serialisation', () => {
assert.equal(serializeSplashState({status: 'not-a-splash-status'}).status, 'checking-for-updates');
});
test('falls back to the splash layout on anything the renderer could not lay out', async () => {
const {serializeSplashState, SplashLayout, SplashStatus} = await loadSplashWindow('serialise-layout');
const layoutFor = (layout) => serializeSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE, layout}).layout;
assert.equal(layoutFor(SplashLayout.MANUAL_UPDATE), 'manual-update');
assert.equal(layoutFor(SplashLayout.SPLASH), 'splash');
assert.equal(layoutFor('dice-roll'), 'splash');
assert.equal(layoutFor(undefined), 'splash');
assert.equal(layoutFor(null), 'splash');
});
test('carries every shipped action through and drops one the renderer could not route', async () => {
const {serializeSplashState, SplashAction, SplashStatus} = await loadSplashWindow('serialise-action');
const actionFor = (action) => serializeSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE, action}).action;
@@ -597,49 +568,6 @@ describe('splash state serialisation', () => {
assert.equal(actionFor({kind: 'retry', label: 'l'.repeat(200)}).label.length, 48);
});
test('carries the whole picker through and drops entries the renderer could not route', async () => {
const {serializeSplashState, SplashStatus} = await loadSplashWindow('serialise-options');
const optionsFor = (options) => serializeSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE, options}).options;
assert.deepEqual(optionsFor(MANUAL_UPDATE_OPTIONS), MANUAL_UPDATE_OPTIONS);
assert.equal(optionsFor(undefined), null);
assert.equal(optionsFor('deb'), null);
assert.equal(optionsFor([]), null);
assert.equal(optionsFor([{value: 'deb', label: 'Debian', buttonLabel: 'Download', kind: 'launch-anyway'}]), null);
assert.equal(optionsFor([{value: 'DEB', label: 'Debian', buttonLabel: 'Download', kind: 'download'}]), null);
assert.equal(optionsFor([{value: '../etc', label: 'Debian', buttonLabel: 'Download', kind: 'download'}]), null);
assert.equal(optionsFor([{value: 'deb', label: ' ', buttonLabel: 'Download', kind: 'download'}]), null);
assert.equal(optionsFor([{value: 'deb', label: 'Debian', buttonLabel: '', kind: 'download'}]), null);
assert.deepEqual(
optionsFor([
{value: 'deb', label: 'Debian', buttonLabel: 'Download', kind: 'download'},
{value: 'not valid', label: 'Nope', buttonLabel: 'Nope', kind: 'download'},
]),
[{value: 'deb', label: 'Debian', buttonLabel: 'Download', kind: 'download'}],
);
assert.equal(
optionsFor(
Array.from({length: 12}, (_entry, index) => ({
value: `format_${index}`,
label: `Format ${index}`,
buttonLabel: 'Download',
kind: 'download',
})),
).length,
8,
);
assert.equal(
optionsFor([{value: 'deb', label: 'l'.repeat(200), buttonLabel: 'b'.repeat(200), kind: 'download'}])[0].label
.length,
48,
);
assert.equal(
optionsFor([{value: 'deb', label: 'l'.repeat(200), buttonLabel: 'b'.repeat(200), kind: 'download'}])[0]
.buttonLabel.length,
24,
);
});
test('only a state that carries a way out counts as an affordance', async () => {
const {hasSplashAffordance, serializeSplashState, SplashAction, SplashStatus} =
await loadSplashWindow('affordance-predicate');
@@ -650,43 +578,6 @@ describe('splash state serialisation', () => {
hasSplashAffordance(serializeSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE, action: SplashAction.QUIT})),
true,
);
assert.equal(
hasSplashAffordance(
serializeSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE, options: MANUAL_UPDATE_OPTIONS}),
),
true,
);
});
test('the manual layout can never be serialised without a way out of it', async () => {
const {hasSplashAffordance, serializeSplashState, SplashLayout, SplashStatus} =
await loadSplashWindow('affordance-manual');
const manual = (patch) =>
serializeSplashState({layout: SplashLayout.MANUAL_UPDATE, status: SplashStatus.BLOCKED_SHELL_UPDATE, ...patch});
assert.deepEqual(manual({}).action, {kind: 'quit', label: 'Quit'});
assert.deepEqual(manual({action: {kind: 'launch-anyway', label: 'Launch anyway'}, options: []}).action, {
kind: 'quit',
label: 'Quit',
});
assert.equal(hasSplashAffordance(manual({})), true);
assert.equal(manual({options: MANUAL_UPDATE_OPTIONS}).action, null);
assert.deepEqual(manual({options: MANUAL_UPDATE_OPTIONS}).options, MANUAL_UPDATE_OPTIONS);
assert.equal(serializeSplashState({status: SplashStatus.BLOCKED_SHELL_UPDATE}).action, null);
});
test('only a token the main process could have minted survives the download payload check', async () => {
const {toSplashDownloadValue} = await loadSplashWindow('download-payload');
assert.equal(toSplashDownloadValue('deb'), 'deb');
assert.equal(toSplashDownloadValue('tar_gz'), 'tar_gz');
assert.equal(toSplashDownloadValue(undefined), null);
assert.equal(toSplashDownloadValue(null), null);
assert.equal(toSplashDownloadValue(42), null);
assert.equal(toSplashDownloadValue('../../etc/passwd'), null);
assert.equal(toSplashDownloadValue('https://evil.example/x'), null);
assert.equal(toSplashDownloadValue('a'.repeat(33)), null);
assert.equal(toSplashDownloadValue(''), null);
});
});
@@ -791,9 +682,7 @@ describe('splash IPC', () => {
{
channel: 'desktop-splash:state',
payload: {
layout: 'splash',
status: 'downloading-updates',
requiredSecurityUpdate: false,
current: 2,
total: 5,
progress: 40,
@@ -803,8 +692,6 @@ describe('splash IPC', () => {
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
options: null,
},
},
]);
@@ -827,9 +714,7 @@ describe('splash IPC', () => {
{
channel: 'desktop-splash:state',
payload: {
layout: 'splash',
status: 'verifying',
requiredSecurityUpdate: false,
current: null,
total: null,
progress: null,
@@ -839,8 +724,6 @@ describe('splash IPC', () => {
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
options: null,
},
},
]);
@@ -888,23 +771,22 @@ describe('splash IPC', () => {
assert.equal(onlineCount, 1);
});
test('hands the download listener the chosen option, and nothing at all for a bare click', async () => {
test('notifies download listeners only for the splash sender and hands them nothing from the payload', async () => {
const {onSplashOpenDownload, openSplashWindow} = await loadSplashWindow('ipc-open-download');
const window = withPlatformResult('darwin', () => openSplashWindow());
const values = [];
onSplashOpenDownload((value) => {
values.push(value);
const calls = [];
onSplashOpenDownload((...args) => {
calls.push(args);
});
emitIpc('desktop-splash:open-download', {sender: new FakeWebContents()}, 'deb');
emitIpc('desktop-splash:open-download', {sender: new FakeWebContents()});
assert.deepEqual(values, []);
assert.deepEqual(calls, []);
emitIpc('desktop-splash:open-download', {sender: window.webContents});
emitIpc('desktop-splash:open-download', {sender: window.webContents}, 'tar_gz');
emitIpc('desktop-splash:open-download', {sender: window.webContents}, 'https://evil.example');
assert.deepEqual(values, [null, 'tar_gz', null]);
assert.deepEqual(calls, [[], []]);
});
test('stops sending state once the splash is closed', async () => {
@@ -1060,10 +942,6 @@ class StubNode {
querySelector(selector) {
return this.descendants().find((node) => node.matches(selector)) ?? null;
}
querySelectorAll(selector) {
return this.descendants().filter((node) => node.matches(selector));
}
}
const {serializeSplashState: serializePreloadState} = await loadSplashWindow('preload-state-producer');
@@ -1143,13 +1021,7 @@ function createPreloadHarness(options = {}) {
liveIntervals: () => intervals.filter((timer) => !timer.cleared),
mount,
query,
queryAll: (selector) => mount.querySelectorAll(selector),
click: (selector) => query(selector).dispatch('click'),
changeSelect: (value) => {
const select = query('#dl-select-input');
select.value = value;
select.dispatch('change');
},
sendState: (payload) => ipcListeners.get('desktop-splash:state')({}, serializePreloadState(payload)),
reveal: () => ipcListeners.get('desktop-splash:revealed')({}),
sent,
@@ -1174,14 +1046,6 @@ function createPreloadHarness(options = {}) {
};
}
function styleRule(selector) {
const marker = `\n${selector} {`;
const start = STYLESHEET_SOURCE.indexOf(marker);
assert.notEqual(start, -1, `${selector} is missing from the splash stylesheet`);
const end = STYLESHEET_SOURCE.indexOf('\n}', start);
return STYLESHEET_SOURCE.slice(start + marker.length, end);
}
describe('splash preload rendering', () => {
test('a held splash waits for its reveal before timing the diagnostics links', async () => {
const diagnosticsTimers = (harness) =>
@@ -1418,21 +1282,6 @@ describe('splash preload rendering', () => {
assert.deepEqual(harness.sent.at(-1), {channel: 'desktop-splash:open-download', payload: undefined});
});
test('offers only a quit action on a package manager owned install', async () => {
const {serializeSplashState, SplashAction} = await loadSplashWindow('preload-managed-action');
const harness = createPreloadHarness();
await harness.start();
harness.sendState(serializeSplashState({status: 'blocked-shell-update-managed', action: SplashAction.QUIT}));
assert.equal(harness.statusText(), 'Update Fluxer through your package manager');
assert.equal(harness.query('.splash-action').textContent, 'Quit');
harness.click('.splash-action');
assert.deepEqual(harness.channels(), ['desktop-splash:ready', 'desktop-splash:quit']);
});
test('routes a click to the current action kind without rebuilding the button', async () => {
const {serializeSplashState, SplashAction} = await loadSplashWindow('preload-action-swap');
const harness = createPreloadHarness();
@@ -1471,112 +1320,12 @@ describe('splash preload rendering', () => {
});
});
describe('splash preload manual update layout', () => {
function manualState(patch = {}) {
return {
layout: 'manual-update',
status: 'blocked-shell-update',
current: null,
total: null,
progress: null,
seconds: null,
action: null,
message: 'A new version of Fluxer is ready to install.',
versionLabel: 'Version 2026.823.1 available',
options: MANUAL_UPDATE_OPTIONS,
...patch,
};
}
test('renders the validated markup, every format and the opt-out', () => {
const harness = createPreloadHarness();
harness.start();
harness.sendState(manualState());
assert.equal(harness.mount.replaceChildrenCalls, 2);
assert.equal(harness.query('.splash-inner-dl').children.length, 4);
assert.equal(harness.query('.splash-mark-dl').querySelector('img').getAttribute('src'), './symbol.svg');
assert.equal(harness.query('.dl-update-message').textContent, 'A new version of Fluxer is ready to install.');
assert.equal(harness.query('.dl-version-message').textContent, 'Version 2026.823.1 available');
assert.deepEqual(
harness.queryAll('option').map((node) => [node.value, node.textContent]),
[
['deb', 'Debian (deb)'],
['rpm', 'Fedora (rpm)'],
['appimage', 'Linux (AppImage)'],
['tar_gz', 'Linux (tar.gz)'],
['nope', "I'll figure it out"],
],
);
assert.equal(harness.query('#dl-select-input').value, 'deb');
assert.equal(harness.query('#dl-button').textContent, 'Download');
assert.equal(harness.query('#dl-button').tagName, 'button');
});
test('clicking with a real format sends that format on the download channel', async () => {
const harness = createPreloadHarness();
await harness.start();
harness.sendState(manualState());
harness.click('#dl-button');
harness.changeSelect('tar_gz');
harness.click('#dl-button');
assert.deepEqual(harness.sent.slice(1), [
{channel: 'desktop-splash:open-download', payload: 'deb'},
{channel: 'desktop-splash:open-download', payload: 'tar_gz'},
]);
});
test('clicking the opt-out quits and opens nothing', async () => {
const harness = createPreloadHarness();
await harness.start();
harness.sendState(manualState());
harness.changeSelect('nope');
harness.click('#dl-button');
assert.deepEqual(harness.sent.slice(1), [{channel: 'desktop-splash:quit', payload: 'nope'}]);
});
test('a picker with nothing routable in it falls back to a quit control instead of a dead window', async () => {
const harness = createPreloadHarness();
await harness.start();
harness.sendState(manualState({options: null}));
assert.equal(harness.query('.dl-select-frame'), null);
assert.equal(harness.query('#dl-button').textContent, 'Quit');
assert.equal(harness.query('.dl-update-message').textContent, 'A new version of Fluxer is ready to install.');
harness.click('#dl-button');
assert.deepEqual(harness.channels(), ['desktop-splash:ready', 'desktop-splash:quit']);
});
test('a manual state that carries an action instead of a picker renders that action', () => {
const harness = createPreloadHarness();
harness.start();
harness.sendState(manualState({options: null, action: {kind: 'download', label: 'Download update'}}));
assert.equal(harness.query('.dl-select-frame'), null);
assert.equal(harness.query('#dl-button').textContent, 'Download update');
harness.click('#dl-button');
assert.deepEqual(harness.sent.at(-1), {channel: 'desktop-splash:open-download', payload: undefined});
});
});
describe('splash preload contract', () => {
test('knows every status and layout the main process can send', async () => {
const {SplashLayout, SplashStatus} = await loadSplashWindow('preload-status-parity');
test('knows every status the main process can send', async () => {
const {SplashStatus} = await loadSplashWindow('preload-status-parity');
const {context} = createPreloadHarness();
assert.deepEqual(JSON.parse(JSON.stringify(vm.runInContext('SplashStatus', context))), SplashStatus);
assert.deepEqual(JSON.parse(JSON.stringify(vm.runInContext('SplashLayout', context))), SplashLayout);
});
test('shows the bar only for a numeric progress on a status that carries one', () => {
@@ -1594,23 +1343,6 @@ describe('splash preload contract', () => {
assert.equal(shows({status: 'launching', progress: 50}), false);
});
test('clamps the manual update message so a long one can never push the controls out of the window', () => {
const message = styleRule('.dl-update-message');
assert.match(message, /-webkit-line-clamp: \d+;/);
assert.match(message, /overflow: hidden;/);
assert.match(message, /min-height: 0;/);
assert.match(styleRule('.splash-inner-dl'), /overflow: hidden;/);
});
test('keeps every manual control out of the shrink path and out of the drag region', () => {
for (const selector of ['.dl-select-frame', '.dl-action-frame']) {
assert.match(styleRule(selector), /flex: none;/, `${selector} must never shrink`);
assert.match(styleRule(selector), /-webkit-app-region: no-drag;/, `${selector} must stay clickable`);
}
assert.match(styleRule('.dl-version-message'), /flex: none;/);
});
test('uses the same channel names as the main process', async () => {
const module = await loadSplashWindow('preload-channels');
+8 -92
View File
@@ -11,11 +11,6 @@ const SPLASH_WINDOW_HEIGHT_DEFAULT = 350;
const SPLASH_BACKGROUND_COLOR = '#1a1a1a';
const SPLASH_ACTION_LABEL_MAX_LENGTH = 48;
const SPLASH_MESSAGE_MAX_LENGTH = 160;
const SPLASH_VERSION_LABEL_MAX_LENGTH = 64;
const SPLASH_OPTION_LABEL_MAX_LENGTH = 48;
const SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH = 24;
const SPLASH_OPTION_MAX_COUNT = 8;
const SPLASH_OPTION_VALUE_PATTERN = /^[a-z0-9_]{1,32}$/;
const SPLASH_PROGRESS_MAX = 100;
export const SPLASH_READY_WATCHDOG_MS = 3000;
@@ -31,13 +26,6 @@ export const DESKTOP_SPLASH_NETWORK_ONLINE_CHANNEL = 'desktop-splash:network-onl
export const DESKTOP_SPLASH_OPEN_LOGS_CHANNEL = 'desktop-splash:open-logs';
export const DESKTOP_SPLASH_COPY_DIAGNOSTICS_CHANNEL = 'desktop-splash:copy-diagnostics';
export const SplashLayout = Object.freeze({
SPLASH: 'splash',
MANUAL_UPDATE: 'manual-update',
} as const);
export type SplashLayout = (typeof SplashLayout)[keyof typeof SplashLayout];
export const SplashStatus = Object.freeze({
CHECKING_FOR_UPDATES: 'checking-for-updates',
DOWNLOADING_UPDATES: 'downloading-updates',
@@ -49,10 +37,7 @@ export const SplashStatus = Object.freeze({
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
BLOCKED_UPDATE_UNREACHABLE: 'blocked-update-unreachable',
BLOCKED_SECURITY_UPDATE_REQUIRED: 'blocked-security-update-required',
BLOCKED_SECURITY_UPDATE_MANAGED: 'blocked-security-update-managed',
BLOCKED_SHELL_UPDATE: 'blocked-shell-update',
BLOCKED_SHELL_UPDATE_MANAGED: 'blocked-shell-update-managed',
BLOCKED_UNSUPPORTED_BUILD: 'blocked-unsupported-build',
LAUNCHING: 'launching',
UNREACHABLE_LAUNCH: 'unreachable-launch',
@@ -74,13 +59,6 @@ export interface SplashActionDescriptor {
readonly label: string;
}
export interface SplashDownloadOption {
readonly value: string;
readonly label: string;
readonly buttonLabel: string;
readonly kind: SplashActionKind;
}
export const SplashAction = Object.freeze({
RETRY: Object.freeze({kind: SplashActionKind.RETRY, label: 'Retry now'}),
DOWNLOAD: Object.freeze({kind: SplashActionKind.DOWNLOAD, label: 'Download update'}),
@@ -89,8 +67,6 @@ export const SplashAction = Object.freeze({
export interface SplashState {
readonly status: SplashStatus;
readonly requiredSecurityUpdate?: boolean;
readonly layout?: SplashLayout | null;
readonly current?: number | null;
readonly total?: number | null;
readonly progress?: number | null;
@@ -100,14 +76,10 @@ export interface SplashState {
readonly bytesPerSecond?: number | null;
readonly action?: SplashActionDescriptor | null;
readonly message?: string | null;
readonly versionLabel?: string | null;
readonly options?: ReadonlyArray<SplashDownloadOption> | null;
}
export interface SerializedSplashState {
readonly layout: SplashLayout;
readonly status: SplashStatus;
readonly requiredSecurityUpdate: boolean;
readonly current: number | null;
readonly total: number | null;
readonly progress: number | null;
@@ -117,22 +89,18 @@ export interface SerializedSplashState {
readonly bytesPerSecond: number | null;
readonly action: SplashActionDescriptor | null;
readonly message: string | null;
readonly versionLabel: string | null;
readonly options: ReadonlyArray<SplashDownloadOption> | null;
}
type SplashListener = () => void;
type SplashDownloadListener = (value: string | null) => void;
const logger = createChildLogger('SplashWindow');
const SPLASH_LAYOUTS = new Set<string>(Object.values(SplashLayout));
const SPLASH_STATUSES = new Set<string>(Object.values(SplashStatus));
const SPLASH_ACTION_KINDS = new Set<string>(Object.values(SplashActionKind));
const readyListeners = new Set<SplashListener>();
const retryListeners = new Set<SplashListener>();
const quitListeners = new Set<SplashListener>();
const networkOnlineListeners = new Set<SplashListener>();
const openDownloadListeners = new Set<SplashDownloadListener>();
const openDownloadListeners = new Set<SplashListener>();
const openLogsListeners = new Set<SplashListener>();
const copyDiagnosticsListeners = new Set<SplashListener>();
@@ -172,41 +140,9 @@ function toSplashAction(action: SplashActionDescriptor | null | undefined): Spla
return {kind: action.kind, label};
}
function toSplashOption(option: SplashDownloadOption): SplashDownloadOption | null {
if (option == null || typeof option !== 'object') return null;
if (typeof option.value !== 'string' || !SPLASH_OPTION_VALUE_PATTERN.test(option.value)) return null;
if (!SPLASH_ACTION_KINDS.has(option.kind)) return null;
const label = toSplashText(option.label, SPLASH_OPTION_LABEL_MAX_LENGTH);
if (label == null) return null;
const buttonLabel = toSplashText(option.buttonLabel, SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH);
if (buttonLabel == null) return null;
return {value: option.value, label, buttonLabel, kind: option.kind};
}
function toSplashOptions(
options: ReadonlyArray<SplashDownloadOption> | null | undefined,
): ReadonlyArray<SplashDownloadOption> | null {
if (!Array.isArray(options)) return null;
const accepted: Array<SplashDownloadOption> = [];
for (const option of options.slice(0, SPLASH_OPTION_MAX_COUNT)) {
const normalized = toSplashOption(option);
if (normalized != null) {
accepted.push(normalized);
}
}
return accepted.length === 0 ? null : accepted;
}
function withManualUpdateAffordance(state: SerializedSplashState): SerializedSplashState {
if (state.layout !== SplashLayout.MANUAL_UPDATE || hasSplashAffordance(state)) return state;
return {...state, action: SplashAction.QUIT};
}
export function serializeSplashState(state: SplashState): SerializedSplashState {
return withManualUpdateAffordance({
layout: SPLASH_LAYOUTS.has(state.layout as string) ? (state.layout as SplashLayout) : SplashLayout.SPLASH,
return {
status: SPLASH_STATUSES.has(state.status) ? state.status : SplashStatus.CHECKING_FOR_UPDATES,
requiredSecurityUpdate: state.requiredSecurityUpdate === true,
current: toSplashCount(state.current),
total: toSplashCount(state.total),
progress: toSplashProgress(state.progress),
@@ -216,18 +152,11 @@ export function serializeSplashState(state: SplashState): SerializedSplashState
bytesPerSecond: toSplashCount(state.bytesPerSecond),
action: toSplashAction(state.action),
message: toSplashText(state.message, SPLASH_MESSAGE_MAX_LENGTH),
versionLabel: toSplashText(state.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH),
options: toSplashOptions(state.options),
});
};
}
export function hasSplashAffordance(state: SerializedSplashState): boolean {
return state.action != null || state.options != null;
}
export function toSplashDownloadValue(value: unknown): string | null {
if (typeof value !== 'string') return null;
return SPLASH_OPTION_VALUE_PATTERN.test(value) ? value : null;
return state.action != null;
}
export function getSplashWindowHeight(platform: NodeJS.Platform): number {
@@ -238,10 +167,7 @@ export function shouldQuitOnSplashClosed(platform: NodeJS.Platform, isLaunchLatc
return platform !== 'darwin' && !isLaunchLatched;
}
function addSplashListener<T extends SplashListener | SplashDownloadListener>(
listeners: Set<T>,
listener: T,
): () => void {
function addSplashListener(listeners: Set<SplashListener>, listener: SplashListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
@@ -258,16 +184,6 @@ function emitSplashListeners(listeners: Set<SplashListener>): void {
}
}
function emitSplashDownloadListeners(value: string | null): void {
for (const listener of Array.from(openDownloadListeners)) {
try {
listener(value);
} catch (error) {
logger.error('A splash listener threw', error);
}
}
}
function isSplashSender(event: IpcMainEvent): boolean {
return splashWindow != null && !splashWindow.isDestroyed() && event.sender === splashWindow.webContents;
}
@@ -311,9 +227,9 @@ function registerSplashIpc(): void {
if (!isSplashSender(event)) return;
emitSplashListeners(networkOnlineListeners);
});
ipcMain.on(DESKTOP_SPLASH_OPEN_DOWNLOAD_CHANNEL, (event, value: unknown) => {
ipcMain.on(DESKTOP_SPLASH_OPEN_DOWNLOAD_CHANNEL, (event) => {
if (!isSplashSender(event)) return;
emitSplashDownloadListeners(toSplashDownloadValue(value));
emitSplashListeners(openDownloadListeners);
});
ipcMain.on(DESKTOP_SPLASH_OPEN_LOGS_CHANNEL, (event) => {
if (!isSplashSender(event)) return;
@@ -527,7 +443,7 @@ export function onSplashNetworkOnline(listener: SplashListener): () => void {
return addSplashListener(networkOnlineListeners, listener);
}
export function onSplashOpenDownload(listener: SplashDownloadListener): () => void {
export function onSplashOpenDownload(listener: SplashListener): () => void {
return addSplashListener(openDownloadListeners, listener);
}
+14 -27
View File
@@ -8,12 +8,10 @@ import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/S
import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability';
import {
DOWNLOAD_PAGE_URL,
getManualDownloadOptions,
getManualDownloadUrl,
type ManualLatestFile,
type ManualLatestInfo,
UPDATE_BASE_URL,
type UpdaterDownloadOption,
} from '@electron/main/UpdaterDownloads';
import {app, type BrowserWindow, ipcMain, net} from 'electron';
import log from 'electron-log';
@@ -29,7 +27,6 @@ type UpdaterEvent =
context: UpdaterContext;
version?: string | null;
downloadUrl?: string;
downloadOptions?: Array<UpdaterDownloadOption>;
}
| {
type: 'not-available';
@@ -43,7 +40,7 @@ type UpdaterEvent =
| {
type: 'unsupported';
context: UpdaterContext;
reason: 'platform' | 'unpackaged' | 'managed-package';
reason: 'platform' | 'unpackaged';
downloadUrl?: string;
};
@@ -104,7 +101,9 @@ async function fetchManualLatest(options: {forceRefresh?: boolean} = {}): Promis
export async function findNewerManualShell(): Promise<ManualLatestInfo | null> {
const plan = resolveShellUpdatePlan();
if (plan.capability !== ShellUpdateCapability.MANUAL_DOWNLOAD || plan.reason !== 'platform') {
const managed = plan.capability === ShellUpdateCapability.MANAGED_PACKAGE;
const manual = plan.capability === ShellUpdateCapability.MANUAL_DOWNLOAD && plan.reason === 'platform';
if (!managed && !manual) {
return null;
}
const latest = await fetchManualLatest({forceRefresh: true});
@@ -116,13 +115,11 @@ function sendManualUpdateAvailable(
context: UpdaterContext,
latest: ManualLatestInfo,
): void {
const downloadOptions = getManualDownloadOptions(latest);
send(getMainWindow(), {
type: 'available',
context,
version: latest.version,
downloadUrl: getManualDownloadUrl(latest),
...(downloadOptions.length > 0 ? {downloadOptions} : {}),
});
}
@@ -157,10 +154,7 @@ function registerSelfUpdater(getMainWindow: () => BrowserWindow | null): void {
});
}
function registerManualUpdater(
getMainWindow: () => BrowserWindow | null,
reason: 'platform' | 'unpackaged' | 'managed-package',
): void {
function registerManualUpdater(getMainWindow: () => BrowserWindow | null, unpackaged: boolean): void {
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
if (context === 'user') {
try {
@@ -169,13 +163,8 @@ function registerManualUpdater(
log.warn('Desktop module update check failed', error);
}
}
if (reason !== 'platform') {
send(getMainWindow(), {
type: 'unsupported',
context,
reason,
...(reason === 'unpackaged' ? {downloadUrl: DOWNLOAD_PAGE_URL} : {}),
});
if (unpackaged) {
send(getMainWindow(), {type: 'unsupported', context, reason: 'unpackaged', downloadUrl: DOWNLOAD_PAGE_URL});
return;
}
await checkManualUpdate(context, getMainWindow);
@@ -184,14 +173,12 @@ function registerManualUpdater(
export function registerUpdater(getMainWindow: () => BrowserWindow | null) {
const plan = resolveShellUpdatePlan();
switch (plan.capability) {
case ShellUpdateCapability.SELF_UPDATE:
registerSelfUpdater(getMainWindow);
return;
case ShellUpdateCapability.MANAGED_PACKAGE:
registerManualUpdater(getMainWindow, 'managed-package');
return;
default:
registerManualUpdater(getMainWindow, plan.reason);
if (plan.capability === ShellUpdateCapability.SELF_UPDATE) {
registerSelfUpdater(getMainWindow);
return;
}
registerManualUpdater(
getMainWindow,
plan.capability === ShellUpdateCapability.MANUAL_DOWNLOAD && plan.reason === 'unpackaged',
);
}
+1 -64
View File
@@ -2,24 +2,7 @@
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import {DOWNLOAD_PAGE_URLS} from '@electron/common/Constants';
import {DESKTOP_ARTIFACT_PRODUCT_NAME} from '@electron/common/DesktopIdentity';
import {
DESKTOP_DOWNLOAD_ARCH,
getUpdateBaseUrl,
isLinuxManualDesktopFormat,
LINUX_MANUAL_ARCH_TOKENS,
LINUX_MANUAL_FORMAT_EXTENSIONS,
type LinuxManualDesktopFormat,
type ManualDesktopFormat,
} from '@electron/main/ShellDownloadFormats';
export type UpdaterDownloadOption = {
format: ManualDesktopFormat;
label: string;
url: string;
suggestedName?: string;
sha256?: string | null;
};
import {getUpdateBaseUrl, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats';
export const UPDATE_BASE_URL = getUpdateBaseUrl();
export const DOWNLOAD_PAGE_URL = DOWNLOAD_PAGE_URLS[BUILD_CHANNEL];
@@ -33,9 +16,6 @@ export type ManualLatestInfo = {
};
function getManualDownloadFormatPreference(): Array<ManualDesktopFormat> {
if (process.platform === 'linux') {
return ['appimage', 'deb', 'rpm', 'tar_gz'];
}
if (process.platform === 'darwin') {
return ['dmg', 'zip'];
}
@@ -45,50 +25,7 @@ function getManualDownloadFormatPreference(): Array<ManualDesktopFormat> {
return [];
}
const LINUX_MANUAL_FORMAT_LABELS: Record<LinuxManualDesktopFormat, string> = {
appimage: 'AppImage',
deb: 'DEB package',
rpm: 'RPM package',
tar_gz: 'tar.gz archive',
};
export function buildManualVersionDownloadUrl(version: string, format: ManualDesktopFormat): string {
return `${UPDATE_BASE_URL}/${version}/${format}`;
}
function getArtifactProductName(): string {
return DESKTOP_ARTIFACT_PRODUCT_NAME;
}
function getManualUpdateSuggestedName(format: LinuxManualDesktopFormat, version: string): string {
const archToken = LINUX_MANUAL_ARCH_TOKENS[format][DESKTOP_DOWNLOAD_ARCH];
const extension = LINUX_MANUAL_FORMAT_EXTENSIONS[format];
return `${getArtifactProductName()}-${version}-linux-${archToken}${extension}`;
}
export function getManualDownloadOptions(info: ManualLatestInfo): Array<UpdaterDownloadOption> {
if (process.platform !== 'linux') {
return [];
}
return getManualDownloadFormatPreference()
.filter(isLinuxManualDesktopFormat)
.map((format) => {
const file = info.files[format];
return {
format,
label: LINUX_MANUAL_FORMAT_LABELS[format],
url: buildManualVersionDownloadUrl(info.version, format),
suggestedName: getManualUpdateSuggestedName(format, info.version),
sha256: file?.sha256 ?? null,
};
});
}
export function getManualDownloadUrl(info: ManualLatestInfo): string {
const [preferredOption] = getManualDownloadOptions(info);
if (preferredOption) {
return preferredOption.url;
}
for (const format of getManualDownloadFormatPreference()) {
const url = info.files[format]?.url;
if (url) {