fix(gui): desktop shell stability — gated ready state, iframe retry with backoff, theme toggle

- coreReady now only flips after the embedded core actually answers HTTP
  (fixes blank-screen race where the iframe loaded before the listener)
- renderer probes reachability, retries frame load with exponential backoff,
  and recovers via onerror instead of giving up forever
- keep per-user theme in localStorage
- embedded profile no longer preconfigures a zen source (desktop users add
  their own upstreams on the sources page)
This commit is contained in:
JianFeeeee
2026-08-17 08:53:28 +08:00
parent e356885130
commit f67831c3c2
2 changed files with 96 additions and 23 deletions

View File

@ -5,6 +5,7 @@
const state = {
core: {
running: false,
ready: false,
baseUrl: "",
port: 8787,
profileDir: "",
@ -18,7 +19,9 @@ const state = {
minimizeToTray: true,
},
theme: localStorage.getItem("mr-theme") || "light",
loadedOnce: false,
loadedOnce: false, // iframe has been pointed at the web UI at least once
frameLoaded: false, // last loadFrame actually finished loading (did-finish-load)
loadTries: 0,
};
const $ = (s) => document.querySelector(s);
@ -55,7 +58,11 @@ function renderCoreStatus() {
const core = state.core;
const dot = $("#conn-dot");
dot.className = "status-dot" + (core.running ? " ok" : " bad");
dot.title = core.running ? "内核运行中" : "内核未运行";
dot.title = !core.running
? "内核未运行"
: core.ready
? "内核运行中"
: "内核启动中…";
}
function showOffline() {
@ -70,11 +77,47 @@ function showLoading() {
$("#console-loading").style.display = "flex";
}
// probe the core's web root directly (fetch from the renderer). Using
// fetch() instead of relying on the iframe's own load means the core's
// readiness is confirmed by an actual HTTP round-trip before we point the
// iframe at it — this is what prevents the blank-screen race where the
// frame load happens while the listener is still coming up.
function coreReachable() {
return fetch(
state.core.baseUrl || "http://127.0.0.1:" + (state.core.port || 8787),
{ method: "GET", redirect: "follow", cache: "no-store" },
)
.then(() => true)
.catch(() => false);
}
// point the iframe at the WebUI, but only after the core is reachable.
// If the load fails (core still booting / raced), we retry with backoff
// instead of giving up forever (the old code set loadedOnce blindly and
// never retried -> permanent blank screen).
let frameRetryTimer = null;
function scheduleFrameLoad() {
if (frameRetryTimer) return; // already scheduled
frameRetryTimer = setTimeout(async () => {
frameRetryTimer = null;
if (!state.core.running) return; // core stopped meanwhile
if (!(await coreReachable())) {
// core not reachable yet: wait a bit and try again
state.loadTries += 1;
showLoading();
scheduleFrameLoad();
return;
}
loadFrame(state.core.baseUrl || "http://127.0.0.1:" + state.core.port);
}, state.loadTries === 0 ? 800 : Math.min(3000, 400 * state.loadTries));
}
function loadFrame(url) {
const f = frame();
f.style.display = "block";
$("#offline").style.display = "none";
$("#console-loading").style.display = "none";
if (state.loadedOnce && f.src === url) return; // already pointing there
f.src = url;
state.loadedOnce = true;
}
@ -82,8 +125,9 @@ function loadFrame(url) {
async function ensureCore() {
await refreshCore();
if (state.core.running) {
loadFrame(state.core.baseUrl);
scheduleFrameLoad();
} else {
state.loadedOnce = false;
showOffline();
}
}
@ -141,10 +185,21 @@ async function saveSettings() {
toast("设置已保存");
await refreshCore();
if (state.core.running) {
loadFrame(state.core.baseUrl);
scheduleFrameLoad();
} else {
state.loadedOnce = false;
showOffline();
}
}
// ===== theme =====
function applyTheme() {
document.documentElement.dataset.theme = state.theme;
try {
localStorage.setItem("mr-theme", state.theme);
} catch (e) {}
}
// ===== init =====
function init() {
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
@ -171,15 +226,30 @@ function init() {
showLoading();
await window.modelrouter.core.start();
await refreshCore();
if (state.core.running) loadFrame(state.core.baseUrl);
if (state.core.running) scheduleFrameLoad();
else showOffline();
};
frame().onload = () => refreshCore();
// when the iframe finishes loading, mark loaded and refresh core status so
// the conn-dot reflects the actual WebUI (not just the core reachability)
frame().onload = () => {
state.frameLoaded = true;
refreshCore();
};
// load failure (e.g. the frame was pointed at the core just before it
// finished booting) -> clear loadedOnce and let scheduleFrameLoad retry
frame().onerror = () => {
state.loadedOnce = false;
state.frameLoaded = false;
scheduleFrameLoad();
};
window.modelrouter.core.onState((d) => {
state.core = Object.assign({}, state.core, d);
renderCoreStatus();
if (d.running && !state.loadedOnce) loadFrame(state.core.baseUrl);
if (!d.running) showOffline();
if (d.running && !state.loadedOnce) scheduleFrameLoad();
if (!d.running) {
state.loadedOnce = false;
showOffline();
}
});
// Escape closes overlay
document.addEventListener("keydown", (e) => {
@ -189,6 +259,7 @@ function init() {
}
async function boot() {
applyTheme();
init();
state.settings = await window.modelrouter.settings.get();
renderRail();