electron-main.js
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (c) 2026 开发者猫叔 (DeveloperCatUncle) · 商业使用需授权:COMMERCIAL-LICENSE.md
"use strict";
/** Electron 桌面壳 — 启动内嵌服务并打开桌面窗口。运行:npm run app */
const BOOT_T0 = Date.now(); // 启动分段计时:哪段慢一眼看清,别靠体感猜
const { app, BrowserWindow, dialog, shell, globalShortcut, Menu, clipboard, Tray, nativeImage, nativeTheme, screen, session, powerMonitor } = require("electron");
const path = require("path");
const fs = require("fs");
const os = require("os");
const { dataPath, seedDataDir, resolvePort } = require("./src/platform/paths");
const { throttleWhenAway } = require("./src/desktop/win-away");
const { workbenchLink } = require("./src/desktop/window-links");
const openFiles = require("./src/desktop/open-files");
const fileOpenTokens = [];
let fileOpenTimer = null;
function deliverOpenFiles() {
if (fileOpenTimer) return;
fileOpenTimer = setInterval(async () => {
if (!fileOpenTokens.length) { clearInterval(fileOpenTimer); fileOpenTimer = null; return; }
if (!win || win.isDestroyed() || !PAGE_UP || win.webContents.getURL() !== `http://127.0.0.1:${PORT}/`) return;
const token = fileOpenTokens[0];
const accepted = await win.webContents.executeJavaScript(`typeof window.owbOpenFiles === "function" && window.owbOpenFiles(${JSON.stringify(token)})`).catch(() => false);
if (accepted && fileOpenTokens[0] === token) fileOpenTokens.shift();
}, 700);
fileOpenTimer.unref();
}
function queueOpenFiles(files) {
const token = openFiles.offer(files);
if (!token) return;
fileOpenTokens.push(token); deliverOpenFiles();
}
app.on("open-file", (event, file) => { event.preventDefault(); queueOpenFiles([file]); });
// ---------- 桌面壳这一层的文案 ----------
/**
* 网页那套翻译是照着 DOM 走的:它认的是文本节点和属性,遇上就换掉。
* 而原生右键菜单、系统报错框、启动失败页里的字,从头到尾只是主进程里的 JS 字符串——
* 一秒钟都没进过渲染进程的 DOM,所以网页那边翻得再全,右键点一下弹出来的还是中文。
* 这一层只能自己带一份小词表。条目不多,摊开写,别再套一层查词函数。
*/
const SHELL_TEXT = {
zh: {
copyImage: "复制图片", copyImageURL: "复制图片地址",
copy: "复制", cut: "剪切", paste: "粘贴", selectAll: "全选",
copyLink: "复制链接", openInBrowser: "在浏览器里打开",
unknownError: "未知错误",
failTitle: "OpenWorkBuddy 没能启动",
bootLogLabel: "启动日志:",
bootLogNone: "(日志文件写不出来)",
logUnwritable: "写不出来",
bootLogForIssue: "启动日志(贴 issue 时带上它):",
cfgFallback: "用户目录下的 OpenWorkBuddy/config.json",
gpuTip1: "还可以试:窗口一直不出现、或者整片黑,多半是显卡驱动画不出来——加一行 ",
gpuTip2: ",写进 ", gpuTip3: " 的 ", gpuTip4: " 里,再打开。",
versionLabel: "版本", openIssue: "提 issue",
stuckTitle: "OpenWorkBuddy 已经在运行了,但窗口没出来",
stuckBody: "后台还留着一个卡住的 OpenWorkBuddy 进程,它占着单实例锁,所以新的一次启动被挡住了。\n\n先到任务管理器(macOS 活动监视器)里结束 OpenWorkBuddy 进程,再重新打开。",
hintMissingFiles: "安装包里少了文件。到 GitHub Releases 重新下载最新版本覆盖安装即可;如果最新版仍然这样,请把下面这行贴到 issue 里。",
hintDataDir: "放数据的文件夹建不起来(默认在用户目录下的 OpenWorkBuddy)。常见原因是公司电脑把用户目录重定向到了连不上的网络盘,或者磁盘满了。设一个环境变量 OPENWORKBUDDY_HOME 指向本机一个能写的文件夹(比如 D:\\OpenWorkBuddy)再打开。",
hintPortDenied: (port) => `没权限使用端口 ${port}。Windows 上多半是 Hyper-V / WSL 预留了这段端口(命令行跑 netsh interface ipv4 show excludedportrange protocol=tcp 能看到保留段),把用户目录下 OpenWorkBuddy/config.json 里的 server.port 换成一个没被预留的(比如 3810)再打开。`,
hintPortBusy: (port) => `端口 ${port} 被占了,往后连试十个也都被占着。关掉占用它们的程序,或者把用户目录下 OpenWorkBuddy/config.json 里的 server.port 换一个。`,
hintHostGone: "配置里的 server.host 在这台机器上不存在了(换过网络之后常见)。把用户目录下 OpenWorkBuddy/config.json 里的 server.host 改回 127.0.0.1 再打开。",
hintCrashed: "服务端启动时崩了。把下面这行贴到 GitHub issue 里,附上你的系统版本。",
// 界面进程卡死 / 没了(attachCrashGuard)。服务端和任务都在主进程里,重载只动界面这一层
hungTitle: "界面没响应了",
hungDetail: "重新加载会丢掉没发出去的输入,后台任务照跑;也可以再等等。",
goneTitle: "界面停止运行了",
goneDetail: "重新加载就能接着用,后台任务照跑。",
reasonLabel: "原因代码:",
reloadBtn: "重新加载", waitBtn: "再等等", laterBtn: "先不管",
// 托盘和退出确认(createTray / requestQuit)
trayTip: "OpenWorkBuddy 桌面版",
trayShow: "显示窗口", trayNewTask: "新建任务", trayQuit: "退出",
quitBusy: (n) => `还有 ${n} 个任务在跑,退出会中断它们`,
quitBtn: "中断并退出", stayBtn: "先不退",
},
en: {
copyImage: "Copy image", copyImageURL: "Copy image address",
copy: "Copy", cut: "Cut", paste: "Paste", selectAll: "Select all",
copyLink: "Copy link", openInBrowser: "Open in browser",
unknownError: "Unknown error",
failTitle: "OpenWorkBuddy failed to start",
bootLogLabel: "Startup log: ",
bootLogNone: "(the log file could not be written)",
logUnwritable: "could not be written",
bootLogForIssue: "Startup log (attach it to the issue): ",
cfgFallback: "OpenWorkBuddy/config.json in your home folder",
gpuTip1: "One more thing to try: if the window never appears, or is all black, the graphics driver probably cannot draw it — add ",
gpuTip2: " to ", gpuTip3: " under ", gpuTip4: ", and open it again.",
versionLabel: "Version", openIssue: "open an issue",
stuckTitle: "OpenWorkBuddy is already running, but its window never appeared",
stuckBody: "A stuck OpenWorkBuddy process is still in the background holding the single-instance lock, so this new launch was blocked.\n\nEnd the OpenWorkBuddy process in Task Manager (Activity Monitor on macOS), then open it again.",
hintMissingFiles: "Files are missing from the installer. Download the latest release from GitHub Releases and install over this one; if the latest still does this, paste the line below into an issue.",
hintDataDir: "The data folder cannot be created (by default OpenWorkBuddy under your home folder). Usually this is a work machine whose home folder is redirected to a network drive that is unreachable, or a full disk. Set the environment variable OPENWORKBUDDY_HOME to a writable folder on this machine (e.g. D:\\OpenWorkBuddy) and open it again.",
hintPortDenied: (port) => `No permission to use port ${port}. On Windows this is usually Hyper-V / WSL reserving that range (run netsh interface ipv4 show excludedportrange protocol=tcp to see the reserved ranges). Change server.port in OpenWorkBuddy/config.json under your home folder to one that is not reserved (3810, say) and open it again.`,
hintPortBusy: (port) => `Port ${port} is taken, and so are the ten after it. Close whatever is using them, or change server.port in OpenWorkBuddy/config.json under your home folder.`,
hintHostGone: "The server.host in your config no longer exists on this machine (common after switching networks). Set server.host back to 127.0.0.1 in OpenWorkBuddy/config.json under your home folder and open it again.",
hintCrashed: "The server crashed while starting. Paste the line below into a GitHub issue, along with your OS version.",
hungTitle: "The window is not responding",
hungDetail: "Reloading drops anything you haven't sent yet; background tasks keep running. You can also wait a bit longer.",
goneTitle: "The window stopped working",
goneDetail: "Reload to keep working; background tasks keep running.",
reasonLabel: "Reason code: ",
reloadBtn: "Reload", waitBtn: "Keep waiting", laterBtn: "Not now",
trayTip: "OpenWorkBuddy desktop",
trayShow: "Show window", trayNewTask: "New task", trayQuit: "Quit",
quitBusy: (n) => (n === 1 ? "1 task is still running. Quitting will stop it." : `${n} tasks are still running. Quitting will stop them.`),
quitBtn: "Stop and quit", stayBtn: "Keep running",
},
};
/**
* 窗口还没起来的时候,系统语言是唯一问得到的信号——启动失败页正是这种时候要画的东西。
* 口径跟网页那边的 detect() 一致:问不出来算中文,zh 开头算中文,其余算英文。
*/
function osLang() {
let loc = "";
try { loc = String(app.getLocale() || ""); } catch {}
if (!loc) return "zh";
return /^zh/i.test(loc) ? "zh" : "en";
}
const T = (lang) => SHELL_TEXT[lang === "en" ? "en" : "zh"];
// 渲染进程上一次答出来的界面语言(见 uiLang)。界面卡死或者没了的时候问不着它,只能用这一份
let LAST_UI_LANG = "";
// ---------- 启动日志:出事时用户手里唯一的物证 ----------
/**
* 「任务管理器里有进程、屏幕上没窗口」这类报障(issue #1),没有日志就只能靠猜,
* 用户能做的只有重装三遍——而重装治不好这个病。所以从进程起来的第一行就往磁盘记,
* 最后把日志路径写进报错页面,让他直接贴给我们。
*
* 首选数据目录;数据目录本身就是起不来的原因时(没权限、家目录被重定向到离线的网络盘),
* 退到系统临时目录。这段代码自己绝不许抛——它是用来报错的,不能成为新的错因。
*/
function pickBootLog(candidates) {
for (const f of candidates) {
if (!f) continue;
try {
fs.mkdirSync(path.dirname(f), { recursive: true });
// 只留最近一次启动的上下文,别让它长成一个没人敢打开的大文件
try { if (fs.statSync(f).size > 512 * 1024) fs.truncateSync(f, 0); } catch {}
fs.appendFileSync(f, "");
return f;
} catch {}
}
return null; // 哪儿都写不了:那就只剩控制台,至少别把启动本身拖垮
}
const BOOT_LOG = pickBootLog([
(() => { try { return path.join(dataPath("logs"), "boot.log"); } catch { return null; } })(),
(() => { try { return path.join(os.tmpdir(), "OpenWorkBuddy-boot.log"); } catch { return null; } })(),
]);
function bootLog(...parts) {
const line = `+${Date.now() - BOOT_T0}ms ${parts.join(" ")}`;
console.log("[启动] " + line);
if (!BOOT_LOG) return;
try { fs.appendFileSync(BOOT_LOG, `[${new Date().toISOString()}] ${line}\n`); } catch {}
}
bootLog(`—— OpenWorkBuddy ${require("./package.json").version} 启动 · ${process.platform}/${process.arch} · Electron ${process.versions.electron} ——`);
// ---------- 主线程卡顿记录(常开,见 bridge-main.js createStallWatch) ----------
// 这个进程就是界面线程。卡过 250ms 记一行:卡了多久、桥上那会儿有哪几件活(只有名字,没有路径和内容)。
// 行打到控制台(开发壳把它接进 ~/Library/Logs/OpenWorkBuddy.log),同时追加进启动日志;追加走异步,
// 记卡顿这件事自己不许再卡界面。OWB_MAIN_STALL_MS 给测试调门槛(浸泡测试用 100)
const MAIN_OPS = require("./src/desktop/bridge-main").createOpTracker();
const MAIN_STALL = require("./src/desktop/bridge-main").createStallWatch({
thresholdMs: Number(process.env.OWB_MAIN_STALL_MS) > 0 ? Number(process.env.OWB_MAIN_STALL_MS) : 250,
ops: MAIN_OPS,
write: (line) => {
console.warn(line);
if (BOOT_LOG) fs.appendFile(BOOT_LOG, `[${new Date().toISOString()}] ${line}\n`, () => {});
},
});
// 只在本应用有窗口拿着焦点时开:100ms 一拍,人在别的应用里时界面线程卡一下谁也看不见,
// 这一拍却一直挡着 CPU 进深睡(2026-10-07 实测,空闲主进程每秒被叫醒的次数里它占大头)。
// 测试给了 OWB_MAIN_STALL_MS 就常开:浸泡测试的窗口是藏着的,永远拿不到焦点
const MAIN_STALL_ALWAYS = Number(process.env.OWB_MAIN_STALL_MS) > 0;
if (MAIN_STALL_ALWAYS) MAIN_STALL.start();
app.on("browser-window-focus", () => MAIN_STALL.start());
app.on("browser-window-blur", () => { if (!MAIN_STALL_ALWAYS && !BrowserWindow.getFocusedWindow()) MAIN_STALL.stop(); });
global.__owbMainStall = MAIN_STALL; // 浸泡测试跟 electron-main 同一个进程,从这儿读卡顿记录
global.__owbMainOps = MAIN_OPS;
// ---------- 主进程 CPU 采样(只给测试:OWB_MAIN_PROFILE=要写的 .cpuprofile 路径) ----------
// 卡顿记录说得出「那会儿有哪几件活」,说不出「卡在哪一帧」。测试宿主要点名到函数时开这个,
// 退出前(或者 global.__owbMainProfile.stop())落一份 Chrome DevTools 能直接打开的 .cpuprofile。平时不开:采样本身有开销
const MAIN_PROFILE = (() => {
const out = String(process.env.OWB_MAIN_PROFILE || "");
if (!out) return null;
try {
const s = new (require("inspector").Session)();
s.connect();
s.post("Profiler.enable");
s.post("Profiler.setSamplingInterval", { interval: 500 }); // 0.5ms 一采:100ms 的卡顿能落 200 个样本
s.post("Profiler.start");
let stopping = null;
const stop = () => stopping || (stopping = new Promise((resolve) => {
s.post("Profiler.stop", (err, r) => {
try { if (!err && r && r.profile) fs.writeFileSync(out, JSON.stringify(r.profile)); } catch {}
try { s.disconnect(); } catch {}
resolve(err ? "" : out);
});
}));
bootLog(`[采样] 主进程 CPU 采样已开,退出时写到 ${out}`);
return { stop };
} catch (e) {
bootLog("▲ 主进程 CPU 采样没开起来:" + String((e && e.message) || e));
return null;
}
})();
global.__owbMainProfile = MAIN_PROFILE;
// 端口优先级跟服务端共用一份实现(paths.js),各写各的必然漂——漂了的症状是窗口永远等不到人。
// 端口要在 fatal 之前就位:报错文案里要用它,而异常可能发生在模块还没读完的时候
let PORT;
try {
PORT = resolvePort(process.env, require(dataPath("config.json")));
} catch {
PORT = resolvePort(process.env, null);
}
// 有些机器的显卡驱动会让 Electron 的窗口永远画不出来——进程活着,屏幕上什么都没有。
// 给一个不用改代码就能绕过去的开关:环境变量,或者用记事本在 config.json 里加一行。
// 报错页面会把这一招写给用户看。
const NO_GPU = process.env.OPENWORKBUDDY_DISABLE_GPU === "1" || (() => {
try { return require(dataPath("config.json")).server.disable_gpu === true; } catch { return false; }
})();
if (NO_GPU) {
try { app.disableHardwareAcceleration(); bootLog("已关闭硬件加速(disable_gpu)"); } catch {}
}
// 窗口收起来以后要降频(win-away.js),但不要 Chromium 藏满 5 分钟后那档「计时器一分钟才醒一次」:
// 前端的断流看门狗、审批倒计时都挂在计时器上,晚一分钟才发现断流,用户切回来看到的是一条早就断了的流。
// 必须在 ready 之前设
try { app.commandLine.appendSwitch("disable-features", "IntensiveWakeUpThrottling"); } catch {}
// Windows 上 userData 在 %APPDATA%(Roaming):公司域账号开了漫游配置时,几百 MB 网页缓存每次登录注销
// 都要跟服务器同步一遍。只把 HTTP 缓存挪到本机的 %LOCALAPPDATA%;localStorage、Cookie 原地不动——
// 那些一挪用户就被登出了
if (process.platform === "win32" && process.env.LOCALAPPDATA && !process.env.OWB_USER_DATA_DIR) {
try { app.commandLine.appendSwitch("disk-cache-dir", path.join(process.env.LOCALAPPDATA, "openworkbuddy", "Cache")); } catch {}
}
let win;
let PAGE_UP = false; // 页面真加载出来了:之后再有偶发异常,不该把用户正在做的事掐掉
let FATAL_SHOWN = false;
// 服务端是不是跑在这个进程里。端口上早有一台 OWB、窗口连过去的时候(got.reused)任务在人家进程里,
// 这边退出中断不了它们——那就既不该问「要不要中断」,更不能顺手去叫停别人的任务
let SERVER_OWN = false;
// 退出走到哪一步了(requestQuit):"" 没在退 · "asking" 确认框挂着 · "closing" 正在收尾 · "done" 放行
let QUIT_STATE = "";
let SHUTDOWN = null; // shutdown() 那一趟的 Promise:谁来要都给同一个,收尾只做一遍
let tray = null; // 托盘图标。得有人一直拿着引用,被垃圾回收掉的话图标会从状态栏上凭空消失
let trayLang = ""; // 托盘菜单眼下是哪种语言(refreshTray):没变就不重建
// Windows 上窗口底色跟着主题走(为什么见 themeBg):theme 是上次问到的设置页主题,wins 是主窗口 + 站内子窗口
const SHELL_THEME = { theme: "", wins: new Set() };
// 服务端跑在哪。2026-09-29 审计:几个对话一起跑时服务端在主进程里把事件循环卡到 p99 68ms、最长 330ms,
// 窗口、菜单、托盘跟着一顿一顿(用户原话「开几个对话整个应用连带电脑都卡」)。所以默认挪进独立的
// utilityProcess(server-host.js),这边只管窗口。OWB_SERVER_PROCESS=inproc 走老路子;
// 独立进程起不来、反复崩,server-supervisor.js 自己退回老路子,日志里写明为什么
const SERVER_PROCESS = process.env.OWB_SERVER_PROCESS === "inproc" ? "inproc" : "utility";
let SERVER_REMOTE = false; // 眼下服务端是不是在独立进程里(退回之后变 false)
let SUPERVISOR = null; // server-supervisor.js 那个看护
let SHELL_BRIDGE = null; // bridge-main.js 那一半:测试宿主的 status 要报它接了几个 call(证明渲染真是过桥做的)
let approvalNudge = null; // 来审批了、窗口不在前台时提醒一下;独立进程报过来时调的也是它
// 测试宿主(test/server-process.js):不进 Dock、窗口在屏幕外、不弹任何系统框、不抢全局快捷键、不建托盘。
// 没人去点的框挂在用户桌面上、被抢走的快捷键,都是在打扰一个正在干活的人。平时不设
const HIDDEN = process.env.OWB_SHELL_HIDDEN === "1";
if (HIDDEN) {
try { if (app.dock) app.dock.hide(); } catch {}
dialog.showErrorBox = (title, body) => console.error(`[隐藏运行] 报错框(没弹):${title} · ${String(body || "").split("\n")[0]}`);
dialog.showMessageBox = async (...a) => {
const o = a[a.length - 1] || {};
console.log(`[隐藏运行] 询问框(没弹,按第一个按钮答):${o.message || ""}`);
return { response: 0, checkboxChecked: false };
};
// 选文件夹 / 另存为:服务端在这个进程里跑(inproc、退回)时直接调的就是这两个,弹出来就挂在用户桌面上等人点
dialog.showOpenDialog = async () => { console.log("[隐藏运行] 选文件框(没弹,按取消答)"); return { canceled: true, filePaths: [] }; };
dialog.showSaveDialog = async () => { console.log("[隐藏运行] 存储框(没弹,按取消答)"); return { canceled: true, filePath: "" }; };
globalShortcut.register = () => true;
}
/**
* 启动阶段的每一声崩溃都得有个出口。
* 没有这个出口的表现就是 issue #1:进程活着、窗口不出现、用户手里一条线索都没有。
* 窗口已经在了就把原因画进窗口;窗口还没有就弹系统级报错框——它不需要窗口也能显示。
*/
function fatal(stage, err) {
const msg = String((err && (err.stack || err.message)) || err || "未知错误");
bootLog(`✗ ${stage}:${msg}`);
if (PAGE_UP || FATAL_SHOWN) return; // 已经跑起来了,或者已经报过一次,不重复打扰
// 已经在退了(启动到一半他点了关窗、系统在关机):窗口一销毁,还没跑完的启动流程会接着撞上死窗口抛错。
// 这时候再弹「启动失败」是吓人——他要的是退出,退出这条路自己会走完。日志上面已经记了
if (QUIT_STATE) return;
FATAL_SHOWN = true;
if (win && !win.isDestroyed()) return showBootFailure(err);
const show = () => {
try {
const t = T(osLang()); // app.getLocale() 得等 ready,而这个函数正是 ready 之后才跑的
dialog.showErrorBox(
t.failTitle,
`${bootAdvice(err, msg, PORT, t)}\n\n${((err && err.bootProblem && err.bootProblem.title) || msg).split("\n")[0]}\n\n${t.bootLogLabel}${BOOT_LOG || t.bootLogNone}`
);
} catch {}
app.exit(1);
};
// macOS 上 showErrorBox 必须等 ready;Windows/Linux 上早晚都行
if (app.isReady()) show();
else app.whenReady().then(show).catch(() => app.exit(1));
}
process.on("uncaughtException", (e) => fatal("主进程未捕获异常", e));
// 没人接的 Promise 拒绝没那么致命:后台某个 fetch 挂了也会掉到这儿,
// 不该因此把一个本来能用的应用换成报错页。只有连窗口都还没建出来时才当启动失败办,
// 其余情况记一笔日志——窗口到底出没出来,交给下面的看门狗判。
process.on("unhandledRejection", (e) => {
if (!win) return fatal("主进程未处理的 Promise 拒绝", e);
bootLog("▲ 有个没人接的 Promise 拒绝:" + String((e && e.message) || e));
});
// 装机态:代码在只读的应用包里,配置/数据/工作区落到 ~/OpenWorkBuddy。
// 首次启动(以及每次升级后)把包里自带的 experts.json 和内置技能补进去;用户没动过的内置技能跟着换新版,
// 改过一个字节的原样留着(细节见 paths.js syncBuiltinSkills)。
// ⚠️ 以前这一句在模块顶层裸跑。它要在用户家目录下建 ~/OpenWorkBuddy:没权限、家目录被重定向到
// 离线的网络盘,都会炸在这儿——一抛异常主进程当场没了,窗口永远不出现,用户只看到一个进程。
// 现在先接住,等窗口建好了再把原因画给他看。
let SEED_ERR = null;
try {
seedDataDir();
bootLog("数据目录就绪:" + dataPath());
} catch (e) {
SEED_ERR = e;
bootLog("✗ 数据目录建不起来:" + ((e && e.message) || e));
}
// 改过两次名(workbuddy-clone → openbuddy → openworkbuddy)。Electron 的 userData 目录跟着
// package.json 的 name 走,不搬家的话老用户会丢 localStorage(表现为莫名其妙被登出)。
// 按时间倒序找最近的一个旧目录搬过来,只在新目录不存在时搬一次。
// ⚠️ 白名单只放我们自己用过的精确名字,并且只做全等匹配、不做前缀/模糊匹配:
// appData 底下还躺着别家同类应用的目录,名字跟我们挨得很近,匹配放宽一格就是删别人的数据。
const LEGACY_USERDATA = ["openbuddy", "workbuddy-clone"];
// 显示名叫 OpenWorkBuddy(「关于」面板、系统通知的署名),但 userData 目录钉死在 openworkbuddy:
// app.setName 会连带把 userData 改成 appData/OpenWorkBuddy,那等于第三次改名、用户又被登出一次
app.setPath("userData", path.join(app.getPath("appData"), "openworkbuddy"));
// 测试宿主(test/server-process.js)用自己的 userData:单实例锁就在这个目录里,跟用户正开着的那台共用一把锁,
// 起测试就等于在他那台上触发 second-instance(窗口被拽到前台)。平时不设
if (process.env.OWB_USER_DATA_DIR) app.setPath("userData", path.resolve(process.env.OWB_USER_DATA_DIR));
app.setName("OpenWorkBuddy");
// Windows 按 AppUserModelID 认「这是哪个应用」:安装程序给开始菜单和桌面快捷方式写的是 appId,
// 进程自己不设,Electron 会在第一次弹通知时编一个 electron.app.OpenWorkBuddy——通知没图标没署名、
// 有的直接被系统吞掉,钉在任务栏的图标和开着的窗口也分成两个按钮。
// 必须和 electron-builder.config.js 的 appId 一字不差,并且赶在建任何窗口、发任何通知之前。
// 开发态没有快捷方式可对,用 exe 路径当 ID,别冒领装好的那份
if (process.platform === "win32") app.setAppUserModelId(app.isPackaged ? "com.catcatuncle.openworkbuddy" : process.execPath);
/**
* 「关于」面板那行版权:许可证和主页都从 package.json 读。
* 以前写死 "MIT",许可证早换成 PolyForm-Noncommercial 了,面板还在说 MIT——对外等于许了一个不存在的授权。
*/
function aboutCopyright(pkg) {
const home = String((pkg && pkg.homepage) || "").replace(/^https?:\/\//i, "").replace(/\/+$/, "");
return [pkg && pkg.license, home].filter(Boolean).join(" · ");
}
app.setAboutPanelOptions({ applicationName: "OpenWorkBuddy", applicationVersion: require("./package.json").version, copyright: aboutCopyright(require("./package.json")) });
(function migrateUserData() {
if (process.env.OWB_USER_DATA_DIR) return; // 测试宿主:用户那几个老目录一个都别碰
try {
const base = app.getPath("appData");
const to = path.join(base, "openworkbuddy");
if (fs.existsSync(to)) return;
for (const name of LEGACY_USERDATA) {
const from = path.join(base, name);
if (!fs.existsSync(from)) continue;
fs.renameSync(from, to);
console.log(`[迁移] userData 已从 ${name} 搬到 openworkbuddy`);
return;
}
} catch (e) {
console.warn("[迁移] userData 搬家失败(不影响使用,只是要重新登录一次):", e.message);
}
})();
// 单实例:双击启动器/重复 npm run app 时,把已开的窗口拉到前台,而不是再叠一个实例
// (第二个实例的服务端会撞端口走"连接已运行实例"分支,结果就是两个窗口两份 Dock 图标)
if (!app.requestSingleInstanceLock()) {
// 留一行日志再走。用户这边看到的是「双击了没反应」,日志里得说清是「已经有一个在跑」,
// 否则这条正常行为和真的启动失败长得一模一样。
bootLog("已经有一个实例在跑,这次启动把它唤到前台后退出");
app.exit(0); // 立即退出:app.quit() 是异步的,慢一步的话 whenReady 还会抢跑建出第二个窗口
} else {
app.on("second-instance", (_event, argv) => {
queueOpenFiles(argv.slice(app.isPackaged ? 1 : 2));
// 锁在、窗口不在:上一个实例卡在启动中途(或者已经崩了但进程没退)。
// 这时候用户会一直双击图标、一直没反应——必须告诉他发生了什么,以及去哪儿看日志。
if (!win || win.isDestroyed()) {
bootLog("重复启动:锁被一个没有窗口的实例占着");
try {
const t = T(osLang());
dialog.showErrorBox(
t.stuckTitle,
`${t.stuckBody}\n\n${t.bootLogLabel}${BOOT_LOG || t.bootLogNone}`
);
} catch {}
return;
}
if (win.isMinimized()) win.restore();
win.show();
win.focus();
});
}
// ---------- 窗口记忆 ----------
// 窗口上次关在哪儿、多大,下次还开在那儿(userData/window-state.json)。
// 这份文件纯属锦上添花,读写都兜住:读坏了当没存过,写不进去就算了——为它开不出窗口不值得。
const WIN_SIZE = { width: 1520, height: 900, minWidth: 680, minHeight: 480 };
function winStateFile() {
try { return path.join(app.getPath("userData"), "window-state.json"); } catch { return null; }
}
function readWindowState(file) {
try {
const v = JSON.parse(fs.readFileSync(file, "utf8"));
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
} catch {
return null; // 没有、读不了、半截 JSON(上次写到一半断电):都当没存过
}
}
function writeWindowState(file, state) {
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
// 先写旁边再改名:写到一半断电,盘上留下的是上一份完整的,不是半截 JSON
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state));
fs.renameSync(tmp, file);
return true;
} catch {
return false;
}
}
/**
* 存下来的位置还能不能原样用。
* · 数不像话(NaN、字符串、负的宽高)→ 那一项用默认;太小抬到最小尺寸,比屏还大压到最大那块屏以内;
* · 位置:标题栏得整条落在某块屏的工作区里、横向至少露出 80 像素才用。上次开在外接屏上、
* 这次屏拔了,坐标指着一片不存在的地方——窗口开在那儿等于没开,也拖不回来(抓不到标题栏)。
* 这时候不给 x/y,Electron 自己居中。口径照宠物的 sanePos(pet.js),只是主窗口得抓得到标题栏。
*/
function saneWindowBounds(saved, areas, def) {
const num = (v) => typeof v === "number" && Number.isFinite(v);
const s = saved && typeof saved === "object" ? saved : {};
const scr = (areas || []).filter((a) => a && num(a.x) && num(a.y) && a.width > 0 && a.height > 0);
let width = num(s.width) && s.width > 0 ? s.width : def.width;
let height = num(s.height) && s.height > 0 ? s.height : def.height;
if (scr.length) {
width = Math.min(width, Math.max(...scr.map((a) => a.width)));
height = Math.min(height, Math.max(...scr.map((a) => a.height)));
}
const out = { width: Math.round(Math.max(def.minWidth, width)), height: Math.round(Math.max(def.minHeight, height)) };
if (num(s.x) && num(s.y)) {
const x = Math.round(s.x), y = Math.round(s.y);
// y 放 10 像素余量:Windows 的窗口四周有一圈看不见的缩放边,贴顶摆的窗口 y 会是个小负数
const seen = scr.some((a) => x + out.width - 80 >= a.x && x + 80 <= a.x + a.width && y >= a.y - 10 && y + 40 <= a.y + a.height);
if (seen) { out.x = x; out.y = y; }
}
if (s.maximized === true) out.maximized = true;
return out;
}
/**
* Windows 上不要 Electron 自带的那套 File/Edit/View 英文菜单。
* · autoHideMenuBar 只是把它藏起来:按一下 Alt 它就冒出来、焦点被它拿走,用户接着打的字全进了菜单;
* · 那套菜单还带着 Ctrl+R / Ctrl+Shift+R 重新加载,输入框里没发出去的话按一下就没了;
* · 复制 / 粘贴 / 剪切 / 全选 / 撤销 / 重做在 Windows 上是网页内核自己认的按键,不靠菜单加速键;
* 应用自己的快捷键(新建对话、F11 全屏这些)是页面里的 keydown,唤起窗口走 globalShortcut,都不受影响。
* 菜单里还有两样有人在用:缩放(Ctrl + = - 0)和开发态的开发者工具,由 attachShellKeys 接过来。
* mac 不动:那边的 ⌘C / ⌘V 就挂在 Edit 菜单上,拿掉了全部失灵。Linux 也照旧(不出 Linux 包,没法验)
*/
function dropDefaultMenu(menu, platform) {
if (platform !== "win32") return false;
menu.setApplicationMenu(null);
return true;
}
/**
* 默认菜单拿掉以后,Windows 上哪些按键要由主进程自己接(见 dropDefaultMenu):
* · 开发态 F12 / Ctrl+Shift+I 开关开发者工具;装机包里不给(用户误按出一块英文面板,只会以为坏了);
* · Ctrl + = / + / - / 0 缩放,跟原来菜单里那三项一样每次半档、Ctrl+0 回原样。
* 按着 Alt 的一律不管:Windows 上 AltGr 报出来就是 Ctrl+Alt,德语、法语键盘用它打 { } [ ] 这些字,
* 不能被当成缩放吃掉。Win 键(meta)组合是系统的,也不碰
*/
function shellKeyAction(input, platform, packaged) {
if (platform !== "win32" || !input || input.type !== "keyDown" || input.alt || input.meta) return null;
const key = String(input.key || ""), code = String(input.code || "");
if (!packaged) {
if (key === "F12" && !input.control && !input.shift) return "devtools";
if (input.control && input.shift && (code === "KeyI" || key.toLowerCase() === "i")) return "devtools";
}
if (!input.control) return null;
if (key === "=" || key === "+" || code === "Equal" || code === "NumpadAdd") return "zoomIn";
if (input.shift) return null; // Ctrl+Shift+- / Ctrl+Shift+0 留给页面(可能被用户绑成了别的快捷键)
if (key === "-" || code === "Minus" || code === "NumpadSubtract") return "zoomOut";
if (key === "0" || code === "Digit0" || code === "Numpad0") return "zoomReset";
return null;
}
/** 把 shellKeyAction 认出来的键接到一个 webContents 上;只在 Windows 上挂(别的平台菜单还在,按键照旧归它) */
function attachShellKeys(wc, platform, packaged) {
if (platform !== "win32" || !wc) return false;
wc.on("before-input-event", (event, input) => {
const act = shellKeyAction(input, platform, packaged);
if (!act) return;
event.preventDefault();
try {
if (act === "devtools") wc.toggleDevTools();
else if (act === "zoomReset") wc.setZoomLevel(0);
else wc.setZoomLevel(wc.getZoomLevel() + (act === "zoomIn" ? 0.5 : -0.5));
} catch (e) {
bootLog("▲ 快捷键没生效(" + act + "):" + String((e && e.message) || e));
}
});
return true;
}
/**
* 窗口底色。拖窗口边改大小时,新露出来的那一条网页还没画上,系统先拿窗口底色填——
* 写死白色的话,深色模式下每拖一下边上就闪一道白。颜色跟 public/css/ui.css 的 --background 一致。
* 主题以设置页里选的为准(owb-theme,见 uiTheme);没选或选了「跟随系统」才看系统。
* 只改 Windows,别的平台还是原来的白底,一点不动
*/
function themeBg(theme, systemDark, platform) {
if (platform !== "win32") return "#ffffff";
const dark = theme === "dark" || (theme !== "light" && !!systemDark);
return dark ? "#1e1f24" : "#ffffff";
}
/**
* 把主题落到窗口上:每个还开着的窗口底色换掉。只在 Windows 上做,别的平台原样返回 null。
* nativeTheme.themeSource 故意不碰:它一改,所有渲染进程的 prefers-color-scheme 跟着翻,
* 隐藏的出图窗口(网页截图、网页转视频、页面检查、缩图)也在内,生成的图和视频就会随应用主题变
*/
function applyShellTheme(theme, nt, wins, platform) {
if (platform !== "win32" || !nt) return null;
const bg = themeBg(theme, nt.shouldUseDarkColors, platform);
for (const w of wins || []) {
try { if (w && !w.isDestroyed()) w.setBackgroundColor(bg); } catch {}
}
return bg;
}
/**
* 设置页的主题存在渲染进程的 localStorage 里(owb-theme),主进程读不到,照 uiLang 的办法张嘴问一句。
* 问不到(页面卡住、启动失败页、存储被禁)回 null,调用方就沿用上次那份
*/
async function uiTheme(wc) {
try {
const v = await wc.executeJavaScript('(function(){try{return localStorage.getItem("owb-theme")||""}catch(e){return ""}})()', false);
return v === "dark" || v === "light" || v === "system" ? v : "";
} catch {
return null;
}
}
/**
* 主题什么时候会变:用户在设置页点了一下(网页不告诉主进程,只能在点完、按完键之后问一句),
* 切回窗口时(可能在别的窗口改过),页面重新加载完,还有系统自己换了深浅(nativeTheme 'updated')。
* 点击、按键停手 waitMs 再问:打字时不至于每个键都去问一遍。只在 Windows 上挂
*/
function watchShellTheme(w, nt, state, platform, waitMs = 200) {
if (platform !== "win32" || !w || !nt || !state) return null;
const wc = w.webContents;
const sync = async () => {
if (!wc || (wc.isDestroyed && wc.isDestroyed())) return state.theme;
const t = await uiTheme(wc);
if (t !== null) state.theme = t;
applyShellTheme(state.theme, nt, state.wins, platform);
return state.theme;
};
let timer = null;
const later = () => { clearTimeout(timer); timer = setTimeout(sync, waitMs); };
wc.on("did-finish-load", () => { sync(); });
w.on("focus", () => { sync(); });
wc.on("input-event", (_e, ev) => {
const t = ev && ev.type;
if (t === "mouseUp" || t === "keyUp" || t === "touchEnd" || t === "gestureTap") later();
});
nt.on("updated", () => { applyShellTheme(state.theme, nt, state.wins, platform); });
return sync;
}
/**
* 托盘图标用哪个文件。Windows 托盘按「小图标」的系统尺寸取图:100% 缩放 16 像素,125% 20,150% 24,200% 32……
* 只给 16 / 32 两份 PNG 的话,125%、150% 是拿 32 现缩的,糊。public/tray.ico 里每档都有一份现成的,
* 而且只有从 .ico 文件建的图,Electron 才会按系统尺寸挑那一帧。mac / Linux 照旧用 favicon.png
*/
function trayIconFile(dir, platform) {
return path.join(dir, "public", platform === "win32" ? "tray.ico" : "favicon.png");
}
/**
* 缩图三件活(缩略图、给模型看的压图、宠物头像)交给谁做,都不在界面线程上(为什么见 thumb-sips.js):
* mac → sips 子进程;Windows → 常驻的隐藏网页窗口(browser-render.js createRenderPixels);
* 其余平台、或者 OWB_MAIN_PIXELS=native(浸泡测试的反向对照)→ null,走回主线程 nativeImage
*/
function pixelsKind(platform, env) {
if (env && env.OWB_MAIN_PIXELS === "native") return null;
if (platform === "darwin") return "sips";
if (platform === "win32") return "render";
return null;
}
async function waitForServer(url, tries = 200) {
for (let i = 0; i < tries; i++) {
try {
// 只看服务端有没有应答,不看状态码:/api/* 挂在登录守卫后面,没登录时回 401,
// 那也是「服务端活着」。以前只认 r.ok,导致每次启动都空等满 30 秒超时才加载页面
await fetch(url);
return true;
} catch {}
await new Promise((r) => setTimeout(r, 150)); // 服务端 1 秒内就绪,粗轮询白等半秒
}
return false;
}
app.whenReady().then(async () => {
queueOpenFiles(process.argv.slice(app.isPackaged ? 1 : 2));
bootLog("Electron 运行时就绪");
if (app.isPackaged && !HIDDEN && path.resolve(app.getAppPath()) === path.resolve(__dirname) && process.env.OPENWORKBUDDY_INSTALL_CLI !== "0") {
require("./src/desktop/cli-install").installCli({ appDir: __dirname, dataHome: process.env.OPENWORKBUDDY_HOME || undefined })
.then(r => { if (!r.ok) bootLog("CLI 注册未完成:" + r.reason); })
.catch(e => bootLog("CLI 注册失败:" + e.message));
}
// 开发态(npm run app)跑的是 node_modules 里的 Electron.app,Dock 默认挂它的图标;换成我们自己的。
// 菜单栏左上角的名字改不了——macOS 只认正在跑的那个 .app 的 Info.plist,
// 要连名字一起对,用 scripts/make-mac-app.sh 生成的 ~/Applications/OpenWorkBuddy.app 启动。
if (process.platform === "darwin" && app.dock && !app.isPackaged) {
try { app.dock.setIcon(path.join(__dirname, "build", "icon.png")); } catch (e) { console.warn("[启动] Dock 图标设置失败:", e.message); }
}
// 上次关窗时的位置和大小。屏幕一块一块问:上次那块屏可能已经拔了
let place = { width: WIN_SIZE.width, height: WIN_SIZE.height };
try {
place = saneWindowBounds(readWindowState(winStateFile()), screen.getAllDisplays().map((d) => d.workArea), WIN_SIZE);
} catch (e) {
bootLog("▲ 上次的窗口位置没用上,按默认大小居中:" + String((e && e.message) || e));
}
// Windows 上拿掉默认的英文菜单(为什么见 dropDefaultMenu)。得在建窗口之前:窗口一建就把应用菜单挂上了
try { dropDefaultMenu(Menu, process.platform); } catch (e) { bootLog("▲ 默认菜单没拿掉:" + String((e && e.message) || e)); }
// 窗口先开(秒响应),服务端在同进程内随后启动,就绪即加载页面。
// 顺序反过来的话,用户要盯着 Dock 图标空等服务端把路由全注册完。
win = new BrowserWindow({
width: place.width,
height: place.height,
...(place.x !== undefined ? { x: place.x, y: place.y } : {}), // 不给 x/y 就是居中
...(HIDDEN ? { x: -20000, y: -20000 } : {}),
minWidth: WIN_SIZE.minWidth,
minHeight: WIN_SIZE.minHeight,
title: "OpenWorkBuddy",
autoHideMenuBar: true,
// Windows 上跟着主题(themeBg);这会儿还问不着设置页,先按系统深浅,页面加载完再对一次
backgroundColor: themeBg(SHELL_THEME.theme, nativeTheme.shouldUseDarkColors, process.platform),
show: false, // 页面渲染好了再亮相(ready-to-show),不给用户看白屏;下面有兜底定时防止永不出现
webPreferences: {
// 看得见的时候不降频;收起、最小化时 throttleWhenAway 再打开,拿回来关上(为什么见 win-away.js)
backgroundThrottling: false,
},
});
// 测试宿主:亮相、抢焦点的几个口子全堵上(ready-to-show、3 秒兜底、activate 都会来叫)
if (HIDDEN) for (const k of ["show", "showInactive", "focus", "maximize", "moveTop"]) win[k] = () => {};
// 窗口不在前台 = 页面认为人走开了,任务一跑完就发系统通知——测试宿主的通知一样会落在用户的通知中心里。
// 通知权限整个拒掉(页面看到的是 denied,不会再弹),声音静掉
if (HIDDEN) {
try {
win.webContents.setAudioMuted(true);
session.defaultSession.setPermissionCheckHandler((_wc, perm) => perm !== "notifications");
session.defaultSession.setPermissionRequestHandler((_wc, perm, cb) => cb(perm !== "notifications"));
} catch {}
}
throttleWhenAway(win, { log: bootLog });
// 窗口挪了、拉大了就记下来;拖动时 move 一秒几十次,停手半秒再写。关窗那一下立刻写
let saveTimer = null;
const saveWinState = () => {
clearTimeout(saveTimer);
saveTimer = null;
if (!win || win.isDestroyed()) return;
// 记「正常状态」那一份:最大化、全屏时的外框不是他摆的位置,下次按它还原会铺满屏又缩不回去
writeWindowState(winStateFile(), { ...win.getNormalBounds(), maximized: win.isMaximized() });
};
for (const ev of ["move", "resize", "maximize", "unmaximize"]) {
win.on(ev, () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveWinState, 500); });
}
win.on("close", saveWinState);
// 系统要关机 / 重启 / 注销:不拦。这时候拦下来问一句,用户看到的是「OpenWorkBuddy 阻止了关机」
// (macOS 上注销直接被取消),而关机本来就会带走所有进程,问了也留不住什么
let sysQuitTimer = null;
const letSystemQuit = () => {
QUIT_STATE = "done";
bootLog("系统要关机或注销:不拦,直接退");
// 关机也可能被取消(别的应用拦下、用户点了「取消」),进程还活着。一直停在「放行」的话,
// 之后关窗 / ⌘Q 就不问也不收尾,在跑的任务一声不吭就断了——过一分钟还没退,就把闸装回去
clearTimeout(sysQuitTimer);
sysQuitTimer = setTimeout(() => {
if (QUIT_STATE !== "done" || SHUTDOWN) return;
QUIT_STATE = "";
bootLog("系统关机一分钟了还没退,当它取消了:退出前的确认装回去");
}, SYS_QUIT_REARM_MS);
if (sysQuitTimer.unref) sysQuitTimer.unref();
};
win.on("query-session-end", letSystemQuit); // Windows
try { powerMonitor.on("shutdown", letSystemQuit); } catch {} // macOS / Linux
// 刚醒来那几十秒系统还压着 CPU(DarkWake),计时器迟到是系统干的,不记成界面卡顿
try { powerMonitor.on("resume", () => MAIN_STALL.pause(30000)); } catch {}
try { powerMonitor.on("suspend", () => MAIN_STALL.pause(30000)); } catch {}
// 首绘打磨:正常流程 ready-to-show 在 ~0.7s 内到,一次干净的整页亮相;
// 服务端起不来时它可能永远不触发,3 秒兜底强制亮窗,让用户看到报错而不是什么都没有。
// ⚠️ 这段必须排在 require("./server.js") 前面。放后面的话,服务端 require 一抛异常,
// 兜底定时器根本没来得及挂上,窗口就永远停在 show:false —— v0.1.1 装机包缺引擎目录
// 时用户看到的正是这个:任务管理器里有进程,屏幕上什么都没有。
// 上次是最大化关的,这次亮相前先最大化。不能在建窗口那会儿就调:Windows 上 maximize() 会顺手把窗口亮出来,白屏就露了
const showOnce = () => { if (win && !win.isVisible()) { if (place.maximized) win.maximize(); win.show(); } };
win.once("ready-to-show", () => { bootLog("窗口亮相"); showOnce(); });
setTimeout(showOnce, 3000);
// 看门狗:20 秒还没有一个亮着的窗口,就当启动已经失败了。
// 「窗口对象建出来了」不等于「用户看得见东西」——显卡驱动画不出来、loadURL 卡在网络栈上,
// 都会停在这一步,而这正是 issue #1 里「任务管理器有进程、屏幕上什么都没有」的样子。
const watchdog = setTimeout(() => {
if (PAGE_UP) return;
if (win && !win.isDestroyed() && win.isVisible()) return;
fatal("启动看门狗", new Error("启动 20 秒后仍然没有可见窗口"));
}, 20000);
if (watchdog.unref) watchdog.unref(); // 别让它拖着进程不退出
// 外链交给系统浏览器、右键给出「复制」——这两根线也必须排在 require("./server.js") 前面,
// 理由和上面那个 3 秒兜底亮窗一样:服务端 require 一抛异常,下面的代码一行都不会执行,
// 而这时窗口里画的恰恰是启动失败页。那一页上写着「把这行贴到 issue 里」「贴 issue 时带上
// 启动日志」,右键却弹不出「复制」——让一个刚被挡在门外的人手抄一串路径,等于没给出路。
// 页面上那个「提 issue」也一样:没挂 openHandler 的话它会在应用里另开一个没有地址栏、
// 没有登录态的 Electron 窗口,而不是去用户自己的浏览器。
// 站内链接留在应用里,只有真外链才交给系统浏览器。
// 这两件事以前是一件:凡 target="_blank" 一律 shell.openExternal。可登录令牌是一枚发给
// 这个 Electron session 的 HttpOnly cookie,系统浏览器身上根本没有——于是资料库里点一下
// 「新窗口打开」(那是 /api/files/view/…,站内地址),Safari 弹出来只有一行
// {"error":"未登录","setup":false}
// 「本地部署预览」那条不在此列:它是另起的一个进程、另一个端口,认不了这枚 cookie,
// 链接里自己带着令牌,本来就该去系统浏览器(手机上扫码打开也是靠它)。
const sameOrigin = (u) => {
try { return new URL(u).origin === `http://127.0.0.1:${PORT}`; } catch { return false; }
};
const showWorkbench = (url) => {
const target = workbenchLink(url, `http://127.0.0.1:${PORT}`);
if (!target) return false;
if (win && !win.isDestroyed()) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
// 只切任务,不重载页面:保留未发送的输入和正在运行的任务连接。
if (target.sessionId) win.webContents.executeJavaScript(
`window.dispatchEvent(new CustomEvent("owb-open-session", { detail: ${JSON.stringify(target.sessionId)} }));`, false
).catch(() => bootLog("▲ 任务链接未能交回主窗口"));
}
return true;
};
const openHandler = ({ url }) => {
if (showWorkbench(url)) return { action: "deny" };
if (sameOrigin(url)) {
// 同一个 session,cookie 跟着走;父窗口关了它还能留着,所以不设 parent
const child = new BrowserWindow({
width: 1000, height: 780, backgroundColor: themeBg(SHELL_THEME.theme, nativeTheme.shouldUseDarkColors, process.platform),
webPreferences: { backgroundThrottling: false },
});
throttleWhenAway(child); // 子窗口收起来一样降频
child.webContents.setWindowOpenHandler(openHandler); // 子窗口里再点链接,同一套规矩
// 预览中的链接和重定向也不能把预览窗口变成第二个工作台。
for (const event of ["will-navigate", "will-redirect"]) {
child.webContents.on(event, (e, nextURL) => { if (showWorkbench(nextURL)) e.preventDefault(); });
}
attachContextMenu(child.webContents);
// Windows:主题换了子窗口底色跟着换,缩放 / 开发者工具的按键跟主窗口一样(见 attachShellKeys)
if (process.platform === "win32") {
SHELL_THEME.wins.add(child);
child.on("closed", () => SHELL_THEME.wins.delete(child));
attachShellKeys(child.webContents, process.platform, app.isPackaged);
}
child.loadURL(url);
return { action: "deny" };
}
// 协议白名单:file:// 能把本机任意文件递出去,自定义 scheme 会唤起别的应用——
// 而这两种地址都可能来自模型写的网页或下载来的资料,不是用户自己打的
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url);
return { action: "deny" };
};
win.webContents.setWindowOpenHandler(openHandler);
attachContextMenu(win.webContents);
// Windows:窗口底色跟着设置页的主题走;默认菜单拿掉后缩放 / 开发者工具的按键由这边接
if (process.platform === "win32") {
const mainWin = win;
SHELL_THEME.wins.add(mainWin);
mainWin.on("closed", () => SHELL_THEME.wins.delete(mainWin));
watchShellTheme(win, nativeTheme, SHELL_THEME, process.platform);
attachShellKeys(win.webContents, process.platform, app.isPackaged);
}
// 界面卡死 / 崩掉的兜底。同样挂在 require 服务端之前:启动失败页那一页也可能卡住
attachCrashGuard(win);
// 数据目录在模块顶层就没建起来。后面服务端一定会跟着崩,但崩出来的错更难懂
// (读不到 config.json 之类),所以在这儿就把真正的原因交出来。
if (SEED_ERR) return fatal("准备数据目录", SEED_ERR);
// 在 Electron 主进程内直接启动服务端。
// 它是整个应用的地基,塌了就没有「降级可用」这回事——但用户至少得知道塌在哪,
// 而不是对着一个不出现的窗口重装三遍。
// 服务端起不来时,它得有个地方把原因交出来。没有这个出口的话它只能 process.exit(1):
// 主进程当场消失,上面那个 3 秒兜底亮窗根本轮不到,用户看到的就是「有进程、没界面」。
// ⚠️ 必须挂在 require 之前——server.js 的 main() 是异步的,失败可能发生在 require 返回之后的任何时刻。
global.__wbBootFail = (e) => showBootFailure(e);
// 服务端最后绑上的那个口,不一定是上面算出来的这个:3800 被别的程序占着时它会自己换一个。
// 不等它报数、直接按算出来的口加载,窗口就连到占着口的陌生程序上去了——用户看到一个不认识
// 的页面或者白屏,日志里却写着「服务端就绪」。这正是 issue 里「下载之后打不开」的样子。
let onBound;
const bound = new Promise((r) => { onBound = r; });
global.__wbOnListen = (p, meta) => onBound({ port: p, ...(meta || {}) });
// 默认先起独立服务进程。走不通(没监听 / 反复崩 / 它自己报错)返回 null,落到下面的老路子:
// 在这个进程里 require——同一个错会在这儿再抛一次,由 __wbBootFail 画进窗口,用户不会对着一个死窗口
const remote = SERVER_PROCESS === "utility" ? await startServerProcess() : null;
if (remote) onBound(remote);
else try {
require(path.join(__dirname, "server.js"));
} catch (e) {
console.error("[启动] 服务端起不来:", e);
return showBootFailure(e);
}
const got = await Promise.race([bound, new Promise((r) => setTimeout(() => r(null), 30000))]);
if (!got) {
// 服务端自己报错的那条路已经把窗口画成报错页了(__wbBootFail),别再盖一层
if (FATAL_SHOWN) return;
console.error(`[启动] 等了 30 秒,服务端一直没说它绑在哪个端口`);
return showBootFailure(new Error(`服务端启动后 30 秒内没有监听 ${PORT} 端口`));
}
if (got.port !== PORT) bootLog(`端口换了:${PORT} → ${got.port}(原来那个被别的程序占着)`);
PORT = got.port;
const up = await waitForServer(`http://127.0.0.1:${PORT}/api/info`);
if (!up) {
console.error(`[启动] 等了 30 秒,${PORT} 端口一直没人应答`);
return showBootFailure(new Error(`服务端启动后 30 秒内没有监听 ${PORT} 端口`));
}
bootLog(got.reused ? `${PORT} 上已经有一台 OpenWorkBuddy,连过去` : `服务端就绪,监听 ${PORT}`);
SERVER_OWN = !got.reused;
win.webContents.once("did-finish-load", () => {
// 过了这条线就算启动成功了:再有偶发异常只记日志,不能把用户正在做的事掐掉换成报错页
PAGE_UP = true;
clearTimeout(watchdog);
bootLog("页面加载完成 ✓ 启动成功");
// 免安装版被强杀时留在 %TEMP% 的解压目录(一份几百 MB),等启动忙完再收拾,规则见 portable-temp.js
if (process.platform === "win32" && app.isPackaged) {
setTimeout(() => {
require("./src/desktop/portable-temp").sweepStale()
.then((gone) => { if (gone.length) bootLog(`清掉免安装版留下的 ${gone.length} 个临时解压目录`); })
.catch(() => {});
}, 60 * 1000).unref();
}
});
// 用 127.0.0.1 而不是 localhost:有些机器(改过 hosts、或者 IPv6 优先)会把 localhost 解析到 ::1,
// 而服务端只监听了 IPv4,表现就是窗口一直空白。
win.loadURL(`http://127.0.0.1:${PORT}`);
/**
* 关掉主界面 = 整个应用退出,桌面宠物跟着一起走。
*
* 这件事原本只挂在 window-all-closed 上,而那个事件要求**所有**窗口都关掉才触发。
* 宠物是个 BrowserWindow,只要它还飘在桌面上,主界面关了也永远轮不到它。于是:
* · 进程留在后台,宠物赶不走,Dock 上那个图标也不消失;
* · 单实例锁还占着,用户再点图标就撞进 second-instance 分支,而 win 此刻已经销毁,
* 迎面收到一句「已经在运行了,但窗口没出来,请去活动监视器结束进程」——只为了关个窗口。
*
* 旧代码那行的注释写的就是「盯的是主窗口的 closed」,底下写的却是 window-all-closed。
* 两边对不上的时候,错的是代码。
*
* 注意这里盯的是 closed 不是 close:快捷键收起窗口走的是 win.hide(),碰不到这条路。
*
* 关窗这一下先被 close 拦住,交给 requestQuit:有任务在跑就先问一句,确认了才走 shutdown 收尾,
* 收完才放行。走到 closed 的时候那一趟已经放行过了(QUIT_STATE === "done"),这里再要一次只是确认退出。
*/
win.on("close", (e) => {
if (QUIT_STATE === "done") return;
e.preventDefault();
requestQuit();
});
win.on("closed", () => {
win = null;
global.__wbWin = null; // 留着一个已销毁的引用,server.js 那边取到就会往死对象上调方法
pushServerState({ winAlive: false });
requestQuit(); // 统一出口:会走 will-quit,宠物在那儿 destroy、全局快捷键在那儿注销
});
// 供 server.js(同进程内运行)访问窗口:全屏切换 / 快捷键热更新
global.__wbWin = win;
global.__wbRegisterShortcuts = registerShortcuts;
// 托盘:窗口被快捷键收起来之后,除了 Dock / 任务栏,这是另一个找得回它的地方
createTray();
win.webContents.on("did-finish-load", syncTrayLang);
win.on("focus", syncTrayLang);
win.on("blur", syncTrayLang);
// 独立服务进程问「是不是全屏」(/api/app/fullscreen 要切换)拿的是这边推过去的这份
win.on("enter-full-screen", () => pushServerState({ fullScreen: true }));
win.on("leave-full-screen", () => pushServerState({ fullScreen: false }));
// Dock 角标跟着网页标题走:标题前缀「(n) 」就是「有 n 件事在等你」,网页那边只维护这一个数。
// 不 preventDefault:窗口标题照旧跟网页走。Windows / 多数 Linux 上 setBadgeCount 是空操作,那边靠下面的闪烁
win.on("page-title-updated", (_e, title) => {
const m = /^\((\d+)\) /.exec(String(title || ""));
try { app.setBadgeCount(m ? Math.min(99, +m[1]) : 0); } catch {}
});
// 审批没有宠物那条提醒线(宠物只管 ask_user,而且已经 critical 弹过了),这里补一下轻的。
// 标题变化时不弹:那会把 ask_user 再弹一遍。跟 server.js 用的是同一个 security 模块实例
// 服务端在独立进程里时审批队列也在那边:它报过来(bridge-main 的 approval.open)再走同一个回调
try {
const watchApprovals = SERVER_REMOTE ? (fn) => { approvalNudge = fn; } : (fn) => {
approvalNudge = fn;
return require(path.join(__dirname, "src", "core", "safety", "security.js")).watchApprovals(fn);
};
watchApprovals((m) => {
if (!m || m.type !== "open" || !win || win.isDestroyed() || win.isFocused()) return;
try {
if (process.platform === "darwin" && app.dock) app.dock.bounce("informational");
else win.flashFrame(true);
} catch {}
});
} catch {}
// 桌面宠物:常驻角落显示 agent 在干什么,agent 要提问时跳给你看。
// 放在窗口之后创建,这样它一出生 global.__wbWin 就是齐的(点它要唤起主窗口)。
const pet = require(path.join(__dirname, "src", "desktop", "pet.js"));
global.__openworkbuddyPet = pet;
try {
/**
* 开机时把盘上那份原样交给宠物,一个字段都不许漏。
*
* 以前这儿手抄了五个字段,**偏偏漏掉了 sprite**。于是选了精灵图宠物的人,
* 每次重开都变回内置那只猫:character 传成了 "sprite",sprite 却是空字符串,
* pet.js 拿空 id 去 findPet 自然找不着,create() 最后那行
* `character: photo ? "photo" : sp ? "sprite" : "cat"` 就静静地落回 "cat"。
*
* 同一份名单还漏了 notifyDone 和 wander,scale 的兜底值也跟 pet.js 自己的
* DEFAULT_SCALE 对不上(那边是 2,这里写死 1)——三处都是同一个病:
* 手抄一份字段清单,加字段的人不会想到回来改它。
* 所以现在整份摊过去,只把 enabled 单独钉死成「必须显式为 true」(没配过就不该有宠物),
* 其余交给 pet.js 自己的默认值。以后加字段不用再动这里。
*/
const petCfg = require(dataPath("config.json")).pet || {};
pet.applyConfig({ ...petCfg, enabled: petCfg.enabled === true });
} catch {
pet.applyConfig({ enabled: false }); // 读不到配置就当没配过:默认不该有宠物
}
try {
const shortcuts = require(dataPath("config.json")).shortcuts || {};
registerShortcuts(shortcuts);
} catch {
registerShortcuts({});
}
}).catch((e) => fatal("桌面窗口初始化", e));
/**
* 把启动错误翻成一句用户能照着做的话。
*
* 这段文案是「什么都打不开」时用户手里唯一的线索,所以拎成具名函数,让 test/ 能直接切片测:
* 错一个分支的代价不是排版难看,是用户对着一句「服务端崩了」重装三遍。
*/
/**
* 右键菜单。Electron 默认**一个都没有**——桌面壳里右键点任何东西都是死的。
*
* 这不是「少个锦上添花的功能」:网页里右键图片选「复制图片」是所有人的肌肉记忆,
* 到了桌面版按下去什么都不弹,用户的结论只会是「这软件的图导不出去」。
* 而图恰恰是最该导出去的产出——生成完了就是要粘进微信、粘进 PPT。
*
* copyImageAt 是 Electron 自带的:按坐标把那张图以**系统原生位图**写进剪贴板,
* 不走网页那条 canvas 转码的路,所以不挑格式、不掉画质,粘到哪儿都认。
*/
function contextMenuItems(params, t, wc) {
const items = [];
if (params.mediaType === "image" && params.srcURL) {
items.push({ label: t.copyImage, click: () => wc.copyImageAt(params.x, params.y) });
items.push({ label: t.copyImageURL, click: () => clipboard.writeText(params.srcURL) });
items.push({ type: "separator" });
}
if (params.selectionText) {
items.push({ label: t.copy, role: "copy" });
if (params.isEditable) items.push({ label: t.cut, role: "cut" });
}
if (params.isEditable) {
items.push({ label: t.paste, role: "paste" });
items.push({ label: t.selectAll, role: "selectAll" });
}
// 链接单独一条:模型写出来的汇报里全是链接,想存一条下来以前只能手抄
if (params.linkURL && /^https?:/i.test(params.linkURL)) {
if (items.length) items.push({ type: "separator" });
items.push({ label: t.copyLink, click: () => clipboard.writeText(params.linkURL) });
items.push({ label: t.openInBrowser, click: () => shell.openExternal(params.linkURL) });
}
return items;
}
/**
* 界面语言存在渲染进程的 localStorage 里(owb-lang),主进程读不到,所以张嘴问一句。
* 问不到就退回系统语言——右键菜单绝不能因为这一问失败就弹不出来。
*/
async function uiLang(wc) {
try {
const v = await wc.executeJavaScript('(function(){try{return localStorage.getItem("owb-lang")||""}catch(e){return ""}})()', true);
if (v === "en" || v === "zh") return (LAST_UI_LANG = v); // 记一份:界面卡死时弹框要用,那会儿问不着
} catch {}
return osLang();
}
function attachContextMenu(wc) {
wc.on("context-menu", async (_e, params) => {
const items = contextMenuItems(params, T(await uiLang(wc)), wc);
if (!items.length) return; // 没什么可做的就别弹一个空菜单
if (wc.isDestroyed()) return; // 问语言这一下是异步的,这中间窗口可能已经关了
Menu.buildFromTemplate(items).popup({ window: BrowserWindow.fromWebContents(wc) || undefined });
});
}
/**
* 界面进程卡死、或者干脆没了,得有人接。
*
* Electron 默认什么都不做:进程没了窗口里就是一片白,卡死了就一直转圈,用户唯一的办法是强退整个应用——
* 而服务端和后台任务都跑在主进程里,强退会把它们一起带走。这里只动界面那一层:问他一句。
* · 卡死:「重新加载」还是「再等等」。不替他挑——渲染一大段内容时卡几秒是会自己缓过来的,
* 而重载会丢掉输入框里还没发出去的字。选了再等等、30 秒后还没缓过来,再问一次。
* 缓过来了(responsive)就把还挂着的框收掉,别让他对着一个已经没事的窗口做选择。
* · 没了:只剩「重新加载」和「先不管」两条——进程已经不在了,「再等等」等不来任何东西。
* 不自动重载:一加载就崩的页面会重载成死循环,框一个接一个弹。
* 卡死时重载得先把旧进程掐掉(forcefullyCrashRenderer),不然 reload 排在卡住的进程后面永远轮不到;
* 掐的那一下也会报一次「没了」,那是自己掐的,不算事故,不再弹框——重载就等它报上来再做:
* 实测 Electron 43,掐完紧跟着 reload 不会起新进程,窗口停在一片白,之后也再没有任何事件,
* 而那一下「没了」又被当成自己掐的咽了,用户选了重新加载,拿到的是一个永远白着、也不再问他的窗口。
* 弹框的字用上一次问到的界面语言(LAST_UI_LANG):这时候渲染进程已经答不了话了。
*/
function attachCrashGuard(w) {
const wc = w.webContents;
let open = null; // 正挂着的那个框 { kind, ctl },同一时刻只留一个
let hung = false; // 卡着还没缓过来
let recheck = null; // 选了「再等等」之后的复查
let killWait = null; // 为了重载自己掐了进程、正等它报「没了」:这段时间里报上来的那一下是自己掐的
const lang = () => LAST_UI_LANG || osLang();
const afterKill = () => {
clearTimeout(killWait);
killWait = null;
if (!w.isDestroyed()) wc.reload();
};
const reload = (kill) => {
if (w.isDestroyed()) return;
if (!kill) return wc.reload();
// 「没了」一直不来(进程早就不在了之类),5 秒后照样重载,别让窗口干白着
killWait = setTimeout(afterKill, 5000);
try { wc.forcefullyCrashRenderer(); } catch { afterKill(); }
};
const ask = async (kind, reason) => {
if (w.isDestroyed()) return;
if (open && open.kind === kind) return;
if (open) open.ctl.abort(); // 卡死的框还挂着、进程却没了:换成「没了」的那个框
const t = T(lang());
const mine = { kind, ctl: new AbortController() };
open = mine;
const gone = kind === "gone";
try {
const { response } = await dialog.showMessageBox(w, {
type: "warning",
title: "OpenWorkBuddy",
message: gone ? t.goneTitle : t.hungTitle,
detail: gone ? `${t.goneDetail}\n\n${t.reasonLabel}${reason || "unknown"}` : t.hungDetail,
buttons: [t.reloadBtn, gone ? t.laterBtn : t.waitBtn],
defaultId: gone ? 0 : 1, // 卡死时回车默认是「再等等」:不丢东西的那条
cancelId: 1,
noLink: true, // Windows 上别画成命令链接
signal: mine.ctl.signal,
});
if (mine.ctl.signal.aborted) return; // 被收掉的框(缓过来了 / 换了一个框),它的回答不算数
if (response === 0) {
bootLog(`界面${gone ? "没了" : "卡死"}:用户选了重新加载`);
hung = false;
reload(!gone);
} else if (!gone) {
bootLog("界面卡死:用户选了再等等");
clearTimeout(recheck);
recheck = setTimeout(() => { recheck = null; if (hung) ask("hung"); }, 30000);
if (recheck && recheck.unref) recheck.unref();
}
} catch (e) {
bootLog("▲ 界面兜底的弹框没弹出来:" + String((e && e.message) || e));
} finally {
if (open === mine) open = null;
}
};
wc.on("did-finish-load", () => { uiLang(wc); }); // 页面好好的时候问一句语言,存下来备用
wc.on("unresponsive", () => {
hung = true;
bootLog("▲ 界面没有响应");
ask("hung");
});
wc.on("responsive", () => {
hung = false;
clearTimeout(recheck);
recheck = null;
bootLog("界面缓过来了");
if (open && open.kind === "hung") open.ctl.abort();
});
wc.on("render-process-gone", (_e, details) => {
const reason = (details && details.reason) || "";
hung = false;
clearTimeout(recheck);
recheck = null;
if (killWait) return afterKill(); // 自己掐的:不弹框,这时候才重载
if (reason === "clean-exit") return; // 关窗、退应用时的正常退出,不是事故
bootLog(`▲ 界面进程没了:${reason}(exitCode ${details && details.exitCode})`);
ask("gone", reason);
});
}
/**
* 启动失败页上那句大标题该写什么。
*
* 开机闸门(boot-check.js)已经查出来的三种死法——Node 太老、源码版依赖没装、装机版缺文件——
* 它自己就带着一句该怎么修,原样用就行。以前这儿一律走下面的 bootHint 去**猜**:那些判据认的是
* "Cannot find module"、"EADDRINUSE" 这些英文报错,而闸门给的是中文人话,一条都对不上,于是
* 最知道该怎么修的三种情况,页面上写的全是「服务端启动时崩了,把这行贴到 issue 里」。
*/
function bootAdvice(err, msg, port, t) {
var bp = err && err.bootProblem;
return bp && bp.fix ? bp.fix : bootHint(msg, port, t);
}
function bootHint(msg, port, t) {
msg = String(msg || "");
if (/Cannot find module/.test(msg))
return t.hintMissingFiles;
// 数据目录建不起来要排在下面两条端口分支前面:它报的也是 EACCES,但换端口一点用没有。
// 用 mkdir/copyfile 这些系统调用名跟 listen EACCES 区分开——两者的解法完全不同。
if (/数据目录|EROFS|ENOSPC|\b(mkdir|copyfile|scandir|unlink|rmdir)\b/.test(msg) && !/listen/.test(msg))
return t.hintDataDir;
// EACCES 要排在 EADDRINUSE 前面:两者都是「端口用不了」,但解法不同,
// 前者换个端口就好,后者得去关掉占用的程序。
if (/EACCES|EPERM/.test(msg))
return t.hintPortDenied(port);
// 走到这儿说明连着往后试十个口也全被占着——本机版一般不会有这一天,
// 绑的不是本机地址时(Docker / 服务器)端口是运维定死的,压根不自动换。
if (/EADDRINUSE|端口/.test(msg))
return t.hintPortBusy(port);
if (/EADDRNOTAVAIL/.test(msg))
return t.hintHostGone;
return t.hintCrashed;
}
/**
* 启动失败时,把窗口亮出来说清楚哪儿坏了。
* 不这么做的话表现是「双击没反应」——用户唯一能做的就是重装,而重装治不好装机包缺文件。
* 页面用 data: URL 直接塞,因为这会儿 HTTP 服务端正是那个起不来的东西。
*/
function showBootFailure(err) {
const lang = osLang();
const t = T(lang);
const msg = String((err && err.message) || err || t.unknownError);
const hint = bootAdvice(err, msg, PORT, t);
// 闸门查出来的病因,红框里只放那一句结论就够了;解法已经当大标题写在上面,重复一遍反而更长
const detail = (err && err.bootProblem && err.bootProblem.title) || msg;
// 这一页上所有要他动手的东西都得给出确切位置:写「用户目录的 OpenWorkBuddy/config.json」,
// 等于让一个已经卡在门外的人自己去猜用户目录在哪,而 Windows 和 macOS 还不是一个地方
const CFG = (() => { try { return dataPath("config.json"); } catch (e) { return t.cfgFallback; } })();
const esc = (t) => String(t).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
const html = `<!doctype html><html lang="${lang === "en" ? "en" : "zh-CN"}"><meta charset="utf-8"><title>${esc(t.failTitle)}</title>
<style>
body{margin:0;font:14px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#1f2328;background:#fff;
display:flex;align-items:center;justify-content:center;height:100vh}
.box{max-width:560px;padding:0 32px}
h1{font-size:20px;margin:0 0 12px}
p{margin:0 0 16px;color:#57606a}
pre{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:12px 14px;overflow:auto;
font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:#cf222e;white-space:pre-wrap}
a{color:#0969da}
.small{font-size:12px;color:#8b949e}
code{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;padding:1px 5px;border-radius:4px}
</style>
<div class=box>
<h1>${esc(t.failTitle)}</h1>
<p>${esc(hint)}</p>
<pre>${esc(detail)}</pre>
<p class=small>${esc(t.gpuTip1)}<code>"disable_gpu": true</code>${esc(t.gpuTip2)}<code>${esc(CFG)}</code>${esc(t.gpuTip3)}<code>server</code>${esc(t.gpuTip4)}</p>
<p class=small>${esc(t.bootLogForIssue)}<code>${esc(BOOT_LOG || t.logUnwritable)}</code></p>
<p>${esc(t.versionLabel)} ${esc(require("./package.json").version)} · <a href="https://github.com/CatCatUncle/openworkbuddy/issues" target="_blank">${esc(t.openIssue)}</a></p>
</div>`;
// 连窗口都没有,就退到系统级报错框,别把原因吞掉——「双击没反应」就是这么来的
if (!win || win.isDestroyed()) return fatal("启动", err);
FATAL_SHOWN = true;
win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html));
win.show();
win.focus();
}
/**
* 设置页存的键 → Electron 认的加速键写法。两边不是一套词:
* · 设置页(app-02.js 的 SHORTCUT_DEFS / accelFromEvent):Mod = 这台机器的主修饰键,Meta = ⌘ / Win 键,
* 键名照抄 KeyboardEvent.code 去掉 Key/Digit 前缀(Comma、BracketLeft、ArrowUp、Numpad1……),
* 默认键还可能写成「mac 那份|其他平台那份」;
* · Electron:CommandOrControl / Command / Super / Control,键名是 "," "[" "Up" "num1"。
* 对不上的那个键 register 会直接抛,被 catch 吞掉,用户看到的就是「改了键、按了没反应」。
* 以前这里只换了 Meta→Command、Ctrl→Control:Windows 上 Meta 换成 Command 是个不存在的键,
* 默认键哪天用上 Mod 也会整条注册不上。
*/
function electronAccel(a, mac) {
let s = String(a || "");
if (s.includes("|")) s = s.split("|")[mac ? 0 : 1] || "";
const MOD = { mod: "CommandOrControl", meta: mac ? "Command" : "Super", cmd: "Command", command: "Command", super: "Super",
ctrl: "Control", control: "Control", alt: "Alt", option: "Alt", shift: "Shift", commandorcontrol: "CommandOrControl", cmdorctrl: "CommandOrControl" };
const KEY = { Comma: ",", Period: ".", Slash: "/", Backslash: "\\", Semicolon: ";", Quote: "'", Backquote: "`",
BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right",
NumpadAdd: "numadd", NumpadSubtract: "numsub", NumpadMultiply: "nummult", NumpadDivide: "numdiv", NumpadDecimal: "numdec", NumpadEnter: "Enter" };
const out = [];
for (const p of s.split("+").map((x) => x.trim()).filter(Boolean)) {
const m = MOD[p.toLowerCase()];
const k = m || KEY[p] || p.replace(/^Numpad(\d)$/, "num$1");
if (!out.includes(k)) out.push(k);
}
return out.join("+");
}
/** 全局快捷键(系统级,仅「唤起/隐藏主窗口」需要);设置页改绑后由 server.js 调用热更新 */
function registerShortcuts(shortcuts) {
try {
globalShortcut.unregisterAll();
const accel = electronAccel((shortcuts || {})["toggle-window"] || "Shift+Alt+W", process.platform === "darwin");
const ok = globalShortcut.register(accel, () => {
if (!win) return;
if (win.isVisible() && win.isFocused()) win.hide();
else {
win.show();
win.focus();
}
});
// 返回 false 不抛:这个组合键系统没给。记一笔,不然日志里看不出按了没反应是为什么
if (!ok) bootLog(`▲ 全局快捷键 ${accel} 没注册上,系统没接这个组合键`);
} catch (e) {
console.warn("[快捷键] 全局快捷键注册失败:", e.message);
}
}
// ---------- 退出:先问、再收尾、最后才放行 ----------
const SHUTDOWN_WAIT_MS = 3000; // 叫停任务之后最多等这么久让它们自己收尾,过了就直接杀子进程
const SYS_QUIT_REARM_MS = 60000; // 系统说要关机之后这么久还活着,就当关机被取消了(见 letSystemQuit)
/**
* 以用户本人的身份问服务端一句。服务端就跑在这个进程里,但任务表(server.js 的 activeRuns)
* 是它模块里的局部变量,壳够不着,只能走 HTTP。登录令牌是界面那个 session 上的 HttpOnly cookie,
* 从 cookie 罐里取出来带上。问不到(没登录、服务端没起来、端口上是别的实例、超时)一律回 null:
* 退出这条路不许因为这一问卡住。
*/
async function apiCall(method, pathname, body) {
if (!SERVER_OWN) return null;
try {
const base = `http://127.0.0.1:${PORT}`;
const jar = await session.defaultSession.cookies.get({ url: base });
const headers = {};
const cookie = jar.map((c) => `${c.name}=${c.value}`).join("; ");
if (cookie) headers.cookie = cookie;
if (body) headers["content-type"] = "application/json";
const r = await fetch(base + pathname, {
method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(1500),
});
return r.ok ? await r.json() : null;
} catch {
return null;
}
}
/** 正在跑的任务(会话 id)。口径跟页面刷新后接回任务那份一样:只算当前登录的这个人的 */
async function runningTasks() {
const ids = await apiCall("GET", "/api/chat/running");
return Array.isArray(ids) ? ids : [];
}
/**
* `ps -A -ww -o pid=,ppid=,pgid=,args=` 的输出 → 这个进程底下该送走的那些。
* 刨掉 Electron 自己的进程(GPU / 渲染 / 网络,连同它们底下的,得留给 Electron 自己收):
* · 命令行带 --type= 的——Chromium 的子进程都这么起,这条最准;
* 光靠 keep(getAppMetrics)不够:真 Electron 43 实测刚启动那一阵 GPU 进程还没进 metrics 表;
* · keep 里的,和崩溃上报进程。
* 不能按「跟主进程同一个可执行文件」刨:服务端用 ELECTRON_RUN_AS_NODE 拿 Electron 二进制跑脚本,
* 那些正是要收的。groups 是子孙里自立门户(detached)的进程组组长:按组杀才能把
* 已经过继给 init 的孙子一起带走——模型跑的 `npm run dev` 就是这么脱钩的。
*/
function childTree(psOut, root, keep) {
const rows = [];
for (const line of String(psOut || "").split(/\r?\n/)) { // PowerShell 吐的是 \r\n,行尾那个 \r 会让下面的 $ 对不上
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*(.*)$/.exec(line);
if (m) rows.push({ pid: +m[1], ppid: +m[2], pgid: +m[3], cmd: m[4] });
}
const skip = new Set(keep || []);
const mine = new Set([root]);
for (let grew = true; grew;) {
grew = false;
for (const r of rows) {
if (mine.has(r.pid) || !mine.has(r.ppid) || skip.has(r.pid)) continue;
if (/\s--type=[a-z-]+/.test(r.cmd) || /crashpad/i.test(r.cmd)) continue;
mine.add(r.pid);
grew = true;
}
}
mine.delete(root);
const groups = rows.filter((r) => mine.has(r.pid) && r.pgid === r.pid).map((r) => r.pid);
return { pids: [...mine], groups };
}
/**
* 把还挂在这个进程底下的子进程送走:MCP 服务、叫停时没跟着走的命令、评测、ffmpeg……
* 任务被叫停时各自已经在杀自己那棵树了,这一步收的是漏网的——不收的话应用退了,
* 它们还在后台占着端口和 CPU,下次启动 MCP 还会撞上自己的上一辈。返回送走了几个,只为记日志。
*/
// 独立服务进程(2026-09-29 起默认)自己带 --type=utility,扫主进程这棵树时它连同底下的 MCP、模型起的
// 开发服务器会被整枝刨掉——所以它的 pid 另算一个根(extra),它底下的照样收
function killChildren(sig, extra = typeof serverRoots === "function" ? serverRoots() : []) {
const cp = require("child_process");
const keep = [];
try { for (const m of app.getAppMetrics()) keep.push(m.pid); } catch {}
const roots = [process.pid, ...extra];
try {
if (process.platform === "win32") {
// Windows 没有进程组、也没有 ps:PowerShell 列出直接子进程(带命令行,好认出 --type= 的 Electron 自家进程),
// taskkill /T 连孙子一起带走。不分先礼后兵,一律 /F:走到这一步已经给过它们 3 秒了。
// 退出收尾走的是下面的异步版 killChildrenWin;这里只剩关机、重启应用那两条来不及等的路
const r = cp.spawnSync("powershell.exe", winChildArgs(roots), { encoding: "utf8", timeout: 5000, windowsHide: true });
const pids = winTargets(r.stdout, roots, keep.concat(r.pid || []));
if (pids.length) {
try { cp.spawnSync("taskkill", winKillArgs(pids), { timeout: 5000, windowsHide: true, stdio: "ignore" }); } catch {}
}
return pids.length;
}
// -ww:不截断命令行,--type= 在很长的一串参数后面。不加 windowsHide:Windows 在上面那段就 return 了
const r = cp.spawnSync("ps", ["-A", "-ww", "-o", "pid=,ppid=,pgid=,args="], { encoding: "utf8", timeout: 5000, maxBuffer: 64 * 1024 * 1024 });
let n = 0;
for (const root of roots) {
const { pids, groups } = childTree(r.stdout, root, keep.concat(r.pid || []));
for (const g of groups) { try { process.kill(-g, sig); } catch {} }
for (const p of pids) { try { process.kill(p, sig); } catch {} }
n += pids.length;
}
return n;
} catch (e) {
bootLog("▲ 清子进程出错:" + String((e && e.message) || e));
return 0;
}
}
/** Windows:一次 PowerShell 列出这几个根底下的直接子进程。它冷启动就要一两秒,别每个根起一次 */
function winChildArgs(roots) {
const filter = roots.map((r) => `ParentProcessId=${Number(r)}`).join(" OR ");
return ["-NoProfile", "-NonInteractive", "-Command",
`Get-CimInstance Win32_Process -Filter '${filter}' | ForEach-Object { [string]$_.ProcessId + ' ' + [string]$_.ParentProcessId + ' 0 ' + $_.Name + ' ' + $_.CommandLine }`];
}
/** 上面列出来的 → 各根底下要送走的 pid,合在一起 */
function winTargets(out, roots, keep) {
const all = new Set();
for (const root of roots) for (const p of childTree(out, root, keep).pids) all.add(p);
return [...all];
}
/** 一条 taskkill 带走全部:每个 pid 起一次 taskkill 也是一次进程启动 */
function winKillArgs(pids) {
return ["/T", "/F", ...pids.flatMap((p) => ["/PID", String(p)])];
}
/**
* killChildren 的 Windows 异步版,退出收尾用。同步那版要干等 PowerShell 冷启动,
* 这一两秒主线程卡住,窗口标题栏挂上「未响应」,人以为退出卡死了,再点一下就成了强制结束。
*/
async function killChildrenWin(extra = typeof serverRoots === "function" ? serverRoots() : []) {
const cp = require("child_process");
const keep = [];
try { for (const m of app.getAppMetrics()) keep.push(m.pid); } catch {}
const roots = [process.pid, ...extra];
const run = (bin, args) => new Promise((resolve) => {
/** @type {any} */
let child = null;
try {
child = cp.execFile(bin, args, { encoding: "utf8", timeout: 5000, windowsHide: true, maxBuffer: 16 << 20 },
(_e, out) => resolve({ out: String(out || ""), pid: (child && child.pid) || 0 }));
} catch { resolve({ out: "", pid: 0 }); }
});
try {
const r = await run("powershell.exe", winChildArgs(roots));
const pids = winTargets(r.out, roots, keep.concat(r.pid || []));
if (pids.length) await run("taskkill", winKillArgs(pids));
return pids.length;
} catch (e) {
bootLog("▲ 清子进程出错:" + String((e && e.message) || e));
return 0;
}
}
/**
* 统一的收尾,谁要退都走它,而且只走一遍(第二次来要的拿到的是同一个 Promise):
* 1. 叫服务端停掉在跑的任务(跟用户点「让我停下」同一条路:/api/chat/stop);
* 2. 等它们收尾,最多 SHUTDOWN_WAIT_MS;
* 3. 不管等没等完,把还挂着的子进程送走:先 SIGTERM,半秒后还在的 SIGKILL。
* 以前关窗就是 app.quit():任务停在半截,写到一半的文件、跑到一半的命令都没人收,
* MCP 服务和模型起的开发服务器在后台接着跑,下次打开端口还被自己占着。
*/
function shutdown() {
if (SHUTDOWN) return SHUTDOWN;
SHUTDOWN = (async () => {
const t0 = Date.now();
const onWin = process.platform === "win32";
// Windows 上收尾要几秒(等任务停、PowerShell 列进程),窗口先收起来:点了关闭窗口还杵着,人会再点、会去任务管理器结束
if (onWin && win && !win.isDestroyed()) win.hide();
let gaveUp = false;
const settle = (async () => {
const ids = await runningTasks();
if (!ids.length) return;
bootLog(`退出:叫停 ${ids.length} 个在跑的任务`);
await Promise.all(ids.map((id) => apiCall("POST", "/api/chat/stop", { sessionId: id })));
while (!gaveUp) {
if (!(await runningTasks()).length) return;
await new Promise((r) => setTimeout(r, 150));
}
})().catch(() => {});
let timer = null;
const late = await Promise.race([
settle.then(() => false),
new Promise((r) => { timer = setTimeout(() => r(true), SHUTDOWN_WAIT_MS); }),
]);
clearTimeout(timer);
gaveUp = true;
if (late) bootLog(`▲ 退出:等了 ${SHUTDOWN_WAIT_MS}ms 任务还没收完,不等了`);
// Windows 那版一律 taskkill /F,一遍就够,不补第二遍
const termed = onWin ? await killChildrenWin() : killChildren("SIGTERM");
let killed = 0;
if (termed && !onWin) {
await new Promise((r) => setTimeout(r, 500));
killed = killChildren("SIGKILL"); // 重新列一遍:半秒里走掉的不会再挨一下,pid 被别人复用了也伤不着
}
// 独立服务进程最后才停:先停它的话,它底下没收走的子孙立刻过继给 init,上面那两遍就扫不到了
if (typeof stopServer === "function") await stopServer();
bootLog(`退出收尾完:用时 ${Date.now() - t0}ms,子进程 ${termed} 个${killed ? `(其中 ${killed} 个不肯走,硬杀)` : ""}`);
})().catch((e) => bootLog("▲ 退出收尾出错:" + String((e && e.message) || e)));
return SHUTDOWN;
}
/**
* 所有「要退出」的入口都走这一个:关主窗口、⌘Q / Alt+F4、Dock 右键退出、托盘「退出」。
* 有任务在跑就先问一句——关窗和退出是顺手一点的事,而中断的是跑了半小时的活儿。
* 回车默认是「先不退」:不丢东西的那条。没有任务就不打扰,直接收尾退出。
* 框挂着的时候再点一次关闭 / 再按一次 ⌘Q 不叠第二个框。
*/
async function requestQuit() {
if (QUIT_STATE === "done") return app.quit();
if (QUIT_STATE) return;
QUIT_STATE = "asking";
try {
const n = (await runningTasks()).length;
if (n > 0) {
const t = T(LAST_UI_LANG || osLang());
const w = win && !win.isDestroyed() ? win : null;
if (w) { if (w.isMinimized()) w.restore(); w.show(); } // 框挂在窗口上:窗口收着的话框也跟着看不见
const opts = { type: "warning", title: "OpenWorkBuddy", message: t.quitBusy(n), buttons: [t.quitBtn, t.stayBtn], defaultId: 1, cancelId: 1, noLink: true };
const { response } = await (w ? dialog.showMessageBox(w, opts) : dialog.showMessageBox(opts));
if (response !== 0) {
QUIT_STATE = "";
bootLog(`退出:${n} 个任务在跑,用户选了先不退`);
return;
}
}
QUIT_STATE = "closing";
await shutdown();
} catch (e) {
bootLog("▲ 退出前的确认 / 收尾出错,照样退:" + String((e && e.message) || e));
}
QUIT_STATE = "done";
app.quit();
}
// ⌘Q、Dock 右键退出、系统菜单的退出,都先到这儿。只注册这一次:两个 before-quit 各拦各的,
// 一个放行了另一个还在拦,退出就成了死循环
app.on("before-quit", (e) => {
if (QUIT_STATE === "done") return;
e.preventDefault();
requestQuit();
});
// ---------- 托盘 ----------
/** 托盘菜单就三条:显示窗口 / 新建任务 / 退出。字从 SHELL_TEXT 来 */
function trayMenuItems(t, act) {
return [
{ label: t.trayShow, click: act.show },
{ label: t.trayNewTask, click: act.newTask },
{ type: "separator" },
{ label: t.trayQuit, click: act.quit },
];
}
function showMainWindow() {
if (!win || win.isDestroyed()) return false;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
return true;
}
function refreshTray(lang) {
if (!tray || tray.isDestroyed()) return;
// 语言没变就不动:窗口失焦、鼠标划过托盘都会来问一次,而 Windows 上右键托盘的那一下主窗口正好失焦——
// 菜单开着的时候把它整个换掉,开着的那份点下去可能落空
const lng = (lang || LAST_UI_LANG || osLang()) === "en" ? "en" : "zh";
if (lng === trayLang) return;
const t = T(lng);
tray.setToolTip(t.trayTip);
tray.setContextMenu(Menu.buildFromTemplate(trayMenuItems(t, {
show: showMainWindow,
// 跟页面里 ⌘N 按的是同一颗「新建任务」按钮:新建要清哪些状态由页面说了算,这里不另抄一份
newTask: () => {
if (!showMainWindow()) return;
win.webContents.executeJavaScript('(function(){var b=document.getElementById("new-task");if(b)b.click();})()', true).catch(() => {});
},
quit: () => requestQuit(),
})));
trayLang = lng; // 换成了才记:中途抛了的话下回还会再换
}
/** 托盘菜单的字跟着界面语言走。网页切语言不刷新页面、也不告诉主进程,只能在这几个时候问一句 */
function syncTrayLang() {
if (win && !win.isDestroyed()) uiLang(win.webContents).then(refreshTray, () => {});
}
function createTray() {
if (tray || HIDDEN) return; // 测试宿主不往用户的菜单栏上放图标
try {
// Windows 用多尺寸的 tray.ico(为什么见 trayIconFile)。读不出来就退回下面那两份 PNG,日志里留一句
let ico = null;
if (process.platform === "win32") {
ico = nativeImage.createFromPath(trayIconFile(__dirname, process.platform));
if (ico.isEmpty()) {
bootLog("▲ 托盘的 .ico 读不出来,先用 PNG:" + trayIconFile(__dirname, process.platform));
ico = null;
}
}
if (ico) tray = new Tray(ico);
else {
// 图标用 public/ 那张(装机包里有,build/ 不进包)。给 1x 和 2x 两份,Retina 屏上不糊
const src = nativeImage.createFromPath(path.join(__dirname, "public", "favicon.png"));
const img = nativeImage.createEmpty();
img.addRepresentation({ scaleFactor: 1, buffer: src.resize({ width: 16, height: 16, quality: "best" }).toPNG() });
img.addRepresentation({ scaleFactor: 2, buffer: src.resize({ width: 32, height: 32, quality: "best" }).toPNG() });
tray = new Tray(img);
}
refreshTray();
// Windows / Linux 的习惯是单击托盘图标就把窗口叫出来、右键才出菜单;macOS 单击就是出菜单
if (process.platform !== "darwin") tray.on("click", showMainWindow);
// macOS 上点状态栏图标不会让窗口失焦,鼠标移上去那一下再问一次语言(Linux 没有这个事件,靠窗口焦点那两下)
tray.on("mouse-enter", syncTrayLang);
} catch (e) {
tray = null;
bootLog("▲ 托盘图标没建起来(不影响使用):" + String((e && e.message) || e));
}
}
app.on("will-quit", () => {
MAIN_STALL.stop();
if (MAIN_PROFILE) MAIN_PROFILE.stop(); // 同一线程上的调试会话是同步回话的:这一行返回时文件已经写好
try { if (SHELL_BRIDGE && SHELL_BRIDGE.pixels) SHELL_BRIDGE.pixels.close(); } catch {} // 缩图的临时目录
// 系统关机那条路不走 shutdown(不拦它),子进程至少发一声 SIGTERM,别留在后台
if (!SHUTDOWN) killChildren("SIGTERM");
if (SUPERVISOR) SUPERVISOR.kill(); // 独立服务进程:收尾那条路已经请它退过了,这里只是兜底
try {
globalShortcut.unregisterAll();
} catch {}
try {
if (global.__openworkbuddyPet) global.__openworkbuddyPet.destroy();
} catch {}
try {
if (tray) tray.destroy(); // Windows 上不收的话,退出后托盘里会留一个鼠标划过才消失的空图标
} catch {}
});
// 点 Dock 图标(macOS)/ 点任务栏图标要能把界面叫回来。
// 没有这一段的时候:窗口一最小化或者按快捷键藏起来,Dock 图标就成了摆设——点它什么都不发生,
// 用户只能去戳桌面宠物才能把主界面调出来,而宠物默认还是关着的,等于彻底找不回来。
// Electron 在 macOS 上不会自己 show 窗口,activate 事件得应用自己接。
app.on("activate", () => {
if (!win || win.isDestroyed()) return; // 窗口还没建出来/已经销毁:启动流程或 closed 分支会管,这里别插手
if (win.isMinimized()) win.restore();
win.show();
win.focus();
});
// 兜底。真正管退出的是主窗口的 close / closed(见上面)——那条不挑窗口数量,
// 宠物开着照样退。这条只在宠物关着(默认就是关着)时顺带触发一次;requestQuit 放行过之后再来就是直接退,重复无害。
// 启动失败页那条路窗口上没挂 close 的闸,关掉它走的是这里:服务端没起来,问不出在跑的任务,直接收尾退出
app.on("window-all-closed", () => requestQuit());
// ---------- 独立服务进程(2026-09-29 起桌面版默认,为什么见 SERVER_PROCESS 那段) ----------
/**
* 起独立服务进程,等它报端口。能用返回 {port, reused};走不通返回 null,调用方退回在这个进程里 require server.js。
* dialog / shell / clipboard / 离屏渲染窗口这些只有主进程才有的东西,它发消息过来要(bridge-main.js)。
*/
async function startServerProcess() {
let electron = null;
try { electron = require("electron"); } catch {}
if (!electron || !electron.utilityProcess || typeof electron.utilityProcess.fork !== "function") {
bootLog("[服务进程] 这个 Electron 没有 utilityProcess,已改回在主进程里运行");
return null;
}
const shellBridge = SHELL_BRIDGE = require("./src/desktop/bridge-main").createShellBridge({
electron,
getWin: () => win,
pet: () => global.__openworkbuddyPet, // 宠物在服务端起来之后才建
registerShortcuts,
relaunch: relaunchApp,
onApproval: (m) => { if (approvalNudge) approvalNudge(m); },
bootLog,
hidden: HIDDEN,
ops: MAIN_OPS,
// 缩图三件活挪出界面线程(为什么见 thumb-sips.js;谁来做见 pixelsKind)。OWB_MAIN_PIXELS=native 走回主线程 nativeImage,浸泡测试做反向对照用
pixels: (() => {
const pk = pixelsKind(process.platform, process.env);
if (pk === "sips") return require("./src/platform/render/thumb-sips").createSipsPixels();
if (pk === "render") return require("./src/platform/render/browser-render").createRenderPixels({ electron, log: bootLog });
return null;
})(),
});
// 网页截图的 PNG 编码同理(见 htmlshot.js pngOf):反向对照时一起退回界面线程上的 toPNG
if (process.env.OWB_MAIN_PIXELS === "native") require("./src/platform/render/htmlshot")._internals.setEncoder({ native: true });
const nice = Number(process.env.OWB_SERVER_NICE);
SUPERVISOR = require("./src/desktop/server-supervisor").createServerSupervisor({
fork: (env) => electron.utilityProcess.fork(path.join(__dirname, "server-host.js"), [], {
serviceName: "OpenWorkBuddy Server", stdio: "pipe", cwd: process.cwd(), env,
}),
env: () => {
const alive = !!(win && !win.isDestroyed());
const env = {
...process.env,
OWB_BRIDGE: "1",
// 服务进程里 app.isPackaged 问不到,数据根却要靠它定(paths.js);server-host.js 会拿 OWB_DATA_DIR 对一遍账
OWB_PACKAGED: app.isPackaged ? "1" : "0",
OWB_DATA_DIR: dataPath(),
OWB_USER_DATA: app.getPath("userData"),
// 服务进程的 process.execPath 是 Electron Helper:拉「当 node 用」的子进程得用主程序(electron-bridge.nodeExec)
OWB_NODE_EXEC: process.execPath,
OWB_SHELL_PID: String(process.pid),
OWB_BRIDGE_STATE: JSON.stringify({
caps: { windows: true, pet: !HIDDEN },
winAlive: alive,
fullScreen: alive && win.isFullScreen(),
}),
};
// 带上它 server.js 会把自己当成 run_node 派生的子进程,不认「个人桌面版」
delete env.ELECTRON_RUN_AS_NODE;
return env;
},
shell: shellBridge,
log: bootLog,
readyMs: Number(process.env.OWB_SERVER_READY_MS) || undefined,
nice: Number.isFinite(nice) && nice > 0 ? nice : 5,
onPort: (p, reused) => {
SERVER_OWN = !reused;
SERVER_REMOTE = !reused;
if (p === PORT) return;
bootLog(`[服务进程] 重启后端口换了:${PORT} → ${p}`);
PORT = p;
if (win && !win.isDestroyed()) win.loadURL(`http://127.0.0.1:${PORT}`);
},
onFallback: (why) => fallBackInproc(why),
});
const r = await SUPERVISOR.start();
if (!r) { SUPERVISOR = null; return null; }
SERVER_REMOTE = !r.reused;
if (SERVER_REMOTE) bootLog(`[服务进程] 服务端在独立进程里(pid ${SUPERVISOR.pid()}),主进程只管窗口`);
return r;
}
/**
* 独立服务进程跑起来之后 5 分钟内又崩满 3 次:不再折腾它,在这个进程里接着跑(钉在原来那个口上,窗口不用换地址)。
* 这一路跟启动时退回是同一个结果,只是晚一点——用户手上的是一个还能用的应用,不是一个连不上的窗口。
*/
function fallBackInproc(why) {
SUPERVISOR = null;
SERVER_REMOTE = false;
if (QUIT_STATE) return; // 正在退:不用再起
bootLog(`[服务进程] 改在主进程里接着跑(${why})`);
process.env.PORT = String(PORT);
global.__wbOnListen = (p) => {
if (p === PORT) return;
bootLog(`端口换了:${PORT} → ${p}(原来那个被别的程序占着)`);
PORT = p;
if (win && !win.isDestroyed()) win.loadURL(`http://127.0.0.1:${PORT}`);
};
if (approvalNudge) { try { require(path.join(__dirname, "src", "core", "safety", "security.js")).watchApprovals(approvalNudge); } catch {} }
try {
require("./server.js");
} catch (e) {
console.error("[启动] 服务端起不来:", e);
showBootFailure(e);
}
}
/** 窗口在不在、是不是全屏:推给独立服务进程(它那边的 global.__wbWin 就靠这一份) */
function pushServerState(patch) {
if (SUPERVISOR) SUPERVISOR.post({ t: "state", patch });
}
/** killChildren 要多扫的根 */
function serverRoots() {
return SUPERVISOR ? SUPERVISOR.roots() : [];
}
/** 退出收尾的最后一步:请独立服务进程自己收尾(停 MCP),等不到就杀 */
async function stopServer() {
if (SUPERVISOR) await SUPERVISOR.stop();
}
/** 数据恢复之后的「重启应用」(server.js /api/backup/restart 从服务进程发过来) */
function relaunchApp() {
if (HIDDEN) return bootLog("[隐藏运行] 收到重启请求,测试宿主不重启");
bootLog("重启应用(数据恢复之后)");
try { killChildren("SIGTERM"); } catch {}
if (SUPERVISOR) SUPERVISOR.kill();
app.relaunch();
app.exit(0);
}
/**
* 测试宿主的遥控(只在 OWB_SHELL_HIDDEN=1 时接):stdin 一行一个命令。
* lag 那一份是主进程事件循环的迟到量——CF-2 要证明的就是这个数:服务端挪走以后,对话跑得再多它也不该涨。
*/
if (HIDDEN) {
const step = 10;
// 量迟到用单调钟,另拿墙钟对一下:2026-09-29 浸泡测试里那个 2000ms 是合盖睡了 181 秒(Date.now 睡着也走),
// 不是主线程卡住。两个钟差出一秒以上 = 这一拍跨过了睡眠,记进 slept、不算迟到——测试据此判这一轮作废
const mono = () => require("perf_hooks").performance.now();
let samples = [], max = 0, last = mono(), lastWall = Date.now(), slept = 0;
const tick = setInterval(() => {
const now = mono(), wallNow = Date.now();
const gap = now - last, wallGap = wallNow - lastWall;
last = now; lastWall = wallNow;
if (wallGap - gap > 1000) { slept++; return; }
const d = Math.max(0, Math.round(gap - step));
samples.push(d);
if (d > max) max = d;
if (samples.length > 200000) samples = samples.slice(-100000);
}, step);
if (tick.unref) tick.unref();
const lag = () => {
const s = samples.slice().sort((a, b) => a - b);
const q = (p) => (s.length ? s[Math.min(s.length - 1, Math.floor(p * s.length))] : 0);
return { n: s.length, p50: q(0.5), p99: q(0.99), max, slept };
};
let buf = "";
try {
process.stdin.setEncoding("utf8");
process.stdin.on("data", (d) => {
buf += d;
for (let i = buf.indexOf("\n"); i >= 0; i = buf.indexOf("\n")) {
const cmd = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (cmd === "quit") requestQuit();
else if (cmd === "lag-reset") { samples = []; max = 0; slept = 0; last = mono(); lastWall = Date.now(); }
else if (cmd === "status") {
console.log("OWB_CTL " + JSON.stringify({
lag: lag(), remote: SERVER_REMOTE, port: PORT, pageUp: PAGE_UP,
serverPid: SUPERVISOR ? SUPERVISOR.pid() : 0, starts: SUPERVISOR ? SUPERVISOR.starts : 0,
bridge: SHELL_BRIDGE ? { ...SHELL_BRIDGE.counts } : null,
stall: { ...MAIN_STALL.counts, max: MAIN_STALL.maxMs, last: MAIN_STALL.stalls.slice(-5) },
ops: MAIN_OPS.stats(),
pixels: SHELL_BRIDGE && SHELL_BRIDGE.pixels ? { ...SHELL_BRIDGE.pixels.counts } : null,
shot: (() => { try { return { ...require("./src/platform/render/htmlshot")._internals.state().stats }; } catch { return null; } })(),
}));
}
else if (cmd === "profile-stop" && MAIN_PROFILE) {
MAIN_PROFILE.stop().then((f) => console.log("OWB_CTL " + JSON.stringify({ profile: f })));
}
}
});
// 跑测试的那个进程没了:别留一个谁也看不见、谁也不管的应用在后台。
// OWB_SHELL_STDIN=0 是 Windows 包冒烟(scripts/win-smoke.js)用的:免安装版外面套着一层解压壳,
// 壳拉起真应用时不把 stdin 传下去,真应用一读就是「已经结束」,没等窗口出来就自己退了
if (process.env.OWB_SHELL_STDIN !== "0") process.stdin.on("end", () => requestQuit());
} catch {}
}