mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 03:11:45 +00:00
feat(frontend): implement boot splash screen and improve Codec2 script loading with retry logic
This commit is contained in:
4 files changed
+236
-8
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user