feat(frontend): implement boot splash screen and improve Codec2 script loading with retry logic

This commit is contained in:
Ivan committed 2026-04-23 17:12:39 -05:00
1 parent 084c1105db
commit d4c60897d0
4 files changed
+236 -8

No files matched your search

+52
View File
@@ -7,6 +7,53 @@
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png" />
<title>Reticulum MeshChatX</title>
<style>
#meshchatx-boot-splash {
box-sizing: border-box;
position: fixed;
inset: 0;
z-index: 2147483000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1.5rem;
font:
0.95rem/1.5 system-ui,
-apple-system,
"Segoe UI",
sans-serif;
color: #0f172a;
background: linear-gradient(160deg, #f1f5f9 0%, #e2e8f0 45%, #cbd5e1 100%);
}
@media (prefers-color-scheme: dark) {
#meshchatx-boot-splash {
color: #f1f5f9;
background: linear-gradient(160deg, #0f172a 0%, #18181b 50%, #27272a 100%);
}
}
#meshchatx-boot-splash[data-state="error"] {
background: #450a0a;
color: #fecaca;
}
#meshchatx-boot-splash .meshchatx-boot-logo {
width: 3.25rem;
height: 3.25rem;
object-fit: contain;
}
#meshchatx-boot-splash .meshchatx-boot-title {
font-weight: 600;
font-size: 1.05rem;
letter-spacing: -0.02em;
}
#meshchatx-boot-splash .meshchatx-boot-line {
max-width: 20rem;
text-align: center;
opacity: 0.85;
font-size: 0.875rem;
}
</style>
</head>
<body class="bg-gray-100">
<noscript>
@@ -32,6 +79,11 @@
and reload this page.
</div>
</noscript>
<div id="meshchatx-boot-splash" role="status" aria-live="polite" aria-busy="true">
<img class="meshchatx-boot-logo" src="favicons/favicon-512x512.png" alt="" width="52" height="52" />
<div class="meshchatx-boot-title">MeshChatX</div>
<div class="meshchatx-boot-line" data-boot-line>Starting…</div>
</div>
<div id="app"></div>
<script type="module" src="main.js"></script>
</body>
+75 -4
View File
@@ -8,6 +8,7 @@ const codec2ScriptPaths = [
];
let loadPromise = null;
let resolvedOk = false;
function injectScript(src) {
if (typeof document === "undefined") {
@@ -45,14 +46,84 @@ function injectScript(src) {
});
}
function loadChain() {
return codec2ScriptPaths.reduce((chain, src) => chain.then(() => injectScript(src)), Promise.resolve());
}
/**
* Run fn until it succeeds or maxAttempts is reached. Waits between failures with capped exponential backoff.
*
* @param {() => Promise<void>} fn
* @param {{ maxAttempts?: number, baseDelayMs?: number, maxDelayMs?: number }} options
*/
export async function withRetries(fn, options = {}) {
const maxAttempts = options.maxAttempts ?? 12;
let delayMs = options.baseDelayMs ?? 400;
const maxDelayMs = options.maxDelayMs ?? 8000;
let lastErr;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await fn();
return;
} catch (e) {
lastErr = e;
if (attempt === maxAttempts - 1) {
break;
}
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(maxDelayMs, Math.floor(delayMs * 1.5));
}
}
throw lastErr;
}
/**
* Resolves when all Codec2 helper scripts are present. Rejects if any script fails to load.
* Call from features that require Codec2; use {@link startCodec2ScriptsBackgroundLoad} for startup.
*/
export function ensureCodec2ScriptsLoaded() {
if (typeof window === "undefined") {
return Promise.resolve();
}
if (!loadPromise) {
loadPromise = codec2ScriptPaths.reduce((chain, src) => chain.then(() => injectScript(src)), Promise.resolve());
if (resolvedOk) {
return Promise.resolve();
}
if (!loadPromise) {
loadPromise = loadChain()
.then(() => {
resolvedOk = true;
})
.catch((err) => {
loadPromise = null;
throw err;
});
}
return loadPromise;
}
/**
* Loads Codec2 scripts in the background with retries (embedded server may not be ready on first paint).
* Swallows final failure after logging; the rest of the app stays usable without voice-codec scripts.
*
* @param {{ maxAttempts?: number, baseDelayMs?: number, maxDelayMs?: number }} options
*/
export async function startCodec2ScriptsBackgroundLoad(options = {}) {
if (typeof window === "undefined") {
return;
}
try {
await withRetries(() => ensureCodec2ScriptsLoaded(), {
maxAttempts: options.maxAttempts ?? 12,
baseDelayMs: options.baseDelayMs ?? 400,
maxDelayMs: options.maxDelayMs ?? 8000,
});
} catch (e) {
console.warn("Codec2 scripts failed to load after retries; voice codec features may be unavailable.", e);
}
}
/** Clears loader state (for unit tests only). */
export function resetCodec2LoaderState() {
resolvedOk = false;
loadPromise = null;
}
+20 -4
View File
@@ -11,7 +11,7 @@ injectMeshchatThemeVariables();
window.DOMPurify = DOMPurify;
import "@mdi/font/css/materialdesignicons.css";
import "./fonts/RobotoMonoNerdFont/font.css";
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
import { startCodec2ScriptsBackgroundLoad } from "./js/Codec2Loader";
import { createApiClient } from "./js/apiClient.js";
import App from "./components/App.vue";
@@ -346,10 +346,26 @@ function registerMeshchatServiceWorker() {
});
}
async function bootstrap() {
function bootstrap() {
registerMeshchatServiceWorker();
await ensureCodec2ScriptsLoaded();
createApp(App).use(router).use(vuetify).use(i18n).use(vClickOutside).mount("#app");
const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
try {
createApp(App).use(router).use(vuetify).use(i18n).use(vClickOutside).mount("#app");
} catch (e) {
console.error("MeshChatX bootstrap failed:", e);
if (splash) {
splash.setAttribute("data-state", "error");
const line = splash.querySelector("[data-boot-line]");
if (line) {
line.textContent = "Failed to start. Try closing and reopening the app.";
}
}
return;
}
if (splash) {
splash.remove();
}
void startCodec2ScriptsBackgroundLoad();
}
bootstrap();
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
withRetries,
resetCodec2LoaderState,
ensureCodec2ScriptsLoaded,
startCodec2ScriptsBackgroundLoad,
} from "../../meshchatx/src/frontend/js/Codec2Loader.js";
describe("withRetries", () => {
it("succeeds on first attempt", async () => {
const fn = vi.fn().mockResolvedValue(undefined);
await withRetries(fn, { maxAttempts: 4, baseDelayMs: 1, maxDelayMs: 5 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries with backoff until success", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("a"))
.mockRejectedValueOnce(new Error("b"))
.mockResolvedValue(undefined);
await withRetries(fn, { maxAttempts: 6, baseDelayMs: 1, maxDelayMs: 5 });
expect(fn).toHaveBeenCalledTimes(3);
});
it("throws after maxAttempts failures", async () => {
const fn = vi.fn().mockRejectedValue(new Error("always"));
await expect(withRetries(fn, { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 5 })).rejects.toThrow("always");
expect(fn).toHaveBeenCalledTimes(3);
});
it("performance: completes quickly with tiny delays", async () => {
const fn = vi.fn().mockResolvedValue(undefined);
const t0 = performance.now();
await withRetries(fn, { maxAttempts: 8, baseDelayMs: 0, maxDelayMs: 1 });
expect(performance.now() - t0).toBeLessThan(500);
});
});
describe("Codec2Loader integration (jsdom)", () => {
const origAppend = document.head.appendChild.bind(document.head);
beforeEach(() => {
resetCodec2LoaderState();
vi.restoreAllMocks();
});
afterEach(() => {
resetCodec2LoaderState();
document.head.appendChild = origAppend;
document.querySelectorAll("script[data-codec2-src]").forEach((el) => el.remove());
});
it("ensureCodec2ScriptsLoaded resolves when script tags load", async () => {
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
if (node instanceof HTMLScriptElement && node.src) {
origAppend(node);
queueMicrotask(() => node.dispatchEvent(new Event("load")));
return node;
}
return origAppend(node);
});
await ensureCodec2ScriptsLoaded();
await expect(ensureCodec2ScriptsLoaded()).resolves.toBeUndefined();
expect(document.querySelectorAll("script[data-codec2-src]").length).toBe(6);
});
it("startCodec2ScriptsBackgroundLoad swallows errors after retries", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
if (node instanceof HTMLScriptElement && node.src) {
origAppend(node);
queueMicrotask(() => node.dispatchEvent(new Event("error")));
return node;
}
return origAppend(node);
});
await startCodec2ScriptsBackgroundLoad({
maxAttempts: 2,
baseDelayMs: 0,
maxDelayMs: 1,
});
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});