mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。
补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。
后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。
CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
557 lines
19 KiB
JavaScript
557 lines
19 KiB
JavaScript
// ModelRouter Desktop GUI — renderer
|
|
// Clash-Verge-style: the content area is a full-screen iframe loading the
|
|
// real WebUI 1:1 (status / chat / keys / priority / sources / adapters).
|
|
// The shell only adds the titlebar, settings overlay and tray integration.
|
|
const state = {
|
|
core: {
|
|
running: false,
|
|
ready: false,
|
|
baseUrl: "",
|
|
port: 8787,
|
|
profileDir: "",
|
|
configFile: "",
|
|
coreExists: true,
|
|
},
|
|
settings: {
|
|
port: 8787,
|
|
autoStart: true,
|
|
silentStart: false,
|
|
minimizeToTray: true,
|
|
},
|
|
theme: localStorage.getItem("mr-theme") || "light",
|
|
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);
|
|
|
|
function toast(msg, isErr, ms) {
|
|
const t = $("#toast");
|
|
t.textContent = msg;
|
|
t.className = "show" + (isErr ? " err" : "");
|
|
clearTimeout(t._tm);
|
|
t._tm = setTimeout(() => {
|
|
t.className = "";
|
|
}, ms || 2600);
|
|
}
|
|
|
|
const frame = () => $("#webui-frame");
|
|
|
|
// ===== core status =====
|
|
async function refreshCore() {
|
|
state.core = await window.modelrouter.core.state();
|
|
renderCoreStatus();
|
|
}
|
|
|
|
function renderCoreStatus() {
|
|
const core = state.core;
|
|
const dot = $("#conn-dot");
|
|
dot.className = "status-dot" + (core.running ? " ok" : " bad");
|
|
dot.title = !core.running
|
|
? "内核未运行"
|
|
: core.ready
|
|
? "内核运行中"
|
|
: "内核启动中…";
|
|
}
|
|
|
|
function showOffline() {
|
|
frame().style.display = "none";
|
|
$("#offline").style.display = "flex";
|
|
$("#console-loading").style.display = "none";
|
|
}
|
|
|
|
function showLoading() {
|
|
frame().style.display = "none";
|
|
$("#offline").style.display = "none";
|
|
$("#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;
|
|
}
|
|
|
|
async function ensureCore() {
|
|
await refreshCore();
|
|
if (state.core.running) {
|
|
scheduleFrameLoad();
|
|
} else {
|
|
state.loadedOnce = false;
|
|
showOffline();
|
|
}
|
|
}
|
|
|
|
// ===== side rail toggles =====
|
|
function renderRail() {
|
|
const auto = $("#rail-autostart-dot");
|
|
const sil = $("#rail-silent-dot");
|
|
if (auto) auto.classList.toggle("on", !!state.settings.autoStart);
|
|
if (sil) sil.classList.toggle("on", !!state.settings.silentStart);
|
|
}
|
|
|
|
async function toggleAutoStart() {
|
|
const next = !state.settings.autoStart;
|
|
state.settings.autoStart = next;
|
|
await window.modelrouter.settings.set({ autoStart: next });
|
|
renderRail();
|
|
toast(next ? "开机自启已开启" : "开机自启已关闭");
|
|
}
|
|
|
|
async function toggleSilent() {
|
|
const next = !state.settings.silentStart;
|
|
state.settings.silentStart = next;
|
|
await window.modelrouter.settings.set({ silentStart: next });
|
|
renderRail();
|
|
toast(next ? "静默启动已开启" : "静默启动已关闭");
|
|
}
|
|
|
|
// ===== settings overlay =====
|
|
async function openSettings() {
|
|
state.settings = await window.modelrouter.settings.get();
|
|
$("#set-port").value = state.settings.port ?? 8787;
|
|
$("#set-autostart").checked = !!state.settings.autoStart;
|
|
$("#set-silent").checked = !!state.settings.silentStart;
|
|
$("#set-tray").checked = !!state.settings.minimizeToTray;
|
|
$("#settings-overlay").style.display = "flex";
|
|
renderRail();
|
|
loadPlugins();
|
|
}
|
|
function closeSettings() {
|
|
$("#settings-overlay").style.display = "none";
|
|
}
|
|
|
|
async function saveSettings() {
|
|
const port = parseInt($("#set-port").value, 10);
|
|
const autoStart = $("#set-autostart").checked;
|
|
const silentStart = $("#set-silent").checked;
|
|
const minimizeToTray = $("#set-tray").checked;
|
|
await window.modelrouter.settings.set({
|
|
port,
|
|
autoStart,
|
|
silentStart,
|
|
minimizeToTray,
|
|
});
|
|
closeSettings();
|
|
toast("设置已保存");
|
|
await refreshCore();
|
|
if (state.core.running) {
|
|
scheduleFrameLoad();
|
|
} else {
|
|
state.loadedOnce = false;
|
|
showOffline();
|
|
}
|
|
}
|
|
|
|
// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
|
|
// shell needs its own because renderer/app.js is a separate document that
|
|
// never loads index.html's script.
|
|
function esc(s) {
|
|
return String(s == null ? "" : s).replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
|
|
);
|
|
}
|
|
function escAttr(s) {
|
|
return esc(s).replace(/`/g, "`");
|
|
}
|
|
|
|
// ===== plugin management =====
|
|
//
|
|
// The desktop shell manages plugins through the embedded core's /api/plugins
|
|
// surface, proxied over IPC (see plugins:proxy in the main process). The
|
|
// renderer never holds the admin key.
|
|
//
|
|
// Scope note: the desktop build has no plugin_dir configured by default, so this
|
|
// panel normally reports "plugins disabled" with the one-line fix. That is a
|
|
// deliberate state, not an error — the packaged profile is a per-user directory
|
|
// and seeding a plugin tree into someone's home without asking would be rude.
|
|
|
|
async function loadPlugins() {
|
|
const list = $("#pl-list");
|
|
const hint = $("#set-plugins-hint");
|
|
if (!list || !hint) return;
|
|
let j;
|
|
try {
|
|
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
|
|
} catch (e) {
|
|
hint.textContent = "内核未就绪:" + e.message;
|
|
list.innerHTML = "";
|
|
return;
|
|
}
|
|
if (!j.plugin_dir) {
|
|
hint.innerHTML =
|
|
'未配置 <code>plugin_dir</code>,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
|
|
list.innerHTML = "";
|
|
return;
|
|
}
|
|
const rows = j.on_disk || [];
|
|
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
|
|
const active = rows.filter((p) => p.loaded && !p.disabled).length;
|
|
const broken = rows.filter((p) => !p.loaded).length;
|
|
hint.textContent =
|
|
`${rows.length} 个插件 · ${active} 个启用中` +
|
|
(broken ? ` · ${broken} 个加载失败` : "");
|
|
list.innerHTML = rows.length
|
|
? rows
|
|
.map((p) => {
|
|
const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
|
|
const label = !p.loaded
|
|
? "加载失败"
|
|
: p.disabled
|
|
? "已禁用"
|
|
: "启用中";
|
|
const btn = p.loaded
|
|
? `<button class="ghost" data-act="toggle" data-name="${escAttr(
|
|
p.name,
|
|
)}" data-en="${p.disabled ? "1" : "0"}">${
|
|
p.disabled ? "启用" : "禁用"
|
|
}</button>`
|
|
: "";
|
|
const builtin = p.builtin
|
|
? '<span class="pl-builtin">内置</span>'
|
|
: "";
|
|
// Edit / view source / delete are the operations an operator
|
|
// actually needs after enable/disable: a plugin that cannot be
|
|
// read back cannot be fixed, and one that cannot be removed cannot
|
|
// be rolled back.
|
|
const acts = [
|
|
btn,
|
|
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
|
|
p.builtin ? "查看源码" : "编辑"
|
|
}</button>`,
|
|
builtin
|
|
? ""
|
|
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
|
|
p.name,
|
|
)}">删除</button>`,
|
|
]
|
|
.filter(Boolean)
|
|
.join("");
|
|
const pages = Array.isArray(p.pages) ? p.pages : [];
|
|
return `<div class="pl-item ${cls}">
|
|
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
|
|
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
|
|
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
|
|
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
|
|
<div class="pl-acts">${acts}</div>
|
|
</div>`;
|
|
})
|
|
.join("")
|
|
: '<div class="pl-empty">插件目录为空</div>';
|
|
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
|
|
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
|
|
});
|
|
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
|
|
b.onclick = () => editPlugin(b.dataset.name);
|
|
});
|
|
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
|
|
b.onclick = () => deletePlugin(b.dataset.name);
|
|
});
|
|
}
|
|
|
|
// editPlugin shows the plugin source in a modal. Built-in plugins are
|
|
// read-only: their file ships inside the binary, so a save would either be
|
|
// ignored or silently do nothing — showing an editable text area that
|
|
// discards on save is worse than saying so up front.
|
|
function editPlugin(name) {
|
|
// The plugin list is the only place `builtin` is reported; keep the last
|
|
// fetch so the modal does not need a second round trip (and cannot guess).
|
|
const row = (window.__plRows || []).find((p) => p.name === name);
|
|
const isBuiltin = !!(row && row.builtin);
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "overlay";
|
|
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
|
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
|
|
${isBuiltin
|
|
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
|
|
: `<textarea id="pl-code" spellcheck="false"></textarea>
|
|
<p class="hint" id="pl-code-err"></p>`}
|
|
<div class="row actions">
|
|
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
|
|
<button class="ghost" id="pl-code-close">关闭</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(wrap);
|
|
wrap.addEventListener("click", (e) => {
|
|
if (e.target === wrap) wrap.remove();
|
|
});
|
|
$("#pl-code-close").onclick = () => wrap.remove();
|
|
const save = $("#pl-code-save");
|
|
if (save) {
|
|
save.onclick = async () => {
|
|
save.disabled = true;
|
|
try {
|
|
// Saving writes through POST /api/plugins — the same install path the
|
|
// installer uses, which validates the name, compiles the source and
|
|
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
|
|
// save there would silently disable nothing and store nothing.
|
|
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
|
name,
|
|
code: $("#pl-code").value,
|
|
});
|
|
toast(`${name} 已保存`);
|
|
wrap.remove();
|
|
await loadPlugins();
|
|
} catch (e) {
|
|
$("#pl-code-err").textContent = e.message;
|
|
save.disabled = false;
|
|
}
|
|
};
|
|
}
|
|
if (isBuiltin) return;
|
|
(async () => {
|
|
const ta = $("#pl-code");
|
|
try {
|
|
const j = await window.modelrouter.plugins.request(
|
|
"GET",
|
|
`/api/plugins/${encodeURIComponent(name)}`,
|
|
);
|
|
ta.value = j.code || "";
|
|
const err = $("#pl-code-err");
|
|
if (err && j.parse_error) err.textContent = String(j.parse_error);
|
|
} catch (e) {
|
|
$("#pl-code-err").textContent = e.message;
|
|
}
|
|
})();
|
|
}
|
|
|
|
function deletePlugin(name) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "overlay";
|
|
wrap.innerHTML = `<div class="overlay-card">
|
|
<div class="overlay-head"><h2>删除插件</h2></div>
|
|
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
|
|
<div class="row actions">
|
|
<button class="danger" id="pl-del-ok">删除</button>
|
|
<button class="ghost" id="pl-del-no">取消</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(wrap);
|
|
wrap.addEventListener("click", (e) => {
|
|
if (e.target === wrap) wrap.remove();
|
|
});
|
|
$("#pl-del-no").onclick = () => wrap.remove();
|
|
$("#pl-del-ok").onclick = async () => {
|
|
try {
|
|
await window.modelrouter.plugins.request(
|
|
"DELETE",
|
|
`/api/plugins/${encodeURIComponent(name)}`,
|
|
);
|
|
toast(`已删除 ${name}`);
|
|
wrap.remove();
|
|
await loadPlugins();
|
|
} catch (e) {
|
|
toast(e.message, true);
|
|
}
|
|
};
|
|
}
|
|
|
|
// installPlugin creates a new plugin from source. The name becomes the file
|
|
// name, so it is validated client-side to save a round trip on the obvious
|
|
// mistakes (empty, path separators, non-portable characters).
|
|
function installPlugin() {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "overlay";
|
|
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
|
<div class="overlay-head"><h2>安装插件</h2></div>
|
|
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
|
|
<p class="hint" id="pl-new-err"></p>
|
|
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件 plugin = {} ..."></textarea>
|
|
<div class="row actions">
|
|
<button id="pl-new-ok">安装</button>
|
|
<button class="ghost" id="pl-new-no">取消</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(wrap);
|
|
wrap.addEventListener("click", (e) => {
|
|
if (e.target === wrap) wrap.remove();
|
|
});
|
|
$("#pl-new-no").onclick = () => wrap.remove();
|
|
$("#pl-new-ok").onclick = async () => {
|
|
const name = $("#pl-new-name").value.trim();
|
|
const code = $("#pl-new-code").value;
|
|
const err = $("#pl-new-err");
|
|
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
err.textContent = "名称只能包含字母、数字、下划线和连字符";
|
|
return;
|
|
}
|
|
if (!code.trim()) {
|
|
err.textContent = "源码不能为空";
|
|
return;
|
|
}
|
|
$("#pl-new-ok").disabled = true;
|
|
try {
|
|
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
|
// The backend appends .lua itself (installPlugin writes name+".lua"),
|
|
// so passing it here would create name.lua.lua.
|
|
name,
|
|
code,
|
|
});
|
|
toast(`已安装 ${name}`);
|
|
wrap.remove();
|
|
await loadPlugins();
|
|
} catch (e) {
|
|
err.textContent = e.message;
|
|
$("#pl-new-ok").disabled = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
async function togglePlugin(name, disabled) {
|
|
try {
|
|
await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
|
|
enabled: disabled,
|
|
});
|
|
toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
|
|
await loadPlugins();
|
|
} catch (e) {
|
|
toast(e.message, true);
|
|
}
|
|
}
|
|
|
|
async function enableAllPlugins() {
|
|
let j;
|
|
try {
|
|
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
|
|
} catch (e) {
|
|
return toast(e.message, true);
|
|
}
|
|
const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
|
|
for (const p of off) {
|
|
try {
|
|
await window.modelrouter.plugins.request(
|
|
"PUT",
|
|
`/api/plugins/${encodeURIComponent(p.name)}`,
|
|
{ enabled: true },
|
|
);
|
|
} catch (e) {
|
|
toast(`${p.name}: ${e.message}`, true);
|
|
}
|
|
}
|
|
toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
|
|
await loadPlugins();
|
|
}
|
|
|
|
// ===== 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();
|
|
$("#tb-max").onclick = () => window.modelrouter.win.toggleMaximize();
|
|
$("#tb-close").onclick = () => window.modelrouter.win.close();
|
|
$("#tb-settings").onclick = openSettings;
|
|
$("#rail-settings").onclick = openSettings;
|
|
const plReload = document.getElementById("pl-reload");
|
|
if (plReload) plReload.onclick = loadPlugins;
|
|
const plAll = document.getElementById("pl-toggle-all");
|
|
if (plAll) plAll.onclick = enableAllPlugins;
|
|
const plInstall = document.getElementById("pl-install");
|
|
if (plInstall) plInstall.onclick = installPlugin;
|
|
$("#rail-autostart").onclick = toggleAutoStart;
|
|
$("#rail-silent").onclick = toggleSilent;
|
|
$("#rail-theme").onclick = () => {
|
|
state.theme = state.theme === "dark" ? "light" : "dark";
|
|
applyTheme();
|
|
};
|
|
$("#set-close").onclick = closeSettings;
|
|
$("#set-cancel").onclick = closeSettings;
|
|
$("#set-save").onclick = saveSettings;
|
|
$("#set-dir").onclick = () =>
|
|
window.modelrouter.shell.openPath(state.core.profileDir);
|
|
$("#set-log").onclick = async () => {
|
|
const p = await window.modelrouter.settings.logFile();
|
|
window.modelrouter.shell.openPath(p);
|
|
};
|
|
$("#off-start").onclick = async () => {
|
|
showLoading();
|
|
await window.modelrouter.core.start();
|
|
await refreshCore();
|
|
if (state.core.running) scheduleFrameLoad();
|
|
else showOffline();
|
|
};
|
|
// 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) scheduleFrameLoad();
|
|
if (!d.running) {
|
|
state.loadedOnce = false;
|
|
showOffline();
|
|
}
|
|
});
|
|
// Escape closes overlay
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && $("#settings-overlay").style.display === "flex")
|
|
closeSettings();
|
|
});
|
|
}
|
|
|
|
async function boot() {
|
|
applyTheme();
|
|
init();
|
|
state.settings = await window.modelrouter.settings.get();
|
|
renderRail();
|
|
await ensureCore();
|
|
}
|
|
|
|
boot();
|