mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
532 lines
24 KiB
HTML
532 lines
24 KiB
HTML
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta
|
|
http-equiv="Content-Security-Policy"
|
|
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://127.0.0.1:9337 https://127.0.0.1:9337 http://localhost:9337 https://localhost:9337;"
|
|
/>
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
<meta name="color-scheme" content="light dark" />
|
|
<title>MeshChatX</title>
|
|
<script src="./assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
|
|
<style>
|
|
@keyframes meshchatx-indeterminate {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(350%);
|
|
}
|
|
}
|
|
.meshchatx-bar {
|
|
animation: meshchatx-indeterminate 1.4s ease-in-out infinite;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body
|
|
class="min-h-screen antialiased bg-gradient-to-b from-slate-100 to-slate-200 text-slate-800 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-100"
|
|
>
|
|
<main class="flex min-h-screen items-center justify-center px-4 py-10 sm:px-6">
|
|
<div class="w-full max-w-sm">
|
|
<div
|
|
class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white/90 shadow-lg shadow-slate-200/50 backdrop-blur-sm dark:border-zinc-700/80 dark:bg-zinc-900/90 dark:shadow-black/40"
|
|
>
|
|
<div class="px-6 pt-8 pb-2 text-center">
|
|
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center overflow-visible">
|
|
<img class="h-14 w-14 object-contain" src="./assets/images/logo.png" alt="" />
|
|
</div>
|
|
<h1 class="text-xl font-semibold tracking-tight text-slate-900 dark:text-white">MeshChatX</h1>
|
|
<p id="status-line" class="mt-2 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
|
|
Starting…
|
|
</p>
|
|
</div>
|
|
|
|
<div class="px-6 pb-6">
|
|
<div class="h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-800">
|
|
<div
|
|
class="meshchatx-bar h-full w-1/3 rounded-full bg-blue-600 dark:bg-blue-500"
|
|
aria-hidden="true"
|
|
></div>
|
|
</div>
|
|
<p
|
|
id="attempt-hint"
|
|
class="mt-3 min-h-[1.25rem] text-center text-xs text-slate-500 dark:text-zinc-500"
|
|
></p>
|
|
<p
|
|
id="connection-notice"
|
|
class="mt-2 min-h-[2rem] text-center text-xs leading-relaxed text-amber-700 dark:text-amber-300"
|
|
></p>
|
|
<div
|
|
id="startup-diagnostics"
|
|
class="mt-3 hidden rounded-xl border border-red-200/80 bg-red-50/70 p-3 text-left dark:border-red-900/50 dark:bg-red-950/30"
|
|
>
|
|
<div
|
|
id="startup-diagnosis-summary"
|
|
class="text-xs font-medium text-red-800 dark:text-red-200"
|
|
></div>
|
|
<pre
|
|
id="startup-diagnosis-log"
|
|
class="mt-2 max-h-36 overflow-auto rounded-lg border border-red-200/60 bg-white/80 p-2 font-mono text-[10px] text-red-900 dark:border-red-900/40 dark:bg-zinc-950 dark:text-red-300"
|
|
></pre>
|
|
<p
|
|
id="startup-log-path"
|
|
class="mt-2 break-all text-[10px] text-red-700 dark:text-red-300"
|
|
></p>
|
|
<div class="mt-3 flex flex-wrap justify-center gap-2">
|
|
<button
|
|
id="btn-view-crash-report"
|
|
type="button"
|
|
class="rounded-lg bg-blue-600 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-blue-500"
|
|
>
|
|
View full report
|
|
</button>
|
|
<button
|
|
id="btn-open-logs"
|
|
type="button"
|
|
class="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-red-800 hover:bg-red-100 dark:border-red-800 dark:bg-zinc-900 dark:text-red-200 dark:hover:bg-zinc-800"
|
|
>
|
|
Open logs folder
|
|
</button>
|
|
<button
|
|
id="btn-restart-backend"
|
|
type="button"
|
|
class="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-red-800 hover:bg-red-100 dark:border-red-800 dark:bg-zinc-900 dark:text-red-200 dark:hover:bg-zinc-800"
|
|
>
|
|
Retry backend
|
|
</button>
|
|
<button
|
|
id="btn-emergency-relaunch"
|
|
type="button"
|
|
class="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-red-800 hover:bg-red-100 dark:border-red-800 dark:bg-zinc-900 dark:text-red-200 dark:hover:bg-zinc-800"
|
|
>
|
|
Emergency mode
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="mt-4 text-center text-[11px] text-slate-400 dark:text-zinc-600" id="app-version">
|
|
v0.0.0
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<script src="./loadingStatusNotice.js"></script>
|
|
<script src="./loadingStatusProbe.js"></script>
|
|
<script>
|
|
const statusLine = document.getElementById("status-line");
|
|
const attemptHint = document.getElementById("attempt-hint");
|
|
const connectionNotice = document.getElementById("connection-notice");
|
|
const startupDiagnostics = document.getElementById("startup-diagnostics");
|
|
const startupDiagnosisSummary = document.getElementById("startup-diagnosis-summary");
|
|
const startupDiagnosisLog = document.getElementById("startup-diagnosis-log");
|
|
const startupLogPath = document.getElementById("startup-log-path");
|
|
const btnViewCrashReport = document.getElementById("btn-view-crash-report");
|
|
const btnOpenLogs = document.getElementById("btn-open-logs");
|
|
const btnRestartBackend = document.getElementById("btn-restart-backend");
|
|
const btnEmergencyRelaunch = document.getElementById("btn-emergency-relaunch");
|
|
|
|
const API_HOST = "127.0.0.1";
|
|
const API_PORT = "9337";
|
|
const base = (protocol) => `${protocol}://${API_HOST}:${API_PORT}`;
|
|
const startupParams = new URLSearchParams(window.location.search);
|
|
const NOTICE_AFTER_ATTEMPTS = 10;
|
|
const NETWORK_WARNING_AFTER_ATTEMPTS = 24;
|
|
const RUNTIME_RECHECK_INTERVAL = 4;
|
|
const MAX_FAILURE_HISTORY = 8;
|
|
|
|
let protocolOrder = ["https", "http"];
|
|
|
|
applyTheme(detectPreferredTheme());
|
|
showAppVersion();
|
|
listenForSystemThemeChanges();
|
|
|
|
(async function bootstrap() {
|
|
applyStartupErrorHint();
|
|
wireStartupActions();
|
|
if (window.electron && typeof window.electron.onBackendStartupFailed === "function") {
|
|
window.electron.onBackendStartupFailed((payload) => {
|
|
handleStartupFailure(payload);
|
|
});
|
|
}
|
|
try {
|
|
if (window.electron && typeof window.electron.backendStartupDiagnostics === "function") {
|
|
cachedDiagnostics = await window.electron.backendStartupDiagnostics();
|
|
cachedCrash = cachedDiagnostics?.crash || null;
|
|
}
|
|
} catch (e) {}
|
|
try {
|
|
if (window.electron && typeof window.electron.backendHttpOnly === "function") {
|
|
const httpOnly = await window.electron.backendHttpOnly();
|
|
if (httpOnly) {
|
|
protocolOrder = ["http", "https"];
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
check();
|
|
})();
|
|
|
|
function wireStartupActions() {
|
|
if (btnViewCrashReport) {
|
|
btnViewCrashReport.addEventListener("click", async () => {
|
|
if (!window.electron?.openBackendCrashReport) {
|
|
return;
|
|
}
|
|
await window.electron.openBackendCrashReport();
|
|
});
|
|
}
|
|
if (btnOpenLogs) {
|
|
btnOpenLogs.addEventListener("click", async () => {
|
|
const logsDir = cachedDiagnostics?.paths?.logsDir;
|
|
if (!logsDir || !window.electron?.openPath) {
|
|
return;
|
|
}
|
|
await window.electron.openPath(logsDir);
|
|
});
|
|
}
|
|
if (btnRestartBackend) {
|
|
btnRestartBackend.addEventListener("click", async () => {
|
|
if (!window.electron?.restartBackend) {
|
|
return;
|
|
}
|
|
startupFailed = false;
|
|
if (startupDiagnostics) {
|
|
startupDiagnostics.classList.add("hidden");
|
|
}
|
|
statusLine.textContent = "Retrying backend startup…";
|
|
connectionNotice.textContent = "";
|
|
const result = await window.electron.restartBackend();
|
|
if (!result?.ok) {
|
|
connectionNotice.textContent = result?.error || "Backend restart failed.";
|
|
return;
|
|
}
|
|
attemptCount = 0;
|
|
recentFailures.length = 0;
|
|
scheduleCheck(250);
|
|
});
|
|
}
|
|
if (btnEmergencyRelaunch) {
|
|
btnEmergencyRelaunch.addEventListener("click", async () => {
|
|
if (!window.electron?.relaunchEmergency) {
|
|
return;
|
|
}
|
|
await window.electron.relaunchEmergency();
|
|
});
|
|
}
|
|
}
|
|
|
|
function scheduleCheck(delayMs) {
|
|
if (pollTimer != null) {
|
|
clearTimeout(pollTimer);
|
|
}
|
|
pollTimer = setTimeout(check, delayMs);
|
|
}
|
|
|
|
function formatCrashExcerpt(crash) {
|
|
if (!crash || typeof crash !== "object") {
|
|
return "No backend output was captured.";
|
|
}
|
|
const stderr = (crash.stderr || "").trim();
|
|
const stdout = (crash.stdout || "").trim();
|
|
const chunk = stderr || stdout;
|
|
if (!chunk) {
|
|
return "No backend output was captured.";
|
|
}
|
|
if (chunk.length <= 1800) {
|
|
return chunk;
|
|
}
|
|
return chunk.slice(-1800);
|
|
}
|
|
|
|
function showStartupDiagnostics(issue) {
|
|
if (!startupDiagnostics || !issue) {
|
|
return;
|
|
}
|
|
startupDiagnostics.classList.remove("hidden");
|
|
if (startupDiagnosisSummary) {
|
|
const codeText = issue.exitCode != null ? `Exit code ${issue.exitCode}. ` : "";
|
|
startupDiagnosisSummary.textContent = `${codeText}${issue.detail || issue.headline || ""}`;
|
|
}
|
|
if (startupDiagnosisLog) {
|
|
startupDiagnosisLog.textContent = formatCrashExcerpt(issue.crash || cachedCrash);
|
|
}
|
|
if (startupLogPath) {
|
|
startupLogPath.textContent = issue.logHint || "";
|
|
}
|
|
}
|
|
|
|
function handleStartupFailure(payload) {
|
|
startupFailed = true;
|
|
cachedCrash = payload || cachedCrash;
|
|
if (pollTimer != null) {
|
|
clearTimeout(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
updateStartupNotice(true);
|
|
}
|
|
|
|
function applyStartupErrorHint() {
|
|
const startupError = startupParams.get("startup_error");
|
|
if (startupError !== "backend_unreachable") {
|
|
return;
|
|
}
|
|
statusLine.textContent = "Lost connection to local backend.";
|
|
connectionNotice.textContent =
|
|
"Still retrying. If this continues, firewall or localhost filtering may be blocking access.";
|
|
}
|
|
|
|
async function showAppVersion() {
|
|
try {
|
|
const appVersion = await window.electron.appVersion();
|
|
document.getElementById("app-version").innerText = "v" + appVersion;
|
|
} catch (e) {}
|
|
}
|
|
|
|
function detectPreferredTheme() {
|
|
try {
|
|
const storedTheme =
|
|
localStorage.getItem("meshchat.theme") || localStorage.getItem("meshchatx.theme");
|
|
if (storedTheme === "dark" || storedTheme === "light") {
|
|
return storedTheme;
|
|
}
|
|
} catch (e) {}
|
|
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark"
|
|
: "light";
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
const isDark = theme === "dark";
|
|
document.documentElement.classList.toggle("dark", isDark);
|
|
document.body.dataset.theme = isDark ? "dark" : "light";
|
|
}
|
|
|
|
function listenForSystemThemeChanges() {
|
|
if (!window.matchMedia) {
|
|
return;
|
|
}
|
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
media.addEventListener("change", (event) => {
|
|
applyTheme(event.matches ? "dark" : "light");
|
|
});
|
|
}
|
|
|
|
let detectedProtocol = "http";
|
|
let attemptCount = 0;
|
|
let runtimeProbeAttempt = 0;
|
|
let cachedRuntimeState = null;
|
|
let cachedDiagnostics = null;
|
|
let cachedCrash = null;
|
|
let startupFailed = false;
|
|
let pollTimer = null;
|
|
const recentFailures = [];
|
|
|
|
function parseStatusJson(text) {
|
|
const helper = window.MeshchatLoadingStatusProbe;
|
|
if (helper && typeof helper.parseStatusJson === "function") {
|
|
return helper.parseStatusJson(text);
|
|
}
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function evaluateStatusResponse(httpStatus, bodyText) {
|
|
const helper = window.MeshchatLoadingStatusProbe;
|
|
if (helper && typeof helper.evaluateStatusResponse === "function") {
|
|
return helper.evaluateStatusResponse(httpStatus, bodyText);
|
|
}
|
|
if (Number(httpStatus) !== 200) {
|
|
return {
|
|
ok: false,
|
|
failure: { kind: "http-error", status: Number(httpStatus) || 0 },
|
|
};
|
|
}
|
|
const data = parseStatusJson(bodyText);
|
|
if (data && (data.status === "ok" || data.status === "starting")) {
|
|
return {
|
|
ok: true,
|
|
stage: data.stage || data.status,
|
|
networkReady: !!data.network_ready,
|
|
};
|
|
}
|
|
return { ok: false, failure: { kind: "invalid-payload" } };
|
|
}
|
|
|
|
function rememberFailure(failure) {
|
|
if (!failure || typeof failure !== "object") {
|
|
return;
|
|
}
|
|
recentFailures.push(failure);
|
|
if (recentFailures.length > MAX_FAILURE_HISTORY) {
|
|
recentFailures.shift();
|
|
}
|
|
}
|
|
|
|
async function refreshBackendRuntimeStateIfNeeded() {
|
|
if (!window.electron || typeof window.electron.backendRuntimeState !== "function") {
|
|
return;
|
|
}
|
|
if (attemptCount - runtimeProbeAttempt < RUNTIME_RECHECK_INTERVAL && cachedRuntimeState) {
|
|
return;
|
|
}
|
|
runtimeProbeAttempt = attemptCount;
|
|
try {
|
|
cachedRuntimeState = await window.electron.backendRuntimeState();
|
|
} catch (e) {}
|
|
}
|
|
|
|
function resolveFetchFailureKind(error) {
|
|
const helper = window.MeshchatLoadingStatusNotice;
|
|
if (helper && typeof helper.classifyFetchError === "function") {
|
|
return helper.classifyFetchError(error);
|
|
}
|
|
return "network-error";
|
|
}
|
|
|
|
async function tryOnce(protocol) {
|
|
const url = `${base(protocol)}/api/v1/status`;
|
|
try {
|
|
const result = await fetch(url, { cache: "no-store" });
|
|
const text = await result.text();
|
|
const evaluated = evaluateStatusResponse(result.status, text);
|
|
if (evaluated.ok) {
|
|
return {
|
|
ok: true,
|
|
protocol: protocol,
|
|
stage: evaluated.stage,
|
|
networkReady: evaluated.networkReady,
|
|
};
|
|
}
|
|
const failure = evaluated.failure || { kind: "invalid-payload" };
|
|
return {
|
|
ok: false,
|
|
failure: { ...failure, protocol: protocol },
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
failure: {
|
|
kind: resolveFetchFailureKind(error),
|
|
protocol: protocol,
|
|
message: String((error && error.message) || ""),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function classifyConnectionIssue() {
|
|
const helper = window.MeshchatLoadingStatusNotice;
|
|
if (helper && typeof helper.classifyConnectionIssue === "function") {
|
|
return helper.classifyConnectionIssue(recentFailures, cachedRuntimeState, {
|
|
attemptCount: attemptCount,
|
|
networkWarnAfterAttempts: NETWORK_WARNING_AFTER_ATTEMPTS,
|
|
crash: cachedCrash,
|
|
paths: cachedDiagnostics?.paths || null,
|
|
});
|
|
}
|
|
return {
|
|
reason: "starting",
|
|
headline: "Waiting for backend startup.",
|
|
detail: "MeshChatX is still initializing services.",
|
|
};
|
|
}
|
|
|
|
async function updateStartupNotice(forceDiagnostics) {
|
|
if (startupFailed || forceDiagnostics) {
|
|
await refreshBackendRuntimeStateIfNeeded();
|
|
const issue = classifyConnectionIssue();
|
|
statusLine.textContent = issue.headline;
|
|
connectionNotice.textContent = issue.detail;
|
|
showStartupDiagnostics(issue);
|
|
return;
|
|
}
|
|
if (attemptCount < NOTICE_AFTER_ATTEMPTS) {
|
|
connectionNotice.textContent = "";
|
|
if (startupDiagnostics) {
|
|
startupDiagnostics.classList.add("hidden");
|
|
}
|
|
return;
|
|
}
|
|
await refreshBackendRuntimeStateIfNeeded();
|
|
const issue = classifyConnectionIssue();
|
|
statusLine.textContent = issue.headline;
|
|
connectionNotice.textContent = issue.detail;
|
|
if (issue.reason === "backend-exited") {
|
|
startupFailed = true;
|
|
showStartupDiagnostics(issue);
|
|
}
|
|
}
|
|
|
|
async function check() {
|
|
if (startupFailed) {
|
|
return;
|
|
}
|
|
attemptCount += 1;
|
|
if (attemptCount === 1) {
|
|
attemptHint.textContent = "";
|
|
connectionNotice.textContent = "";
|
|
} else {
|
|
attemptHint.textContent = "Still starting…";
|
|
}
|
|
|
|
// Prefer HTTPS unless the backend was started with --no-https (then HTTP first).
|
|
for (const protocol of protocolOrder) {
|
|
const result = await tryOnce(protocol);
|
|
if (result.ok) {
|
|
if (window.electron && typeof window.electron.markBackendHealthy === "function") {
|
|
try {
|
|
await window.electron.markBackendHealthy();
|
|
} catch (e) {}
|
|
}
|
|
detectedProtocol = result.protocol;
|
|
if (result.networkReady) {
|
|
statusLine.textContent = "Opening the app…";
|
|
} else {
|
|
statusLine.textContent = "Opening the app (network still starting)…";
|
|
}
|
|
attemptHint.textContent = "";
|
|
connectionNotice.textContent = "";
|
|
// Theme/config may 503 until identity is ready; ignore failures.
|
|
syncThemeFromConfig();
|
|
setTimeout(onReady, 200);
|
|
return;
|
|
}
|
|
if (result.failure) {
|
|
rememberFailure(result.failure);
|
|
}
|
|
}
|
|
await updateStartupNotice(false);
|
|
scheduleCheck(350);
|
|
}
|
|
|
|
function onReady() {
|
|
const timestamp = new Date().getTime();
|
|
window.location.href = `${detectedProtocol}://${API_HOST}:${API_PORT}/?nocache=${timestamp}`;
|
|
}
|
|
|
|
async function syncThemeFromConfig() {
|
|
try {
|
|
const response = await fetch(`${detectedProtocol}://${API_HOST}:${API_PORT}/api/v1/config`, {
|
|
cache: "no-store",
|
|
});
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const body = await response.json();
|
|
const cfg = body && body.config ? body.config : body;
|
|
const theme = cfg && cfg.theme;
|
|
if (theme === "dark" || theme === "light") {
|
|
applyTheme(theme);
|
|
try {
|
|
localStorage.setItem("meshchat.theme", theme);
|
|
} catch (e) {}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|