fix(gui): 桌面版真正加载渠道插件 + 外链一律交给默认浏览器

## ① 桌面版此前**完全没有插件系统**(渠道页根本不存在)
GUI 内嵌内核的 config.yaml 由 main.js 生成,唯独漏了 `plugin_dir` ——
而内核刻意不给它默认值(运维显式开启)。后果:桌面版 `plugin_dir: ""`,
`/api/plugins` 返回空列表,channels 插件(多平台账号登录、凭据保管、自动
领取)在桌面端**完全不可见**。实测复现:同一份配置去掉 plugin_dir 就是
`plugins: []`,加上就有 billing/channels/quota/usage 四个。

修法:
- 新 profile 直接写 `plugin_dir: <profile>/plugins`;
- **老 profile 回填**(用户已有 config.yaml,不写就只有老用户永远没有插件)——
  新增 `ensurePluginDirConfigured()`,只在缺少该键时追加,其余内容不动;
- 插件树由内核 SeedBundled 播种(按文件补齐,用户改过的副本优先保留)。

## ② 升级后渠道修复**下发不到桌面用户**
内核播种规则是「文件缺失才写」,所以桌面用户升级 GUI 后 `channels.lua`
仍是旧版 —— 我们发的渠道修复对桌面用户静默失效。桌面版必须自己处理:
按 app 版本打戳(`.plugin-seed-version`),版本变化时删掉 4 个内置插件文件
让新内核重新播种,旧副本留 `.prev-<旧版本>` 可人工恢复;用户自建插件不碰。
逻辑抽到 `cmd/gui/plugin-sync.js`(无 Electron 依赖),配 6 条 node 单测。

## ③ 登录一律拉起默认浏览器(三条路径都覆盖)
桌面版的内容区是加载内核 WebUI 的 iframe,渠道登录链接是 `target=_blank`。
此前只处理了 `setWindowOpenHandler`,另两条路径仍会把 Electron 窗口带到
第三方页面。现在三条路径统一外抛:
1. `window.open` / target=_blank  → setWindowOpenHandler
2. 顶层同标签跳转               → will-navigate
3. iframe 内导航                → will-frame-navigate
**loopback 例外必须保留**:channels 的服务端浏览器(noVNC)就在
`127.0.0.1:<port>` 的 iframe 里,拦掉它会让 VNC 登录失效。

实测(打包后的 GUI + Xvfb + CDP 驱动真实窗口):
  [gui] open in default browser: https://example.com/mr-windowopen-probe
  [gui] open in default browser: https://example.com/mr-navigate-probe
  [gui] open in default browser: https://example.com/mr-frame-probe
  [gui] open in default browser: https://xiaohuanxiong.com/login?code=…(真实商汤登录链接)
顶层 URL 保持在内核页面(跳转被拦下),loopback iframe 正常加载。

## ④ 顺手
- GUI 版本号 1.9.7 → 2.0.42(与内核同版本发版;package-lock 里还是 1.9.3,
  一并同步);
- `make gui-test` 跑桌面壳单测。

验证:打包后的 GUI 启动即 `/api/plugins` 列出 channels(ui.page=channels),
插件目录播种 4 个内置插件;外链四类实测均走默认浏览器。
This commit is contained in:
root
2026-10-20 08:44:07 +08:00
parent 662f8f02b6
commit 411503aea5
6 changed files with 236 additions and 6 deletions

View File

@ -1,4 +1,4 @@
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean gui-test
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
export BINARY
@ -65,6 +65,13 @@ gui-dist-dir:
test:
go test ./...
# Desktop shell unit tests (no Electron needed): plugin-tree seeding/migration.
# These guard the two things that silently break the desktop build:
# - plugin_dir missing from an embedded profile -> channels plugin invisible
# - bundled plugin files never refreshed -> old channels.lua survives upgrades
gui-test:
@cd $(GUI_DIR) && node --test plugin-sync.test.js
clean:
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
@ -75,6 +82,7 @@ help:
@echo " make gui run desktop GUI (dev)"
@echo " make gui-dev run GUI with devtools"
@echo " make gui-dist build deb + rpm + AppImage (linux)"
@echo " make gui-test desktop shell unit tests (plugin tree seeding)"
@echo " make gui-deb build deb only"
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"

View File

@ -18,6 +18,7 @@ const http = require("http");
const os = require("os");
const crypto = require("crypto");
const { spawn, execFileSync } = require("child_process");
const pluginSync = require("./plugin-sync");
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
function log(msg) {
@ -40,6 +41,13 @@ console.error = function () {
// Per-user profile dir: <userData>/profile
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
// Plugin tree for the embedded core. The core deliberately does NOT default
// plugin_dir (an operator opts in), so the desktop build must set it — without
// it the whole plugin system stays off and the channels plugin (multi-platform
// account login + credential vault + auto check-in) is invisible in the WebUI.
// Seeding is the core's job (SeedBundled fills MISSING files only), so a user's
// edited copy always wins.
const PLUGINS_DIR = path.join(PROFILE_DIR, "plugins");
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
// Dev: cmd/gui/bin, populated by prepare-core.js.
const CORE_EXE = path.join(
@ -216,6 +224,9 @@ function writeDefaultEmbeddedConfig() {
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
"default_model: AUTO",
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
// Plugin tree: channels/billing/quota/usage ship inside the core binary and
// are seeded into this dir on first start. Omitting it disables plugins.
"plugin_dir: " + JSON.stringify(PLUGINS_DIR),
"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
@ -268,6 +279,9 @@ function startCore() {
// ensure profile dir + config + local adapters exist
fs.mkdirSync(PROFILE_DIR, { recursive: true });
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
pluginSync.ensurePluginDirConfigured(CONFIG_FILE, PLUGINS_DIR, log);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
pluginSync.syncBundledPluginsOnUpgrade(PROFILE_DIR, PLUGINS_DIR, app.getVersion(), log);
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
cwd: PROFILE_DIR,
@ -622,10 +636,55 @@ function createWindow() {
},
});
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
// External links ALWAYS go to the user's default browser, never an in-app
// window. Three paths have to be covered, and missing any one of them is why
// "click the login link and nothing happens / the app navigates away" shows up:
// 1. window.open / target=_blank -> setWindowOpenHandler
// 2. top-level navigation (same-tab click) -> will-navigate
// 3. navigation inside the WebUI <iframe> -> will-frame-navigate
// The embedded core's own origin (and file:// for the shell) must keep
// working, otherwise the WebUI and plugin pages stop loading.
const isInternal = (url) =>
!url ||
url === "about:blank" ||
url.startsWith("file://") ||
url.startsWith(embeddedBaseUrl()) ||
url.startsWith("devtools://");
const openExternally = (url) => {
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
log("[gui] open in default browser: " + url);
shell.openExternal(url);
}
};
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url && url.startsWith("http")) shell.openExternal(url);
if (!isInternal(url)) openExternally(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (e, url) => {
if (isInternal(url)) return;
e.preventDefault();
openExternally(url);
});
// iframe navigations (the WebUI content area and the plugin pages inside it).
// The channels plugin renders its server-side login browser (noVNC) in an
// iframe on a LOOPBACK port, so loopback MUST stay in-app — blocking it would
// break VNC login. Everything non-loopback is external: open the default
// browser instead of silently replacing the gateway UI with a third-party page.
const isLoopback = (url) => {
try {
const h = new URL(url).hostname;
return h === "127.0.0.1" || h === "localhost" || h === "[::1]";
} catch (e) {
return false;
}
};
if (mainWindow.webContents.on) {
mainWindow.webContents.on("will-frame-navigate", (e) => {
if (isInternal(e.url) || isLoopback(e.url)) return;
e.preventDefault();
openExternally(e.url);
});
}
// 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.

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "2.0.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "2.0.42",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.9.7",
"version": "2.0.42",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",
@ -81,6 +81,7 @@
},
"files": [
"main.js",
"plugin-sync.js",
"preload.js",
"renderer/**/*",
"build/**/*",
@ -89,4 +90,4 @@
"asar": true
},
"homepage": "https://gitcode.com/JianFeeeee/ModelRouter"
}
}

82
cmd/gui/plugin-sync.js Normal file
View File

@ -0,0 +1,82 @@
// Plugin-tree maintenance for the embedded core (no Electron imports, so this
// is unit-testable with plain node).
//
// Two problems this solves, both invisible from the gateway side:
// 1. The core deliberately does NOT default `plugin_dir` (an operator opts
// in). A desktop profile without it runs with ZERO plugins — the channels
// plugin (account login, credential vault, auto check-in) never appears.
// 2. The core seeds bundled plugins only when the file is ABSENT, so a
// desktop upgrade would keep the user's old channels.lua forever and
// silently skip every channel fix. On version change the shell must drop
// the built-in files so the new binary reseeds them.
const fs = require("fs");
const path = require("path");
const BUILTIN_PLUGINS = ["billing.lua", "channels.lua", "quota.lua", "usage.lua"];
// ensurePluginDirConfigured back-fills `plugin_dir` into an existing config.
// Appending a top-level YAML key is safe for the shapes this file can have
// (flat mapping + a keys list). Returns "added" | "present" | "absent" | "error".
function ensurePluginDirConfigured(configFile, pluginsDir, log) {
try {
if (!fs.existsSync(configFile)) return "absent";
const cur = fs.readFileSync(configFile, "utf-8");
if (/^\s*plugin_dir\s*:/m.test(cur)) return "present";
fs.writeFileSync(
configFile,
cur.replace(/\s*$/, "") +
"\n# added by ModelRouter Desktop: enables the plugin tree (channels etc.)\n" +
"plugin_dir: " + JSON.stringify(pluginsDir) + "\n",
"utf-8",
);
if (log) log("[gui] plugin_dir back-filled into " + configFile);
return "added";
} catch (e) {
if (log) log("[gui] ensurePluginDirConfigured failed: " + e.message);
return "error";
}
}
// syncBundledPluginsOnUpgrade drops built-in plugin files when the app version
// changed, so the core reseeds them from the binary it shipped with. Returns
// { changed, dropped:[...], kept: "prev-<ver>" }.
function syncBundledPluginsOnUpgrade(profileDir, pluginsDir, version, log) {
const stamp = path.join(profileDir, ".plugin-seed-version");
let have = "";
try {
have = fs.readFileSync(stamp, "utf-8").trim();
} catch (e) {
/* first run */
}
if (have === version) return { changed: false, dropped: [] };
const dropped = [];
for (const name of BUILTIN_PLUGINS) {
const f = path.join(pluginsDir, name);
if (!fs.existsSync(f)) continue;
try {
if (have) fs.renameSync(f, f + ".prev-" + have);
else fs.unlinkSync(f);
dropped.push(name);
} catch (e) {
if (log) log("[gui] drop builtin plugin " + name + " failed: " + e.message);
}
}
try {
fs.writeFileSync(stamp, version + "\n", "utf-8");
} catch (e) {
if (log) log("[gui] write plugin seed stamp failed: " + e.message);
}
if (dropped.length && log) {
log(
"[gui] plugins refreshed for " + version + " (" + dropped.length +
" builtin file(s) reseeded by core)",
);
}
return { changed: true, dropped, kept: have ? "prev-" + have : "" };
}
module.exports = {
BUILTIN_PLUGINS,
ensurePluginDirConfigured,
syncBundledPluginsOnUpgrade,
};

View File

@ -0,0 +1,80 @@
// Unit tests for the desktop plugin-tree maintenance (plain node, no Electron).
// Run: node --test cmd/gui/plugin-sync.test.js
const test = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { BUILTIN_PLUGINS, ensurePluginDirConfigured, syncBundledPluginsOnUpgrade } = require("./plugin-sync");
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-"));
test("back-fills plugin_dir into an existing profile without it", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
fs.writeFileSync(cfg, 'listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n');
const plugins = path.join(dir, "plugins");
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
const out = fs.readFileSync(cfg, "utf-8");
assert.match(out, /^plugin_dir: "/m);
assert.ok(out.includes(plugins.replace(/\\/g, "\\\\")) || out.includes(plugins), "path written");
// the rest of the profile must survive untouched
assert.match(out, /^listen: 127\.0\.0\.1:8787$/m);
assert.match(out, /^ - key: k$/m);
});
test("leaves a profile that already sets plugin_dir alone", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
const orig = 'plugin_dir: "/custom/plugins"\nlisten: ":1"\n';
fs.writeFileSync(cfg, orig);
assert.equal(ensurePluginDirConfigured(cfg, "/whatever", null), "present");
assert.equal(fs.readFileSync(cfg, "utf-8"), orig);
});
test("missing config is not created by the back-fill", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
assert.equal(ensurePluginDirConfigured(cfg, path.join(dir, "p"), null), "absent");
assert.equal(fs.existsSync(cfg), false);
});
test("upgrade drops builtin plugins (keeping a backup) but not user plugins", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
for (const f of BUILTIN_PLUGINS) fs.writeFileSync(path.join(plugins, f), "OLD");
fs.writeFileSync(path.join(plugins, "my-own.lua"), "MINE");
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "1.0.0\n");
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
assert.equal(r.changed, true);
assert.deepEqual(r.dropped.sort(), [...BUILTIN_PLUGINS].sort());
for (const f of BUILTIN_PLUGINS) {
assert.equal(fs.existsSync(path.join(plugins, f)), false, f + " dropped");
assert.equal(fs.readFileSync(path.join(plugins, f + ".prev-1.0.0"), "utf-8"), "OLD");
}
assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE");
assert.equal(fs.readFileSync(path.join(dir, ".plugin-seed-version"), "utf-8").trim(), "2.0.42");
});
test("same version is a no-op (nothing dropped)", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
fs.writeFileSync(path.join(plugins, "channels.lua"), "CURRENT");
fs.writeFileSync(path.join(dir, ".plugin-seed-version"), "2.0.42\n");
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
assert.equal(r.changed, false);
assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "CURRENT");
});
test("first run on a fresh profile just stamps the version", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
const r = syncBundledPluginsOnUpgrade(dir, plugins, "2.0.42", null);
assert.equal(r.changed, true);
assert.deepEqual(r.dropped, []);
assert.equal(fs.existsSync(path.join(dir, ".plugin-seed-version")), true);
});