feat(desktop): let users install updates on demand (#3487)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 03:50:03 +02:00
1 parent 9213a765b1
commit 105bd49c9e
46 files changed
+477 -360

No files matched your search

+44 -15
View File
@@ -72,6 +72,7 @@ const MODULE_SYSTEM_LAUNCH = resolveModuleSystemLaunch({hasOfflineRenderer: CAN_
const MODULE_LAUNCH_PERMIT = Symbol('fluxer.desktop.moduleLaunchPermit');
const PENDING_UPDATE_MARKER_NAME = 'update-pending';
const REQUESTED_UPDATE_MARKER_NAME = 'update-requested';
const IN_PLACE_RELOAD_CONFIRM_TIMEOUT_MS = 45_000;
const UPDATE_TAKEOVER_SPLASH_WAIT_MS = 250;
const UPDATE_SPLASH_PRELOAD_DELAY_MS = 5000;
@@ -159,7 +160,7 @@ async function runModuleBootstrap(): Promise<void> {
const {configureUserDataPath} = await import('@electron/common/UserDataPath');
const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache');
const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch');
const {areModuleUpdatesDisabled} = await import('@electron/main/DesktopDebugInfo');
const {isUpdateOnDemandLaunch} = await import('@electron/main/DesktopDebugInfo');
const {isDesktopUpdateRequested, shouldDisableModuleUpdatesForLaunch} = await import('@electron/main/LaunchOptions');
const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath');
const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol');
@@ -392,13 +393,28 @@ async function runModuleBootstrap(): Promise<void> {
setAside: store.recoveredUnreadableState,
});
}
if (store.shellVersionChanged) {
logger.info('The shell version changed since the last boot, converging the modules before launch');
}
const moduleUpdatesForcedOff = shouldDisableModuleUpdatesForLaunch();
const updateOnDemand = isUpdateOnDemandLaunch();
const pendingUpdateMarker = path.join(store.root, PENDING_UPDATE_MARKER_NAME);
const requestedUpdateMarker = path.join(store.root, REQUESTED_UPDATE_MARKER_NAME);
const updateWasPending = fs.existsSync(pendingUpdateMarker);
if (updateWasPending) {
logger.info('An update was waiting when the app last quit, installing it before launch');
const updateWasRequested = isDesktopUpdateRequested() || fs.existsSync(requestedUpdateMarker);
if (moduleUpdatesForcedOff) {
logger.info('Module updates are disabled for this launch');
} else if (updateWasRequested) {
logger.info('The user asked for an update, installing it before launch');
} else if (updateOnDemand) {
logger.info('Updates install on demand, launching the installed modules', {
shellVersionChanged: store.shellVersionChanged,
updateWasPending,
});
} else {
if (store.shellVersionChanged) {
logger.info('The shell version changed since the last boot, converging the modules before launch');
}
if (updateWasPending) {
logger.info('An update was waiting when the app last quit, installing it before launch');
}
}
const recordPendingUpdate = (check: {readonly shellNewer: boolean; readonly modulesChanged: boolean}): void => {
try {
@@ -411,20 +427,27 @@ async function runModuleBootstrap(): Promise<void> {
logger.warn('Failed to record whether an update is waiting', error);
}
};
const moduleUpdatesForcedOff = shouldDisableModuleUpdatesForLaunch();
const moduleUpdatesDisabled = areModuleUpdatesDisabled();
if (moduleUpdatesDisabled) {
logger.info('Module updates are disabled for this launch');
}
const recordRequestedUpdate = (requested: boolean): void => {
try {
if (requested) {
fs.writeFileSync(requestedUpdateMarker, '');
} else {
fs.rmSync(requestedUpdateMarker, {force: true});
}
} catch (error) {
logger.warn('Failed to record whether the user asked for an update', error);
}
};
const updater = new ModuleUpdater({
store,
shellVersion: app.getVersion(),
hasOfflineRenderer: HAS_OFFLINE_RENDERER,
bundledRendererVersion: BUNDLED_RENDERER_VERSION,
preferUnversionedBundle: app.isPackaged,
forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(),
forceStartupUpdate: store.shellVersionChanged || updateWasPending,
startupUpdateRequested: updateWasRequested,
moduleUpdatesDisabled: moduleUpdatesForcedOff,
moduleUpdatesDisabledBySetting: moduleUpdatesDisabled && !moduleUpdatesForcedOff,
updateOnDemand,
selfUpdateShellFirst:
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
? async (latestVersion) => {
@@ -713,12 +736,17 @@ async function runModuleBootstrap(): Promise<void> {
},
};
const desktopUpdate = moduleUpdatesDisabled
const desktopUpdate = moduleUpdatesForcedOff
? null
: new DesktopUpdateRun({
probe: () => updater.checkForUpdate(),
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(),
runShellSelfUpdate: async () => {
recordRequestedUpdate(true);
const result = await runShellSelfUpdateOnSplash();
recordRequestedUpdate(false);
return result;
},
installModules: async () => {
const launchAttempt = await updater.installPending();
if (launchAttempt != null) {
@@ -749,6 +777,7 @@ async function runModuleBootstrap(): Promise<void> {
localNetworkHint.disarm();
if (!permit.unreachable) {
recordPendingUpdate({shellNewer: false, modulesChanged: false});
recordRequestedUpdate(false);
}
await refreshModuleRoots(permit.launchAttempt.committed);
markSplashLaunching();
+2 -2
View File
@@ -106,8 +106,8 @@ export function getLaunchDesktopTroubleshootingSettings(): DesktopTroubleshootin
return launchTroubleshootingSettings;
}
export function areModuleUpdatesDisabled(): boolean {
return getLaunchDesktopTroubleshootingSettings().disableModuleUpdates;
export function isUpdateOnDemandLaunch(): boolean {
return getLaunchDesktopTroubleshootingSettings().disableModuleUpdates && !shouldDisableModuleUpdatesForLaunch();
}
export function shouldOpenDevToolsOnLaunch(argv: ReadonlyArray<string>): boolean {
+2 -2
View File
@@ -11,9 +11,9 @@ import type {
TrayActionPayload,
TrayRuntimeStatePayload,
} from '@electron/common/Types';
import {areModuleUpdatesDisabled} from '@electron/main/DesktopDebugInfo';
import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/DesktopUpdateGate';
import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt';
import {shouldDisableModuleUpdatesForLaunch} from '@electron/main/LaunchOptions';
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
import {onLocaleChange, t} from '@electron/main/MainI18n';
import {renderTrayIcon, resolveTrayIconAppearance} from '@electron/main/TrayIconImage';
@@ -447,7 +447,7 @@ function buildTrayMenu(): Menu {
click: () => runTrayMenuAction(startDesktopUpdateFromShell),
});
}
if (!areModuleUpdatesDisabled()) {
if (!shouldDisableModuleUpdatesForLaunch()) {
menuTemplate.push({
label: t('desktop.tray.checkForUpdates'),
click: () =>
@@ -19,8 +19,7 @@ globalThis.__fluxerDesktopTrayIconTestSettings__ = settings;
installTestModuleStub(
'@electron/common/DesktopConfig',
`export const getDesktopWindowBehaviorSettings = () => ({...globalThis.__fluxerDesktopTrayIconTestSettings__});
export const getDesktopTroubleshootingSettings = () => ({disableHardwareAcceleration: false, disableModuleUpdates: false});`,
'export const getDesktopWindowBehaviorSettings = () => ({...globalThis.__fluxerDesktopTrayIconTestSettings__});',
);
installTestModuleStub(
'@electron/main/DesktopUpdatePrompt',
+3 -3
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity';
import {areModuleUpdatesDisabled} from '@electron/main/DesktopDebugInfo';
import {checkForUpdatesFromShell} from '@electron/main/DesktopUpdatePrompt';
import {shouldDisableModuleUpdatesForLaunch} from '@electron/main/LaunchOptions';
import {onLocaleChange, t} from '@electron/main/MainI18n';
import {openExternalDeduped} from '@electron/main/OpenExternal';
import {buildTroubleshootingMenuItems} from '@electron/main/Troubleshooting';
@@ -32,7 +32,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
role: 'about',
label: t('desktop.appMenu.about', {appName}),
},
...(areModuleUpdatesDisabled() ? [] : [checkForUpdatesItem()]),
...(shouldDisableModuleUpdatesForLaunch() ? [] : [checkForUpdatesItem()]),
{type: 'separator'},
{
label: t('desktop.appMenu.preferences'),
@@ -210,7 +210,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
? `${t('desktop.appMenu.help')}${MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT}`
: t('desktop.appMenu.help'),
submenu: [
...(isMac || areModuleUpdatesDisabled() ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
...(isMac || shouldDisableModuleUpdatesForLaunch() ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
{
label: t('desktop.appMenu.website'),
click: async () => {
+74 -5
View File
@@ -127,8 +127,9 @@ function createUpdater(store, modules, options = {}) {
hasOfflineRenderer: options.bundledRendererVersion != null,
bundledRendererVersion: options.bundledRendererVersion ?? null,
forceStartupUpdate: options.forceStartupUpdate ?? false,
startupUpdateRequested: options.startupUpdateRequested ?? false,
moduleUpdatesDisabled: options.moduleUpdatesDisabled ?? false,
moduleUpdatesDisabledBySetting: options.moduleUpdatesDisabledBySetting ?? false,
updateOnDemand: options.updateOnDemand ?? false,
selfUpdateShellFirst: options.selfUpdateShellFirst,
onState: options.onState,
fetch: async (url, init) => {
@@ -576,7 +577,7 @@ describe('ModuleUpdater with module updates disabled', () => {
assert.deepEqual(sleeps, []);
});
test('the saved setting launches the verified set without fetching', async () => {
test('ignores an explicit update request', async () => {
const userDataPath = createUserData();
const store = await openStore(userDataPath);
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
@@ -585,7 +586,7 @@ describe('ModuleUpdater with module updates disabled', () => {
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{moduleUpdatesDisabledBySetting: true},
{moduleUpdatesDisabled: true, startupUpdateRequested: true},
);
const outcome = await updater.run();
@@ -594,13 +595,81 @@ describe('ModuleUpdater with module updates disabled', () => {
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.deepEqual(requests, []);
});
});
test('the saved setting goes to the update server when nothing can launch', async () => {
describe('ModuleUpdater with updates on demand', () => {
async function storeOnRenderer() {
const userDataPath = createUserData();
const storeRoot = getModuleStoreRoot(userDataPath);
const store = await openStore(userDataPath);
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
await store.commit({fluxer_renderer: RENDERER_SHA});
return {store, storeRoot};
}
test('launches the verified set without fetching when an automatic check left an update waiting', async () => {
const {store} = await storeOnRenderer();
assert.equal((await createUpdater(store, {fluxer_renderer: RENDERER_SHA}).updater.run()).status, 'launching');
const statuses = [];
let shellUpdates = 0;
const {updater, requests, sleeps} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{
shellLatest: '2026.824.0',
forceStartupUpdate: true,
updateOnDemand: true,
onState: (state) => statuses.push(state.status),
selfUpdateShellFirst: async () => {
shellUpdates += 1;
return {reason: 'no-update'};
},
},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'unreachable-launch');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.deepEqual(requests, []);
assert.deepEqual(sleeps, []);
assert.equal(shellUpdates, 0);
assert.deepEqual(statuses, ['launching']);
});
test('installs the update the user asked for before launch', async () => {
const {store, storeRoot} = await storeOnRenderer();
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer-two']]);
seedCachedPackage(store, NEXT_RENDERER_SHA);
const steps = [];
const {updater, requests} = createUpdater(
recordInstalls(store, steps),
{fluxer_renderer: NEXT_RENDERER_SHA},
{
shellLatest: '2026.824.1',
updateOnDemand: true,
startupUpdateRequested: true,
selfUpdateShellFirst: async (latestVersion) => {
steps.push(`shell ${latestVersion}`);
return {reason: 'install-failed'};
},
},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_renderer: NEXT_RENDERER_SHA});
assert.deepEqual(steps, ['shell 2026.824.1', 'fluxer_renderer']);
assert.ok(requests.some((url) => String(url).endsWith('/modules.json')));
});
test('goes to the update server when nothing can launch', async () => {
const store = await openStore(createUserData());
const {updater, requests} = createUpdater(
store,
{fluxer_renderer: RENDERER_SHA},
{moduleUpdatesDisabledBySetting: true, respond: missingPackages()},
{updateOnDemand: true, respond: missingPackages()},
);
const outcome = await updater.run();
+14 -8
View File
@@ -267,8 +267,9 @@ interface ModuleUpdaterOptions {
readonly bundledRendererVersion?: string | null;
readonly preferUnversionedBundle?: boolean;
readonly forceStartupUpdate?: boolean;
readonly startupUpdateRequested?: boolean;
readonly moduleUpdatesDisabled?: boolean;
readonly moduleUpdatesDisabledBySetting?: boolean;
readonly updateOnDemand?: boolean;
readonly selfUpdateShellFirst?: (latestVersion: string) => Promise<{readonly reason: string}>;
readonly onState?: (state: ModuleUpdaterSplashState) => void;
readonly report?: (report: ModuleUpdaterReport) => void;
@@ -289,7 +290,7 @@ export class ModuleUpdater {
private readonly hasOfflineRenderer: boolean;
private readonly forceStartupUpdate: boolean;
private readonly moduleUpdatesDisabled: boolean;
private readonly moduleUpdatesDisabledBySetting: boolean;
private readonly launchInstalledOnDemand: boolean;
private readonly selfUpdateShellFirst: ((latestVersion: string) => Promise<{readonly reason: string}>) | null;
private readonly shellFirstAttempts = new Map<string, {readonly reason: string} | null>();
private readonly manifestFeed: ModuleManifestFeedIdentity;
@@ -317,9 +318,10 @@ export class ModuleUpdater {
this.store = options.store;
this.shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
this.hasOfflineRenderer = options.hasOfflineRenderer ?? false;
this.forceStartupUpdate = options.forceStartupUpdate ?? false;
const startupUpdateRequested = options.startupUpdateRequested ?? false;
this.forceStartupUpdate = (options.forceStartupUpdate ?? false) || startupUpdateRequested;
this.moduleUpdatesDisabled = options.moduleUpdatesDisabled ?? false;
this.moduleUpdatesDisabledBySetting = options.moduleUpdatesDisabledBySetting ?? false;
this.launchInstalledOnDemand = (options.updateOnDemand ?? false) && !startupUpdateRequested;
this.selfUpdateShellFirst = options.selfUpdateShellFirst ?? null;
const releaseChannel = options.releaseChannel ?? BUILD_CHANNEL;
this.platform = options.platform ?? process.platform;
@@ -424,10 +426,12 @@ export class ModuleUpdater {
return reverted;
}
}
if (this.moduleUpdatesDisabled || this.moduleUpdatesDisabledBySetting) {
if (this.moduleUpdatesDisabled || this.launchInstalledOnDemand) {
const decision = await this.planner.evaluateUnreachableLaunch(this.startupUpdateEnforced);
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
return await this.launchUnreachable();
return await this.launchUnreachable(
this.moduleUpdatesDisabled ? ModuleUpdaterStatus.UNREACHABLE_LAUNCH : ModuleUpdaterStatus.LAUNCHING,
);
}
if (this.moduleUpdatesDisabled) {
return this.blockUpdateRequired(decision.reason, [], 'module updates are disabled', {
@@ -813,9 +817,11 @@ export class ModuleUpdater {
};
}
private async launchUnreachable(): Promise<ModuleUpdaterOutcome> {
private async launchUnreachable(
splashStatus: ModuleUpdaterStatus = ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
): Promise<ModuleUpdaterOutcome> {
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
this.emit(splashStatus);
return {
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
committed: this.store.getCommitted(),
@@ -13,9 +13,7 @@ const {getDesktopTroubleshootingSettings, loadDesktopConfig, setDesktopTroublesh
'../common/DesktopConfig.ts'
);
const {shouldDisableModuleUpdatesForLaunch} = await import('./LaunchOptions.ts');
const {areModuleUpdatesDisabled, resolveEffectiveDesktopTroubleshootingSettings} = await import(
'./DesktopDebugInfo.ts'
);
const {isUpdateOnDemandLaunch, resolveEffectiveDesktopTroubleshootingSettings} = await import('./DesktopDebugInfo.ts');
function loadSettings(troubleshooting) {
const userDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-desktop-module-updates-setting-test-'));
@@ -112,9 +110,25 @@ describe('disabling module updates', () => {
test('keeps the launch answer when the persisted setting changes without a restart', () => {
withModuleUpdatesEnv(undefined, () => {
loadSettings({disableModuleUpdates: true});
assert.equal(areModuleUpdatesDisabled(), true);
assert.equal(isUpdateOnDemandLaunch(), true);
setDesktopTroubleshootingSettings({disableModuleUpdates: false});
assert.equal(areModuleUpdatesDisabled(), true);
assert.equal(isUpdateOnDemandLaunch(), true);
});
});
test('the persisted setting moves updates on demand and leaves the in-app update check on', () => {
withModuleUpdatesEnv(undefined, () => {
loadSettings({disableModuleUpdates: true});
assert.equal(isUpdateOnDemandLaunch(), true);
assert.equal(shouldDisableModuleUpdatesForLaunch([]), false);
});
});
test('the environment variable turns updates off entirely, even with the setting on', () => {
withModuleUpdatesEnv('1', () => {
loadSettings({disableModuleUpdates: true});
assert.equal(isUpdateOnDemandLaunch(), false);
assert.equal(shouldDisableModuleUpdatesForLaunch([]), true);
});
});
});
+2 -3
View File
@@ -49,7 +49,6 @@ import {
recordDesktopAppStoreSuccess,
} from '@electron/main/DesktopAppStoreHealth';
import {
areModuleUpdatesDisabled,
formatDesktopDebugInfo,
getDesktopDebugInfo,
getLaunchNetLogPath,
@@ -74,7 +73,7 @@ import {
registerIpcHandlers,
} from '@electron/main/IpcHandlers';
import {initializeJumpList} from '@electron/main/JumpList';
import {describeLaunchDiagnosticOptions} from '@electron/main/LaunchOptions';
import {describeLaunchDiagnosticOptions, shouldDisableModuleUpdatesForLaunch} from '@electron/main/LaunchOptions';
import {cleanupLegacyHarvestHandlers, registerLegacyHarvestHandlers} from '@electron/main/LegacyHarvestIpc';
import {initializeLegacyOriginHarvest} from '@electron/main/LegacyOriginHarvest';
import {cleanupVirtmic, registerVirtmicHandlers} from '@electron/main/LinuxAudioCapture';
@@ -601,7 +600,7 @@ if (launchConfigurationError) {
showWindow,
});
});
if (process.env.FLUXER_OFFLINE !== '1' && !areModuleUpdatesDisabled()) {
if (process.env.FLUXER_OFFLINE !== '1' && !shouldDisableModuleUpdatesForLaunch()) {
const {registerUpdater} = await import('@electron/main/Updater');
registerUpdater(getMainWindow);
}