mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(desktop): shell-compatible module updates, no linux walls (#3452)
This commit is contained in:
1 parent
f501d34179
commit
959b03b5f4
73 files changed
+4142
-4311
No files matched your search
@@ -7,11 +7,6 @@ const DESKTOP_APP_NAMES: Record<BuildChannel, string> = {
|
||||
canary: 'Fluxer Canary',
|
||||
development: 'Fluxer Development',
|
||||
};
|
||||
const DESKTOP_ARTIFACT_PRODUCT_NAMES: Record<BuildChannel, string> = {
|
||||
stable: 'Fluxer',
|
||||
canary: 'Fluxer-Canary',
|
||||
development: 'Fluxer-Development',
|
||||
};
|
||||
const MACOS_BUNDLE_IDS: Record<BuildChannel, string> = {
|
||||
stable: 'app.fluxer',
|
||||
canary: 'app.fluxer.canary',
|
||||
@@ -49,7 +44,6 @@ const WINDOWS_TOAST_ACTIVATOR_CLSIDS: Record<BuildChannel, string> = {
|
||||
};
|
||||
|
||||
export const DESKTOP_APP_NAME = DESKTOP_APP_NAMES[BUILD_CHANNEL];
|
||||
export const DESKTOP_ARTIFACT_PRODUCT_NAME = DESKTOP_ARTIFACT_PRODUCT_NAMES[BUILD_CHANNEL];
|
||||
export const MACOS_BUNDLE_ID = MACOS_BUNDLE_IDS[BUILD_CHANNEL];
|
||||
export const LINUX_DESKTOP_ENTRY_ID = LINUX_DESKTOP_ENTRY_IDS[BUILD_CHANNEL];
|
||||
export const LINUX_PORTAL_SESSION_TOKEN = LINUX_PORTAL_SESSION_TOKENS[BUILD_CHANNEL];
|
||||
|
||||
@@ -42,7 +42,6 @@ import type {
|
||||
ThemeLocalFileReference,
|
||||
TrayPresenceStatus,
|
||||
TrayRuntimeStatePayload,
|
||||
UpdaterDownloadOption,
|
||||
VirtmicBackend,
|
||||
VirtmicNode,
|
||||
VoiceBackgroundMediaCacheRequest,
|
||||
@@ -115,7 +114,6 @@ export type UpdaterEvent =
|
||||
context: UpdaterContext;
|
||||
version: string | null;
|
||||
downloadUrl?: string;
|
||||
downloadOptions?: Array<UpdaterDownloadOption>;
|
||||
}
|
||||
| {
|
||||
type: 'not-available';
|
||||
@@ -129,7 +127,7 @@ export type UpdaterEvent =
|
||||
| {
|
||||
type: 'unsupported';
|
||||
context: UpdaterContext;
|
||||
reason: 'platform' | 'unpackaged' | 'managed-package';
|
||||
reason: 'platform' | 'unpackaged';
|
||||
downloadUrl?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'};
|
||||
}
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,11 +24,6 @@ const DIAGNOSTICS_COPIED_MS = 2000;
|
||||
const BYTES_PER_MEGABYTE = 1000000;
|
||||
const BYTES_PER_KILOBYTE = 1000;
|
||||
|
||||
const SplashLayout = Object.freeze({
|
||||
SPLASH: 'splash',
|
||||
MANUAL_UPDATE: 'manual-update',
|
||||
});
|
||||
|
||||
const SplashStatus = Object.freeze({
|
||||
CHECKING_FOR_UPDATES: 'checking-for-updates',
|
||||
DOWNLOADING_UPDATES: 'downloading-updates',
|
||||
@@ -40,10 +35,7 @@ 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',
|
||||
@@ -61,7 +53,6 @@ const DIAGNOSTICS_STATUSES = new Set([
|
||||
SplashStatus.DOWNLOAD_STALLED,
|
||||
SplashStatus.BLOCKED_UPDATE_REQUIRED,
|
||||
SplashStatus.BLOCKED_UPDATE_UNREACHABLE,
|
||||
SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED,
|
||||
SplashStatus.BLOCKED_SHELL_UPDATE,
|
||||
SplashStatus.BLOCKED_UNSUPPORTED_BUILD,
|
||||
SplashStatus.UNREACHABLE_LAUNCH,
|
||||
@@ -73,9 +64,7 @@ const ACTION_CHANNELS = new Map([
|
||||
['quit', SPLASH_QUIT_CHANNEL],
|
||||
]);
|
||||
const DEFAULT_STATE = Object.freeze({
|
||||
layout: SplashLayout.SPLASH,
|
||||
status: SplashStatus.CHECKING_FOR_UPDATES,
|
||||
requiredSecurityUpdate: false,
|
||||
current: null,
|
||||
total: null,
|
||||
progress: null,
|
||||
@@ -85,15 +74,11 @@ const DEFAULT_STATE = Object.freeze({
|
||||
bytesPerSecond: null,
|
||||
action: null,
|
||||
message: null,
|
||||
versionLabel: null,
|
||||
options: null,
|
||||
});
|
||||
|
||||
let splashState = null;
|
||||
let renderedLayout = null;
|
||||
let renderedManualSignature = null;
|
||||
let splashBuilt = false;
|
||||
let countdownTimer = null;
|
||||
let selectedOption = null;
|
||||
let splashStartedAt = Number.POSITIVE_INFINITY;
|
||||
let diagnosticsRevealTimer = null;
|
||||
let diagnosticsCopiedTimer = null;
|
||||
@@ -101,43 +86,25 @@ let diagnosticsCopiedTimer = null;
|
||||
function getStatusText(state) {
|
||||
switch (state.status) {
|
||||
case SplashStatus.DOWNLOADING_UPDATES:
|
||||
return state.requiredSecurityUpdate
|
||||
? `Downloading required security update ${state.current ?? 0} of ${state.total ?? 0}…`
|
||||
: `Downloading update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||
return `Downloading update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||
case SplashStatus.INSTALLING_UPDATES:
|
||||
return state.requiredSecurityUpdate
|
||||
? `Installing required security update ${state.current ?? 0} of ${state.total ?? 0}…`
|
||||
: `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||
return `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||
case SplashStatus.VERIFYING:
|
||||
return state.requiredSecurityUpdate ? 'Verifying required security update…' : 'Verifying files…';
|
||||
return 'Verifying files…';
|
||||
case SplashStatus.DOWNLOAD_STALLED:
|
||||
return `Download stalled. Retrying in ${state.seconds ?? 0} sec…`;
|
||||
case SplashStatus.UPDATE_FAILURE:
|
||||
return state.requiredSecurityUpdate
|
||||
? `Required security update failed. Retrying in ${state.seconds ?? 0} sec…`
|
||||
: `Update failed. Retrying in ${state.seconds ?? 0} sec…`;
|
||||
return `Update failed. Retrying in ${state.seconds ?? 0} sec…`;
|
||||
case SplashStatus.SHELL_UPDATE_DOWNLOADING:
|
||||
return state.requiredSecurityUpdate ? 'Downloading required security update…' : 'Updating Fluxer…';
|
||||
return 'Updating Fluxer…';
|
||||
case SplashStatus.SHELL_UPDATE_RESTARTING:
|
||||
return state.requiredSecurityUpdate
|
||||
? 'Restarting to finish the required security update…'
|
||||
: 'Restarting to finish the update…';
|
||||
return 'Restarting to finish the update…';
|
||||
case SplashStatus.BLOCKED_UPDATE_REQUIRED:
|
||||
return state.requiredSecurityUpdate
|
||||
? 'A required security update is needed to continue'
|
||||
: 'Update required to continue';
|
||||
return 'Update required to continue';
|
||||
case SplashStatus.BLOCKED_UPDATE_UNREACHABLE:
|
||||
return "Can't reach the Fluxer update server";
|
||||
case SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED:
|
||||
return 'A required security update is needed to continue';
|
||||
case SplashStatus.BLOCKED_SECURITY_UPDATE_MANAGED:
|
||||
return 'Install the required security update through your package manager';
|
||||
case SplashStatus.BLOCKED_SHELL_UPDATE:
|
||||
return state.requiredSecurityUpdate
|
||||
? 'A required security update is needed to continue'
|
||||
: 'A new version of Fluxer is required';
|
||||
case SplashStatus.BLOCKED_SHELL_UPDATE_MANAGED:
|
||||
return 'Update Fluxer through your package manager';
|
||||
return 'A new version of Fluxer is required';
|
||||
case SplashStatus.BLOCKED_UNSUPPORTED_BUILD:
|
||||
return 'This installation is incomplete. Reinstall Fluxer';
|
||||
case SplashStatus.LAUNCHING:
|
||||
@@ -147,7 +114,7 @@ function getStatusText(state) {
|
||||
case SplashStatus.WAITING_LOCAL_NETWORK:
|
||||
return 'Waiting for local network permission…';
|
||||
default:
|
||||
return state.requiredSecurityUpdate ? 'Checking for required security update…' : 'Checking for updates…';
|
||||
return 'Checking for updates…';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +151,6 @@ function getMetricsText(state) {
|
||||
}
|
||||
|
||||
function shouldShowDiagnostics(state) {
|
||||
if (state.layout !== SplashLayout.SPLASH) return false;
|
||||
if (DIAGNOSTICS_HIDDEN_STATUSES.has(state.status)) return false;
|
||||
if (DIAGNOSTICS_STATUSES.has(state.status)) return true;
|
||||
return Date.now() - splashStartedAt >= DIAGNOSTICS_REVEAL_MS;
|
||||
@@ -223,19 +189,15 @@ function createProgressPlaceholder() {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function sendSplashAction(kind, value) {
|
||||
function sendSplashAction(kind) {
|
||||
const channel = ACTION_CHANNELS.get(kind);
|
||||
if (channel == null) return;
|
||||
if (value === undefined) {
|
||||
ipcRenderer.send(channel);
|
||||
return;
|
||||
}
|
||||
ipcRenderer.send(channel, value);
|
||||
ipcRenderer.send(channel);
|
||||
}
|
||||
|
||||
function onSplashActionClick() {
|
||||
if (splashState == null || splashState.action == null) return;
|
||||
sendSplashAction(splashState.action.kind, undefined);
|
||||
sendSplashAction(splashState.action.kind);
|
||||
}
|
||||
|
||||
function buildSplashLayout(mount) {
|
||||
@@ -329,111 +291,12 @@ function reconcileAction(mount, state) {
|
||||
existing.textContent = state.action.label;
|
||||
}
|
||||
|
||||
function findSelectedOption() {
|
||||
if (splashState == null || splashState.options == null) return null;
|
||||
return splashState.options.find((option) => option.value === selectedOption) ?? null;
|
||||
}
|
||||
|
||||
function manualUpdateSignature(state) {
|
||||
return JSON.stringify([state.action, state.options]);
|
||||
}
|
||||
|
||||
function patchManualUpdateText(mount, state) {
|
||||
const message = mount.querySelector('.dl-update-message');
|
||||
if (message != null) {
|
||||
message.textContent = state.message ?? '';
|
||||
}
|
||||
const version = mount.querySelector('.dl-version-message');
|
||||
if (version != null) {
|
||||
version.textContent = state.versionLabel ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildManualUpdateLayout(mount, state) {
|
||||
const splash = createNode('div');
|
||||
splash.id = 'splash';
|
||||
const inner = createNode('div', 'splash-inner-dl');
|
||||
const markBox = createNode('div', 'splash-mark-dl');
|
||||
markBox.appendChild(createMark(null));
|
||||
const message = createNode('div', 'dl-update-message');
|
||||
message.textContent = state.message ?? '';
|
||||
const version = createNode('div', 'dl-version-message');
|
||||
version.textContent = state.versionLabel ?? '';
|
||||
inner.appendChild(markBox);
|
||||
inner.appendChild(message);
|
||||
inner.appendChild(buildManualUpdateControl(state));
|
||||
inner.appendChild(version);
|
||||
splash.appendChild(inner);
|
||||
mount.replaceChildren(splash);
|
||||
}
|
||||
|
||||
function buildManualUpdateControl(state) {
|
||||
return state.options == null ? buildManualUpdateAction(state) : buildManualUpdatePicker(state);
|
||||
}
|
||||
|
||||
function buildManualUpdateAction(state) {
|
||||
const frame = createNode('div', 'dl-action-frame');
|
||||
const button = createNode('button', 'dl-button');
|
||||
button.id = 'dl-button';
|
||||
button.textContent = state.action.label;
|
||||
button.addEventListener('click', onSplashActionClick);
|
||||
frame.appendChild(button);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function buildManualUpdatePicker(state) {
|
||||
const frame = createNode('div', 'dl-select-frame');
|
||||
const selectBox = createNode('div', 'dl-select');
|
||||
const select = createNode('select');
|
||||
select.id = 'dl-select-input';
|
||||
for (const option of state.options) {
|
||||
const node = createNode('option');
|
||||
node.value = option.value;
|
||||
node.textContent = option.label;
|
||||
select.appendChild(node);
|
||||
}
|
||||
selectBox.appendChild(select);
|
||||
const button = createNode('button', 'dl-button');
|
||||
button.id = 'dl-button';
|
||||
if (findSelectedOption() == null) {
|
||||
selectedOption = state.options[0].value;
|
||||
}
|
||||
const initial = findSelectedOption();
|
||||
select.value = selectedOption;
|
||||
button.textContent = initial == null ? '' : initial.buttonLabel;
|
||||
select.addEventListener('change', () => {
|
||||
selectedOption = select.value;
|
||||
const option = findSelectedOption();
|
||||
button.textContent = option == null ? '' : option.buttonLabel;
|
||||
});
|
||||
button.addEventListener('click', () => {
|
||||
const option = findSelectedOption();
|
||||
if (option == null) return;
|
||||
sendSplashAction(option.kind, option.value);
|
||||
});
|
||||
frame.appendChild(selectBox);
|
||||
frame.appendChild(button);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const mount = document.getElementById(SPLASH_MOUNT_ID);
|
||||
if (mount == null) return;
|
||||
const state = splashState ?? DEFAULT_STATE;
|
||||
if (state.layout === SplashLayout.MANUAL_UPDATE) {
|
||||
const signature = manualUpdateSignature(state);
|
||||
if (renderedLayout !== SplashLayout.MANUAL_UPDATE || renderedManualSignature !== signature) {
|
||||
renderedLayout = SplashLayout.MANUAL_UPDATE;
|
||||
renderedManualSignature = signature;
|
||||
buildManualUpdateLayout(mount, state);
|
||||
return;
|
||||
}
|
||||
patchManualUpdateText(mount, state);
|
||||
return;
|
||||
}
|
||||
if (renderedLayout !== SplashLayout.SPLASH) {
|
||||
renderedLayout = SplashLayout.SPLASH;
|
||||
renderedManualSignature = null;
|
||||
if (!splashBuilt) {
|
||||
splashBuilt = true;
|
||||
buildSplashLayout(mount);
|
||||
}
|
||||
const statusNode = mount.querySelector('.splash-status');
|
||||
@@ -454,7 +317,7 @@ function render() {
|
||||
}
|
||||
|
||||
function hasCountdown(state) {
|
||||
return state.layout === SplashLayout.SPLASH && state.seconds != null && state.seconds > 0;
|
||||
return state.seconds != null && state.seconds > 0;
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
|
||||
@@ -219,155 +219,3 @@ html {
|
||||
.splash-diagnostics-link:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
#splash:has(.splash-inner-dl) {
|
||||
align-items: stretch;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.splash-inner-dl {
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 30px 20px 12px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splash-inner-dl .splash-mark-dl {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.splash-inner-dl .splash-mark-dl img {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dl-update-message {
|
||||
font-family: var(--primary-font);
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--white);
|
||||
margin: 26px 0 0;
|
||||
max-width: 26ch;
|
||||
text-wrap: balance;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dl-action-frame {
|
||||
-webkit-app-region: no-drag;
|
||||
font-family: var(--primary-font);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dl-select-frame {
|
||||
-webkit-app-region: no-drag;
|
||||
font-family: var(--primary-font);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dl-select {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.dl-select select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
background: var(--window-background);
|
||||
color: var(--white);
|
||||
padding: 0 32px 0 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dl-select select:focus-visible {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px rgba(70, 65, 217, 0.45);
|
||||
}
|
||||
|
||||
.dl-select::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 13px;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -2px;
|
||||
transform: translateY(-50%);
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dl-button {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
width: 92px;
|
||||
height: 44px;
|
||||
background-color: var(--brand);
|
||||
color: var(--white);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dl-button:hover {
|
||||
background-color: var(--brand-bright);
|
||||
}
|
||||
|
||||
.dl-button:focus-visible {
|
||||
outline: 2px solid var(--brand-bright);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dl-version-message {
|
||||
font-family: var(--primary-font);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
margin: auto 0 0;
|
||||
padding-top: 18px;
|
||||
flex: none;
|
||||
}
|
||||
Reference in new issue
Block a user