"use strict"; /** * DoTouchAI — a thin menu-bar shell for the local `dsh web` service. * * Three jobs only: * 1. supervise: attach to a healthy `dsh web` on :3080, else spawn and * baby-sit our own child (crash restart with backoff, graceful stop). * 2. window: a BrowserWindow pointing at the service URL — never a bundled * copy of the UI, because the official web app must be served by dsh * itself (it injects window.__DSH_BOOT__). * 3. update: watch npm for @deepseek-ai/dsh releases; one-click upgrade + * restart, automatic rollback when the upgraded service fails to boot. * * Test hooks (documented in ../README.md): * DSH_DESKTOP_SMOKE=1 auto-quit shortly after readiness, log state * DSH_DESKTOP_NO_ATTACH=1 never attach; always spawn our own child * DSH_DESKTOP_USER_DATA=dir force the Electron user-data directory * DSH_DESKTOP_DSH_BIN=path force the dsh binary path */ const { app, BrowserWindow, Tray, Menu, Notification, nativeImage, globalShortcut, shell, dialog, clipboard } = require("electron"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { Service } = require("./service"); const { Updater } = require("./updater"); const { resolveDsh } = require("./env"); const SMOKE = process.env.DSH_DESKTOP_SMOKE === "1"; // This shell renders one trusted local URL; Chromium's OS-level sandbox and GPU // acceleration are unnecessary here and fail outright in constrained launch // contexts (seatbelt profiles, VNC/SSH sessions). Renderer isolation stays on // via webPreferences below. app.commandLine.appendSwitch("no-sandbox"); app.commandLine.appendSwitch("disable-gpu"); app.disableHardwareAcceleration(); let tray = null; let win = null; let service = null; let updater = null; let quitting = false; let reloadTimer = null; let settings = { dockIcon: false }; function settingsPath() { return path.join(app.getPath("userData"), "settings.json"); } function loadSettings() { try { return { dockIcon: false, ...JSON.parse(fs.readFileSync(settingsPath(), "utf8")) }; } catch { return { dockIcon: false }; } } function saveSettings() { try { fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2)); } catch { /* ignore */ } } if (process.env.DSH_DESKTOP_USER_DATA) { fs.mkdirSync(process.env.DSH_DESKTOP_USER_DATA, { recursive: true }); app.setPath("userData", process.env.DSH_DESKTOP_USER_DATA); } if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on("second-instance", () => showWindow()); app.on("activate", () => showWindow()); // clicking the Dock icon app.whenReady().then(main).catch((err) => { console.error("[desktop] fatal:", err); app.exit(1); }); app.on("window-all-closed", () => { /* tray-resident: keep running */ }); app.on("before-quit", (event) => { event.preventDefault(); quit(); }); } async function main() { // Menu-bar resident by default: the shell is a supervisor that keeps the // dsh service running while its window comes and goes. Users who prefer a // regular Dock app can flip "在程序坞显示图标" in the tray menu. settings = loadSettings(); if (!settings.dockIcon) app.dock?.hide(); service = new Service({ userDataDir: app.getPath("userData"), onState: (s) => { refreshTray(); if (s.running && s.url && win && win.webContents.getURL() !== s.url) loadApp(); }, onLog: (line) => { if (SMOKE) console.log("[dsh]", line); }, }); updater = new Updater({ service, cacheDir: path.join(app.getPath("userData"), "npm-cache"), log: (line) => service.appendLog(line), notify, }); createTray(); createWindow(); // Bundled plugin sync (voice suite): install/upgrade into the web profile // BEFORE the service starts, so a cold boot loads them immediately; when // attaching to an already-running instance, restart it to pick them up. let pluginChanges = []; try { const { syncPlugins } = require("./plugins"); const { changed } = syncPlugins({ log: (line) => service.appendLog(line) }); pluginChanges = changed; } catch (err) { service.appendLog(`[plugins] sync failed: ${err.message}`); } if (pluginChanges.length > 0) { notify("语音插件已安装/更新", `${pluginChanges.join("、")} · 即将生效`); } const ok = await service.ensure(); if (!ok) { // A fresh Mac without dsh gets an install guide instead of a bare error. const dshMissing = resolveDsh() === "dsh"; if (dshMissing && !SMOKE) showSetupGuide(); else notify("DSH 服务未能启动", "托盘菜单可“重启服务”,或打开日志排查"); } if (pluginChanges.length > 0 && service.running && !service.spawned) { // attached instance predates the plugin change — restart to load it service.appendLog("[plugins] restarting attached service to load plugin changes"); await service.restart(); } if (service.url) await loadApp(); refreshTray(); try { globalShortcut.register("CommandOrControl+Shift+D", () => { if (win && win.isVisible() && win.isFocused()) win.hide(); else showWindow(); }); } catch { /* shortcut already taken — ignore */ } updater.start(); if (SMOKE) { console.log(`[smoke] state=${service.state} url=${service.url} version=${service.version()}`); setTimeout(() => app.quit(), 2500); } } function createWindow() { const isWin = process.platform === "win32"; win = new BrowserWindow({ width: 1440, height: 920, minWidth: 960, minHeight: 600, show: false, title: "DSH", backgroundColor: "#17181c", icon: path.join(__dirname, "assets", isWin ? "icon.ico" : "icon.icns"), webPreferences: { contextIsolation: true, sandbox: true }, }); win.loadURL(splashUrl()); win.once("ready-to-show", () => win.show()); win.on("close", (event) => { if (!quitting) { event.preventDefault(); win.hide(); } }); win.webContents.on("did-fail-load", () => { if (!quitting) scheduleReload(); }); win.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:/.test(url)) shell.openExternal(url); return { action: "deny" }; }); } async function loadApp() { if (!win || !service.url || quitting) return; try { await win.loadURL(service.url); if (!win.isVisible()) win.show(); } catch { scheduleReload(); } } function scheduleReload() { if (reloadTimer || quitting) return; reloadTimer = setTimeout(async () => { reloadTimer = null; if (!quitting) await loadApp(); }, 2000); } function showWindow() { if (!win) { createWindow(); if (service && service.url) loadApp(); return; } win.show(); try { win.focus(); } catch { /* window may be mid-teardown */ } if (service && service.url && win.webContents.getURL() !== service.url) loadApp(); } function createTray() { const isWin = process.platform === "win32"; const iconFile = isWin ? "icon.ico" : "trayTemplate.png"; const icon = nativeImage.createFromPath(path.join(__dirname, "assets", iconFile)); if (!isWin) icon.setTemplateImage(true); // macOS: monochrome template image tray = new Tray(icon); tray.setToolTip("DoTouchAI"); refreshTray(); } function tooltipText() { if (!service) return "DoTouchAI"; const map = { running: "运行中", starting: "启动中", stopping: "停止中", stopped: "已停止", crashed: "异常退出" }; let text = `DSH · ${map[service.state] || service.state}`; if (service.port) text += ` · :${service.port}`; if (updater && updater.available) text += ` · 有新版 ${updater.available.version}`; return text; } function refreshTray() { if (!tray) return; tray.setToolTip(tooltipText()); tray.setContextMenu(Menu.buildFromTemplate(menuTemplate())); } /** Build metadata written by tools/package.sh (absent in plain `npm start`). */ function buildInfo() { try { return JSON.parse(fs.readFileSync(path.join(__dirname, "build.json"), "utf8")); } catch { return {}; } } /** * User plugins of the web profile (~/.dsh/profiles/web/node_modules), each as * "插件名 vX.Y.Z". These upgrade independently of the shell — surfacing them * here answers "did that plugin fix make it into my install?" directly. */ function profilePluginsSummary() { const lines = ["插件(web profile):"]; try { const root = path.join(process.env.DSH_HOME || path.join(os.homedir(), ".dsh"), "profiles", "web", "node_modules"); const entries = fs.readdirSync(root).filter((name) => !name.startsWith(".")); const rows = []; for (const name of entries) { try { const pkgPath = path.join(root, name, "package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); // A dsh plugin: declares a "dsh" field or follows the dsh-* naming // convention; plain deps (ws, sherpa-onnx, …) stay hidden. if (pkg.name && pkg.version && (pkg.dsh || pkg.name.startsWith("dsh-"))) { const linked = fs.lstatSync(path.join(root, name)).isSymbolicLink() ? " · 本地链接" : ""; rows.push(` ${pkg.name} v${pkg.version}${linked}`); } } catch { /* entry without a readable package.json — skip */ } } if (rows.length === 0) return ["插件(web profile):无"]; lines.push(...rows.sort()); } catch { return []; } return lines; } function menuTemplate() { const items = []; const stateLabel = { running: `● 运行中${service.detail ? " · " + service.detail : ""}`, starting: "◌ 启动中…", stopping: "◌ 停止中…", stopped: "⏸ 已停止", crashed: `⚠ ${service.detail || "异常退出"}`, }[service ? service.state : "stopped"] || "…"; const version = (service && service.version()) || updater?.current || "?"; items.push({ label: `${stateLabel} · v${version}${service && service.port ? " · :" + service.port : ""}`, enabled: false }); items.push({ type: "separator" }); items.push({ label: "打开 DSH", click: () => showWindow() }); if (updater && updater.available) { items.push({ label: `⬆ 升级到 v${updater.available.version}`, click: () => { updater.upgrade(); } }); } items.push({ label: "检查更新…", click: () => { updater.checkNow(true); } }); items.push({ type: "separator" }); items.push({ label: "重启服务", click: () => { service.restart().then((ok) => { if (!ok) notify("重启失败", "详见日志"); }); }, }); items.push({ label: service && (service.running || service.state === "starting") ? "停止服务" : "启动服务", click: () => { if (service && (service.running || service.state === "starting")) service.stop(); else if (service) service.start(0); }, }); items.push({ type: "separator" }); items.push({ label: "打开日志文件", click: () => shell.showItemInFolder(service.ensureLogFile()) }); items.push({ label: "打开数据目录(~/.dsh)", click: () => shell.openPath(process.env.DSH_HOME || path.join(os.homedir(), ".dsh")), }); items.push({ label: "在程序坞显示图标", type: "checkbox", visible: process.platform === "darwin", checked: settings.dockIcon, click: (menuItem) => { settings.dockIcon = menuItem.checked; saveSettings(); if (menuItem.checked) app.dock?.show(); else app.dock?.hide(); }, }); items.push({ label: "开机自启", type: "checkbox", checked: app.getLoginItemSettings().openAtLogin, click: (menuItem) => app.setLoginItemSettings({ openAtLogin: menuItem.checked }), }); items.push({ type: "separator" }); items.push({ label: "关于 DoTouchAI", click: () => { dialog.showMessageBox({ type: "info", title: "DoTouchAI", message: `DoTouchAI v${app.getVersion()}${buildInfo().builtAt ? `(构建于 ${buildInfo().builtAt})` : ""}`, detail: [ `dsh:v${(service && service.version()) || "?"}(@deepseek-ai/dsh,经 npm 升级)`, `Electron:v${process.versions.electron}`, ...profilePluginsSummary(), "", "菜单栏常驻的 dsh 本地桌面壳:进程托管 + 官方 UI 窗口 + npm 升级管家。", "壳版本 / dsh 版本 / 插件版本三者独立升级;web 插件修复无需重装本应用。", ].join("\n"), buttons: ["好"], }); }, }); items.push({ label: "退出", click: () => app.quit() }); return items; } function notify(title, body) { try { if (Notification.isSupported()) new Notification({ title, body }).show(); } catch { /* notifications are best-effort */ } if (service) service.appendLog(`[desktop] ${title}${body ? " — " + body : ""}`); refreshTray(); } /** First-run guide for a fresh machine: no dsh detected → how to install it. */ async function showSetupGuide() { const isWin = process.platform === "win32"; const installCmd = isWin ? "npm config set prefix %USERPROFILE%\\.local && npm install -g @deepseek-ai/dsh" : "npm config set prefix ~/.local && npm install -g @deepseek-ai/dsh && echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.zshrc"; const { response } = await dialog.showMessageBox({ type: "warning", title: "DoTouchAI", message: "未检测到 dsh —— 新机器需要先安装(一次性)", detail: isWin ? [ "1. 安装 Node.js LTS:https://nodejs.org", "2. 命令提示符执行安装命令(点下方按钮即可复制):", "", " npm config set prefix %USERPROFILE%\\.local", " npm install -g @deepseek-ai/dsh", "", "3. 安装完成后:托盘菜单 → 启动服务", "(语音插件已随本应用内置,会自动装好,无需其他操作)", ].join("\n") : [ "1. 安装 Node.js LTS:https://nodejs.org(或 brew install node)", "2. 终端执行安装命令(点下方按钮即可复制):", "", " npm config set prefix ~/.local", " npm install -g @deepseek-ai/dsh", " echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.zshrc", "", "3. 安装完成后:托盘菜单 → 启动服务", "(语音插件已随本应用内置,会自动装好,无需其他操作)", ].join("\n"), buttons: ["复制安装命令", "好"], defaultId: 0, }); if (response === 0) { clipboard.writeText(installCmd); notify("安装命令已复制", "粘贴到终端执行,完成后从托盘菜单“启动服务”"); } } async function quit() { if (quitting) return; quitting = true; try { globalShortcut.unregisterAll(); } catch { /* ignore */ } try { if (service) await service.stop(); } catch { /* ignore */ } // no-op when we only attached app.exit(0); } function splashUrl() { const html = `DSH

正在启动 DSH 服务…

`; return "data:text/html;charset=utf-8," + encodeURIComponent(html); }