feat(desktop): show download progress and diagnostics on splash (#3273)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 03:06:06 +02:00
1 parent 795e190bda
commit 0ef1cd14c3
12 files changed
+667 -18

No files matched your search

+75 -2
View File
@@ -92,12 +92,15 @@ const SPLASH_STATUS_BY_UPDATER_STATUS: Readonly<Record<ModuleUpdaterStatus, Spla
function toSplashState(state: ModuleUpdaterSplashState): SplashState {
return {
status: SPLASH_STATUS_BY_UPDATER_STATUS[state.status],
status: state.stalled ? 'download-stalled' : SPLASH_STATUS_BY_UPDATER_STATUS[state.status],
requiredSecurityUpdate: state.requiredSecurityUpdate,
current: state.current,
total: state.total,
progress: state.progress,
seconds: state.seconds,
receivedBytes: state.receivedBytes,
totalBytes: state.totalBytes,
bytesPerSecond: state.bytesPerSecond,
message: state.detail,
};
}
@@ -259,7 +262,7 @@ async function refuseUnsupportedBuild(reason: string): Promise<never> {
}
async function runModuleBootstrap(): Promise<void> {
const {app} = await import('electron');
const {app, clipboard, session, shell} = await import('electron');
const {BUILD_CHANNEL} = await import('@electron/common/BuildChannel');
const {createChildLogger} = await import('@electron/common/Logger');
const {loadDesktopConfig} = await import('@electron/common/DesktopConfig');
@@ -288,11 +291,14 @@ async function runModuleBootstrap(): Promise<void> {
const {resolveShellUpdatePlan, ShellUpdateCapability} = await import('@electron/main/ShellUpdateCapability');
const {armBlockedShellUpdate} = await import('@electron/main/ShellUpdateSplash');
const {UpdateServerRetry} = await import('@electron/main/UpdateServerRetry');
const {buildSplashDiagnosticsText} = await import('@electron/main/SplashDiagnostics');
const {
closeSplashWindow,
focusSplashWindow,
markSplashLaunching,
onSplashCopyDiagnostics,
onSplashNetworkOnline,
onSplashOpenLogs,
onSplashQuit,
onSplashRetry,
openSplashWindow,
@@ -373,6 +379,72 @@ async function runModuleBootstrap(): Promise<void> {
updateServerRetry.networkReturned();
});
const splashOpenedAt = Date.now();
let diagnosticsSource: {
readonly store: ModuleStoreInstance;
readonly updater: ModuleUpdaterInstance;
} | null = null;
const resolveLogsPath = (): string | null => {
try {
return app.getPath('logs');
} catch {
return null;
}
};
const collectSplashDiagnostics = async (): Promise<string> => {
const updaterDiagnostics = diagnosticsSource?.updater.diagnostics() ?? null;
const packageOrigin = updaterDiagnostics?.packageOrigin ?? resolveDesktopPackageOrigin();
let proxyRoute: string | null = null;
try {
proxyRoute = await session.defaultSession.resolveProxy(packageOrigin);
} catch (error) {
logger.warn('Failed to resolve the proxy route for diagnostics', error);
}
const state = updaterDiagnostics?.state ?? null;
return buildSplashDiagnosticsText({
generatedAt: Date.now(),
splashOpenedAt,
appVersion: app.getVersion(),
channel: BUILD_CHANNEL,
platform: process.platform,
arch: process.arch,
osVersion: process.getSystemVersion(),
electronVersion: process.versions.electron ?? 'unknown',
logsPath: resolveLogsPath(),
userDataPath: userDataConfig.base,
packageOrigin,
proxyRoute,
splashStatus: state == null ? null : (toSplashState(state).status ?? null),
updaterStatus: state?.status ?? null,
pendingModule: state?.moduleName ?? null,
receivedBytes: state?.receivedBytes ?? null,
totalBytes: state?.totalBytes ?? null,
bytesPerSecond: state?.bytesPerSecond ?? null,
committed: diagnosticsSource?.store.getCommitted() ?? {},
lastError: updaterDiagnostics?.lastError ?? null,
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});
}
});
});
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);
});
});
const showBlockedSplash = (status: SplashStatus, message: string | null = null): void => {
openSplashWindow();
setSplashState({status, action: SplashAction.RETRY, message});
@@ -432,6 +504,7 @@ async function runModuleBootstrap(): Promise<void> {
});
},
});
diagnosticsSource = {store, updater};
const runModuleUpdateAttempt = async (): Promise<ModuleLaunchPermit | null> => {
let outcome: ModuleUpdaterOutcome;
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const RATE_SMOOTHING_WINDOW_MS = 3000;
const RATE_MIN_SAMPLE_MS = 200;
export class ModuleDownloadRate {
private lastBytes: number | null = null;
private lastAt = 0;
private rate: number | null = null;
public reset(): void {
this.lastBytes = null;
this.lastAt = 0;
this.rate = null;
}
public sample(bytes: number, at: number): number | null {
if (this.lastBytes == null || bytes < this.lastBytes) {
this.lastBytes = bytes;
this.lastAt = at;
this.rate = null;
return null;
}
const elapsed = at - this.lastAt;
if (elapsed < RATE_MIN_SAMPLE_MS) {
return this.rate;
}
const instant = ((bytes - this.lastBytes) * 1000) / elapsed;
const weight = 1 - Math.exp(-elapsed / RATE_SMOOTHING_WINDOW_MS);
this.rate = this.rate == null ? instant : this.rate + (instant - this.rate) * weight;
this.lastBytes = bytes;
this.lastAt = at;
return this.rate;
}
}
@@ -14,7 +14,8 @@ import type {ModulePlanItem} from '@electron/main/ModuleUpdatePlanner';
const MODULE_PACKAGE_HASH_MISMATCH_RETRIES = 1;
const MODULE_PACKAGE_HEADER_TIMEOUT_MS = 30000;
const MODULE_PACKAGE_STALL_TIMEOUT_MS = 60000;
export const MODULE_PACKAGE_STALL_TIMEOUT_MS = 30000;
const MODULE_PACKAGE_PROGRESS_INTERVAL_MS = 250;
const TRANSIENT_HTTP_STATUS_FLOOR = 500;
const TRANSIENT_PACKAGE_HTTP_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
const CAUSE_CHAIN_LIMIT = 8;
@@ -37,6 +38,8 @@ interface ModulePackageInstallProgress {
readonly total: number;
readonly moduleName: string;
readonly progress: number;
readonly receivedBytes: number;
readonly totalBytes: number;
}
export const ModulePackageInstallerReportType = Object.freeze({
@@ -77,6 +80,7 @@ interface ModulePackageInstallerOptions {
readonly packageOrigin: string;
readonly fetch: typeof globalThis.fetch;
readonly onProgress: (progress: ModulePackageInstallProgress) => void;
readonly now?: () => number;
readonly report: (report: ModulePackageInstallerReport) => void;
}
@@ -92,6 +96,17 @@ export class ModulePackageFetchError extends Error {
}
}
export class ModulePackageStallError extends ModulePackageFetchError {
public constructor(moduleName: string, stallMs: number) {
super(moduleName, null, `package download stalled for ${stallMs}ms`);
this.name = 'ModulePackageStallError';
}
}
export function isModulePackageStall(error: unknown): boolean {
return findCause(error, ModulePackageStallError) != null;
}
class ModulePackageUrlPolicyError extends Error {
public readonly module: string;
@@ -168,6 +183,7 @@ export class ModulePackageInstaller {
private readonly packageOrigin: string;
private readonly fetchImplementation: typeof globalThis.fetch;
private readonly onProgress: (progress: ModulePackageInstallProgress) => void;
private readonly now: () => number;
private readonly reporter: (report: ModulePackageInstallerReport) => void;
public constructor(options: ModulePackageInstallerOptions) {
@@ -175,6 +191,7 @@ export class ModulePackageInstaller {
this.packageOrigin = options.packageOrigin;
this.fetchImplementation = options.fetch;
this.onProgress = options.onProgress;
this.now = options.now ?? Date.now;
this.reporter = options.report;
}
@@ -192,6 +209,8 @@ export class ModulePackageInstaller {
total,
moduleName: item.module,
progress: Math.round((current / total) * 100),
receivedBytes: item.entry.bytes,
totalBytes: item.entry.bytes,
});
return;
} catch (error) {
@@ -238,6 +257,8 @@ export class ModulePackageInstaller {
total,
moduleName: item.module,
progress: item.entry.bytes === 0 ? 0 : Math.floor((offset / item.entry.bytes) * 100),
receivedBytes: offset,
totalBytes: item.entry.bytes,
});
if (offset > 0 && offset === item.entry.bytes) {
return {offset, chunks: []};
@@ -339,18 +360,13 @@ export class ModulePackageInstaller {
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
let received = start;
let reported = item.entry.bytes === 0 ? 0 : Math.floor((start / item.entry.bytes) * 100);
let reportedAt = this.now();
let completed = false;
let transferFailure: unknown = null;
let stall: NodeJS.Timeout | null = null;
const arm = (): void => {
stall = setTimeout(() => {
controller.abort(
new ModulePackageFetchError(
item.module,
null,
`package download stalled for ${MODULE_PACKAGE_STALL_TIMEOUT_MS}ms`,
),
);
controller.abort(new ModulePackageStallError(item.module, MODULE_PACKAGE_STALL_TIMEOUT_MS));
}, MODULE_PACKAGE_STALL_TIMEOUT_MS);
};
const disarm = (): void => {
@@ -378,14 +394,18 @@ export class ModulePackageInstaller {
);
}
const percent = item.entry.bytes === 0 ? 100 : Math.floor((received / item.entry.bytes) * 100);
if (percent > reported) {
const at = this.now();
if (percent > reported || at - reportedAt >= MODULE_PACKAGE_PROGRESS_INTERVAL_MS) {
reported = percent;
reportedAt = at;
this.onProgress({
phase: ModulePackageInstallPhase.DOWNLOADING,
current,
total,
moduleName: item.module,
progress: percent,
receivedBytes: received,
totalBytes: item.entry.bytes,
});
}
yield chunk.value;
+69 -1
View File
@@ -2,6 +2,7 @@
import {createHash} from 'node:crypto';
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
import {ModuleDownloadRate} from '@electron/main/ModuleDownloadRate';
import {
blockingModuleNames,
type DesktopLinuxSecurityMinimum,
@@ -16,6 +17,7 @@ import {
import {
describeErrorChain,
isDeterministicPackageFailure,
isModulePackageStall,
isTransientServerStatus,
ModulePackageFetchError,
ModulePackageInstaller,
@@ -123,6 +125,17 @@ export interface ModuleUpdaterSplashState {
readonly seconds: number | null;
readonly moduleName: string | null;
readonly detail: string | null;
readonly receivedBytes: number | null;
readonly totalBytes: number | null;
readonly bytesPerSecond: number | null;
readonly stalled: boolean;
}
export interface ModuleUpdaterDiagnostics {
readonly state: ModuleUpdaterSplashState;
readonly packageOrigin: string;
readonly lastError: string | null;
readonly lastErrorAt: number | null;
}
interface ModuleUpdaterReport {
@@ -289,6 +302,11 @@ export class ModuleUpdater {
private readonly random: () => number;
private readonly now: () => number;
private lastState: ModuleUpdaterSplashState;
private readonly packageOrigin: string;
private readonly downloadRate = new ModuleDownloadRate();
private downloadProgressed = false;
private lastError: string | null = null;
private lastErrorAt: number | null = null;
private ensureBlockedUntil = 0;
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
private manifestObservationTail: Promise<void> = Promise.resolve();
@@ -303,6 +321,7 @@ export class ModuleUpdater {
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.onState = options.onState ?? (() => {});
this.reporter = options.report ?? (() => {});
@@ -323,10 +342,15 @@ export class ModuleUpdater {
store: this.store,
packageOrigin,
fetch: options.fetch,
now: () => this.now(),
onProgress: (progress) => {
switch (progress.phase) {
case ModulePackageInstallPhase.DOWNLOADING:
this.emit(ModuleUpdaterStatus.DOWNLOADING, progress);
this.recordDownloadProgress(progress.receivedBytes);
this.emit(ModuleUpdaterStatus.DOWNLOADING, {
...progress,
bytesPerSecond: this.downloadRate.sample(progress.receivedBytes, this.now()) ?? undefined,
});
break;
case ModulePackageInstallPhase.INSTALLING:
this.emit(ModuleUpdaterStatus.INSTALLING, progress);
@@ -346,9 +370,29 @@ export class ModuleUpdater {
seconds: null,
moduleName: null,
detail: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
stalled: false,
};
}
public diagnostics(): ModuleUpdaterDiagnostics {
return {
state: this.lastState,
packageOrigin: this.packageOrigin,
lastError: this.lastError,
lastErrorAt: this.lastErrorAt,
};
}
private recordDownloadProgress(receivedBytes: number): void {
const previous = this.lastState.status === ModuleUpdaterStatus.DOWNLOADING ? this.lastState.receivedBytes : null;
if (previous != null && receivedBytes > previous) {
this.downloadProgressed = true;
}
}
public getLastState(): ModuleUpdaterSplashState {
return this.lastState;
}
@@ -372,6 +416,8 @@ export class ModuleUpdater {
const backoff = new ModuleUpdaterBackoff(this.random);
for (;;) {
this.emit(ModuleUpdaterStatus.CHECKING);
this.downloadRate.reset();
this.downloadProgressed = false;
let fetched = false;
try {
const document = await this.manifestRepository.fetchLatest();
@@ -415,8 +461,13 @@ export class ModuleUpdater {
backoff.reset();
} catch (error) {
this.reportFailure(error);
this.lastError = describeErrorChain(error);
this.lastErrorAt = this.now();
const detail = isStorageFullError(error) ? 'Not enough disk space to install the update' : null;
const updateServerUnreachable = !isRejectedManifestError(error) && isUpdateServerUnreachable(error);
if (this.downloadProgressed) {
backoff.reset();
}
const delayMs = backoff.fail();
const exhausted = backoff.reachedCeiling || isRejectedManifestError(error);
if (exhausted || !fetched) {
@@ -441,9 +492,18 @@ export class ModuleUpdater {
});
}
}
const stalled = isModulePackageStall(error);
const position = stalled && this.lastState.status === ModuleUpdaterStatus.DOWNLOADING ? this.lastState : null;
this.emit(ModuleUpdaterStatus.RETRY_WAIT, {
seconds: Math.max(1, Math.round(delayMs / 1000)),
detail,
stalled,
current: position?.current ?? undefined,
total: position?.total ?? undefined,
progress: position?.progress ?? undefined,
moduleName: position?.moduleName ?? undefined,
receivedBytes: position?.receivedBytes ?? undefined,
totalBytes: position?.totalBytes ?? undefined,
});
await this.sleep(delayMs);
}
@@ -934,6 +994,10 @@ export class ModuleUpdater {
readonly seconds?: number;
readonly moduleName?: string;
readonly detail?: string | null;
readonly receivedBytes?: number;
readonly totalBytes?: number;
readonly bytesPerSecond?: number;
readonly stalled?: boolean;
} = {},
): void {
this.lastState = {
@@ -945,6 +1009,10 @@ export class ModuleUpdater {
seconds: patch.seconds ?? null,
moduleName: patch.moduleName ?? null,
detail: patch.detail ?? null,
receivedBytes: patch.receivedBytes ?? null,
totalBytes: patch.totalBytes ?? null,
bytesPerSecond: patch.bytesPerSecond ?? null,
stalled: patch.stalled === true,
};
this.onState(this.lastState);
}
@@ -391,6 +391,9 @@ describe('arming the blocked shell update', () => {
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: {kind: 'quit', label: 'Quit'},
message: null,
versionLabel: null,
@@ -420,6 +423,9 @@ describe('arming the blocked shell update', () => {
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: {kind: 'download', label: 'Download update'},
message: null,
versionLabel: null,
@@ -449,6 +455,9 @@ describe('arming the blocked shell update', () => {
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: SPLASH_MANUAL_UPDATE_MESSAGE,
versionLabel: 'Version 2026.823.1 available',
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface SplashDiagnosticsInput {
readonly generatedAt: number;
readonly splashOpenedAt: number | null;
readonly appVersion: string;
readonly channel: string;
readonly platform: string;
readonly arch: string;
readonly osVersion: string;
readonly electronVersion: string;
readonly logsPath: string | null;
readonly userDataPath: string;
readonly packageOrigin: string;
readonly proxyRoute: string | null;
readonly splashStatus: string | null;
readonly updaterStatus: string | null;
readonly pendingModule: string | null;
readonly receivedBytes: number | null;
readonly totalBytes: number | null;
readonly bytesPerSecond: number | null;
readonly committed: Readonly<Record<string, string>>;
readonly lastError: string | null;
readonly lastErrorAt: number | null;
}
const SHA_PREFIX_LENGTH = 12;
const SECRET_HEADER_PATTERN =
/\b(authorization|cookie|token|secret|password)\b\s*[:=]\s*(?:(?:bearer|basic)\s+)?[^\s&]+/giu;
const SECRET_QUERY_PATTERN = /([?&])([^=&#\s]+)=([^&#\s]*)/gu;
const SECRET_USERINFO_PATTERN = /[^\s:@/]+:[^\s@/]+@/gu;
export function redactDiagnosticText(value: string): string {
return value
.replace(SECRET_HEADER_PATTERN, (_match, key: string) => `${key}=…`)
.replace(SECRET_QUERY_PATTERN, (_match, separator: string, key: string) => `${separator}${key}=…`)
.replace(SECRET_USERINFO_PATTERN, '…@');
}
function formatTime(value: number | null): string {
return value == null ? 'none' : new Date(value).toISOString();
}
function formatBytes(value: number | null): string {
return value == null ? 'unknown' : `${value} bytes`;
}
export function buildSplashDiagnosticsText(input: SplashDiagnosticsInput): string {
const committed = Object.entries(input.committed)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, sha]) => ` ${name} ${sha.slice(0, SHA_PREFIX_LENGTH)}`);
const lines = [
'Fluxer desktop updater diagnostics',
`Generated: ${formatTime(input.generatedAt)}`,
`Splash opened: ${formatTime(input.splashOpenedAt)}`,
`App version: ${input.appVersion} (${input.channel})`,
`Platform: ${input.platform} ${input.arch}, OS ${input.osVersion}, Electron ${input.electronVersion}`,
`Logs: ${input.logsPath ?? 'unknown'}`,
`User data: ${input.userDataPath}`,
`Package origin: ${input.packageOrigin}`,
`Proxy route: ${input.proxyRoute ?? 'unknown'}`,
`Splash status: ${input.splashStatus ?? 'unknown'}`,
`Updater status: ${input.updaterStatus ?? 'unknown'}`,
`Pending module: ${input.pendingModule ?? 'none'}`,
`Downloaded: ${formatBytes(input.receivedBytes)} of ${formatBytes(input.totalBytes)}`,
`Speed: ${input.bytesPerSecond == null ? 'unknown' : `${Math.round(input.bytesPerSecond)} bytes/s`}`,
`Last updater error: ${input.lastError ?? 'none'}`,
`Last updater error at: ${formatTime(input.lastErrorAt)}`,
'Committed modules:',
...(committed.length === 0 ? [' none'] : committed),
];
return redactDiagnosticText(lines.join('\n'));
}
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
import './LocalAppTestSupport.test.mjs';
const {ModuleDownloadRate} = await import('@electron/main/ModuleDownloadRate');
const {buildSplashDiagnosticsText, redactDiagnosticText} = await import('@electron/main/SplashDiagnostics');
const {isModulePackageStall, ModulePackageFetchError, ModulePackageStallError, MODULE_PACKAGE_STALL_TIMEOUT_MS} =
await import('@electron/main/ModulePackageInstaller');
const {serializeSplashState, SplashStatus} = await import('@electron/main/SplashWindow');
function diagnosticsInput(overrides = {}) {
return {
generatedAt: Date.UTC(2026, 9, 8, 1, 0, 0),
splashOpenedAt: Date.UTC(2026, 9, 8, 0, 59, 0),
appVersion: '2026.1008.1',
channel: 'canary',
platform: 'win32',
arch: 'x64',
osVersion: '10.0.26100',
electronVersion: '44.4.1',
logsPath: 'C:\\Users\\u\\AppData\\Roaming\\fluxer_desktop_canary\\logs',
userDataPath: 'C:\\Users\\u\\AppData\\Roaming\\fluxercanary',
packageOrigin: 'https://pkgs.fluxer.com',
proxyRoute: 'PROXY 10.0.0.2:3128',
splashStatus: 'download-stalled',
updaterStatus: 'retry-wait',
pendingModule: 'fluxer_renderer',
receivedBytes: 42000000,
totalBytes: 146684915,
bytesPerSecond: 3100000,
committed: {fluxer_sourcemaps: 'b'.repeat(64), fluxer_renderer: 'a'.repeat(64)},
lastError: 'package download stalled for 30000ms',
lastErrorAt: Date.UTC(2026, 9, 8, 0, 59, 40),
...overrides,
};
}
describe('module download rate', () => {
test('reports nothing until a second sample and then the transfer rate', () => {
const rate = new ModuleDownloadRate();
assert.equal(rate.sample(0, 0), null);
assert.equal(rate.sample(1_000_000, 1000), 1_000_000);
});
test('smooths a sudden change instead of jumping to it', () => {
const rate = new ModuleDownloadRate();
rate.sample(0, 0);
rate.sample(1_000_000, 1000);
const smoothed = rate.sample(1_000_000 + 4_000_000, 2000);
assert.ok(smoothed > 1_000_000 && smoothed < 4_000_000, String(smoothed));
});
test('keeps the last rate for samples closer together than the minimum window', () => {
const rate = new ModuleDownloadRate();
rate.sample(0, 0);
const first = rate.sample(1_000_000, 1000);
assert.equal(rate.sample(1_100_000, 1050), first);
});
test('starts over when the byte count goes backwards or after a reset', () => {
const rate = new ModuleDownloadRate();
rate.sample(0, 0);
rate.sample(5_000_000, 1000);
assert.equal(rate.sample(100, 1500), null);
rate.reset();
assert.equal(rate.sample(0, 3000), null);
});
});
describe('package download stalls', () => {
test('a stall is a transient fetch failure the updater can name', () => {
const stall = new ModulePackageStallError('fluxer_renderer', MODULE_PACKAGE_STALL_TIMEOUT_MS);
assert.ok(stall instanceof ModulePackageFetchError);
assert.equal(stall.status, null);
assert.equal(isModulePackageStall(new Error('install failed', {cause: stall})), true);
assert.equal(isModulePackageStall(new ModulePackageFetchError('fluxer_renderer', 503, 'busy')), false);
});
test('gives up on a silent transfer after thirty seconds', () => {
assert.equal(MODULE_PACKAGE_STALL_TIMEOUT_MS, 30000);
});
});
describe('splash download state', () => {
test('carries byte counts and speed across the IPC boundary as whole numbers', () => {
const serialized = serializeSplashState({
status: SplashStatus.DOWNLOADING_UPDATES,
receivedBytes: 42_000_000.7,
totalBytes: 146_684_915,
bytesPerSecond: 3_100_000.4,
});
assert.equal(serialized.receivedBytes, 42_000_000);
assert.equal(serialized.totalBytes, 146_684_915);
assert.equal(serialized.bytesPerSecond, 3_100_000);
});
test('keeps the stalled status and drops byte values that are not numbers', () => {
const serialized = serializeSplashState({
status: SplashStatus.DOWNLOAD_STALLED,
seconds: 4,
receivedBytes: Number.NaN,
totalBytes: '9',
bytesPerSecond: Number.POSITIVE_INFINITY,
});
assert.equal(serialized.status, 'download-stalled');
assert.equal(serialized.receivedBytes, null);
assert.equal(serialized.totalBytes, null);
assert.equal(serialized.bytesPerSecond, null);
});
});
describe('splash diagnostics', () => {
test('lists what support needs to place a stuck download', () => {
const text = buildSplashDiagnosticsText(diagnosticsInput());
for (const expected of [
'App version: 2026.1008.1 (canary)',
'Platform: win32 x64, OS 10.0.26100, Electron 44.4.1',
'Package origin: https://pkgs.fluxer.com',
'Proxy route: PROXY 10.0.0.2:3128',
'Splash status: download-stalled',
'Pending module: fluxer_renderer',
'Downloaded: 42000000 bytes of 146684915 bytes',
'Speed: 3100000 bytes/s',
'Last updater error: package download stalled for 30000ms',
'Last updater error at: 2026-10-08T00:59:40.000Z',
' fluxer_renderer aaaaaaaaaaaa',
' fluxer_sourcemaps bbbbbbbbbbbb',
]) {
assert.ok(text.includes(expected), `missing ${expected}\n${text}`);
}
assert.ok(text.indexOf('fluxer_renderer aaaa') < text.indexOf('fluxer_sourcemaps bbbb'));
});
test('never carries query values or credential-looking pairs', () => {
const text = buildSplashDiagnosticsText(
diagnosticsInput({
lastError: 'failed to reach https://pkgs.fluxer.com/x?token=abc123&sig=zzz: authorization: Bearer secretvalue',
proxyRoute: 'PROXY user:pass@proxy:8080',
}),
);
assert.ok(!text.includes('abc123'));
assert.ok(!text.includes('zzz'));
assert.ok(!text.includes('secretvalue'));
assert.ok(!text.includes('user:pass'));
assert.ok(text.includes('?token=…&sig=…'));
assert.ok(text.includes('Proxy route: PROXY …@proxy:8080'));
});
test('redaction leaves plain diagnostic text alone', () => {
assert.equal(
redactDiagnosticText('Package origin: https://pkgs.fluxer.com'),
'Package origin: https://pkgs.fluxer.com',
);
});
test('says so when nothing is installed yet', () => {
const text = buildSplashDiagnosticsText(diagnosticsInput({committed: {}, lastError: null, lastErrorAt: null}));
assert.ok(text.includes('Committed modules:\n none'));
assert.ok(text.includes('Last updater error: none'));
});
});
+23 -2
View File
@@ -533,6 +533,9 @@ describe('splash state serialisation', () => {
total: 7,
progress: 42.5,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
@@ -560,6 +563,9 @@ describe('splash state serialisation', () => {
total: null,
progress: 0,
seconds: 0,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
@@ -820,6 +826,9 @@ describe('splash IPC', () => {
total: 5,
progress: 40,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
@@ -853,6 +862,9 @@ describe('splash IPC', () => {
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
@@ -1132,6 +1144,12 @@ function createPreloadHarness() {
timeouts.push({callback, delayMs});
return timeouts.length;
},
clearTimeout: (id) => {
const timer = timeouts[id - 1];
if (timer != null) {
timer.cleared = true;
}
},
window: {
addEventListener: (eventName, listener) => {
windowListeners.set(eventName, listener);
@@ -1209,8 +1227,8 @@ describe('splash preload rendering', () => {
assert.deepEqual(harness.sent, []);
assert.equal(harness.query('.splash-mark').decodeCalls, 1);
assert.deepEqual(harness.fontLoads, ['400 12px "Fluxer Sans"', '500 16px "Fluxer Sans"']);
assert.equal(harness.timeouts.length, 1);
assert.ok(harness.timeouts[0].delayMs <= 500);
const brandingWaits = harness.timeouts.filter((timer) => timer.delayMs <= 500);
assert.equal(brandingWaits.length, 1);
await harness.settle();
assert.deepEqual(harness.sent, []);
@@ -1713,6 +1731,9 @@ describe('splash preload contract', () => {
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
+32
View File
@@ -27,6 +27,8 @@ export const DESKTOP_SPLASH_RETRY_NOW_CHANNEL = 'desktop-splash:retry-now';
export const DESKTOP_SPLASH_QUIT_CHANNEL = 'desktop-splash:quit';
export const DESKTOP_SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download';
export const DESKTOP_SPLASH_NETWORK_ONLINE_CHANNEL = 'desktop-splash:network-online';
export const DESKTOP_SPLASH_OPEN_LOGS_CHANNEL = 'desktop-splash:open-logs';
export const DESKTOP_SPLASH_COPY_DIAGNOSTICS_CHANNEL = 'desktop-splash:copy-diagnostics';
export const SplashLayout = Object.freeze({
SPLASH: 'splash',
@@ -41,6 +43,7 @@ export const SplashStatus = Object.freeze({
INSTALLING_UPDATES: 'installing-updates',
VERIFYING: 'verifying',
UPDATE_FAILURE: 'update-failure',
DOWNLOAD_STALLED: 'download-stalled',
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
@@ -91,6 +94,9 @@ export interface SplashState {
readonly total?: number | null;
readonly progress?: number | null;
readonly seconds?: number | null;
readonly receivedBytes?: number | null;
readonly totalBytes?: number | null;
readonly bytesPerSecond?: number | null;
readonly action?: SplashActionDescriptor | null;
readonly message?: string | null;
readonly versionLabel?: string | null;
@@ -105,6 +111,9 @@ export interface SerializedSplashState {
readonly total: number | null;
readonly progress: number | null;
readonly seconds: number | null;
readonly receivedBytes: number | null;
readonly totalBytes: number | null;
readonly bytesPerSecond: number | null;
readonly action: SplashActionDescriptor | null;
readonly message: string | null;
readonly versionLabel: string | null;
@@ -123,6 +132,8 @@ const retryListeners = new Set<SplashListener>();
const quitListeners = new Set<SplashListener>();
const networkOnlineListeners = new Set<SplashListener>();
const openDownloadListeners = new Set<SplashDownloadListener>();
const openLogsListeners = new Set<SplashListener>();
const copyDiagnosticsListeners = new Set<SplashListener>();
let splashWindow: BrowserWindow | null = null;
let splashIpcRegistered = false;
@@ -196,6 +207,9 @@ export function serializeSplashState(state: SplashState): SerializedSplashState
total: toSplashCount(state.total),
progress: toSplashProgress(state.progress),
seconds: toSplashCount(state.seconds),
receivedBytes: toSplashCount(state.receivedBytes),
totalBytes: toSplashCount(state.totalBytes),
bytesPerSecond: toSplashCount(state.bytesPerSecond),
action: toSplashAction(state.action),
message: toSplashText(state.message, SPLASH_MESSAGE_MAX_LENGTH),
versionLabel: toSplashText(state.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH),
@@ -294,6 +308,14 @@ function registerSplashIpc(): void {
if (!isSplashSender(event)) return;
emitSplashDownloadListeners(toSplashDownloadValue(value));
});
ipcMain.on(DESKTOP_SPLASH_OPEN_LOGS_CHANNEL, (event) => {
if (!isSplashSender(event)) return;
emitSplashListeners(openLogsListeners);
});
ipcMain.on(DESKTOP_SPLASH_COPY_DIAGNOSTICS_CHANNEL, (event) => {
if (!isSplashSender(event)) return;
emitSplashListeners(copyDiagnosticsListeners);
});
}
function registerSplashDiagnostics(window: BrowserWindow): void {
@@ -391,6 +413,8 @@ export function closeSplashWindow(): void {
quitListeners.clear();
networkOnlineListeners.clear();
openDownloadListeners.clear();
openLogsListeners.clear();
copyDiagnosticsListeners.clear();
if (readyWatchdog != null) {
clearTimeout(readyWatchdog);
readyWatchdog = null;
@@ -450,3 +474,11 @@ export function onSplashNetworkOnline(listener: SplashListener): () => void {
export function onSplashOpenDownload(listener: SplashDownloadListener): () => void {
return addSplashListener(openDownloadListeners, listener);
}
export function onSplashOpenLogs(listener: SplashListener): () => void {
return addSplashListener(openLogsListeners, listener);
}
export function onSplashCopyDiagnostics(listener: SplashListener): () => void {
return addSplashListener(copyDiagnosticsListeners, listener);
}
+113 -4
View File
@@ -8,6 +8,8 @@ const SPLASH_RETRY_CHANNEL = 'desktop-splash:retry-now';
const SPLASH_QUIT_CHANNEL = 'desktop-splash:quit';
const SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download';
const SPLASH_NETWORK_ONLINE_CHANNEL = 'desktop-splash:network-online';
const SPLASH_OPEN_LOGS_CHANNEL = 'desktop-splash:open-logs';
const SPLASH_COPY_DIAGNOSTICS_CHANNEL = 'desktop-splash:copy-diagnostics';
const SPLASH_MOUNT_ID = 'splash-mount';
const SPLASH_MARK_SIZE = 88;
@@ -23,6 +25,10 @@ const SPLASH_OPTION_LABEL_MAX_LENGTH = 48;
const SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH = 24;
const SPLASH_OPTION_MAX_COUNT = 8;
const SPLASH_OPTION_VALUE_PATTERN = /^[a-z0-9_]{1,32}$/;
const DIAGNOSTICS_REVEAL_MS = 30000;
const DIAGNOSTICS_COPIED_MS = 2000;
const BYTES_PER_MEGABYTE = 1000000;
const BYTES_PER_KILOBYTE = 1000;
const SplashLayout = Object.freeze({
SPLASH: 'splash',
@@ -35,6 +41,7 @@ const SplashStatus = Object.freeze({
INSTALLING_UPDATES: 'installing-updates',
VERIFYING: 'verifying',
UPDATE_FAILURE: 'update-failure',
DOWNLOAD_STALLED: 'download-stalled',
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
@@ -53,9 +60,21 @@ const KNOWN_LAYOUTS = new Set(Object.values(SplashLayout));
const KNOWN_STATUSES = new Set(Object.values(SplashStatus));
const PROGRESS_STATUSES = new Set([
SplashStatus.DOWNLOADING_UPDATES,
SplashStatus.DOWNLOAD_STALLED,
SplashStatus.INSTALLING_UPDATES,
SplashStatus.SHELL_UPDATE_DOWNLOADING,
]);
const DIAGNOSTICS_STATUSES = new Set([
SplashStatus.UPDATE_FAILURE,
SplashStatus.DOWNLOAD_STALLED,
SplashStatus.BLOCKED_UPDATE_REQUIRED,
SplashStatus.BLOCKED_UPDATE_UNREACHABLE,
SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED,
SplashStatus.BLOCKED_SHELL_UPDATE,
SplashStatus.BLOCKED_UNSUPPORTED_BUILD,
SplashStatus.UNREACHABLE_LAUNCH,
]);
const DIAGNOSTICS_HIDDEN_STATUSES = new Set([SplashStatus.LAUNCHING, SplashStatus.SHELL_UPDATE_RESTARTING]);
const ACTION_CHANNELS = new Map([
['retry', SPLASH_RETRY_CHANNEL],
['download', SPLASH_OPEN_DOWNLOAD_CHANNEL],
@@ -68,6 +87,9 @@ let renderedLayout = null;
let renderedManualSignature = null;
let countdownTimer = null;
let selectedOption = null;
let splashStartedAt = Date.now();
let diagnosticsRevealTimer = null;
let diagnosticsCopiedTimer = null;
function toCount(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
@@ -128,6 +150,9 @@ function normalizeState(payload) {
total: null,
progress: null,
seconds: null,
receivedBytes: null,
totalBytes: null,
bytesPerSecond: null,
action: null,
message: null,
versionLabel: null,
@@ -142,6 +167,9 @@ function normalizeState(payload) {
total: toCount(payload.total),
progress: typeof payload.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : null,
seconds: toCount(payload.seconds),
receivedBytes: toCount(payload.receivedBytes),
totalBytes: toCount(payload.totalBytes),
bytesPerSecond: toCount(payload.bytesPerSecond),
action: normalizeAction(payload.action),
message: toText(payload.message, SPLASH_MESSAGE_MAX_LENGTH),
versionLabel: toText(payload.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH),
@@ -161,6 +189,8 @@ function getStatusText(state) {
: `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`;
case SplashStatus.VERIFYING:
return state.requiredSecurityUpdate ? 'Verifying required security update…' : 'Verifying files…';
case SplashStatus.DOWNLOAD_STALLED:
return `Download stalled. Retrying in ${state.seconds ?? 0} sec…`;
case SplashStatus.UPDATE_FAILURE:
return state.requiredSecurityUpdate
? `Required security update failed. Retrying in ${state.seconds ?? 0} sec…`
@@ -204,9 +234,39 @@ function shouldShowProgress(state) {
return state.progress !== null && PROGRESS_STATUSES.has(state.status);
}
function toProgressPercent(progress) {
if (progress === null) return 0;
return Math.min(100, Math.max(0, progress));
function toProgressPercent(state) {
if (state.receivedBytes !== null && state.totalBytes !== null && state.totalBytes > 0) {
return Math.min(100, Math.max(0, (state.receivedBytes / state.totalBytes) * 100));
}
if (state.progress === null) return 0;
return Math.min(100, Math.max(0, state.progress));
}
function formatMegabytes(bytes) {
const megabytes = bytes / BYTES_PER_MEGABYTE;
return megabytes >= 10 ? String(Math.round(megabytes)) : megabytes.toFixed(1);
}
function formatSpeed(bytesPerSecond) {
if (bytesPerSecond >= BYTES_PER_MEGABYTE) {
return `${(bytesPerSecond / BYTES_PER_MEGABYTE).toFixed(1)} MB/s`;
}
return `${Math.max(0, Math.round(bytesPerSecond / BYTES_PER_KILOBYTE))} KB/s`;
}
function getMetricsText(state) {
if (state.status !== SplashStatus.DOWNLOADING_UPDATES && state.status !== SplashStatus.DOWNLOAD_STALLED) return '';
if (state.receivedBytes === null || state.totalBytes === null || state.totalBytes === 0) return '';
const amount = `${formatMegabytes(state.receivedBytes)} of ${formatMegabytes(state.totalBytes)} MB`;
if (state.status !== SplashStatus.DOWNLOADING_UPDATES || state.bytesPerSecond === null) return amount;
return `${amount}, ${formatSpeed(state.bytesPerSecond)}`;
}
function shouldShowDiagnostics(state) {
if (state.layout !== SplashLayout.SPLASH) return false;
if (DIAGNOSTICS_HIDDEN_STATUSES.has(state.status)) return false;
if (DIAGNOSTICS_STATUSES.has(state.status)) return true;
return Date.now() - splashStartedAt >= DIAGNOSTICS_REVEAL_MS;
}
function createNode(tagName, className) {
@@ -267,18 +327,55 @@ function buildSplashLayout(mount) {
text.appendChild(createNode('span', 'splash-status'));
text.appendChild(createNode('span', 'splash-detail'));
text.appendChild(createProgressPlaceholder());
text.appendChild(createNode('span', 'splash-metrics'));
text.appendChild(createNode('div', 'splash-action-slot'));
inner.appendChild(media);
inner.appendChild(text);
splash.appendChild(inner);
splash.appendChild(buildDiagnosticsRow());
mount.replaceChildren(splash);
}
function buildDiagnosticsRow() {
const row = createNode('div', 'splash-diagnostics');
const openLogs = createNode('button', 'splash-diagnostics-link');
openLogs.setAttribute('data-action', 'open-logs');
openLogs.textContent = 'Open logs';
openLogs.addEventListener('click', () => {
ipcRenderer.send(SPLASH_OPEN_LOGS_CHANNEL);
});
const copy = createNode('button', 'splash-diagnostics-link');
copy.setAttribute('data-action', 'copy-diagnostics');
copy.textContent = 'Copy diagnostics';
copy.addEventListener('click', () => {
ipcRenderer.send(SPLASH_COPY_DIAGNOSTICS_CHANNEL);
copy.textContent = 'Copied';
if (diagnosticsCopiedTimer != null) clearTimeout(diagnosticsCopiedTimer);
diagnosticsCopiedTimer = setTimeout(() => {
diagnosticsCopiedTimer = null;
copy.textContent = 'Copy diagnostics';
}, DIAGNOSTICS_COPIED_MS);
});
row.appendChild(openLogs);
row.appendChild(copy);
return row;
}
function reconcileDiagnostics(mount, state) {
const row = mount.querySelector('.splash-diagnostics');
if (row == null) return;
if (shouldShowDiagnostics(state)) {
row.classList.add('is-visible');
} else {
row.classList.remove('is-visible');
}
}
function reconcileProgress(mount, state) {
const existing = mount.querySelector('.progress, .progress-placeholder');
if (existing == null) return;
if (shouldShowProgress(state)) {
const percent = toProgressPercent(state.progress);
const percent = toProgressPercent(state);
const complete = existing.querySelector('.complete');
if (complete != null) {
complete.style.width = `${percent}%`;
@@ -426,8 +523,13 @@ function render() {
if (detailNode != null) {
detailNode.textContent = state.message ?? '';
}
const metricsNode = mount.querySelector('.splash-metrics');
if (metricsNode != null) {
metricsNode.textContent = getMetricsText(state) || NON_BREAKING_SPACE;
}
reconcileProgress(mount, state);
reconcileAction(mount, state);
reconcileDiagnostics(mount, state);
}
function hasCountdown(state) {
@@ -491,6 +593,11 @@ function afterNextPaint(callback) {
}
window.addEventListener('DOMContentLoaded', () => {
splashStartedAt = Date.now();
diagnosticsRevealTimer = setTimeout(() => {
diagnosticsRevealTimer = null;
render();
}, DIAGNOSTICS_REVEAL_MS);
render();
const mount = document.getElementById(SPLASH_MOUNT_ID);
const branded = mount == null ? Promise.resolve() : waitForBranding(mount);
@@ -507,4 +614,6 @@ window.addEventListener('online', () => {
window.addEventListener('beforeunload', () => {
stopCountdown();
if (diagnosticsRevealTimer != null) clearTimeout(diagnosticsRevealTimer);
if (diagnosticsCopiedTimer != null) clearTimeout(diagnosticsCopiedTimer);
});
+41
View File
@@ -101,6 +101,15 @@ html {
font-variant-numeric: tabular-nums;
}
&.splash-metrics {
margin-top: 8px;
height: 16px;
line-height: 16px;
font-weight: 400;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
&.splash-detail {
max-width: 280px;
margin: 6px auto 0;
@@ -179,6 +188,38 @@ html {
background: var(--brand-bright);
}
.splash-diagnostics {
position: absolute;
left: 0;
right: 0;
bottom: 14px;
display: flex;
justify-content: center;
gap: 14px;
visibility: hidden;
}
.splash-diagnostics.is-visible {
visibility: visible;
}
.splash-diagnostics-link {
-webkit-app-region: no-drag;
background: none;
border: none;
padding: 2px 0;
color: var(--muted);
font-family: var(--primary-font);
font-size: 11px;
font-weight: 400;
cursor: pointer;
text-decoration: none;
}
.splash-diagnostics-link:hover {
color: var(--white);
}
#splash:has(.splash-inner-dl) {
align-items: stretch;
padding-bottom: 0;
+5
View File
@@ -15,9 +15,14 @@
<div class="splash-text">
<span class="splash-status">Checking for updates…</span>
<div class="progress-placeholder">&nbsp;</div>
<span class="splash-metrics">&nbsp;</span>
<div class="splash-action-slot"></div>
</div>
</div>
<div class="splash-diagnostics">
<button class="splash-diagnostics-link" data-action="open-logs">Open logs</button>
<button class="splash-diagnostics-link" data-action="copy-diagnostics">Copy diagnostics</button>
</div>
</div>
</div>
</body>