diff --git a/meshchatx/src/frontend/index.html b/meshchatx/src/frontend/index.html index 9236768..9754927 100644 --- a/meshchatx/src/frontend/index.html +++ b/meshchatx/src/frontend/index.html @@ -7,6 +7,53 @@ Reticulum MeshChatX + +
+ +
MeshChatX
+
Starting…
+
diff --git a/meshchatx/src/frontend/js/Codec2Loader.js b/meshchatx/src/frontend/js/Codec2Loader.js index 9318ad5..f29e5db 100644 --- a/meshchatx/src/frontend/js/Codec2Loader.js +++ b/meshchatx/src/frontend/js/Codec2Loader.js @@ -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} 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; +} diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js index ec5446f..c2799c5 100644 --- a/meshchatx/src/frontend/main.js +++ b/meshchatx/src/frontend/main.js @@ -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(); diff --git a/tests/frontend/Codec2Loader.test.js b/tests/frontend/Codec2Loader.test.js new file mode 100644 index 0000000..30e1f4d --- /dev/null +++ b/tests/frontend/Codec2Loader.test.js @@ -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(); + }); +});