mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
## WebUI:新增「插件」页
- 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**,
否则一个语法错误看起来和"插件没装"完全一样
- 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖
- 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是
"功能就是不work"
- 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal),
适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数
## TABS 收敛为单一常量
tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。
加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"——
和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。
## Electron 壳:设置面板里的插件管理
渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC:
renderer → plugins:proxy → main → HTTP /api/plugins
代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩,
而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来
新增的任何 /api/plugins 路由,路径在主进程校验。
## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题
1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本
2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js
是独立文档,点击时 ReferenceError
3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里
于是是无样式裸按钮
补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类):
TestGUICSSClassesExist 用到的类必须在样式表里定义
TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义
TestGUIPluginPanelIsReachable 面板在 overlay 内、按钮已绑定、打开设置会加载
TestGUIIPCPathIsConstrained 代理必须限定 /api/plugins 前缀并拒绝路径穿越
写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当
selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的
教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的
token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类,
不是本次引入,失败它们只会让判据对新工作失去意义)。
## 变异验证
改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红
改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红
★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。
这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。
363 个测试全绿。
849 lines
28 KiB
JavaScript
849 lines
28 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, execFileSync } = 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.
|
|
const raw = readConfigRaw();
|
|
if (!raw) return "";
|
|
// keys:
|
|
// - key: sk-gw-...
|
|
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
|
|
raw,
|
|
);
|
|
let v = k && k[1] ? k[1] : "";
|
|
if (!v) {
|
|
// 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]) v = g[1];
|
|
if (!v) {
|
|
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
|
|
if (g2 && g2[1]) v = g2[1];
|
|
}
|
|
}
|
|
// The core seals credentials at rest (enc:v1:...), so from the second start
|
|
// onward the file holds ciphertext. Injecting that as a cookie is a
|
|
// guaranteed 401, which surfaces to the user as "enter your key" for a key
|
|
// they never set. Ask the core to unseal rather than reimplementing its
|
|
// crypto here. -show-secrets only reads, prints and exits: it starts nothing
|
|
// and writes nothing.
|
|
if (v && v.indexOf(SEALED_PREFIX) === 0) return unsealViaCore();
|
|
return v;
|
|
}
|
|
|
|
const SEALED_PREFIX = "enc:v1:";
|
|
|
|
function readConfigRaw() {
|
|
try {
|
|
if (!fs.existsSync(CONFIG_FILE)) return "";
|
|
return fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
} catch (e) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Returns the admin key in the clear, or "" if the core cannot be asked.
|
|
//
|
|
// cwd must be the directory that holds master.key. The core derives that path
|
|
// from the config's runtime_file, so a call made from any other directory has
|
|
// the core generate a *second* master key in the CWD and then fail to decrypt
|
|
// ("master key changed?"). Electron's CWD is not the profile dir, so this is
|
|
// not optional: without it the desktop build cannot read its own key back.
|
|
//
|
|
// readAdminKey runs on every outbound request, and this spawns a process, so
|
|
// the result is cached against the config's mtime. Editing the config (or the
|
|
// port rewrite, or the first write) changes the mtime and invalidates it, which
|
|
// keeps the "read live so ordering never matters" property the auth rule
|
|
// depends on.
|
|
function unsealViaCore() {
|
|
const st = safeStat(CONFIG_FILE);
|
|
const stamp = st ? st.mtimeMs : 0;
|
|
if (unsealCache && unsealCache.stamp === stamp) return unsealCache.key;
|
|
let key = "";
|
|
try {
|
|
const out = execFileSync(
|
|
CORE_EXE,
|
|
["-config", CONFIG_FILE, "-show-secrets"],
|
|
{
|
|
encoding: "utf-8",
|
|
timeout: 10000,
|
|
windowsHide: true,
|
|
cwd: PROFILE_DIR,
|
|
},
|
|
);
|
|
// -show-secrets prints one line per credential:
|
|
// key <name> role=<role> <value>
|
|
const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out);
|
|
if (admin && admin[1]) key = admin[1];
|
|
// Tolerate a field-order or spacing change rather than locking the user out.
|
|
if (!key) {
|
|
const loose = /role=admin\s+(\S+)/.exec(out);
|
|
if (loose && loose[1]) key = loose[1];
|
|
}
|
|
} catch (e) {
|
|
console.error("unseal via core failed:", e.message);
|
|
}
|
|
// Only cache a success. A transient failure must not pin an empty key until
|
|
// the config next changes, or a momentary spawn error locks the user out.
|
|
if (key) unsealCache = { stamp, key };
|
|
return key;
|
|
}
|
|
|
|
let unsealCache = null;
|
|
|
|
function safeStat(p) {
|
|
try {
|
|
return fs.statSync(p);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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(),
|
|
);
|
|
|
|
// ---- plugin management over IPC -------------------------------------------
|
|
//
|
|
// The renderer cannot call the embedded core directly: it has no key and no
|
|
// network identity, and the core binds a loopback port that only the main
|
|
// process knows about. So every plugin action is proxied through the main
|
|
// process, which already knows how to obtain the admin key (unsealViaCore).
|
|
//
|
|
// The proxy is deliberately a raw (method, path, body) pass-through rather than
|
|
// a fixed set of commands. A fixed set would have to be extended for every new
|
|
// plugin endpoint, and the one thing worse than "no button for this" is "a
|
|
// button that silently does nothing" — with a pass-through the renderer can talk
|
|
// to any /api/plugins route the core grows, and the path is validated here so
|
|
// this channel cannot be used to reach arbitrary endpoints.
|
|
function pluginProxy(req) {
|
|
const { method, path, body } = req || {};
|
|
const M = ["GET", "POST", "PUT", "DELETE"];
|
|
if (!M.includes(method)) throw new Error("bad method: " + method);
|
|
// The path must stay inside the plugin namespace. A prefix check alone would
|
|
// still allow /api/plugins/../keys, so reject any traversal outright.
|
|
if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
|
|
throw new Error("path must start with /api/plugins");
|
|
}
|
|
if (path.includes("..") || path.includes("\\")) {
|
|
throw new Error("path traversal rejected");
|
|
}
|
|
const key = unsealViaCore();
|
|
if (!key) throw new Error("no admin key available yet");
|
|
return new Promise((resolve, reject) => {
|
|
const u = new URL(embeddedBaseUrl() + path);
|
|
const data = body == null ? null : JSON.stringify(body);
|
|
const headers = { Authorization: "Bearer " + key };
|
|
if (data) {
|
|
headers["Content-Type"] = "application/json";
|
|
headers["Content-Length"] = Buffer.byteLength(data);
|
|
}
|
|
const r = http.request(
|
|
{
|
|
hostname: u.hostname,
|
|
port: u.port,
|
|
path: u.pathname + u.search,
|
|
method,
|
|
headers,
|
|
},
|
|
(res) => {
|
|
let raw = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (c) => (raw += c));
|
|
res.on("end", () => {
|
|
let parsed = null;
|
|
try {
|
|
parsed = raw ? JSON.parse(raw) : null;
|
|
} catch (e) {
|
|
parsed = { raw };
|
|
}
|
|
if (res.statusCode >= 400) {
|
|
const msg =
|
|
(parsed && parsed.error && parsed.error.message) ||
|
|
"HTTP " + res.statusCode;
|
|
reject(new Error(msg));
|
|
return;
|
|
}
|
|
resolve(parsed);
|
|
});
|
|
},
|
|
);
|
|
r.on("error", reject);
|
|
if (data) r.write(data);
|
|
r.end();
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
|
|
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();
|
|
}
|