fix(desktop): update from the splash only after the user clicks (#3280)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:25:34 +02:00
1 parent f1373a63c3
commit 7937d5f802
90 files changed
+6888 -11255

No files matched your search

+2 -19
View File
@@ -6,7 +6,7 @@ import type {DesktopHandoffAPI} from '@fluxer/desktop_ipc/src/BrowserHandoffCont
import type {DesktopCapabilityManifest} from '@fluxer/desktop_ipc/src/CapabilityManifest';
import type {NativeGatewayTransportAPI} from '@fluxer/desktop_ipc/src/GatewayTransportContract';
import type {DesktopKnownInstanceStorageAPI} from '@fluxer/desktop_ipc/src/KnownInstanceContract';
import type {DesktopModuleAPI} from '@fluxer/desktop_ipc/src/ModuleContract';
import type {DesktopModuleAPI, DesktopUpdateAPI} from '@fluxer/desktop_ipc/src/ModuleContract';
import type {DesktopStorageAPI} from '@fluxer/desktop_ipc/src/StorageContract';
import type {VoiceEngineV2BridgeHardwareEncoderApi} from '@fluxer/voice_engine_v2/bridge';
import type {
@@ -217,8 +217,6 @@ export type UpdaterEvent =
type: 'available';
context: UpdaterContext;
version: string | null;
downloadSize?: number | null;
downloadStarted: boolean;
downloadUrl?: string;
downloadOptions?: Array<UpdaterDownloadOption>;
}
@@ -226,24 +224,10 @@ export type UpdaterEvent =
type: 'not-available';
context: UpdaterContext;
}
| {
type: 'downloaded';
context: UpdaterContext;
version: string | null;
}
| {
type: 'progress';
context: UpdaterContext;
percent: number;
transferred: number;
total: number;
bytesPerSecond: number;
}
| {
type: 'error';
context: UpdaterContext;
message: string;
phase?: 'check' | 'download' | 'install';
}
| {
type: 'unsupported';
@@ -676,8 +660,6 @@ export interface ElectronAPI {
popupHelpMenu: () => Promise<void>;
onUpdaterEvent: (callback: (event: UpdaterEvent) => void) => () => void;
updaterCheck: (context: UpdaterContext) => Promise<void>;
updaterDownload: (context: UpdaterContext) => Promise<void>;
updaterInstall: () => Promise<void>;
windowMinimize: () => void;
windowMaximize: () => void;
windowClose: () => void;
@@ -806,6 +788,7 @@ export interface ElectronAPI {
getAppMetrics?: () => Promise<AppMetricsSnapshot>;
capabilities: DesktopCapabilityManifest;
desktopModules: DesktopModuleAPI;
desktopUpdate: DesktopUpdateAPI;
reportLastRoute: (routePath: string) => void;
notifyFirstContentPainted: () => void;
desktopAccounts: DesktopAccountStorageAPI;
+259 -171
View File
@@ -10,10 +10,10 @@ import {
ModuleSystemLaunchDecision,
resolveModuleSystemLaunch,
} from '@electron/common/ModuleSystem';
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
import {DesktopUpdateRun, type DesktopUpdateTakeover} from '@electron/main/DesktopUpdateRun';
import {armOpenUrlForwarding, observeRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff';
import {watchModuleRendererReload} from '@electron/main/ModuleRendererReload';
import type {ModuleLaunchAttempt, ModuleStore as ModuleStoreInstance} from '@electron/main/ModuleStore';
import {offerPendingModuleUpdate} from '@electron/main/ModuleUpdateGate';
import type {
ModuleUpdater as ModuleUpdaterInstance,
ModuleUpdaterOutcome,
@@ -65,6 +65,9 @@ const MODULE_SYSTEM_LAUNCH = resolveModuleSystemLaunch({hasOfflineRenderer: HAS_
const MODULE_LAUNCH_PERMIT = Symbol('fluxer.desktop.moduleLaunchPermit');
const UPDATE_TAKEOVER_SPLASH_WAIT_MS = 250;
const UPDATE_SPLASH_PRELOAD_DELAY_MS = 5000;
const UPDATE_SERVER_UNREACHABLE_MESSAGE =
"Fluxer couldn't reach its update server to download app files. Check your internet connection, proxy or firewall. Fluxer keeps retrying.";
@@ -124,125 +127,49 @@ async function launchMainApp(permit: ModuleLaunchPermit, logger: BootstrapLogger
await import(MAIN_PROCESS_ENTRY_URL);
}
function armModulePoll(
plan: ModulePollPlan,
updater: ModuleUpdaterInstance,
refreshModuleRoots: (modules: Readonly<Record<string, string>>) => Promise<void>,
logger: BootstrapLogger,
): void {
let mainWindow: BrowserWindow | null = null;
function armModulePoll(plan: ModulePollPlan, logger: BootstrapLogger): void {
let inFlight = false;
let failing = false;
void import('@electron/main/ModuleBootHandoff').then(({observeMainWindow, onMainWindowReady}) => {
observeMainWindow((window) => {
mainWindow = window;
});
onMainWindowReady(() => {
logger.info('Polling the module manifest', {
intervalMs: plan.intervalMs,
jitterRatio: plan.jitterRatio,
});
const schedule = (): void => {
const timer = setTimeout(tick, nextModulePollDelay(plan));
timer.unref();
};
const tick = (): void => {
const window = mainWindow;
if (inFlight || window == null || window.isDestroyed()) {
schedule();
return;
}
inFlight = true;
void updater
.refreshInstalledModules()
.then(async (refresh) => {
if (failing) {
failing = false;
logger.info('The module poll recovered');
}
if (refresh.status !== 'activated') {
return;
}
logger.info('A new module set landed, offering it to the renderer', {modules: refresh.modules});
try {
if (window.isDestroyed()) {
throw new Error('the main window closed before the activated module set could be offered');
}
const releasePendingLaunch = (): void => {
updater.abandonPendingLaunch(refresh.launchAttempt);
};
const reloadRenderer = (): void => {
if (window.isDestroyed()) {
logger.warn('The main window closed before the activated module set could reload');
releasePendingLaunch();
return;
}
const webContents = window.webContents;
const markReloadSucceeded = (): void => {
void updater.markLaunchSucceeded(refresh.launchAttempt).catch((error: unknown) => {
logger.error('Failed to record a successful module reload', error);
});
};
void refreshModuleRoots(refresh.launchAttempt.committed).then(
() => {
if (window.isDestroyed()) {
logger.warn('The main window closed before the activated module set could reload');
releasePendingLaunch();
return;
}
const stopWatchingReload = watchModuleRendererReload({
target: webContents,
observeLaunchConfirmed: observeRendererLaunchConfirmed,
onLoaded: markReloadSucceeded,
onAbandoned: (reason) => {
logger.warn('The renderer never confirmed the module reload, releasing the pending launch', {
reason,
});
releasePendingLaunch();
},
});
try {
webContents.reloadIgnoringCache();
} catch (error) {
stopWatchingReload();
releasePendingLaunch();
logger.error('The activated module set could not reload the renderer', error);
}
},
(error: unknown) => {
releasePendingLaunch();
logger.error('The activated module set could not be served to the renderer', error);
},
);
};
offerPendingModuleUpdate({
update: {modules: refresh.modules},
apply: reloadRenderer,
discard: releasePendingLaunch,
});
} catch (error) {
updater.abandonPendingLaunch(refresh.launchAttempt);
throw error;
}
})
.catch((error: unknown) => {
if (!failing) {
failing = true;
logger.warn('The module poll failed, staying quiet until it recovers', error);
}
})
.finally(() => {
inFlight = false;
schedule();
});
};
if (process.platform === 'linux') {
tick();
} else {
schedule();
}
});
logger.info('Polling the module manifest', {
intervalMs: plan.intervalMs,
jitterRatio: plan.jitterRatio,
});
const schedule = (): void => {
const timer = setTimeout(tick, nextModulePollDelay(plan));
timer.unref();
};
const tick = (): void => {
if (inFlight) {
schedule();
return;
}
inFlight = true;
void checkDesktopUpdateNow()
.then((state) => {
if (failing) {
failing = false;
logger.info('The module poll recovered');
}
if (state.available) {
logger.info('A desktop update is available, waiting for the user to start it');
}
})
.catch((error: unknown) => {
if (!failing) {
failing = true;
logger.warn('The module poll failed, staying quiet until it recovers', error);
}
})
.finally(() => {
inFlight = false;
schedule();
});
};
if (process.platform === 'linux') {
tick();
} else {
schedule();
}
}
async function refuseUnsupportedBuild(reason: string): Promise<never> {
@@ -275,12 +202,14 @@ async function runModuleBootstrap(): Promise<void> {
const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol');
const {
armSecondInstanceForwarding,
getMainWindowFactory,
onMainWindowCreated,
onMainWindowReady,
setCommittedModuleFiles,
setOnDemandModuleInstaller,
setSecondInstanceSink,
} = await import('@electron/main/ModuleBootHandoff');
const {armDesktopUpdate, publishDesktopUpdateCheck} = await import('@electron/main/DesktopUpdateGate');
const {instanceTurnedModulesOff} = await import('@electron/main/InstanceModulePreference');
const {createOnDemandModuleInstaller} = await import('@electron/main/ModuleOnDemand');
const {getModuleStoreRoot, ModuleStore} = await import('@electron/main/ModuleStore');
@@ -300,9 +229,12 @@ async function runModuleBootstrap(): Promise<void> {
onSplashNetworkOnline,
onSplashOpenLogs,
onSplashQuit,
onSplashReady,
onSplashRetry,
openSplashWindow,
preloadSplashWindow,
releaseSplashAffordance,
revealPreloadedSplashWindow,
setSplashState,
SplashAction,
} = await import('@electron/main/SplashWindow');
@@ -363,23 +295,23 @@ async function runModuleBootstrap(): Promise<void> {
openSplashWindow();
}
setSplashState({status: 'checking-for-updates'});
onSplashQuit(() => {
logger.info('The splash requested a quit');
app.quit();
});
onSplashRetry(() => {
logger.info('The splash requested a retry, relaunching');
relaunchStableLaunchPath();
app.exit(0);
});
const updateServerRetry = new UpdateServerRetry();
onSplashNetworkOnline(() => {
logger.info('The splash reported the network is back');
updateServerRetry.networkReturned();
});
const splashOpenedAt = Date.now();
const shellUpdatePlan = resolveShellUpdatePlan();
if (shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE && shellUpdatePlan.updater === 'appimage') {
const {sweepAbandonedAppImageUpdates} = await import('@electron/main/AppImageUpdate');
try {
const reclaimed = sweepAbandonedAppImageUpdates(shellUpdatePlan.target);
if (reclaimed.length > 0) {
logger.info('Reclaimed abandoned AppImage staging directories', {count: reclaimed.length});
}
} catch (error) {
logger.warn('Failed to reclaim abandoned AppImage staging directories', error);
}
}
let splashOpenedAt = Date.now();
let diagnosticsSource: {
readonly store: ModuleStoreInstance;
readonly updater: ModuleUpdaterInstance;
@@ -425,25 +357,58 @@ async function runModuleBootstrap(): Promise<void> {
lastErrorAt: updaterDiagnostics?.lastErrorAt ?? null,
});
};
onSplashOpenLogs(() => {
const logsPath = resolveLogsPath();
if (logsPath == null) return;
void shell.openPath(logsPath).then((failure) => {
if (failure.length > 0) {
logger.warn('Failed to open the logs folder from the splash', {failure});
}
const armSplashActions = (): void => {
onSplashQuit(() => {
logger.info('The splash requested a quit');
app.quit();
});
});
onSplashCopyDiagnostics(() => {
void collectSplashDiagnostics()
.then((text) => {
clipboard.writeText(text);
logger.info('Copied updater diagnostics from the splash');
})
.catch((error: unknown) => {
logger.error('Failed to copy updater diagnostics', error);
onSplashRetry(() => {
logger.info('The splash requested a retry, relaunching');
relaunchStableLaunchPath();
app.exit(0);
});
onSplashNetworkOnline(() => {
logger.info('The splash reported the network is back');
updateServerRetry.networkReturned();
});
onSplashOpenLogs(() => {
const logsPath = resolveLogsPath();
if (logsPath == null) return;
void shell.openPath(logsPath).then((failure) => {
if (failure.length > 0) {
logger.warn('Failed to open the logs folder from the splash', {failure});
}
});
});
});
onSplashCopyDiagnostics(() => {
void collectSplashDiagnostics()
.then((text) => {
clipboard.writeText(text);
logger.info('Copied updater diagnostics from the splash');
})
.catch((error: unknown) => {
logger.error('Failed to copy updater diagnostics', error);
});
});
};
armSplashActions();
const runShellSelfUpdateOnSplash = async (
requiredSecurityUpdate: boolean,
): 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});
},
onRestarting: () => {
setSplashState({status: 'shell-update-restarting', requiredSecurityUpdate});
},
});
};
const showBlockedSplash = (status: SplashStatus, message: string | null = null): void => {
openSplashWindow();
@@ -478,10 +443,25 @@ async function runModuleBootstrap(): Promise<void> {
},
});
if (store.shellVersionChanged) {
logger.info('The shell version changed since the last boot, converging the modules before launch');
}
const updater = new ModuleUpdater({
store,
shellVersion: app.getVersion(),
hasOfflineRenderer: HAS_OFFLINE_RENDERER,
forceStartupUpdate: store.shellVersionChanged,
selfUpdateShellFirst:
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
? async (latestVersion, requiredSecurityUpdate) => {
logger.info('A newer shell is available, updating it before the modules', {latestVersion});
const result = await runShellSelfUpdateOnSplash(requiredSecurityUpdate);
logger.warn('The shell self update did not restart the app, converging the modules for this shell', {
latestVersion,
...result,
});
}
: undefined,
fetch: moduleNetworkFetch,
sleep: updateServerRetry.sleep,
onState: (state) => {
@@ -536,33 +516,17 @@ async function runModuleBootstrap(): Promise<void> {
launchAttempt: outcome.launchAttempt,
};
case 'blocked-shell-update': {
const plan = resolveShellUpdatePlan();
logger.warn('A shell update is required before the modules can converge', {
latestVersion: outcome.latestVersion,
minimumVersion: outcome.minimumVersion,
capability: plan.capability,
capability: shellUpdatePlan.capability,
});
if (plan.capability === ShellUpdateCapability.SELF_UPDATE) {
const {runShellSelfUpdate} = await import('@electron/main/ShellSelfUpdate');
const failure = await runShellSelfUpdate(plan, {
onDownloading: (progress) => {
setSplashState({
status: 'shell-update-downloading',
requiredSecurityUpdate: outcome.requiredSecurityUpdate,
progress,
});
},
onRestarting: () => {
setSplashState({
status: 'shell-update-restarting',
requiredSecurityUpdate: outcome.requiredSecurityUpdate,
});
},
});
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(plan, outcome.latestVersion, outcome.requiredSecurityUpdate);
armBlockedShellUpdate(shellUpdatePlan, outcome.latestVersion, outcome.requiredSecurityUpdate);
setSecondInstanceSink(focusSplashWindow);
return await new Promise<never>(() => {});
}
@@ -641,10 +605,131 @@ async function runModuleBootstrap(): Promise<void> {
closeSplashWindow();
return;
}
window.once('closed', closeSplashWindow);
const closeSplashWithWindow = (): void => {
closeSplashWindow();
};
window.once('closed', closeSplashWithWindow);
onMainWindowReady(() => {
window.removeListener('closed', closeSplashWithWindow);
});
});
};
const reopenMainWindow = (launchAttempt: ModuleLaunchAttempt | null): void => {
const createMainWindow = getMainWindowFactory();
if (createMainWindow == null) {
logger.error('No main window factory is registered, relaunching to recover');
relaunchStableLaunchPath();
app.exit(0);
return;
}
if (launchAttempt != null) {
const stopObservingLaunch = observeRendererLaunchConfirmed(() => {
stopObservingLaunch();
void updater.markLaunchSucceeded(launchAttempt).catch((error: unknown) => {
logger.error('Failed to record a successful module update launch', error);
});
});
}
setSplashState({status: 'launching'});
const window = createMainWindow();
window.once('show', () => {
logger.info('The updated main window is showing, closing the splash');
closeSplashWindow();
});
window.once('closed', closeSplashWindow);
};
let takeoverWindows: typeof import('@electron/main/Window') | null = null;
let takeoverSplash: BrowserWindow | null = null;
let takeoverHidden: ReadonlyArray<BrowserWindow> = [];
let takeoverActive = false;
const preloadUpdateSplash = (): void => {
const timer = setTimeout(() => {
if (takeoverActive) return;
void import('@electron/main/Window').then((windows) => {
takeoverWindows = windows;
if (!takeoverActive) preloadSplashWindow();
});
}, UPDATE_SPLASH_PRELOAD_DELAY_MS);
timer.unref();
};
const updateTakeover: DesktopUpdateTakeover = {
begin: async () => {
takeoverActive = true;
const windows = takeoverWindows ?? (await import('@electron/main/Window'));
takeoverWindows = windows;
splashOpenedAt = Date.now();
setSplashState({status: 'checking-for-updates'});
armSplashActions();
const preloaded = revealPreloadedSplashWindow();
if (preloaded != null) {
takeoverSplash = preloaded;
takeoverHidden = windows.hideAppWindowsForUpdate(preloaded);
windows.beginMainWindowTakeover(focusSplashWindow);
logger.info('Revealed the preloaded update splash and hid the app windows in the same tick');
return;
}
const splash = openSplashWindow({darkThemeOnShow: true});
takeoverSplash = splash;
windows.beginMainWindowTakeover(focusSplashWindow);
await new Promise<void>((resolve) => {
let settled = false;
const takeOver = (): void => {
if (settled) return;
settled = true;
stopWaitingForSplash();
clearTimeout(deadline);
focusSplashWindow();
takeoverHidden = windows.hideAppWindowsForUpdate(splash);
resolve();
};
const stopWaitingForSplash = onSplashReady(takeOver);
const deadline = setTimeout(takeOver, UPDATE_TAKEOVER_SPLASH_WAIT_MS);
});
},
restore: () => {
takeoverWindows?.endMainWindowTakeover();
takeoverWindows?.restoreAppWindowsAfterUpdate(takeoverHidden);
takeoverHidden = [];
closeSplashWindow();
takeoverActive = false;
preloadUpdateSplash();
},
closeApp: async () => {
if (takeoverSplash != null) {
await takeoverWindows?.closeAppWindowsForUpdate(takeoverSplash);
}
takeoverHidden = [];
},
reopen: (launchAttempt) => {
takeoverWindows?.endMainWindowTakeover();
takeoverActive = false;
reopenMainWindow(launchAttempt);
preloadUpdateSplash();
},
};
const desktopUpdate = new DesktopUpdateRun({
probe: () => updater.checkForUpdate(),
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(false),
installModules: async () => {
const launchAttempt = await updater.installPending();
if (launchAttempt != null) {
await refreshModuleRoots(launchAttempt.committed);
}
return launchAttempt;
},
takeover: updateTakeover,
publish: publishDesktopUpdateCheck,
openDownloadsPage: async () => {
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
await shell.openExternal(DOWNLOAD_PAGE_URL);
},
logger,
});
const permit = await runModuleUpdateLoop();
localNetworkHint.disarm();
await refreshModuleRoots(permit.launchAttempt.committed);
@@ -658,8 +743,11 @@ async function runModuleBootstrap(): Promise<void> {
}),
);
markSplashLaunching();
armDesktopUpdate({check: () => desktopUpdate.check(), start: () => desktopUpdate.start()});
armMainWindowHandoff(updater, permit.launchAttempt, () => {
armModulePoll(resolveModulePollPlan(readDevModulePollInterval()), updater, refreshModuleRoots, logger);
desktopUpdate.markLaunchSettled();
preloadUpdateSplash();
armModulePoll(resolveModulePollPlan(readDevModulePollInterval()), logger);
});
app.once('before-quit', () => {
closeSplashWindow();
+64 -84
View File
@@ -92,20 +92,21 @@ describe('Bootstrap entry point', () => {
/blockUntilRelaunch\('blocked-shell-update'\)/,
'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 plan = resolveShellUpdatePlan\(\);/);
assert.match(source, /armBlockedShellUpdate\(plan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/);
});
test('a shell that can update itself tries to, and still falls through to the manual screen when it cannot', () => {
assert.match(source, /if \(plan\.capability === ShellUpdateCapability\.SELF_UPDATE\) \{/);
assert.match(source, /const failure = await runShellSelfUpdate\(plan, \{/);
assert.match(source, /const shellUpdatePlan = resolveShellUpdatePlan\(\);/);
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\(plan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
/armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
);
});
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.',
);
});
test('the self update is imported inside the update loop, never at the top level', () => {
assert.doesNotMatch(
source,
@@ -116,16 +117,10 @@ describe('Bootstrap entry point', () => {
});
test('the self update paints its own progress rather than leaving the checking screen up', () => {
assert.match(
source,
/status: 'shell-update-downloading',\n\t+requiredSecurityUpdate: outcome\.requiredSecurityUpdate,\n\t+progress,/,
);
assert.match(
source,
/status: 'shell-update-restarting',\n\t+requiredSecurityUpdate: outcome\.requiredSecurityUpdate,/,
);
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\)/);
});
test('the retry affordance still belongs to the module update block', () => {
assert.match(source, /setSplashState\(\{status, action: SplashAction\.RETRY, message\}\);/);
assert.match(
@@ -138,11 +133,15 @@ describe('Bootstrap entry point', () => {
assert.equal(source.split('\n').filter((line) => line.includes('onSplashRetry(')).length, 1);
assert.match(
source,
/onSplashQuit\(\(\) => \{\n\t\tlogger\.info\('The splash requested a quit'\);\n\t\tapp\.quit\(\);\n\t\}\);\n\tonSplashRetry\(/,
/onSplashQuit\(\(\) => \{\n\t\t\tlogger\.info\('The splash requested a quit'\);\n\t\t\tapp\.quit\(\);\n\t\t\}\);\n\t\tonSplashRetry\(/,
'Two reachable blockUntilRelaunch calls would otherwise register two listeners and one click would relaunch twice.',
);
assert.doesNotMatch(
source,
/const blockUntilRelaunch = [^\n]*\n[^\n]*armSplashActions\(\)/,
'The splash forgets its listeners when it closes, so the update splash arms them again, but the block helper never does.',
);
});
test('refusing to start never top level awaits either', () => {
assert.doesNotMatch(source, /^await refuseUnsupportedBuild\(/m);
assert.match(source, /void refuseUnsupportedBuild\(/);
@@ -198,10 +197,9 @@ describe('Bootstrap entry point', () => {
);
assert.match(
source,
/armBlockedShellUpdate\(plan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);\n\t+setSecondInstanceSink\(focusSplashWindow\);/,
/armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);\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(
@@ -222,15 +220,19 @@ describe('Bootstrap entry point', () => {
);
});
test('the module poll tracks the live main window rather than the one the boot handoff latched', () => {
assert.doesNotMatch(
source,
/onMainWindowCreated\(\(window\) => \{\n\t\t\tmainWindow = window;/,
'onMainWindowCreated is a one shot boot handoff. Closing the window and reopening it from the dock or the tray would leave the poll holding a destroyed window forever, so every later tick would short circuit and no module version would ever land again.',
test('the module poll only checks, it never downloads or reloads underneath the user', () => {
const poll = source.slice(
source.indexOf('function armModulePoll('),
source.indexOf('async function refuseUnsupportedBuild('),
);
assert.match(source, /observeMainWindow\(\(window\) => \{\n\t\t\tmainWindow = window;\n\t\t\}\);/);
assert.match(poll, /void checkDesktopUpdateNow\(\)/);
assert.doesNotMatch(
poll,
/installPending|reloadIgnoringCache|refreshModuleRoots|activateMergedForRendererReload/,
'A background poll that downloads the renderer and commits it makes the update look like it already happened, then the click reloads in place while the shell update still waits. The poll only publishes whether an update exists.',
);
assert.doesNotMatch(source, /reloadIgnoringCache/);
});
test('a module poll that went quiet says so when it comes back', () => {
assert.match(
source,
@@ -239,60 +241,6 @@ describe('Bootstrap entry point', () => {
);
});
test('an activated module set that cannot reach the renderer releases the pending launch', () => {
assert.match(
source,
/\} catch \(error\) \{\n\t+updater\.abandonPendingLaunch\(refresh\.launchAttempt\);\n\t+throw error;/,
'refreshInstalledModules holds a pending launch from the moment it activates. If refreshing the module roots, the closed window check or reloadIgnoringCache throws, the poll only logs, and every later poll returns awaiting-renderer for the rest of the session.',
);
});
test('a reload the renderer never confirms releases the pending launch too', () => {
assert.doesNotMatch(
source,
/webContents\.once\('did-finish-load'/,
'A bare one shot only settles when the load lands. Nothing throws when the window is destroyed mid reload, so the pending launch would stay held and every later poll would return awaiting-renderer for the rest of the session.',
);
assert.match(
source,
/const stopWatchingReload = watchModuleRendererReload\(\{\n\t+target: webContents,\n\t+observeLaunchConfirmed: observeRendererLaunchConfirmed,\n\t+onLoaded: markReloadSucceeded,\n\t+onAbandoned: \(reason\) => \{/,
);
assert.match(source, /releasePendingLaunch\(\);\n\t+\},\n\t+\}\);/);
assert.match(
source,
/\} catch \(error\) \{\n\t+stopWatchingReload\(\);\n\t+releasePendingLaunch\(\);/,
'The reload runs from a user click, not from the poll, so nothing upstream is left to catch a rethrow. It has to release the pending launch itself or every later poll returns awaiting-renderer for the rest of the session.',
);
});
test('an activated module set is offered to the renderer rather than reloaded underneath it', () => {
assert.match(
source,
/offerPendingModuleUpdate\(\{\n\t+update: \{modules: refresh\.modules\},\n\t+apply: reloadRenderer,\n\t+discard: releasePendingLaunch,\n\t+\}\);/,
'A silent reloadIgnoringCache throws away whatever the user was typing. The poll hands the activated set to the gate and the renderer reloads when the user asks for it.',
);
assert.doesNotMatch(
source,
/\}\);\n\t+webContents\.reloadIgnoringCache\(\);/,
'reloadIgnoringCache belongs inside the applied callback, never on the poll path.',
);
});
test('a superseded or discarded module update releases the pending launch', () => {
assert.match(
source,
/discard: releasePendingLaunch,/,
'The gate holds at most one offer. Dropping one without releasing its launch attempt would leave refreshInstalledModules returning awaiting-renderer for the rest of the session.',
);
});
test('a window that closed before the user applied the update does not reload a destroyed target', () => {
assert.match(
source,
/const reloadRenderer = \(\): void => \{\n\t+if \(window\.isDestroyed\(\)\) \{/,
'The offer can sit unapplied for as long as the user leaves it. The window it captured may be gone by the time they click.',
);
});
test('a minimized login launch converges the modules without flashing the splash', () => {
const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
assert.match(
@@ -317,10 +265,42 @@ describe('Bootstrap entry point', () => {
);
assert.match(
source,
/openSplashWindow\(\);\n\t+armBlockedShellUpdate\(plan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
/openSplashWindow\(\);\n\t+armBlockedShellUpdate\(shellUpdatePlan, outcome\.latestVersion, outcome\.requiredSecurityUpdate\);/,
);
});
test('a user check works from the first window, a click only after the renderer confirmed its launch', () => {
const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
const arm = bootstrap.indexOf(
'armDesktopUpdate({check: () => desktopUpdate.check(), start: () => desktopUpdate.start()});',
);
assert.notEqual(arm, -1);
assert.ok(arm < bootstrap.indexOf('await launchMainApp(permit, logger);'));
assert.match(bootstrap, /\.finally\(onLaunchSettled\)/);
assert.match(bootstrap, /desktopUpdate\.markLaunchSettled\(\);/);
});
test('the update splash hides the app only once it is on screen, in its own theme', () => {
const bootstrap = source.slice(source.indexOf('async function runModuleBootstrap()'));
assert.match(bootstrap, /openSplashWindow\(\{darkThemeOnShow: true\}\)/);
assert.match(
bootstrap,
/focusSplashWindow\(\);\n\t+takeoverHidden = windows\.hideAppWindowsForUpdate\(splash\);/,
'A deadline that wins before the splash preload reports ready would otherwise hide the app with nothing on screen.',
);
assert.match(
bootstrap,
/splashOpenedAt = Date\.now\(\);/,
'Copy diagnostics counts from the update, not from boot.',
);
});
test('the main window that closes for an update never takes the update splash with it', () => {
assert.match(
source,
/window\.once\('closed', closeSplashWithWindow\);\n\t+onMainWindowReady\(\(\) => \{\n\t+window\.removeListener\('closed', closeSplashWithWindow\);/,
);
});
test('the pre-ready Chromium configuration runs before the bootstrap waits for ready', () => {
const configure = source.indexOf('applyPreReadyChromiumConfiguration(userDataConfig.channel, process.argv);');
const gpuWorkaround = source.indexOf('await appendWindowsGpuDriverWorkaroundSwitches();');
+15 -1
View File
@@ -3,7 +3,7 @@
import {APP_PROTOCOL} from '@electron/common/Constants';
import {parseJumpListTaskFromArgv} from '@electron/main/JumpList';
import {recordRecentDeepLink} from '@electron/main/RecentDocuments';
import {getMainWindow, showWindow} from '@electron/main/Window';
import {getMainWindow, isMainWindowTakenOver, onMainWindowTakeoverEnded, showWindow} from '@electron/main/Window';
import {app, ipcMain} from 'electron';
import log from 'electron-log';
@@ -125,6 +125,14 @@ export function initializeDeepLinks(): void {
registerInitialDeepLinkHandler();
}
function deliverDeepLinkHeldByTakeover(): void {
const mainWindow = getMainWindow();
if (initialDeepLink == null || mainWindow == null || mainWindow.isDestroyed()) return;
const url = initialDeepLink;
initialDeepLink = null;
mainWindow.webContents.send('deep-link', url);
}
function registerInitialDeepLinkHandler(): void {
const deepLinkArg = extractDeepLinkFromArgv(process.argv);
if (deepLinkArg) {
@@ -134,6 +142,7 @@ function registerInitialDeepLinkHandler(): void {
shouldSuppressAsDuplicate(normalized);
}
}
onMainWindowTakeoverEnded(deliverDeepLinkHeldByTakeover);
ipcMain.handle('get-initial-deep-link', (): string | null => {
const url = initialDeepLink;
initialDeepLink = null;
@@ -145,6 +154,11 @@ function dispatchDeepLink(url: string): void {
const normalized = normalizeDeepLinkForRenderer(url);
if (!normalized || shouldSuppressAsDuplicate(normalized)) return;
recordRecentDeepLink(url);
if (isMainWindowTakenOver()) {
initialDeepLink = normalized;
showWindow();
return;
}
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('deep-link', normalized);
+2 -1
View File
@@ -33,6 +33,7 @@ const TRAY_POSITION_GUID = TRAY_POSITION_GUIDS[BUILD_CHANNEL];
interface DesktopTrayController {
createWindow: () => BrowserWindow;
getMainWindow: () => BrowserWindow | null;
isMainWindowTakenOver: () => boolean;
hideWindow: () => void;
setQuitting: (quitting: boolean) => void;
showWindow: () => void;
@@ -236,7 +237,7 @@ function createTrayIcon(): Electron.NativeImage | null {
function ensureMainWindowVisible(): void {
if (!controller) return;
const mainWindow = controller.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
if ((!mainWindow || mainWindow.isDestroyed()) && !controller.isMainWindowTakenOver()) {
controller.createWindow();
}
controller.showWindow();
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
const {
armDesktopUpdate,
checkDesktopUpdateNow,
getDesktopUpdateState,
observeDesktopUpdateState,
publishDesktopUpdateCheck,
startDesktopUpdate,
} = await import('@electron/main/DesktopUpdateGate');
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return {promise, resolve};
}
describe('DesktopUpdateGate', () => {
test('nothing is available and nothing starts before the bootstrap arms the gate', async () => {
assert.deepEqual(getDesktopUpdateState(), {available: false});
assert.deepEqual(await checkDesktopUpdateNow(), {available: false});
assert.equal(startDesktopUpdate(), false);
});
test('a check publishes the update and one click runs it exactly once', async () => {
const states = [];
const stop = observeDesktopUpdateState((state) => states.push(state.available));
const running = deferred();
let starts = 0;
armDesktopUpdate({
check: async () => ({shellNewer: false, modulesChanged: true}),
start: async () => {
starts += 1;
await running.promise;
publishDesktopUpdateCheck({shellNewer: false, modulesChanged: false});
},
});
assert.deepEqual(await checkDesktopUpdateNow(), {available: true});
assert.equal(startDesktopUpdate(), true);
assert.equal(startDesktopUpdate(), false, 'a second click while the update runs is a no-op');
assert.deepEqual(getDesktopUpdateState(), {available: false}, 'the affordance hides the moment it is clicked');
assert.deepEqual(await checkDesktopUpdateNow(), {available: false}, 'a poll during the update changes nothing');
running.resolve();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(starts, 1);
assert.deepEqual(getDesktopUpdateState(), {available: false});
assert.deepEqual(states, [true, false]);
stop();
});
test('a shell update that could not finish stays available for the next click', async () => {
armDesktopUpdate({
check: async () => ({shellNewer: true, modulesChanged: false}),
start: async () => {
publishDesktopUpdateCheck({shellNewer: true, modulesChanged: false});
},
});
await checkDesktopUpdateNow();
assert.equal(startDesktopUpdate(), true);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(getDesktopUpdateState(), {available: true});
assert.equal(startDesktopUpdate(), true);
});
});
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChildLogger} from '@electron/common/Logger';
import type {DesktopUpdateState} from '@fluxer/desktop_ipc/src/ModuleContract';
const logger = createChildLogger('DesktopUpdateGate');
export interface DesktopUpdateCheck {
readonly shellNewer: boolean;
readonly modulesChanged: boolean;
}
interface DesktopUpdateController {
readonly check: () => Promise<DesktopUpdateCheck>;
readonly start: () => Promise<void>;
}
type DesktopUpdateStateListener = (state: DesktopUpdateState) => void;
interface DesktopUpdateGateState {
controller: DesktopUpdateController | null;
lastCheck: DesktopUpdateCheck | null;
running: Promise<void> | null;
notifiedAvailable: boolean;
readonly listeners: Set<DesktopUpdateStateListener>;
}
const gateState: DesktopUpdateGateState = {
controller: null,
lastCheck: null,
running: null,
notifiedAvailable: false,
listeners: new Set(),
};
function isAvailable(check: DesktopUpdateCheck | null): boolean {
return check != null && (check.shellNewer || check.modulesChanged);
}
export function getDesktopUpdateState(): DesktopUpdateState {
return {available: gateState.running == null && isAvailable(gateState.lastCheck)};
}
function notify(): void {
const state = getDesktopUpdateState();
if (state.available === gateState.notifiedAvailable) {
return;
}
gateState.notifiedAvailable = state.available;
for (const listener of Array.from(gateState.listeners)) {
try {
listener(state);
} catch (error) {
logger.error('A desktop update state listener threw', error);
}
}
}
export function armDesktopUpdate(controller: DesktopUpdateController): void {
gateState.controller = controller;
}
export function publishDesktopUpdateCheck(check: DesktopUpdateCheck): void {
gateState.lastCheck = check;
notify();
}
export async function checkDesktopUpdateNow(): Promise<DesktopUpdateState> {
const controller = gateState.controller;
if (controller == null || gateState.running != null) {
return getDesktopUpdateState();
}
const check = await controller.check();
if (gateState.running == null) {
publishDesktopUpdateCheck(check);
}
return getDesktopUpdateState();
}
export function startDesktopUpdate(): boolean {
const controller = gateState.controller;
if (controller == null || gateState.running != null || !isAvailable(gateState.lastCheck)) {
return false;
}
const running = controller
.start()
.catch((error: unknown) => {
logger.error('The desktop update failed', error);
})
.finally(() => {
if (gateState.running === running) {
gateState.running = null;
notify();
}
});
gateState.running = running;
notify();
return true;
}
export function observeDesktopUpdateState(listener: DesktopUpdateStateListener): () => void {
gateState.listeners.add(listener);
return () => {
gateState.listeners.delete(listener);
};
}
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
const {DesktopUpdateRun} = await import('@electron/main/DesktopUpdateRun');
const LAUNCH_ATTEMPT = Object.freeze({committed: {fluxer_renderer: 'b'.repeat(64)}});
function harness({
probes = [{shellLatestVersion: '2026.1008.2', shellNewer: false, modulesChanged: true}],
canSelfUpdateShell = true,
shellResults = [],
installFails = false,
settled = true,
} = {}) {
const steps = [];
const published = [];
let probeIndex = 0;
let shellIndex = 0;
const quiet = () => undefined;
const run = new DesktopUpdateRun({
probe: async () => {
const next = probes[Math.min(probeIndex, probes.length - 1)];
probeIndex += 1;
steps.push('probe');
if (next instanceof Error) throw next;
return next;
},
canSelfUpdateShell,
runShellSelfUpdate: async () => {
steps.push('shell-update');
const result = shellResults[Math.min(shellIndex, shellResults.length - 1)];
shellIndex += 1;
return result;
},
installModules: async () => {
steps.push('install');
if (installFails) throw new Error('disk full');
return LAUNCH_ATTEMPT;
},
takeover: {
begin: async () => {
steps.push('takeover');
},
restore: () => {
steps.push('restore');
},
closeApp: async () => {
steps.push('close');
},
reopen: (launchAttempt) => {
steps.push(launchAttempt == null ? 'reopen' : 'reopen-updated');
},
},
publish: (check) => {
published.push(check);
},
openDownloadsPage: async () => {
steps.push('downloads-page');
},
logger: {info: quiet, warn: quiet, error: quiet},
});
if (settled) run.markLaunchSettled();
return {run, steps, published};
}
describe('DesktopUpdateRun', () => {
test('the background check only looks, it never downloads, closes or installs anything', async () => {
const {run, steps, published} = harness();
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: true});
assert.deepEqual(steps, ['probe']);
assert.deepEqual(published, []);
});
test('a click takes the window over, checks, and only then closes the app and installs', async () => {
const {run, steps, published} = harness();
await run.start();
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'install', 'reopen-updated']);
assert.deepEqual(published, [{shellNewer: false, modulesChanged: false}]);
});
test('a click while offline gives the same windows back instead of tearing the app down', async () => {
const {run, steps, published} = harness({probes: [new Error('offline')]});
await run.start();
assert.deepEqual(steps, ['probe', 'takeover', 'restore']);
assert.deepEqual(published, [], 'the icon stays so the next click can try again');
});
test('a click after the update was withdrawn gives the windows back and hides the icon', async () => {
const {run, steps, published} = harness({
probes: [{shellLatestVersion: '2026.1008.1', shellNewer: false, modulesChanged: false}],
});
await run.start();
assert.deepEqual(steps, ['probe', 'takeover', 'restore']);
assert.deepEqual(published, [{shellNewer: false, modulesChanged: false}]);
});
test('a shell update runs before any module download, and modules install only when the shell did not restart', async () => {
const {run, steps} = harness({
probes: [{shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: true}],
shellResults: [{reason: 'install-failed', detail: 'nope'}],
});
await run.start();
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'shell-update', 'install', 'reopen-updated']);
});
test('a shell feed that lags the manifest stops offering the shell until the manifest moves on', async () => {
const lagging = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false};
const {run, steps, published} = harness({
probes: [lagging, lagging, {...lagging, shellLatestVersion: '2026.1008.3'}],
shellResults: [{reason: 'no-update', detail: null}],
});
await run.start();
assert.deepEqual(published, [{shellNewer: false, modulesChanged: false}]);
assert.deepEqual(
await run.check(),
{shellNewer: false, modulesChanged: false},
'the poll no longer shows the icon',
);
assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false}, 'a newer manifest shell does');
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'shell-update', 'reopen', 'probe', 'probe']);
});
test('a shell update that failed on the network is retried by the next click', async () => {
const shell = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false};
const {run, steps, published} = harness({
probes: [shell],
shellResults: [
{reason: 'timed-out', detail: 'slow link'},
{reason: 'check-failed', detail: 'offline'},
],
});
await run.start();
await run.start();
assert.deepEqual(published, [
{shellNewer: true, modulesChanged: false},
{shellNewer: true, modulesChanged: false},
]);
assert.equal(steps.filter((step) => step === 'shell-update').length, 2);
assert.equal(steps.includes('downloads-page'), false);
});
test('only a shell update that failed to install falls back to the downloads page', async () => {
const shell = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false};
const {run, steps} = harness({probes: [shell], shellResults: [{reason: 'install-failed', detail: 'apply'}]});
await run.start();
steps.length = 0;
await run.start();
assert.deepEqual(steps, ['downloads-page']);
});
test('a shell that cannot update itself never offers the shell part', async () => {
const {run} = harness({
canSelfUpdateShell: false,
probes: [{shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false}],
});
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false});
});
test('a failed module install reopens the app on the installed set and keeps the icon', async () => {
const {run, steps, published} = harness({installFails: true});
await run.start();
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'install', 'reopen']);
assert.deepEqual(published, [{shellNewer: false, modulesChanged: true}]);
});
test('a click before the renderer confirmed its launch does nothing', async () => {
const {run, steps} = harness({settled: false});
await run.start();
assert.deepEqual(steps, []);
});
});
+153
View File
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DesktopUpdateCheck} from '@electron/main/DesktopUpdateGate';
import type {ModuleLaunchAttempt} from '@electron/main/ModuleStore';
export interface DesktopUpdateProbe {
readonly shellLatestVersion: string;
readonly shellNewer: boolean;
readonly modulesChanged: boolean;
}
export interface ShellSelfUpdateResult {
readonly reason: string;
readonly detail: string | null;
}
export interface DesktopUpdateTakeover {
readonly begin: () => Promise<void>;
readonly restore: () => void;
readonly closeApp: () => Promise<void>;
readonly reopen: (launchAttempt: ModuleLaunchAttempt | null) => void;
}
interface DesktopUpdateLogger {
readonly info: (message: string, details?: unknown) => void;
readonly warn: (message: string, details?: unknown) => void;
readonly error: (message: string, details?: unknown) => void;
}
interface DesktopUpdateRunOptions {
readonly probe: () => Promise<DesktopUpdateProbe>;
readonly canSelfUpdateShell: boolean;
readonly runShellSelfUpdate: () => Promise<ShellSelfUpdateResult>;
readonly installModules: () => Promise<ModuleLaunchAttempt | null>;
readonly takeover: DesktopUpdateTakeover;
readonly publish: (check: DesktopUpdateCheck) => void;
readonly openDownloadsPage: () => Promise<void>;
readonly logger: DesktopUpdateLogger;
readonly now?: () => number;
}
interface FilteredProbe {
readonly check: DesktopUpdateCheck;
readonly shellLatestVersion: string;
}
export class DesktopUpdateRun {
private readonly options: DesktopUpdateRunOptions;
private readonly now: () => number;
private launchSettled = false;
private shellFeedBehindVersion: string | null = null;
private shellInstallFailed = false;
private lastCheck: DesktopUpdateCheck | null = null;
public constructor(options: DesktopUpdateRunOptions) {
this.options = options;
this.now = options.now ?? Date.now;
}
public markLaunchSettled(): void {
this.launchSettled = true;
}
public async check(): Promise<DesktopUpdateCheck> {
return (await this.probe()).check;
}
public async start(): Promise<void> {
const {logger, takeover} = this.options;
const startedAt = this.now();
if (!this.launchSettled) {
logger.warn('Ignoring a desktop update start before the renderer confirmed its launch');
return;
}
if (this.shellInstallFailed && this.lastCheck?.modulesChanged !== true) {
logger.warn('The shell update failed to install this session, opening the downloads page instead');
await this.options.openDownloadsPage();
return;
}
logger.info('Starting the desktop update');
const probing = this.probe();
probing.catch(() => {});
await takeover.begin();
logger.info('The update splash took the app windows over', {elapsedMs: this.now() - startedAt});
let probe: FilteredProbe;
try {
probe = await probing;
} catch (error) {
logger.warn('The desktop update check failed, giving the app windows back', error);
takeover.restore();
return;
}
const {check} = probe;
logger.info('Checked for the desktop update', {...check, elapsedMs: this.now() - startedAt});
if (!check.shellNewer && !check.modulesChanged) {
this.publish(check);
takeover.restore();
return;
}
await takeover.closeApp();
logger.info('Closed the app windows for the update', {elapsedMs: this.now() - startedAt});
let launchAttempt: ModuleLaunchAttempt | null = null;
let remaining = check;
try {
if (check.shellNewer) {
remaining = await this.updateShell(probe);
}
if (check.modulesChanged) {
launchAttempt = await this.options.installModules();
remaining = {shellNewer: remaining.shellNewer, modulesChanged: false};
logger.info('Installed the module update', {
activated: launchAttempt != null,
elapsedMs: this.now() - startedAt,
});
}
} catch (error) {
logger.error('The desktop update failed, reopening the app on the installed modules', error);
} finally {
this.publish(remaining);
takeover.reopen(launchAttempt);
}
}
private async updateShell(probe: FilteredProbe): Promise<DesktopUpdateCheck> {
const {logger} = this.options;
const result = await this.options.runShellSelfUpdate();
logger.warn('The shell self update did not restart the app', result);
if (result.reason === 'no-update') {
this.shellFeedBehindVersion = probe.shellLatestVersion;
return {shellNewer: false, modulesChanged: probe.check.modulesChanged};
}
if (result.reason === 'install-failed') {
this.shellInstallFailed = true;
}
return probe.check;
}
private async probe(): Promise<FilteredProbe> {
const probe = await this.options.probe();
const check = {
shellNewer:
this.options.canSelfUpdateShell && probe.shellNewer && probe.shellLatestVersion !== this.shellFeedBehindVersion,
modulesChanged: probe.modulesChanged,
};
this.lastCheck = check;
return {check, shellLatestVersion: probe.shellLatestVersion};
}
private publish(check: DesktopUpdateCheck): void {
this.lastCheck = check;
this.options.publish(check);
}
}
+14 -14
View File
@@ -26,6 +26,7 @@ import {
hasActiveDesktopTray,
updateTrayRuntimeState,
} from '@electron/main/DesktopTray';
import {getDesktopUpdateState, observeDesktopUpdateState, startDesktopUpdate} from '@electron/main/DesktopUpdateGate';
import {DownloadChecksumError, downloadFile} from '@electron/main/FileDownloads';
import {getGatewayOriginRegistry} from '@electron/main/GatewayOriginRegistry';
import {retryBlockedGlobalShortcutHooks} from '@electron/main/GlobalShortcutsIpc';
@@ -40,11 +41,6 @@ import {setNativeStrings} from '@electron/main/MainI18n';
import {copyRemoteFileToClipboard, parseClipboardWriteFileOptions} from '@electron/main/MediaClipboard';
import {signalRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff';
import {ensureDesktopModule} from '@electron/main/ModuleOnDemand';
import {
applyPendingModuleUpdate,
getPendingModuleUpdate,
observePendingModuleUpdate,
} from '@electron/main/ModuleUpdateGate';
import {
createDesktopNativeGatewayTransport,
type DesktopNativeGatewayTransport,
@@ -101,7 +97,11 @@ import {
import {flashWindowForAttention, stopFlashingWindow} from '@electron/main/WindowFlash';
import {setWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
import {registerWindowsToastIpcHandlers} from '@electron/main/WindowsToast';
import {DESKTOP_MODULE_CHANNELS, DESKTOP_MODULE_EVENTS} from '@fluxer/desktop_ipc/src/ModuleContract';
import {
DESKTOP_MODULE_CHANNELS,
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_EVENTS,
} from '@fluxer/desktop_ipc/src/ModuleContract';
import {app, BrowserWindow, clipboard, dialog, ipcMain, powerMonitor, shell, systemPreferences} from 'electron';
import log from 'electron-log';
@@ -168,13 +168,13 @@ export function registerIpcHandlers(): void {
requirePrivilegedRendererDocumentSender(event, DESKTOP_MODULE_CHANNELS.ensure);
return ensureDesktopModule(moduleName);
});
ipcMain.handle(DESKTOP_MODULE_CHANNELS.pendingUpdate, (event) => {
requirePrivilegedRendererDocumentSender(event, DESKTOP_MODULE_CHANNELS.pendingUpdate);
return getPendingModuleUpdate();
ipcMain.handle(DESKTOP_UPDATE_CHANNELS.state, (event) => {
requirePrivilegedRendererDocumentSender(event, DESKTOP_UPDATE_CHANNELS.state);
return getDesktopUpdateState();
});
ipcMain.handle(DESKTOP_MODULE_CHANNELS.applyPendingUpdate, (event) => {
requirePrivilegedRendererDocumentSender(event, DESKTOP_MODULE_CHANNELS.applyPendingUpdate);
return applyPendingModuleUpdate();
ipcMain.handle(DESKTOP_UPDATE_CHANNELS.start, (event) => {
requirePrivilegedRendererDocumentSender(event, DESKTOP_UPDATE_CHANNELS.start);
startDesktopUpdate();
});
ipcMain.handle(DESKTOP_MODULE_CHANNELS.confirmLaunch, (event) => {
requirePrivilegedRendererDocumentSender(event, DESKTOP_MODULE_CHANNELS.confirmLaunch);
@@ -189,12 +189,12 @@ export function registerIpcHandlers(): void {
}
signalRendererLaunchConfirmed();
});
observePendingModuleUpdate((pending) => {
observeDesktopUpdateState((state) => {
const mainWindow = getMainWindow();
if (mainWindow == null || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(DESKTOP_MODULE_EVENTS.pendingUpdateChanged, pending);
mainWindow.webContents.send(DESKTOP_UPDATE_EVENTS.stateChanged, state);
});
ipcMain.handle('get-desktop-info', () => getDesktopInfo());
ipcMain.handle('get-gpu-info', () => getGpuInfo());
@@ -16,6 +16,8 @@ type SecondInstanceSink = (argv: Array<string>) => void;
type OpenUrlSink = (url: string) => void;
type MainWindowFactory = () => BrowserWindow;
interface CommittedModuleFileIndex {
readonly root: string | null;
readonly files: ReadonlyMap<string, string>;
@@ -30,6 +32,7 @@ interface ModuleBootHandoffState {
mainWindow: BrowserWindow | null;
liveMainWindow: BrowserWindow | null;
onDemandModuleInstaller: OnDemandModuleInstaller | null;
mainWindowFactory: MainWindowFactory | null;
secondInstanceForwarding: boolean;
secondInstanceSink: SecondInstanceSink | null;
readonly bufferedSecondInstances: Array<Array<string>>;
@@ -49,6 +52,7 @@ const handoffState: ModuleBootHandoffState = {
mainWindow: null,
liveMainWindow: null,
onDemandModuleInstaller: null,
mainWindowFactory: null,
secondInstanceForwarding: false,
secondInstanceSink: null,
bufferedSecondInstances: [],
@@ -165,6 +169,14 @@ export function getOnDemandModuleInstaller(): OnDemandModuleInstaller | null {
return handoffState.onDemandModuleInstaller;
}
export function setMainWindowFactory(factory: MainWindowFactory): void {
handoffState.mainWindowFactory = factory;
}
export function getMainWindowFactory(): MainWindowFactory | null {
return handoffState.mainWindowFactory;
}
export function signalMainWindowCreated(window: BrowserWindow | null): void {
handoffState.liveMainWindow = window;
if (!handoffState.mainWindowSignalled) {
@@ -1,132 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {describe, test} from 'node:test';
import './LocalAppTestSupport.test.mjs';
const {watchModuleRendererReload} = await import('@electron/main/ModuleRendererReload');
function createWatch({timeoutMs = 60_000} = {}) {
const target = new EventEmitter();
const loaded = [];
const abandoned = [];
let announceAbandon = () => undefined;
const whenAbandoned = new Promise((resolve) => {
announceAbandon = resolve;
});
const stop = watchModuleRendererReload({
target,
observeLaunchConfirmed: (listener) => {
const handler = () => {
target.removeListener('launch-confirmed', handler);
listener();
};
target.on('launch-confirmed', handler);
return () => {
target.removeListener('launch-confirmed', handler);
};
},
timeoutMs,
onLoaded: () => {
loaded.push('loaded');
},
onAbandoned: (reason) => {
abandoned.push(reason);
announceAbandon(reason);
},
});
return {abandoned, loaded, stop, target, whenAbandoned};
}
function listenerCounts(target) {
return {
'launch-confirmed': target.listenerCount('launch-confirmed'),
destroyed: target.listenerCount('destroyed'),
};
}
async function settle(milliseconds) {
await new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function waitForAbandon(watch) {
await Promise.race([watch.whenAbandoned, settle(1000)]);
}
describe('watching a renderer reload for an activated module set', () => {
test('records the launch once the renderer confirms it booted', () => {
const watch = createWatch();
watch.target.emit('launch-confirmed');
assert.deepEqual(watch.loaded, ['loaded']);
assert.deepEqual(watch.abandoned, []);
assert.deepEqual(listenerCounts(watch.target), {'launch-confirmed': 0, destroyed: 0});
});
test('a renderer destroyed after it loaded never turns the recorded launch into an abandoned one', () => {
const watch = createWatch();
watch.target.emit('launch-confirmed');
watch.target.emit('destroyed');
assert.deepEqual(watch.loaded, ['loaded']);
assert.deepEqual(watch.abandoned, []);
});
test('releases the pending launch when the renderer is destroyed before it confirms the reload', () => {
const watch = createWatch();
watch.target.emit('destroyed');
assert.equal(watch.abandoned.length, 1);
assert.match(watch.abandoned[0], /destroyed/u);
assert.deepEqual(watch.loaded, []);
assert.deepEqual(listenerCounts(watch.target), {'launch-confirmed': 0, destroyed: 0});
});
test('a launch confirmation that arrives from the wreckage of a destroyed renderer records nothing', () => {
const watch = createWatch();
watch.target.emit('destroyed');
watch.target.emit('launch-confirmed');
assert.deepEqual(watch.loaded, []);
assert.equal(watch.abandoned.length, 1);
});
test('releases the pending launch when neither event ever arrives', async () => {
const watch = createWatch({timeoutMs: 5});
await waitForAbandon(watch);
assert.equal(watch.abandoned.length, 1);
assert.deepEqual(watch.loaded, []);
});
test('a reload that lands after the deadline is still recorded rather than left as a failed boot attempt', async () => {
const watch = createWatch({timeoutMs: 5});
await waitForAbandon(watch);
watch.target.emit('launch-confirmed');
assert.deepEqual(watch.loaded, ['loaded']);
assert.equal(watch.abandoned.length, 1);
});
test('stopping the watch detaches every listener and disarms the deadline', async () => {
const watch = createWatch({timeoutMs: 5});
watch.stop();
watch.target.emit('launch-confirmed');
watch.target.emit('destroyed');
await settle(30);
assert.deepEqual(watch.loaded, []);
assert.deepEqual(watch.abandoned, []);
assert.deepEqual(listenerCounts(watch.target), {'launch-confirmed': 0, destroyed: 0});
});
});
@@ -1,69 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const RENDERER_RELOAD_TIMEOUT_MS = 60_000;
interface ModuleRendererReloadTarget {
once(event: 'destroyed', listener: () => void): void;
removeListener(event: 'destroyed', listener: () => void): void;
}
interface ModuleRendererReloadWatch {
readonly target: ModuleRendererReloadTarget;
readonly observeLaunchConfirmed: (listener: () => void) => () => void;
readonly timeoutMs?: number;
readonly onLoaded: () => void;
readonly onAbandoned: (reason: string) => void;
}
export function watchModuleRendererReload({
target,
observeLaunchConfirmed,
timeoutMs = RENDERER_RELOAD_TIMEOUT_MS,
onLoaded,
onAbandoned,
}: ModuleRendererReloadWatch): () => void {
let abandoned = false;
let deadline: ReturnType<typeof setTimeout> | null = null;
let stopObservingConfirmation: (() => void) | null = null;
const clearDeadline = (): void => {
if (deadline != null) {
clearTimeout(deadline);
deadline = null;
}
};
const stopConfirmation = (): void => {
const stop = stopObservingConfirmation;
stopObservingConfirmation = null;
stop?.();
};
const abandon = (reason: string): void => {
clearDeadline();
if (abandoned) {
return;
}
abandoned = true;
onAbandoned(reason);
};
const handleLoaded = (): void => {
clearDeadline();
stopConfirmation();
target.removeListener('destroyed', handleDestroyed);
onLoaded();
};
const handleDestroyed = (): void => {
stopConfirmation();
abandon('the renderer was destroyed before it confirmed the reload');
};
stopObservingConfirmation = observeLaunchConfirmed(handleLoaded);
target.once('destroyed', handleDestroyed);
deadline = setTimeout(() => {
deadline = null;
abandon('the renderer did not confirm the reload in time');
}, timeoutMs);
deadline.unref();
return (): void => {
clearDeadline();
stopConfirmation();
target.removeListener('destroyed', handleDestroyed);
};
}
@@ -215,6 +215,41 @@ describe('ModuleStore', () => {
assert.equal(existsSync(path.join(userDataPath, 'desktop-app-store.sqlite3')), false);
});
test('says when the shell changed version since the store was last opened', async () => {
const userDataPath = createUserData();
assert.equal((await openStore(userDataPath)).shellVersionChanged, false);
assert.equal((await openStore(userDataPath)).shellVersionChanged, false);
const upgraded = await ModuleStore.open({
root: getModuleStoreRoot(userDataPath),
shellVersion: '2026.824.1',
releaseChannel: RELEASE_CHANNEL,
});
assert.equal(upgraded.shellVersionChanged, true);
assert.notEqual(
readState(userDataPath).shell_version,
'2026.824.1',
'a quit or crash before the modules converge has to force the update again on the next boot',
);
const reopened = await ModuleStore.open({
root: getModuleStoreRoot(userDataPath),
shellVersion: '2026.824.1',
releaseChannel: RELEASE_CHANNEL,
});
assert.equal(reopened.shellVersionChanged, true);
await reopened.recordShellVersionConverged();
assert.equal(readState(userDataPath).shell_version, '2026.824.1');
const converged = await ModuleStore.open({
root: getModuleStoreRoot(userDataPath),
shellVersion: '2026.824.1',
releaseChannel: RELEASE_CHANNEL,
});
assert.equal(converged.shellVersionChanged, false);
});
test('writes state.json atomically and leaves no temporary behind', async () => {
const userDataPath = createUserData();
const store = await openStore(userDataPath);
+22 -2
View File
@@ -1178,6 +1178,7 @@ export class ModuleStore {
public readonly incomingDownloadRoot: string;
public readonly storeRoot: string;
private state: ModuleStoreState;
private convergingShellVersion: string | null = null;
private launchSucceeded = false;
private activeLaunchAttempt: ModuleLaunchAttempt | null = null;
private readonly installCoordinator = new ModuleInstallCoordinator<ModuleInstallation>();
@@ -1217,8 +1218,11 @@ export class ModuleStore {
return store;
}
const store = new ModuleStore(resolved, stateFile.state);
if (stateFile.state.shell_version !== shellVersion || stateFile.state.release_channel !== releaseChannel) {
await store.writeState({...store.state, shell_version: shellVersion, release_channel: releaseChannel});
if (stateFile.state.shell_version !== shellVersion) {
store.convergingShellVersion = shellVersion;
}
if (stateFile.state.release_channel !== releaseChannel) {
await store.writeState({...store.state, release_channel: releaseChannel});
}
return store;
}
@@ -1265,6 +1269,22 @@ export class ModuleStore {
}
}
public get shellVersionChanged(): boolean {
return this.convergingShellVersion != null;
}
public async recordShellVersionConverged(): Promise<void> {
const shellVersion = this.convergingShellVersion;
if (shellVersion == null) {
return;
}
await this.withStateLock(async () => {
if (this.state.shell_version !== shellVersion) {
await this.writeState({...this.state, shell_version: shellVersion});
}
});
}
public getState(): ModuleStoreState {
return this.state;
}
@@ -1,93 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {beforeEach, describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
const {
applyPendingModuleUpdate,
discardPendingModuleUpdate,
getPendingModuleUpdate,
observePendingModuleUpdate,
offerPendingModuleUpdate,
} = await import('@electron/main/ModuleUpdateGate');
function entry(modules, calls) {
return {
update: {modules},
apply: () => calls.push(`apply:${modules.join(',')}`),
discard: () => calls.push(`discard:${modules.join(',')}`),
};
}
describe('ModuleUpdateGate', () => {
beforeEach(() => {
discardPendingModuleUpdate();
});
test('there is no pending update until one is offered', () => {
assert.equal(getPendingModuleUpdate(), null);
assert.equal(applyPendingModuleUpdate(), false);
});
test('an offered update is readable and applies exactly once', () => {
const calls = [];
offerPendingModuleUpdate(entry(['fluxer_renderer'], calls));
assert.deepEqual(getPendingModuleUpdate(), {modules: ['fluxer_renderer']});
assert.equal(applyPendingModuleUpdate(), true);
assert.equal(applyPendingModuleUpdate(), false);
assert.deepEqual(calls, ['apply:fluxer_renderer']);
assert.equal(getPendingModuleUpdate(), null);
});
test('applying never discards the update it applied', () => {
const calls = [];
offerPendingModuleUpdate(entry(['fluxer_twemoji'], calls));
applyPendingModuleUpdate();
assert.deepEqual(calls, ['apply:fluxer_twemoji']);
});
test('discarding releases the pending update without applying it', () => {
const calls = [];
offerPendingModuleUpdate(entry(['fluxer_grammars'], calls));
discardPendingModuleUpdate();
assert.equal(getPendingModuleUpdate(), null);
assert.deepEqual(calls, ['discard:fluxer_grammars']);
});
test('a superseding offer discards the update it replaced', () => {
const calls = [];
offerPendingModuleUpdate(entry(['first'], calls));
offerPendingModuleUpdate(entry(['second'], calls));
assert.deepEqual(calls, ['discard:first']);
assert.deepEqual(getPendingModuleUpdate(), {modules: ['second']});
applyPendingModuleUpdate();
assert.deepEqual(calls, ['discard:first', 'apply:second']);
});
test('observers receive the current state on subscribe and on every change', () => {
const seen = [];
const stop = observePendingModuleUpdate((pending) => {
seen.push(pending == null ? null : pending.modules.join(','));
});
offerPendingModuleUpdate(entry(['fluxer_renderer'], []));
applyPendingModuleUpdate();
stop();
offerPendingModuleUpdate(entry(['ignored'], []));
assert.deepEqual(seen, [null, 'fluxer_renderer', null]);
});
test('a throwing observer does not stop the others', () => {
const seen = [];
observePendingModuleUpdate(() => {
throw new Error('this observer throws');
});
observePendingModuleUpdate((pending) => {
seen.push(pending == null ? null : pending.modules.join(','));
});
offerPendingModuleUpdate(entry(['fluxer_renderer'], []));
assert.deepEqual(seen, [null, 'fluxer_renderer']);
});
});
@@ -1,80 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChildLogger} from '@electron/common/Logger';
import type {DesktopPendingModuleUpdate} from '@fluxer/desktop_ipc/src/ModuleContract';
const logger = createChildLogger('ModuleUpdateGate');
type PendingModuleUpdateListener = (pending: DesktopPendingModuleUpdate | null) => void;
interface PendingModuleUpdateEntry {
readonly update: DesktopPendingModuleUpdate;
readonly apply: () => void;
readonly discard: () => void;
}
interface ModuleUpdateGateState {
entry: PendingModuleUpdateEntry | null;
readonly listeners: Set<PendingModuleUpdateListener>;
}
const gateState: ModuleUpdateGateState = {
entry: null,
listeners: new Set(),
};
function deliver(listener: PendingModuleUpdateListener, pending: DesktopPendingModuleUpdate | null): void {
try {
listener(pending);
} catch (error) {
logger.error('A pending module update listener threw', error);
}
}
function notify(pending: DesktopPendingModuleUpdate | null): void {
for (const listener of Array.from(gateState.listeners)) {
deliver(listener, pending);
}
}
export function offerPendingModuleUpdate(entry: PendingModuleUpdateEntry): void {
const superseded = gateState.entry;
gateState.entry = entry;
if (superseded != null) {
superseded.discard();
}
notify(entry.update);
}
export function getPendingModuleUpdate(): DesktopPendingModuleUpdate | null {
return gateState.entry?.update ?? null;
}
export function applyPendingModuleUpdate(): boolean {
const entry = gateState.entry;
if (entry == null) {
return false;
}
gateState.entry = null;
notify(null);
entry.apply();
return true;
}
export function discardPendingModuleUpdate(): void {
const entry = gateState.entry;
if (entry == null) {
return;
}
gateState.entry = null;
notify(null);
entry.discard();
}
export function observePendingModuleUpdate(listener: PendingModuleUpdateListener): () => void {
gateState.listeners.add(listener);
deliver(listener, getPendingModuleUpdate());
return () => {
gateState.listeners.delete(listener);
};
}
+212 -57
View File
@@ -65,13 +65,20 @@ function installModuleOnDisk(storeRoot, moduleName, digest, entries) {
function manifestBytes(
modules,
{shellLatest = SHELL_VERSION, shellMinimum = SHELL_VERSION, required = null, bounds = {}, metadataVersion = 1} = {},
{
shellLatest = SHELL_VERSION,
shellMinimum = SHELL_VERSION,
required = null,
bounds = {},
metadataVersion = 1,
platform = PLATFORM,
} = {},
) {
return Buffer.from(
JSON.stringify({
manifest_version: 1,
release_channel: RELEASE_CHANNEL,
platform: PLATFORM,
platform,
arch: ARCH,
build_version: SHELL_VERSION,
pub_date: '2026-08-23T00:00:00.000Z',
@@ -111,10 +118,13 @@ function createUpdater(store, modules, options = {}) {
store,
shellVersion: SHELL_VERSION,
releaseChannel: RELEASE_CHANNEL,
platform: PLATFORM,
platform: options.platform ?? PLATFORM,
arch: ARCH,
packageOrigin: PACKAGE_ORIGIN,
hasOfflineRenderer: false,
forceStartupUpdate: options.forceStartupUpdate ?? false,
selfUpdateShellFirst: options.selfUpdateShellFirst,
onState: options.onState,
fetch: async (url, init) => {
requests.push(url);
const response = options.respond == null ? null : await options.respond(url, init);
@@ -218,6 +228,24 @@ function mergeDuringInstall(store, entries) {
});
}
function recordInstalls(store, installs) {
return new Proxy(store, {
get(target, property) {
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') {
return value;
}
if (property !== 'installModule') {
return value.bind(target);
}
return async (request) => {
installs.push(request.module);
return await value.call(target, request);
};
},
});
}
function unreachableFeed() {
return async () => {
throw new Error('module feed is unreachable');
@@ -451,37 +479,86 @@ describe('ModuleUpdater.run', () => {
});
});
describe('ModuleUpdater.refreshInstalledModules', () => {
test('an activated set that never reaches the renderer strands the poll until the launch is abandoned', async () => {
describe('ModuleUpdater checks without downloading and installs only when asked', () => {
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});
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer-two']]);
seedCachedPackage(store, NEXT_RENDERER_SHA);
const {updater, requests} = createUpdater(store, {fluxer_renderer: NEXT_RENDERER_SHA});
return {store, storeRoot};
}
const activated = await updater.refreshInstalledModules();
assert.equal(activated.status, 'activated');
assert.deepEqual(activated.modules, ['fluxer_renderer']);
assert.equal(requests.length, 1);
test('a check reads only the manifest and leaves the committed set, the floor and the boot attempt alone', async () => {
const {store} = await storeOnRenderer();
const before = store.getState();
const installs = [];
const {updater, requests} = createUpdater(recordInstalls(store, installs), {fluxer_renderer: NEXT_RENDERER_SHA});
assert.equal((await updater.refreshInstalledModules()).status, 'awaiting-renderer');
assert.equal(requests.length, 1, 'a pending launch short circuits the poll before it even reaches the manifest');
const check = await updater.checkForUpdate();
updater.abandonPendingLaunch(activated.launchAttempt);
assert.equal((await updater.refreshInstalledModules()).status, 'unchanged');
assert.equal(requests.length, 2, 'abandoning the launch the renderer never took must let the poll run again');
assert.deepEqual(check, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true});
assert.deepEqual(
requests.map((url) => String(url).split('/').pop()),
['modules.json'],
);
assert.deepEqual(installs, []);
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
assert.equal(store.getState().boot_attempt, before.boot_attempt);
assert.deepEqual(store.getState().floor, before.floor);
assert.equal(store.getState().last_manifest_fetch, before.last_manifest_fetch);
});
test('keeps a module installed on demand while the poll was downloading', async () => {
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});
test('a check reports a newer shell and an unchanged module set', async () => {
const {store} = await storeOnRenderer();
const {updater} = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {shellLatest: '2026.824.1'});
assert.deepEqual(await updater.checkForUpdate(), {
shellLatestVersion: '2026.824.1',
shellNewer: true,
modulesChanged: false,
});
});
test('a module that needs the next shell reads as a shell update, not as a module update', async () => {
const {store} = await storeOnRenderer();
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{shellLatest: '2026.824.1', bounds: {fluxer_renderer: {minimum_shell_version: '2026.824.1'}}},
);
assert.deepEqual(await updater.checkForUpdate(), {
shellLatestVersion: '2026.824.1',
shellNewer: true,
modulesChanged: false,
});
});
test('the click installs the new set, reports progress and arms a launch attempt for the next window', async () => {
const {store, storeRoot} = await storeOnRenderer();
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer-two']]);
seedCachedPackage(store, NEXT_RENDERER_SHA);
const statuses = [];
const {updater} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{onState: (state) => statuses.push(state.status)},
);
const attempt = await updater.installPending();
assert.deepEqual(attempt?.committed, {fluxer_renderer: NEXT_RENDERER_SHA});
assert.deepEqual(store.getCommitted(), {fluxer_renderer: NEXT_RENDERER_SHA});
assert.equal(store.getState().boot_attempt, 1);
assert.equal(statuses[0], 'checking');
assert.equal(statuses.at(-1), 'verifying');
await updater.markLaunchSucceeded(attempt);
assert.equal(store.getState().boot_attempt, 0);
});
test('the click keeps a module installed on demand while it was downloading', async () => {
const {store, storeRoot} = await storeOnRenderer();
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer-two']]);
installModuleOnDisk(storeRoot, 'fluxer_overlay', OVERLAY_SHA, [['index.js', 'overlay']]);
seedCachedPackage(store, NEXT_RENDERER_SHA);
@@ -489,35 +566,27 @@ describe('ModuleUpdater.refreshInstalledModules', () => {
fluxer_renderer: NEXT_RENDERER_SHA,
});
const activated = await updater.refreshInstalledModules();
const attempt = await updater.installPending();
assert.equal(activated.status, 'activated');
assert.deepEqual(activated.launchAttempt.committed, {
fluxer_overlay: OVERLAY_SHA,
fluxer_renderer: NEXT_RENDERER_SHA,
});
assert.deepEqual(attempt?.committed, {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: NEXT_RENDERER_SHA});
assert.deepEqual(store.getCommitted(), {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: NEXT_RENDERER_SHA});
});
test('abandoning an attempt that is not the pending one leaves the pending launch alone', async () => {
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});
installModuleOnDisk(storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer-two']]);
seedCachedPackage(store, NEXT_RENDERER_SHA);
const {updater} = createUpdater(store, {fluxer_renderer: NEXT_RENDERER_SHA});
test('the click installs nothing when the manifest now needs a newer shell', async () => {
const {store} = await storeOnRenderer();
const installs = [];
const {updater} = createUpdater(
recordInstalls(store, installs),
{fluxer_renderer: NEXT_RENDERER_SHA},
{shellLatest: '2026.824.1', shellMinimum: '2026.824.1'},
);
const activated = await updater.refreshInstalledModules();
assert.equal(activated.status, 'activated');
updater.abandonPendingLaunch({...activated.launchAttempt});
assert.equal((await updater.refreshInstalledModules()).status, 'awaiting-renderer');
assert.equal(await updater.installPending(), null);
assert.deepEqual(installs, []);
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
});
test('a module the manifest advertises but the CDN never got does not strand the rest of the poll', async () => {
test('a module the manifest advertises but the CDN never got does not strand the rest of the update', async () => {
const userDataPath = createUserData();
const storeRoot = getModuleStoreRoot(userDataPath);
const store = await openStore(userDataPath);
@@ -536,11 +605,9 @@ describe('ModuleUpdater.refreshInstalledModules', () => {
},
);
const activated = await updater.refreshInstalledModules();
const attempt = await updater.installPending();
assert.equal(activated.status, 'activated');
assert.deepEqual(activated.modules, ['fluxer_renderer']);
assert.deepEqual(store.getCommitted(), {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: NEXT_RENDERER_SHA});
assert.deepEqual(attempt?.committed, {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: NEXT_RENDERER_SHA});
assert.deepEqual(
[...new Set(reports.filter((report) => report.type === 'package-missing').map((report) => report.module))],
['fluxer_overlay'],
@@ -566,14 +633,12 @@ describe('ModuleUpdater.refreshInstalledModules', () => {
},
);
const activated = await updater.refreshInstalledModules();
await updater.installPending();
assert.equal(activated.status, 'activated');
assert.deepEqual(activated.modules, ['fluxer_renderer']);
assert.deepEqual(store.getCommitted(), {fluxer_renderer: NEXT_RENDERER_SHA});
});
test('a package failure with no deterministic cause still abandons the whole poll', async () => {
test('a package failure with no deterministic cause rejects the click and keeps the installed set', async () => {
const userDataPath = createUserData();
const storeRoot = getModuleStoreRoot(userDataPath);
const store = await openStore(userDataPath);
@@ -592,12 +657,12 @@ describe('ModuleUpdater.refreshInstalledModules', () => {
},
);
await assert.rejects(updater.refreshInstalledModules(), /failed to download module fluxer_renderer/u);
await assert.rejects(updater.installPending(), /failed to download module fluxer_renderer/u);
assert.deepEqual(store.getCommitted(), {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: RENDERER_SHA});
});
test('a poll that runs out of disk space aborts instead of activating the part that fit', async () => {
test('a click that runs out of disk space aborts instead of activating the part that fit', async () => {
const userDataPath = createUserData();
const storeRoot = getModuleStoreRoot(userDataPath);
const store = await openStore(userDataPath);
@@ -613,10 +678,100 @@ describe('ModuleUpdater.refreshInstalledModules', () => {
{required: ['fluxer_renderer']},
);
await assert.rejects(updater.refreshInstalledModules(), /no space left on device/u);
await assert.rejects(updater.installPending(), /no space left on device/u);
assert.deepEqual(store.getCommitted(), {fluxer_overlay: OVERLAY_SHA, fluxer_renderer: RENDERER_SHA});
});
test('a boot with a newer shell updates the shell before downloading any module', 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} = createUpdater(
recordInstalls(store, steps),
{fluxer_renderer: NEXT_RENDERER_SHA},
{
shellLatest: '2026.824.1',
forceStartupUpdate: true,
selfUpdateShellFirst: async (latestVersion) => {
steps.push(`shell ${latestVersion}`);
},
},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(steps, ['shell 2026.824.1', 'fluxer_renderer']);
});
test('a boot with the newest shell never runs the shell update', 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} = createUpdater(
recordInstalls(store, steps),
{fluxer_renderer: NEXT_RENDERER_SHA},
{
forceStartupUpdate: true,
selfUpdateShellFirst: async (latestVersion) => {
steps.push(`shell ${latestVersion}`);
},
},
);
await updater.run();
assert.deepEqual(steps, ['fluxer_renderer']);
});
test('a Linux boot right after a shell update fetches the modules instead of launching the old set', async () => {
const optional = await storeOnRenderer();
const quiet = createUpdater(optional.store, {fluxer_renderer: NEXT_RENDERER_SHA}, {platform: 'linux'});
const skipped = await quiet.updater.run();
assert.equal(skipped.status, 'launching');
assert.deepEqual(skipped.committed, {fluxer_renderer: RENDERER_SHA});
assert.deepEqual(quiet.requests, []);
const forced = await storeOnRenderer();
installModuleOnDisk(forced.storeRoot, 'fluxer_renderer', NEXT_RENDERER_SHA, [['index.js', 'renderer-two']]);
seedCachedPackage(forced.store, NEXT_RENDERER_SHA);
const {updater, requests} = createUpdater(
forced.store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{platform: 'linux', forceStartupUpdate: true},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'launching');
assert.deepEqual(outcome.committed, {fluxer_renderer: NEXT_RENDERER_SHA});
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 () => {
const {store} = await storeOnRenderer();
const {updater, sleeps} = createUpdater(
store,
{fluxer_renderer: NEXT_RENDERER_SHA},
{
platform: 'linux',
forceStartupUpdate: true,
respond: async (url) => (String(url).endsWith('package.br') ? new Response('', {status: 503}) : null),
},
);
const outcome = await updater.run();
assert.equal(outcome.status, 'unreachable-launch');
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
assert.deepEqual(
sleeps,
[],
'a package or deb upgrade never waits through the backoff for a module it can live without',
);
});
});
describe('ModuleUpdater explains why a launch is blocked', () => {
+99 -66
View File
@@ -2,6 +2,7 @@
import {createHash} from 'node:crypto';
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import type {DesktopUpdateProbe} from '@electron/main/DesktopUpdateRun';
import {ModuleDownloadRate} from '@electron/main/ModuleDownloadRate';
import {
blockingModuleNames,
@@ -44,7 +45,7 @@ import {
ModuleUpdatePlanner,
ModuleUpdaterBlockReason,
} from '@electron/main/ModuleUpdatePlanner';
import {compareModuleVersions, parseModuleVersion} from '@electron/main/ModuleVersion';
import {compareModuleVersions, type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
import {resolveDesktopPackageOrigin} from '@electron/main/ShellDownloadFormats';
import type {DesktopModuleEnsureResult} from '@fluxer/desktop_ipc/src/ModuleContract';
import {DesktopModuleEnsureStatus} from '@fluxer/desktop_ipc/src/ModuleContract';
@@ -71,21 +72,6 @@ export const ModuleUpdaterStatus = Object.freeze({
export type ModuleUpdaterStatus = (typeof ModuleUpdaterStatus)[keyof typeof ModuleUpdaterStatus];
const ModuleRefreshStatus = Object.freeze({
UNCHANGED: 'unchanged',
AWAITING_RENDERER: 'awaiting-renderer',
ACTIVATED: 'activated',
} as const);
type ModuleRefreshResult =
| {readonly status: typeof ModuleRefreshStatus.UNCHANGED}
| {readonly status: typeof ModuleRefreshStatus.AWAITING_RENDERER}
| {
readonly status: typeof ModuleRefreshStatus.ACTIVATED;
readonly modules: ReadonlyArray<string>;
readonly launchAttempt: ModuleLaunchAttempt;
};
const ModuleUpdaterReportType = Object.freeze({
NETWORK_ERROR: 'network-error',
MANIFEST_ROLLBACK: 'manifest-rollback',
@@ -277,6 +263,8 @@ interface ModuleUpdaterOptions {
readonly arch?: NodeJS.Architecture;
readonly packageOrigin?: string;
readonly hasOfflineRenderer?: boolean;
readonly forceStartupUpdate?: boolean;
readonly selfUpdateShellFirst?: (latestVersion: string, requiredSecurityUpdate: boolean) => Promise<void>;
readonly onState?: (state: ModuleUpdaterSplashState) => void;
readonly report?: (report: ModuleUpdaterReport) => void;
readonly fetch: typeof globalThis.fetch;
@@ -292,6 +280,13 @@ function isRejectedManifestError(error: unknown): boolean {
export class ModuleUpdater {
public readonly store: ModuleStore;
private readonly platform: string;
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 manifestFeed: ModuleManifestFeedIdentity;
private readonly manifestRepository: ModuleManifestRepository;
private readonly planner: ModuleUpdatePlanner;
@@ -311,18 +306,20 @@ export class ModuleUpdater {
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
private manifestObservationTail: Promise<void> = Promise.resolve();
private recordedManifestSha256: string | null = null;
private pendingRendererLaunch: ModuleLaunchAttempt | null = null;
public constructor(options: ModuleUpdaterOptions) {
this.store = options.store;
const shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
this.shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
this.hasOfflineRenderer = options.hasOfflineRenderer ?? false;
this.forceStartupUpdate = options.forceStartupUpdate ?? false;
this.selfUpdateShellFirst = options.selfUpdateShellFirst ?? null;
const releaseChannel = options.releaseChannel ?? BUILD_CHANNEL;
this.platform = options.platform ?? process.platform;
const arch = resolveDesktopModuleArchitecture(options.arch ?? process.arch);
this.manifestFeed = {releaseChannel, platform: this.platform, arch};
const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, '');
this.packageOrigin = packageOrigin;
this.planner = new ModuleUpdatePlanner(this.store, shellVersion, options.hasOfflineRenderer ?? false);
this.planner = new ModuleUpdatePlanner(this.store, this.shellVersion, this.hasOfflineRenderer);
this.onState = options.onState ?? (() => {});
this.reporter = options.report ?? (() => {});
this.sleep = options.sleep ?? defaultSleep;
@@ -427,6 +424,9 @@ export class ModuleUpdater {
if (!startupUpdateRequired) {
return await this.launchIfAtFloor();
}
if (this.planner.shellUpdateRequired(manifest) && (await this.onlyForcedByShellChange())) {
return await this.launchIfAtFloor();
}
if (this.planner.shellUpdateRequired(manifest)) {
this.emit(ModuleUpdaterStatus.BLOCKED_SHELL_UPDATE);
return {
@@ -436,6 +436,9 @@ export class ModuleUpdater {
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);
@@ -469,14 +472,15 @@ export class ModuleUpdater {
backoff.reset();
}
const delayMs = backoff.fail();
const exhausted = backoff.reachedCeiling || isRejectedManifestError(error);
const exhausted =
backoff.reachedCeiling || isRejectedManifestError(error) || (await this.onlyForcedByShellChange());
if (exhausted || !fetched) {
const decision = await this.planner.evaluateUnreachableLaunch(
this.isStartupUpdateEnforced(),
this.isSecurityUpdateRequired(),
);
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
const launchAttempt = await this.recordRendererLaunchAttempt();
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
return {
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
@@ -562,24 +566,67 @@ export class ModuleUpdater {
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
}
public async refreshInstalledModules(): Promise<ModuleRefreshResult> {
if (this.pendingRendererLaunch != null) {
return {status: ModuleRefreshStatus.AWAITING_RENDERER};
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
const latestVersion = manifest.shell.latestVersion.source;
if (
this.selfUpdateShellFirst == null ||
compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) <= 0 ||
this.shellFirstAttempted.has(latestVersion)
) {
return false;
}
this.manifestRepository.clearMemo();
const manifest = await this.observeManifest(await this.manifestRepository.fetchLatest());
this.shellFirstAttempted.add(latestVersion);
await this.selfUpdateShellFirst(latestVersion, this.isSecurityUpdateRequired());
return true;
}
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY);
const shellLatestVersion = manifest.shell.latestVersion.source;
const shellNewer = compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) > 0;
if (this.planner.shellUpdateRequired(manifest)) {
return {status: ModuleRefreshStatus.UNCHANGED};
return {shellLatestVersion, shellNewer, modulesChanged: false};
}
return {
shellLatestVersion,
shellNewer,
modulesChanged: (await this.collectPendingItems(manifest, this.planner)).length > 0,
};
}
public async installPending(): Promise<ModuleLaunchAttempt | null> {
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) {
this.emit(ModuleUpdaterStatus.VERIFYING, {current: items.length, total: items.length});
}
return await this.store.activateMergedForRendererReload(installed, unresolved);
}
private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise<DesktopModuleUpdateManifest> {
this.manifestRepository.clearMemo();
return await this.observeManifest(await this.manifestRepository.fetchLatest(), mode);
}
private async collectPendingItems(
manifest: DesktopModuleUpdateManifest,
planner: ModuleUpdatePlanner,
): Promise<Array<ModulePlanItem>> {
const committed = this.store.getCommitted();
const names = new Set<string>([...Object.keys(committed), ...blockingModuleNames(manifest)]);
const wanted: Array<ModulePlanItem> = [];
for (const moduleName of Array.from(names).sort()) {
const entry = manifest.modules[moduleName];
if (entry == null || !this.planner.isShellCompatible(entry)) {
if (entry == null || !planner.isShellCompatible(entry)) {
continue;
}
if (committed[moduleName] === entry.sha256 && (await this.planner.isInstalled(moduleName, entry.sha256))) {
if (committed[moduleName] === entry.sha256 && (await planner.isInstalled(moduleName, entry.sha256))) {
continue;
}
if (this.store.isRejected(moduleName, entry.sha256)) {
@@ -587,15 +634,19 @@ export class ModuleUpdater {
}
wanted.push({module: moduleName, entry, requirement: ModulePlanItemRequirement.OPTIONAL});
}
if (wanted.length === 0) {
return {status: ModuleRefreshStatus.UNCHANGED};
}
return wanted;
}
private async installItems(
items: ReadonlyArray<ModulePlanItem>,
): Promise<{readonly installed: Readonly<Record<string, string>>; readonly unresolved: ReadonlySet<string>}> {
const committed = this.store.getCommitted();
const installed: Record<string, string> = {};
const unresolved = new Set<string>();
for (let index = 0; index < wanted.length; index += 1) {
const item = wanted[index];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
try {
await this.packageInstaller.install(item, index + 1, wanted.length);
await this.packageInstaller.install(item, index + 1, items.length);
} catch (error) {
if (isStorageFullError(error) || !isDeterministicPackageFailure(error)) {
throw error;
@@ -614,34 +665,16 @@ export class ModuleUpdater {
}
installed[item.module] = item.entry.sha256;
}
const activated = await this.store.activateMergedForRendererReload(installed, unresolved);
if (activated == null) {
return {status: ModuleRefreshStatus.UNCHANGED};
}
this.pendingRendererLaunch = activated;
return {
status: ModuleRefreshStatus.ACTIVATED,
modules: Object.keys(installed),
launchAttempt: activated,
};
return {installed, unresolved};
}
public async markLaunchSucceeded(attempt: ModuleLaunchAttempt): Promise<void> {
if (!(await this.store.markLaunchAttemptSucceeded(attempt))) {
return;
}
if (this.pendingRendererLaunch === attempt) {
this.pendingRendererLaunch = null;
}
await this.store.collectGarbage({now: this.now()});
}
public abandonPendingLaunch(attempt: ModuleLaunchAttempt): void {
if (this.pendingRendererLaunch === attempt) {
this.pendingRendererLaunch = null;
}
}
public async canLaunchWhileUnreachable(): Promise<boolean> {
return (
(await this.planner.evaluateUnreachableLaunch(this.isStartupUpdateEnforced(), this.isSecurityUpdateRequired()))
@@ -672,7 +705,8 @@ export class ModuleUpdater {
'no module is committed and this build carries no offline renderer',
);
}
const launchAttempt = await this.recordRendererLaunchAttempt();
await this.store.recordShellVersionConverged();
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.LAUNCHING);
return {
status: ModuleUpdaterStatus.LAUNCHING,
@@ -705,7 +739,7 @@ export class ModuleUpdater {
message: `launching the reverted set below the persisted floor for ${belowFloor.join(', ')}, because recovering a bricked install outranks enforcing the forced update`,
});
}
const launchAttempt = await this.recordRendererLaunchAttempt();
const launchAttempt = await this.store.recordLaunchAttempt();
this.emit(ModuleUpdaterStatus.LAUNCHING);
return {
status: ModuleUpdaterStatus.LAUNCHING,
@@ -844,8 +878,16 @@ export class ModuleUpdater {
return observed;
}
private async onlyForcedByShellChange(): Promise<boolean> {
return (
this.forceStartupUpdate && !this.isStartupUpdateEnforced() && (await this.planner.canLaunchCommittedModules())
);
}
private async startupUpdateRequired(): Promise<boolean> {
return this.isStartupUpdateEnforced() || !(await this.planner.canLaunchCommittedModules());
return (
this.forceStartupUpdate || this.isStartupUpdateEnforced() || !(await this.planner.canLaunchCommittedModules())
);
}
private async execute(plan: ModulePlan): Promise<ModulePlanResult> {
@@ -884,15 +926,6 @@ export class ModuleUpdater {
return {desired, installed, blocked, blockedError};
}
private async recordRendererLaunchAttempt(): Promise<ModuleLaunchAttempt> {
if (this.pendingRendererLaunch != null) {
throw new Error('cannot begin a module renderer launch while another launch is awaiting readiness');
}
const launchAttempt = await this.store.recordLaunchAttempt();
this.pendingRendererLaunch = launchAttempt;
return launchAttempt;
}
private async persistManifest(
document: ModuleManifestDocument,
manifest: DesktopModuleUpdateManifest,
@@ -129,7 +129,7 @@ class FakeAutoUpdater {
}
const autoUpdater = new FakeAutoUpdater();
const exitCodes = [];
const quits = [];
const userDataPath = mkdtempSync(path.join(tmpdir(), 'fluxer-shell-self-update-'));
const applyStatePath = path.join(userDataPath, 'update-apply-state.json');
process.on('exit', () => {
@@ -140,8 +140,8 @@ installElectronStub({
app: {
getVersion: () => '2026.800.0',
getPath: () => userDataPath,
exit: (code) => {
exitCodes.push(code);
quit: () => {
quits.push('quit');
},
},
autoUpdater,
@@ -247,7 +247,7 @@ beforeEach(() => {
autoUpdater.installs = 0;
autoUpdater.checkError = null;
autoUpdater.installError = null;
exitCodes.length = 0;
quits.length = 0;
});
describe('velopack self update', () => {
@@ -322,7 +322,11 @@ describe('velopack self update', () => {
'downloadUpdateAsync',
'waitExitThenApplyUpdate:v2:true:true',
]);
assert.deepEqual(exitCodes, [0]);
assert.deepEqual(
quits,
['quit'],
'the restart goes through app.quit so the will-quit cleanup checkpoints the app store',
);
},
);
@@ -346,7 +350,7 @@ describe('velopack self update', () => {
});
assert.deepEqual(calls, []);
assert.deepEqual(velopack.calls, []);
assert.deepEqual(exitCodes, []);
assert.deepEqual(quits, []);
});
selfUpdateTest('an apply that landed is forgotten and the next one is recorded', async ({start, stillPending}) => {
@@ -370,7 +374,7 @@ describe('velopack self update', () => {
reason: 'install-failed',
detail: 'the updater binary is missing',
});
assert.deepEqual(exitCodes, []);
assert.deepEqual(quits, []);
},
);
+2 -6
View File
@@ -143,9 +143,7 @@ async function runVelopackSelfUpdate(control: SelfUpdateControl, hooks: ShellSel
control.settle({reason: 'install-failed', detail: errorDetail(error)});
return;
}
setImmediate(() => {
app.exit(0);
});
app.quit();
}
async function fetchPublishedAppImage(): Promise<{version: string; sha256: string | null}> {
@@ -222,9 +220,7 @@ async function runAppImageSelfUpdate(
return;
}
relaunchStableLaunchPath();
setImmediate(() => {
app.exit(0);
});
app.quit();
}
function runElectronSelfUpdate(control: SelfUpdateControl, hooks: ShellSelfUpdateHooks): void {
+4 -1
View File
@@ -1041,6 +1041,8 @@ const shouldHandleContextMenu = (webContents: WebContents, params: Electron.Cont
const isTextLike = inputFieldType === 'plainText' || inputFieldType === 'textarea' || inputFieldType === undefined;
return Boolean((targetRecent && target.isTextarea) || isTextLike);
};
let rendererSpellcheckHandlersRegistered = false;
export const registerSpellcheck = (webContents: WebContents): void => {
ensureSharedIpc();
if (!launchModeLogged) {
@@ -1074,7 +1076,8 @@ export const registerSpellcheck = (webContents: WebContents): void => {
webContents.on('did-finish-load', () => {
void applyStateToWebContents(webContents, sessionState.get(session) ?? state, {broadcastResolved: true});
});
if (!ipcMain.eventNames().includes('spellcheck-get-state')) {
if (!rendererSpellcheckHandlersRegistered) {
rendererSpellcheckHandlersRegistered = true;
ipcMain.handle('spellcheck-get-state', (event) => {
const targetSession = event.sender.session;
const next = applyLaunchSpellcheckMode(sessionState.get(targetSession) ?? {...defaultState});
+40 -1
View File
@@ -58,7 +58,10 @@ class FakeWebContents {
}
class FakeBrowserWindow {
static last = null;
constructor(options) {
FakeBrowserWindow.last = this;
this.options = options;
this.webContents = new FakeWebContents();
this.listeners = new Map();
@@ -393,6 +396,25 @@ describe('splash window configuration', () => {
assert.equal(window.shownInactive, 1);
});
test('a preloaded splash loads hidden, stays hidden once ready, and reveals on demand', async () => {
const {preloadSplashWindow, revealPreloadedSplashWindow} = await loadSplashWindow('preload');
const {timers} = withPlatformTimers('darwin', () => preloadSplashWindow());
const splash = FakeBrowserWindow.last;
assert.equal(timers.length, 0);
assert.equal(splash.visible, false);
assert.equal(splash.skipTaskbar, true);
assert.equal(revealPreloadedSplashWindow(), null);
emitIpc('desktop-splash:ready', {sender: splash.webContents});
assert.equal(splash.visible, false);
assert.equal(revealPreloadedSplashWindow(), splash);
assert.equal(splash.visible, true);
assert.equal(splash.skipTaskbar, false);
assert.equal(revealPreloadedSplashWindow(), null);
});
test('a preload that never reports ready still gets shown by the watchdog', async () => {
const {openSplashWindow, SPLASH_READY_WATCHDOG_MS} = await loadSplashWindow('show-watchdog');
@@ -1094,7 +1116,7 @@ class StubNode {
}
}
function createPreloadHarness() {
function createPreloadHarness(options = {}) {
const sent = [];
const ipcListeners = new Map();
const windowListeners = new Map();
@@ -1151,6 +1173,7 @@ function createPreloadHarness() {
}
},
window: {
location: {search: options.search ?? ''},
addEventListener: (eventName, listener) => {
windowListeners.set(eventName, listener);
},
@@ -1176,6 +1199,7 @@ function createPreloadHarness() {
select.dispatch('change');
},
sendState: (payload) => ipcListeners.get('desktop-splash:state')({}, payload),
reveal: () => ipcListeners.get('desktop-splash:revealed')({}),
sent,
channels: () => sent.map((entry) => entry.channel),
frames,
@@ -1207,6 +1231,21 @@ function styleRule(selector) {
}
describe('splash preload rendering', () => {
test('a held splash waits for its reveal before timing the diagnostics links', async () => {
const diagnosticsTimers = (harness) =>
harness.timeouts.filter((timer) => timer.delayMs === 30000 && !timer.cleared);
const shown = createPreloadHarness();
await shown.start();
assert.equal(diagnosticsTimers(shown).length, 1);
const held = createPreloadHarness({search: '?held=1'});
await held.start();
assert.equal(diagnosticsTimers(held).length, 0);
held.reveal();
assert.equal(diagnosticsTimers(held).length, 1);
});
test('renders before it reports ready, so the first paint is never a blank frame', async () => {
const harness = createPreloadHarness();
+67 -11
View File
@@ -22,6 +22,7 @@ export const SPLASH_READY_WATCHDOG_MS = 3000;
export const SPLASH_CLOSE_DELAY_MS = 100;
export const DESKTOP_SPLASH_STATE_CHANNEL = 'desktop-splash:state';
export const DESKTOP_SPLASH_REVEALED_CHANNEL = 'desktop-splash:revealed';
export const DESKTOP_SPLASH_READY_CHANNEL = 'desktop-splash:ready';
export const DESKTOP_SPLASH_RETRY_NOW_CHANNEL = 'desktop-splash:retry-now';
export const DESKTOP_SPLASH_QUIT_CHANNEL = 'desktop-splash:quit';
@@ -142,6 +143,9 @@ let affordanceLatched = false;
let lastSerializedState: SerializedSplashState | null = null;
let readyWatchdog: NodeJS.Timeout | null = null;
let themeSourceBeforeSplash: typeof nativeTheme.themeSource | null = null;
let darkThemePendingShow = false;
let splashHeldHidden = false;
let splashDocumentReady = false;
function toSplashCount(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
@@ -273,11 +277,13 @@ function showSplashWindow(reason: 'ready' | 'watchdog'): void {
clearTimeout(readyWatchdog);
readyWatchdog = null;
}
if (splashHeldHidden) return;
const window = splashWindow;
if (window == null || window.isDestroyed() || window.isVisible()) return;
if (reason === 'watchdog') {
logger.warn('The splash preload never reported ready, showing the window anyway');
}
applySplashTheme();
window.showInactive();
}
@@ -286,6 +292,7 @@ function registerSplashIpc(): void {
splashIpcRegistered = true;
ipcMain.on(DESKTOP_SPLASH_READY_CHANNEL, (event) => {
if (!isSplashSender(event)) return;
splashDocumentReady = true;
showSplashWindow('ready');
if (lastSerializedState != null) {
event.sender.send(DESKTOP_SPLASH_STATE_CHANNEL, lastSerializedState);
@@ -334,11 +341,42 @@ function registerSplashDiagnostics(window: BrowserWindow): void {
});
}
export function openSplashWindow(): BrowserWindow {
if (splashWindow != null && !splashWindow.isDestroyed()) return splashWindow;
export function openSplashWindow(options: {readonly darkThemeOnShow?: boolean} = {}): BrowserWindow {
const existing = splashWindow;
if (existing != null && !existing.isDestroyed()) {
if (splashHeldHidden) {
splashHeldHidden = false;
existing.setSkipTaskbar(false);
if (splashDocumentReady) showSplashWindow('ready');
}
return existing;
}
return createSplashWindow(options.darkThemeOnShow === true, false);
}
export function preloadSplashWindow(): void {
if (splashWindow != null && !splashWindow.isDestroyed()) return;
createSplashWindow(true, true);
}
export function revealPreloadedSplashWindow(): BrowserWindow | null {
const window = splashWindow;
if (!splashHeldHidden || !splashDocumentReady || window == null || window.isDestroyed()) return null;
splashHeldHidden = false;
window.setSkipTaskbar(false);
window.webContents.send(DESKTOP_SPLASH_REVEALED_CHANNEL);
applySplashTheme();
window.show();
window.focus();
return window;
}
function createSplashWindow(darkThemeOnShow: boolean, heldHidden: boolean): BrowserWindow {
registerSplashIpc();
if (themeSourceBeforeSplash == null) themeSourceBeforeSplash = nativeTheme.themeSource;
nativeTheme.themeSource = 'dark';
darkThemePendingShow = true;
splashHeldHidden = heldHidden;
splashDocumentReady = false;
if (!darkThemeOnShow) applySplashTheme();
const window = new BrowserWindow({
width: SPLASH_WINDOW_WIDTH,
height: getSplashWindowHeight(process.platform),
@@ -368,19 +406,26 @@ export function openSplashWindow(): BrowserWindow {
window.on('closed', () => {
if (splashWindow === window) {
splashWindow = null;
splashHeldHidden = false;
splashDocumentReady = false;
}
restoreThemeSource();
if (!shouldQuitOnSplashClosed(process.platform, launchLatched)) return;
logger.info('The splash window closed before launch, quitting');
app.quit();
});
readyWatchdog = setTimeout(() => {
readyWatchdog = null;
showSplashWindow('watchdog');
}, SPLASH_READY_WATCHDOG_MS);
readyWatchdog.unref();
const documentUrl = pathToFileURL(getDesktopDistributionPath('splash', 'index.html')).href;
window.loadURL(documentUrl).catch((error) => {
if (heldHidden) {
window.setSkipTaskbar(true);
} else {
readyWatchdog = setTimeout(() => {
readyWatchdog = null;
showSplashWindow('watchdog');
}, SPLASH_READY_WATCHDOG_MS);
readyWatchdog.unref();
}
const documentUrl = pathToFileURL(getDesktopDistributionPath('splash', 'index.html'));
if (heldHidden) documentUrl.searchParams.set('held', '1');
window.loadURL(documentUrl.href).catch((error) => {
logger.error('Failed to load the splash document', error);
});
return window;
@@ -392,11 +437,20 @@ export function focusSplashWindow(): void {
if (window.isMinimized()) {
window.restore();
}
applySplashTheme();
window.show();
window.focus();
}
function applySplashTheme(): void {
if (!darkThemePendingShow) return;
darkThemePendingShow = false;
if (themeSourceBeforeSplash == null) themeSourceBeforeSplash = nativeTheme.themeSource;
nativeTheme.themeSource = 'dark';
}
function restoreThemeSource(): void {
darkThemePendingShow = false;
if (themeSourceBeforeSplash == null) return;
nativeTheme.themeSource = themeSourceBeforeSplash;
themeSourceBeforeSplash = null;
@@ -405,6 +459,8 @@ function restoreThemeSource(): void {
export function closeSplashWindow(): void {
const window = splashWindow;
splashWindow = null;
splashHeldHidden = false;
splashDocumentReady = false;
restoreThemeSource();
lastSerializedState = null;
affordanceLatched = false;
+108 -443
View File
@@ -2,7 +2,7 @@
import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import {chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {createServer} from 'node:http';
import {createRequire} from 'node:module';
import {tmpdir} from 'node:os';
@@ -34,14 +34,6 @@ const shellDownloadFormatsSource = transform('ShellDownloadFormats.ts');
const shellUpdateCapabilitySource = transform('ShellUpdateCapability.ts');
const updaterDownloadsSource = transform('UpdaterDownloads.ts');
const updaterSource = transform('Updater.ts');
const updaterPlatformUtilsSource = transform('../../../fluxer_app/src/features/app/utils/UpdaterPlatformUtils.ts');
function loadUpdaterPlatformUtils() {
const module = {exports: {}};
const context = vm.createContext({module, exports: module.exports, require});
vm.runInContext(updaterPlatformUtilsSource.code, context, {filename: updaterPlatformUtilsSource.path});
return module.exports;
}
const INSTALLED_NAME = 'My Fluxer.AppImage';
const CURRENT_VERSION = '2026.903.231208';
@@ -52,9 +44,7 @@ const NEW_SHA256 = createHash('sha256').update(NEW_BYTES).digest('hex');
let server;
let baseUrl;
let serveCorruptBytes = false;
let appImageRequests = 0;
let failAppImageRequests = 0;
before(async () => {
server = createServer((request, response) => {
@@ -75,15 +65,8 @@ before(async () => {
}
if (request.url === `/${PUBLISHED_VERSION}/appimage`) {
appImageRequests += 1;
if (failAppImageRequests > 0) {
failAppImageRequests -= 1;
response.writeHead(503);
response.end('try again');
return;
}
const body = serveCorruptBytes ? Buffer.concat([Buffer.from([0]), NEW_BYTES.subarray(1)]) : NEW_BYTES;
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': body.length});
response.end(body);
response.writeHead(200, {'content-type': 'application/octet-stream', 'content-length': NEW_BYTES.length});
response.end(NEW_BYTES);
return;
}
response.writeHead(404);
@@ -97,74 +80,38 @@ after(async () => {
await new Promise((resolve) => server.close(resolve));
});
function createInstall() {
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
const applications = join(root, 'Applications');
mkdirSync(applications);
const mount = join(root, '.mount_Fluxer');
mkdirSync(mount);
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const installedPath = join(applications, INSTALLED_NAME);
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
return {applications, installedPath, mount};
}
function loadUpdater({
appImagePath,
version = CURRENT_VERSION,
appDir,
execPath,
stagingErrno,
platform = 'linux',
arch = 'arm64',
velopack,
applyAttempt = null,
packaged = true,
flatpak = false,
gateCheck = async () => ({available: true}),
}) {
const events = [];
const handlers = new Map();
const appEvents = new Map();
const state = {relaunched: false, electronRelaunched: false};
const applyState = {attempt: applyAttempt, recorded: [], cleared: 0};
const gate = {checks: 0};
const module = {exports: {}};
const stubs = {
'@electron/main/UpdaterApplyState': {
readVelopackApplyAttempt: () => applyState.attempt,
recordVelopackApplyAttempt: (recordedVersion) => {
applyState.recorded.push(recordedVersion);
applyState.attempt = {version: recordedVersion, attemptedAt: 0};
},
clearVelopackApplyAttempt: () => {
applyState.cleared += 1;
applyState.attempt = null;
},
},
'@electron/common/BuildChannel': {BUILD_CHANNEL: 'canary'},
'@electron/common/Constants': {DOWNLOAD_PAGE_URLS: {canary: 'https://canary.fluxer.app/download'}},
'@electron/common/DesktopIdentity': {DESKTOP_ARTIFACT_PRODUCT_NAME: 'Fluxer-Canary'},
'@electron/common/UserDataPath': {isPortableMode: () => false},
'@electron/main/DesktopTray': {destroyDesktopTray() {}},
'@electron/main/ModuleNetworkFetch': {moduleNetworkFetch: (input, init) => sandbox.fetch(input, init)},
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => false},
'@electron/main/Troubleshooting': {
relaunchAndExit() {
state.relaunched = true;
'@electron/main/DesktopUpdateGate': {
checkDesktopUpdateNow: async () => {
gate.checks += 1;
return await gateCheck();
},
},
'@electron/main/Window': {setQuitting() {}},
'@electron/main/LinuxSandbox': {isFlatpakRuntime: () => flatpak},
'electron-log': {info() {}, warn() {}, error() {}, debug() {}},
electron: {
app: {
isPackaged: true,
isPackaged: packaged,
getVersion: () => version,
on(event, handler) {
appEvents.set(event, handler);
},
relaunch() {
state.electronRelaunched = true;
},
exit() {},
},
autoUpdater: {on() {}},
net: {fetch: (input, init) => sandbox.fetch(input, init)},
ipcMain: {
handle(channel, handler) {
@@ -176,11 +123,12 @@ function loadUpdater({
const sandbox = {
console,
Buffer,
URL,
process: {
...process,
platform,
arch,
execPath: execPath ?? (appDir ? join(appDir, 'fluxer-canary') : process.execPath),
execPath: appDir ? join(appDir, 'fluxer-canary') : process.execPath,
env: {
...(appImagePath ? {APPIMAGE: appImagePath} : {}),
...(appDir ? {APPDIR: appDir} : {}),
@@ -193,56 +141,21 @@ function loadUpdater({
const url = String(input).replace(/https:\/\/pkgs\.fluxer\.com\/desktop\/canary\/[^/]+\/[^/]+/, baseUrl);
return fetch(url, init);
},
require: (specifier) => {
if (specifier === 'node:module' && velopack) {
return {
...require('node:module'),
createRequire: () => (name) => (name === 'velopack' ? velopack : require(name)),
};
}
if (specifier === 'node:fs/promises' && stagingErrno) {
return {
...require(specifier),
open: async () => {
const error = new Error(`${stagingErrno}: staging refused`);
error.code = stagingErrno;
throw error;
},
};
}
return stubs[specifier] ?? require(specifier);
},
require: (specifier) => stubs[specifier] ?? require(specifier),
};
const context = vm.createContext(sandbox);
const appImageModule = {exports: {}};
sandbox.module = appImageModule;
sandbox.exports = appImageModule.exports;
sandbox.__filename = appImageUpdateSource.path;
vm.runInContext(appImageUpdateSource.code, context, {filename: appImageUpdateSource.path});
stubs['@electron/main/AppImageUpdate'] = appImageModule.exports;
const shellUpdateCapabilityModule = {exports: {}};
sandbox.module = shellUpdateCapabilityModule;
sandbox.exports = shellUpdateCapabilityModule.exports;
sandbox.__filename = shellUpdateCapabilitySource.path;
vm.runInContext(shellUpdateCapabilitySource.code, context, {filename: shellUpdateCapabilitySource.path});
stubs['@electron/main/ShellUpdateCapability'] = shellUpdateCapabilityModule.exports;
const shellDownloadFormatsModule = {exports: {}};
sandbox.module = shellDownloadFormatsModule;
sandbox.exports = shellDownloadFormatsModule.exports;
sandbox.__filename = shellDownloadFormatsSource.path;
vm.runInContext(shellDownloadFormatsSource.code, context, {filename: shellDownloadFormatsSource.path});
stubs['@electron/main/ShellDownloadFormats'] = shellDownloadFormatsModule.exports;
const updaterDownloadsModule = {exports: {}};
sandbox.module = updaterDownloadsModule;
sandbox.exports = updaterDownloadsModule.exports;
sandbox.__filename = updaterDownloadsSource.path;
vm.runInContext(updaterDownloadsSource.code, context, {filename: updaterDownloadsSource.path});
stubs['@electron/main/UpdaterDownloads'] = updaterDownloadsModule.exports;
const load = (source, specifier) => {
const loaded = {exports: {}};
sandbox.module = loaded;
sandbox.exports = loaded.exports;
sandbox.__filename = source.path;
vm.runInContext(source.code, context, {filename: source.path});
stubs[specifier] = loaded.exports;
};
load(appImageUpdateSource, '@electron/main/AppImageUpdate');
load(shellUpdateCapabilitySource, '@electron/main/ShellUpdateCapability');
load(shellDownloadFormatsSource, '@electron/main/ShellDownloadFormats');
load(updaterDownloadsSource, '@electron/main/UpdaterDownloads');
sandbox.module = module;
sandbox.exports = module.exports;
sandbox.__filename = updaterSource.path;
@@ -250,12 +163,10 @@ function loadUpdater({
module.exports.registerUpdater(() => ({webContents: {send: (_channel, event) => events.push(event)}}));
return {
applyState,
events,
state,
check: () => handlers.get('updater-check')({}, 'user'),
install: () => handlers.get('updater-install')({}),
quit: () => appEvents.get('will-quit')?.(),
gate,
channels: () => [...handlers.keys()],
check: (context = 'user') => handlers.get('updater-check')({}, context),
};
}
@@ -263,359 +174,113 @@ function types(events) {
return events.map((event) => event.type);
}
function stagingLeftovers(directory) {
return readdirSync(directory).filter((entry) => entry.startsWith('.fluxer-update-'));
function createInstall() {
const root = mkdtempSync(join(tmpdir(), 'fluxer-updater-test-'));
const applications = join(root, 'Applications');
mkdirSync(applications);
const mount = join(root, '.mount_Fluxer');
mkdirSync(mount);
writeFileSync(join(mount, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const installedPath = join(applications, INSTALLED_NAME);
writeFileSync(installedPath, OLD_BYTES, {mode: 0o755});
return {applications, installedPath, mount};
}
describe('Updater AppImage lifecycle', () => {
test('downloads on check, reports the macOS lifecycle, and swaps in place', async () => {
describe('Updater on a shell that updates itself', () => {
test('registers only the check, never a download or an install channel', () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
await updater.check();
const available = updater.events.find((event) => event.type === 'available');
assert.equal(available.downloadStarted, true);
assert.equal(available.version, PUBLISHED_VERSION);
assert.equal(available.downloadUrl, undefined);
assert.equal(available.downloadOptions, undefined);
const progress = updater.events.filter((event) => event.type === 'progress');
assert.ok(progress.length > 0);
assert.equal(progress.at(-1).total, NEW_BYTES.length);
assert.equal(progress.at(-1).transferred, NEW_BYTES.length);
assert.equal(progress.at(-1).percent, 100);
const downloaded = updater.events.find((event) => event.type === 'downloaded');
assert.equal(downloaded.version, PUBLISHED_VERSION);
assert.deepEqual(
types(updater.events).filter((type) => type !== 'progress'),
['checking', 'available', 'downloaded'],
);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
await updater.install();
assert.equal(updater.state.relaunched, true);
assert.equal(
updater.state.electronRelaunched,
false,
"app.relaunch cannot start an AppImage: Electron's relauncher runs under PR_SET_NO_NEW_PRIVS and the setuid fusermount the AppImage runtime needs fails with EPERM",
);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true);
assert.deepEqual(updater.channels(), ['updater-check']);
});
test('refuses to swap when the download does not match the published sha256', async () => {
test('a background check downloads nothing and leaves the poll to the bootstrap', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
serveCorruptBytes = true;
try {
await updater.check();
} finally {
serveCorruptBytes = false;
}
appImageRequests = 0;
assert.deepEqual(
types(updater.events).filter((type) => type !== 'progress'),
['checking', 'available', 'error', 'available'],
);
const failure = updater.events.find((event) => event.type === 'error');
assert.equal(failure.phase, 'download');
assert.match(failure.message, /checksum mismatch/);
const fallback = updater.events.at(-1);
assert.equal(fallback.downloadStarted, false);
assert.ok(fallback.downloadOptions.length > 0);
await updater.check('background');
await updater.check('focus');
assert.equal(updater.gate.checks, 0);
assert.deepEqual(updater.events, []);
assert.equal(appImageRequests, 0);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
assert.deepEqual(stagingLeftovers(install.applications), []);
await assert.rejects(updater.install(), /No AppImage update is ready to install/);
});
test('offers the manual download when the install directory is not writable', {
skip: process.getuid?.() === 0,
}, async () => {
test('a user check asks the manifest and leaves the update itself to the click', async () => {
const install = createInstall();
chmodSync(install.applications, 0o500);
try {
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
appImageRequests = 0;
await updater.check();
await updater.check('user');
assert.deepEqual(types(updater.events), ['checking', 'available']);
const available = updater.events.at(-1);
assert.equal(available.downloadStarted, false);
assert.equal(available.version, PUBLISHED_VERSION);
assert.ok(available.downloadOptions.length > 0);
assert.equal(available.downloadOptions[0].format, 'appimage');
assert.equal(
available.downloadOptions[0].suggestedName,
`Fluxer-Canary-${PUBLISHED_VERSION}-linux-arm64.AppImage`,
);
} finally {
chmodSync(install.applications, 0o755);
}
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
assert.equal(updater.gate.checks, 1);
assert.deepEqual(types(updater.events), ['not-available']);
assert.equal(appImageRequests, 0);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('falls back to the manual updater when APPIMAGE is unset', async () => {
test('a user check that cannot reach the manifest says so', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
gateCheck: async () => {
throw new Error('manifest unreachable');
},
});
await updater.check('user');
assert.deepEqual(types(updater.events), ['error']);
assert.match(updater.events[0].message, /manifest unreachable/);
});
test('macOS and Windows go through the same check', async () => {
for (const platform of ['darwin', 'win32']) {
const updater = loadUpdater({platform, arch: 'x64'});
await updater.check('user');
assert.equal(updater.gate.checks, 1, platform);
assert.deepEqual(updater.channels(), ['updater-check'], platform);
}
});
});
describe('Updater on a shell that cannot update itself', () => {
test('a Linux package install offers the newer package for manual download', async () => {
const updater = loadUpdater({appImagePath: null});
await updater.check();
await updater.check('user');
assert.equal(updater.gate.checks, 1, 'the renderer module can update even when the shell cannot');
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events.at(-1).downloadStarted, false);
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
const available = updater.events.at(-1);
assert.equal(available.version, PUBLISHED_VERSION);
assert.equal(Object.hasOwn(available, 'downloadStarted'), false);
assert.ok(available.downloadOptions.some((option) => option.format === 'deb'));
});
test('never rewrites an inherited APPIMAGE that this process did not come from', async () => {
const install = createInstall();
const victim = join(install.applications, 'Kdenlive-24.12.AppImage');
writeFileSync(victim, OLD_BYTES, {mode: 0o755});
const optDir = mkdtempSync(join(tmpdir(), 'fluxer-opt-test-'));
const updater = loadUpdater({appImagePath: victim, execPath: join(optDir, 'fluxer')});
test('a Linux package install that is current reports no update', async () => {
const updater = loadUpdater({appImagePath: null, version: PUBLISHED_VERSION});
await updater.check();
await updater.check('user');
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events.at(-1).downloadStarted, false);
assert.equal(readFileSync(victim).equals(OLD_BYTES), true);
assert.deepEqual(stagingLeftovers(install.applications), []);
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
});
test('never rewrites the AppImage that only the shell around this process came from', async () => {
const install = createInstall();
const victim = join(install.applications, 'Kdenlive-24.12.AppImage');
writeFileSync(victim, OLD_BYTES, {mode: 0o755});
const extracted = join(mkdtempSync(join(tmpdir(), 'fluxer-extracted-test-')), 'squashfs-root');
mkdirSync(extracted);
writeFileSync(join(extracted, 'AppRun'), '#!/usr/bin/env bash\n', {mode: 0o755});
const updater = loadUpdater({
appImagePath: victim,
appDir: install.mount,
execPath: join(extracted, 'fluxer-canary'),
});
await updater.check();
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events.at(-1).downloadStarted, false);
assert.equal(readFileSync(victim).equals(OLD_BYTES), true);
assert.deepEqual(stagingLeftovers(install.applications), []);
await assert.rejects(updater.install(), /In-app updates are not supported on this platform/);
});
test('gives up on a local write failure instead of downloading the AppImage again', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
stagingErrno: 'ENOSPC',
});
const before = appImageRequests;
await updater.check();
assert.equal(appImageRequests - before, 1, 'a full disk must not pull the whole AppImage down five more times');
const failure = updater.events.find((event) => event.type === 'error');
assert.equal(failure.phase, 'download');
assert.match(failure.message, /ENOSPC/);
assert.equal(updater.events.at(-1).type, 'available');
assert.equal(updater.events.at(-1).downloadStarted, false);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('downloads the AppImage again after a transient server failure', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
const before = appImageRequests;
failAppImageRequests = 1;
try {
await updater.check();
} finally {
failAppImageRequests = 0;
}
assert.equal(appImageRequests - before, 2);
assert.equal(updater.events.at(-1).type, 'downloaded');
await updater.install();
assert.equal(readFileSync(install.installedPath).equals(NEW_BYTES), true);
});
test('reports no update when the published version is not newer', async () => {
const install = createInstall();
const updater = loadUpdater({
appImagePath: install.installedPath,
appDir: install.mount,
version: PUBLISHED_VERSION,
});
await updater.check();
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
});
test('reclaims a staged update abandoned by a process that never reached will-quit', async () => {
const install = createInstall();
const abandoned = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
await abandoned.check();
assert.equal(stagingLeftovers(install.applications).length, 1);
loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
assert.deepEqual(stagingLeftovers(install.applications), []);
assert.deepEqual(readdirSync(install.applications), [INSTALLED_NAME]);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
});
test('shows the renderer the download progress the main process reports', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
const {shouldShowNativeDesktopUpdateDownloadProgress} = loadUpdaterPlatformUtils();
await updater.check();
const reportsProgress = updater.events.some((event) => event.type === 'progress');
assert.equal(reportsProgress, true);
assert.equal(shouldShowNativeDesktopUpdateDownloadProgress('linux'), reportsProgress);
});
test('discards a staged update that the user never installed', async () => {
const install = createInstall();
const updater = loadUpdater({appImagePath: install.installedPath, appDir: install.mount});
await updater.check();
assert.equal(stagingLeftovers(install.applications).length, 1);
updater.quit();
assert.deepEqual(stagingLeftovers(install.applications), []);
assert.equal(readFileSync(install.installedPath).equals(OLD_BYTES), true);
});
});
function createVelopackStub({installedVersion, pendingRestart = null, remoteUpdate = null}) {
const applied = [];
class UpdateManager {
getCurrentVersion() {
return installedVersion;
}
getUpdatePendingRestart() {
return pendingRestart;
}
checkForUpdatesAsync() {
return Promise.resolve(remoteUpdate);
}
downloadUpdateAsync() {
return Promise.resolve();
}
waitExitThenApplyUpdate(update) {
applied.push(update);
}
}
return {applied, module: {UpdateManager}};
}
function loadWindowsUpdater({installedVersion, pendingRestart, remoteUpdate, applyAttempt = null}) {
const velopack = createVelopackStub({installedVersion, pendingRestart, remoteUpdate});
const updater = loadUpdater({
platform: 'win32',
arch: 'x64',
velopack: velopack.module,
applyAttempt,
});
return {...updater, applied: velopack.applied};
}
describe('Updater Windows apply failures', () => {
test('offers the installer when a downloaded update never applied', async () => {
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
});
await updater.check();
assert.deepEqual(types(updater.events), ['checking', 'error', 'available']);
assert.equal(updater.events[1].phase, 'install');
assert.ok(updater.events[1].message.includes(PUBLISHED_VERSION));
assert.equal(updater.events[2].downloadStarted, false);
assert.equal(updater.events[2].downloadUrl, `${baseUrl}/setup`);
assert.equal(updater.applyState.attempt.version, PUBLISHED_VERSION);
});
test('keeps offering the installer when no newer release than the failed one is out', async () => {
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
remoteUpdate: {Version: PUBLISHED_VERSION, Size: 100},
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
});
await updater.check();
assert.deepEqual(types(updater.events), ['checking', 'error', 'available']);
assert.equal(updater.events[2].downloadStarted, false);
assert.equal(updater.applyState.cleared, 0);
});
test('retries the in-app update when a newer release than the failed one is out', async () => {
const newerVersion = '2026.905.101010';
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
remoteUpdate: {Version: newerVersion, Size: 200},
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
});
await updater.check();
assert.equal(updater.applyState.cleared, 1);
assert.equal(updater.applyState.attempt, null);
assert.deepEqual(types(updater.events), ['checking', 'available']);
assert.equal(updater.events[1].version, newerVersion);
assert.equal(updater.events[1].downloadUrl, undefined);
});
test('resumes normal updates once the installed version catches up', async () => {
const updater = loadWindowsUpdater({
installedVersion: PUBLISHED_VERSION,
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
});
await updater.check();
assert.equal(updater.applyState.cleared, 1);
assert.equal(updater.applyState.attempt, null);
assert.deepEqual(types(updater.events), ['checking', 'not-available']);
});
test('records the version it hands to the updater before quitting', async () => {
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
});
test('Flatpak and development builds point at their own update path', async () => {
const flatpak = loadUpdater({appImagePath: null, flatpak: true});
await flatpak.check('user');
assert.deepEqual(JSON.parse(JSON.stringify(flatpak.events)), [
{type: 'unsupported', context: 'user', reason: 'managed-package'},
]);
await updater.install();
assert.deepEqual(updater.applyState.recorded, [PUBLISHED_VERSION]);
assert.deepEqual(updater.applied, [{Version: PUBLISHED_VERSION, Size: 100}]);
});
test('refuses to re-apply a version that already failed to install', async () => {
const updater = loadWindowsUpdater({
installedVersion: CURRENT_VERSION,
pendingRestart: {Version: PUBLISHED_VERSION, Size: 100},
applyAttempt: {version: PUBLISHED_VERSION, attemptedAt: 0},
});
await assert.rejects(() => updater.install(), /Download the installer/);
assert.deepEqual(updater.applied, []);
assert.deepEqual(updater.applyState.recorded, []);
const unpackaged = loadUpdater({appImagePath: null, packaged: false});
await unpackaged.check('user');
assert.equal(unpackaged.events[0].reason, 'unpackaged');
});
});
+22 -640
View File
@@ -1,30 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createRequire} from 'node:module';
import {
AppImageChecksumError,
AppImageStagingError,
type AppImageTarget,
applyStagedAppImageUpdate,
discardStagedAppImageUpdate,
resolveAppImageTarget,
type StagedAppImageUpdate,
stageAppImageUpdate,
sweepAbandonedAppImageUpdates,
} from '@electron/main/AppImageUpdate';
import {destroyDesktopTray} from '@electron/main/DesktopTray';
import {moduleNetworkFetch} from '@electron/main/ModuleNetworkFetch';
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
import {MANUAL_DESKTOP_FORMATS, type ManualDesktopFormat} from '@electron/main/ShellDownloadFormats';
import {resolveShellUpdatePlan, ShellUpdateCapability} from '@electron/main/ShellUpdateCapability';
import {relaunchAndExit} from '@electron/main/Troubleshooting';
import {
clearVelopackApplyAttempt,
readVelopackApplyAttempt,
recordVelopackApplyAttempt,
type VelopackApplyAttempt,
} from '@electron/main/UpdaterApplyState';
import {
buildManualVersionDownloadUrl,
DOWNLOAD_PAGE_URL,
getManualDownloadOptions,
getManualDownloadUrl,
@@ -33,10 +12,8 @@ import {
UPDATE_BASE_URL,
type UpdaterDownloadOption,
} from '@electron/main/UpdaterDownloads';
import {setQuitting} from '@electron/main/Window';
import {app, autoUpdater, type BrowserWindow, ipcMain, net} from 'electron';
import {app, type BrowserWindow, ipcMain, net} from 'electron';
import log from 'electron-log';
import type {UpdateInfo, VelopackAsset} from 'velopack';
type UpdaterContext = 'user' | 'background' | 'focus';
type UpdaterEvent =
@@ -48,8 +25,6 @@ type UpdaterEvent =
type: 'available';
context: UpdaterContext;
version?: string | null;
downloadSize?: number | null;
downloadStarted: boolean;
downloadUrl?: string;
downloadOptions?: Array<UpdaterDownloadOption>;
}
@@ -57,24 +32,10 @@ type UpdaterEvent =
type: 'not-available';
context: UpdaterContext;
}
| {
type: 'downloaded';
context: UpdaterContext;
version?: string | null;
}
| {
type: 'progress';
context: UpdaterContext;
percent: number;
transferred: number;
total: number;
bytesPerSecond: number;
}
| {
type: 'error';
context: UpdaterContext;
message: string;
phase?: 'check' | 'download' | 'install';
}
| {
type: 'unsupported';
@@ -83,27 +44,6 @@ type UpdaterEvent =
downloadUrl?: string;
};
const requireModule = createRequire(import.meta.url);
let lastContext: UpdaterContext = 'background';
type VelopackUpdate = UpdateInfo | VelopackAsset;
let pendingVelopackUpdate: VelopackUpdate | null = null;
let velopackCheckPromise: Promise<void> | null = null;
let velopackDownloadPromise: Promise<void> | null = null;
let velopackInstallStarted = false;
let pendingAppImageUpdate: PendingAppImageUpdate | null = null;
let appImageUpdatePromise: Promise<void> | null = null;
let appImageInstallStarted = false;
const UPDATE_DOWNLOAD_MAX_ATTEMPTS = 5;
const UPDATE_DOWNLOAD_RETRY_BASE_DELAY_MS = 3000;
const UPDATE_DOWNLOAD_RETRY_MAX_DELAY_MS = 60000;
const ELECTRON_DOWNLOAD_MAX_RETRIES = 4;
const UPDATE_PROGRESS_SAMPLE_INTERVAL_MS = 500;
type PendingAppImageUpdate = {version: string; target: AppImageTarget; staged: StagedAppImageUpdate};
function send(win: BrowserWindow | null, event: UpdaterEvent) {
win?.webContents.send('updater-event', event);
}
@@ -119,387 +59,6 @@ function getErrorMessage(error: unknown): string {
return String(error);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function backoffDelay(attempt: number): number {
const exponential = UPDATE_DOWNLOAD_RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
const capped = Math.min(exponential, UPDATE_DOWNLOAD_RETRY_MAX_DELAY_MS);
return Math.round(capped * (0.5 + Math.random() * 0.5));
}
function getVelopackAsset(update: VelopackUpdate): VelopackAsset {
return 'TargetFullRelease' in update ? update.TargetFullRelease : update;
}
function getVelopackUpdateVersion(update: VelopackUpdate): string | null {
return getVelopackAsset(update).Version ?? null;
}
function getVelopackUpdateSize(update: VelopackUpdate): number | null {
const raw = getVelopackAsset(update).Size;
if (raw == null) return null;
if (typeof raw === 'bigint') {
return Number(raw);
}
return Number(raw);
}
function createVelopackUpdateManager() {
const {UpdateManager} = requireModule('velopack') as typeof import('velopack');
return new UpdateManager(UPDATE_BASE_URL);
}
type VelopackUpdateManager = ReturnType<typeof createVelopackUpdateManager>;
function getInstalledVelopackVersion(updateManager: VelopackUpdateManager): string | null {
try {
const version = updateManager.getCurrentVersion();
return typeof version === 'string' && version.length > 0 ? version : null;
} catch (error) {
log.warn('Failed to read the installed Velopack version', error);
return null;
}
}
function resolveFailedVelopackApply(updateManager: VelopackUpdateManager): VelopackApplyAttempt | null {
const attempt = readVelopackApplyAttempt();
if (!attempt) {
return null;
}
const installedVersion = getInstalledVelopackVersion(updateManager) ?? app.getVersion();
if (compareVersions(installedVersion, attempt.version) >= 0) {
clearVelopackApplyAttempt();
return null;
}
return attempt;
}
async function sendVelopackApplyFailure(
context: UpdaterContext,
getMainWindow: () => BrowserWindow | null,
attempt: VelopackApplyAttempt,
): Promise<void> {
log.error('A downloaded update was never applied, so the installer is offered instead.', attempt);
send(getMainWindow(), {
type: 'error',
context,
phase: 'install',
message: `Fluxer could not finish installing version ${attempt.version}.`,
});
try {
const latest = await fetchManualLatest({forceRefresh: true});
sendManualUpdateAvailable(getMainWindow, context, latest);
return;
} catch (error) {
log.warn('Failed to resolve the installer download after a failed update apply', error);
}
send(getMainWindow(), {
type: 'available',
context,
version: attempt.version,
downloadSize: null,
downloadStarted: false,
downloadUrl: buildManualVersionDownloadUrl(attempt.version, 'setup'),
});
}
async function checkVelopackForUpdates(
context: UpdaterContext,
getMainWindow: () => BrowserWindow | null,
): Promise<void> {
if (velopackCheckPromise) {
return velopackCheckPromise;
}
velopackCheckPromise = (async () => {
try {
send(getMainWindow(), {type: 'checking', context});
const updateManager = createVelopackUpdateManager();
const failedApply = resolveFailedVelopackApply(updateManager);
let update: Awaited<ReturnType<VelopackUpdateManager['checkForUpdatesAsync']>>;
try {
update = await updateManager.checkForUpdatesAsync();
} catch (error) {
if (!failedApply) throw error;
await sendVelopackApplyFailure(context, getMainWindow, failedApply);
return;
}
if (failedApply) {
const updateVersion = update ? getVelopackUpdateVersion(update) : null;
if (!updateVersion || compareVersions(updateVersion, failedApply.version) <= 0) {
await sendVelopackApplyFailure(context, getMainWindow, failedApply);
return;
}
log.info('A release newer than the update that failed to apply is out, retrying the in-app update', {
failedVersion: failedApply.version,
updateVersion,
});
clearVelopackApplyAttempt();
}
const pendingUpdate = failedApply ? null : updateManager.getUpdatePendingRestart();
if (!update) {
if (pendingUpdate) {
pendingVelopackUpdate = pendingUpdate;
send(getMainWindow(), {
type: 'downloaded',
context,
version: getVelopackUpdateVersion(pendingUpdate),
});
return;
}
pendingVelopackUpdate = null;
send(getMainWindow(), {type: 'not-available', context});
return;
}
if (pendingUpdate) {
const pendingVersion = getVelopackUpdateVersion(pendingUpdate);
const updateVersion = getVelopackUpdateVersion(update);
if (pendingVersion && updateVersion && compareVersions(updateVersion, pendingVersion) <= 0) {
pendingVelopackUpdate = pendingUpdate;
send(getMainWindow(), {
type: 'downloaded',
context,
version: pendingVersion,
});
return;
}
log.info('Newer Velopack update found while another update is pending restart.', {
pendingVersion,
updateVersion,
});
}
pendingVelopackUpdate = update;
send(getMainWindow(), {
type: 'available',
context,
version: getVelopackUpdateVersion(update),
downloadSize: getVelopackUpdateSize(update),
downloadStarted: false,
});
} catch (error) {
send(getMainWindow(), {type: 'error', context, phase: 'check', message: getErrorMessage(error)});
}
})().finally(() => {
velopackCheckPromise = null;
});
return velopackCheckPromise;
}
async function downloadVelopackUpdate(
context: UpdaterContext,
getMainWindow: () => BrowserWindow | null,
): Promise<void> {
if (velopackDownloadPromise) {
return velopackDownloadPromise;
}
const update = pendingVelopackUpdate;
if (!update) {
send(getMainWindow(), {
type: 'error',
context,
phase: 'download',
message: 'No update available to download. Please check for updates first.',
});
return;
}
if (!('TargetFullRelease' in update)) {
send(getMainWindow(), {type: 'downloaded', context, version: getVelopackUpdateVersion(update)});
return;
}
velopackDownloadPromise = (async () => {
const total = getVelopackUpdateSize(update) ?? 0;
let lastError: unknown;
for (let attempt = 1; attempt <= UPDATE_DOWNLOAD_MAX_ATTEMPTS; attempt += 1) {
let lastSampleAt = Date.now();
let lastSampleTransferred = 0;
let smoothedBytesPerSecond = 0;
try {
const updateManager = createVelopackUpdateManager();
await updateManager.downloadUpdateAsync(update, (percent) => {
const transferred = total > 0 ? Math.round((total * percent) / 100) : 0;
const now = Date.now();
const dtMs = now - lastSampleAt;
if (dtMs >= 500 || percent >= 100) {
if (dtMs > 0 && transferred >= lastSampleTransferred) {
const instant = ((transferred - lastSampleTransferred) * 1000) / dtMs;
smoothedBytesPerSecond =
smoothedBytesPerSecond === 0 ? instant : smoothedBytesPerSecond * 0.7 + instant * 0.3;
}
lastSampleAt = now;
lastSampleTransferred = transferred;
send(getMainWindow(), {
type: 'progress',
context,
percent,
transferred,
total,
bytesPerSecond: Math.round(smoothedBytesPerSecond),
});
}
});
send(getMainWindow(), {
type: 'downloaded',
context,
version: getVelopackUpdateVersion(update),
});
return;
} catch (error) {
lastError = error;
if (attempt >= UPDATE_DOWNLOAD_MAX_ATTEMPTS) {
break;
}
const delay = backoffDelay(attempt);
const reason = getErrorMessage(error);
const waitSeconds = Math.round(delay / 1000);
log.warn(
`Velopack update download attempt ${attempt}/${UPDATE_DOWNLOAD_MAX_ATTEMPTS} failed (${reason}); retrying in ${waitSeconds}s`,
);
await sleep(delay);
}
}
log.error('Velopack update download failed after retries', lastError);
send(getMainWindow(), {type: 'error', context, phase: 'download', message: getErrorMessage(lastError)});
})().finally(() => {
velopackDownloadPromise = null;
});
return velopackDownloadPromise;
}
function installVelopackUpdate(): void {
if (velopackInstallStarted) {
log.warn('Velopack install already in progress; ignoring duplicate request.');
return;
}
const updateManager = createVelopackUpdateManager();
const update = pendingVelopackUpdate ?? updateManager.getUpdatePendingRestart();
if (!update) {
throw new Error('No Velopack update is ready to install.');
}
if (resolveFailedVelopackApply(updateManager)) {
throw new Error('The last update could not be installed. Download the installer to update.');
}
const updateVersion = getVelopackUpdateVersion(update);
if (updateVersion) {
recordVelopackApplyAttempt(updateVersion);
}
velopackInstallStarted = true;
setQuitting(true);
destroyDesktopTray();
updateManager.waitExitThenApplyUpdate(update);
setImmediate(() => app.exit(0));
}
function registerVelopackUpdater(getMainWindow: () => BrowserWindow | null): void {
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
lastContext = context;
await checkVelopackForUpdates(context, getMainWindow);
});
ipcMain.handle('updater-download', async (_e, context: UpdaterContext) => {
lastContext = context;
await downloadVelopackUpdate(context, getMainWindow);
});
ipcMain.handle('updater-install', async () => {
installVelopackUpdate();
});
}
function registerElectronUpdater(getMainWindow: () => BrowserWindow | null): void {
let electronUpdateDownloading = false;
let electronDownloadRetries = 0;
let electronUpdateDownloaded = false;
let electronDownloadedVersion: string | null = null;
const {UpdateSourceType, updateElectronApp} = requireModule(
'update-electron-app',
) as typeof import('update-electron-app');
updateElectronApp({
updateSource: {
type: UpdateSourceType.StaticStorage,
baseUrl: UPDATE_BASE_URL,
},
updateInterval: '12 hours',
logger: log,
notifyUser: false,
});
autoUpdater.on('checking-for-update', () => {
send(getMainWindow(), {type: 'checking', context: lastContext});
});
const sendPendingRestart = () => {
send(getMainWindow(), {type: 'downloaded', context: lastContext, version: electronDownloadedVersion});
};
autoUpdater.on('update-available', () => {
if (electronUpdateDownloaded) {
sendPendingRestart();
return;
}
electronUpdateDownloading = true;
send(getMainWindow(), {
type: 'available',
context: lastContext,
version: null,
downloadSize: null,
downloadStarted: true,
});
});
autoUpdater.on('update-not-available', () => {
if (electronUpdateDownloaded) {
sendPendingRestart();
return;
}
send(getMainWindow(), {type: 'not-available', context: lastContext});
});
autoUpdater.on('update-downloaded', (_event, _releaseNotes, releaseName) => {
electronUpdateDownloading = false;
electronUpdateDownloaded = true;
if (releaseName) {
electronDownloadedVersion = releaseName;
}
sendPendingRestart();
});
autoUpdater.on('error', (err: Error) => {
const message = err?.message ?? String(err);
const phase: 'check' | 'download' = electronUpdateDownloading ? 'download' : 'check';
if (electronUpdateDownloading && electronDownloadRetries < ELECTRON_DOWNLOAD_MAX_RETRIES) {
electronDownloadRetries += 1;
electronUpdateDownloading = false;
const delay = backoffDelay(electronDownloadRetries);
const waitSeconds = Math.round(delay / 1000);
log.warn(
`Update download failed (attempt ${electronDownloadRetries}/${ELECTRON_DOWNLOAD_MAX_RETRIES}); retrying in ${waitSeconds}s: ${message}`,
);
setTimeout(() => {
try {
autoUpdater.checkForUpdates();
} catch (retryError) {
log.warn('Update retry check failed', retryError);
}
}, delay);
return;
}
electronUpdateDownloading = false;
if (electronUpdateDownloaded) {
sendPendingRestart();
return;
}
send(getMainWindow(), {type: 'error', context: lastContext, phase, message});
});
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
lastContext = context;
try {
autoUpdater.checkForUpdates();
} catch (error) {
send(getMainWindow(), {type: 'error', context, phase: 'check', message: getErrorMessage(error)});
}
});
ipcMain.handle('updater-download', async () => {});
ipcMain.handle('updater-install', async () => {
setQuitting(true);
autoUpdater.quitAndInstall();
});
}
let manualLatestCache: {at: number; info: ManualLatestInfo} | null = null;
const MANUAL_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -587,8 +146,6 @@ function sendManualUpdateAvailable(
type: 'available',
context,
version: latest.version,
downloadSize: null,
downloadStarted: false,
downloadUrl: getManualDownloadUrl(latest),
...(downloadOptions.length > 0 ? {downloadOptions} : {}),
});
@@ -606,185 +163,22 @@ async function checkManualUpdate(context: UpdaterContext, getMainWindow: () => B
}
} catch (error) {
log.warn('Manual update check failed', error);
send(getMainWindow(), {type: 'error', context, phase: 'check', message: getErrorMessage(error)});
send(getMainWindow(), {type: 'error', context, message: getErrorMessage(error)});
}
}
async function downloadAppImageUpdate(
context: UpdaterContext,
getMainWindow: () => BrowserWindow | null,
latest: ManualLatestInfo,
target: AppImageTarget,
expectedSha256: string,
): Promise<void> {
const version = latest.version;
const url = buildManualVersionDownloadUrl(version, 'appimage');
let lastError: unknown;
for (let attempt = 1; attempt <= UPDATE_DOWNLOAD_MAX_ATTEMPTS; attempt += 1) {
let lastSampleAt = Date.now();
let lastSampleTransferred = 0;
let smoothedBytesPerSecond = 0;
try {
const staged = await stageAppImageUpdate({
target,
url,
expectedSha256,
fetchImpl: moduleNetworkFetch,
onProgress: ({transferred, total}) => {
const now = Date.now();
const dtMs = now - lastSampleAt;
const complete = total > 0 && transferred >= total;
if (dtMs < UPDATE_PROGRESS_SAMPLE_INTERVAL_MS && !complete) {
return;
}
if (dtMs > 0 && transferred >= lastSampleTransferred) {
const instant = ((transferred - lastSampleTransferred) * 1000) / dtMs;
smoothedBytesPerSecond =
smoothedBytesPerSecond === 0 ? instant : smoothedBytesPerSecond * 0.7 + instant * 0.3;
}
lastSampleAt = now;
lastSampleTransferred = transferred;
send(getMainWindow(), {
type: 'progress',
context,
percent: total > 0 ? Math.min(100, (transferred / total) * 100) : 0,
transferred,
total,
bytesPerSecond: Math.round(smoothedBytesPerSecond),
});
},
});
if (pendingAppImageUpdate) {
discardStagedAppImageUpdate(pendingAppImageUpdate.staged);
}
pendingAppImageUpdate = {version, target, staged};
send(getMainWindow(), {type: 'downloaded', context, version});
return;
} catch (error) {
lastError = error;
if (
error instanceof AppImageChecksumError ||
error instanceof AppImageStagingError ||
attempt >= UPDATE_DOWNLOAD_MAX_ATTEMPTS
) {
break;
}
const delay = backoffDelay(attempt);
const reason = getErrorMessage(error);
const waitSeconds = Math.round(delay / 1000);
log.warn(
`AppImage update download attempt ${attempt}/${UPDATE_DOWNLOAD_MAX_ATTEMPTS} failed (${reason}), retrying in ${waitSeconds}s`,
);
await sleep(delay);
}
}
log.error('AppImage update download failed', lastError);
send(getMainWindow(), {type: 'error', context, phase: 'download', message: getErrorMessage(lastError)});
sendManualUpdateAvailable(getMainWindow, context, latest);
}
async function checkAppImageUpdate(
context: UpdaterContext,
getMainWindow: () => BrowserWindow | null,
target: AppImageTarget,
): Promise<void> {
if (appImageUpdatePromise) {
return appImageUpdatePromise;
}
appImageUpdatePromise = (async () => {
send(getMainWindow(), {type: 'checking', context});
let latest: ManualLatestInfo;
try {
latest = await fetchManualLatest({forceRefresh: context === 'user'});
} catch (error) {
log.warn('AppImage update check failed', error);
send(getMainWindow(), {type: 'error', context, phase: 'check', message: getErrorMessage(error)});
return;
}
if (pendingAppImageUpdate && compareVersions(latest.version, pendingAppImageUpdate.version) <= 0) {
send(getMainWindow(), {type: 'downloaded', context, version: pendingAppImageUpdate.version});
return;
}
if (compareVersions(latest.version, app.getVersion()) <= 0) {
send(getMainWindow(), {type: 'not-available', context});
return;
}
const published = latest.files.appimage;
const resolved = resolveAppImageTarget(target.installedPath);
if (!resolved.ok || !published?.sha256) {
log.info('AppImage cannot be replaced in place, so the manual download is offered instead.', {
reason: resolved.ok ? 'published-checksum-missing' : resolved.reason,
});
sendManualUpdateAvailable(getMainWindow, context, latest);
return;
}
send(getMainWindow(), {
type: 'available',
context,
version: latest.version,
downloadSize: null,
downloadStarted: true,
});
await downloadAppImageUpdate(context, getMainWindow, latest, resolved.target, published.sha256);
})().finally(() => {
appImageUpdatePromise = null;
});
return appImageUpdatePromise;
}
function installAppImageUpdate(getMainWindow: () => BrowserWindow | null): void {
if (appImageInstallStarted) {
log.warn('AppImage install already in progress, ignoring the duplicate request.');
return;
}
const pending = pendingAppImageUpdate;
if (!pending) {
throw new Error('No AppImage update is ready to install.');
}
try {
applyStagedAppImageUpdate(pending.target, pending.staged);
} catch (error) {
log.error('AppImage update install failed', error);
pendingAppImageUpdate = null;
send(getMainWindow(), {type: 'error', context: lastContext, phase: 'install', message: getErrorMessage(error)});
return;
}
appImageInstallStarted = true;
pendingAppImageUpdate = null;
log.info(`Replaced ${pending.target.installedPath} with ${pending.version}, relaunching now.`);
relaunchAndExit();
}
function reclaimAbandonedAppImageUpdates(target: AppImageTarget): void {
try {
const reclaimed = sweepAbandonedAppImageUpdates(target);
if (reclaimed.length > 0) {
log.info(`Reclaimed ${reclaimed.length} abandoned AppImage staging directories in ${target.directory}.`);
}
} catch (error) {
log.warn('Failed to reclaim abandoned AppImage staging directories', error);
}
}
function registerAppImageUpdater(getMainWindow: () => BrowserWindow | null, target: AppImageTarget): void {
reclaimAbandonedAppImageUpdates(target);
app.on('will-quit', () => {
if (!pendingAppImageUpdate || appImageInstallStarted) {
return;
}
discardStagedAppImageUpdate(pendingAppImageUpdate.staged);
pendingAppImageUpdate = null;
});
function registerSelfUpdater(getMainWindow: () => BrowserWindow | null): void {
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
lastContext = context;
await checkAppImageUpdate(context, getMainWindow, target);
});
ipcMain.handle('updater-download', async (_e, context: UpdaterContext) => {
lastContext = context;
await checkAppImageUpdate(context, getMainWindow, target);
});
ipcMain.handle('updater-install', async () => {
installAppImageUpdate(getMainWindow);
if (context !== 'user') {
return;
}
try {
await checkDesktopUpdateNow();
send(getMainWindow(), {type: 'not-available', context});
} catch (error) {
log.warn('Desktop update check failed', error);
send(getMainWindow(), {type: 'error', context, message: getErrorMessage(error)});
}
});
}
@@ -793,6 +187,13 @@ function registerManualUpdater(
reason: 'platform' | 'unpackaged' | 'managed-package',
): void {
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
if (context === 'user') {
try {
await checkDesktopUpdateNow();
} catch (error) {
log.warn('Desktop module update check failed', error);
}
}
if (reason !== 'platform') {
send(getMainWindow(), {
type: 'unsupported',
@@ -804,32 +205,13 @@ function registerManualUpdater(
}
await checkManualUpdate(context, getMainWindow);
});
ipcMain.handle('updater-download', async (_e, context: UpdaterContext) => {
send(getMainWindow(), {
type: 'unsupported',
context,
reason,
...(reason === 'managed-package' ? {} : {downloadUrl: DOWNLOAD_PAGE_URL}),
});
});
ipcMain.handle('updater-install', async () => {
throw new Error('In-app updates are not supported on this platform.');
});
}
export function registerUpdater(getMainWindow: () => BrowserWindow | null) {
const plan = resolveShellUpdatePlan();
switch (plan.capability) {
case ShellUpdateCapability.SELF_UPDATE:
if (plan.updater === 'velopack') {
registerVelopackUpdater(getMainWindow);
return;
}
if (plan.updater === 'appimage') {
registerAppImageUpdater(getMainWindow, plan.target);
return;
}
registerElectronUpdater(getMainWindow);
registerSelfUpdater(getMainWindow);
return;
case ShellUpdateCapability.MANAGED_PACKAGE:
registerManualUpdater(getMainWindow, 'managed-package');
+86 -1
View File
@@ -47,6 +47,7 @@ const runtimeSecurity = new DesktopRuntimeSecurity({
const LIVE_RESIZE_IDLE_MS = 400;
const VISIBILITY_MARGIN = 32;
const RENDERER_GONE_REPEAT_WINDOW_MS = 30000;
const CLOSE_FOR_UPDATE_TIMEOUT_MS = 5000;
const THEME_WINDOW_BACKGROUND_COLORS: Readonly<Record<string, string>> = Object.freeze({
dark: '#1a181e',
light: '#ebecef',
@@ -153,6 +154,9 @@ let initialAllowTransparency: boolean | null = null;
let themeStudioPopoutWindow: BrowserWindow | null = null;
let lastRestorableMainWindowMaximized = false;
let mainWindowRendererGone = false;
let closingMainWindowForUpdate = false;
let mainWindowTakeover: (() => void) | null = null;
const takeoverEndedListeners = new Set<() => void>();
let pendingMainWindowReveal: {readonly window: BrowserWindow; readonly requestShow: () => void} | null = null;
const maximizeChangeForwarders = new WeakSet<BrowserWindow>();
@@ -863,7 +867,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
if (saveTimeout) clearTimeout(saveTimeout);
endLiveResize();
saveWindowBounds();
if (!isQuitting && shouldHideMainWindowOnClose()) {
if (!isQuitting && !closingMainWindowForUpdate && shouldHideMainWindowOnClose()) {
event.preventDefault();
logger.info(
process.platform === 'darwin'
@@ -1127,6 +1131,10 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
}
export function showWindow(): void {
if (mainWindowTakeover != null) {
mainWindowTakeover();
return;
}
if (mainWindow && pendingMainWindowReveal?.window === mainWindow && !mainWindow.isVisible()) {
pendingMainWindowReveal.requestShow();
return;
@@ -1179,3 +1187,80 @@ export function hideWindow(): void {
export function setQuitting(quitting: boolean): void {
isQuitting = quitting;
}
export function beginMainWindowTakeover(focus: () => void): void {
mainWindowTakeover = focus;
}
export function endMainWindowTakeover(): void {
if (mainWindowTakeover == null) return;
mainWindowTakeover = null;
for (const listener of Array.from(takeoverEndedListeners)) {
try {
listener();
} catch (error) {
logger.error('A main window takeover listener threw', error);
}
}
}
export function onMainWindowTakeoverEnded(listener: () => void): () => void {
takeoverEndedListeners.add(listener);
return () => {
takeoverEndedListeners.delete(listener);
};
}
export function isMainWindowTakenOver(): boolean {
return mainWindowTakeover != null;
}
export function hideAppWindowsForUpdate(keep: BrowserWindow): ReadonlyArray<BrowserWindow> {
const hidden: Array<BrowserWindow> = [];
for (const window of BrowserWindow.getAllWindows()) {
if (window === keep || window.isDestroyed() || !window.isVisible()) continue;
window.hide();
hidden.push(window);
}
return hidden;
}
export function restoreAppWindowsAfterUpdate(hidden: ReadonlyArray<BrowserWindow>): void {
for (const window of hidden) {
if (window.isDestroyed()) continue;
window.show();
}
}
export async function closeAppWindowsForUpdate(keep: BrowserWindow): Promise<void> {
const closing = BrowserWindow.getAllWindows().filter((window) => window !== keep && !window.isDestroyed());
closingMainWindowForUpdate = true;
try {
await Promise.all(
closing.map(
(window) =>
new Promise<void>((resolve) => {
let deadline: NodeJS.Timeout | null = null;
window.once('closed', () => {
if (deadline != null) clearTimeout(deadline);
resolve();
});
if (!window.isClosable()) {
window.destroy();
return;
}
deadline = setTimeout(() => {
logger.warn('A window did not close for the update in time, destroying it');
if (!window.isDestroyed()) window.destroy();
}, CLOSE_FOR_UPDATE_TIMEOUT_MS);
window.webContents.once('will-prevent-unload', (event) => {
event.preventDefault();
});
window.close();
}),
),
);
} finally {
closingMainWindowForUpdate = false;
}
}
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
const openWindows = [];
class FakeWindow {
constructor(name, {vetoUnload = false, closable = true} = {}) {
this.name = name;
this.vetoUnload = vetoUnload;
this.closable = closable;
this.visible = true;
this.destroyed = false;
this.listeners = new Map();
this.unloadListeners = [];
this.webContents = {
once: (event, listener) => {
if (event === 'will-prevent-unload') this.unloadListeners.push(listener);
},
};
openWindows.push(this);
}
once(event, listener) {
this.listeners.set(event, listener);
}
isDestroyed() {
return this.destroyed;
}
isVisible() {
return this.visible;
}
isClosable() {
return this.closable;
}
hide() {
this.visible = false;
}
show() {
this.visible = true;
}
close() {
if (!this.closable) return;
if (this.vetoUnload) {
let prevented = false;
for (const listener of this.unloadListeners) {
listener({preventDefault: () => (prevented = true)});
}
if (!prevented) return;
}
this.destroy();
}
destroy() {
this.destroyed = true;
this.visible = false;
this.listeners.get('closed')?.();
}
}
const noop = () => undefined;
const ipcHandlers = new Map();
class ElectronEmitterStub {
on() {
return this;
}
once() {
return this;
}
off() {
return this;
}
removeListener() {
return this;
}
removeAllListeners() {
return this;
}
emit() {
return false;
}
}
installElectronStub({
app: Object.assign(new ElectronEmitterStub(), {
isReady: () => true,
isPackaged: false,
getPath: () => '/tmp/fluxer-desktop-test',
getAppPath: () => '/tmp/fluxer-desktop-test',
getVersion: () => '0.0.0',
getLocale: () => 'en-US',
setPath: noop,
exit: noop,
quit: noop,
whenReady: () => Promise.resolve(),
commandLine: {appendSwitch: noop, appendArgument: noop},
}),
BrowserWindow: class extends ElectronEmitterStub {
static fromWebContents() {
return null;
}
static getAllWindows() {
return openWindows.filter((window) => !window.isDestroyed());
}
static getFocusedWindow() {
return null;
}
},
Menu: {buildFromTemplate: () => ({}), setApplicationMenu: noop},
Notification: Object.assign(class extends ElectronEmitterStub {}, {isSupported: () => false}),
clipboard: {},
desktopCapturer: {},
dialog: {showMessageBoxSync: () => 0, showErrorBox: noop},
globalShortcut: {},
ipcMain: Object.assign(new ElectronEmitterStub(), {
handle: (channel, handler) => {
ipcHandlers.set(channel, handler);
},
removeHandler: noop,
}),
nativeImage: {createFromPath: () => ({isEmpty: () => true})},
nativeTheme: new ElectronEmitterStub(),
net: {fetch: () => Promise.reject(new Error('outbound network is not available in tests'))},
powerMonitor: new ElectronEmitterStub(),
powerSaveBlocker: {start: () => 0, stop: noop, isStarted: () => false},
protocol: {registerSchemesAsPrivileged: noop, handle: noop, unhandle: noop, isProtocolHandled: () => false},
safeStorage: {isEncryptionAvailable: () => false},
screen: Object.assign(new ElectronEmitterStub(), {
getPrimaryDisplay: () => ({workAreaSize: {width: 1920, height: 1080}}),
getAllDisplays: () => [],
}),
session: {defaultSession: {}},
shell: {openExternal: noop, showItemInFolder: noop},
systemPreferences: {},
});
const {
beginMainWindowTakeover,
closeAppWindowsForUpdate,
endMainWindowTakeover,
hideAppWindowsForUpdate,
onMainWindowTakeoverEnded,
restoreAppWindowsAfterUpdate,
isMainWindowTakenOver,
showWindow,
} = await import('@electron/main/Window');
describe('the update takes the app windows over', () => {
test('the windows hide at once and only the splash stays on screen', () => {
openWindows.length = 0;
const splash = new FakeWindow('splash');
const main = new FakeWindow('main');
const popout = new FakeWindow('voice popout');
hideAppWindowsForUpdate(splash);
assert.equal(splash.isVisible(), true);
assert.equal(main.isVisible(), false);
assert.equal(popout.isVisible(), false);
});
test('a check that finds nothing gives back exactly the windows it hid', () => {
openWindows.length = 0;
const splash = new FakeWindow('splash');
const main = new FakeWindow('main');
const trayHidden = new FakeWindow('hidden popout');
trayHidden.hide();
const hidden = hideAppWindowsForUpdate(splash);
restoreAppWindowsAfterUpdate(hidden);
assert.deepEqual(hidden, [main]);
assert.equal(main.isVisible(), true);
assert.equal(trayHidden.isVisible(), false, 'a window the user had hidden stays hidden');
});
test('a window that cannot be closed is destroyed at once instead of holding the update back', async () => {
openWindows.length = 0;
const splash = new FakeWindow('splash');
const guard = new FakeWindow('screen capture guard', {closable: false});
const startedAt = Date.now();
await closeAppWindowsForUpdate(splash);
assert.equal(guard.isDestroyed(), true);
assert.ok(Date.now() - startedAt < 1000, 'the 5 second close deadline never came into it');
});
test('ending the takeover tells its listeners once', () => {
let ended = 0;
const stop = onMainWindowTakeoverEnded(() => {
ended += 1;
});
beginMainWindowTakeover(() => undefined);
endMainWindowTakeover();
endMainWindowTakeover();
stop();
assert.equal(ended, 1);
});
test('the windows really close, past a renderer that would veto the unload', async () => {
openWindows.length = 0;
const splash = new FakeWindow('splash');
const main = new FakeWindow('main', {vetoUnload: true});
const popout = new FakeWindow('theme studio');
await closeAppWindowsForUpdate(splash);
assert.equal(main.isDestroyed(), true);
assert.equal(popout.isDestroyed(), true);
assert.equal(splash.isDestroyed(), false);
});
test('showing the app during the update raises the splash instead of a window that is gone', () => {
let focused = 0;
beginMainWindowTakeover(() => {
focused += 1;
});
assert.equal(isMainWindowTakenOver(), true);
showWindow();
assert.equal(focused, 1);
endMainWindowTakeover();
assert.equal(isMainWindowTakenOver(), false);
showWindow();
assert.equal(focused, 1);
});
test('close to hide never swallows the close the update asks for', () => {
const source = readFileSync(new URL('./Window.ts', import.meta.url), 'utf8');
assert.match(source, /if \(!isQuitting && !closingMainWindowForUpdate && shouldHideMainWindowOnClose\(\)\) \{/);
});
});
+9 -1
View File
@@ -82,6 +82,7 @@ import {createApplicationMenu} from '@electron/main/Menu';
import {
armOpenUrlForwarding,
armSecondInstanceForwarding,
setMainWindowFactory,
setOpenUrlSink,
setSecondInstanceSink,
} from '@electron/main/ModuleBootHandoff';
@@ -100,6 +101,7 @@ import {
createWindow,
getMainWindow,
hideWindow,
isMainWindowTakenOver,
setQuitting,
showWindow,
} from '@electron/main/Window';
@@ -374,6 +376,7 @@ if (launchConfigurationError) {
});
armSecondInstanceForwarding();
setSecondInstanceSink(handleSecondInstance);
setMainWindowFactory(() => createWindow());
app.on('child-process-gone', (_event, details) => {
log.error('Child process gone', details);
});
@@ -591,6 +594,7 @@ if (launchConfigurationError) {
initializeDesktopTray({
createWindow,
getMainWindow,
isMainWindowTakenOver,
hideWindow,
setQuitting,
showWindow,
@@ -602,7 +606,7 @@ if (launchConfigurationError) {
}
app.on('activate', () => {
const mainWindow = getMainWindow();
if (mainWindow === null || mainWindow.isDestroyed()) {
if ((mainWindow === null || mainWindow.isDestroyed()) && !isMainWindowTakenOver()) {
createWindow();
} else {
showWindow();
@@ -619,6 +623,10 @@ if (launchConfigurationError) {
log.info('[Shutdown] All windows closed before startup created the main window, keeping app alive');
return;
}
if (isMainWindowTakenOver()) {
log.info('[Shutdown] The update splash closed mid update, keeping app alive to reopen the main window');
return;
}
const settings = getDesktopWindowBehaviorSettings();
if (process.platform !== 'darwin' && !(hasActiveDesktopTray() && settings.showTrayIcon && settings.closeToTray)) {
app.quit();
+14 -10
View File
@@ -25,9 +25,14 @@ import {DESKTOP_RUNTIME_CONFIG_CHANNELS} from '@fluxer/desktop_ipc/src/LocalAppR
import type {
DesktopModuleAPI,
DesktopModuleEnsureResult,
DesktopPendingModuleUpdate,
DesktopUpdateAPI,
DesktopUpdateState,
} from '@fluxer/desktop_ipc/src/ModuleContract';
import {
DESKTOP_MODULE_CHANNELS,
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_EVENTS,
} from '@fluxer/desktop_ipc/src/ModuleContract';
import {DESKTOP_MODULE_CHANNELS, DESKTOP_MODULE_EVENTS} from '@fluxer/desktop_ipc/src/ModuleContract';
interface LocalAppPreloadRenderer {
invoke: (channel: string, ...args: Array<unknown>) => Promise<unknown>;
@@ -49,6 +54,7 @@ interface DesktopLocalAppUploadPreloadAPI {
interface LocalAppPreloadAPI {
readonly desktopLegacyHarvest: DesktopLegacyHarvestPreloadAPI;
readonly desktopModules: DesktopModuleAPI;
readonly desktopUpdate: DesktopUpdateAPI;
readonly desktopRuntimeConfig: DesktopRuntimeConfigAPI;
readonly localAppUpload: DesktopLocalAppUploadPreloadAPI;
}
@@ -64,16 +70,14 @@ export function createLocalAppPreloadAPI(renderer: LocalAppPreloadRenderer): Loc
}),
desktopModules: Object.freeze<DesktopModuleAPI>({
ensure: (moduleName: string) => invoke<DesktopModuleEnsureResult>(DESKTOP_MODULE_CHANNELS.ensure, moduleName),
pendingUpdate: () => invoke<DesktopPendingModuleUpdate | null>(DESKTOP_MODULE_CHANNELS.pendingUpdate),
applyPendingUpdate: () => invoke<boolean>(DESKTOP_MODULE_CHANNELS.applyPendingUpdate),
onPendingUpdateChanged: (listener: (pending: DesktopPendingModuleUpdate | null) => void) =>
subscribeToChannel<DesktopPendingModuleUpdate | null>(
renderer,
DESKTOP_MODULE_EVENTS.pendingUpdateChanged,
listener,
),
confirmLaunch: () => invoke<void>(DESKTOP_MODULE_CHANNELS.confirmLaunch),
}),
desktopUpdate: Object.freeze<DesktopUpdateAPI>({
state: () => invoke<DesktopUpdateState>(DESKTOP_UPDATE_CHANNELS.state),
start: () => invoke<void>(DESKTOP_UPDATE_CHANNELS.start),
onStateChanged: (listener: (state: DesktopUpdateState) => void) =>
subscribeToChannel<DesktopUpdateState>(renderer, DESKTOP_UPDATE_EVENTS.stateChanged, listener),
}),
desktopRuntimeConfig: Object.freeze<DesktopRuntimeConfigAPI>({
initialInput: () => invoke<string | null>(DESKTOP_RUNTIME_CONFIG_CHANNELS.initialInput),
resolve: (request: DesktopRuntimeResolveRequest) =>
-2
View File
@@ -463,8 +463,6 @@ const api: ElectronAPI = {
return () => ipcRenderer.removeListener('updater-event', handler);
},
updaterCheck: (context: UpdaterContext): Promise<void> => ipcRenderer.invoke('updater-check', context),
updaterDownload: (context: UpdaterContext): Promise<void> => ipcRenderer.invoke('updater-download', context),
updaterInstall: () => ipcRenderer.invoke('updater-install'),
windowMinimize: (): void => {
ipcRenderer.send('window-minimize');
},
+15 -2
View File
@@ -3,6 +3,7 @@
const {ipcRenderer} = require('electron');
const SPLASH_STATE_CHANNEL = 'desktop-splash:state';
const SPLASH_REVEALED_CHANNEL = 'desktop-splash:revealed';
const SPLASH_READY_CHANNEL = 'desktop-splash:ready';
const SPLASH_RETRY_CHANNEL = 'desktop-splash:retry-now';
const SPLASH_QUIT_CHANNEL = 'desktop-splash:quit';
@@ -87,7 +88,7 @@ let renderedLayout = null;
let renderedManualSignature = null;
let countdownTimer = null;
let selectedOption = null;
let splashStartedAt = Date.now();
let splashStartedAt = Number.POSITIVE_INFINITY;
let diagnosticsRevealTimer = null;
let diagnosticsCopiedTimer = null;
@@ -592,12 +593,24 @@ function afterNextPaint(callback) {
});
}
window.addEventListener('DOMContentLoaded', () => {
function restartDiagnosticsReveal() {
splashStartedAt = Date.now();
if (diagnosticsRevealTimer != null) clearTimeout(diagnosticsRevealTimer);
diagnosticsRevealTimer = setTimeout(() => {
diagnosticsRevealTimer = null;
render();
}, DIAGNOSTICS_REVEAL_MS);
}
ipcRenderer.on(SPLASH_REVEALED_CHANNEL, () => {
restartDiagnosticsReveal();
render();
});
window.addEventListener('DOMContentLoaded', () => {
if (!/(?:^|[?&])held=1(?:&|$)/.test(window.location?.search ?? '')) {
restartDiagnosticsReveal();
}
render();
const mount = document.getElementById(SPLASH_MOUNT_ID);
const branded = mount == null ? Promise.resolve() : waitForBranding(mount);