| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- "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 = `<!doctype html><html><head><meta charset="utf-8"><title>DSH</title><style>
- 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}
- .ring{width:34px;height:34px;border-radius:50%;border:3px solid #33363e;border-top-color:#4d6bfe;animation:spin .9s linear infinite}
- @keyframes spin{to{transform:rotate(360deg)}}
- p{font-size:13px;color:#9aa0ad;margin:0}
- </style></head><body><div class="ring"></div><p>正在启动 DSH 服务…</p></body></html>`;
- return "data:text/html;charset=utf-8," + encodeURIComponent(html);
- }
|