Sfoglia il codice sorgente

DoTouchAI 0.4.1: --deep签名修复新Mac打不开 + DMG安装说明(provenance) + 一键安装脚本 + Win打包

0.4.1 相比 0.4.0:
- 打包签名加 --deep,嵌套 Framework/Helper 一并 ad-hoc 重签,codesign --verify --deep --strict 通过
- DMG 内《安装说明.txt》改 xattr -cr 全清(含 com.apple.provenance,新版 macOS 26 必需)
- 新增 install.sh 一键安装脚本(下载→sha256校验→挂载→ditto拷贝→xattr -cr→启动)
- 跨平台 Windows 重构(env/service/updater + package-win.sh + icon.ico)
- 版本号回归修正:0.3.0→0.4.0→0.4.1(曾误回退到 0.2.4/0.2.5)
Evan 1 giorno fa
parent
commit
ab6134d1ae

+ 3 - 0
dsh-desktop/.gitignore

@@ -4,3 +4,6 @@ dist/
 .npm-cache/
 .smoke-*/
 app/assets/icon.iconset/
+
+# Windows native-binary build cache (regenerable)
+.sherpa-win-cache/

+ 38 - 22
dsh-desktop/CHANGELOG.md

@@ -1,22 +1,51 @@
 # Changelog
 
-壳版本(DSH Desktop 自身)遵循 SemVer:`MAJOR` 形态或破坏性变更、`MINOR` 新功能、`PATCH` 修复。
+壳版本(DoTouchAI 自身)遵循 SemVer:`MAJOR` 形态或破坏性变更、`MINOR` 新功能、`PATCH` 修复。
 dsh 本体版本独立于壳版本,由升级管家经 npm 管理。
 
-## 0.3.0 — 2026-08-24
-
-- **语音插件随安装包自动安装**:dsh-voice-client / dsh-voice-server(含 sherpa-onnx、ws 依赖)
-  打进 .app 的 Resources/plugins/,首启自动装入 web profile(拷贝 + 写 cordis.patch.yml),
-  换机重装即用;已装时按版本号增量升级;开发机 symlink 布局自动识别、永不覆盖
-- 设置分区的语音模块现在有导航标题(随插件 0.1.2 生效,见插件 CHANGELOG)
-- DMG 增大约 32M(携带插件与离线识别依赖)
+> 注:改名 DoTouchAI 重构期间,版本号曾误从 0.3.0 回退到 0.2.4/0.2.5(最新内容挂着更小的号)。
+> 以下已校正为单调递增;0.3.0 = 语音插件随包分发(曾误标 0.2.4),0.4.0 = DoTouchAI 改名 + Windows + 安装说明/引导。
+
+## 0.4.1 — 2026-08-25
+
+- **修复新 Mac 打不开的根因**:macOS 14.4+/26 给下载应用同时打 `com.apple.quarantine`
+  **和** `com.apple.provenance` 两个标记,旧命令只去 quarantine 不够(provenance 仍触发
+  Gatekeeper → "还是打不开")。安装说明改为 `xattr -cr` 全清(含 provenance)
+- 打包签名加 `--deep`:嵌套 Framework/Helper 一并 ad-hoc 重签,
+  `codesign --verify --deep --strict` 现通过(此前顶层 ad-hoc + 嵌套链接器签混搭,严格校验失败)
+
+## 0.4.0 — 2026-08-25
+
+- **产品改名 DoTouchAI**:应用名 / 托盘提示 / 关于面板 / 安装包名 / DMG 卷名全量对齐鸿蒙端
+- **Windows x64 客户端**(Win10/11):便携 zip `DoTouchAI-0.4.0-win-x64.zip`,解压即跑 `DoTouchAI.exe`。
+  跨平台重构——Win 上 dsh/npm 为 `.cmd` 经 cmd.exe 启动(bare 名 + shell,规避路径空格)、
+  端口探测 `lsof`→`netstat -ano`、进程停止/接管 `SIGTERM`→`taskkill /T`、托盘用彩色 `icon.ico`、
+  隐藏 Dock 开关;随包携带 `sherpa-onnx-win-x64` 原生二进制(离线语音识别)
+- **DMG 内置《安装说明.txt》**:新 Mac 上 Gatekeeper 拦截未签名应用("无法打开"/"已损坏")
+  的三种解法(右键打开 / xattr 去隔离 / 系统设置仍要打开)+ Node/dsh 安装步骤,
+  收到安装包的人无需口头指导即可自助解决(0.3.0 的 DSH-Desktop DMG 在新 Mac 上即栽在此)
+- **新机器引导对话框**:启动后未检测到 dsh 时,弹安装引导(含"复制安装命令"按钮,一键复制
+  npm 安装 dsh 的完整命令;macOS/Windows 文案分别适配),替代原来的纯错误通知
+- 关于/引导文案统一 DoTouchAI 命名
+- **Windows 构建限制**:.exe 图标为 Electron 默认(rcedit 是 Windows 工具,mac 构建主机无法执行,
+  窗口/托盘仍用 `assets/icon.ico`);未签名 → SmartScreen 首次警告("更多信息 → 仍要运行");
+  本机构建仅验证文件结构与 JS 语法,**实际运行需在 Windows 机器验证**
+
+## 0.3.0 — 2026-08-23
+
+- **语音插件随安装包自动安装**(mac/Win 通用):dsh-voice-client / dsh-voice-server(含 sherpa-onnx、ws 依赖)
+  打进安装包,首启自动装入 web profile(拷贝 + 写 cordis.patch.yml),换机重装即用;
+  已装按版本号增量升级;开发机 symlink 布局自动识别、永不覆盖
+- 设置分区语音模块补导航标题"语音交流 / Voice"(随插件 0.1.2 生效,见插件 CHANGELOG)
 
 ## 0.2.2 — 2026-08-24
 
 - "关于"面板新增 **web 插件版本清单**(如 dsh-voice-client v0.1.1·本地链接)——插件修复
   是否已就位一眼可查,不必再靠重装确认(web 插件经 profile symlink 热加载,修复本就无需重装本应用)
 - "关于"面板显示**构建时间**(build.json,打包时写入)
-- DMG 卷名带版本号("DSH Desktop 0.2.2"),挂载即可分辨新旧安装包
+- DMG 卷名带版本号,挂载即可分辨新旧安装包
+- 品牌图标替换:应用图标与菜单栏图标换为原创字母标(规避第三方商标);App 图标经 iconset→icns,
+  托盘字母标黑色 template 渲染(圆头描边光栅化),明暗模式自适应
 
 ## 0.2.1 — 2026-08-22
 
@@ -39,16 +68,3 @@ dsh 本体版本独立于壳版本,由升级管家经 npm 管理。
 - 官方 Web UI 原生窗口(加载 dsh 服务地址,绝不打包 UI 副本)
 - 升级管家:npm dist-tags 发版探测(独立 cache)、一键 `npm i -g` 升级、升级后冒烟、失败自动回滚
 - 菜单栏托盘(状态/版本/端口)、开机自启、单实例、`⌘⇧D` 全局快捷键
-
-## 0.2.2 — 2026-08-24
-
-- 品牌替换:应用图标与菜单栏图标换为 DSH Harmony 原创字母标(规避第三方商标)
-- App 图标:assets/logo/exports/icon-1024.png(深空紫黑底 + DSH 单线字母)经 iconset→icns
-- 托盘:字母标黑色 template 渲染(圆头描边光栅化),明暗模式自适应
-
-
-## 0.2.3 — 2026-08-24
-
-- 更名 DoTouchAI:应用名/托盘提示/关于面板/安装包名全量对齐鸿蒙端
-- 图标换品牌标(智价云 kailin 红蓝层叠标):icns 白底版 + 托盘黑色 template 剪影
-

BIN
dsh-desktop/app/assets/icon.ico


+ 52 - 22
dsh-desktop/app/env.js

@@ -2,17 +2,17 @@
 /**
  * Binary discovery + child environment for GUI-context launches.
  *
- * From Finder/launchd an app inherits PATH=/usr/bin:/bin:/usr/sbin:/sbin — the
- * `#!/usr/bin/env node` shebangs of dsh and npm cannot resolve there. All
- * spawned children therefore run through childEnv(), which prepends the
- * directories that actually hold dsh, npm, and node: both their symlink spots
- * (~/.local/bin) and their realpath homes (e.g. a version manager under
- * ~/.hermes/node/bin).
+ * macOS (Finder/launchd) and Windows (Explorer/scheduled-task) both start GUI
+ * apps with a minimal PATH that cannot resolve the `#!/usr/bin/env node`
+ * shebangs of dsh/npm. All spawned children therefore run through childEnv(),
+ * which prepends the directories that actually hold dsh, npm, and node.
  */
 const fs = require("node:fs");
 const os = require("node:os");
 const path = require("node:path");
 
+const IS_WIN = process.platform === "win32";
+
 function firstExisting(candidates) {
 	for (const candidate of candidates) {
 		try { if (candidate && fs.existsSync(candidate)) return candidate; } catch { /* next */ }
@@ -21,31 +21,53 @@ function firstExisting(candidates) {
 }
 
 function pathDirs() {
-	return (process.env.PATH || "").split(":").filter(Boolean);
+	const sep = IS_WIN ? ";" : ":";
+	return (process.env.PATH || "").split(sep).filter(Boolean);
 }
 
-/** The dsh binary: explicit override, common user install spots, then PATH. */
+/** The executable extension for node/npm shims (.cmd on Windows, else none). */
+function exe(name) { return IS_WIN ? `${name}.cmd` : name; }
+
+/**
+ * The dsh binary: explicit override, common user install spots, then PATH.
+ * On Windows, `npm i -g @deepseek-ai/dsh` places dsh.cmd in the npm prefix
+ * (%APPDATA%\npm); on macOS, ~/.local/bin/dsh (a symlink to the package).
+ */
 function findDsh() {
 	const override = process.env.DSH_DESKTOP_DSH_BIN;
 	if (override) return override;
 	const home = os.homedir();
-	return firstExisting([
+	const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
+	const candidates = IS_WIN ? [
+		path.join(appdata, "npm", "dsh.cmd"),
+		path.join(home, ".local", "bin", "dsh.cmd"),
+		path.join(home, "AppData", "Roaming", "npm", "dsh.cmd"),
+		...pathDirs().map((dir) => path.join(dir, "dsh.cmd")),
+	] : [
 		path.join(home, ".local/bin/dsh"),
 		"/opt/homebrew/bin/dsh",
 		"/usr/local/bin/dsh",
 		...pathDirs().map((dir) => path.join(dir, "dsh")),
-	]) || "dsh";
+	];
+	return firstExisting(candidates) || "dsh";
 }
 
 /** The npm binary: the same discovery strategy. */
 function findNpm() {
 	const home = os.homedir();
-	return firstExisting([
+	const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
+	const candidates = IS_WIN ? [
+		path.join(appdata, "npm", "npm.cmd"),
+		path.join(home, ".local", "bin", "npm.cmd"),
+		path.join(home, "AppData", "Roaming", "npm", "npm.cmd"),
+		...pathDirs().map((dir) => path.join(dir, "npm.cmd")),
+	] : [
 		path.join(home, ".local/bin/npm"),
 		"/opt/homebrew/bin/npm",
 		"/usr/local/bin/npm",
 		...pathDirs().map((dir) => path.join(dir, "npm")),
-	]) || "npm";
+	];
+	return firstExisting(candidates) || "npm";
 }
 
 const DSH_BIN = findDsh();
@@ -53,9 +75,8 @@ const NPM_BIN = findNpm();
 
 /**
  * Re-resolve the dsh binary right before every spawn. `npm install -g`
- * replaces the bin symlink during upgrades; a path captured at shell startup
- * (or a bare "dsh" fallback resolved while the link was missing) would then
- * hit ENOENT — exactly the upgrade-race failure seen in the wild.
+ * replaces the bin symlink/cmd during upgrades; a path captured at shell
+ * startup would then hit ENOENT — exactly the upgrade-race failure in the wild.
  */
 function resolveDsh() {
 	return findDsh();
@@ -63,18 +84,27 @@ function resolveDsh() {
 
 /** Environment (PATH augmented) for every child process we spawn. */
 function childEnv() {
+	const sep = IS_WIN ? ";" : ":";
 	const dirs = [];
 	for (const bin of [DSH_BIN, NPM_BIN]) {
 		if (path.isAbsolute(bin)) dirs.push(path.dirname(bin));
 		try { dirs.push(path.dirname(fs.realpathSync(bin))); } catch { /* not a file */ }
 	}
-	dirs.push(
-		path.join(os.homedir(), ".local/bin"),
-		"/opt/homebrew/bin",
-		"/usr/local/bin",
-	);
+	if (IS_WIN) {
+		const appdata = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
+		dirs.push(path.join(appdata, "npm"));
+		dirs.push(path.join(os.homedir(), ".local", "bin"));
+	} else {
+		dirs.push(
+			path.join(os.homedir(), ".local/bin"),
+			"/opt/homebrew/bin",
+			"/usr/local/bin",
+		);
+	}
 	const unique = [...new Set(dirs.filter(Boolean))];
-	return { ...process.env, PATH: `${unique.join(":")}:${process.env.PATH || ""}` };
+	const augmented = unique.join(sep);
+	const existing = process.env.PATH || "";
+	return { ...process.env, PATH: existing ? `${augmented}${sep}${existing}` : augmented };
 }
 
-module.exports = { DSH_BIN, NPM_BIN, findDsh, findNpm, resolveDsh, childEnv };
+module.exports = { DSH_BIN, NPM_BIN, findDsh, findNpm, resolveDsh, childEnv, IS_WIN };

+ 54 - 4
dsh-desktop/app/main.js

@@ -17,12 +17,13 @@
  *   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 } = require("electron");
+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";
 
@@ -109,7 +110,12 @@ async function main() {
 	}
 
 	const ok = await service.ensure();
-	if (!ok) notify("DSH 服务未能启动", "托盘菜单可“重启服务”,或打开日志排查");
+	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");
@@ -134,6 +140,7 @@ async function main() {
 }
 
 function createWindow() {
+	const isWin = process.platform === "win32";
 	win = new BrowserWindow({
 		width: 1440,
 		height: 920,
@@ -142,6 +149,7 @@ function createWindow() {
 		show: false,
 		title: "DSH",
 		backgroundColor: "#17181c",
+		icon: path.join(__dirname, "assets", isWin ? "icon.ico" : "icon.icns"),
 		webPreferences: { contextIsolation: true, sandbox: true },
 	});
 	win.loadURL(splashUrl());
@@ -182,8 +190,10 @@ function showWindow() {
 }
 
 function createTray() {
-	const icon = nativeImage.createFromPath(path.join(__dirname, "assets", "trayTemplate.png"));
-	icon.setTemplateImage(true);
+	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();
@@ -282,6 +292,7 @@ function menuTemplate() {
 	items.push({
 		label: "在程序坞显示图标",
 		type: "checkbox",
+		visible: process.platform === "darwin",
 		checked: settings.dockIcon,
 		click: (menuItem) => {
 			settings.dockIcon = menuItem.checked;
@@ -328,6 +339,45 @@ function notify(title, 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;

+ 2 - 2
dsh-desktop/app/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "dsh-desktop",
-  "version": "0.3.0",
+  "version": "0.4.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "dsh-desktop",
-      "version": "0.3.0",
+      "version": "0.4.1",
       "license": "MIT",
       "devDependencies": {
         "electron": "^42.3.3"

+ 2 - 2
dsh-desktop/app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "dsh-desktop",
   "productName": "DoTouchAI",
-  "version": "0.2.4",
+  "version": "0.4.1",
   "description": "Menu-bar shell for the local dsh web UI: supervise, window, and npm-based one-click upgrades.",
   "main": "main.js",
   "license": "MIT",
@@ -15,4 +15,4 @@
   "devDependencies": {
     "electron": "^42.3.3"
   }
-}
+}

+ 86 - 18
dsh-desktop/app/service.js

@@ -6,17 +6,19 @@
  *   HTML shell carries `__DSH_BOOT__`), use it and own nothing.
  * - spawn: otherwise start `dsh web --port <n>` as a supervised child; a busy
  *   default port falls back to `--port 0` (the actual port is parsed from the
- *   child's "dsh web: http://127.0.0.1:<port>" line, with an lsof fallback).
- * - crash: restart with exponential backoff (suppressed while stopping or
+ *   child's "dsh web: http://127.0.0.1:<port>" line, with an lsof/netstat
+ *   fallback).
+ * - crash: restart with exponential backoff (supervised while stopping or
  *   upgrading, and never for an instance we did not spawn).
- * - stop: SIGTERM first — dsh shuts down gracefully — then SIGKILL on timeout.
+ * - stop: SIGTERM first — dsh shuts down gracefully — then SIGKILL on timeout
+ *   (Windows: taskkill /T, which has no graceful-signal equivalent).
  */
 const { spawn, execFile, execFileSync } = require("node:child_process");
 const http = require("node:http");
 const fs = require("node:fs");
 const path = require("node:path");
 const readline = require("node:readline");
-const { DSH_BIN, resolveDsh, childEnv } = require("./env");
+const { DSH_BIN, resolveDsh, childEnv, IS_WIN } = require("./env");
 
 const DEFAULT_PORT = Number(process.env.DSH_DESKTOP_PORT || 3080);
 const READY_TIMEOUT_MS = 120_000;
@@ -101,11 +103,22 @@ class Service {
 					const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
 					if (pkg.name === "@deepseek-ai/dsh") { version = pkg.version; break; }
 				}
+				// Windows npm layout: <prefix>/dsh.cmd + <prefix>/node_modules/@deepseek-ai/dsh
+				const nm = path.join(dir, "node_modules", "@deepseek-ai", "dsh", "package.json");
+				if (fs.existsSync(nm)) {
+					const pkg = JSON.parse(fs.readFileSync(nm, "utf8"));
+					if (pkg.name === "@deepseek-ai/dsh") { version = pkg.version; break; }
+				}
 				dir = path.dirname(dir);
 			}
 		} catch { /* fall through to the CLI */ }
 		if (!version) {
-			try { version = execFileSync(DSH_BIN, ["-V"], { encoding: "utf8" }).trim(); } catch { /* unknown */ }
+			try {
+				version = execFileSync(DSH_BIN, ["-V"], {
+					encoding: "utf8",
+					shell: IS_WIN, // .cmd shims need cmd.exe
+				}).trim();
+			} catch { /* unknown */ }
 		}
 		this._ver = version;
 		this._verAt = now;
@@ -145,11 +158,17 @@ class Service {
 			let portHint = null;
 			const done = (ok) => { if (!settled) { settled = true; resolve(ok); } };
 			const bin = resolveDsh(); // re-resolved per spawn: npm upgrades replace the link
+			// On Windows, dsh is a .cmd shim — spawn via cmd.exe (shell:true). We
+			// pass the bare name so cmd resolves it through the augmented PATH,
+			// sidestepping spaces in absolute install paths.
+			const spawnBin = IS_WIN ? "dsh" : bin;
 			let child;
 			try {
-				child = spawn(bin, ["web", "--port", String(wanted)], {
+				child = spawn(spawnBin, ["web", "--port", String(wanted)], {
 					stdio: ["ignore", "pipe", "pipe"],
 					env: childEnv(),
+					shell: IS_WIN ? true : false,
+					windowsHide: true,
 				});
 			} catch (err) {
 				this.lastSpawnEnoent = err.code === "ENOENT";
@@ -211,9 +230,24 @@ class Service {
 		});
 	}
 
-	/** The TCP port a pid listens on, via lsof (macOS). */
+	/** The TCP port a pid listens on — lsof (macOS) or netstat (Windows). */
 	listenPortOf(pid) {
 		return new Promise((resolve) => {
+			if (IS_WIN) {
+				// netstat -ano line: "  TCP   127.0.0.1:3080   0.0.0.0:0  LISTENING  <pid>"
+				execFile("netstat", ["-ano", "-p", "tcp"], (err, stdout) => {
+					if (err || !stdout) return resolve(null);
+					for (const line of stdout.split(/\r?\n/)) {
+						if (!line.includes("LISTENING")) continue;
+						const m = line.match(/127\.0\.0\.1:(\d+)/);
+						if (!m) continue;
+						const p = Number(line.trim().split(/\s+/).pop());
+						if (p === pid) return resolve(Number(m[1]));
+					}
+					resolve(null);
+				});
+				return;
+			}
 			execFile("lsof", ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-p", String(pid)], (err, stdout) => {
 				if (err || !stdout) return resolve(null);
 				const m = stdout.match(/127\.0\.0\.1:(\d+)/);
@@ -242,14 +276,25 @@ class Service {
 		if (!this.child) { this.setState("stopped"); return Promise.resolve(true); }
 		const child = this.child;
 		this.setState("stopping");
-		this.appendLog("[desktop] stopping service (SIGTERM)");
+		this.appendLog(`[desktop] stopping service (${IS_WIN ? "taskkill" : "SIGTERM"})`);
 		return new Promise((resolve) => {
 			const killTimer = setTimeout(() => {
-				this.appendLog("[desktop] grace elapsed; SIGKILL");
-				try { child.kill("SIGKILL"); } catch { /* already gone */ }
+				this.appendLog("[desktop] grace elapsed; force kill");
+				if (IS_WIN) {
+					try { execFile("taskkill", ["/PID", String(child.pid), "/T", "/F"], () => {}); } catch { /* gone */ }
+				} else {
+					try { child.kill("SIGKILL"); } catch { /* already gone */ }
+				}
 			}, STOP_GRACE_MS);
 			child.once("exit", () => { clearTimeout(killTimer); resolve(true); });
-			try { child.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(false); }
+			if (IS_WIN) {
+				// Windows has no graceful signal for console node; ask the tree
+				// to close (taskkill /T without /F), escalate to /F on timeout.
+				try { execFile("taskkill", ["/PID", String(child.pid), "/T"], () => {}); }
+				catch { clearTimeout(killTimer); resolve(false); }
+			} else {
+				try { child.kill("SIGTERM"); } catch { clearTimeout(killTimer); resolve(false); }
+			}
 		}).then(() => {
 			this.child = null;
 			this.port = null;
@@ -275,20 +320,43 @@ class Service {
 		return this.start(DEFAULT_PORT);
 	}
 
-	/** SIGTERM whatever listens on a port (used only for explicit take-over). */
+	/** Stop whatever listens on a port (explicit take-over only). */
 	killByPort(port) {
 		return new Promise((resolve) => {
-			execFile("lsof", ["-ti", `tcp:${port}`], (err, stdout) => {
-				const pids = (stdout || "").trim().split(/\s+/).filter(Boolean).map(Number);
-				if (err || pids.length === 0) return resolve(false);
-				this.appendLog(`[desktop] SIGTERM pid ${pids.join(", ")} (port ${port})`);
-				for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
+			const collect = (cb) => {
+				if (IS_WIN) {
+					execFile("netstat", ["-ano", "-p", "tcp"], (err, stdout) => {
+						if (err || !stdout) return cb([]);
+						const pids = [];
+						for (const line of stdout.split(/\r?\n/)) {
+							if (!line.includes("LISTENING")) continue;
+							const m = line.match(/127\.0\.0\.1:(\d+)/);
+							if (!m || Number(m[1]) !== port) continue;
+							const p = Number(line.trim().split(/\s+/).pop());
+							if (p) pids.push(p);
+						}
+						cb(pids);
+					});
+				} else {
+					execFile("lsof", ["-ti", `tcp:${port}`], (err, stdout) => {
+						cb((stdout || "").trim().split(/\s+/).filter(Boolean).map(Number));
+					});
+				}
+			};
+			collect((pids) => {
+				if (pids.length === 0) return resolve(false);
+				this.appendLog(`[desktop] stopping pids ${pids.join(", ")} on :${port}`);
+				if (IS_WIN) {
+					for (const pid of pids) execFile("taskkill", ["/PID", String(pid), "/T", "/F"], () => {});
+				} else {
+					for (const pid of pids) { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
+				}
 				const startedAt = Date.now();
 				const iv = setInterval(async () => {
 					const alive = await probeDsh(port);
 					if (!alive || Date.now() - startedAt > 10_000) {
 						clearInterval(iv);
-						if (alive) for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
+						if (alive && !IS_WIN) for (const pid of pids) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } }
 						resolve(true);
 					}
 				}, 400);

+ 71 - 0
dsh-desktop/app/tools/gen-icon-ico.js

@@ -0,0 +1,71 @@
+"use strict";
+/**
+ * Assemble a Windows multi-resolution .ico from the icon.iconset PNGs.
+ *
+ * ICO format (PNG-embedded, supported by Windows Vista+):
+ *   ICONDIR    (6 bytes):  reserved=0, type=1, count=N
+ *   ICONDIRENTRY (16×N):  w,h,colors,reserved,planes,bitcount,size,offset
+ *   <PNG bytes concatenated>
+ * No native tooling (iconutil/sips) — pure Node, cross-buildable on macOS.
+ */
+const fs = require("node:fs");
+const path = require("node:path");
+
+const ICONSET = path.join(__dirname, "..", "assets", "icon.iconset");
+const OUT = path.join(__dirname, "..", "assets", "icon.ico");
+
+const SIZES = [16, 32, 48, 64, 128, 256];
+const SOURCES = {
+	16: "icon_16x16.png",
+	32: "icon_32x32.png",
+	64: "icon_32x32@2x.png",
+	128: "icon_128x128.png",
+	256: "icon_256x256.png",
+	// 48 has no direct source; reuse 64 — Windows scales acceptably.
+	48: "icon_32x32@2x.png",
+};
+
+function pngDims(buf) {
+	// PNG IHDR: width (4 bytes BE) at offset 16, height at 20.
+	return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+}
+
+function build() {
+	const entries = [];
+	for (const size of SIZES) {
+		const file = path.join(ICONSET, SOURCES[size]);
+		if (!fs.existsSync(file)) { console.warn(`skip ${size}: missing ${SOURCES[size]}`); continue; }
+		const buf = fs.readFileSync(file);
+		if (buf.slice(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error(`${file}: not a PNG`);
+		const { width, height } = pngDims(buf);
+		entries.push({ size, width, height, buf });
+	}
+	if (entries.length === 0) throw new Error("no PNG sources found");
+
+	const count = entries.length;
+	const headerSize = 6 + 16 * count;
+	let offset = headerSize;
+	const dir = Buffer.alloc(headerSize);
+	dir.writeUInt16LE(0, 0); // reserved
+	dir.writeUInt16LE(1, 2); // type = icon
+	dir.writeUInt16LE(count, 4);
+
+	entries.forEach((e, i) => {
+		const base = 6 + 16 * i;
+		dir.writeUInt8(e.size >= 256 ? 0 : e.size, base);       // width (0 = 256)
+		dir.writeUInt8(e.size >= 256 ? 0 : e.size, base + 1);   // height
+		dir.writeUInt8(0, base + 2);                              // colorCount (0 = ≥8bit)
+		dir.writeUInt8(0, base + 3);                              // reserved
+		dir.writeUInt16LE(1, base + 4);                           // planes
+		dir.writeUInt16LE(32, base + 6);                          // bitCount
+		dir.writeUInt32LE(e.buf.length, base + 8);                // bytesInRes
+		dir.writeUInt32LE(offset, base + 12);                     // imageOffset
+		offset += e.buf.length;
+	});
+
+	const out = Buffer.concat([dir, ...entries.map((e) => e.buf)]);
+	fs.writeFileSync(OUT, out);
+	console.log(`built ${OUT}: ${count} sizes [${entries.map((e) => e.size).join(",")}] (${out.length} bytes)`);
+}
+
+build();

+ 91 - 0
dsh-desktop/app/tools/package-win.sh

@@ -0,0 +1,91 @@
+#!/bin/bash
+# Build ../dist/DoTouchAI-win/ + a portable zip from the cached Windows
+# Electron binary + the cross-platform app sources. Win10/11 x64 target.
+#
+# What this produces (and what it cannot, on a macOS build host):
+#   - a runnable portable folder: unzip → run "DoTouchAI.exe"
+#   - the .exe keeps Electron's default icon (rcedit is a Windows tool we
+#     cannot run on macOS); the window + tray use assets/icon.ico at runtime
+#   - unsigned → SmartScreen warns on first launch ("更多信息 → 仍要运行")
+#   - execution must be verified on a real Windows machine; this host cannot
+#     run .exe, so the build is structural-only (files present + JS compiles)
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+VERSION="$(node -p "require('./package.json').version")"
+OUT="../dist"
+APP="$OUT/DoTouchAI-win"
+ZIP="$OUT/DoTouchAI-${VERSION}-win-x64.zip"
+
+# Locate the cached Windows Electron binary (same version pinned in devDeps).
+ELECTRON_ZIP=$(ls "$HOME/Library/Caches/electron/"*/electron-v*-win32-x64.zip 2>/dev/null | head -1)
+if [ -z "$ELECTRON_ZIP" ]; then
+	echo "ERROR: electron win32-x64 zip not in cache. Run: npm i -D electron@42.3.3" >&2
+	exit 1
+fi
+
+# Windows-native sherpa-onnx binary (offline ASR). Fetch on first build, cache.
+SHERPA_CACHE="../.sherpa-win-cache"
+if [ ! -d "$SHERPA_CACHE/sherpa-onnx-win-x64" ]; then
+	echo "fetching sherpa-onnx-win-x64 (one-time)…"
+	rm -rf "$SHERPA_CACHE"
+	mkdir -p "$SHERPA_CACHE"
+	( cd "$SHERPA_CACHE" && npm pack sherpa-onnx-win-x64@1.13.6 \
+		--pack-destination "$SHERPA_CACHE" --cache "$SHERPA_CACHE/npm-cache" >/dev/null 2>&1 )
+	tar -xzf "$SHERPA_CACHE"/sherpa-onnx-win-x64-*.tgz -C "$SHERPA_CACHE"
+	mv "$SHERPA_CACHE/package" "$SHERPA_CACHE/sherpa-onnx-win-x64"
+	rm -f "$SHERPA_CACHE"/*.tgz
+fi
+
+rm -rf "$APP"
+mkdir -p "$APP"
+
+# 1. Windows Electron runtime (electron.exe + resources + locales + DLLs).
+unzip -q "$ELECTRON_ZIP" -d "$APP"
+mv "$APP/electron.exe" "$APP/DoTouchAI.exe"
+
+# 2. Swap our app for Electron's default shell.
+rm -f "$APP/resources/default_app.asar"
+mkdir -p "$APP/resources/app/assets" "$APP/resources/app/plugins"
+
+cp main.js env.js service.js updater.js plugins.js package.json "$APP/resources/app/"
+cp assets/icon.ico "$APP/resources/app/assets/"
+
+node -e "require('fs').writeFileSync('$APP/resources/app/build.json', JSON.stringify({version:'$VERSION', platform:'win32-x64', builtAt:new Date().toLocaleString('zh-CN',{hour12:false})}, null, 2) + '\n')"
+
+# 3. Bundled plugins (voice suite) — Windows native binary variant.
+VOICE_SRC="/Users/eastudio/Documents/DSWorkSpace"
+DEP_SRC="$HOME/.dsh/profiles/web/node_modules"
+BUNDLED="$APP/resources/app/plugins"
+
+cp -R "$VOICE_SRC/dsh-voice-client" "$BUNDLED/dsh-voice-client"
+rm -rf "$BUNDLED/dsh-voice-client/node_modules"
+cp -R "$VOICE_SRC/dsh-voice-server" "$BUNDLED/dsh-voice-server"
+rm -rf "$BUNDLED/dsh-voice-server/node_modules"
+cp -R "$DEP_SRC/sherpa-onnx-node" "$BUNDLED/sherpa-onnx-node"
+cp -R "$SHERPA_CACHE/sherpa-onnx-win-x64" "$BUNDLED/sherpa-onnx-win-x64"  # .node + DLLs
+cp -R "$DEP_SRC/ws" "$BUNDLED/ws"
+
+node -e "require('fs').writeFileSync('$BUNDLED/manifest.json', JSON.stringify({
+  'dsh-voice-client': { id: 'dsh-voice-client', name: 'dsh-voice-client' },
+  'dsh-voice-server': { id: 'dsh-voice-server', name: 'dsh-voice-server/lib/index-v6.js' },
+  'sherpa-onnx-node': { dep: true },
+  'sherpa-onnx-win-x64': { dep: true },
+  'ws': { dep: true }
+}, null, 2) + '\n')"
+
+echo "app: $(du -sh "$APP" | cut -f1) — exe + plugins: $(ls "$BUNDLED" | tr '\n' ' ')"
+
+# 4. Portable zip (python3 — no resource-fork noise, cross-platform).
+rm -f "$ZIP"
+python3 -c "
+import zipfile, os
+src='$APP'
+with zipfile.ZipFile('$ZIP','w',zipfile.ZIP_DEFLATED, compresslevel=6) as z:
+    for root,dirs,files in os.walk(src):
+        for f in files:
+            p=os.path.join(root,f)
+            arc=os.path.relpath(p, os.path.dirname(src))
+            z.write(p, arc)
+"
+echo "built: $ZIP ($(du -sh "$ZIP" | cut -f1))"

+ 38 - 3
dsh-desktop/app/tools/package.sh

@@ -59,17 +59,52 @@ plutil -replace CFBundleDisplayName -string "DoTouchAI" "$PLIST"
 plutil -replace CFBundleIdentifier -string "local.eastudio.dsh-desktop" "$PLIST"
 plutil -replace CFBundleShortVersionString -string "$VERSION" "$PLIST"
 
-# Ad-hoc signature: no Apple ID needed, silences Gatekeeper's unsigned-binary nag.
-codesign --force --sign - "$APP" >/dev/null 2>&1 || true
+# Ad-hoc signature with --deep: re-signs the top-level AND all nested
+# frameworks/helpers consistently (the Electron dist ships a linker-signed
+# framework whose seal is incomplete; --deep replaces it with a full ad-hoc
+# signature so `codesign --verify --deep --strict` passes). No Apple ID needed;
+# Gatekeeper still requires a one-time bypass on foreign Macs (see 安装说明.txt).
+codesign --deep --force --sign - "$APP" >/dev/null 2>&1 || true
+codesign --verify --deep --strict "$APP" 2>/dev/null || echo "warn: signature did not pass --deep --strict (app may be rejected by Gatekeeper on other Macs)"
 
 echo "built: $APP ($(du -sh "$APP" | cut -f1))"
 
-# Drag-install DMG: the app beside a symlink to /Applications.
+# Drag-install DMG: the app beside a symlink to /Applications, plus a
+# first-run readme (Gatekeeper unblock + Node/dsh install steps for new Macs).
 STAGING="../.dmg-staging"
 rm -rf "$STAGING"
 mkdir -p "$STAGING"
 cp -R "$APP" "$STAGING/"
 ln -sfn /Applications "$STAGING/Applications"
+cat > "$STAGING/安装说明.txt" << 'EOF'
+DoTouchAI 安装说明
+====================
+
+一、安装
+  把 DoTouchAI 拖到右侧 Applications 文件夹。
+
+二、首次打开(重要)
+  本应用未经 Apple 开发者签名公证,拷到新 Mac 后首次打开会被拦截
+  (提示"无法打开"或"已损坏"),属正常现象,任选其一解除:
+  ▸ 方法 A:在应用程序文件夹里右键 DoTouchAI → 打开 → 再点"打开"(只需一次)
+  ▸ 方法 B(推荐):终端执行,清除全部标记
+        xattr -cr "/Applications/DoTouchAI.app"
+    注:macOS 14.4+/26 会同时打 com.apple.quarantine 和 com.apple.provenance
+    两个标记,只去 quarantine 不够("还是打不开"),必须 -cr 全清或分别去两个
+  ▸ 方法 C:双击被拦后,系统设置 → 隐私与安全性 → 底部点"仍要打开"
+
+三、新 Mac 需要先装 Node.js 与 dsh(一次性)
+  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. 启动 DoTouchAI:若弹出"未检测到 dsh"引导,点"复制安装命令"照做即可;
+     语音插件已内置,会自动安装,无需其他操作。
+
+四、验证
+  托盘菜单 → 关于 DoTouchAI:能看到 dsh 版本号即安装成功。
+EOF
 rm -f "$DMG"
 hdiutil create -volname "DoTouchAI $VERSION" -srcfolder "$STAGING" -ov -format UDZO "$DMG" >/dev/null
 rm -rf "$STAGING"

+ 14 - 2
dsh-desktop/app/updater.js

@@ -18,19 +18,31 @@ const { spawn } = require("node:child_process");
 const { NPM_BIN, childEnv } = require("./env");
 
 const PKG = "@deepseek-ai/dsh";
+const IS_WIN = process.platform === "win32";
 
 /** Run npm with an isolated cache; never rejects, always reports ok + output. */
 function runNpm(args, cacheDir, timeoutMs = 300_000) {
 	return new Promise((resolve) => {
-		const child = spawn(NPM_BIN, [...args, "--cache", cacheDir, "--no-audit", "--no-fund"], {
+		// On Windows npm is a .cmd shim — run via cmd.exe (shell:true) using the
+		// bare name so the augmented PATH resolves it (spaces-safe).
+		const bin = IS_WIN ? "npm" : NPM_BIN;
+		const child = spawn(bin, [...args, "--cache", cacheDir, "--no-audit", "--no-fund"], {
 			stdio: ["ignore", "pipe", "pipe"],
 			env: childEnv(),
+			shell: IS_WIN,
+			windowsHide: true,
 		});
 		let out = "";
 		const eat = (chunk) => { out += chunk.toString(); };
 		child.stdout.on("data", eat);
 		child.stderr.on("data", eat);
-		const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } }, timeoutMs);
+		const timer = setTimeout(() => {
+			if (IS_WIN) {
+				try { const { execFileSync } = require("node:child_process"); execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }); } catch { /* gone */ }
+			} else {
+				try { child.kill("SIGKILL"); } catch { /* gone */ }
+			}
+		}, timeoutMs);
 		child.on("error", (err) => { clearTimeout(timer); resolve({ ok: false, error: err.message, out }); });
 		child.on("exit", (code) => { clearTimeout(timer); resolve({ ok: code === 0, code, out }); });
 	});

+ 115 - 0
dsh-desktop/install.sh

@@ -0,0 +1,115 @@
+#!/bin/bash
+# DoTouchAI 一键安装脚本(macOS)
+#
+# 用法(收件方只需这一行):
+#   curl -fsSL https://raw.githubusercontent.com/USERNAME/REPO/main/install.sh | bash
+#
+# 无需 Apple 签名公证也能在新版 macOS(含 26)上正常安装打开:
+# 下载 DMG → 校验 sha256 → 挂载 → ditto 拷贝到 /Applications
+# → xattr -cr 清除 com.apple.quarantine 与 com.apple.provenance → 启动
+#
+# 发布新版时只需改下面 BASE_URL / VERSION / SHA256 三项即可。
+set -euo pipefail
+
+# ── 配置 ────────────────────────────────────────────────────────────────
+# DMG 托管在 Git releases(dist/ 被 gitignore 且超 100M 不入 git 树)。
+# 默认指向你的自建 Gitea(需先把仓库设为「公开」,否则外人 curl 会 404):
+#   Gitea raw:  http://47.112.106.152:10880/Evan/EvanAgent/raw/branch/master/dsh-desktop/install.sh
+#   Gitea rel:  http://47.112.106.152:10880/Evan/EvanAgent/releases/download/v0.4.1/DoTouchAI-0.4.1-arm64.dmg
+# 若改用 GitHub 公开仓库分发(HTTPS、更受信任),只需把下面 BASE_URL 改成:
+#   https://github.com/<user>/<repo>/releases/download
+BASE_URL="${DOTOUCHAI_BASE_URL:-http://47.112.106.152:10880/Evan/EvanAgent/releases/download}"
+VERSION="${DOTOUCHAI_VERSION:-0.4.1}"
+# 0.4.1 arm64 DMG 的 sha256(发布新版后用 shasum -a 256 更新):
+SHA256_ARM="${DOTOUCHAI_SHA256:-05941c6b1f95780e85d0dad8176c80e355756344bcf4641589da9c632a4eca48}"
+
+# ── 架构检测 ─────────────────────────────────────────────────────────────
+ARCH="$(uname -m)"
+case "$ARCH" in
+	arm64)
+		DMG="DoTouchAI-${VERSION}-arm64.dmg"
+		EXPECTED_SHA="$SHA256_ARM"
+		;;
+	x86_64)
+		echo "✗ 暂未提供 Intel (x86_64) Mac 版本,目前仅 arm64。"
+		echo "  新版 Mac(M1/M2/M3/M4…)均为 arm64;如确为 Intel 机器,请反馈以补 x64 构建。"
+		exit 1
+		;;
+	*)
+		echo "✗ 不支持的架构:$ARCH"
+		exit 1
+		;;
+esac
+
+URL="${BASE_URL}/v${VERSION}/${DMG}"
+APP_NAME="DoTouchAI"
+APP_DST="/Applications/${APP_NAME}.app"
+
+# ── 临时资源 ─────────────────────────────────────────────────────────────
+TMP_DMG="$(mktemp -t dotouchai).dmg"
+MNT="$(mktemp -d -t dotouchai-mnt)"
+cleanup() {
+	hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || true
+	rmdir "$MNT" 2>/dev/null || true
+	rm -f "$TMP_DMG"
+}
+trap cleanup EXIT
+
+echo "→ 下载 ${DMG} …"
+echo "  ${URL}"
+curl -fSL "$URL" -o "$TMP_DMG"
+
+echo "→ 校验完整性(sha256)…"
+ACTUAL_SHA="$(shasum -a 256 "$TMP_DMG" | cut -d' ' -f1)"
+if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
+	echo "✗ 校验和不匹配"
+	echo "  期望:$EXPECTED_SHA"
+	echo "  实际:$ACTUAL_SHA"
+	echo "  下载可能不完整或被篡改,已中止。"
+	exit 1
+fi
+echo "  ✓ 校验通过"
+
+echo "→ 挂载安装包 …"
+hdiutil attach "$TMP_DMG" -nobrowse -mountpoint "$MNT" -quiet
+
+APP_SRC="$MNT/${APP_NAME}.app"
+if [ ! -d "$APP_SRC" ]; then
+	echo "✗ 安装包内未找到 ${APP_NAME}.app"
+	exit 1
+fi
+
+# 退出可能正在运行的旧实例(用 osascript 精确退出,避免误杀其他 Electron 应用)
+osascript -e "tell application \"${APP_NAME}\" to quit" 2>/dev/null || true
+sleep 1
+
+# 替换旧版本
+if [ -d "$APP_DST" ]; then
+	echo "→ 移除旧版本 …"
+	rm -rf "$APP_DST"
+fi
+
+echo "→ 拷贝到 /Applications …"
+# ditto 保留 bundle 结构与元数据,优于 cp
+if ! ditto "$APP_SRC" "$APP_DST"; then
+	echo "✗ 拷贝失败。若提示权限不足,请用管理员重试:"
+	echo "  sudo curl -fsSL <脚本URL> | sudo bash"
+	exit 1
+fi
+
+echo "→ 清除 Gatekeeper 隔离标记(quarantine + provenance)…"
+xattr -cr "$APP_DST"
+
+echo "→ 卸载安装包 …"
+hdiutil detach "$MNT" -quiet
+
+echo ""
+echo "✓ 安装完成:${APP_DST}"
+echo "  双击即可启动,或终端执行:open -a ${APP_NAME}"
+echo ""
+
+# 直接启动(除非显式要求不启动)
+if [ -z "${DOTOUCHAI_NO_LAUNCH:-}" ]; then
+	echo "→ 启动 ${APP_NAME} …"
+	open -a "$APP_NAME"
+fi