main.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. "use strict";
  2. /**
  3. * DoTouchAI — a thin menu-bar shell for the local `dsh web` service.
  4. *
  5. * Three jobs only:
  6. * 1. supervise: attach to a healthy `dsh web` on :3080, else spawn and
  7. * baby-sit our own child (crash restart with backoff, graceful stop).
  8. * 2. window: a BrowserWindow pointing at the service URL — never a bundled
  9. * copy of the UI, because the official web app must be served by dsh
  10. * itself (it injects window.__DSH_BOOT__).
  11. * 3. update: watch npm for @deepseek-ai/dsh releases; one-click upgrade +
  12. * restart, automatic rollback when the upgraded service fails to boot.
  13. *
  14. * Test hooks (documented in ../README.md):
  15. * DSH_DESKTOP_SMOKE=1 auto-quit shortly after readiness, log state
  16. * DSH_DESKTOP_NO_ATTACH=1 never attach; always spawn our own child
  17. * DSH_DESKTOP_USER_DATA=dir force the Electron user-data directory
  18. * DSH_DESKTOP_DSH_BIN=path force the dsh binary path
  19. */
  20. const { app, BrowserWindow, Tray, Menu, Notification, nativeImage, globalShortcut, shell, dialog, clipboard } = require("electron");
  21. const fs = require("node:fs");
  22. const os = require("node:os");
  23. const path = require("node:path");
  24. const { Service } = require("./service");
  25. const { Updater } = require("./updater");
  26. const { resolveDsh } = require("./env");
  27. const SMOKE = process.env.DSH_DESKTOP_SMOKE === "1";
  28. // This shell renders one trusted local URL; Chromium's OS-level sandbox and GPU
  29. // acceleration are unnecessary here and fail outright in constrained launch
  30. // contexts (seatbelt profiles, VNC/SSH sessions). Renderer isolation stays on
  31. // via webPreferences below.
  32. app.commandLine.appendSwitch("no-sandbox");
  33. app.commandLine.appendSwitch("disable-gpu");
  34. app.disableHardwareAcceleration();
  35. let tray = null;
  36. let win = null;
  37. let service = null;
  38. let updater = null;
  39. let quitting = false;
  40. let reloadTimer = null;
  41. let settings = { dockIcon: false };
  42. function settingsPath() { return path.join(app.getPath("userData"), "settings.json"); }
  43. function loadSettings() {
  44. try { return { dockIcon: false, ...JSON.parse(fs.readFileSync(settingsPath(), "utf8")) }; }
  45. catch { return { dockIcon: false }; }
  46. }
  47. function saveSettings() {
  48. try { fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2)); } catch { /* ignore */ }
  49. }
  50. if (process.env.DSH_DESKTOP_USER_DATA) {
  51. fs.mkdirSync(process.env.DSH_DESKTOP_USER_DATA, { recursive: true });
  52. app.setPath("userData", process.env.DSH_DESKTOP_USER_DATA);
  53. }
  54. if (!app.requestSingleInstanceLock()) {
  55. app.quit();
  56. } else {
  57. app.on("second-instance", () => showWindow());
  58. app.on("activate", () => showWindow()); // clicking the Dock icon
  59. app.whenReady().then(main).catch((err) => {
  60. console.error("[desktop] fatal:", err);
  61. app.exit(1);
  62. });
  63. app.on("window-all-closed", () => { /* tray-resident: keep running */ });
  64. app.on("before-quit", (event) => { event.preventDefault(); quit(); });
  65. }
  66. async function main() {
  67. // Menu-bar resident by default: the shell is a supervisor that keeps the
  68. // dsh service running while its window comes and goes. Users who prefer a
  69. // regular Dock app can flip "在程序坞显示图标" in the tray menu.
  70. settings = loadSettings();
  71. if (!settings.dockIcon) app.dock?.hide();
  72. service = new Service({
  73. userDataDir: app.getPath("userData"),
  74. onState: (s) => {
  75. refreshTray();
  76. if (s.running && s.url && win && win.webContents.getURL() !== s.url) loadApp();
  77. },
  78. onLog: (line) => { if (SMOKE) console.log("[dsh]", line); },
  79. });
  80. updater = new Updater({
  81. service,
  82. cacheDir: path.join(app.getPath("userData"), "npm-cache"),
  83. log: (line) => service.appendLog(line),
  84. notify,
  85. });
  86. createTray();
  87. createWindow();
  88. // Bundled plugin sync (voice suite): install/upgrade into the web profile
  89. // BEFORE the service starts, so a cold boot loads them immediately; when
  90. // attaching to an already-running instance, restart it to pick them up.
  91. let pluginChanges = [];
  92. try {
  93. const { syncPlugins } = require("./plugins");
  94. const { changed } = syncPlugins({ log: (line) => service.appendLog(line) });
  95. pluginChanges = changed;
  96. } catch (err) {
  97. service.appendLog(`[plugins] sync failed: ${err.message}`);
  98. }
  99. if (pluginChanges.length > 0) {
  100. notify("语音插件已安装/更新", `${pluginChanges.join("、")} · 即将生效`);
  101. }
  102. const ok = await service.ensure();
  103. if (!ok) {
  104. // A fresh Mac without dsh gets an install guide instead of a bare error.
  105. const dshMissing = resolveDsh() === "dsh";
  106. if (dshMissing && !SMOKE) showSetupGuide();
  107. else notify("DSH 服务未能启动", "托盘菜单可“重启服务”,或打开日志排查");
  108. }
  109. if (pluginChanges.length > 0 && service.running && !service.spawned) {
  110. // attached instance predates the plugin change — restart to load it
  111. service.appendLog("[plugins] restarting attached service to load plugin changes");
  112. await service.restart();
  113. }
  114. if (service.url) await loadApp();
  115. refreshTray();
  116. try {
  117. globalShortcut.register("CommandOrControl+Shift+D", () => {
  118. if (win && win.isVisible() && win.isFocused()) win.hide();
  119. else showWindow();
  120. });
  121. } catch { /* shortcut already taken — ignore */ }
  122. updater.start();
  123. if (SMOKE) {
  124. console.log(`[smoke] state=${service.state} url=${service.url} version=${service.version()}`);
  125. setTimeout(() => app.quit(), 2500);
  126. }
  127. }
  128. function createWindow() {
  129. const isWin = process.platform === "win32";
  130. win = new BrowserWindow({
  131. width: 1440,
  132. height: 920,
  133. minWidth: 960,
  134. minHeight: 600,
  135. show: false,
  136. title: "DSH",
  137. backgroundColor: "#17181c",
  138. icon: path.join(__dirname, "assets", isWin ? "icon.ico" : "icon.icns"),
  139. webPreferences: { contextIsolation: true, sandbox: true },
  140. });
  141. win.loadURL(splashUrl());
  142. win.once("ready-to-show", () => win.show());
  143. win.on("close", (event) => { if (!quitting) { event.preventDefault(); win.hide(); } });
  144. win.webContents.on("did-fail-load", () => { if (!quitting) scheduleReload(); });
  145. win.webContents.setWindowOpenHandler(({ url }) => {
  146. if (/^https?:/.test(url)) shell.openExternal(url);
  147. return { action: "deny" };
  148. });
  149. }
  150. async function loadApp() {
  151. if (!win || !service.url || quitting) return;
  152. try {
  153. await win.loadURL(service.url);
  154. if (!win.isVisible()) win.show();
  155. } catch { scheduleReload(); }
  156. }
  157. function scheduleReload() {
  158. if (reloadTimer || quitting) return;
  159. reloadTimer = setTimeout(async () => {
  160. reloadTimer = null;
  161. if (!quitting) await loadApp();
  162. }, 2000);
  163. }
  164. function showWindow() {
  165. if (!win) {
  166. createWindow();
  167. if (service && service.url) loadApp();
  168. return;
  169. }
  170. win.show();
  171. try { win.focus(); } catch { /* window may be mid-teardown */ }
  172. if (service && service.url && win.webContents.getURL() !== service.url) loadApp();
  173. }
  174. function createTray() {
  175. const isWin = process.platform === "win32";
  176. const iconFile = isWin ? "icon.ico" : "trayTemplate.png";
  177. const icon = nativeImage.createFromPath(path.join(__dirname, "assets", iconFile));
  178. if (!isWin) icon.setTemplateImage(true); // macOS: monochrome template image
  179. tray = new Tray(icon);
  180. tray.setToolTip("DoTouchAI");
  181. refreshTray();
  182. }
  183. function tooltipText() {
  184. if (!service) return "DoTouchAI";
  185. const map = { running: "运行中", starting: "启动中", stopping: "停止中", stopped: "已停止", crashed: "异常退出" };
  186. let text = `DSH · ${map[service.state] || service.state}`;
  187. if (service.port) text += ` · :${service.port}`;
  188. if (updater && updater.available) text += ` · 有新版 ${updater.available.version}`;
  189. return text;
  190. }
  191. function refreshTray() {
  192. if (!tray) return;
  193. tray.setToolTip(tooltipText());
  194. tray.setContextMenu(Menu.buildFromTemplate(menuTemplate()));
  195. }
  196. /** Build metadata written by tools/package.sh (absent in plain `npm start`). */
  197. function buildInfo() {
  198. try {
  199. return JSON.parse(fs.readFileSync(path.join(__dirname, "build.json"), "utf8"));
  200. } catch {
  201. return {};
  202. }
  203. }
  204. /**
  205. * User plugins of the web profile (~/.dsh/profiles/web/node_modules), each as
  206. * "插件名 vX.Y.Z". These upgrade independently of the shell — surfacing them
  207. * here answers "did that plugin fix make it into my install?" directly.
  208. */
  209. function profilePluginsSummary() {
  210. const lines = ["插件(web profile):"];
  211. try {
  212. const root = path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web", "node_modules");
  213. const entries = fs.readdirSync(root).filter((name) => !name.startsWith("."));
  214. const rows = [];
  215. for (const name of entries) {
  216. try {
  217. const pkgPath = path.join(root, name, "package.json");
  218. const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
  219. // A dsh plugin: declares a "dsh" field or follows the dsh-* naming
  220. // convention; plain deps (ws, sherpa-onnx, …) stay hidden.
  221. if (pkg.name && pkg.version && (pkg.dsh || pkg.name.startsWith("dsh-"))) {
  222. const linked = fs.lstatSync(path.join(root, name)).isSymbolicLink() ? " · 本地链接" : "";
  223. rows.push(` ${pkg.name} v${pkg.version}${linked}`);
  224. }
  225. } catch { /* entry without a readable package.json — skip */ }
  226. }
  227. if (rows.length === 0) return ["插件(web profile):无"];
  228. lines.push(...rows.sort());
  229. } catch {
  230. return [];
  231. }
  232. return lines;
  233. }
  234. function menuTemplate() {
  235. const items = [];
  236. const stateLabel = {
  237. running: `● 运行中${service.detail ? " · " + service.detail : ""}`,
  238. starting: "◌ 启动中…",
  239. stopping: "◌ 停止中…",
  240. stopped: "⏸ 已停止",
  241. crashed: `⚠ ${service.detail || "异常退出"}`,
  242. }[service ? service.state : "stopped"] || "…";
  243. const version = (service && service.version()) || updater?.current || "?";
  244. items.push({ label: `${stateLabel} · v${version}${service && service.port ? " · :" + service.port : ""}`, enabled: false });
  245. items.push({ type: "separator" });
  246. items.push({ label: "打开 DSH", click: () => showWindow() });
  247. if (updater && updater.available) {
  248. items.push({ label: `⬆ 升级到 v${updater.available.version}`, click: () => { updater.upgrade(); } });
  249. }
  250. items.push({ label: "检查更新…", click: () => { updater.checkNow(true); } });
  251. items.push({ type: "separator" });
  252. items.push({
  253. label: "重启服务",
  254. click: () => { service.restart().then((ok) => { if (!ok) notify("重启失败", "详见日志"); }); },
  255. });
  256. items.push({
  257. label: service && (service.running || service.state === "starting") ? "停止服务" : "启动服务",
  258. click: () => {
  259. if (service && (service.running || service.state === "starting")) service.stop();
  260. else if (service) service.start(0);
  261. },
  262. });
  263. items.push({ type: "separator" });
  264. items.push({ label: "打开日志文件", click: () => shell.showItemInFolder(service.ensureLogFile()) });
  265. items.push({
  266. label: "打开数据目录(~/.dsh)",
  267. click: () => shell.openPath(process.env.DSH_HOME || path.join(os.homedir(), ".dsh")),
  268. });
  269. items.push({
  270. label: "在程序坞显示图标",
  271. type: "checkbox",
  272. visible: process.platform === "darwin",
  273. checked: settings.dockIcon,
  274. click: (menuItem) => {
  275. settings.dockIcon = menuItem.checked;
  276. saveSettings();
  277. if (menuItem.checked) app.dock?.show();
  278. else app.dock?.hide();
  279. },
  280. });
  281. items.push({
  282. label: "开机自启",
  283. type: "checkbox",
  284. checked: app.getLoginItemSettings().openAtLogin,
  285. click: (menuItem) => app.setLoginItemSettings({ openAtLogin: menuItem.checked }),
  286. });
  287. items.push({ type: "separator" });
  288. items.push({
  289. label: "关于 DoTouchAI",
  290. click: () => {
  291. dialog.showMessageBox({
  292. type: "info",
  293. title: "DoTouchAI",
  294. message: `DoTouchAI v${app.getVersion()}${buildInfo().builtAt ? `(构建于 ${buildInfo().builtAt})` : ""}`,
  295. detail: [
  296. `dsh:v${(service && service.version()) || "?"}(@deepseek-ai/dsh,经 npm 升级)`,
  297. `Electron:v${process.versions.electron}`,
  298. ...profilePluginsSummary(),
  299. "",
  300. "菜单栏常驻的 dsh 本地桌面壳:进程托管 + 官方 UI 窗口 + npm 升级管家。",
  301. "壳版本 / dsh 版本 / 插件版本三者独立升级;web 插件修复无需重装本应用。",
  302. ].join("\n"),
  303. buttons: ["好"],
  304. });
  305. },
  306. });
  307. items.push({ label: "退出", click: () => app.quit() });
  308. return items;
  309. }
  310. function notify(title, body) {
  311. try {
  312. if (Notification.isSupported()) new Notification({ title, body }).show();
  313. } catch { /* notifications are best-effort */ }
  314. if (service) service.appendLog(`[desktop] ${title}${body ? " — " + body : ""}`);
  315. refreshTray();
  316. }
  317. /** First-run guide for a fresh machine: no dsh detected → how to install it. */
  318. async function showSetupGuide() {
  319. const isWin = process.platform === "win32";
  320. const installCmd = isWin
  321. ? "npm config set prefix %USERPROFILE%\\.local && npm install -g @deepseek-ai/dsh"
  322. : "npm config set prefix ~/.local && npm install -g @deepseek-ai/dsh && echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.zshrc";
  323. const { response } = await dialog.showMessageBox({
  324. type: "warning",
  325. title: "DoTouchAI",
  326. message: "未检测到 dsh —— 新机器需要先安装(一次性)",
  327. detail: isWin ? [
  328. "1. 安装 Node.js LTS:https://nodejs.org",
  329. "2. 命令提示符执行安装命令(点下方按钮即可复制):",
  330. "",
  331. " npm config set prefix %USERPROFILE%\\.local",
  332. " npm install -g @deepseek-ai/dsh",
  333. "",
  334. "3. 安装完成后:托盘菜单 → 启动服务",
  335. "(语音插件已随本应用内置,会自动装好,无需其他操作)",
  336. ].join("\n") : [
  337. "1. 安装 Node.js LTS:https://nodejs.org(或 brew install node)",
  338. "2. 终端执行安装命令(点下方按钮即可复制):",
  339. "",
  340. " npm config set prefix ~/.local",
  341. " npm install -g @deepseek-ai/dsh",
  342. " echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.zshrc",
  343. "",
  344. "3. 安装完成后:托盘菜单 → 启动服务",
  345. "(语音插件已随本应用内置,会自动装好,无需其他操作)",
  346. ].join("\n"),
  347. buttons: ["复制安装命令", "好"],
  348. defaultId: 0,
  349. });
  350. if (response === 0) {
  351. clipboard.writeText(installCmd);
  352. notify("安装命令已复制", "粘贴到终端执行,完成后从托盘菜单“启动服务”");
  353. }
  354. }
  355. async function quit() {
  356. if (quitting) return;
  357. quitting = true;
  358. try { globalShortcut.unregisterAll(); } catch { /* ignore */ }
  359. try { if (service) await service.stop(); } catch { /* ignore */ } // no-op when we only attached
  360. app.exit(0);
  361. }
  362. function splashUrl() {
  363. const html = `<!doctype html><html><head><meta charset="utf-8"><title>DSH</title><style>
  364. html,body{margin:0;height:100%;background:#17181c;color:#e8eaf0;font-family:-apple-system,"PingFang SC",sans-serif;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px}
  365. .ring{width:34px;height:34px;border-radius:50%;border:3px solid #33363e;border-top-color:#4d6bfe;animation:spin .9s linear infinite}
  366. @keyframes spin{to{transform:rotate(360deg)}}
  367. p{font-size:13px;color:#9aa0ad;margin:0}
  368. </style></head><body><div class="ring"></div><p>正在启动 DSH 服务…</p></body></html>`;
  369. return "data:text/html;charset=utf-8," + encodeURIComponent(html);
  370. }