// 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 = `${esc(t.failTitle)}

${esc(t.failTitle)}

${esc(hint)}

${esc(detail)}

${esc(t.gpuTip1)}"disable_gpu": true${esc(t.gpuTip2)}${esc(CFG)}${esc(t.gpuTip3)}server${esc(t.gpuTip4)}

${esc(t.bootLogForIssue)}${esc(BOOT_LOG || t.logUnwritable)}

${esc(t.versionLabel)} ${esc(require("./package.json").version)} · ${esc(t.openIssue)}

`; // 连窗口都没有,就退到系统级报错框,别把原因吞掉——「双击没反应」就是这么来的 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 {} }