mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
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:
@ -53,6 +53,7 @@ let mainWindow = null;
|
|||||||
let tray = null;
|
let tray = null;
|
||||||
let coreProc = null;
|
let coreProc = null;
|
||||||
let coreStartedAt = 0;
|
let coreStartedAt = 0;
|
||||||
|
let coreReady = false; // true only after the embedded core actually answers HTTP
|
||||||
let settings = {}; // { port, autoStart, minimizeToTray }
|
let settings = {}; // { port, autoStart, minimizeToTray }
|
||||||
let authRules = [];
|
let authRules = [];
|
||||||
|
|
||||||
@ -120,9 +121,9 @@ function coreStarted() {
|
|||||||
|
|
||||||
function writeDefaultEmbeddedConfig() {
|
function writeDefaultEmbeddedConfig() {
|
||||||
// Generate a self-contained embedded profile with a fresh random admin key.
|
// Generate a self-contained embedded profile with a fresh random admin key.
|
||||||
// (Like Clash Verge generates its profile; the core's EnsureDefault default
|
// No upstream sources are preconfigured — the desktop user adds them via
|
||||||
// is 127.0.0.1:8080 — we want our own port + key on first run.) If a config
|
// the WebUI's sources page (like Clash Verge's empty-by-default profile).
|
||||||
// already exists we do NOT overwrite it (keep user's edits).
|
// If a config already exists we do NOT overwrite it (keep user's edits).
|
||||||
if (fs.existsSync(CONFIG_FILE)) return;
|
if (fs.existsSync(CONFIG_FILE)) return;
|
||||||
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
|
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
|
||||||
const yaml = [
|
const yaml = [
|
||||||
@ -132,16 +133,6 @@ function writeDefaultEmbeddedConfig() {
|
|||||||
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
||||||
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
|
"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
|
// 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
|
// shows the replace-the-initial-key warning. gateway_keys entries are
|
||||||
// marked seed:true by the core.
|
// marked seed:true by the core.
|
||||||
@ -211,6 +202,7 @@ function startCore() {
|
|||||||
coreProc.on("exit", (code) => {
|
coreProc.on("exit", (code) => {
|
||||||
const wasManaged = coreStartedAt > 0;
|
const wasManaged = coreStartedAt > 0;
|
||||||
coreStartedAt = 0;
|
coreStartedAt = 0;
|
||||||
|
coreReady = false;
|
||||||
coreProc = null;
|
coreProc = null;
|
||||||
console.log("core exited:", code);
|
console.log("core exited:", code);
|
||||||
// auto-restart if still running GUI (crash recovery, max every ~5s)
|
// auto-restart if still running GUI (crash recovery, max every ~5s)
|
||||||
@ -223,6 +215,12 @@ function startCore() {
|
|||||||
}, 1500);
|
}, 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();
|
notifyCoreState();
|
||||||
// pump until the core answers, then inject the admin key and tell the
|
// 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)
|
// renderer the web UI is ready (the iframe must load only after auth is set)
|
||||||
@ -230,6 +228,7 @@ function startCore() {
|
|||||||
.then((ok) => {
|
.then((ok) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setEmbeddedAuth();
|
setEmbeddedAuth();
|
||||||
|
coreReady = true;
|
||||||
notifyCoreState();
|
notifyCoreState();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -255,6 +254,7 @@ let coreLastExit = 0;
|
|||||||
|
|
||||||
function stopCore() {
|
function stopCore() {
|
||||||
app.isQuitting = true;
|
app.isQuitting = true;
|
||||||
|
coreReady = false;
|
||||||
if (coreProc && coreProc.exitCode === null) {
|
if (coreProc && coreProc.exitCode === null) {
|
||||||
try {
|
try {
|
||||||
coreProc.kill();
|
coreProc.kill();
|
||||||
@ -438,7 +438,8 @@ function createTray() {
|
|||||||
function notifyCoreState() {
|
function notifyCoreState() {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send("core:state", {
|
mainWindow.webContents.send("core:state", {
|
||||||
running: coreStarted(),
|
running: coreStarted() && coreReady,
|
||||||
|
ready: coreReady,
|
||||||
startedAt: coreStartedAt,
|
startedAt: coreStartedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -560,7 +561,8 @@ ipcMain.handle(
|
|||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle("core:state", () => ({
|
ipcMain.handle("core:state", () => ({
|
||||||
running: coreStarted(),
|
running: coreStarted() && coreReady,
|
||||||
|
ready: coreReady,
|
||||||
startedAt: coreStartedAt,
|
startedAt: coreStartedAt,
|
||||||
baseUrl: embeddedBaseUrl(),
|
baseUrl: embeddedBaseUrl(),
|
||||||
port: settings.port || DEFAULT_PORT,
|
port: settings.port || DEFAULT_PORT,
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
core: {
|
core: {
|
||||||
running: false,
|
running: false,
|
||||||
|
ready: false,
|
||||||
baseUrl: "",
|
baseUrl: "",
|
||||||
port: 8787,
|
port: 8787,
|
||||||
profileDir: "",
|
profileDir: "",
|
||||||
@ -18,7 +19,9 @@ const state = {
|
|||||||
minimizeToTray: true,
|
minimizeToTray: true,
|
||||||
},
|
},
|
||||||
theme: localStorage.getItem("mr-theme") || "light",
|
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);
|
const $ = (s) => document.querySelector(s);
|
||||||
@ -55,7 +58,11 @@ function renderCoreStatus() {
|
|||||||
const core = state.core;
|
const core = state.core;
|
||||||
const dot = $("#conn-dot");
|
const dot = $("#conn-dot");
|
||||||
dot.className = "status-dot" + (core.running ? " ok" : " bad");
|
dot.className = "status-dot" + (core.running ? " ok" : " bad");
|
||||||
dot.title = core.running ? "内核运行中" : "内核未运行";
|
dot.title = !core.running
|
||||||
|
? "内核未运行"
|
||||||
|
: core.ready
|
||||||
|
? "内核运行中"
|
||||||
|
: "内核启动中…";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOffline() {
|
function showOffline() {
|
||||||
@ -70,11 +77,47 @@ function showLoading() {
|
|||||||
$("#console-loading").style.display = "flex";
|
$("#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) {
|
function loadFrame(url) {
|
||||||
const f = frame();
|
const f = frame();
|
||||||
f.style.display = "block";
|
f.style.display = "block";
|
||||||
$("#offline").style.display = "none";
|
$("#offline").style.display = "none";
|
||||||
$("#console-loading").style.display = "none";
|
$("#console-loading").style.display = "none";
|
||||||
|
if (state.loadedOnce && f.src === url) return; // already pointing there
|
||||||
f.src = url;
|
f.src = url;
|
||||||
state.loadedOnce = true;
|
state.loadedOnce = true;
|
||||||
}
|
}
|
||||||
@ -82,8 +125,9 @@ function loadFrame(url) {
|
|||||||
async function ensureCore() {
|
async function ensureCore() {
|
||||||
await refreshCore();
|
await refreshCore();
|
||||||
if (state.core.running) {
|
if (state.core.running) {
|
||||||
loadFrame(state.core.baseUrl);
|
scheduleFrameLoad();
|
||||||
} else {
|
} else {
|
||||||
|
state.loadedOnce = false;
|
||||||
showOffline();
|
showOffline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,10 +185,21 @@ async function saveSettings() {
|
|||||||
toast("设置已保存");
|
toast("设置已保存");
|
||||||
await refreshCore();
|
await refreshCore();
|
||||||
if (state.core.running) {
|
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 =====
|
// ===== init =====
|
||||||
function init() {
|
function init() {
|
||||||
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
|
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
|
||||||
@ -171,15 +226,30 @@ function init() {
|
|||||||
showLoading();
|
showLoading();
|
||||||
await window.modelrouter.core.start();
|
await window.modelrouter.core.start();
|
||||||
await refreshCore();
|
await refreshCore();
|
||||||
if (state.core.running) loadFrame(state.core.baseUrl);
|
if (state.core.running) scheduleFrameLoad();
|
||||||
else showOffline();
|
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) => {
|
window.modelrouter.core.onState((d) => {
|
||||||
state.core = Object.assign({}, state.core, d);
|
state.core = Object.assign({}, state.core, d);
|
||||||
renderCoreStatus();
|
renderCoreStatus();
|
||||||
if (d.running && !state.loadedOnce) loadFrame(state.core.baseUrl);
|
if (d.running && !state.loadedOnce) scheduleFrameLoad();
|
||||||
if (!d.running) showOffline();
|
if (!d.running) {
|
||||||
|
state.loadedOnce = false;
|
||||||
|
showOffline();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Escape closes overlay
|
// Escape closes overlay
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
@ -189,6 +259,7 @@ function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
|
applyTheme();
|
||||||
init();
|
init();
|
||||||
state.settings = await window.modelrouter.settings.get();
|
state.settings = await window.modelrouter.settings.get();
|
||||||
renderRail();
|
renderRail();
|
||||||
|
|||||||
Reference in New Issue
Block a user