From f67831c3c2ed92ddeab4c9ab5ebf013312045f79 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 17 Aug 2026 08:53:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(gui):=20desktop=20shell=20stability=20?= =?UTF-8?q?=E2=80=94=20gated=20ready=20state,=20iframe=20retry=20with=20ba?= =?UTF-8?q?ckoff,=20theme=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- cmd/gui/main.js | 32 ++++++++------- cmd/gui/renderer/app.js | 87 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 23 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 9fb3721..e4c09cb 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -53,6 +53,7 @@ let mainWindow = null; let tray = null; let coreProc = null; let coreStartedAt = 0; +let coreReady = false; // true only after the embedded core actually answers HTTP let settings = {}; // { port, autoStart, minimizeToTray } let authRules = []; @@ -120,9 +121,9 @@ function coreStarted() { function writeDefaultEmbeddedConfig() { // Generate a self-contained embedded profile with a fresh random admin key. - // (Like Clash Verge generates its profile; the core's EnsureDefault default - // is 127.0.0.1:8080 — we want our own port + key on first run.) If a config - // already exists we do NOT overwrite it (keep user's edits). + // No upstream sources are preconfigured — the desktop user adds them via + // the WebUI's sources page (like Clash Verge's empty-by-default profile). + // If a config already exists we do NOT overwrite it (keep user's edits). if (fs.existsSync(CONFIG_FILE)) return; const key = "sk-gw-" + crypto.randomBytes(16).toString("hex"); const yaml = [ @@ -132,16 +133,6 @@ function writeDefaultEmbeddedConfig() { "adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")), "runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")), "", - "sources:", - " - name: zen", - " base_url: https://opencode.ai/zen/v1", - " api_key: public", - " adapter: opencode", - " models:", - " - id: deepseek-v4-flash-free", - " priority: 100", - " kind: chat", - "", // Admin key as a plain `keys` entry (no seed flag) so the web UI never // shows the replace-the-initial-key warning. gateway_keys entries are // marked seed:true by the core. @@ -211,6 +202,7 @@ function startCore() { coreProc.on("exit", (code) => { const wasManaged = coreStartedAt > 0; coreStartedAt = 0; + coreReady = false; coreProc = null; console.log("core exited:", code); // auto-restart if still running GUI (crash recovery, max every ~5s) @@ -223,6 +215,12 @@ function startCore() { }, 1500); } }); + // Broadcast starting (NOT ready) immediately. The renderer must not + // attempt to load the iframe until the core actually answers HTTP — + // otherwise the frame load races the listener and fails permanently. + // (Fix for blank-screen: running=true was previously broadcast while the + // core was still booting, so the iframe ERR_CONNECTION_REFUSED and the + // renderer never retried.) notifyCoreState(); // pump until the core answers, then inject the admin key and tell the // renderer the web UI is ready (the iframe must load only after auth is set) @@ -230,6 +228,7 @@ function startCore() { .then((ok) => { if (ok) { setEmbeddedAuth(); + coreReady = true; notifyCoreState(); } }) @@ -255,6 +254,7 @@ let coreLastExit = 0; function stopCore() { app.isQuitting = true; + coreReady = false; if (coreProc && coreProc.exitCode === null) { try { coreProc.kill(); @@ -438,7 +438,8 @@ function createTray() { function notifyCoreState() { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("core:state", { - running: coreStarted(), + running: coreStarted() && coreReady, + ready: coreReady, startedAt: coreStartedAt, }); } @@ -560,7 +561,8 @@ ipcMain.handle( ); ipcMain.handle("core:state", () => ({ - running: coreStarted(), + running: coreStarted() && coreReady, + ready: coreReady, startedAt: coreStartedAt, baseUrl: embeddedBaseUrl(), port: settings.port || DEFAULT_PORT, diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 8f66873..9e03aa3 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -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();