mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +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);
|
||||
}
|
||||
Reference in new issue
Block a user