From 0ef1cd14c3be963c99e70c3b9bf1a2ad19a40f7b Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 03:06:06 +0200 Subject: [PATCH] feat(desktop): show download progress and diagnostics on splash (#3273) --- fluxer_desktop/src/main/Bootstrap.ts | 77 ++++++++- fluxer_desktop/src/main/ModuleDownloadRate.ts | 35 ++++ .../src/main/ModulePackageInstaller.ts | 38 +++- fluxer_desktop/src/main/ModuleUpdater.ts | 70 +++++++- .../src/main/ShellUpdateCapability.test.mjs | 9 + fluxer_desktop/src/main/SplashDiagnostics.ts | 73 ++++++++ .../main/SplashDownloadDiagnostics.test.mjs | 163 ++++++++++++++++++ fluxer_desktop/src/main/SplashWindow.test.mjs | 25 ++- fluxer_desktop/src/main/SplashWindow.ts | 32 ++++ fluxer_desktop/src/preload/splash.cjs | 117 ++++++++++++- fluxer_desktop/src/splash/index.css | 41 +++++ fluxer_desktop/src/splash/index.html | 5 + 12 files changed, 667 insertions(+), 18 deletions(-) create mode 100644 fluxer_desktop/src/main/ModuleDownloadRate.ts create mode 100644 fluxer_desktop/src/main/SplashDiagnostics.ts create mode 100644 fluxer_desktop/src/main/SplashDownloadDiagnostics.test.mjs diff --git a/fluxer_desktop/src/main/Bootstrap.ts b/fluxer_desktop/src/main/Bootstrap.ts index 8f183ab5a..c355c6183 100644 --- a/fluxer_desktop/src/main/Bootstrap.ts +++ b/fluxer_desktop/src/main/Bootstrap.ts @@ -92,12 +92,15 @@ const SPLASH_STATUS_BY_UPDATER_STATUS: Readonly { } async function runModuleBootstrap(): Promise { - 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 { 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 { 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 => { + 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 { }); }, }); + diagnosticsSource = {store, updater}; const runModuleUpdateAttempt = async (): Promise => { let outcome: ModuleUpdaterOutcome; diff --git a/fluxer_desktop/src/main/ModuleDownloadRate.ts b/fluxer_desktop/src/main/ModuleDownloadRate.ts new file mode 100644 index 000000000..200b44b69 --- /dev/null +++ b/fluxer_desktop/src/main/ModuleDownloadRate.ts @@ -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; + } +} diff --git a/fluxer_desktop/src/main/ModulePackageInstaller.ts b/fluxer_desktop/src/main/ModulePackageInstaller.ts index 24dc7a7c8..d69ffa357 100644 --- a/fluxer_desktop/src/main/ModulePackageInstaller.ts +++ b/fluxer_desktop/src/main/ModulePackageInstaller.ts @@ -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 = 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 | 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; diff --git a/fluxer_desktop/src/main/ModuleUpdater.ts b/fluxer_desktop/src/main/ModuleUpdater.ts index 8ecc9e012..e1ccc2ae3 100644 --- a/fluxer_desktop/src/main/ModuleUpdater.ts +++ b/fluxer_desktop/src/main/ModuleUpdater.ts @@ -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 = 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); } diff --git a/fluxer_desktop/src/main/ShellUpdateCapability.test.mjs b/fluxer_desktop/src/main/ShellUpdateCapability.test.mjs index 7a32a03d1..721ace56a 100644 --- a/fluxer_desktop/src/main/ShellUpdateCapability.test.mjs +++ b/fluxer_desktop/src/main/ShellUpdateCapability.test.mjs @@ -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', diff --git a/fluxer_desktop/src/main/SplashDiagnostics.ts b/fluxer_desktop/src/main/SplashDiagnostics.ts new file mode 100644 index 000000000..c5431af49 --- /dev/null +++ b/fluxer_desktop/src/main/SplashDiagnostics.ts @@ -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>; + 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')); +} diff --git a/fluxer_desktop/src/main/SplashDownloadDiagnostics.test.mjs b/fluxer_desktop/src/main/SplashDownloadDiagnostics.test.mjs new file mode 100644 index 000000000..4c08ee0a0 --- /dev/null +++ b/fluxer_desktop/src/main/SplashDownloadDiagnostics.test.mjs @@ -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')); + }); +}); diff --git a/fluxer_desktop/src/main/SplashWindow.test.mjs b/fluxer_desktop/src/main/SplashWindow.test.mjs index ee12ecc3a..d7e14c59b 100644 --- a/fluxer_desktop/src/main/SplashWindow.test.mjs +++ b/fluxer_desktop/src/main/SplashWindow.test.mjs @@ -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, diff --git a/fluxer_desktop/src/main/SplashWindow.ts b/fluxer_desktop/src/main/SplashWindow.ts index cefa204ed..c66c61924 100644 --- a/fluxer_desktop/src/main/SplashWindow.ts +++ b/fluxer_desktop/src/main/SplashWindow.ts @@ -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(); const quitListeners = new Set(); const networkOnlineListeners = new Set(); const openDownloadListeners = new Set(); +const openLogsListeners = new Set(); +const copyDiagnosticsListeners = new Set(); 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); +} diff --git a/fluxer_desktop/src/preload/splash.cjs b/fluxer_desktop/src/preload/splash.cjs index 66938d282..ad0ee566e 100644 --- a/fluxer_desktop/src/preload/splash.cjs +++ b/fluxer_desktop/src/preload/splash.cjs @@ -8,6 +8,8 @@ const SPLASH_RETRY_CHANNEL = 'desktop-splash:retry-now'; const SPLASH_QUIT_CHANNEL = 'desktop-splash:quit'; const SPLASH_OPEN_DOWNLOAD_CHANNEL = 'desktop-splash:open-download'; const SPLASH_NETWORK_ONLINE_CHANNEL = 'desktop-splash:network-online'; +const SPLASH_OPEN_LOGS_CHANNEL = 'desktop-splash:open-logs'; +const SPLASH_COPY_DIAGNOSTICS_CHANNEL = 'desktop-splash:copy-diagnostics'; const SPLASH_MOUNT_ID = 'splash-mount'; const SPLASH_MARK_SIZE = 88; @@ -23,6 +25,10 @@ const SPLASH_OPTION_LABEL_MAX_LENGTH = 48; const SPLASH_OPTION_BUTTON_LABEL_MAX_LENGTH = 24; const SPLASH_OPTION_MAX_COUNT = 8; const SPLASH_OPTION_VALUE_PATTERN = /^[a-z0-9_]{1,32}$/; +const DIAGNOSTICS_REVEAL_MS = 30000; +const DIAGNOSTICS_COPIED_MS = 2000; +const BYTES_PER_MEGABYTE = 1000000; +const BYTES_PER_KILOBYTE = 1000; const SplashLayout = Object.freeze({ SPLASH: 'splash', @@ -35,6 +41,7 @@ const SplashStatus = Object.freeze({ INSTALLING_UPDATES: 'installing-updates', VERIFYING: 'verifying', UPDATE_FAILURE: 'update-failure', + DOWNLOAD_STALLED: 'download-stalled', SHELL_UPDATE_DOWNLOADING: 'shell-update-downloading', SHELL_UPDATE_RESTARTING: 'shell-update-restarting', BLOCKED_UPDATE_REQUIRED: 'blocked-update-required', @@ -53,9 +60,21 @@ const KNOWN_LAYOUTS = new Set(Object.values(SplashLayout)); const KNOWN_STATUSES = new Set(Object.values(SplashStatus)); const PROGRESS_STATUSES = new Set([ SplashStatus.DOWNLOADING_UPDATES, + SplashStatus.DOWNLOAD_STALLED, SplashStatus.INSTALLING_UPDATES, SplashStatus.SHELL_UPDATE_DOWNLOADING, ]); +const DIAGNOSTICS_STATUSES = new Set([ + SplashStatus.UPDATE_FAILURE, + SplashStatus.DOWNLOAD_STALLED, + SplashStatus.BLOCKED_UPDATE_REQUIRED, + SplashStatus.BLOCKED_UPDATE_UNREACHABLE, + SplashStatus.BLOCKED_SECURITY_UPDATE_REQUIRED, + SplashStatus.BLOCKED_SHELL_UPDATE, + SplashStatus.BLOCKED_UNSUPPORTED_BUILD, + SplashStatus.UNREACHABLE_LAUNCH, +]); +const DIAGNOSTICS_HIDDEN_STATUSES = new Set([SplashStatus.LAUNCHING, SplashStatus.SHELL_UPDATE_RESTARTING]); const ACTION_CHANNELS = new Map([ ['retry', SPLASH_RETRY_CHANNEL], ['download', SPLASH_OPEN_DOWNLOAD_CHANNEL], @@ -68,6 +87,9 @@ let renderedLayout = null; let renderedManualSignature = null; let countdownTimer = null; let selectedOption = null; +let splashStartedAt = Date.now(); +let diagnosticsRevealTimer = null; +let diagnosticsCopiedTimer = null; function toCount(value) { if (typeof value !== 'number' || !Number.isFinite(value)) return null; @@ -128,6 +150,9 @@ function normalizeState(payload) { total: null, progress: null, seconds: null, + receivedBytes: null, + totalBytes: null, + bytesPerSecond: null, action: null, message: null, versionLabel: null, @@ -142,6 +167,9 @@ function normalizeState(payload) { total: toCount(payload.total), progress: typeof payload.progress === 'number' && Number.isFinite(payload.progress) ? payload.progress : null, seconds: toCount(payload.seconds), + receivedBytes: toCount(payload.receivedBytes), + totalBytes: toCount(payload.totalBytes), + bytesPerSecond: toCount(payload.bytesPerSecond), action: normalizeAction(payload.action), message: toText(payload.message, SPLASH_MESSAGE_MAX_LENGTH), versionLabel: toText(payload.versionLabel, SPLASH_VERSION_LABEL_MAX_LENGTH), @@ -161,6 +189,8 @@ function getStatusText(state) { : `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`; case SplashStatus.VERIFYING: return state.requiredSecurityUpdate ? 'Verifying required security update…' : 'Verifying files…'; + case SplashStatus.DOWNLOAD_STALLED: + return `Download stalled. Retrying in ${state.seconds ?? 0} sec…`; case SplashStatus.UPDATE_FAILURE: return state.requiredSecurityUpdate ? `Required security update failed. Retrying in ${state.seconds ?? 0} sec…` @@ -204,9 +234,39 @@ function shouldShowProgress(state) { return state.progress !== null && PROGRESS_STATUSES.has(state.status); } -function toProgressPercent(progress) { - if (progress === null) return 0; - return Math.min(100, Math.max(0, progress)); +function toProgressPercent(state) { + if (state.receivedBytes !== null && state.totalBytes !== null && state.totalBytes > 0) { + return Math.min(100, Math.max(0, (state.receivedBytes / state.totalBytes) * 100)); + } + if (state.progress === null) return 0; + return Math.min(100, Math.max(0, state.progress)); +} + +function formatMegabytes(bytes) { + const megabytes = bytes / BYTES_PER_MEGABYTE; + return megabytes >= 10 ? String(Math.round(megabytes)) : megabytes.toFixed(1); +} + +function formatSpeed(bytesPerSecond) { + if (bytesPerSecond >= BYTES_PER_MEGABYTE) { + return `${(bytesPerSecond / BYTES_PER_MEGABYTE).toFixed(1)} MB/s`; + } + return `${Math.max(0, Math.round(bytesPerSecond / BYTES_PER_KILOBYTE))} KB/s`; +} + +function getMetricsText(state) { + if (state.status !== SplashStatus.DOWNLOADING_UPDATES && state.status !== SplashStatus.DOWNLOAD_STALLED) return ''; + if (state.receivedBytes === null || state.totalBytes === null || state.totalBytes === 0) return ''; + const amount = `${formatMegabytes(state.receivedBytes)} of ${formatMegabytes(state.totalBytes)} MB`; + if (state.status !== SplashStatus.DOWNLOADING_UPDATES || state.bytesPerSecond === null) return amount; + return `${amount}, ${formatSpeed(state.bytesPerSecond)}`; +} + +function shouldShowDiagnostics(state) { + if (state.layout !== SplashLayout.SPLASH) return false; + if (DIAGNOSTICS_HIDDEN_STATUSES.has(state.status)) return false; + if (DIAGNOSTICS_STATUSES.has(state.status)) return true; + return Date.now() - splashStartedAt >= DIAGNOSTICS_REVEAL_MS; } function createNode(tagName, className) { @@ -267,18 +327,55 @@ function buildSplashLayout(mount) { text.appendChild(createNode('span', 'splash-status')); text.appendChild(createNode('span', 'splash-detail')); text.appendChild(createProgressPlaceholder()); + text.appendChild(createNode('span', 'splash-metrics')); text.appendChild(createNode('div', 'splash-action-slot')); inner.appendChild(media); inner.appendChild(text); splash.appendChild(inner); + splash.appendChild(buildDiagnosticsRow()); mount.replaceChildren(splash); } +function buildDiagnosticsRow() { + const row = createNode('div', 'splash-diagnostics'); + const openLogs = createNode('button', 'splash-diagnostics-link'); + openLogs.setAttribute('data-action', 'open-logs'); + openLogs.textContent = 'Open logs'; + openLogs.addEventListener('click', () => { + ipcRenderer.send(SPLASH_OPEN_LOGS_CHANNEL); + }); + const copy = createNode('button', 'splash-diagnostics-link'); + copy.setAttribute('data-action', 'copy-diagnostics'); + copy.textContent = 'Copy diagnostics'; + copy.addEventListener('click', () => { + ipcRenderer.send(SPLASH_COPY_DIAGNOSTICS_CHANNEL); + copy.textContent = 'Copied'; + if (diagnosticsCopiedTimer != null) clearTimeout(diagnosticsCopiedTimer); + diagnosticsCopiedTimer = setTimeout(() => { + diagnosticsCopiedTimer = null; + copy.textContent = 'Copy diagnostics'; + }, DIAGNOSTICS_COPIED_MS); + }); + row.appendChild(openLogs); + row.appendChild(copy); + return row; +} + +function reconcileDiagnostics(mount, state) { + const row = mount.querySelector('.splash-diagnostics'); + if (row == null) return; + if (shouldShowDiagnostics(state)) { + row.classList.add('is-visible'); + } else { + row.classList.remove('is-visible'); + } +} + function reconcileProgress(mount, state) { const existing = mount.querySelector('.progress, .progress-placeholder'); if (existing == null) return; if (shouldShowProgress(state)) { - const percent = toProgressPercent(state.progress); + const percent = toProgressPercent(state); const complete = existing.querySelector('.complete'); if (complete != null) { complete.style.width = `${percent}%`; @@ -426,8 +523,13 @@ function render() { if (detailNode != null) { detailNode.textContent = state.message ?? ''; } + const metricsNode = mount.querySelector('.splash-metrics'); + if (metricsNode != null) { + metricsNode.textContent = getMetricsText(state) || NON_BREAKING_SPACE; + } reconcileProgress(mount, state); reconcileAction(mount, state); + reconcileDiagnostics(mount, state); } function hasCountdown(state) { @@ -491,6 +593,11 @@ function afterNextPaint(callback) { } window.addEventListener('DOMContentLoaded', () => { + splashStartedAt = Date.now(); + diagnosticsRevealTimer = setTimeout(() => { + diagnosticsRevealTimer = null; + render(); + }, DIAGNOSTICS_REVEAL_MS); render(); const mount = document.getElementById(SPLASH_MOUNT_ID); const branded = mount == null ? Promise.resolve() : waitForBranding(mount); @@ -507,4 +614,6 @@ window.addEventListener('online', () => { window.addEventListener('beforeunload', () => { stopCountdown(); + if (diagnosticsRevealTimer != null) clearTimeout(diagnosticsRevealTimer); + if (diagnosticsCopiedTimer != null) clearTimeout(diagnosticsCopiedTimer); }); diff --git a/fluxer_desktop/src/splash/index.css b/fluxer_desktop/src/splash/index.css index 0352c9f69..85b3c065c 100644 --- a/fluxer_desktop/src/splash/index.css +++ b/fluxer_desktop/src/splash/index.css @@ -101,6 +101,15 @@ html { font-variant-numeric: tabular-nums; } + &.splash-metrics { + margin-top: 8px; + height: 16px; + line-height: 16px; + font-weight: 400; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + &.splash-detail { max-width: 280px; margin: 6px auto 0; @@ -179,6 +188,38 @@ html { background: var(--brand-bright); } +.splash-diagnostics { + position: absolute; + left: 0; + right: 0; + bottom: 14px; + display: flex; + justify-content: center; + gap: 14px; + visibility: hidden; +} + +.splash-diagnostics.is-visible { + visibility: visible; +} + +.splash-diagnostics-link { + -webkit-app-region: no-drag; + background: none; + border: none; + padding: 2px 0; + color: var(--muted); + font-family: var(--primary-font); + font-size: 11px; + font-weight: 400; + cursor: pointer; + text-decoration: none; +} + +.splash-diagnostics-link:hover { + color: var(--white); +} + #splash:has(.splash-inner-dl) { align-items: stretch; padding-bottom: 0; diff --git a/fluxer_desktop/src/splash/index.html b/fluxer_desktop/src/splash/index.html index d41b7d329..8c7eb935b 100644 --- a/fluxer_desktop/src/splash/index.html +++ b/fluxer_desktop/src/splash/index.html @@ -15,9 +15,14 @@
Checking for updates…
 
+  
+
+ + +