mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +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 {
|
function toSplashState(state: ModuleUpdaterSplashState): SplashState {
|
||||||
return {
|
return {
|
||||||
status: SPLASH_STATUS_BY_UPDATER_STATUS[state.status],
|
status: state.stalled ? 'download-stalled' : SPLASH_STATUS_BY_UPDATER_STATUS[state.status],
|
||||||
requiredSecurityUpdate: state.requiredSecurityUpdate,
|
requiredSecurityUpdate: state.requiredSecurityUpdate,
|
||||||
current: state.current,
|
current: state.current,
|
||||||
total: state.total,
|
total: state.total,
|
||||||
progress: state.progress,
|
progress: state.progress,
|
||||||
seconds: state.seconds,
|
seconds: state.seconds,
|
||||||
|
receivedBytes: state.receivedBytes,
|
||||||
|
totalBytes: state.totalBytes,
|
||||||
|
bytesPerSecond: state.bytesPerSecond,
|
||||||
message: state.detail,
|
message: state.detail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -259,7 +262,7 @@ async function refuseUnsupportedBuild(reason: string): Promise<never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runModuleBootstrap(): Promise<void> {
|
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 {BUILD_CHANNEL} = await import('@electron/common/BuildChannel');
|
||||||
const {createChildLogger} = await import('@electron/common/Logger');
|
const {createChildLogger} = await import('@electron/common/Logger');
|
||||||
const {loadDesktopConfig} = await import('@electron/common/DesktopConfig');
|
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 {resolveShellUpdatePlan, ShellUpdateCapability} = await import('@electron/main/ShellUpdateCapability');
|
||||||
const {armBlockedShellUpdate} = await import('@electron/main/ShellUpdateSplash');
|
const {armBlockedShellUpdate} = await import('@electron/main/ShellUpdateSplash');
|
||||||
const {UpdateServerRetry} = await import('@electron/main/UpdateServerRetry');
|
const {UpdateServerRetry} = await import('@electron/main/UpdateServerRetry');
|
||||||
|
const {buildSplashDiagnosticsText} = await import('@electron/main/SplashDiagnostics');
|
||||||
const {
|
const {
|
||||||
closeSplashWindow,
|
closeSplashWindow,
|
||||||
focusSplashWindow,
|
focusSplashWindow,
|
||||||
markSplashLaunching,
|
markSplashLaunching,
|
||||||
|
onSplashCopyDiagnostics,
|
||||||
onSplashNetworkOnline,
|
onSplashNetworkOnline,
|
||||||
|
onSplashOpenLogs,
|
||||||
onSplashQuit,
|
onSplashQuit,
|
||||||
onSplashRetry,
|
onSplashRetry,
|
||||||
openSplashWindow,
|
openSplashWindow,
|
||||||
@@ -373,6 +379,72 @@ async function runModuleBootstrap(): Promise<void> {
|
|||||||
updateServerRetry.networkReturned();
|
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 => {
|
const showBlockedSplash = (status: SplashStatus, message: string | null = null): void => {
|
||||||
openSplashWindow();
|
openSplashWindow();
|
||||||
setSplashState({status, action: SplashAction.RETRY, message});
|
setSplashState({status, action: SplashAction.RETRY, message});
|
||||||
@@ -432,6 +504,7 @@ async function runModuleBootstrap(): Promise<void> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
diagnosticsSource = {store, updater};
|
||||||
|
|
||||||
const runModuleUpdateAttempt = async (): Promise<ModuleLaunchPermit | null> => {
|
const runModuleUpdateAttempt = async (): Promise<ModuleLaunchPermit | null> => {
|
||||||
let outcome: ModuleUpdaterOutcome;
|
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_HASH_MISMATCH_RETRIES = 1;
|
||||||
const MODULE_PACKAGE_HEADER_TIMEOUT_MS = 30000;
|
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_HTTP_STATUS_FLOOR = 500;
|
||||||
const TRANSIENT_PACKAGE_HTTP_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
const TRANSIENT_PACKAGE_HTTP_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
|
||||||
const CAUSE_CHAIN_LIMIT = 8;
|
const CAUSE_CHAIN_LIMIT = 8;
|
||||||
@@ -37,6 +38,8 @@ interface ModulePackageInstallProgress {
|
|||||||
readonly total: number;
|
readonly total: number;
|
||||||
readonly moduleName: string;
|
readonly moduleName: string;
|
||||||
readonly progress: number;
|
readonly progress: number;
|
||||||
|
readonly receivedBytes: number;
|
||||||
|
readonly totalBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModulePackageInstallerReportType = Object.freeze({
|
export const ModulePackageInstallerReportType = Object.freeze({
|
||||||
@@ -77,6 +80,7 @@ interface ModulePackageInstallerOptions {
|
|||||||
readonly packageOrigin: string;
|
readonly packageOrigin: string;
|
||||||
readonly fetch: typeof globalThis.fetch;
|
readonly fetch: typeof globalThis.fetch;
|
||||||
readonly onProgress: (progress: ModulePackageInstallProgress) => void;
|
readonly onProgress: (progress: ModulePackageInstallProgress) => void;
|
||||||
|
readonly now?: () => number;
|
||||||
readonly report: (report: ModulePackageInstallerReport) => void;
|
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 {
|
class ModulePackageUrlPolicyError extends Error {
|
||||||
public readonly module: string;
|
public readonly module: string;
|
||||||
|
|
||||||
@@ -168,6 +183,7 @@ export class ModulePackageInstaller {
|
|||||||
private readonly packageOrigin: string;
|
private readonly packageOrigin: string;
|
||||||
private readonly fetchImplementation: typeof globalThis.fetch;
|
private readonly fetchImplementation: typeof globalThis.fetch;
|
||||||
private readonly onProgress: (progress: ModulePackageInstallProgress) => void;
|
private readonly onProgress: (progress: ModulePackageInstallProgress) => void;
|
||||||
|
private readonly now: () => number;
|
||||||
private readonly reporter: (report: ModulePackageInstallerReport) => void;
|
private readonly reporter: (report: ModulePackageInstallerReport) => void;
|
||||||
|
|
||||||
public constructor(options: ModulePackageInstallerOptions) {
|
public constructor(options: ModulePackageInstallerOptions) {
|
||||||
@@ -175,6 +191,7 @@ export class ModulePackageInstaller {
|
|||||||
this.packageOrigin = options.packageOrigin;
|
this.packageOrigin = options.packageOrigin;
|
||||||
this.fetchImplementation = options.fetch;
|
this.fetchImplementation = options.fetch;
|
||||||
this.onProgress = options.onProgress;
|
this.onProgress = options.onProgress;
|
||||||
|
this.now = options.now ?? Date.now;
|
||||||
this.reporter = options.report;
|
this.reporter = options.report;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +209,8 @@ export class ModulePackageInstaller {
|
|||||||
total,
|
total,
|
||||||
moduleName: item.module,
|
moduleName: item.module,
|
||||||
progress: Math.round((current / total) * 100),
|
progress: Math.round((current / total) * 100),
|
||||||
|
receivedBytes: item.entry.bytes,
|
||||||
|
totalBytes: item.entry.bytes,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -238,6 +257,8 @@ export class ModulePackageInstaller {
|
|||||||
total,
|
total,
|
||||||
moduleName: item.module,
|
moduleName: item.module,
|
||||||
progress: item.entry.bytes === 0 ? 0 : Math.floor((offset / item.entry.bytes) * 100),
|
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) {
|
if (offset > 0 && offset === item.entry.bytes) {
|
||||||
return {offset, chunks: []};
|
return {offset, chunks: []};
|
||||||
@@ -339,18 +360,13 @@ export class ModulePackageInstaller {
|
|||||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||||
let received = start;
|
let received = start;
|
||||||
let reported = item.entry.bytes === 0 ? 0 : Math.floor((start / item.entry.bytes) * 100);
|
let reported = item.entry.bytes === 0 ? 0 : Math.floor((start / item.entry.bytes) * 100);
|
||||||
|
let reportedAt = this.now();
|
||||||
let completed = false;
|
let completed = false;
|
||||||
let transferFailure: unknown = null;
|
let transferFailure: unknown = null;
|
||||||
let stall: NodeJS.Timeout | null = null;
|
let stall: NodeJS.Timeout | null = null;
|
||||||
const arm = (): void => {
|
const arm = (): void => {
|
||||||
stall = setTimeout(() => {
|
stall = setTimeout(() => {
|
||||||
controller.abort(
|
controller.abort(new ModulePackageStallError(item.module, MODULE_PACKAGE_STALL_TIMEOUT_MS));
|
||||||
new ModulePackageFetchError(
|
|
||||||
item.module,
|
|
||||||
null,
|
|
||||||
`package download stalled for ${MODULE_PACKAGE_STALL_TIMEOUT_MS}ms`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, MODULE_PACKAGE_STALL_TIMEOUT_MS);
|
}, MODULE_PACKAGE_STALL_TIMEOUT_MS);
|
||||||
};
|
};
|
||||||
const disarm = (): void => {
|
const disarm = (): void => {
|
||||||
@@ -378,14 +394,18 @@ export class ModulePackageInstaller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const percent = item.entry.bytes === 0 ? 100 : Math.floor((received / item.entry.bytes) * 100);
|
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;
|
reported = percent;
|
||||||
|
reportedAt = at;
|
||||||
this.onProgress({
|
this.onProgress({
|
||||||
phase: ModulePackageInstallPhase.DOWNLOADING,
|
phase: ModulePackageInstallPhase.DOWNLOADING,
|
||||||
current,
|
current,
|
||||||
total,
|
total,
|
||||||
moduleName: item.module,
|
moduleName: item.module,
|
||||||
progress: percent,
|
progress: percent,
|
||||||
|
receivedBytes: received,
|
||||||
|
totalBytes: item.entry.bytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
yield chunk.value;
|
yield chunk.value;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {createHash} from 'node:crypto';
|
import {createHash} from 'node:crypto';
|
||||||
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
||||||
|
import {ModuleDownloadRate} from '@electron/main/ModuleDownloadRate';
|
||||||
import {
|
import {
|
||||||
blockingModuleNames,
|
blockingModuleNames,
|
||||||
type DesktopLinuxSecurityMinimum,
|
type DesktopLinuxSecurityMinimum,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
describeErrorChain,
|
describeErrorChain,
|
||||||
isDeterministicPackageFailure,
|
isDeterministicPackageFailure,
|
||||||
|
isModulePackageStall,
|
||||||
isTransientServerStatus,
|
isTransientServerStatus,
|
||||||
ModulePackageFetchError,
|
ModulePackageFetchError,
|
||||||
ModulePackageInstaller,
|
ModulePackageInstaller,
|
||||||
@@ -123,6 +125,17 @@ export interface ModuleUpdaterSplashState {
|
|||||||
readonly seconds: number | null;
|
readonly seconds: number | null;
|
||||||
readonly moduleName: string | null;
|
readonly moduleName: string | null;
|
||||||
readonly detail: 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 {
|
interface ModuleUpdaterReport {
|
||||||
@@ -289,6 +302,11 @@ export class ModuleUpdater {
|
|||||||
private readonly random: () => number;
|
private readonly random: () => number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
private lastState: ModuleUpdaterSplashState;
|
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 ensureBlockedUntil = 0;
|
||||||
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
||||||
private manifestObservationTail: Promise<void> = Promise.resolve();
|
private manifestObservationTail: Promise<void> = Promise.resolve();
|
||||||
@@ -303,6 +321,7 @@ export class ModuleUpdater {
|
|||||||
const arch = resolveDesktopModuleArchitecture(options.arch ?? process.arch);
|
const arch = resolveDesktopModuleArchitecture(options.arch ?? process.arch);
|
||||||
this.manifestFeed = {releaseChannel, platform: this.platform, arch};
|
this.manifestFeed = {releaseChannel, platform: this.platform, arch};
|
||||||
const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, '');
|
const packageOrigin = (options.packageOrigin ?? resolveDesktopPackageOrigin()).replace(/\/+$/u, '');
|
||||||
|
this.packageOrigin = packageOrigin;
|
||||||
this.planner = new ModuleUpdatePlanner(this.store, shellVersion, options.hasOfflineRenderer ?? false);
|
this.planner = new ModuleUpdatePlanner(this.store, shellVersion, options.hasOfflineRenderer ?? false);
|
||||||
this.onState = options.onState ?? (() => {});
|
this.onState = options.onState ?? (() => {});
|
||||||
this.reporter = options.report ?? (() => {});
|
this.reporter = options.report ?? (() => {});
|
||||||
@@ -323,10 +342,15 @@ export class ModuleUpdater {
|
|||||||
store: this.store,
|
store: this.store,
|
||||||
packageOrigin,
|
packageOrigin,
|
||||||
fetch: options.fetch,
|
fetch: options.fetch,
|
||||||
|
now: () => this.now(),
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
switch (progress.phase) {
|
switch (progress.phase) {
|
||||||
case ModulePackageInstallPhase.DOWNLOADING:
|
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;
|
break;
|
||||||
case ModulePackageInstallPhase.INSTALLING:
|
case ModulePackageInstallPhase.INSTALLING:
|
||||||
this.emit(ModuleUpdaterStatus.INSTALLING, progress);
|
this.emit(ModuleUpdaterStatus.INSTALLING, progress);
|
||||||
@@ -346,9 +370,29 @@ export class ModuleUpdater {
|
|||||||
seconds: null,
|
seconds: null,
|
||||||
moduleName: null,
|
moduleName: null,
|
||||||
detail: 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 {
|
public getLastState(): ModuleUpdaterSplashState {
|
||||||
return this.lastState;
|
return this.lastState;
|
||||||
}
|
}
|
||||||
@@ -372,6 +416,8 @@ export class ModuleUpdater {
|
|||||||
const backoff = new ModuleUpdaterBackoff(this.random);
|
const backoff = new ModuleUpdaterBackoff(this.random);
|
||||||
for (;;) {
|
for (;;) {
|
||||||
this.emit(ModuleUpdaterStatus.CHECKING);
|
this.emit(ModuleUpdaterStatus.CHECKING);
|
||||||
|
this.downloadRate.reset();
|
||||||
|
this.downloadProgressed = false;
|
||||||
let fetched = false;
|
let fetched = false;
|
||||||
try {
|
try {
|
||||||
const document = await this.manifestRepository.fetchLatest();
|
const document = await this.manifestRepository.fetchLatest();
|
||||||
@@ -415,8 +461,13 @@ export class ModuleUpdater {
|
|||||||
backoff.reset();
|
backoff.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.reportFailure(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 detail = isStorageFullError(error) ? 'Not enough disk space to install the update' : null;
|
||||||
const updateServerUnreachable = !isRejectedManifestError(error) && isUpdateServerUnreachable(error);
|
const updateServerUnreachable = !isRejectedManifestError(error) && isUpdateServerUnreachable(error);
|
||||||
|
if (this.downloadProgressed) {
|
||||||
|
backoff.reset();
|
||||||
|
}
|
||||||
const delayMs = backoff.fail();
|
const delayMs = backoff.fail();
|
||||||
const exhausted = backoff.reachedCeiling || isRejectedManifestError(error);
|
const exhausted = backoff.reachedCeiling || isRejectedManifestError(error);
|
||||||
if (exhausted || !fetched) {
|
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, {
|
this.emit(ModuleUpdaterStatus.RETRY_WAIT, {
|
||||||
seconds: Math.max(1, Math.round(delayMs / 1000)),
|
seconds: Math.max(1, Math.round(delayMs / 1000)),
|
||||||
detail,
|
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);
|
await this.sleep(delayMs);
|
||||||
}
|
}
|
||||||
@@ -934,6 +994,10 @@ export class ModuleUpdater {
|
|||||||
readonly seconds?: number;
|
readonly seconds?: number;
|
||||||
readonly moduleName?: string;
|
readonly moduleName?: string;
|
||||||
readonly detail?: string | null;
|
readonly detail?: string | null;
|
||||||
|
readonly receivedBytes?: number;
|
||||||
|
readonly totalBytes?: number;
|
||||||
|
readonly bytesPerSecond?: number;
|
||||||
|
readonly stalled?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
this.lastState = {
|
this.lastState = {
|
||||||
@@ -945,6 +1009,10 @@ export class ModuleUpdater {
|
|||||||
seconds: patch.seconds ?? null,
|
seconds: patch.seconds ?? null,
|
||||||
moduleName: patch.moduleName ?? null,
|
moduleName: patch.moduleName ?? null,
|
||||||
detail: patch.detail ?? 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);
|
this.onState(this.lastState);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,6 +391,9 @@ describe('arming the blocked shell update', () => {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: {kind: 'quit', label: 'Quit'},
|
action: {kind: 'quit', label: 'Quit'},
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -420,6 +423,9 @@ describe('arming the blocked shell update', () => {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: {kind: 'download', label: 'Download update'},
|
action: {kind: 'download', label: 'Download update'},
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -449,6 +455,9 @@ describe('arming the blocked shell update', () => {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: SPLASH_MANUAL_UPDATE_MESSAGE,
|
message: SPLASH_MANUAL_UPDATE_MESSAGE,
|
||||||
versionLabel: 'Version 2026.823.1 available',
|
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,
|
total: 7,
|
||||||
progress: 42.5,
|
progress: 42.5,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -560,6 +563,9 @@ describe('splash state serialisation', () => {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
seconds: 0,
|
seconds: 0,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -820,6 +826,9 @@ describe('splash IPC', () => {
|
|||||||
total: 5,
|
total: 5,
|
||||||
progress: 40,
|
progress: 40,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -853,6 +862,9 @@ describe('splash IPC', () => {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -1132,6 +1144,12 @@ function createPreloadHarness() {
|
|||||||
timeouts.push({callback, delayMs});
|
timeouts.push({callback, delayMs});
|
||||||
return timeouts.length;
|
return timeouts.length;
|
||||||
},
|
},
|
||||||
|
clearTimeout: (id) => {
|
||||||
|
const timer = timeouts[id - 1];
|
||||||
|
if (timer != null) {
|
||||||
|
timer.cleared = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
window: {
|
window: {
|
||||||
addEventListener: (eventName, listener) => {
|
addEventListener: (eventName, listener) => {
|
||||||
windowListeners.set(eventName, listener);
|
windowListeners.set(eventName, listener);
|
||||||
@@ -1209,8 +1227,8 @@ describe('splash preload rendering', () => {
|
|||||||
assert.deepEqual(harness.sent, []);
|
assert.deepEqual(harness.sent, []);
|
||||||
assert.equal(harness.query('.splash-mark').decodeCalls, 1);
|
assert.equal(harness.query('.splash-mark').decodeCalls, 1);
|
||||||
assert.deepEqual(harness.fontLoads, ['400 12px "Fluxer Sans"', '500 16px "Fluxer Sans"']);
|
assert.deepEqual(harness.fontLoads, ['400 12px "Fluxer Sans"', '500 16px "Fluxer Sans"']);
|
||||||
assert.equal(harness.timeouts.length, 1);
|
const brandingWaits = harness.timeouts.filter((timer) => timer.delayMs <= 500);
|
||||||
assert.ok(harness.timeouts[0].delayMs <= 500);
|
assert.equal(brandingWaits.length, 1);
|
||||||
|
|
||||||
await harness.settle();
|
await harness.settle();
|
||||||
assert.deepEqual(harness.sent, []);
|
assert.deepEqual(harness.sent, []);
|
||||||
@@ -1713,6 +1731,9 @@ describe('splash preload contract', () => {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: 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_QUIT_CHANNEL = 'desktop-splash:quit';
|
||||||
export const DESKTOP_SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download';
|
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_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({
|
export const SplashLayout = Object.freeze({
|
||||||
SPLASH: 'splash',
|
SPLASH: 'splash',
|
||||||
@@ -41,6 +43,7 @@ export const SplashStatus = Object.freeze({
|
|||||||
INSTALLING_UPDATES: 'installing-updates',
|
INSTALLING_UPDATES: 'installing-updates',
|
||||||
VERIFYING: 'verifying',
|
VERIFYING: 'verifying',
|
||||||
UPDATE_FAILURE: 'update-failure',
|
UPDATE_FAILURE: 'update-failure',
|
||||||
|
DOWNLOAD_STALLED: 'download-stalled',
|
||||||
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
|
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
|
||||||
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
|
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
|
||||||
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
|
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
|
||||||
@@ -91,6 +94,9 @@ export interface SplashState {
|
|||||||
readonly total?: number | null;
|
readonly total?: number | null;
|
||||||
readonly progress?: number | null;
|
readonly progress?: number | null;
|
||||||
readonly seconds?: number | null;
|
readonly seconds?: number | null;
|
||||||
|
readonly receivedBytes?: number | null;
|
||||||
|
readonly totalBytes?: number | null;
|
||||||
|
readonly bytesPerSecond?: number | null;
|
||||||
readonly action?: SplashActionDescriptor | null;
|
readonly action?: SplashActionDescriptor | null;
|
||||||
readonly message?: string | null;
|
readonly message?: string | null;
|
||||||
readonly versionLabel?: string | null;
|
readonly versionLabel?: string | null;
|
||||||
@@ -105,6 +111,9 @@ export interface SerializedSplashState {
|
|||||||
readonly total: number | null;
|
readonly total: number | null;
|
||||||
readonly progress: number | null;
|
readonly progress: number | null;
|
||||||
readonly seconds: number | null;
|
readonly seconds: number | null;
|
||||||
|
readonly receivedBytes: number | null;
|
||||||
|
readonly totalBytes: number | null;
|
||||||
|
readonly bytesPerSecond: number | null;
|
||||||
readonly action: SplashActionDescriptor | null;
|
readonly action: SplashActionDescriptor | null;
|
||||||
readonly message: string | null;
|
readonly message: string | null;
|
||||||
readonly versionLabel: string | null;
|
readonly versionLabel: string | null;
|
||||||
@@ -123,6 +132,8 @@ const retryListeners = new Set<SplashListener>();
|
|||||||
const quitListeners = new Set<SplashListener>();
|
const quitListeners = new Set<SplashListener>();
|
||||||
const networkOnlineListeners = new Set<SplashListener>();
|
const networkOnlineListeners = new Set<SplashListener>();
|
||||||
const openDownloadListeners = new Set<SplashDownloadListener>();
|
const openDownloadListeners = new Set<SplashDownloadListener>();
|
||||||
|
const openLogsListeners = new Set<SplashListener>();
|
||||||
|
const copyDiagnosticsListeners = new Set<SplashListener>();
|
||||||
|
|
||||||
let splashWindow: BrowserWindow | null = null;
|
let splashWindow: BrowserWindow | null = null;
|
||||||
let splashIpcRegistered = false;
|
let splashIpcRegistered = false;
|
||||||
@@ -196,6 +207,9 @@ export function serializeSplashState(state: SplashState): SerializedSplashState
|
|||||||
total: toSplashCount(state.total),
|
total: toSplashCount(state.total),
|
||||||
progress: toSplashProgress(state.progress),
|
progress: toSplashProgress(state.progress),
|
||||||
seconds: toSplashCount(state.seconds),
|
seconds: toSplashCount(state.seconds),
|
||||||
|
receivedBytes: toSplashCount(state.receivedBytes),
|
||||||
|
totalBytes: toSplashCount(state.totalBytes),
|
||||||
|
bytesPerSecond: toSplashCount(state.bytesPerSecond),
|
||||||
action: toSplashAction(state.action),
|
action: toSplashAction(state.action),
|
||||||
message: toSplashText(state.message, SPLASH_MESSAGE_MAX_LENGTH),
|
message: toSplashText(state.message, SPLASH_MESSAGE_MAX_LENGTH),
|
||||||
versionLabel: toSplashText(state.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH),
|
versionLabel: toSplashText(state.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH),
|
||||||
@@ -294,6 +308,14 @@ function registerSplashIpc(): void {
|
|||||||
if (!isSplashSender(event)) return;
|
if (!isSplashSender(event)) return;
|
||||||
emitSplashDownloadListeners(toSplashDownloadValue(value));
|
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 {
|
function registerSplashDiagnostics(window: BrowserWindow): void {
|
||||||
@@ -391,6 +413,8 @@ export function closeSplashWindow(): void {
|
|||||||
quitListeners.clear();
|
quitListeners.clear();
|
||||||
networkOnlineListeners.clear();
|
networkOnlineListeners.clear();
|
||||||
openDownloadListeners.clear();
|
openDownloadListeners.clear();
|
||||||
|
openLogsListeners.clear();
|
||||||
|
copyDiagnosticsListeners.clear();
|
||||||
if (readyWatchdog != null) {
|
if (readyWatchdog != null) {
|
||||||
clearTimeout(readyWatchdog);
|
clearTimeout(readyWatchdog);
|
||||||
readyWatchdog = null;
|
readyWatchdog = null;
|
||||||
@@ -450,3 +474,11 @@ export function onSplashNetworkOnline(listener: SplashListener): () => void {
|
|||||||
export function onSplashOpenDownload(listener: SplashDownloadListener): () => void {
|
export function onSplashOpenDownload(listener: SplashDownloadListener): () => void {
|
||||||
return addSplashListener(openDownloadListeners, listener);
|
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_QUIT_CHANNEL = 'desktop-splash:quit';
|
||||||
const SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download';
|
const SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download';
|
||||||
const SPLASH_NETWORK_ONLINE_CHANNEL = 'desktop-splash:network-online';
|
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_MOUNT_ID = 'splash-mount';
|
||||||
const SPLASH_MARK_SIZE = 88;
|
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_BUTTON_LABEL_MAX_LENGTH = 24;
|
||||||
const SPLASH_OPTION_MAX_COUNT = 8;
|
const SPLASH_OPTION_MAX_COUNT = 8;
|
||||||
const SPLASH_OPTION_VALUE_PATTERN = /^[a-z0-9_]{1,32}$/;
|
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({
|
const SplashLayout = Object.freeze({
|
||||||
SPLASH: 'splash',
|
SPLASH: 'splash',
|
||||||
@@ -35,6 +41,7 @@ const SplashStatus = Object.freeze({
|
|||||||
INSTALLING_UPDATES: 'installing-updates',
|
INSTALLING_UPDATES: 'installing-updates',
|
||||||
VERIFYING: 'verifying',
|
VERIFYING: 'verifying',
|
||||||
UPDATE_FAILURE: 'update-failure',
|
UPDATE_FAILURE: 'update-failure',
|
||||||
|
DOWNLOAD_STALLED: 'download-stalled',
|
||||||
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
|
SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading',
|
||||||
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
|
SHELL_UPDATE_RESTARTING: 'shell-update-restarting',
|
||||||
BLOCKED_UPDATE_REQUIRED: 'blocked-update-required',
|
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 KNOWN_STATUSES = new Set(Object.values(SplashStatus));
|
||||||
const PROGRESS_STATUSES = new Set([
|
const PROGRESS_STATUSES = new Set([
|
||||||
SplashStatus.DOWNLOADING_UPDATES,
|
SplashStatus.DOWNLOADING_UPDATES,
|
||||||
|
SplashStatus.DOWNLOAD_STALLED,
|
||||||
SplashStatus.INSTALLING_UPDATES,
|
SplashStatus.INSTALLING_UPDATES,
|
||||||
SplashStatus.SHELL_UPDATE_DOWNLOADING,
|
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([
|
const ACTION_CHANNELS = new Map([
|
||||||
['retry', SPLASH_RETRY_CHANNEL],
|
['retry', SPLASH_RETRY_CHANNEL],
|
||||||
['download', SPLASH_OPEN_DOWNLOAD_CHANNEL],
|
['download', SPLASH_OPEN_DOWNLOAD_CHANNEL],
|
||||||
@@ -68,6 +87,9 @@ let renderedLayout = null;
|
|||||||
let renderedManualSignature = null;
|
let renderedManualSignature = null;
|
||||||
let countdownTimer = null;
|
let countdownTimer = null;
|
||||||
let selectedOption = null;
|
let selectedOption = null;
|
||||||
|
let splashStartedAt = Date.now();
|
||||||
|
let diagnosticsRevealTimer = null;
|
||||||
|
let diagnosticsCopiedTimer = null;
|
||||||
|
|
||||||
function toCount(value) {
|
function toCount(value) {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||||
@@ -128,6 +150,9 @@ function normalizeState(payload) {
|
|||||||
total: null,
|
total: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
seconds: null,
|
seconds: null,
|
||||||
|
receivedBytes: null,
|
||||||
|
totalBytes: null,
|
||||||
|
bytesPerSecond: null,
|
||||||
action: null,
|
action: null,
|
||||||
message: null,
|
message: null,
|
||||||
versionLabel: null,
|
versionLabel: null,
|
||||||
@@ -142,6 +167,9 @@ function normalizeState(payload) {
|
|||||||
total: toCount(payload.total),
|
total: toCount(payload.total),
|
||||||
progress: typeof payload.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : null,
|
progress: typeof payload.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : null,
|
||||||
seconds: toCount(payload.seconds),
|
seconds: toCount(payload.seconds),
|
||||||
|
receivedBytes: toCount(payload.receivedBytes),
|
||||||
|
totalBytes: toCount(payload.totalBytes),
|
||||||
|
bytesPerSecond: toCount(payload.bytesPerSecond),
|
||||||
action: normalizeAction(payload.action),
|
action: normalizeAction(payload.action),
|
||||||
message: toText(payload.message, SPLASH_MESSAGE_MAX_LENGTH),
|
message: toText(payload.message, SPLASH_MESSAGE_MAX_LENGTH),
|
||||||
versionLabel: toText(payload.versionLabel, SPLASH_VERSION_LABEL_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}…`;
|
: `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`;
|
||||||
case SplashStatus.VERIFYING:
|
case SplashStatus.VERIFYING:
|
||||||
return state.requiredSecurityUpdate ? 'Verifying required security update…' : 'Verifying files…';
|
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:
|
case SplashStatus.UPDATE_FAILURE:
|
||||||
return state.requiredSecurityUpdate
|
return state.requiredSecurityUpdate
|
||||||
? `Required security update failed. Retrying in ${state.seconds ?? 0} sec…`
|
? `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);
|
return state.progress !== null && PROGRESS_STATUSES.has(state.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProgressPercent(progress) {
|
function toProgressPercent(state) {
|
||||||
if (progress === null) return 0;
|
if (state.receivedBytes !== null && state.totalBytes !== null && state.totalBytes > 0) {
|
||||||
return Math.min(100, Math.max(0, progress));
|
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) {
|
function createNode(tagName, className) {
|
||||||
@@ -267,18 +327,55 @@ function buildSplashLayout(mount) {
|
|||||||
text.appendChild(createNode('span', 'splash-status'));
|
text.appendChild(createNode('span', 'splash-status'));
|
||||||
text.appendChild(createNode('span', 'splash-detail'));
|
text.appendChild(createNode('span', 'splash-detail'));
|
||||||
text.appendChild(createProgressPlaceholder());
|
text.appendChild(createProgressPlaceholder());
|
||||||
|
text.appendChild(createNode('span', 'splash-metrics'));
|
||||||
text.appendChild(createNode('div', 'splash-action-slot'));
|
text.appendChild(createNode('div', 'splash-action-slot'));
|
||||||
inner.appendChild(media);
|
inner.appendChild(media);
|
||||||
inner.appendChild(text);
|
inner.appendChild(text);
|
||||||
splash.appendChild(inner);
|
splash.appendChild(inner);
|
||||||
|
splash.appendChild(buildDiagnosticsRow());
|
||||||
mount.replaceChildren(splash);
|
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) {
|
function reconcileProgress(mount, state) {
|
||||||
const existing = mount.querySelector('.progress, .progress-placeholder');
|
const existing = mount.querySelector('.progress, .progress-placeholder');
|
||||||
if (existing == null) return;
|
if (existing == null) return;
|
||||||
if (shouldShowProgress(state)) {
|
if (shouldShowProgress(state)) {
|
||||||
const percent = toProgressPercent(state.progress);
|
const percent = toProgressPercent(state);
|
||||||
const complete = existing.querySelector('.complete');
|
const complete = existing.querySelector('.complete');
|
||||||
if (complete != null) {
|
if (complete != null) {
|
||||||
complete.style.width = `${percent}%`;
|
complete.style.width = `${percent}%`;
|
||||||
@@ -426,8 +523,13 @@ function render() {
|
|||||||
if (detailNode != null) {
|
if (detailNode != null) {
|
||||||
detailNode.textContent = state.message ?? '';
|
detailNode.textContent = state.message ?? '';
|
||||||
}
|
}
|
||||||
|
const metricsNode = mount.querySelector('.splash-metrics');
|
||||||
|
if (metricsNode != null) {
|
||||||
|
metricsNode.textContent = getMetricsText(state) || NON_BREAKING_SPACE;
|
||||||
|
}
|
||||||
reconcileProgress(mount, state);
|
reconcileProgress(mount, state);
|
||||||
reconcileAction(mount, state);
|
reconcileAction(mount, state);
|
||||||
|
reconcileDiagnostics(mount, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasCountdown(state) {
|
function hasCountdown(state) {
|
||||||
@@ -491,6 +593,11 @@ function afterNextPaint(callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
splashStartedAt = Date.now();
|
||||||
|
diagnosticsRevealTimer = setTimeout(() => {
|
||||||
|
diagnosticsRevealTimer = null;
|
||||||
|
render();
|
||||||
|
}, DIAGNOSTICS_REVEAL_MS);
|
||||||
render();
|
render();
|
||||||
const mount = document.getElementById(SPLASH_MOUNT_ID);
|
const mount = document.getElementById(SPLASH_MOUNT_ID);
|
||||||
const branded = mount == null ? Promise.resolve() : waitForBranding(mount);
|
const branded = mount == null ? Promise.resolve() : waitForBranding(mount);
|
||||||
@@ -507,4 +614,6 @@ window.addEventListener('online', () => {
|
|||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
|
if (diagnosticsRevealTimer != null) clearTimeout(diagnosticsRevealTimer);
|
||||||
|
if (diagnosticsCopiedTimer != null) clearTimeout(diagnosticsCopiedTimer);
|
||||||
});
|
});
|
||||||
@@ -101,6 +101,15 @@ html {
|
|||||||
font-variant-numeric: tabular-nums;
|
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 {
|
&.splash-detail {
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
margin: 6px auto 0;
|
margin: 6px auto 0;
|
||||||
@@ -179,6 +188,38 @@ html {
|
|||||||
background: var(--brand-bright);
|
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) {
|
#splash:has(.splash-inner-dl) {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
|||||||
@@ -15,9 +15,14 @@
|
|||||||
<div class="splash-text">
|
<div class="splash-text">
|
||||||
<span class="splash-status">Checking for updates…</span>
|
<span class="splash-status">Checking for updates…</span>
|
||||||
<div class="progress-placeholder"> </div>
|
<div class="progress-placeholder"> </div>
|
||||||
|
<span class="splash-metrics"> </span>
|
||||||
<div class="splash-action-slot"></div>
|
<div class="splash-action-slot"></div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in new issue
Block a user