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();
+ });
+});