mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
The README claimed "~15 MB RSS" and, after the log-loading work, "~10 MB idle /
~19 MB with a 29 MB audit log". Those were TEST-INSTANCE numbers: one mock source
and one adapter. The real production config on this host (16 sources, 13
adapters, 59 models) sits at ~37-42 MB, and sat at ~105 MB before this series.
Quoting the single-source figure as the headline was misleading.
Both READMEs now state that memory scales with the number of configured sources
rather than with uptime, give a three-row measurement table (1 source / 1 source
with a 29 MB audit history / the 16-source production instance), and break the
production RSS down per region (Go heap, thread stacks + LuaJIT, mapped binary,
Go reservations, shared libs) so an operator can tell which part their own
deployment will grow.
Two runtime knobs are documented and now shipped by default in the desktop
build's core spawn (cmd/gui/main.js, overridable by exporting either variable):
* MALLOC_ARENA_MAX=2 — LuaJIT allocates through cgo into glibc malloc, and
glibc keeps up to 8*nproc per-thread arenas of ~1 MB that are never returned.
Measured 8-15 arenas (7-12 MB) -> 0.
* GOGC=50 — halves the Go heap target. Documented explicitly as useless ALONE
(measured 20.3 -> 21.5 MB, i.e. worse, because the saved heap is eaten by
more glibc arenas); only the pair cuts settled RSS, by ~19%.
Also corrects the binary size (8-12 MB, ~8 MB after the deploy script's -s -w)
and adds the elastic-pool / on-demand-log / self-healing-cooldown bullets that
README.md already had to README_EN.md.
702 lines
23 KiB
JavaScript
702 lines
23 KiB
JavaScript
// ModelRouter Desktop GUI — main process
|
|
// Clash-Verge-style: bundles and manages an embedded llmsproxy core instance
|
|
// in a per-user profile dir (userData), auto-starts it, keeps a tray icon,
|
|
// supports launch-at-login. Server-side users keep using the pure Go binary.
|
|
const {
|
|
app,
|
|
BrowserWindow,
|
|
ipcMain,
|
|
Menu,
|
|
Tray,
|
|
shell,
|
|
nativeImage,
|
|
nativeTheme,
|
|
} = require("electron");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const http = require("http");
|
|
const os = require("os");
|
|
const crypto = require("crypto");
|
|
const { spawn } = require("child_process");
|
|
|
|
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
|
|
function log(msg) {
|
|
try {
|
|
fs.appendFileSync(LOG_FILE, new Date().toISOString() + " " + msg + "\n");
|
|
} catch (e) {}
|
|
}
|
|
const _consoleLog = console.log,
|
|
_consoleErr = console.error;
|
|
console.log = function () {
|
|
log(Array.prototype.slice.call(arguments).join(" "));
|
|
_consoleLog.apply(null, arguments);
|
|
};
|
|
console.error = function () {
|
|
log("ERR " + Array.prototype.slice.call(arguments).join(" "));
|
|
_consoleErr.apply(null, arguments);
|
|
};
|
|
|
|
// ---------- embedded core profile ----------
|
|
// Per-user profile dir: <userData>/profile
|
|
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
|
|
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
|
|
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
|
|
// Dev: cmd/gui/bin, populated by prepare-core.js.
|
|
const CORE_EXE = path.join(
|
|
app.isPackaged ? process.resourcesPath : __dirname,
|
|
"bin",
|
|
process.platform === "win32" ? "llmsproxy.exe" : "llmsproxy",
|
|
);
|
|
// Packaged window icon: must be a REAL file on disk (extraResource), not an
|
|
// asar path — Electron/GTK pulls BrowserWindow icons from the filesystem and
|
|
// crashes (SIGSEGV) when given an asar virtual path on Linux.
|
|
// Dev: cmd/gui/build/icon.png.
|
|
const WINDOW_ICON = path.join(
|
|
app.isPackaged ? process.resourcesPath : __dirname,
|
|
"build",
|
|
"icon.png",
|
|
);
|
|
// default port for the embedded core (configurable via settings)
|
|
const DEFAULT_PORT = 8787;
|
|
|
|
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 = [];
|
|
|
|
const isDev = process.argv.includes("--dev");
|
|
const silentFlag = process.argv.includes("--silent");
|
|
|
|
function loadSettings() {
|
|
try {
|
|
const p = path.join(app.getPath("userData"), "settings.json");
|
|
if (fs.existsSync(p)) settings = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
} catch (e) {}
|
|
if (typeof settings.port !== "number") settings.port = DEFAULT_PORT;
|
|
if (typeof settings.silentStart !== "boolean") settings.silentStart = false;
|
|
if (typeof settings.minimizeToTray !== "boolean")
|
|
settings.minimizeToTray = true;
|
|
return settings;
|
|
}
|
|
function saveSettings() {
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(app.getPath("userData"), "settings.json"),
|
|
JSON.stringify(settings, null, 2),
|
|
"utf-8",
|
|
);
|
|
} catch (e) {}
|
|
}
|
|
|
|
// read admin key from embedded config.yaml (generated by core first run)
|
|
function readAdminKey() {
|
|
// The embedded profile stores its admin key in `keys` (NOT gateway_keys):
|
|
// gateway_keys entries get marked seed:true by the core (-> "replace the
|
|
// initial key" warning in the web UI), while a plain keys entry authenticates
|
|
// without any seed warning.
|
|
try {
|
|
if (!fs.existsSync(CONFIG_FILE)) return "";
|
|
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
// keys:
|
|
// - key: sk-gw-...
|
|
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
|
|
raw,
|
|
);
|
|
if (k && k[1]) return k[1];
|
|
// legacy: gateway_keys list (pre-migration configs)
|
|
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(
|
|
raw,
|
|
);
|
|
if (g && g[1]) return g[1];
|
|
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
|
|
if (g2 && g2[1]) return g2[1];
|
|
} catch (e) {}
|
|
return "";
|
|
}
|
|
|
|
const embeddedBaseUrl = () =>
|
|
"http://127.0.0.1:" + (settings.port || DEFAULT_PORT);
|
|
|
|
// ---------- embedded core lifecycle ----------
|
|
function isCoreRunning() {
|
|
return coreProc !== null && coreProc.exitCode === null;
|
|
}
|
|
|
|
function coreStarted() {
|
|
return coreStartedAt > 0 && isCoreRunning();
|
|
}
|
|
|
|
function writeDefaultEmbeddedConfig() {
|
|
// Generate a self-contained embedded profile with a fresh random admin key.
|
|
// 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 = [
|
|
"# ModelRouter embedded profile (auto-generated)",
|
|
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
|
|
"default_model: AUTO",
|
|
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
|
|
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
|
|
"",
|
|
// 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.
|
|
"keys:",
|
|
" - key: " + key,
|
|
" role: admin",
|
|
" name: embedded",
|
|
].join("\n");
|
|
try {
|
|
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
|
fs.writeFileSync(CONFIG_FILE, yaml, "utf-8");
|
|
} catch (e) {
|
|
console.error("write embedded config failed:", e);
|
|
}
|
|
}
|
|
|
|
// does the embedded core answer already? returns its admin key if so
|
|
function probeEmbedded() {
|
|
// Use the admin key from the embedded profile so an up core that requires
|
|
// auth (401 without key) is still detected as healthy. Any HTTP response
|
|
// (even 401/200) proves the listener is up; network errors mean it is not.
|
|
return new Promise((resolve) => {
|
|
const u = embeddedBaseUrl();
|
|
const key = readAdminKey();
|
|
const headers = key ? { Authorization: "Bearer " + key } : {};
|
|
const req = http.get(u + "/v1/models", { headers }, (res) => {
|
|
let body = "";
|
|
res.on("data", (c) => (body += c));
|
|
res.on("end", () => resolve({ ok: true, status: res.statusCode, body }));
|
|
});
|
|
req.on("error", () => resolve({ ok: false }));
|
|
req.setTimeout(1200, () => {
|
|
req.destroy();
|
|
resolve({ ok: false });
|
|
});
|
|
});
|
|
}
|
|
|
|
function startCore() {
|
|
if (coreProc && coreProc.exitCode === null) {
|
|
console.log("core already running");
|
|
return;
|
|
}
|
|
if (!fs.existsSync(CORE_EXE)) {
|
|
console.error("embedded core binary missing:", CORE_EXE);
|
|
return;
|
|
}
|
|
// ensure profile dir + config + local adapters exist
|
|
fs.mkdirSync(PROFILE_DIR, { recursive: true });
|
|
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
|
|
|
|
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
|
|
cwd: PROFILE_DIR,
|
|
env: Object.assign({}, process.env, {
|
|
LLMS_PROXY_GUI: "1",
|
|
// Memory tuning, measured on a 16-source deployment (see README 内存占用):
|
|
// MALLOC_ARENA_MAX caps glibc per-thread malloc arenas. LuaJIT allocates
|
|
// through cgo -> glibc malloc, and glibc defaults to 8*nproc arenas, so
|
|
// every OS thread touching malloc reserves its own ~1 MB arena that is
|
|
// never returned to the OS.
|
|
// GOGC halves the Go heap growth target. On its own it does not help (the
|
|
// saved heap is immediately eaten by extra glibc arenas); only the pair
|
|
// lowers settled RSS. The core is I/O bound, so the extra GC is free.
|
|
// Both are overridable: a user who exports them keeps their own values.
|
|
GOGC: process.env.GOGC || "50",
|
|
MALLOC_ARENA_MAX: process.env.MALLOC_ARENA_MAX || "2",
|
|
}),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
coreStartedAt = Date.now();
|
|
// capture core logs -> gui log
|
|
coreProc.stdout.on("data", (d) => log("[core] " + String(d).trim()));
|
|
coreProc.stderr.on("data", (d) => log("[core-err] " + String(d).trim()));
|
|
coreProc.on("error", (err) => {
|
|
console.error("core spawn error:", err.message);
|
|
coreProc = null;
|
|
});
|
|
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)
|
|
if (wasManaged && !app.isQuitting) {
|
|
const since = Date.now() - (coreLastExit || 0);
|
|
if (since < 2000) return; // avoid tight crash loop
|
|
coreLastExit = Date.now();
|
|
setTimeout(() => {
|
|
if (!app.isQuitting) 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)
|
|
waitForCoreReady()
|
|
.then((ok) => {
|
|
if (ok) {
|
|
setEmbeddedAuth();
|
|
coreReady = true;
|
|
notifyCoreState();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// waitForCoreReady polls probeEmbedded until the listener is up (or timeout).
|
|
function waitForCoreReady(timeoutMs = 10000) {
|
|
const start = Date.now();
|
|
return new Promise((resolve) => {
|
|
const poll = () => {
|
|
probeEmbedded().then((p) => {
|
|
if (p.ok) return resolve(true);
|
|
if (Date.now() - start > timeoutMs) return resolve(false);
|
|
setTimeout(poll, 250);
|
|
});
|
|
};
|
|
poll();
|
|
});
|
|
}
|
|
|
|
let coreLastExit = 0;
|
|
|
|
function stopCore() {
|
|
coreReady = false;
|
|
if (coreProc && coreProc.exitCode === null) {
|
|
try {
|
|
coreProc.kill();
|
|
} catch (e) {}
|
|
}
|
|
coreProc = null;
|
|
coreStartedAt = 0;
|
|
notifyCoreState();
|
|
}
|
|
|
|
function restartCore() {
|
|
app.isQuitting = true;
|
|
if (coreProc && coreProc.exitCode === null) {
|
|
try {
|
|
coreProc.kill();
|
|
} catch (e) {}
|
|
}
|
|
coreProc = null;
|
|
coreStartedAt = 0;
|
|
app.isQuitting = false;
|
|
setTimeout(() => startCore(), 400);
|
|
}
|
|
|
|
// ---------- auth rule injection (embedded only) ----------
|
|
function installAuthRule() {
|
|
// Read the admin key live on every request so injection never depends on
|
|
// ordering: as soon as the embedded core has written its config (key exists)
|
|
// the web UI gets authenticated, even if the renderer loaded early.
|
|
const apply = (details, callback) => {
|
|
const url = details.url || "";
|
|
const rule = authRules.find((r) => r.url && url.indexOf(r.url) === 0);
|
|
if (!rule) return callback({ requestHeaders: details.requestHeaders });
|
|
const h = Object.assign({}, details.requestHeaders);
|
|
const key = rule.apiKey || readAdminKey() || "";
|
|
// ModelRouter auth() checks gw_key cookie first; Chromium lets us inject
|
|
// Cookie on top-level nav AND cross-origin XHR/fetch, which the CORS-
|
|
// restricted Authorization header cannot do inside a sandboxed iframe.
|
|
if (key && !/(^|;)\s*gw_key=/.test(h.Cookie || ""))
|
|
h["Cookie"] = (h["Cookie"] ? h["Cookie"] + "; " : "") + "gw_key=" + key;
|
|
if (rule.cookie)
|
|
h["Cookie"] = (h["Cookie"] ? h["Cookie"] + "; " : "") + rule.cookie;
|
|
if (rule.headers)
|
|
Object.keys(rule.headers).forEach((k) => (h[k] = rule.headers[k]));
|
|
callback({ requestHeaders: h });
|
|
};
|
|
require("electron").session.defaultSession.webRequest.onBeforeSendHeaders(
|
|
(details, callback) => {
|
|
try {
|
|
apply(details, callback);
|
|
} catch (e) {
|
|
callback({ requestHeaders: details.requestHeaders });
|
|
}
|
|
},
|
|
);
|
|
require("electron").session.defaultSession.webRequest.onHeadersReceived(
|
|
(details, callback) => {
|
|
const h = Object.assign({}, details.responseHeaders || {});
|
|
const url = details.url || "";
|
|
if (authRules.some((r) => r.url && url.indexOf(r.url) === 0)) {
|
|
h["Access-Control-Allow-Origin"] = ["*"];
|
|
h["Access-Control-Allow-Methods"] = ["GET,POST,PUT,DELETE,OPTIONS"];
|
|
h["Access-Control-Allow-Headers"] = [
|
|
"Content-Type, X-API-Key, Authorization, Cookie, Range",
|
|
];
|
|
}
|
|
callback({ responseHeaders: h });
|
|
},
|
|
);
|
|
}
|
|
|
|
function setEmbeddedAuth() {
|
|
authRules = [];
|
|
authRules.push({ url: embeddedBaseUrl(), apiKey: readAdminKey() || "" });
|
|
}
|
|
|
|
// ---------- tray ----------
|
|
function createTray() {
|
|
// Tray icon: prefer the packaged asset (inside asar) or the dev build dir.
|
|
// GNOME renders tray icons via AppIndicator: a fully transparent PNG with
|
|
// only colored pixels draws as an odd box, so ship an opaque rounded-blue
|
|
// M icon with a subtle white ring for dark/light trays alike.
|
|
const candidates = [
|
|
path.join(__dirname, "build", "icon.png"),
|
|
path.join(process.resourcesPath || "", "build", "icon.png"),
|
|
];
|
|
let img = null;
|
|
for (const c of candidates) {
|
|
try {
|
|
const t = nativeImage.createFromPath(c);
|
|
if (!t.isEmpty()) {
|
|
img = t;
|
|
break;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
if (!img) {
|
|
// clear blue-M fallback (never the transparent fabric pixel)
|
|
img = nativeImage.createFromDataURL(
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAACXBIWXMAAAsTAAALEwEAmpwYAAABkUlEQVQ4jb2Vz0sCQRTH/S+yP8Ydkg7RKRD6FwpzxiWoQyL+Awohab8g6RD04yBeIuhQt7pVkCeFLv4Hzdtf7vhiZnHTXVzUtR485u3j7Wdn38x8J5H4L0vpfFnLASOU1wiD02jnR4QCle9EQgkFnVBuEAY4m3PQGOQnQ9mswIBToKHfJ3PNNDxzkuVJH6x6Ghvqe+4XzHg9WJCvWli68HyjYPh5GQ/zsibcDl4bAUMjWPDWcXFo9abj52tN28+/d0UILFlTgcUA8bXt+vmXtqtyscHdnkDTRlzdBUzrgGANsNMT8cF3z301sqqJuUNTxbdP/fjgwrmlxssHBxv3jooPzqz44M2Sgdwc4OeXwI+uUK3IFI344EzRUAvmCkTH9RZSbrmFgE9aXgukHbecWcC8HgXerniLJm2rbEaCxw6IFI9gQfnaxqtHB9f3DVzRQcXS5ZZb2/OeKzd2CJyisDMuQoxDXJ3QKOdp9r00pnAag/xCBSigyVR+dZ6ZToT68CxPyiJ17UxzNcnaUQ3+a/sBfMAWejRz3/oAAAAASUVORK5CYII=",
|
|
);
|
|
}
|
|
tray = new Tray(img);
|
|
// Rebuild the context menu when the OS theme flips (dark/light) so menu
|
|
// text colors follow the desktop theme; nativeTheme is the Electron API
|
|
// that tracks GTK/appindicator dark mode on Linux.
|
|
const themeRebuild = () => {
|
|
try {
|
|
if (global.__rebuildTray) global.__rebuildTray();
|
|
} catch (e) {}
|
|
};
|
|
try {
|
|
nativeTheme.on("updated", themeRebuild);
|
|
} catch (e) {}
|
|
// Status rows used to be enabled:false, which GTK renders in a fixed light
|
|
// grey that never flips with the system dark/light theme — invisible on
|
|
// light desktops. They are now enabled (normal theme-aware fg) with a
|
|
// no-op click so they stay display-only but inherit the menu text color.
|
|
const statusRow = (text) => ({ label: text, enabled: true, click: () => {} });
|
|
const rebuild = () => {
|
|
const running = coreStarted();
|
|
const menu = Menu.buildFromTemplate([
|
|
statusRow("ModelRouter Desktop"),
|
|
statusRow(running ? "● 内核运行中" : "○ 内核已停止"),
|
|
{ type: "separator" },
|
|
{
|
|
label: "显示主窗口",
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
},
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: "开启内核 (核心网关启动)",
|
|
enabled: !running,
|
|
click: () => startCore(),
|
|
},
|
|
{
|
|
label: "重启内核",
|
|
enabled: running,
|
|
click: () => restartCore(),
|
|
},
|
|
{
|
|
label: "停止内核",
|
|
enabled: running,
|
|
click: () => stopCore(),
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: "开机自启",
|
|
type: "checkbox",
|
|
checked: !!settings.autoStart,
|
|
click: () => {
|
|
settings.autoStart = !settings.autoStart;
|
|
applyAutoStart();
|
|
saveSettings();
|
|
rebuild();
|
|
},
|
|
},
|
|
{
|
|
label: "静默启动",
|
|
type: "checkbox",
|
|
checked: !!settings.silentStart,
|
|
click: () => {
|
|
settings.silentStart = !settings.silentStart;
|
|
saveSettings();
|
|
rebuild();
|
|
},
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: "退出",
|
|
click: () => app.quit(),
|
|
},
|
|
]);
|
|
tray.setContextMenu(menu);
|
|
};
|
|
tray.setToolTip("ModelRouter");
|
|
tray.on("click", () => {
|
|
if (mainWindow) {
|
|
mainWindow.isVisible() ? mainWindow.focus() : mainWindow.show();
|
|
}
|
|
});
|
|
rebuild();
|
|
global.__rebuildTray = rebuild;
|
|
// keep a weak ref; main process keeps tray alive
|
|
}
|
|
|
|
function notifyCoreState() {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send("core:state", {
|
|
running: coreStarted() && coreReady,
|
|
ready: coreReady,
|
|
startedAt: coreStartedAt,
|
|
});
|
|
}
|
|
try {
|
|
if (global.__rebuildTray) global.__rebuildTray();
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---------- auto start / launch at login ----------
|
|
function setupAutoStart() {
|
|
if (settings.autoStart === undefined) settings.autoStart = true;
|
|
applyAutoStart();
|
|
}
|
|
function applyAutoStart() {
|
|
const on = !!settings.autoStart;
|
|
if (process.platform === "linux") {
|
|
// Linux: explicit XDG autostart desktop entry (app.setLoginItemSettings
|
|
// is a no-op on Linux).
|
|
const d = path.join(
|
|
os.homedir(),
|
|
".config",
|
|
"autostart",
|
|
"modelrouter-gui.desktop",
|
|
);
|
|
try {
|
|
if (on) {
|
|
const exe = process.execPath;
|
|
fs.mkdirSync(path.dirname(d), { recursive: true });
|
|
fs.writeFileSync(
|
|
d,
|
|
'[Desktop Entry]\nType=Application\nName=ModelRouter\nComment=Unified LLM gateway\nExec="' +
|
|
exe +
|
|
'" --no-sandbox' +
|
|
(settings.silentStart ? " --silent" : "") +
|
|
"\nTerminal=false\nX-GNOME-Autostart-enabled=true\n",
|
|
"utf-8",
|
|
);
|
|
} else if (fs.existsSync(d)) {
|
|
fs.unlinkSync(d);
|
|
}
|
|
} catch (e) {
|
|
console.warn("linux autostart failed:", e.message);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
app.setLoginItemSettings({ openAtLogin: on });
|
|
} catch (e) {
|
|
console.warn("auto-start not supported:", e.message);
|
|
}
|
|
}
|
|
|
|
// ---------- window ----------
|
|
function createWindow() {
|
|
const menu = Menu.buildFromTemplate([]);
|
|
Menu.setApplicationMenu(menu);
|
|
mainWindow = new BrowserWindow({
|
|
// Window icon (taskbar/dock). Packaged: real file under resourcesPath (see
|
|
// WINDOW_ICON) — an asar path here makes GTK segfault on Linux.
|
|
icon: WINDOW_ICON,
|
|
// GNOME taskbar icons match the window's WM_CLASS (lowercase
|
|
// "modelrouter-gui") against StartupWMClass in the .desktop file.
|
|
backgroundColor: "#1f2937",
|
|
width: 1280,
|
|
height: 860,
|
|
minWidth: 900,
|
|
minHeight: 600,
|
|
title: "ModelRouter",
|
|
frame: false,
|
|
show: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.js"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (url && url.startsWith("http")) shell.openExternal(url);
|
|
return { action: "deny" };
|
|
});
|
|
// Silent start: `--silent` CLI flag or settings.silentStart keeps the app
|
|
// in the tray without showing the window (Clash-Verge style). First run
|
|
// always shows the window so the user sees the dashboard.
|
|
mainWindow.once("ready-to-show", () => {
|
|
const firstRun = !fs.existsSync(
|
|
path.join(app.getPath("userData"), "settings.json"),
|
|
);
|
|
if (firstRun || !(silentFlag || settings.silentStart)) {
|
|
mainWindow.show();
|
|
}
|
|
});
|
|
mainWindow.on("close", (e) => {
|
|
if (settings.minimizeToTray && !app.isQuitting) {
|
|
e.preventDefault();
|
|
mainWindow.hide();
|
|
tray &&
|
|
tray.displayBalloon &&
|
|
tray.displayBalloon({
|
|
title: "ModelRouter",
|
|
content: "已最小化到托盘,内核继续运行",
|
|
});
|
|
}
|
|
});
|
|
mainWindow.on("closed", () => (mainWindow = null));
|
|
if (isDev) mainWindow.webContents.openDevTools();
|
|
}
|
|
|
|
// ---------- IPC ----------
|
|
ipcMain.handle("window:minimize", (e) =>
|
|
BrowserWindow.fromWebContents(e.sender)?.minimize(),
|
|
);
|
|
ipcMain.handle("window:toggleMaximize", (e) => {
|
|
const w = BrowserWindow.fromWebContents(e.sender);
|
|
if (!w) return;
|
|
if (w.isMaximized()) w.unmaximize();
|
|
else w.maximize();
|
|
});
|
|
ipcMain.handle("window:close", (e) =>
|
|
BrowserWindow.fromWebContents(e.sender)?.close(),
|
|
);
|
|
ipcMain.handle(
|
|
"window:isMaximized",
|
|
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
|
|
);
|
|
|
|
ipcMain.handle("core:state", () => ({
|
|
running: coreStarted() && coreReady,
|
|
ready: coreReady,
|
|
startedAt: coreStartedAt,
|
|
baseUrl: embeddedBaseUrl(),
|
|
port: settings.port || DEFAULT_PORT,
|
|
profileDir: PROFILE_DIR,
|
|
configFile: CONFIG_FILE,
|
|
coreExe: CORE_EXE,
|
|
coreExists: fs.existsSync(CORE_EXE),
|
|
}));
|
|
ipcMain.handle("core:start", () => {
|
|
app.isQuitting = false;
|
|
startCore();
|
|
setTimeout(() => setEmbeddedAuth(), 400);
|
|
return coreStarted();
|
|
});
|
|
ipcMain.handle("core:stop", () => {
|
|
stopCore();
|
|
return true;
|
|
});
|
|
ipcMain.handle("core:restart", () => {
|
|
restartCore();
|
|
setTimeout(() => setEmbeddedAuth(), 500);
|
|
return true;
|
|
});
|
|
ipcMain.handle("core:probe", async () => {
|
|
const p = await probeEmbedded();
|
|
return p;
|
|
});
|
|
ipcMain.handle("core:key", () => readAdminKey());
|
|
|
|
ipcMain.handle("settings:get", () => loadSettings());
|
|
ipcMain.handle("settings:set", (_, patch) => {
|
|
Object.assign(settings, patch);
|
|
// port change -> rewrite ONLY the listen line, preserving sources/keys/etc.
|
|
if (typeof patch.port === "number" && patch.port !== DEFAULT_PORT) {
|
|
try {
|
|
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
|
|
let raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
raw = raw.replace(/^listen:.*$/m, "listen: 127.0.0.1:" + patch.port);
|
|
fs.writeFileSync(CONFIG_FILE, raw, "utf-8");
|
|
} catch (e) {
|
|
console.error("port rewrite failed:", e);
|
|
}
|
|
if (coreStarted()) restartCore();
|
|
}
|
|
if (typeof patch.autoStart === "boolean") applyAutoStart();
|
|
saveSettings();
|
|
setTimeout(() => setEmbeddedAuth(), 300);
|
|
return settings;
|
|
});
|
|
ipcMain.handle("settings:logFile", () => LOG_FILE);
|
|
|
|
// open paths in file manager
|
|
ipcMain.handle("shell:openPath", (_, p) => {
|
|
shell.openPath(p);
|
|
return true;
|
|
});
|
|
|
|
// window control from renderer uses preload -> ipcMain above
|
|
|
|
// ---------- bootstrap ----------
|
|
const gotLock = app.requestSingleInstanceLock();
|
|
if (gotLock) {
|
|
app.on("second-instance", () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
app.whenReady().then(() => {
|
|
installAuthRule();
|
|
loadSettings();
|
|
setupAutoStart();
|
|
setEmbeddedAuth();
|
|
createWindow();
|
|
createTray();
|
|
|
|
// start embedded core if not already running (another instance of llmsproxy)
|
|
setTimeout(async () => {
|
|
const p = await probeEmbedded();
|
|
if (!p.ok) {
|
|
startCore();
|
|
}
|
|
}, 600);
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (mainWindow === null) createWindow();
|
|
});
|
|
app.on("before-quit", () => {
|
|
app.isQuitting = true;
|
|
stopCore();
|
|
try {
|
|
if (tray) tray.destroy();
|
|
} catch (e) {}
|
|
});
|
|
} else {
|
|
app.quit();
|
|
}
|