main.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  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 } = 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 SMOKE = process.env.DSH_DESKTOP_SMOKE === "1";
  27. // This shell renders one trusted local URL; Chromium's OS-level sandbox and GPU
  28. // acceleration are unnecessary here and fail outright in constrained launch
  29. // contexts (seatbelt profiles, VNC/SSH sessions). Renderer isolation stays on
  30. // via webPreferences below.
  31. app.commandLine.appendSwitch("no-sandbox");
  32. app.commandLine.appendSwitch("disable-gpu");
  33. app.disableHardwareAcceleration();
  34. let tray = null;
  35. let win = null;
  36. let service = null;
  37. let updater = null;
  38. let quitting = false;
  39. let reloadTimer = null;
  40. let settings = { dockIcon: false };
  41. function settingsPath() { return path.join(app.getPath("userData"), "settings.json"); }
  42. function loadSettings() {
  43. try { return { dockIcon: false, ...JSON.parse(fs.readFileSync(settingsPath(), "utf8")) }; }
  44. catch { return { dockIcon: false }; }
  45. }
  46. function saveSettings() {
  47. try { fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2)); } catch { /* ignore */ }
  48. }
  49. if (process.env.DSH_DESKTOP_USER_DATA) {
  50. fs.mkdirSync(process.env.DSH_DESKTOP_USER_DATA, { recursive: true });
  51. app.setPath("userData", process.env.DSH_DESKTOP_USER_DATA);
  52. }
  53. if (!app.requestSingleInstanceLock()) {
  54. app.quit();
  55. } else {
  56. app.on("second-instance", () => showWindow());
  57. app.on("activate", () => showWindow()); // clicking the Dock icon
  58. app.whenReady().then(main).catch((err) => {
  59. console.error("[desktop] fatal:", err);
  60. app.exit(1);
  61. });
  62. app.on("window-all-closed", () => { /* tray-resident: keep running */ });
  63. app.on("before-quit", (event) => { event.preventDefault(); quit(); });
  64. }
  65. async function main() {
  66. // Menu-bar resident by default: the shell is a supervisor that keeps the
  67. // dsh service running while its window comes and goes. Users who prefer a
  68. // regular Dock app can flip "在程序坞显示图标" in the tray menu.
  69. settings = loadSettings();
  70. if (!settings.dockIcon) app.dock?.hide();
  71. service = new Service({
  72. userDataDir: app.getPath("userData"),
  73. onState: (s) => {
  74. refreshTray();
  75. if (s.running && s.url && win && win.webContents.getURL() !== s.url) loadApp();
  76. },
  77. onLog: (line) => { if (SMOKE) console.log("[dsh]", line); },
  78. });
  79. updater = new Updater({
  80. service,
  81. cacheDir: path.join(app.getPath("userData"), "npm-cache"),
  82. log: (line) => service.appendLog(line),
  83. notify,
  84. });
  85. createTray();
  86. createWindow();
  87. // Bundled plugin sync (voice suite): install/upgrade into the web profile
  88. // BEFORE the service starts, so a cold boot loads them immediately; when
  89. // attaching to an already-running instance, restart it to pick them up.
  90. let pluginChanges = [];
  91. try {
  92. const { syncPlugins } = require("./plugins");
  93. const { changed } = syncPlugins({ log: (line) => service.appendLog(line) });
  94. pluginChanges = changed;
  95. } catch (err) {
  96. service.appendLog(`[plugins] sync failed: ${err.message}`);
  97. }
  98. if (pluginChanges.length > 0) {
  99. notify("语音插件已安装/更新", `${pluginChanges.join("、")} · 即将生效`);
  100. }
  101. const ok = await service.ensure();
  102. if (!ok) notify("DSH 服务未能启动", "托盘菜单可“重启服务”,或打开日志排查");
  103. if (pluginChanges.length > 0 && service.running && !service.spawned) {
  104. // attached instance predates the plugin change — restart to load it
  105. service.appendLog("[plugins] restarting attached service to load plugin changes");
  106. await service.restart();
  107. }
  108. if (service.url) await loadApp();
  109. refreshTray();
  110. try {
  111. globalShortcut.register("CommandOrControl+Shift+D", () => {
  112. if (win && win.isVisible() && win.isFocused()) win.hide();
  113. else showWindow();
  114. });
  115. } catch { /* shortcut already taken — ignore */ }
  116. updater.start();
  117. if (SMOKE) {
  118. console.log(`[smoke] state=${service.state} url=${service.url} version=${service.version()}`);
  119. setTimeout(() => app.quit(), 2500);
  120. }
  121. }
  122. function createWindow() {
  123. win = new BrowserWindow({
  124. width: 1440,
  125. height: 920,
  126. minWidth: 960,
  127. minHeight: 600,
  128. show: false,
  129. title: "DSH",
  130. backgroundColor: "#17181c",
  131. webPreferences: { contextIsolation: true, sandbox: true },
  132. });
  133. win.loadURL(splashUrl());
  134. win.once("ready-to-show", () => win.show());
  135. win.on("close", (event) => { if (!quitting) { event.preventDefault(); win.hide(); } });
  136. win.webContents.on("did-fail-load", () => { if (!quitting) scheduleReload(); });
  137. win.webContents.setWindowOpenHandler(({ url }) => {
  138. if (/^https?:/.test(url)) shell.openExternal(url);
  139. return { action: "deny" };
  140. });
  141. }
  142. async function loadApp() {
  143. if (!win || !service.url || quitting) return;
  144. try {
  145. await win.loadURL(service.url);
  146. if (!win.isVisible()) win.show();
  147. } catch { scheduleReload(); }
  148. }
  149. function scheduleReload() {
  150. if (reloadTimer || quitting) return;
  151. reloadTimer = setTimeout(async () => {
  152. reloadTimer = null;
  153. if (!quitting) await loadApp();
  154. }, 2000);
  155. }
  156. function showWindow() {
  157. if (!win) {
  158. createWindow();
  159. if (service && service.url) loadApp();
  160. return;
  161. }
  162. win.show();
  163. try { win.focus(); } catch { /* window may be mid-teardown */ }
  164. if (service && service.url && win.webContents.getURL() !== service.url) loadApp();
  165. }
  166. function createTray() {
  167. const icon = nativeImage.createFromPath(path.join(__dirname, "assets", "trayTemplate.png"));
  168. icon.setTemplateImage(true);
  169. tray = new Tray(icon);
  170. tray.setToolTip("DoTouchAI");
  171. refreshTray();
  172. }
  173. function tooltipText() {
  174. if (!service) return "DoTouchAI";
  175. const map = { running: "运行中", starting: "启动中", stopping: "停止中", stopped: "已停止", crashed: "异常退出" };
  176. let text = `DSH · ${map[service.state] || service.state}`;
  177. if (service.port) text += ` · :${service.port}`;
  178. if (updater && updater.available) text += ` · 有新版 ${updater.available.version}`;
  179. return text;
  180. }
  181. function refreshTray() {
  182. if (!tray) return;
  183. tray.setToolTip(tooltipText());
  184. tray.setContextMenu(Menu.buildFromTemplate(menuTemplate()));
  185. }
  186. /** Build metadata written by tools/package.sh (absent in plain `npm start`). */
  187. function buildInfo() {
  188. try {
  189. return JSON.parse(fs.readFileSync(path.join(__dirname, "build.json"), "utf8"));
  190. } catch {
  191. return {};
  192. }
  193. }
  194. /**
  195. * User plugins of the web profile (~/.dsh/profiles/web/node_modules), each as
  196. * "插件名 vX.Y.Z". These upgrade independently of the shell — surfacing them
  197. * here answers "did that plugin fix make it into my install?" directly.
  198. */
  199. function profilePluginsSummary() {
  200. const lines = ["插件(web profile):"];
  201. try {
  202. const root = path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web", "node_modules");
  203. const entries = fs.readdirSync(root).filter((name) => !name.startsWith("."));
  204. const rows = [];
  205. for (const name of entries) {
  206. try {
  207. const pkgPath = path.join(root, name, "package.json");
  208. const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
  209. // A dsh plugin: declares a "dsh" field or follows the dsh-* naming
  210. // convention; plain deps (ws, sherpa-onnx, …) stay hidden.
  211. if (pkg.name && pkg.version && (pkg.dsh || pkg.name.startsWith("dsh-"))) {
  212. const linked = fs.lstatSync(path.join(root, name)).isSymbolicLink() ? " · 本地链接" : "";
  213. rows.push(` ${pkg.name} v${pkg.version}${linked}`);
  214. }
  215. } catch { /* entry without a readable package.json — skip */ }
  216. }
  217. if (rows.length === 0) return ["插件(web profile):无"];
  218. lines.push(...rows.sort());
  219. } catch {
  220. return [];
  221. }
  222. return lines;
  223. }
  224. function menuTemplate() {
  225. const items = [];
  226. const stateLabel = {
  227. running: `● 运行中${service.detail ? " · " + service.detail : ""}`,
  228. starting: "◌ 启动中…",
  229. stopping: "◌ 停止中…",
  230. stopped: "⏸ 已停止",
  231. crashed: `⚠ ${service.detail || "异常退出"}`,
  232. }[service ? service.state : "stopped"] || "…";
  233. const version = (service && service.version()) || updater?.current || "?";
  234. items.push({ label: `${stateLabel} · v${version}${service && service.port ? " · :" + service.port : ""}`, enabled: false });
  235. items.push({ type: "separator" });
  236. items.push({ label: "打开 DSH", click: () => showWindow() });
  237. if (updater && updater.available) {
  238. items.push({ label: `⬆ 升级到 v${updater.available.version}`, click: () => { updater.upgrade(); } });
  239. }
  240. items.push({ label: "检查更新…", click: () => { updater.checkNow(true); } });
  241. items.push({ type: "separator" });
  242. items.push({
  243. label: "重启服务",
  244. click: () => { service.restart().then((ok) => { if (!ok) notify("重启失败", "详见日志"); }); },
  245. });
  246. items.push({
  247. label: service && (service.running || service.state === "starting") ? "停止服务" : "启动服务",
  248. click: () => {
  249. if (service && (service.running || service.state === "starting")) service.stop();
  250. else if (service) service.start(0);
  251. },
  252. });
  253. items.push({ type: "separator" });
  254. items.push({ label: "打开日志文件", click: () => shell.showItemInFolder(service.ensureLogFile()) });
  255. items.push({
  256. label: "打开数据目录(~/.dsh)",
  257. click: () => shell.openPath(process.env.DSH_HOME || path.join(os.homedir(), ".dsh")),
  258. });
  259. items.push({
  260. label: "在程序坞显示图标",
  261. type: "checkbox",
  262. checked: settings.dockIcon,
  263. click: (menuItem) => {
  264. settings.dockIcon = menuItem.checked;
  265. saveSettings();
  266. if (menuItem.checked) app.dock?.show();
  267. else app.dock?.hide();
  268. },
  269. });
  270. items.push({
  271. label: "开机自启",
  272. type: "checkbox",
  273. checked: app.getLoginItemSettings().openAtLogin,
  274. click: (menuItem) => app.setLoginItemSettings({ openAtLogin: menuItem.checked }),
  275. });
  276. items.push({ type: "separator" });
  277. items.push({
  278. label: "关于 DoTouchAI",
  279. click: () => {
  280. dialog.showMessageBox({
  281. type: "info",
  282. title: "DoTouchAI",
  283. message: `DoTouchAI v${app.getVersion()}${buildInfo().builtAt ? `(构建于 ${buildInfo().builtAt})` : ""}`,
  284. detail: [
  285. `dsh:v${(service && service.version()) || "?"}(@deepseek-ai/dsh,经 npm 升级)`,
  286. `Electron:v${process.versions.electron}`,
  287. ...profilePluginsSummary(),
  288. "",
  289. "菜单栏常驻的 dsh 本地桌面壳:进程托管 + 官方 UI 窗口 + npm 升级管家。",
  290. "壳版本 / dsh 版本 / 插件版本三者独立升级;web 插件修复无需重装本应用。",
  291. ].join("\n"),
  292. buttons: ["好"],
  293. });
  294. },
  295. });
  296. items.push({ label: "退出", click: () => app.quit() });
  297. return items;
  298. }
  299. function notify(title, body) {
  300. try {
  301. if (Notification.isSupported()) new Notification({ title, body }).show();
  302. } catch { /* notifications are best-effort */ }
  303. if (service) service.appendLog(`[desktop] ${title}${body ? " — " + body : ""}`);
  304. refreshTray();
  305. }
  306. async function quit() {
  307. if (quitting) return;
  308. quitting = true;
  309. try { globalShortcut.unregisterAll(); } catch { /* ignore */ }
  310. try { if (service) await service.stop(); } catch { /* ignore */ } // no-op when we only attached
  311. app.exit(0);
  312. }
  313. function splashUrl() {
  314. const html = `<!doctype html><html><head><meta charset="utf-8"><title>DSH</title><style>
  315. 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}
  316. .ring{width:34px;height:34px;border-radius:50%;border:3px solid #33363e;border-top-color:#4d6bfe;animation:spin .9s linear infinite}
  317. @keyframes spin{to{transform:rotate(360deg)}}
  318. p{font-size:13px;color:#9aa0ad;margin:0}
  319. </style></head><body><div class="ring"></div><p>正在启动 DSH 服务…</p></body></html>`;
  320. return "data:text/html;charset=utf-8," + encodeURIComponent(html);
  321. }