MeshChatX/meshchatx/src/frontend/main.js

599 lines
20 KiB
JavaScript

import { createApp } from "vue";
import { createRouter, createWebHashHistory } from "vue-router";
import { createI18n } from "vue-i18n";
import vClickOutside from "./libs/clickOutside.js";
import DOMPurify from "dompurify";
import "./style.css";
import { injectMeshchatThemeVariables, vuetifyThemesFromTokens } from "./theme/designTokens.js";
import { registerUiI18n } from "./js/localeLoader.js";
injectMeshchatThemeVariables();
window.DOMPurify = DOMPurify;
import "@mdi/font/css/materialdesignicons.css";
import "./fonts/RobotoMonoNerdFont/font.css";
import { startCodec2ScriptsBackgroundLoad } from "./js/Codec2Loader";
import { createApiClient } from "./js/apiClient.js";
import { fetchCsrfToken } from "./js/csrfToken.js";
import { registerCoreContributions } from "./js/registries/registerCoreContributions.js";
import { installWsEventBridge } from "./js/registries/wsEventBridge.js";
import { pluginHost } from "./js/plugins/PluginHost.js";
import GlobalState from "./js/GlobalState.js";
import { recoveryLocationForNetworkError } from "./js/networkRecovery.js";
import ElectronUtils from "./js/ElectronUtils.js";
import {
decideControllerChangeReload,
isIgnorableServiceWorkerRegistrationError,
serviceWorkerRegisterOptions,
shouldRegisterServiceWorker,
unregisterServiceWorkersIfPresent,
} from "./js/pwa/swClientRegister.js";
import "./js/HeapMonitor.js";
registerCoreContributions();
installWsEventBridge();
import App from "./components/App.vue";
import ChangelogModal from "./components/ChangelogModal.vue";
import TutorialModal from "./components/TutorialModal.vue";
import enMessages from "./locales/en.json";
const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "en",
messages: {
en: enMessages,
},
});
registerUiI18n(i18n);
// init vuetify
import { createVuetify } from "vuetify";
const vuetify = createVuetify({
theme: {
defaultTheme: "light",
themes: vuetifyThemesFromTokens(),
},
});
if (!window.location.hash || window.location.hash === "#") {
history.replaceState(null, "", "#/messages");
}
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
name: "auth",
path: "/auth",
component: () => import("./components/auth/AuthPage.vue"),
},
{
path: "/",
redirect: "/messages",
},
{
name: "about",
path: "/about",
component: () => import("./components/about/AboutPage.vue"),
},
{
name: "interfaces",
path: "/interfaces",
component: () => import("./components/interfaces/InterfacesPage.vue"),
},
{
name: "interfaces.add",
path: "/interfaces/add",
component: () => import("./components/interfaces/AddInterfacePage.vue"),
},
{
name: "interfaces.edit",
path: "/interfaces/edit",
component: () => import("./components/interfaces/AddInterfacePage.vue"),
props: {
interface_name: String,
},
},
{
name: "messages",
path: "/messages/:destinationHash?",
props: true,
meta: { stableKey: true },
component: () => import("./components/messages/MessagesPage.vue"),
},
{
name: "contacts",
path: "/contacts",
component: () => import("./components/contacts/ContactsPage.vue"),
},
{
name: "map",
path: "/map",
meta: { keepAlive: true },
component: () => import("./components/map/MapBrowser.vue"),
},
{
name: "map-popout",
path: "/popout/map",
meta: { popoutType: "map", isPopout: true },
component: () => import("./components/map/MapPage.vue"),
},
{
name: "messages-popout",
path: "/popout/messages/:destinationHash?",
props: true,
meta: { popoutType: "conversation", isPopout: true },
component: () => import("./components/messages/MessagesPage.vue"),
},
{
name: "network-visualiser",
path: "/network-visualiser",
component: () => import("./components/network-visualiser/NetworkVisualiserPage.vue"),
},
{
name: "nomadnetwork",
path: "/nomadnetwork/:destinationHash?",
props: true,
meta: { keepAlive: true },
component: () => import("./components/nomadnetwork/NomadNetworkBrowser.vue"),
},
{
name: "relay-chat",
path: "/relay-chat",
component: () => import("./components/relay/RelayChatPage.vue"),
},
{
name: "relay-chat-popout",
path: "/popout/relay-chat/:hubHash/:room?",
props: true,
meta: { popoutType: "relay", isPopout: true },
component: () => import("./components/relay/RelayChatPage.vue"),
},
{
name: "archives",
path: "/archives",
component: () => import("./components/archives/ArchivesPage.vue"),
},
{
name: "nomadnetwork-popout",
path: "/popout/nomadnetwork/:destinationHash?",
props: true,
meta: { popoutType: "nomad", isPopout: true },
component: () => import("./components/nomadnetwork/NomadNetworkPage.vue"),
},
{
name: "propagation-nodes",
path: "/propagation-nodes",
component: () => import("./components/propagation-nodes/PropagationNodesPage.vue"),
},
{
name: "ping",
path: "/ping",
component: () => import("./components/ping/PingPage.vue"),
},
{
name: "rncp",
path: "/rncp",
component: () => import("./components/rncp/RNCPPage.vue"),
},
{
name: "rns-filesync",
path: "/rns-filesync",
component: () => import("./components/filesync/RnsFilesyncPage.vue"),
},
{
name: "rnsh",
path: "/rnsh",
component: () => import("./components/tools/RNSHManagerPage.vue"),
},
{
name: "rnx",
path: "/rnx",
component: () => import("./components/tools/RNXManagerPage.vue"),
},
{
name: "rnstatus",
path: "/rnstatus",
component: () => import("./components/rnstatus/RNStatusPage.vue"),
},
{
name: "rnpath",
path: "/rnpath",
component: () => import("./components/tools/RNPathPage.vue"),
},
{
name: "rnpath-trace",
path: "/rnpath-trace",
component: () => import("./components/tools/RNPathTracePage.vue"),
},
{
name: "rnprobe",
path: "/rnprobe",
component: () => import("./components/rnprobe/RNProbePage.vue"),
},
{
name: "translator",
path: "/translator",
component: () => import("./components/translator/TranslatorPage.vue"),
},
{
name: "bots",
path: "/bots",
component: () => import("./components/tools/BotsPage.vue"),
},
{
name: "forwarder",
path: "/forwarder",
component: () => import("./components/forwarder/ForwarderPage.vue"),
},
{
name: "micron-editor",
path: "/micron-editor",
component: () => import("./components/micron-editor/MicronEditorPage.vue"),
},
{
name: "reticulum-config-editor",
path: "/tools/reticulum-config-editor",
component: () => import("./components/tools/ReticulumConfigEditorPage.vue"),
},
{
name: "mesh-server",
path: "/mesh-server",
component: () => import("./components/page-nodes/PageNodesPage.vue"),
},
{
name: "documentation",
path: "/documentation",
component: () => import("./components/docs/DocsPage.vue"),
},
{
name: "profile.icon",
path: "/profile/icon",
component: () => import("./components/profile/ProfileIconPage.vue"),
},
{
name: "settings",
path: "/settings",
component: () => import("./components/settings/SettingsPage.vue"),
},
{
name: "identities",
path: "/identities",
component: () => import("./components/settings/IdentitiesPage.vue"),
},
{
name: "blocked",
path: "/blocked",
component: () => import("./components/blocked/BlockedPage.vue"),
},
{
name: "tools",
path: "/tools",
component: () => import("./components/tools/ToolsPage.vue"),
},
{
name: "licenses",
path: "/licenses",
component: () => import("./components/licenses/LicensesPage.vue"),
},
{
name: "paper-message",
path: "/tools/paper-message",
component: () => import("./components/tools/PaperMessagePage.vue"),
},
{
name: "sieve-filters",
path: "/tools/sieve-filters",
component: () => import("./components/tools/SieveFiltersPage.vue"),
},
{
name: "message-blocklist",
path: "/tools/message-blocklist",
component: () => import("./components/tools/MessageBlocklistPage.vue"),
},
{
name: "rnode-flasher",
path: "/tools/rnode-flasher",
component: () => import("./components/tools/RNodeFlasherPage.vue"),
},
{
name: "repository-server",
path: "/tools/repository-server",
component: () => import("./components/tools/RepositoryServerPage.vue"),
},
{
name: "debug-logs",
path: "/debug/logs",
component: () => import("./components/debug/DebugLogsPage.vue"),
},
{
name: "call",
path: "/call",
component: () => import("./components/call/CallPage.vue"),
},
{
name: "call-popout",
path: "/popout/call",
meta: { isPopout: true },
component: () => import("./components/call/CallPage.vue"),
},
{
name: "plugin-mcx-bugs",
path: "/plugins/com.meshchatx.mcx-bugs",
component: () => import("./components/plugins/PluginPage.vue"),
props: { pluginId: "com.meshchatx.mcx-bugs" },
},
{
name: "changelog",
path: "/changelog",
component: ChangelogModal,
meta: { isPage: true },
},
{
name: "tutorial",
path: "/tutorial",
component: TutorialModal,
meta: { isPage: true },
},
],
});
window.api = createApiClient({
onAuthError() {
if (router.currentRoute.value.name !== "auth") {
GlobalState.authenticated = false;
router.push("/auth");
}
},
});
import { waitForMeshReady, waitForNetworkReady } from "./js/networkStartupWait.js";
function setBootSplashLine(text) {
const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
const line = splash?.querySelector("[data-boot-line]");
if (line && text) {
line.textContent = text;
}
}
function markBootSplashError() {
const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
if (splash) {
splash.setAttribute("data-state", "error");
}
}
const networkReady = await waitForNetworkReady({
onLine: setBootSplashLine,
onErrorState: markBootSplashError,
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "RNS unavailable";
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
},
});
if (networkReady) {
if (networkReady === "degraded") {
GlobalState.networkDegraded = true;
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
} else if (networkReady === "ui") {
GlobalState.networkStarting = true;
GlobalState.networkReady = false;
} else {
GlobalState.networkStarting = false;
GlobalState.networkReady = true;
}
try {
const statusResponse = await window.api.get("/api/v1/status");
GlobalState.demoMode = !!statusResponse.data?.demo_mode;
if (typeof statusResponse.data?.is_loopback_bind === "boolean") {
GlobalState.isLoopbackBind = statusResponse.data.is_loopback_bind;
}
} catch {
// status optional during early boot
}
try {
await fetchCsrfToken(window.api);
} catch {
// CSRF token will be retried on the next mutating request if needed.
}
router.beforeEach(async (to, from, next) => {
try {
const response = await window.api.get("/api/v1/auth/status");
const status = response.data;
GlobalState.authEnabled = !!status.auth_enabled;
GlobalState.authenticated = !!status.authenticated;
GlobalState.demoMode = !!status.demo_mode;
if (typeof status.is_loopback_bind === "boolean") {
GlobalState.isLoopbackBind = status.is_loopback_bind;
}
GlobalState.authSessionResolved = true;
if (!status.auth_enabled) {
next();
return;
}
if (status.authenticated) {
if (to.name === "auth") {
next("/");
} else {
next();
}
return;
}
if (to.name === "auth") {
next();
return;
}
next("/auth");
} catch (e) {
GlobalState.authSessionResolved = true;
if (e.response?.status === 401 || e.response?.status === 403) {
GlobalState.authenticated = false;
next("/auth");
} else {
next();
}
}
});
function registerMeshchatServiceWorker() {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
return;
}
if (
!shouldRegisterServiceWorker({
isDev: import.meta.env.DEV,
isElectron: ElectronUtils.isElectron(),
})
) {
void unregisterServiceWorkersIfPresent(navigator.serviceWorker);
return;
}
let refreshing = false;
const hadController = Boolean(navigator.serviceWorker.controller);
navigator.serviceWorker.addEventListener("controllerchange", () => {
const decision = decideControllerChangeReload({ hadController, refreshing });
refreshing = decision.nextRefreshing;
if (decision.shouldReload) {
window.location.reload();
}
});
navigator.serviceWorker
.register("/service-worker.js", serviceWorkerRegisterOptions())
.then((registration) => {
const requestUpdate = () => {
try {
void registration.update();
} catch {
// ignore update failures
}
};
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
requestUpdate();
}
});
requestUpdate();
})
.catch((error) => {
if (isIgnorableServiceWorkerRegistrationError(error)) {
return;
}
console.debug("Service worker registration failed:", error);
});
}
function removeBootSplash(splash) {
if (!splash || !splash.isConnected) {
return;
}
splash.setAttribute("aria-busy", "false");
splash.style.transition = "opacity 140ms ease";
splash.style.opacity = "0";
window.setTimeout(() => {
if (splash.isConnected) {
splash.remove();
}
}, 160);
}
function preloadCriticalRouteChunks() {
void import("./components/messages/MessagesPage.vue");
void import("./components/contacts/ContactsPage.vue");
void import("./components/interfaces/InterfacesPage.vue");
}
function bootstrap() {
registerMeshchatServiceWorker();
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;
}
// Keep splash until the first painted frame so WebView does not flash white.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
removeBootSplash(splash);
});
});
preloadCriticalRouteChunks();
if (GlobalState.networkReady) {
void startCodec2ScriptsBackgroundLoad();
void loadPluginsIfEnabled();
} else if (GlobalState.networkStarting) {
void waitForMeshReady({
onLine: () => {},
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "RNS unavailable";
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
},
}).then((meshState) => {
if (meshState === "ready") {
GlobalState.networkStarting = false;
GlobalState.networkReady = true;
GlobalState.networkDegraded = false;
GlobalState.networkDegradedError = null;
void startCodec2ScriptsBackgroundLoad();
void loadPluginsIfEnabled();
} else if (meshState === "degraded") {
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
} else {
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = GlobalState.networkDegradedError || "RNS startup timed out";
}
});
}
if (GlobalState.networkDegraded) {
const recoveryLocation = recoveryLocationForNetworkError(GlobalState.networkDegradedError);
if (recoveryLocation) {
try {
router.replace(recoveryLocation);
} catch {
// Route may not exist yet during early boot, but the banner still guides the user.
}
}
}
}
async function loadPluginsIfEnabled() {
if (!(GlobalState.authenticated || !GlobalState.authEnabled)) {
return;
}
try {
const response = await window.api.get("/api/v1/plugins");
GlobalState.pluginsEnabled = response.data?.plugins_enabled !== false;
if (!GlobalState.pluginsEnabled) {
return;
}
await pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value);
} catch (error) {
console.debug("Plugin host bootstrap failed:", error);
}
}
bootstrap();
}