mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(desktop): show download progress and diagnostics on splash (#3273)
This commit is contained in:
1 parent
795e190bda
commit
0ef1cd14c3
12 files changed
+667
-18
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -15,9 +15,14 @@
|
||||
<div class="splash-text">
|
||||
<span class="splash-status">Checking for updates…</span>
|
||||
<div class="progress-placeholder"> </div>
|
||||
<span class="splash-metrics"> </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>
|
||||
|
||||
Reference in new issue
Block a user