// SPDX-License-Identifier: AGPL-3.0-or-later const {ipcRenderer} = require('electron'); const SPLASH_STATE_CHANNEL = 'desktop-splash:state'; const SPLASH_REVEALED_CHANNEL = 'desktop-splash:revealed'; const SPLASH_READY_CHANNEL = 'desktop-splash:ready'; 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; const SPLASH_MARK_SOURCE = './symbol.svg'; const COUNTDOWN_INTERVAL_MS = 1000; const SPLASH_BRANDING_WAIT_MS = 400; const SPLASH_FONT_FACES = Object.freeze(['400 12px "Fluxer Sans"', '500 16px "Fluxer Sans"']); const NON_BREAKING_SPACE = '\u00a0'; const DIAGNOSTICS_REVEAL_MS = 30000; const DIAGNOSTICS_COPIED_MS = 2000; const BYTES_PER_MEGABYTE = 1000000; const BYTES_PER_KILOBYTE = 1000; const SplashStatus = Object.freeze({ CHECKING_FOR_UPDATES: 'checking-for-updates', DOWNLOADING_UPDATES: 'downloading-updates', 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', BLOCKED_UPDATE_UNREACHABLE: 'blocked-update-unreachable', BLOCKED_SHELL_UPDATE: 'blocked-shell-update', BLOCKED_UNSUPPORTED_BUILD: 'blocked-unsupported-build', LAUNCHING: 'launching', UNREACHABLE_LAUNCH: 'unreachable-launch', WAITING_LOCAL_NETWORK: 'waiting-local-network', }); 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_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], ['quit', SPLASH_QUIT_CHANNEL], ]); const DEFAULT_STATE = Object.freeze({ status: SplashStatus.CHECKING_FOR_UPDATES, current: null, total: null, progress: null, seconds: null, receivedBytes: null, totalBytes: null, bytesPerSecond: null, action: null, message: null, }); let splashState = null; let splashBuilt = false; let countdownTimer = null; let splashStartedAt = Number.POSITIVE_INFINITY; let diagnosticsRevealTimer = null; let diagnosticsCopiedTimer = null; function getStatusText(state) { switch (state.status) { case SplashStatus.DOWNLOADING_UPDATES: return `Downloading update ${state.current ?? 0} of ${state.total ?? 0}…`; case SplashStatus.INSTALLING_UPDATES: return `Installing update ${state.current ?? 0} of ${state.total ?? 0}…`; case SplashStatus.VERIFYING: return 'Verifying files…'; case SplashStatus.DOWNLOAD_STALLED: return `Download stalled. Retrying in ${state.seconds ?? 0} sec…`; case SplashStatus.UPDATE_FAILURE: return `Update failed. Retrying in ${state.seconds ?? 0} sec…`; case SplashStatus.SHELL_UPDATE_DOWNLOADING: return 'Updating Fluxer…'; case SplashStatus.SHELL_UPDATE_RESTARTING: return 'Restarting to finish the update…'; case SplashStatus.BLOCKED_UPDATE_REQUIRED: return 'Update required to continue'; case SplashStatus.BLOCKED_UPDATE_UNREACHABLE: return "Can't reach the Fluxer update server"; case SplashStatus.BLOCKED_SHELL_UPDATE: return 'A new version of Fluxer is required'; case SplashStatus.BLOCKED_UNSUPPORTED_BUILD: return 'This installation is incomplete. Reinstall Fluxer'; case SplashStatus.LAUNCHING: return 'Starting…'; case SplashStatus.UNREACHABLE_LAUNCH: return 'Starting offline…'; case SplashStatus.WAITING_LOCAL_NETWORK: return 'Waiting for local network permission…'; default: return 'Checking for updates…'; } } function shouldShowProgress(state) { return state.progress !== null && PROGRESS_STATUSES.has(state.status); } 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 (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) { const node = document.createElement(tagName); if (className != null) { node.className = className; } return node; } function createMark(className) { const mark = createNode('img', className); mark.setAttribute('src', SPLASH_MARK_SOURCE); mark.setAttribute('alt', ''); mark.setAttribute('width', String(SPLASH_MARK_SIZE)); mark.setAttribute('height', String(SPLASH_MARK_SIZE)); return mark; } function createProgress(percent) { const progress = createNode('div', 'progress'); const bar = createNode('div', 'progress-bar'); const complete = createNode('div', 'complete'); complete.style.width = `${percent}%`; bar.appendChild(complete); progress.appendChild(bar); return progress; } function createProgressPlaceholder() { const placeholder = createNode('div', 'progress-placeholder'); placeholder.textContent = NON_BREAKING_SPACE; return placeholder; } function sendSplashAction(kind) { const channel = ACTION_CHANNELS.get(kind); if (channel == null) return; ipcRenderer.send(channel); } function onSplashActionClick() { if (splashState == null || splashState.action == null) return; sendSplashAction(splashState.action.kind); } function buildSplashLayout(mount) { const splash = createNode('div'); splash.id = 'splash'; const inner = createNode('div', 'splash-inner'); const media = createNode('div', 'splash-media'); media.appendChild(createMark('splash-mark')); const text = createNode('div', 'splash-text'); 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); const complete = existing.querySelector('.complete'); if (complete != null) { complete.style.width = `${percent}%`; return; } existing.replaceWith(createProgress(percent)); return; } if (existing.classList.contains('progress-placeholder')) return; existing.replaceWith(createProgressPlaceholder()); } function reconcileAction(mount, state) { const slot = mount.querySelector('.splash-action-slot'); if (slot == null) return; const existing = slot.querySelector('.splash-action'); if (state.action == null) { if (existing != null) { slot.replaceChildren(); } return; } if (existing == null) { const button = createNode('button', 'splash-action'); button.textContent = state.action.label; button.addEventListener('click', onSplashActionClick); slot.appendChild(button); return; } existing.textContent = state.action.label; } function render() { const mount = document.getElementById(SPLASH_MOUNT_ID); if (mount == null) return; const state = splashState ?? DEFAULT_STATE; if (!splashBuilt) { splashBuilt = true; buildSplashLayout(mount); } const statusNode = mount.querySelector('.splash-status'); if (statusNode != null) { statusNode.textContent = getStatusText(state); } const detailNode = mount.querySelector('.splash-detail'); 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) { return state.seconds != null && state.seconds > 0; } function stopCountdown() { if (countdownTimer == null) return; clearInterval(countdownTimer); countdownTimer = null; } function tickCountdown() { if (splashState == null || !hasCountdown(splashState)) { stopCountdown(); return; } splashState = {...splashState, seconds: splashState.seconds - 1}; if (!hasCountdown(splashState)) { stopCountdown(); } render(); } function syncCountdown(state) { stopCountdown(); if (!hasCountdown(state)) return; countdownTimer = setInterval(tickCountdown, COUNTDOWN_INTERVAL_MS); } ipcRenderer.on(SPLASH_STATE_CHANNEL, (_event, payload) => { splashState = payload ?? DEFAULT_STATE; syncCountdown(splashState); render(); }); function waitForBranding(mount) { const pending = []; const mark = mount.querySelector('.splash-mark'); if (mark != null && typeof mark.decode === 'function') { pending.push(mark.decode().catch(() => undefined)); } const fonts = document.fonts; if (fonts != null && typeof fonts.load === 'function') { for (const face of SPLASH_FONT_FACES) { pending.push(fonts.load(face).catch(() => undefined)); } } return Promise.race([ Promise.all(pending), new Promise((resolve) => { setTimeout(resolve, SPLASH_BRANDING_WAIT_MS); }), ]); } function afterNextPaint(callback) { window.requestAnimationFrame(() => { window.requestAnimationFrame(callback); }); } function restartDiagnosticsReveal() { splashStartedAt = Date.now(); if (diagnosticsRevealTimer != null) clearTimeout(diagnosticsRevealTimer); diagnosticsRevealTimer = setTimeout(() => { diagnosticsRevealTimer = null; render(); }, DIAGNOSTICS_REVEAL_MS); } ipcRenderer.on(SPLASH_REVEALED_CHANNEL, () => { restartDiagnosticsReveal(); render(); }); window.addEventListener('DOMContentLoaded', () => { if (!/(?:^|[?&])held=1(?:&|$)/.test(window.location?.search ?? '')) { restartDiagnosticsReveal(); } render(); const mount = document.getElementById(SPLASH_MOUNT_ID); const branded = mount == null ? Promise.resolve() : waitForBranding(mount); void branded.then(() => { afterNextPaint(() => { ipcRenderer.send(SPLASH_READY_CHANNEL); }); }); }); window.addEventListener('online', () => { ipcRenderer.send(SPLASH_NETWORK_ONLINE_CHANNEL); }); window.addEventListener('beforeunload', () => { stopCountdown(); if (diagnosticsRevealTimer != null) clearTimeout(diagnosticsRevealTimer); if (diagnosticsCopiedTimer != null) clearTimeout(diagnosticsCopiedTimer); });