// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (c) 2026 开发者猫叔 (DeveloperCatUncle) · 商业使用需授权:COMMERCIAL-LICENSE.md "use strict"; /** * 打安装包的配置。跑 npm run dist:mac / npm run dist:win。 * * 三个不能改的决定,改之前先看理由: * * 1) asar: false —— 必须关。tools.js 的 run_node 会把 node_modules 软链进 * workspace/.tmp 好让生成的脚本 require 到依赖;asar 是个压缩包不是真目录, * 软链过去指向一个文件系统上不存在的路径,run_node 会直接失效。 * 代价是应用包体积大、文件多,换的是「装完就能跑代码」这个核心能力。 * * 2) 技能只打包 git 跟踪的那些。本机 skills/ 下还躺着几个第三方技能(没许可证、 * 或者查不到上游),它们在 .gitignore 里,不该跟着安装包分发出去。 * 这里直接问 git 要清单,而不是手写一份——手写的那份迟早跟 .gitignore 对不上。 * * 3) 不签名、不公证(identity: null)。没有 Apple 开发者证书,硬签不了。 * 后果是用户第一次打开会被 Gatekeeper 拦,README 里写了怎么放行。 * 真要消掉这一步,得买证书 + 配 APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD 走 notarize。 */ // identity: null 拦不住签名——electron-builder 会自动去钥匙串里翻可用证书, // 本机有一张个人的 Apple Development 证书,它就直接拿来签了。公开发的包不该带上任何人的 // 开发者身份(证书里有真实 Apple ID),而且 Development 证书在别人机器上照样过不了 Gatekeeper。 // 只有这个环境变量能真正关掉自动查找,关掉之后走 ad-hoc 签名(签名标识为 "-"), // 这也是 Apple Silicon 上应用能启动的最低要求。放在配置文件里是为了 mac/Windows 都生效。 process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; const { execSync } = require("child_process"); const fs = require("fs"); const path = require("path"); /** 只收 git 跟踪的内置技能;拿不到 git(比如下的是源码 zip)就退回全带上 */ function skillPatterns() { let names = []; try { const out = execSync("git ls-files skills", { cwd: __dirname, encoding: "utf8" }); names = [...new Set(out.split("\n").filter(Boolean).map((p) => p.split("/")[1]).filter(Boolean))]; } catch {} if (!names.length) { console.warn("[打包] 拿不到 git 清单,skills/ 全量打包——发布前确认里面没有不该分发的第三方技能"); return ["skills/**/*"]; } console.log(`[打包] 内置技能 ${names.length} 个:${names.join(", ")}`); return names.map((n) => `skills/${n}/**/*`); } /** * 打完包重新做一次 ad-hoc 签名。 * 不做的话 codesign --verify 报「code has no resources but signature indicates they must be present」: * Electron 的二进制本身带一个 linker-signed 的 ad-hoc 签名,我们往包里塞了 app/ 之后那个签名就对不上了。 * 本机双击可能还能开,但用户从网上下下来的包带 quarantine 标记,Gatekeeper 会直接判「已损坏」。 * 签名标识用 "-" 就是 ad-hoc:不需要任何证书,只是让包内容自洽。 * 它替代不了 Developer ID + 公证——用户首次打开仍要手动放行,README 里写了步骤。 */ /** 包里 app/ 在哪:mac 埋在 .app 里,Windows/Linux 在 resources/ 下。找不到就报出找过哪些路径 */ function findAppDir(ctx) { const cands = [ path.join(ctx.appOutDir, ctx.packager.appInfo.productFilename + ".app", "Contents", "Resources", "app"), path.join(ctx.appOutDir, "resources", "app"), path.join(ctx.appOutDir, "Resources", "app"), ]; const hit = cands.find((d) => fs.existsSync(path.join(d, "package.json"))); if (!hit) throw new Error("[打包] 找不到包里的 app/ 目录,完整性核对没法做。找过:\n" + cands.map((c) => " - " + c).join("\n")); return hit; } async function afterPack(ctx) { // 闸门全过了再签名:缺文件、依赖 require 不起来、瘦身没生效—— // 任何一条都该在这里红掉,别把一个必定打不开的包签得漂漂亮亮发出去 const gate = require("./scripts/check-package-files"); const appDir = findAppDir(ctx); // mermaid / echarts 只带了单文件,被删掉的那批依赖的许可证原文汇总补写进去(核对时要查它在不在) require("./scripts/slim-deps").writeLicenseDigests(appDir); gate.assertPackComplete(appDir); await gate.assertDepsRequirable(appDir); gate.assertSlimmed(appDir); gate.assertBundlesSlim(appDir); assertSandboxHelper(ctx, appDir); await adhocSign(ctx); } /** * Windows 包里得有本架构的系统沙箱小助手(native/bin,发版流水线现编):缺了,用户机器上沙箱就立不起来。 * 另一个架构那份顺手删掉,用不上 */ function assertSandboxHelper(ctx, appDir) { if (ctx.electronPlatformName !== "win32") return; const arch = { 1: "x64", 3: "arm64" }[ctx.arch]; // electron-builder 的 Arch:x64 = 1,arm64 = 3 if (!arch) throw new Error(`[打包] 认不出的架构 ${ctx.arch},核不了沙箱小助手`); const dir = path.join(appDir, "native", "bin"); const name = `owb-sandbox-${arch}.exe`; if (!fs.existsSync(path.join(dir, name))) throw new Error(`[打包] 包里没有沙箱小助手 native/bin/${name}:先跑 npm run build:sandbox(要装 Go)`); for (const f of fs.readdirSync(dir)) if (f !== name) fs.rmSync(path.join(dir, f), { force: true }); console.log(`[打包] 沙箱小助手:native/bin/${name}`); } async function adhocSign(ctx) { if (ctx.electronPlatformName !== "darwin") return; const app = require("path").join(ctx.appOutDir, ctx.packager.appInfo.productFilename + ".app"); execSync(`codesign --force --deep --sign - ${JSON.stringify(app)}`, { stdio: "inherit" }); execSync(`codesign --verify --deep --strict ${JSON.stringify(app)}`, { stdio: "inherit" }); console.log("[打包] ad-hoc 签名通过:" + app); } module.exports = { afterPack, appId: "com.catcatuncle.openworkbuddy", productName: "OpenWorkBuddy", copyright: "Copyright © 2026 CatCatUncle", asar: false, // 见文件头 (1) directories: { output: "dist", buildResources: "build" }, // 白名单:只有列出来的才进包。用户数据(data/ workspace/ config.json backups/ .tmp/ // plugins/ eval/runs/)一个都不能进——那是本机的账号和聊天记录,装机态它们在 ~/OpenWorkBuddy files: [ // ⚠️ "*.js" 只匹配顶层,不含子目录。v0.1.1 就是漏了当时 engines 目录那 7 个文件:装机后 // server.js 在 require 引擎时抛 MODULE_NOT_FOUND,端口没人监听、窗口不亮, // 用户看到的是「双击没反应 / 任务管理器有进程但没界面」。顶层只剩几个入口文件, // 其余源码全在 src/ 下,由 "src/**/*" 一条带上;漏了由 afterPack 的完整性闸门当场拦下 // (scripts/check-package-files.js 按 SOURCE_DIRS 逐个文件核)。 "*.js", "!electron-builder.config.js", "!eslint.config.js", // 开发期的 lint 配置,运行时一个字节都不读,"*.js" 会把它顺手带进包 "src/**/*", "public/**/*", // Chrome 扩展:设置 → 浏览器 那颗「打开扩展文件夹」从这儿拷一份到数据目录给 Chrome 加载 "extension/chrome/**/*", "eval/run.js", "eval/tasks.js", "eval/judge.js", "eval/rejudge.js", "eval/custom-sets.js", "eval/blind.js", "experts.json", "config.example.json", "package.json", "LICENSE", "COMMERCIAL-LICENSE.md", // LICENSE 正文里点名了它(哪些路径额外按 MIT)——被引用的文件不进包,等于那段话指向空气 // 桌面包 asar:false,别人的 MIT/Apache 代码原样躺在 node_modules 里发出去了。 // NOTICE.md 是这批第三方署名的唯一一份汇总,它自己不进包 = 分发时没带署名。 "NOTICE.md", "README.md", ...skillPatterns(), // 依赖里那些「运行时一个字节都不读」的东西,不该进用户的下载包。 // 量过:生产依赖 280 MB / 16809 个文件里,source map 占 116.8 MB、类型声明占 35 MB。 // Windows 免安装版每次启动都要把整包解压到 %TEMP%,还要被 Defender 逐个扫, // 文件越多首次启动越久——issue #1「任务管理器里有进程、屏幕上没窗口」就是等在这儿。 // node_modules 不在上面的白名单里(electron-builder 总是自动带上生产依赖), // 所以这里只能用排除式写法。 "!node_modules/**/*.map", // devtools 才读的源码映射 "!node_modules/@types/**", // TypeScript 类型包,编译期产物 "!node_modules/**/*.d.ts", "!node_modules/**/*.d.mts", "!node_modules/**/*.d.cts", // ⚠️ 按目录名删(test/doc/example 之类)试过,当场炸:@iconify/utils 的运行时代码就住在 // @iconify/utils/lib/emoji/test/ 下、exceljs 的核心在 exceljs/lib/doc/ 下,删完 mermaid 和 exceljs 都 require 不起来。 // 那条规则只省 1 MB,换的是整包打不开——不做。留下的三条只按「运行时永远不读的文件类型」删。 // LICENSE / *.md 一律留着:MIT 之类的许可证要求随分发附上原文,省这几 MB 不值当 // // mermaid / echarts 运行时各只读一个打包好的单文件,其余源码和只被它俩用到的依赖(d3、cytoscape、zrender……) // 一个字节都不读,却占了 node_modules 一半的文件数。删哪些由 scripts/slim-deps.js 按 package-lock 现算, // 被删依赖的许可证由 afterPack 汇总写回,再由 assertBundlesSlim 核对删得对不对 ...require("./scripts/slim-deps").excludePatterns(), ], mac: { fileAssociations: [{ ext: require("./src/desktop/open-files").EXTENSIONS, name: "OpenWorkBuddy Document", role: "Editor", rank: "Alternate" }], category: "public.app-category.productivity", icon: "build/icon.icns", identity: null, // 见文件头 (3) target: [ { target: "dmg", arch: ["arm64", "x64"] }, { target: "zip", arch: ["arm64", "x64"] }, ], artifactName: "${productName}-${version}-mac-${arch}.${ext}", extendInfo: { // 这些是 macOS 的权限说明弹窗文案。截图/录屏能力和「打开所在位置」会摸到这些 NSDesktopFolderUsageDescription: "OpenWorkBuddy 需要访问桌面来读写你交给它的文件", NSDocumentsFolderUsageDescription: "OpenWorkBuddy 需要访问文稿来读写你交给它的文件", NSDownloadsFolderUsageDescription: "OpenWorkBuddy 需要访问下载文件夹来读写你交给它的文件", // 任务里会用 osascript 操作别的应用(安全模块也靠 System Events 数进程)。没有这条说明, // macOS 要么不弹授权框直接拒,要么弹一个空白理由的框,用户不知道该不该点允许 NSAppleEventsUsageDescription: "OpenWorkBuddy 执行你交代的任务时,需要操作访达等其他应用", // 下面两条 Electron 自带英文默认值(This app needs access to …),中文用户看到的是一句没头没尾的英文。 // 现在界面上没有用到麦克风/摄像头的地方,只有 agent 做出来的网页之类真要用时才会问;说清楚拒了也不碍事。 // 开发壳(scripts/make-mac-app.sh)直接读这三条写进 Info.plist,改这里一处就够 NSMicrophoneUsageDescription: "OpenWorkBuddy 只在你用到录音功能时才用麦克风,不同意也不影响其他功能", NSCameraUsageDescription: "OpenWorkBuddy 只在你用到拍照或录像功能时才用摄像头,不同意也不影响其他功能", }, }, dmg: { title: "${productName} ${version}", // 窗口尺寸 = build/background.png 的尺寸(540×420,见 scripts/gen-dmg-bg.py)。 // 背景图不用在这儿声明:build/ 下有 background.png 就自动认(dmgUtil.computeBackground), // 同名 @2x 存在时会被 tiffutil 合成双分辨率。 // 图标中心压在 y=178:下面 288 那条分隔线以下留给「第一次打开被拦了怎么办」, // 图标下面那行文件名(Finder 自己画的)刚好落在两者之间,不会压字。 contents: [ { x: 140, y: 178, type: "file" }, { x: 400, y: 178, type: "link", path: "/Applications" }, ], iconSize: 120, }, win: { publish: [{ provider: "github", owner: "CatCatUncle", repo: "openworkbuddy" }], icon: "build/icon.ico", // 系统沙箱小助手只有 Windows 用(跟上面那份 files 合在一起算) files: ["native/bin/*.exe"], target: [ // 纯 JS 依赖、没有原生模块,arm64(骁龙 X / Surface Pro X 一类)直接多打一份 { target: "nsis", arch: ["x64", "arm64"] }, { target: "portable", arch: ["x64", "arm64"] }, ], // 多架构 nsis 会合成一个安装包(${arch} 为空),装的时候自动选架构; // 名字必须和 portable 分开,不然 v0.1.0 那次两种 .exe 撞名,文档指的「安装包」其实是免安装版 artifactName: "${productName}-${version}-win-${arch}.${ext}", }, portable: { artifactName: "${productName}-${version}-win-${arch}-portable.${ext}", // NSIS 系的目标在多架构下默认还会额外产一个「双架构合体包」:v0.1.6 的 Release 里 // 那个 248 MB 的 OpenWorkBuddy-0.1.6-win-portable.exe 就是它(= x64 120MB + arm64 127MB 摞一起)。 // 免安装版本来就要每次启动解压整包,合体版等于让用户白等一倍时间解压另一个架构用不上的文件, // 而且没有任何文档指向它,纯粹是下载页上一个更大更诱人的错误选项。关掉,只留两个按架构分开的。 // (安装版 nsis 那个合体是要的:artifactName 里没有 ${arch},只会产一个,装的时候自动选架构。) buildUniversalInstaller: false, // 每次启动解压到各自的 %TEMP%\nsXXXX.tmp\app,不再共用一个构建时定死的目录。 // 共用时第二次双击会先把那个目录整个删掉再解压:第一份正开着的代码当场没了, // 解压到一半又双击,两份同时在一个目录里写和删。代价是被强杀时临时目录来不及清, // 下次启动由 portable-temp.js 收拾。注意只能写 true:false 在 electron-builder 里等同不写。 unpackDirName: true, // 解压那一两分钟屏幕上原来什么都没有,用户以为没点上又双击。图由 scripts/gen-portable-splash.py 生成 splashImage: "build/portable-splash.bmp", }, nsis: { artifactName: "${productName}-${version}-win-setup.${ext}", // 向导式:先问「只给我装 / 给这台电脑所有人装」,再让选装在哪个盘哪个文件夹,装的时候有进度条, // 最后一页勾着「运行 OpenWorkBuddy」。以前是一键装:双击后一声不吭装进 %LOCALAPPDATA%\Programs、 // 装完自己启动,用户既选不了位置,也看不出装没装上——报上来就是「双击了没反应」。 // · 只给自己装不要管理员权限;给所有人装(默认 Program Files)会弹一次 UAC。装哪儿都行: // 应用目录只读不写,用户数据一律在 ~/OpenWorkBuddy(paths.js DATA_DIR)。 // · 选了 D:\ 这种盘根,装的时候自动补一层 OpenWorkBuddy 文件夹,不会把一万多个文件撒在盘根。 // · 升级时默认装回上次那个位置(注册表 InstallLocation)。一键装那批老用户装在 // %LOCALAPPDATA%\Programs\openworkbuddy,跟新默认的 …\OpenWorkBuddy 只差大小写,Windows 上是同一个文件夹。 oneClick: false, perMachine: false, allowToChangeInstallationDirectory: true, runAfterFinish: true, createDesktopShortcut: true, createStartMenuShortcut: true, shortcutName: "OpenWorkBuddy", // 卸载时不碰 ~/OpenWorkBuddy:那里面是用户的工作区和成果文件,卸个应用不该把人家的 // PPT 一起删了。要彻底清干净得手动删那个目录,README 里写了 deleteAppDataOnUninstall: false, }, // 这里原来有一段 linux / AppImage 配置,删了。它从来没在流水线上跑过一次 // (release.yml 的 matrix 只有 macOS 和 Windows,上传通道也只收 dmg/zip/exe), // 开发机是 Mac 也造不出来——等于一份没人验证过的配置,却让读代码的人以为有 Linux 安装包。 // Linux 上的既定路线是跑源码:install.sh 开头就写着「一键安装(macOS / Linux)」, // 服务端还有 Docker。真要恢复得四处一起改:matrix 补 ubuntu 腿、upload 的 path 补 *.AppImage // (那步是 if-no-files-found: error,不补必红)、README 下载表补一行、Release 正文补一行。 };