electron-builder.config.js
Rama main · réplica de lectura del repositorio original.
// 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 正文补一行。
};