diff --git a/README.md b/README.md index 003e5e2..7a3acf1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **为 ZCode 桌面版注入一键式提示词增强(Prompt Enhancement)** [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Platform: Windows](https://img.shields.io/badge/Platform-Windows-blue.svg)]() +[![Platform: Windows + macOS](https://img.shields.io/badge/Platform-Windows%20%2B%20macOS-blue.svg)]() [![Dependencies: Zero](https://img.shields.io/badge/Dependencies-Zero-green.svg)]() 在输入框旁注入一颗星芒按钮 ✨ —— 点击把当前草稿发给模型,改写得更清晰后回填,检查后发送,支持撤销。 @@ -40,13 +40,13 @@ ZCode 桌面版是 Electron 应用,UI 为 Chromium 渲染的 Web DOM,但没有官方用户脚本或扩展机制。ZCode+ 通过 Chrome DevTools Protocol 实现非侵入注入: ``` -桌面「ZCode+」入口(vbs 无窗口启动) - └→ 本地控制器(常驻 Node 进程,凭据仅存于此) +桌面「ZCode+」入口(Windows: 快捷方式/vbs 无窗口启动;macOS: ZCode+.app 双击启动,可驻 Dock) + └→ 本地控制器(常驻 Node 进程,凭据仅存于此;单实例锁,重复点击只聚焦已运行的 ZCode+) ├─ 分配空闲调试端口(默认 9333,占用自动顺延 9334-9350,启动前 bind 检测) - ├─ 以 --remote-debugging-port 拉起 ZCode.exe(不改安装目录、不碰签名) + ├─ 以 --remote-debugging-port 拉起 ZCode(Windows: ZCode.exe;macOS: ZCode.app;不改安装目录、不碰签名) ├─ CDP 向页面注入增强脚本(页面刷新/新窗口自动重注入) ├─ 页面脚本经 Runtime binding 发送当前草稿 → 控制器调用模型 → 回传 - └─ 页面回填(回填前校验草稿未变,改稿/切任务时结果保留不覆盖) + └─ 页面回填(受信全选按键 Windows Ctrl+A / macOS Cmd+A;回填前校验草稿未变,改稿/切任务时结果保留不覆盖) ``` 关键设计: @@ -64,9 +64,11 @@ ZCode 桌面版是 Electron 应用,UI 为 Chromium 渲染的 Web DOM,但没 - Electron 远程调试(`--remote-debugging-port`) - 图标与安装器同为纯 Node 实现(PNG 解码 → ICO 打包) -## 安装(Windows) +## 安装 -### 方式一:下载发行包(推荐,零依赖) +### Windows + +#### 方式一:下载发行包(推荐,零依赖) 1. 从 [Releases](../../releases) 下载 `ZCodePlus-vX.Y.Z.zip` 2. 解压到任意目录(推荐 `%LOCALAPPDATA%\ZCodePlus`) @@ -74,7 +76,7 @@ ZCode 桌面版是 Electron 应用,UI 为 Chromium 渲染的 Web DOM,但没 前置条件:已安装 ZCode 桌面版。首次运行自动探测安装位置(ZCode+ 所在目录及上级、各盘符 `\zcode` 目录、标准安装位置、PATH);探测失败会弹窗引导,在安装目录的 `zcode-plus-config.json` 中手动填写 `zcodePath` 即可(首次运行自动生成该文件)。 -### 方式二:从源码运行(开发者) +#### 方式二:从源码运行(开发者) ```bash git clone <本仓库地址> @@ -84,6 +86,23 @@ node install.mjs # 部署到 %LOCALAPPDATA%\ZCodePlus 并创建桌面快 或前台调试模式:`node controller.mjs`(控制台直接看日志)。 +### macOS + +前置条件:已安装 ZCode 桌面版 + [Node.js](https://nodejs.org) 18+(推荐 22+)。 + +```bash +git clone <本仓库地址> +cd zcode-plus +node install.mjs # 部署到 ~/Library/Application Support/ZCodePlus +``` + +安装后获得真正的应用入口(无终端窗口,图标为 ZCode 原版反色:白底黑 Z): + +- **桌面「ZCode+.app」** 与 **`~/Applications/ZCode+.app`**(启动台可见):双击即启动 ZCode+ 并自动注入,可拖入 Dock 常驻 +- 重复点击入口不会重复注入:控制器单实例锁,只把已运行的 ZCode+ 窗口带到前台 +- 自动探测 `/Applications`、`~/Applications` 与 Spotlight 索引中的 `ZCode.app`;失败时弹窗引导编辑 `zcode-plus-config.json`(支持 `.app` 包路径或内部可执行文件路径) +- 前台调试模式:`node controller.mjs`,或运行安装目录内 `ZCode+.command`(Terminal 可见日志) + ## 使用 1. 通过「ZCode+」入口启动(原版 ZCode 运行中会弹窗询问是否重启) @@ -102,7 +121,7 @@ node install.mjs # 部署到 %LOCALAPPDATA%\ZCodePlus 并创建桌面快 | 字段 | 说明 | |---|---| -| `zcodePath` | ZCode.exe 完整路径;留空 `""` 表示自动探测;推荐正斜杠写法 `E:/zcode/ZCode.exe`,反斜杠需写成 `\\` | +| `zcodePath` | ZCode 路径;留空 `""` 表示自动探测。Windows 填 `ZCode.exe` 完整路径(推荐正斜杠 `E:/zcode/ZCode.exe`,反斜杠需 `\\`);macOS 填 `.app` 包(如 `/Applications/ZCode.app`)或内部可执行文件 | | `port` | 调试端口,默认 9333;被占用自动顺延 9334-9350 | 优先级:环境变量 `ZCODE_PLUS_ZCODE_PATH` > 配置文件 `zcodePath` > 自动探测。配置了 `zcodePath` 但路径无效时会明确报错,不会静默回退。 @@ -111,10 +130,12 @@ node install.mjs # 部署到 %LOCALAPPDATA%\ZCodePlus 并创建桌面快 | 现象 | 处理 | |---|---| -| 提示"未找到 ZCode.exe" | 按弹窗指引编辑安装目录下 `zcode-plus-config.json`,把 `zcodePath` 填为 ZCode.exe 完整路径(推荐正斜杠,如 `E:/zcode/ZCode.exe`),保存后重试 | +| 提示"未找到 ZCode"(Windows: ZCode.exe / macOS: ZCode.app) | 按弹窗指引编辑安装目录下 `zcode-plus-config.json`,把 `zcodePath` 填为 ZCode 路径(Windows 示例 `E:/zcode/ZCode.exe`;macOS 示例 `/Applications/ZCode.app`),保存后重试 | | 按钮不出现 | 确认从「ZCode+」入口启动(原版无 CDP 通道无注入);等待 2-3 秒 | | 增强失败 | 右键按钮 → 设置 → 复制错误信息到社区反馈 | -| 查看日志 | 控制台启动 `ZCodePlus.exe controller.mjs`;日志在安装目录 `zcode-plus.log` | +| 查看日志 | Windows 控制台启动 `ZCodePlus.exe controller.mjs`,macOS 运行安装目录 `ZCode+.command`(前台)或 `node controller.mjs`;日志在安装目录 `zcode-plus.log` | +| macOS 重复点击 ZCode+.app | 正常:单实例锁生效,仅聚焦已运行的 ZCode+ 窗口,不重复注入 | +| macOS 提示未找到 node | node 升级/移动后重跑 `node install.mjs` 重新生成 ZCode+.app(启动器内烘焙 node 路径) | | 端口冲突 | 自动顺延 9334-9350;全占用则启动失败并写日志 | | ZCode 大版本更新后按钮消失 | 页面结构可能变化,更新本仓库 inject.js 后重启 | @@ -128,10 +149,12 @@ node install.mjs # 部署到 %LOCALAPPDATA%\ZCodePlus 并创建桌面快 ## 构建 ```bash -node build-exe.mjs # 生成 dist/ZCodePlus-vX.Y.Z.zip(内嵌官方 Node 的发行包) -node make-icon.mjs # 从 ZCode 原版图标像素级反色生成 ZCode+ 图标 +node build-exe.mjs # 生成 dist/ZCodePlus-vX.Y.Z.zip(内嵌官方 Node 的 Windows 发行包) +node make-icon.mjs # 从 ZCode 原版图标像素级反色生成 ZCode+ 图标(Windows) ``` +macOS 无需打包:`node install.mjs` 源码部署即可(发行包形态目前仅 Windows)。 + ## 许可证与免责声明 ### 开源许可 diff --git a/build-exe.mjs b/build-exe.mjs index 50bd5d8..00f8f5e 100644 --- a/build-exe.mjs +++ b/build-exe.mjs @@ -39,6 +39,10 @@ function ensureOfficialNode() { } function main() { + if (process.platform !== "win32") { + console.error("[跳过] build-exe.mjs 仅构建 Windows 发行包;macOS 用 node install.mjs 源码部署即可。"); + process.exit(1); + } // inject.js 版本必须来自控制器注入(面板显示),不允许再硬编码数字版本 if (/"1\.\d+\.\d+"/.test(fs.readFileSync(path.join(ROOT, "inject.js"), "utf8"))) { throw new Error("inject.js 仍含硬编码版本号(应读 __zcodePlusControllerVersion),构建中止"); diff --git a/controller.mjs b/controller.mjs index ea5f0c9..4c288f9 100644 --- a/controller.mjs +++ b/controller.mjs @@ -7,7 +7,7 @@ ZCode+ 提示词增强控制器(社区移植,非 ZCode 官方产品) - 凭据只存在于本进程内存:不写盘、不进日志、不回传页面 */ -import { spawn, execSync } from "node:child_process"; +import { spawn, execSync, execFileSync } from "node:child_process"; import { createServer } from "node:net"; import fs from "node:fs"; import path from "node:path"; @@ -17,12 +17,14 @@ import { fileURLToPath } from "node:url"; const INSTALL_DIR = path.dirname(fileURLToPath(import.meta.url)); const LOG_FILE = path.join(INSTALL_DIR, "zcode-plus.log"); // 版本单一来源:--version 输出与页面设置面板显示都取这里(build-exe.mjs 也从此解析) -const CONTROLLER_VERSION = "1.2.4"; +const CONTROLLER_VERSION = "1.3.0"; const ZCODE_HOME = process.env.ZCODE_HOME || path.join(os.homedir(), ".zcode"); const CONFIG_FILE = path.join(INSTALL_DIR, "zcode-plus-config.json"); +const LOCK_FILE = path.join(INSTALL_DIR, ".zcode-plus.lock"); const REQUEST_TIMEOUT_MS = 90000; const CDP_BOOT_TIMEOUT_MS = 30000; const PORT_RANGE = [9333, 9350]; +const IS_MAC = process.platform === "darwin"; // 首次运行生成默认配置文件:自动探测失败时用户可在此手动填写 ZCode 路径 function ensureDefaultConfig() { @@ -30,9 +32,15 @@ function ensureDefaultConfig() { const config = { _readme: [ "ZCode+ 配置文件(JSON 格式,不支持注释)", - "zcodePath:ZCode 桌面版 ZCode.exe 的完整路径;留空 \"\" 表示自动探测。", - "自动探测失败时在此填写,推荐用正斜杠,例如 \"E:/zcode/ZCode.exe\";", - "用反斜杠则必须写成双反斜杠,例如 \"E:\\\\zcode\\\\ZCode.exe\"。", + IS_MAC + ? "zcodePath:ZCode 桌面版路径;留空 \"\" 表示自动探测。" + : "zcodePath:ZCode 桌面版 ZCode.exe 的完整路径;留空 \"\" 表示自动探测。", + IS_MAC + ? "自动探测失败时在此填写,支持 .app 包(如 \"/Applications/ZCode.app\")" + : "自动探测失败时在此填写,推荐用正斜杠,例如 \"E:/zcode/ZCode.exe\";", + IS_MAC + ? "或内部可执行文件(如 \"/Applications/ZCode.app/Contents/MacOS/ZCode\")。" + : "用反斜杠则必须写成双反斜杠,例如 \"E:\\\\zcode\\\\ZCode.exe\"。", "port:调试端口,默认 9333;被占用时自动顺延(9334-9350)。", ], zcodePath: "", @@ -60,41 +68,73 @@ function listDriveRoots() { function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } } +// macOS 允许直接填 .app 包路径:自动解析到内部可执行文件(CFBundleExecutable,读取失败缺省 ZCode) +function resolveZcodeExecutable(p) { + if (!IS_MAC || !/\.app\/?$/i.test(String(p))) return p; + try { + if (!fs.statSync(p).isDirectory()) return p; + let name = "ZCode"; + try { + // Info.plist 可能是二进制格式:正则不命中即退回缺省名,不视为错误 + const plist = fs.readFileSync(path.join(p, "Contents", "Info.plist"), "utf8"); + const m = plist.match(/CFBundleExecutable<\/key>\s*([^<]+)<\/string>/); + if (m) name = m[1]; + } catch {} + const inner = path.join(p, "Contents", "MacOS", name); + return isFile(inner) ? inner : p; + } catch { return p; } +} // 分发场景不硬编码安装路径:显式配置优先,其次多来源探测 function findZcodePath() { // 1) 显式配置(环境变量 > 配置文件):意图明确,路径无效时直接报错,不静默回退 const configured = configuredZcodePath(); if (configured) { - if (isFile(configured)) return { path: configured, source: "手动配置" }; - return { path: null, error: `配置的 zcodePath 无效(该路径不是可用的 ZCode.exe):${configured}` }; + const resolved = resolveZcodeExecutable(configured); + if (isFile(resolved)) return { path: resolved, source: "手动配置" }; + return { path: null, error: `配置的 zcodePath 无效(该路径不是可用的 ZCode${IS_MAC ? " 应用" : ".exe"}):${configured}` }; } const candidates = []; const add = (p, source) => { if (p) candidates.push({ p, source }); }; // 2) ZCode+ 所在目录及逐级向上:覆盖「把 ZCode+ 放进 ZCode 安装目录或其子目录」 let cur = INSTALL_DIR; for (let depth = 0; depth < 6 && cur; depth++) { - add(path.join(cur, "ZCode.exe"), "ZCode+ 所在位置"); + if (IS_MAC) add(path.join(cur, "ZCode.app", "Contents", "MacOS", "ZCode"), "ZCode+ 所在位置"); + else add(path.join(cur, "ZCode.exe"), "ZCode+ 所在位置"); const parent = path.dirname(cur); if (parent === cur) break; cur = parent; } - // 3) 各盘符根下的常见目录名(Windows 路径不区分大小写,一种写法即可):覆盖自定义盘自定义目录安装(如 E:\zcode) - for (const root of listDriveRoots()) { - add(path.join(root, "zcode", "ZCode.exe"), "盘符常见位置"); + if (IS_MAC) { + // 3) macOS 标准安装位置 + add(path.join("/Applications", "ZCode.app"), "标准安装位置"); + add(path.join(os.homedir(), "Applications", "ZCode.app"), "标准安装位置"); + // 4) Spotlight 索引:覆盖自定义位置安装(含外接盘);索引未覆盖时不命中,无害 + try { + const out = execSync(`mdfind "kMDItemFSName == 'ZCode.app'"`, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }); + for (const line of out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(0, 10)) add(line, "Spotlight"); + } catch {} + } else { + // 3) 各盘符根下的常见目录名(Windows 路径不区分大小写,一种写法即可):覆盖自定义盘自定义目录安装(如 E:\zcode) + for (const root of listDriveRoots()) { + add(path.join(root, "zcode", "ZCode.exe"), "盘符常见位置"); + } + // 4) Windows 标准安装位置 + add("C:\\Program Files\\ZCode\\ZCode.exe", "标准安装位置"); + add("C:\\Program Files (x86)\\ZCode\\ZCode.exe", "标准安装位置"); + add(path.join(os.homedir(), "AppData", "Local", "Programs", "ZCode", "ZCode.exe"), "标准安装位置"); + // 5) PATH 中的 ZCode.exe(PATH 含失效网络路径时 where 可能变慢,限 5 秒) + try { + const out = execSync("where ZCode.exe", { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }); + add(out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0], "PATH"); + } catch {} } - // 4) Windows 标准安装位置 - add("C:\\Program Files\\ZCode\\ZCode.exe", "标准安装位置"); - add("C:\\Program Files (x86)\\ZCode\\ZCode.exe", "标准安装位置"); - add(path.join(os.homedir(), "AppData", "Local", "Programs", "ZCode", "ZCode.exe"), "标准安装位置"); - // 5) PATH 中的 ZCode.exe(PATH 含失效网络路径时 where 可能变慢,限 5 秒) - try { - const out = execSync("where ZCode.exe", { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }); - add(out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0], "PATH"); - } catch {} const tried = []; for (const { p, source } of candidates) { tried.push(p); - try { if (isFile(p)) return { path: p, source, tried }; } catch {} + try { + const resolved = resolveZcodeExecutable(p); + if (isFile(resolved)) return { path: resolved, source, tried }; + } catch {} } return { path: null, tried }; } @@ -232,6 +272,34 @@ function renderPrompt(draft, enhanceMode, customTemplate) { } // ---- 端口与进程管理 ---- +// 单实例锁:同一安装目录只允许一个控制器常驻。重复点击入口时若控制器还活着, +// 聚焦已运行的 ZCode+ 后退出——否则两条 CDP 通道同时收 binding 会双重增强/双重回填。 +// 返回值:null=已持锁可继续;数字=持锁的存活 pid(调用方应退出) +function acquireSingleInstanceLock() { + try { + const pid = Number(readJson(LOCK_FILE)); + if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) { + process.kill(pid, 0); // 不抛 = 进程存活;ESRCH = 已死 + return pid; + } + } catch (error) { + if (error?.code === "EPERM") return -1; // 进程存在但无权限探测:按存活处理 + // ESRCH / 文件不存在 / 内容损坏:锁已失效,继续抢占 + } + try { fs.writeFileSync(LOCK_FILE, String(process.pid)); } catch {} + const release = () => { + try { if (String(readJson(LOCK_FILE)) === String(process.pid)) fs.rmSync(LOCK_FILE, { force: true }); } catch {} + }; + process.on("exit", release); + // 信号默认终止不保证触发 exit 事件:显式转 process.exit 走清理 + for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0)); + return null; +} +// macOS:把已运行的 ZCode+ 窗口带到前台(新入口点击用户预期是「打开窗口」) +function activateZcodeApp() { + if (!IS_MAC) return; + try { execFileSync("osascript", ["-e", 'tell application "ZCode" to activate'], { timeout: 5000, stdio: "ignore" }); } catch {} +} function portInUse(port) { return new Promise((resolve) => { const srv = createServer(); @@ -278,23 +346,44 @@ async function isZcodeBrowser(port) { } function zcodeProcessesRunning() { try { + if (IS_MAC) { + execSync("pgrep -x ZCode", { stdio: "ignore", timeout: 5000 }); + return true; + } const out = execSync('tasklist /fi "IMAGENAME eq ZCode.exe" /fo csv /nh', { encoding: "utf8" }); return /ZCode\.exe/i.test(out); } catch { return false; } } -// 弹窗文本走 base64(UTF-16LE):规避中文/换行/引号在 cmd→PowerShell 间的多层转义 +// Windows 弹窗文本走 base64(UTF-16LE):规避中文/换行/引号在 cmd→PowerShell 间的多层转义 +// macOS 走 osascript display dialog:参数用 execFileSync 数组传递,不做 shell 转义 function showMessageBox(text, buttons = "OK") { + if (IS_MAC) { + const yesNo = /yes/i.test(buttons); + // AppleScript 字符串字面量不含原生换行:按行拆开用 linefeed 连接 + const literal = String(text).split("\n") + .map((line) => '"' + line.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"') + .join(" & linefeed & "); + const btns = yesNo ? '"否", "是"' : '"好"'; + const script = `display dialog ${literal} with title "ZCode+" ` + + `buttons [${btns}] default button ${yesNo ? '"是"' : '"好"'} with icon caution`; + try { return execFileSync("osascript", ["-e", script], { encoding: "utf8", timeout: 120000 }).trim(); } + catch { return ""; } // 用户按 Esc 取消 / 超时 + } const b64 = Buffer.from(text, "utf16le").toString("base64"); const cmd = `powershell -NoProfile -Command "$t=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${b64}')); Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show($t,'ZCode+','${buttons}','Warning')"`; try { return execSync(cmd, { encoding: "utf8", timeout: 120000 }).trim(); } catch { return ""; } } +// 弹窗「确认」语义的返回值:macOS osascript 输出形如 "button returned:是" +function messageBoxConfirmed(result) { + return IS_MAC ? result.includes("button returned:是") : result.includes("Yes"); +} async function askCloseOriginal() { // 询问是否关闭正在运行的原版 ZCode(用户可能丢未发送草稿,必须显式确认) - return showMessageBox( + return messageBoxConfirmed(showMessageBox( "ZCode 原版正在运行,ZCode+ 需要独占启动。是否关闭原版并以 ZCode+ 重启?", "YesNo", - ).includes("Yes"); + )); } function launchZcode(port, zcodePath) { const child = spawn(zcodePath, [`--remote-debugging-port=${port}`], { @@ -305,7 +394,7 @@ function launchZcode(port, zcodePath) { child.once("error", (error) => { log(`拉起 ZCode 失败: ${safeError(error)}(zcodePath=${zcodePath})`); showMessageBox( - `拉起 ZCode 失败:${safeError(error)}\n\n请检查配置中的 zcodePath 是否指向 ZCode.exe:\n${CONFIG_FILE}`, + `拉起 ZCode 失败:${safeError(error)}\n\n请检查配置中的 zcodePath 是否指向 ZCode${IS_MAC ? " 应用" : ".exe"}:\n${CONFIG_FILE}`, "OK", ); process.exit(1); @@ -684,13 +773,15 @@ async function handleBinding(cdp, sessionId, payload) { const text = await callLLM(cfg, String(msg.draft || ""), msg.enhanceMode, msg.customTemplate, msg.thinking); await cdp.reply(sessionId, id, { ok: true, text }); } else if (msg.type === "insertText") { - // 页面回填富文本编辑器的受信输入通道:先受信 Ctrl+A 全选再插入,镜像真实用户操作。 + // 页面回填富文本编辑器的受信输入通道:先受信全选按键再插入,镜像真实用户操作。 // ZCode 3.11+ 的 Lexical 输入框对程序化选区/合成事件不认账(会丢弃或按内部选区追加), // 只有受信按键走真实输入管线才可靠;本分支不读取任何配置 const text = String(msg.text ?? ""); if (!text || text.length > 100000) throw new Error("无效的 insertText 请求"); - await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 }, sessionId); - await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 }, sessionId); + // 全选修饰键:macOS 为 Cmd(modifiers 4),Windows 为 Ctrl(2);Chromium 修饰键位掩码 Alt=1/Ctrl=2/Meta=4/Shift=8 + const modifiers = IS_MAC ? 4 : 2; + await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers }, sessionId); + await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers }, sessionId); await cdp.send("Input.insertText", { text }, sessionId); await cdp.reply(sessionId, id, { ok: true }); } else if (msg.type === "models") { @@ -722,8 +813,9 @@ async function handleBinding(cdp, sessionId, payload) { } } -// exe(SEA)模式首次运行自动创建桌面快捷方式;node 模式由 install.mjs 创建 +// exe(SEA)模式首次运行自动创建桌面快捷方式;node 模式由 install.mjs 创建(仅 Windows 发行形态) function ensureDesktopShortcut() { + if (process.platform !== "win32") return; let isSea = false; try { isSea = require("node:sea").isSea(); } catch {} if (!isSea) return; @@ -749,21 +841,33 @@ function ensureDesktopShortcut() { // ---- 主流程 ---- async function main() { + // 0) 单实例:控制器已在跑 → 聚焦 ZCode+ 窗口后退出(不重复建 CDP 通道) + const lockOwner = acquireSingleInstanceLock(); + if (lockOwner !== null) { + log(`已有 ZCode+ 控制器在运行 (pid=${lockOwner}),激活窗口后本次启动退出`); + activateZcodeApp(); + process.exit(0); + } let found = findZcodePath(); if (found.error || !found.path) { // 探测失败:引导用户在配置文件中手动填写 zcodePath(编辑保存后自动重试一次) - log(found.error || `未找到 ZCode.exe,已尝试:${found.tried.join("; ")}`); + log(found.error || `未找到 ZCode,已尝试:${found.tried.join("; ")}`); const guide = found.error ? found.error - : `自动探测未找到 ZCode.exe(已尝试 ZCode+ 所在位置、各盘符常见目录、标准安装位置、PATH)。`; + : `自动探测未找到 ZCode${IS_MAC ? " 应用(ZCode.app)" : ".exe"}` + + `(已尝试 ZCode+ 所在位置、${IS_MAC ? "标准安装位置、Spotlight" : "各盘符常见目录、标准安装位置、PATH"})。`; + const example = IS_MAC ? '"zcodePath": "/Applications/ZCode.app"' : '"zcodePath": "E:/zcode/ZCode.exe"'; const choice = showMessageBox( guide + `\n\n请在配置文件中手动填写 zcodePath:\n${CONFIG_FILE}\n\n` - + `示例(推荐正斜杠,反斜杠需写成双反斜杠):\n"zcodePath": "E:/zcode/ZCode.exe"\n\n` - + `是否现在用记事本打开配置文件编辑?(保存后 ZCode+ 自动重试)`, + + `示例:\n${example}\n\n` + + `是否现在用${IS_MAC ? "文本编辑" : "记事本"}打开配置文件编辑?(保存后 ZCode+ 自动重试)`, "YesNo", ); - if (choice.includes("Yes")) { - try { spawn("notepad.exe", [CONFIG_FILE], { detached: true, stdio: "ignore" }).unref(); } catch {} + if (messageBoxConfirmed(choice)) { + try { + if (IS_MAC) spawn("open", ["-t", CONFIG_FILE], { detached: true, stdio: "ignore" }).unref(); + else spawn("notepad.exe", [CONFIG_FILE], { detached: true, stdio: "ignore" }).unref(); + } catch {} // 轮询等待(最长 3 分钟):路径有效即提前继续;用户已保存但路径仍无效/留空也提前结束 // (Win11 记事本可能把文件并入已有窗口进程,不能依赖其进程生命周期) let mtimeBefore = 0; @@ -779,10 +883,10 @@ async function main() { } } if (found.error || !found.path) { - const detail = found.error || `仍未找到 ZCode.exe,已尝试:${found.tried.join("; ")}`; + const detail = found.error || `仍未找到 ZCode${IS_MAC ? " 应用" : ".exe"},已尝试:${found.tried.join("; ")}`; log(detail); showMessageBox( - `启动失败:${found.error || "未找到 ZCode.exe。"}\n\n` + `启动失败:${found.error || `未找到 ZCode${IS_MAC ? " 应用" : ".exe"}。`}\n\n` + `请在配置文件中检查 zcodePath 后重新运行:\n${CONFIG_FILE}\n\n日志:${LOG_FILE}`, "OK", ); @@ -804,7 +908,10 @@ async function main() { log("用户拒绝关闭原版 ZCode,退出"); process.exit(0); } - try { execSync('taskkill /IM ZCode.exe /F', { stdio: "ignore" }); } catch {} + try { + if (IS_MAC) execSync("pkill -x ZCode", { stdio: "ignore", timeout: 5000 }); + else execSync('taskkill /IM ZCode.exe /F', { stdio: "ignore" }); + } catch {} await new Promise((r) => setTimeout(r, 2500)); } // 3) 分配端口(bind 校验,杜绝冲突)并拉起 ZCode+ diff --git a/inject.js b/inject.js index 8d91f4c..34ab685 100644 --- a/inject.js +++ b/inject.js @@ -222,7 +222,7 @@ Icons adapted from Lucide v1.8.0 (Sparkles, LoaderCircle, Undo2, X), ISC License sel.addRange(range); }; let done = false; - // 通道1:控制器受信输入。控制器先发受信 Ctrl+A 全选再插入,镜像真实用户操作。 + // 通道1:控制器受信输入。控制器先发受信全选按键(macOS Cmd+A / Windows Ctrl+A)再插入,镜像真实用户操作。 // ZCode 3.11+ 的 Lexical 输入框对程序化选区与合成事件不认账(丢弃 execCommand、 // 回滚直接 DOM 改写、粘贴按内部选区追加),受信按键走真实输入管线才可靠 if (typeof window.__wbEnhance === "function") { diff --git a/install.mjs b/install.mjs index c0a6c1d..7e1f5fe 100644 --- a/install.mjs +++ b/install.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node /* ZCode+ 安装器: -- 复制 controller / inject / launcher / start 到 %LOCALAPPDATA%\ZCodePlus -- 生成独立图标与桌面快捷方式「ZCode+」(不改动原 ZCode 快捷方式) -- 探测并写入 ZCode.exe 路径与端口偏好 +- Windows:复制 controller / inject / launcher / start 到 %LOCALAPPDATA%\ZCodePlus, + 生成独立图标与桌面快捷方式「ZCode+」(不改动原 ZCode 快捷方式) +- macOS:复制 controller / inject 到 ~/Library/Application Support/ZCodePlus, + 生成可双击的「ZCode+.command」启动器(安装目录 + 桌面) +- 探测并写入 ZCode 路径与端口偏好 重复运行 = 覆盖更新(不影响已保存的页面设置)。 */ import fs from "node:fs"; @@ -11,9 +13,16 @@ import path from "node:path"; import os from "node:os"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { decodePng, invert, resize, encodePng, packIcns } from "./make-icon.mjs"; const SRC = path.dirname(fileURLToPath(import.meta.url)); -const DEST = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "ZCodePlus"); +const IS_MAC = process.platform === "darwin"; +// 版本号唯一来源是 controller.mjs 的 CONTROLLER_VERSION(与 build-exe.mjs 同源) +const VERSION = fs.readFileSync(path.join(SRC, "controller.mjs"), "utf8") + .match(/const CONTROLLER_VERSION = "([^"]+)"/)?.[1] || "0.0.0"; +const DEST = IS_MAC + ? path.join(os.homedir(), "Library", "Application Support", "ZCodePlus") + : path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "ZCodePlus"); const DESKTOP = path.join(os.homedir(), "Desktop"); function listDriveRoots() { @@ -24,8 +33,24 @@ function listDriveRoots() { } return roots; } +// macOS:ZCode 桌面版是 .app 包,存 .app 路径即可(controller 会解析到内部可执行文件) +function findZcodeMac() { + const candidates = [ + "/Applications/ZCode.app", + path.join(os.homedir(), "Applications", "ZCode.app"), + ]; + try { + const out = execSync(`mdfind "kMDItemFSName == 'ZCode.app'"`, { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }); + candidates.push(...out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(0, 10)); + } catch {} + for (const p of candidates) { + try { if (fs.statSync(p).isDirectory()) return p; } catch {} + } + return null; +} // 与 controller.mjs 的探测链保持一致:不硬编码本机路径 function findZcode() { + if (IS_MAC) return findZcodeMac(); const candidates = [ path.join(SRC, "ZCode.exe"), "C:\\Program Files\\ZCode\\ZCode.exe", @@ -44,6 +69,86 @@ function findZcode() { } return null; } +// macOS 双击启动器:经 Terminal 打开(登录 shell,PATH 含 node);前台排错用 +function writeMacLauncher(file, targetDir) { + const script = `#!/bin/bash\n# ZCode+ 启动器(macOS)\ncd "${targetDir.replace(/"/g, '\\"')}"\nexec node controller.mjs\n`; + fs.writeFileSync(file, script, "utf8"); + fs.chmodSync(file, 0o755); +} +// macOS:生成 ZCode+.app 应用包(LSUIElement 后台型:自身无 Dock 图标无窗口,点击即拉起控制器) +// 图标优先用 ZCode 原版图标像素级反色(与 Windows 同源逻辑);失败回退原版 icns +function buildIcns(sourcePng) { + const { rgba, width, height } = decodePng(sourcePng); + invert(rgba); + const types = [["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024]] + .filter(([, size]) => size <= Math.max(width, height)); // 不放大超过源尺寸 + return packIcns(types.map(([type, size]) => ({ type, png: encodePng(resize(rgba, width, height, size, size), size, size) }))); +} +function macAppBundleDir(zcodePath) { + if (/\.app\/?$/i.test(zcodePath)) return zcodePath; + const m = String(zcodePath).match(/^(.*?\.app)\//); + return m ? m[1] : null; // 配置填的是内部可执行文件路径时向上找 .app 根 +} +function buildMacApp(appDir, { targetDir, nodeBin, version, iconPng, iconIcns }) { + fs.rmSync(appDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(appDir, "Contents", "MacOS"), { recursive: true }); + fs.mkdirSync(path.join(appDir, "Contents", "Resources"), { recursive: true }); + // 启动器:优先安装时烘焙的 node 绝对路径;失效回退 PATH 与常见安装位置(Finder 启动无登录 shell PATH) + const q = (s) => String(s).replace(/"/g, '\\"'); + const launcher = [ + "#!/bin/bash", + "# ZCode+ 启动器(macOS 应用包)", + `NODE_BIN="${q(nodeBin)}"`, + 'if [ ! -x "$NODE_BIN" ]; then', + ' NODE_BIN="$(command -v node || true)"', + ' [ -x "$NODE_BIN" ] || NODE_BIN="$(ls -1 /opt/homebrew/bin/node /usr/local/bin/node 2>/dev/null | head -1)"', + "fi", + 'if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then', + ` osascript -e 'display dialog "未找到 node(可能已升级或卸载),请重跑 node install.mjs 重新生成 ZCode+.app" with title "ZCode+" buttons ["好"] default button "好" with icon caution' >/dev/null 2>&1`, + " exit 1", + "fi", + // 后台拉起控制器后本进程立即退出:否则 Launch Services 认为应用常驻, + // 后续点击 .app/Dock 只向本进程发激活事件而不再启动,窗口聚焦逻辑失效。 + // 控制器自身有单实例锁:重复点击 → 锁命中 → 激活已运行的 ZCode+ 后退出。 + `"$NODE_BIN" "${q(targetDir)}/controller.mjs" &`, + "exit 0", + "", + ].join("\n"); + fs.writeFileSync(path.join(appDir, "Contents", "MacOS", "ZCode+"), launcher); + fs.chmodSync(path.join(appDir, "Contents", "MacOS", "ZCode+"), 0o755); + let iconOk = false; + if (iconPng && fs.existsSync(iconPng)) { + try { + fs.writeFileSync(path.join(appDir, "Contents", "Resources", "app.icns"), buildIcns(iconPng)); + iconOk = true; + } catch (error) { + console.log("[提示] 反色图标生成失败,回退 ZCode 原版图标:" + String(error?.message || error)); + } + } + if (!iconOk && iconIcns && fs.existsSync(iconIcns)) { + fs.copyFileSync(iconIcns, path.join(appDir, "Contents", "Resources", "app.icns")); + iconOk = true; + } + let plist = ` + + + + CFBundleNameZCode+ + CFBundleDisplayNameZCode+ + CFBundleIdentifiercom.zcodeplus.launcher + CFBundleExecutableZCode+ + CFBundlePackageTypeAPPL + CFBundleVersion${version} + CFBundleShortVersionString${version} + CFBundleIconFileapp.icns + LSUIElement + LSMinimumSystemVersion11.0 + + +`; + if (!iconOk) plist = plist.replace(' CFBundleIconFileapp.icns\n', ""); + fs.writeFileSync(path.join(appDir, "Contents", "Info.plist"), plist); +} function main() { // 1) node 检查 try { execSync("node -v", { stdio: "pipe" }); } @@ -53,38 +158,64 @@ function main() { } const zcodePath = process.argv[2] || findZcode(); if (!zcodePath || !fs.existsSync(zcodePath)) { - console.error("[错误] 未找到 ZCode.exe。用法:node install.mjs "); - console.error(" 已尝试:源码目录、各盘符 zcode 目录、标准安装位置、PATH。"); + console.error(`[错误] 未找到 ZCode${IS_MAC ? " 应用(ZCode.app)" : ".exe"}。` + + (IS_MAC ? "用法:node install.mjs " : "用法:node install.mjs ")); + console.error(IS_MAC ? " 已尝试:/Applications、~/Applications、Spotlight。" : " 已尝试:源码目录、各盘符 zcode 目录、标准安装位置、PATH。"); process.exit(1); } // 2) 复制文件 fs.mkdirSync(DEST, { recursive: true }); - for (const file of ["controller.mjs", "inject.js", "launcher.vbs", "start-zcode-plus.bat"]) { + const files = IS_MAC ? ["controller.mjs", "inject.js"] : ["controller.mjs", "inject.js", "launcher.vbs", "start-zcode-plus.bat"]; + for (const file of files) { fs.copyFileSync(path.join(SRC, file), path.join(DEST, file)); } - // 3) 生成图标(从 ZCode 原版图标像素级反色:白底黑 Z;源缺失时沿用已生成的 ico) - const zcodeIcon = path.join(path.dirname(zcodePath), "resources", "icon.png"); - if (fs.existsSync(zcodeIcon)) { - execSync(`node "${path.join(SRC, "make-icon.mjs")}" "${zcodeIcon}"`, { stdio: "inherit" }); + // 3) 平台专属:Windows 生成图标 + 快捷方式;macOS 生成 .command 排错入口 + ZCode+.app 应用包 + if (IS_MAC) { + writeMacLauncher(path.join(DEST, "ZCode+.command"), DEST); // 前台排错入口(Terminal 可见日志) + const bundle = macAppBundleDir(zcodePath); + const res = bundle ? path.join(bundle, "Contents", "Resources") : null; + const appDir = path.join(os.homedir(), "Applications", "ZCode+.app"); + buildMacApp(appDir, { + targetDir: DEST, + nodeBin: process.execPath, + version: VERSION, + iconPng: res ? path.join(res, "icon.png") : null, + iconIcns: res ? path.join(res, "icon.icns") : null, + }); + // 桌面入口 = .app 副本(覆盖旧版 .command 入口) + fs.rmSync(path.join(DESKTOP, "ZCode+.command"), { force: true }); + fs.rmSync(path.join(DESKTOP, "ZCode+.app"), { recursive: true, force: true }); + fs.cpSync(appDir, path.join(DESKTOP, "ZCode+.app"), { recursive: true }); } else { - console.log("[提示] 未找到 ZCode 原版图标 " + zcodeIcon + ",沿用已有 ZCodePlus.ico"); + // 生成图标(从 ZCode 原版图标像素级反色:白底黑 Z;源缺失时沿用已生成的 ico) + const zcodeIcon = path.join(path.dirname(zcodePath), "resources", "icon.png"); + if (fs.existsSync(zcodeIcon)) { + execSync(`node "${path.join(SRC, "make-icon.mjs")}" "${zcodeIcon}"`, { stdio: "inherit" }); + } else { + console.log("[提示] 未找到 ZCode 原版图标 " + zcodeIcon + ",沿用已有 ZCodePlus.ico"); + } + fs.copyFileSync(path.join(SRC, "ZCodePlus.ico"), path.join(DEST, "ZCodePlus.ico")); } - fs.copyFileSync(path.join(SRC, "ZCodePlus.ico"), path.join(DEST, "ZCodePlus.ico")); // 4) 配置(探测/手动指定的 zcodePath 落盘,controller 启动时直接使用;zcodePath 留空则启动时重新探测) const config = { _readme: [ "ZCode+ 配置文件(JSON 格式,不支持注释)", - "zcodePath:ZCode 桌面版 ZCode.exe 的完整路径;留空 \"\" 表示自动探测。", - "推荐用正斜杠,例如 \"E:/zcode/ZCode.exe\";用反斜杠则必须写成双反斜杠。", + IS_MAC + ? 'zcodePath:ZCode 桌面版路径;留空 "" 表示自动探测。支持 .app 包(如 "/Applications/ZCode.app")或内部可执行文件。' + : 'zcodePath:ZCode 桌面版 ZCode.exe 的完整路径;留空 "" 表示自动探测。', + IS_MAC + ? null + : '推荐用正斜杠,例如 "E:/zcode/ZCode.exe";用反斜杠则必须写成双反斜杠。', "port:调试端口,默认 9333;被占用时自动顺延(9334-9350)。", - ], + ].filter(Boolean), zcodePath, port: 9333, installedAt: new Date().toISOString(), }; fs.writeFileSync(path.join(DEST, "zcode-plus-config.json"), JSON.stringify(config, null, 2)); - // 5) 桌面快捷方式 ZCode+(独立图标,不动原 ZCode 快捷方式) - const ps = ` + // 5) 桌面入口:Windows 用 .lnk 快捷方式,macOS 用 .command(上方已生成) + if (!IS_MAC) { + const ps = ` $ws = New-Object -ComObject WScript.Shell $lnk = $ws.CreateShortcut('${DESKTOP.replace(/\\/g, "\\") + "\\\\ZCode+.lnk"}') $lnk.TargetPath = 'C:\\\\Windows\\\\System32\\\\wscript.exe' @@ -95,16 +226,23 @@ $lnk.Description = 'ZCode+ 提示词增强版' $lnk.Save() Write-Output 'shortcut created' `.trim(); - fs.writeFileSync(path.join(SRC, "install-shortcut.ps1"), ps); - execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${path.join(SRC, "install-shortcut.ps1")}"`, { stdio: "inherit" }); - fs.rmSync(path.join(SRC, "install-shortcut.ps1"), { force: true }); + fs.writeFileSync(path.join(SRC, "install-shortcut.ps1"), ps); + execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${path.join(SRC, "install-shortcut.ps1")}"`, { stdio: "inherit" }); + fs.rmSync(path.join(SRC, "install-shortcut.ps1"), { force: true }); + } console.log(""); console.log("[完成] ZCode+ 安装到 " + DEST); - console.log(" - 桌面快捷方式:ZCode+(独立图标,原 ZCode 快捷方式不受影响)"); - console.log(" - ZCode 路径:" + zcodePath); + if (IS_MAC) { + console.log(" - 桌面入口:ZCode+.app(双击启动 ZCode+,无终端窗口;可拖入 Dock 常驻)"); + console.log(" - 应用入口:~/Applications/ZCode+.app(启动台可见)"); + console.log(" - ZCode 路径:" + zcodePath); + } else { + console.log(" - 桌面快捷方式:ZCode+(独立图标,原 ZCode 快捷方式不受影响)"); + console.log(" - ZCode 路径:" + zcodePath); + } console.log(" - 调试端口:9333(被占用时自动顺延到 9334-9350)"); - console.log(" - 排错:运行 " + path.join(DEST, "start-zcode-plus.bat") + " 查看控制台;日志见 zcode-plus.log"); + console.log(" - 排错:运行 " + (IS_MAC ? path.join(DEST, "ZCode+.command") : path.join(DEST, "start-zcode-plus.bat")) + " 查看控制台;日志见 zcode-plus.log"); console.log(" - 注意:ZCode 为单实例应用,原版与 ZCode+ 不能同时运行;"); console.log(" 若原版在运行,ZCode+ 会询问是否关闭原版后重启。"); } diff --git a/make-icon.mjs b/make-icon.mjs index ca22cb9..2720d54 100644 --- a/make-icon.mjs +++ b/make-icon.mjs @@ -1,13 +1,14 @@ #!/usr/bin/env node /* ZCode+ 图标生成器:读取 ZCode 原版图标(黑底白 Z 圆角方形),像素级反色生成 -白底黑 Z 版本,缩放打包为多尺寸 ICO。纯 Node(zlib),无第三方依赖。 -用法:node make-icon.mjs [源图标.png] (默认 D:/Zcode/resources/icon.png) +白底黑 Z 版本,缩放打包为多尺寸 ICO(Windows)/ ICNS(macOS)。纯 Node(zlib),无第三方依赖。 +用法(CLI):node make-icon.mjs [源图标.png] (默认 D:/Zcode/resources/icon.png,产物 ZCodePlus.ico) +模块导入:install.mjs 复用 decodePng/invert/resize/encodePng/packIco/packIcns 生成 mac 应用图标 */ import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const OUT = path.join(SCRIPT_DIR, "ZCodePlus.ico"); @@ -179,6 +180,24 @@ function packIco(pngs) { return Buffer.concat([header, dir, ...pngs]); } +// ---- ICNS 打包(macOS,PNG 内嵌;现代类型,10.7+ 系统均支持)---- +// sizes 显式传 { type, size } 列表:由调用方按源图尺寸决定放哪些(不放大超过源尺寸) +function packIcns(entries) { + const parts = []; + for (const { type, png } of entries) { + const entry = Buffer.alloc(8 + png.length); + entry.write(type, 0, "ascii"); + entry.writeUInt32BE(8 + png.length, 4); + png.copy(entry, 8); + parts.push(entry); + } + const body = Buffer.concat(parts); + const header = Buffer.alloc(8); + header.write("icns", 0, "ascii"); + header.writeUInt32BE(8 + body.length, 4); + return Buffer.concat([header, body]); +} + function main() { const { rgba, width, height } = decodePng(SOURCE); // 角点采样报告:确认圆角外是否透明(不透明则反色后是白色直角,需提醒) @@ -193,4 +212,9 @@ function main() { console.log(`注意:源图四角 alpha 最大值 ${maxCorner}(非全透明),图标可能呈方形而非圆角显示`); } } -main(); + +// CLI 直跑才生成 ico;被 import 时不执行(install.mjs 复用函数生成 mac 图标) +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} +export { decodePng, invert, resize, encodePng, packIco, packIcns };