react-native-flux-desktop-packer 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -1
- package/README.zh-CN.md +27 -1
- package/launcher/main.go +5 -2
- package/lib/brand.js +133 -0
- package/lib/bundle.js +83 -0
- package/lib/config.js +18 -1
- package/lib/icon.js +50 -37
- package/lib/index.js +45 -8
- package/lib/sea.js +298 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Requirements (build machine): Node.js ≥ 18 on Windows. Optional: [Go](https://
|
|
|
54
54
|
| `icon` | PNG embedded as the exe icon (optional; default icon otherwise) |
|
|
55
55
|
| `main` | compiled entry JS; falls back to `package.json`'s `main` |
|
|
56
56
|
| `output` | where the exe lands (default `build`) |
|
|
57
|
-
| `pack` | payload whitelist, default `["dist","node_modules","assets","package.json","app.json"]` |
|
|
57
|
+
| `pack` | payload whitelist, default `["dist","node_modules","assets","package.json","app.json"]`; also `pack.mode` (`folder` default, or `sea`) and `pack.assets` (asset dir, default `assets`) |
|
|
58
58
|
|
|
59
59
|
**2.** Build your app, then pack:
|
|
60
60
|
|
|
@@ -84,6 +84,32 @@ The stage copies your `dist/`, `node_modules/`, `assets/`, `package.json` and `a
|
|
|
84
84
|
|
|
85
85
|
At runtime the launcher sets `FLUX_PACKAGED=1` and `FLUX_APP_DIR=<AppName>`, so the library routes KV/log data to `%APPDATA%\<AppName>\` — no writable-path issues even when the exe sits in `Program Files`.
|
|
86
86
|
|
|
87
|
+
## SEA single-file mode (`pack.mode: "sea"`)
|
|
88
|
+
|
|
89
|
+
The default path above ships the **folder/launcher** payload (a Go exe embedding a zip of `dist/` + `node_modules/` + `node.exe`). On first launch it self-extracts a readable **JS source tree** to `%LOCALAPPDATA%` — which works, but the extracted `dist/**/*.js` and `node_modules/` make it obviously a JavaScript project.
|
|
90
|
+
|
|
91
|
+
Setting `"pack": { "mode": "sea" }` in `app.json` switches to a **Node SEA** (Single Executable Application) build that fixes the optics. The existing `folder` path is untouched — flip the field back to revert.
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
app.json ─▶ [1] esbuild all JS → one minified app.cjs (no dist/ tree, no node_modules)
|
|
95
|
+
─▶ [2] SEA assets app.cjs + native .node (self + skia) + icudtl.dat + image assets embedded in the blob
|
|
96
|
+
─▶ [3] rcedit icon + FileDescription/ProductName/… written onto a node.exe copy (BEFORE injection)
|
|
97
|
+
─▶ [4] postject NODE_SEA_BLOB injected into that exe
|
|
98
|
+
─▶ [5] subsystem PE OptionalHeader.Subsystem 3→2 (GUI) so double-click doesn't flash a console
|
|
99
|
+
─▶ [6] deliver <output>/<Name>-<version>.exe (one file)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
At runtime, `node:sea` runs a bootstrap (the SEA `main`) that self-extracts the embedded natives/assets once to a hidden `%LOCALAPPDATA%\<AppName>\run-<stamp>\`, installs native-resolution hooks (skia via `NAPI_RS_NATIVE_LIBRARY_PATH`, your `.node` via `Module._resolveFilename`), `chdir`s to the extracted asset dir, sets `FLUX_PACKAGED=1` / `FLUX_APP_DIR`, then `createRequire`s the opaque `app.cjs`. The `.extracted` marker makes second launches skip re-extraction. There is **no `node.exe` child process** — Task Manager shows the branded `<App>.exe` name and icon.
|
|
103
|
+
|
|
104
|
+
What you gain: the disk no longer holds a JS source tree — the extracted dir contains a single minified `app.cjs` plus opaque compiled binaries (`.node`/`.dat`) and your image assets.
|
|
105
|
+
|
|
106
|
+
**Requirements:** build machine needs Node.js ≥ 20 with SEA support (uses `--experimental-sea-config`) plus `esbuild` and `postject` (declared as `optionalDependencies`; `rcedit` is optional too — icon/version injection degrades gracefully when absent). Missing `esbuild`/`postject` → the packer errors with the exact `npm i` to run.
|
|
107
|
+
|
|
108
|
+
**Honest limitations:**
|
|
109
|
+
- **Not absolute anti-reverse-engineering.** SEA blobs have public extraction tools and `app.cjs` is minified plaintext JS. This *raises the bar* and removes the "obviously a JS project" surface; it does **not** make the source unrecoverable. For more, layer `bytenode`/V8 bytecode over the bundle (out of scope here).
|
|
110
|
+
- **Runtime still writes opaque natives + one `app.cjs`** to a hidden `%LOCALAPPDATA%` dir. Node SEA can only embed JS in the blob — native `.node` addons and non-JS assets must land on a real path (this is the C1 self-extract tradeoff; a co-located `native/` folder is the only alternative).
|
|
111
|
+
- **Size** grows to roughly node.exe + blob + embedded natives (≈ 130–140 MB for the gallery demo).
|
|
112
|
+
|
|
87
113
|
## Pre-flight validation
|
|
88
114
|
|
|
89
115
|
The packer fails fast, before copying anything:
|
package/README.zh-CN.md
CHANGED
|
@@ -54,7 +54,7 @@ npm install --save-dev react-native-flux-desktop-packer
|
|
|
54
54
|
| `icon` | 嵌入 exe 的 PNG 图标(可选,缺省用默认图标) |
|
|
55
55
|
| `main` | 编译后的入口 JS;缺省回落 `package.json` 的 `main` |
|
|
56
56
|
| `output` | 产物落地目录(默认 `build`) |
|
|
57
|
-
| `pack` | 负载白名单,默认 `["dist","node_modules","assets","package.json","app.json"]` |
|
|
57
|
+
| `pack` | 负载白名单,默认 `["dist","node_modules","assets","package.json","app.json"]`;另含 `pack.mode`(默认 `folder`,或 `sea`)与 `pack.assets`(资源目录,默认 `assets`) |
|
|
58
58
|
|
|
59
59
|
**2.** 先编译应用,再打包:
|
|
60
60
|
|
|
@@ -84,6 +84,32 @@ stage 阶段会拷贝你的 `dist/`、`node_modules/`、`assets/`、`package.jso
|
|
|
84
84
|
|
|
85
85
|
运行时启动器会注入 `FLUX_PACKAGED=1` 和 `FLUX_APP_DIR=<AppName>`,库会据此把 KV/日志数据路由到 `%APPDATA%\<AppName>\`——exe 放进 `Program Files` 也没有可写路径问题。
|
|
86
86
|
|
|
87
|
+
## SEA 单文件模式(`pack.mode: "sea"`)
|
|
88
|
+
|
|
89
|
+
上面的默认路径走的是**文件夹/启动器**负载(一个 Go exe 内嵌 `dist/` + `node_modules/` + `node.exe` 的 zip)。首启会自解压出一份可读的 **JS 源码树**到 `%LOCALAPPDATA%`——能跑,但解压出的 `dist/**/*.js` 和 `node_modules/` 让人一眼看出这是个 JS 项目。
|
|
90
|
+
|
|
91
|
+
在 `app.json` 里设 `"pack": { "mode": "sea" }` 即切到 **Node SEA**(单可执行应用)构建,修掉这个观感。现有 `folder` 路径原封不动——把字段改回去即可回退。
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
app.json ─▶ [1] esbuild 全部 JS → 一个 minified app.cjs(无 dist/ 树、无 node_modules)
|
|
95
|
+
─▶ [2] SEA 资产 app.cjs + 原生 .node(自研 + skia)+ icudtl.dat + 图片资源 → 内嵌进 blob
|
|
96
|
+
─▶ [3] rcedit 在 node.exe 副本上写图标 + FileDescription/ProductName/…(在注入之前)
|
|
97
|
+
─▶ [4] postject 将 NODE_SEA_BLOB 注入该 exe
|
|
98
|
+
─▶ [5] 子系统 PE OptionalHeader.Subsystem 3→2(GUI),双击不弹控制台
|
|
99
|
+
─▶ [6] 交付 <output>/<Name>-<version>.exe(单文件)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
运行时,`node:sea` 跑一个 bootstrap(即 SEA 的 `main`):把内嵌的原生/资源一次性自解压到隐藏的 `%LOCALAPPDATA%\<AppName>\run-<stamp>\`,安装原生解析钩子(skia 走 `NAPI_RS_NATIVE_LIBRARY_PATH`,你的 `.node` 走 `Module._resolveFilename`),`chdir` 到解压出的资源目录,设 `FLUX_PACKAGED=1` / `FLUX_APP_DIR`,然后 `createRequire` 加载不透明的 `app.cjs`。`.extracted` 标记使二次启动跳过重复解压。**没有 `node.exe` 子进程**——任务管理器显示品牌化的 `<App>.exe` 名与图标。
|
|
103
|
+
|
|
104
|
+
收益:磁盘上不再有 JS 源码树——解压目录只有一份 minified `app.cjs` 加不透明的编译二进制(`.node`/`.dat`)与你的图片资源。
|
|
105
|
+
|
|
106
|
+
**前置要求:** 构建机需支持 SEA 的 Node.js ≥ 20(用 `--experimental-sea-config`),外加 `esbuild` 和 `postject`(声明为 `optionalDependencies`;`rcedit` 也是选填—图标/版本注入缺失时优雅跳过)。缺 `esbuild`/`postject` → packer 报错并给出需执行的 `npm i`。
|
|
107
|
+
|
|
108
|
+
**诚实的限制:**
|
|
109
|
+
- **非绝对防逆向。** SEA blob 有公开提取工具,`app.cjs` 是 minified 明文 JS。本模式*抬高成本*、去掉“一眼是 JS 项目”的观感;**并不能**让源码不可还原。若要更硬,在 bundle 上叠 `bytenode`/V8 字节码(本次范围外)。
|
|
110
|
+
- **运行期仍会写不透明原生 + 一份 `app.cjs`** 到隐藏的 `%LOCALAPPDATA%` 目录。Node SEA 的 blob 只能嵌 JS——原生 `.node` 插件与非 JS 资源必须落到真实路径(这是 C1 自解压的取舍;唯一替代是 exe 旁放 `native/` 文件夹)。
|
|
111
|
+
- **体积**增长到约为 node.exe + blob + 内嵌原生(gallery demo 约 130–140 MB)。
|
|
112
|
+
|
|
87
113
|
## 打包前硬校验
|
|
88
114
|
|
|
89
115
|
复制动作开始之前,packer 会快速失败:
|
package/launcher/main.go
CHANGED
|
@@ -39,6 +39,9 @@ var (
|
|
|
39
39
|
Entry = "dist\\main.js"
|
|
40
40
|
AppName = "Flux"
|
|
41
41
|
ExeName = "Flux"
|
|
42
|
+
// Runtime 运行时 exe 的文件名(相对缓存根)。打包时经 -X main.Runtime 注入为应用名.exe,
|
|
43
|
+
// 使任务管理器里真正吃内存的进程显示应用名而非 node.exe;缺省兼容旧负载(node.exe)。
|
|
44
|
+
Runtime = "node.exe"
|
|
42
45
|
)
|
|
43
46
|
|
|
44
47
|
var (
|
|
@@ -138,10 +141,10 @@ func main() {
|
|
|
138
141
|
_ = os.WriteFile(ready, []byte("ok"), 0o644)
|
|
139
142
|
}
|
|
140
143
|
|
|
141
|
-
node := filepath.Join(dest,
|
|
144
|
+
node := filepath.Join(dest, Runtime)
|
|
142
145
|
entry := filepath.Join(dest, Entry)
|
|
143
146
|
if _, err := os.Stat(node); err != nil {
|
|
144
|
-
showFatal(ExeName, "
|
|
147
|
+
showFatal(ExeName, "解压后缺少运行时 exe:\n"+node)
|
|
145
148
|
}
|
|
146
149
|
if _, err := os.Stat(entry); err != nil {
|
|
147
150
|
showFatal(ExeName, "解压后缺少入口:\n"+entry)
|
package/lib/brand.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// brand.js —— 给「运行时 exe(原 node.exe)」注入应用图标 + 版本字符串。
|
|
2
|
+
// 动机:任务管理器里真正吃内存的进程是启动器 spawn 的运行时 exe,它显示的名字/图标取自「该 exe 主模块的文件名 +
|
|
3
|
+
// 内嵌版本资源(FileDescription) + 内嵌图标」,与启动器图标、App 内窗口图标都无关。仅改文件名可去掉 “node.exe”
|
|
4
|
+
// 字样,但 Win11 分组视图的友好名与图标仍是 Node 的——要彻底换成品牌,须改写该 exe 的资源(rcedit,Electron 同款)。
|
|
5
|
+
// 本模块全程优雅降级:未装 rcedit / 无图标 → 跳过注入(改名仍生效),绝不阻断打包。
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const cp = require('child_process');
|
|
9
|
+
const { warn, ok } = require('./util');
|
|
10
|
+
|
|
11
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
12
|
+
|
|
13
|
+
/** 把多尺寸 PNG 打包成 .ico(Vista 起图标条目可用 PNG 负载;rcedit/Windows 接受)。 */
|
|
14
|
+
function buildIcoFromPngs(pngPaths, outIco) {
|
|
15
|
+
const entries = pngPaths.map((p) => fs.readFileSync(p));
|
|
16
|
+
const dims = entries.map((b) => ({ w: b.readUInt32BE(16), h: b.readUInt32BE(20) })); // PNG IHDR
|
|
17
|
+
const n = entries.length;
|
|
18
|
+
const header = Buffer.alloc(6);
|
|
19
|
+
header.writeUInt16LE(0, 0);
|
|
20
|
+
header.writeUInt16LE(1, 2); // type=icon
|
|
21
|
+
header.writeUInt16LE(n, 4);
|
|
22
|
+
const dir = Buffer.alloc(16 * n);
|
|
23
|
+
let offset = 6 + dir.length;
|
|
24
|
+
const parts = [header, dir];
|
|
25
|
+
entries.forEach((buf, i) => {
|
|
26
|
+
const base = i * 16;
|
|
27
|
+
const { w, h } = dims[i];
|
|
28
|
+
dir.writeUInt8(w >= 256 ? 0 : w, base + 0);
|
|
29
|
+
dir.writeUInt8(h >= 256 ? 0 : h, base + 1);
|
|
30
|
+
dir.writeUInt8(0, base + 2); // 颜色数
|
|
31
|
+
dir.writeUInt8(0, base + 3); // 保留
|
|
32
|
+
dir.writeUInt16LE(1, base + 4); // 平面
|
|
33
|
+
dir.writeUInt16LE(32, base + 6); // 位深
|
|
34
|
+
dir.writeUInt32LE(buf.length, base + 8); // 数据大小
|
|
35
|
+
dir.writeUInt32LE(offset, base + 12); // 数据偏移
|
|
36
|
+
offset += buf.length;
|
|
37
|
+
parts.push(buf);
|
|
38
|
+
});
|
|
39
|
+
fs.writeFileSync(outIco, Buffer.concat(parts));
|
|
40
|
+
return outIco;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 解析 rcedit(消费方或 packer 自身 node_modules 均可);找不到返回 null。 */
|
|
44
|
+
function resolveRcedit(projectRoot) {
|
|
45
|
+
const cand = [projectRoot, PKG_ROOT].filter(Boolean);
|
|
46
|
+
try {
|
|
47
|
+
return require(require.resolve('rcedit', { paths: cand }));
|
|
48
|
+
} catch {
|
|
49
|
+
/* not here */
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return require('rcedit');
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 解析 rcedit 自带的可执行 exe(x64 优先);找不到返回 null。 */
|
|
59
|
+
function resolveRceditBin(projectRoot) {
|
|
60
|
+
const cand = [projectRoot, PKG_ROOT].filter(Boolean);
|
|
61
|
+
let dir = null;
|
|
62
|
+
try {
|
|
63
|
+
dir = path.dirname(require.resolve('rcedit', { paths: cand }));
|
|
64
|
+
} catch {
|
|
65
|
+
try {
|
|
66
|
+
dir = path.dirname(require.resolve('rcedit'));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const binDir = path.join(dir, '..', 'bin');
|
|
72
|
+
const x64 = path.join(binDir, 'rcedit-x64.exe');
|
|
73
|
+
if (fs.existsSync(x64)) return x64;
|
|
74
|
+
const x86 = path.join(binDir, 'rcedit.exe');
|
|
75
|
+
return fs.existsSync(x86) ? x86 : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 就地给 exePath 注入图标 + 版本字符串。直接 spawnSync rcedit 自带 exe 并设硬超时——
|
|
80
|
+
* 规避 rcedit 的 JS 异步包装在本环境下不返回的问题;任何失败均优雅跳过(不抛、不阻断)。
|
|
81
|
+
*/
|
|
82
|
+
async function brandRuntime(exePath, { projectRoot, iconPngPaths, name, description, version }) {
|
|
83
|
+
const bin = resolveRceditBin(projectRoot);
|
|
84
|
+
if (!bin) {
|
|
85
|
+
warn('未安装可用的 rcedit(在 packer 里 `npm i rcedit`),跳过 exe 图标/描述注入(改名仍生效)');
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
let ico = null;
|
|
89
|
+
if (Array.isArray(iconPngPaths) && iconPngPaths.length) {
|
|
90
|
+
ico = path.join(path.dirname(exePath), '__brand__.ico');
|
|
91
|
+
try {
|
|
92
|
+
buildIcoFromPngs(iconPngPaths, ico);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
warn(`生成 .ico 失败(仅改版本字符串):${e.message}`);
|
|
95
|
+
ico = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const args = [exePath];
|
|
99
|
+
const push = (k, v) => {
|
|
100
|
+
if (v != null && v !== '') args.push('--set-version-string', k, String(v));
|
|
101
|
+
};
|
|
102
|
+
// FileDescription 会被 Windows 当作任务栏/Alt-Tab 的「应用友好名」——必须是简短可读名,用 name。
|
|
103
|
+
// description(常为整句产品描述)不适合放这里,否则任务栏会显示一长串文案。
|
|
104
|
+
// push('FileDescription', name);
|
|
105
|
+
push('ProductName', name);
|
|
106
|
+
push('OriginalFilename', name + '.exe');
|
|
107
|
+
push('InternalName', name);
|
|
108
|
+
const v = version != null ? String(version).replace(/^v/, '') : '';
|
|
109
|
+
if (/^\d+(\.\d+){0,3}$/.test(v)) {
|
|
110
|
+
args.push('--set-file-version', v, '--set-product-version', v);
|
|
111
|
+
}
|
|
112
|
+
if (ico) args.push('--set-icon', ico);
|
|
113
|
+
try {
|
|
114
|
+
const r = cp.spawnSync(bin, args, { windowsHide: true, encoding: 'utf8', timeout: 60000 });
|
|
115
|
+
if (r.error) throw r.error;
|
|
116
|
+
if (r.status !== 0) throw new Error(String(r.stderr || r.stdout || '').trim() || 'exit ' + r.status);
|
|
117
|
+
ok(`exe 已注入品牌:${path.basename(exePath)}${ico ? ' + 图标' : ''}`);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
warn(`rcedit 注入失败(忽略):${(e && e.message) || e}`);
|
|
121
|
+
return false;
|
|
122
|
+
} finally {
|
|
123
|
+
if (ico) {
|
|
124
|
+
try {
|
|
125
|
+
fs.unlinkSync(ico);
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { brandRuntime, buildIcoFromPngs, resolveRcedit, resolveRceditBin };
|
package/lib/bundle.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// bundle.js —— 用 esbuild 把应用入口(dist/App.js,纯 CommonJS)连同全部纯 JS 依赖
|
|
2
|
+
// 打成单个 minified bundle。原生 addon(.node) 与 yoga wasm 之外的一切都被内联;
|
|
3
|
+
// .node / 平台专属包标为 external,交由 SEA bootstrap 落盘后正常 require。
|
|
4
|
+
// esbuild 作为 packer 的可选依赖:消费方或 packer 自身 node_modules 均可解析;缺失则抛清晰错误。
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
// external 名单:SEA main 作用域外(bootstrap 用 createRequire 加载 bundle)时,
|
|
11
|
+
// 这些 require 保持原样,运行时由 bootstrap 的解析钩子/环境重定向到自解压目录。
|
|
12
|
+
const EXTERNALS = [
|
|
13
|
+
'node:sea',
|
|
14
|
+
'*.node',
|
|
15
|
+
// 自研库的原生绑定(按平台分包名,运行时只会用到 win32-x64)
|
|
16
|
+
'react-native-flux-desktop-win32-x64-msvc',
|
|
17
|
+
'react-native-flux-desktop-win32-ia32-msvc',
|
|
18
|
+
'react-native-flux-desktop-win32-arm64-msvc',
|
|
19
|
+
'react-native-flux-desktop-darwin-x64',
|
|
20
|
+
'react-native-flux-desktop-darwin-arm64',
|
|
21
|
+
'react-native-flux-desktop-linux-*',
|
|
22
|
+
'react-native-flux-desktop-android-*',
|
|
23
|
+
'react-native-flux-desktop-freebsd-*',
|
|
24
|
+
// @napi-rs/canvas 的平台预编译包(skia .node 由 bootstrap 经 env 指向解压副本)
|
|
25
|
+
'@napi-rs/canvas-win32-x64-msvc',
|
|
26
|
+
'@napi-rs/canvas-win32-x64-gnu',
|
|
27
|
+
'@napi-rs/canvas-win32-arm64-msvc',
|
|
28
|
+
'@napi-rs/canvas-win32-ia32-msvc',
|
|
29
|
+
'@napi-rs/canvas-darwin-x64',
|
|
30
|
+
'@napi-rs/canvas-darwin-arm64',
|
|
31
|
+
'@napi-rs/canvas-linux-x64-gnu',
|
|
32
|
+
'@napi-rs/canvas-linux-x64-musl',
|
|
33
|
+
'@napi-rs/canvas-linux-arm64-gnu',
|
|
34
|
+
'@napi-rs/canvas-linux-arm64-musl',
|
|
35
|
+
'@napi-rs/canvas-android-arm64',
|
|
36
|
+
'@napi-rs/canvas-android-arm-eabi',
|
|
37
|
+
// 可选大二进制:缺失时运行时自动降级(波形),不内联避免体积膨胀/解析失败
|
|
38
|
+
'ffmpeg-static',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function resolveEsbuild(projectRoot) {
|
|
42
|
+
const cand = [projectRoot, PKG_ROOT].filter(Boolean);
|
|
43
|
+
try {
|
|
44
|
+
return require(require.resolve('esbuild', { paths: cand }));
|
|
45
|
+
} catch {
|
|
46
|
+
/* not here */
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return require('esbuild');
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 打包入口为单个 CJS bundle。
|
|
57
|
+
* @returns {Promise<{outfile:string,size:number}>}
|
|
58
|
+
*/
|
|
59
|
+
async function buildAppBundle({ projectRoot, entryAbs, outfile, minify = true, extraExternals = [] }) {
|
|
60
|
+
const esbuild = resolveEsbuild(projectRoot);
|
|
61
|
+
if (!esbuild) {
|
|
62
|
+
throw new Error('未安装 esbuild(sea 模式所需)。在 packer 目录执行:npm i esbuild postject');
|
|
63
|
+
}
|
|
64
|
+
if (!fs.existsSync(entryAbs)) {
|
|
65
|
+
throw new Error(`入口不存在,无法打包:${entryAbs}`);
|
|
66
|
+
}
|
|
67
|
+
await esbuild.build({
|
|
68
|
+
absWorkingDir: projectRoot,
|
|
69
|
+
entryPoints: [entryAbs],
|
|
70
|
+
bundle: true,
|
|
71
|
+
platform: 'node',
|
|
72
|
+
format: 'cjs',
|
|
73
|
+
minify,
|
|
74
|
+
target: 'node20',
|
|
75
|
+
legalComments: 'none',
|
|
76
|
+
logLevel: 'warning',
|
|
77
|
+
outfile,
|
|
78
|
+
external: EXTERNALS.concat(extraExternals),
|
|
79
|
+
});
|
|
80
|
+
return { outfile, size: fs.statSync(outfile).size };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { buildAppBundle, resolveEsbuild, EXTERNALS };
|
package/lib/config.js
CHANGED
|
@@ -42,6 +42,12 @@ function loadConfig(projectRoot) {
|
|
|
42
42
|
const version = typeof app.version === 'string' ? app.version : '0.0.0';
|
|
43
43
|
const output = app.output || 'build';
|
|
44
44
|
|
|
45
|
+
// 打包模式:app.pack.mode = "folder"(默认,Go 启动器 + 解压明文 dist) | "sea"(Node SEA 单文件)
|
|
46
|
+
const packObj = app.pack && typeof app.pack === 'object' ? app.pack : {};
|
|
47
|
+
const packMode = packObj.mode === 'sea' ? 'sea' : 'folder';
|
|
48
|
+
// 资源目录(相对项目根),sea 模式内嵌进 exe、运行时解压到 cwd;folder 模式整目录拷贝
|
|
49
|
+
const assetsDirRel = typeof packObj.assets === 'string' ? packObj.assets : 'assets';
|
|
50
|
+
|
|
45
51
|
const cfg = {
|
|
46
52
|
projectRoot,
|
|
47
53
|
name: app.name,
|
|
@@ -50,8 +56,12 @@ function loadConfig(projectRoot) {
|
|
|
50
56
|
icon: typeof app.icon === 'string' ? app.icon : null,
|
|
51
57
|
outputAbs: path.isAbsolute(output) ? output : path.join(projectRoot, output),
|
|
52
58
|
outputRel: output,
|
|
59
|
+
packMode,
|
|
60
|
+
assetsDirRel,
|
|
53
61
|
// 归一化入口为平台分隔(Go 里用 dist\\Gallery.js)
|
|
54
62
|
mainRel: main.split('/').join(path.sep),
|
|
63
|
+
// 数据/缓存目录用的应用名(与启动器 sanitize 同规则:空格/斜杠等转 '-')
|
|
64
|
+
safeAppName: sanitizeDirName(app.name) || 'Flux',
|
|
55
65
|
appJson: app,
|
|
56
66
|
};
|
|
57
67
|
|
|
@@ -139,4 +149,11 @@ function safeName(s) {
|
|
|
139
149
|
.trim();
|
|
140
150
|
}
|
|
141
151
|
|
|
142
|
-
|
|
152
|
+
// 数据/缓存目录名净化(对齐 launcher main.go 的 sanitize:空格、/ \ : * ? " < > | 一律转 '-')
|
|
153
|
+
function sanitizeDirName(s) {
|
|
154
|
+
return String(s)
|
|
155
|
+
.replace(/[ /\\:*?"<>|]/g, '-')
|
|
156
|
+
.trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { loadConfig, fourPart, safeName, sanitizeDirName, validateEntry, newestSourceMtime };
|
package/lib/icon.js
CHANGED
|
@@ -11,44 +11,11 @@ async function buildIconSyso({ projectRoot, iconRel, outDir, name, version, desc
|
|
|
11
11
|
warn('app.json 未配置 icon,跳过图标(exe 使用默认图标)');
|
|
12
12
|
return null;
|
|
13
13
|
}
|
|
14
|
-
const iconPath = path.isAbsolute(iconRel) ? iconRel : path.join(projectRoot, iconRel);
|
|
15
|
-
if (!fs.existsSync(iconPath)) {
|
|
16
|
-
warn(`图标文件不存在:${iconPath}(跳过图标)`);
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// 从消费方项目解析 @napi-rs/canvas(packer 自身不依赖它)
|
|
21
|
-
let canvas;
|
|
22
|
-
try {
|
|
23
|
-
const modPath = require.resolve('@napi-rs/canvas', { paths: [projectRoot] });
|
|
24
|
-
canvas = require(modPath);
|
|
25
|
-
} catch {
|
|
26
|
-
warn('消费方未安装 @napi-rs/canvas,无法缩放图标(跳过图标)');
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const goWinres = which(path.join(process.env.USERPROFILE || '', 'go', 'bin', 'go-winres.exe')) || which('go-winres');
|
|
31
|
-
if (!goWinres) {
|
|
32
|
-
warn('未找到 go-winres(go install github.com/tc-hib/go-winres@latest),跳过图标');
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
14
|
ensureDir(outDir);
|
|
37
15
|
const winresDir = path.join(outDir, 'winres');
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const files = [];
|
|
42
|
-
for (const s of SIZES) {
|
|
43
|
-
const c = canvas.createCanvas(s, s);
|
|
44
|
-
const ctx = c.getContext('2d');
|
|
45
|
-
ctx.imageSmoothingEnabled = true;
|
|
46
|
-
ctx.imageSmoothingQuality = 'high';
|
|
47
|
-
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, s, s);
|
|
48
|
-
const fname = `icon-${s}x${s}.png`;
|
|
49
|
-
fs.writeFileSync(path.join(winresDir, fname), c.toBuffer('image/png'));
|
|
50
|
-
files.push(fname);
|
|
51
|
-
}
|
|
16
|
+
const gen = await renderIconPngs({ projectRoot, iconRel, outDir: winresDir });
|
|
17
|
+
if (!gen) return null;
|
|
18
|
+
const files = gen.files;
|
|
52
19
|
|
|
53
20
|
const v = fourPart(version);
|
|
54
21
|
const json = {
|
|
@@ -75,6 +42,11 @@ async function buildIconSyso({ projectRoot, iconRel, outDir, name, version, desc
|
|
|
75
42
|
fs.writeFileSync(jsonPath, JSON.stringify(json, null, 2));
|
|
76
43
|
|
|
77
44
|
// go-winres make → 产出 rsrc_windows_amd64.syso 于 outDir
|
|
45
|
+
const goWinres = which(path.join(process.env.USERPROFILE || '', 'go', 'bin', 'go-winres.exe')) || which('go-winres');
|
|
46
|
+
if (!goWinres) {
|
|
47
|
+
warn('未找到 go-winres(go install github.com/tc-hib/go-winres@latest),跳过图标');
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
78
50
|
run(goWinres, ['make', '--in', jsonPath, '--out', path.join(outDir, 'rsrc'), '--arch', 'amd64'], { stdio: 'ignore' });
|
|
79
51
|
const syso = path.join(outDir, 'rsrc_windows_amd64.syso');
|
|
80
52
|
if (!fs.existsSync(syso)) {
|
|
@@ -85,4 +57,45 @@ async function buildIconSyso({ projectRoot, iconRel, outDir, name, version, desc
|
|
|
85
57
|
return syso;
|
|
86
58
|
}
|
|
87
59
|
|
|
88
|
-
module.exports = { buildIconSyso };
|
|
60
|
+
module.exports = { buildIconSyso, renderIconPngs };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 把 app.json.icon 指的 PNG 缩放为多尺寸图标 PNG,写入 outDir。
|
|
64
|
+
* 供 go-winres(启动器 exe)与 rcedit(运行时 exe 品牌注入)两处复用,避免取图逻辑漂移。
|
|
65
|
+
* 任一前置缺失(无 iconRel / 文件不存在 / 无 canvas)都优雅返回 null。
|
|
66
|
+
* 返回 { abs: 绝对路径数组, files: 相对 outDir 的文件名数组 }。
|
|
67
|
+
*/
|
|
68
|
+
async function renderIconPngs({ projectRoot, iconRel, outDir }) {
|
|
69
|
+
if (!iconRel) return null;
|
|
70
|
+
const iconPath = path.isAbsolute(iconRel) ? iconRel : path.join(projectRoot, iconRel);
|
|
71
|
+
if (!fs.existsSync(iconPath)) {
|
|
72
|
+
warn(`图标文件不存在:${iconPath}(跳过图标)`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
let canvas;
|
|
76
|
+
try {
|
|
77
|
+
const modPath = require.resolve('@napi-rs/canvas', { paths: [projectRoot] });
|
|
78
|
+
canvas = require(modPath);
|
|
79
|
+
} catch {
|
|
80
|
+
warn('消费方未安装 @napi-rs/canvas,无法缩放图标(跳过图标)');
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
ensureDir(outDir);
|
|
84
|
+
const SIZES = [16, 24, 32, 48, 64, 128, 256];
|
|
85
|
+
const img = await canvas.loadImage(iconPath);
|
|
86
|
+
const abs = [];
|
|
87
|
+
const files = [];
|
|
88
|
+
for (const s of SIZES) {
|
|
89
|
+
const c = canvas.createCanvas(s, s);
|
|
90
|
+
const ctx = c.getContext('2d');
|
|
91
|
+
ctx.imageSmoothingEnabled = true;
|
|
92
|
+
ctx.imageSmoothingQuality = 'high';
|
|
93
|
+
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, s, s);
|
|
94
|
+
const fname = `icon-${s}x${s}.png`;
|
|
95
|
+
const p = path.join(outDir, fname);
|
|
96
|
+
fs.writeFileSync(p, c.toBuffer('image/png'));
|
|
97
|
+
abs.push(p);
|
|
98
|
+
files.push(fname);
|
|
99
|
+
}
|
|
100
|
+
return { abs, files };
|
|
101
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,9 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { step, ok, warn, rmrf, ensureDir, copyTree, dirSize, fmtMB, which, run } = require('./util');
|
|
8
8
|
const { loadConfig, safeName } = require('./config');
|
|
9
|
-
const { buildIconSyso } = require('./icon');
|
|
9
|
+
const { buildIconSyso, renderIconPngs } = require('./icon');
|
|
10
|
+
const { brandRuntime } = require('./brand');
|
|
11
|
+
const { packSea } = require('./sea');
|
|
10
12
|
|
|
11
13
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
12
14
|
const LAUNCHER_DIR = path.join(PKG_ROOT, 'launcher');
|
|
@@ -33,12 +35,29 @@ async function pack(options = {}) {
|
|
|
33
35
|
console.log(` 应用名:${cfg.name} 版本:${cfg.version}`);
|
|
34
36
|
console.log(` 入口 :${cfg.mainRel}`);
|
|
35
37
|
console.log(` 产物到:${cfg.outputAbs}`);
|
|
38
|
+
console.log(` 模式 :${cfg.packMode}`);
|
|
36
39
|
|
|
37
40
|
const work = path.join(projectRoot, '.flux-pack');
|
|
38
41
|
const stage = path.join(work, 'stage');
|
|
39
42
|
const zip = path.join(work, 'payload.zip');
|
|
40
43
|
const buildDir = path.join(work, 'launcher-build');
|
|
41
44
|
rmrf(work);
|
|
45
|
+
|
|
46
|
+
// ---- sea 模式:Node SEA 单文件(无 dist 明文树)----
|
|
47
|
+
if (cfg.packMode === 'sea') {
|
|
48
|
+
ensureDir(cfg.outputAbs);
|
|
49
|
+
const entryAbs = path.join(projectRoot, cfg.mainRel);
|
|
50
|
+
let exe;
|
|
51
|
+
try {
|
|
52
|
+
exe = await packSea({ projectRoot, cfg, work, outAbs: cfg.outputAbs, exeBase, fileBase, entryAbs, assetsDirRel: cfg.assetsDirRel });
|
|
53
|
+
} finally {
|
|
54
|
+
if (!keepWork) rmrf(work);
|
|
55
|
+
else warn(`保留工作目录:${work}`);
|
|
56
|
+
}
|
|
57
|
+
console.log('\n\x1b[32m✔ 打包完成(sea 单文件)→\x1b[0m ' + exe);
|
|
58
|
+
return { exe };
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
ensureDir(stage);
|
|
43
62
|
|
|
44
63
|
// ---- 1) 组装载 ----
|
|
@@ -60,9 +79,27 @@ async function pack(options = {}) {
|
|
|
60
79
|
fs.copyFileSync(src, path.join(stage, item));
|
|
61
80
|
}
|
|
62
81
|
}
|
|
63
|
-
// 捆绑当前 node.exe
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
// 捆绑当前 node.exe,但以应用名落盘(napi ABI 稳定,跨大版本可加载)。
|
|
83
|
+
// 运行时进程改名 → 任务管理器「详细信息」页的名称列不再显示 node.exe(node 不校验自身文件名,改名照常运行)。
|
|
84
|
+
const runtimeName = exeBase + '.exe';
|
|
85
|
+
fs.copyFileSync(process.execPath, path.join(stage, runtimeName));
|
|
86
|
+
ok(`运行时 exe:${runtimeName}(来自 ${process.execPath})`);
|
|
87
|
+
|
|
88
|
+
// 品牌注入:把应用图标 + FileDescription 写进该运行时 exe 的资源,使任务管理器里真正吃内存的那个进程
|
|
89
|
+
// 显示应用名与自定义图标(友好名/图标取自 exe 内嵌资源,与启动器图标、App 内窗口图标无关)。
|
|
90
|
+
// 依赖 rcedit:未装则优雅跳过(改名仍生效),绝不阻断打包。
|
|
91
|
+
try {
|
|
92
|
+
const gen = await renderIconPngs({ projectRoot, iconRel: cfg.icon, outDir: path.join(work, 'rtpng') });
|
|
93
|
+
await brandRuntime(path.join(stage, runtimeName), {
|
|
94
|
+
projectRoot,
|
|
95
|
+
iconPngPaths: gen ? gen.abs : [],
|
|
96
|
+
name: cfg.name,
|
|
97
|
+
description: cfg.description,
|
|
98
|
+
version: cfg.version,
|
|
99
|
+
});
|
|
100
|
+
} catch (e) {
|
|
101
|
+
warn(`运行时 exe 品牌注入异常(忽略):${e.message}`);
|
|
102
|
+
}
|
|
66
103
|
|
|
67
104
|
// 入口存在性校验(相对 stage 根)
|
|
68
105
|
const entryInStage = path.join(stage, cfg.mainRel);
|
|
@@ -105,7 +142,7 @@ async function pack(options = {}) {
|
|
|
105
142
|
let delivered;
|
|
106
143
|
if (!goExe) {
|
|
107
144
|
warn('未检测到 Go,无法产单文件 exe → 回落「便携文件夹 + .cmd」(同样可直接运行)');
|
|
108
|
-
delivered = emitPortableFolder({ stage, outAbs: cfg.outputAbs, exeBase: fileBase, mainRel: cfg.mainRel });
|
|
145
|
+
delivered = emitPortableFolder({ stage, outAbs: cfg.outputAbs, exeBase: fileBase, mainRel: cfg.mainRel, runtimeName });
|
|
109
146
|
} else {
|
|
110
147
|
ensureDir(buildDir);
|
|
111
148
|
fs.copyFileSync(path.join(LAUNCHER_DIR, 'main.go'), path.join(buildDir, 'main.go'));
|
|
@@ -115,7 +152,7 @@ async function pack(options = {}) {
|
|
|
115
152
|
const outExe = path.join(buildDir, fileBase + '.exe');
|
|
116
153
|
// 用生成的 .cmd 显式控制引号:go 的 -ldflags 必须是「单个带引号字符串」,
|
|
117
154
|
// 而 Node 直接 spawn go 会把内层引号拆坏。值均用无空格 ASCII(exeBase / mainRel)。
|
|
118
|
-
const ld = `-H windowsgui -X main.Entry=${cfg.mainRel} -X main.AppName=${exeBase} -X main.ExeName=${exeBase}`;
|
|
155
|
+
const ld = `-H windowsgui -X main.Entry=${cfg.mainRel} -X main.AppName=${exeBase} -X main.ExeName=${exeBase} -X main.Runtime=${runtimeName}`;
|
|
119
156
|
const cmdFile = path.join(buildDir, 'build-launcher.cmd');
|
|
120
157
|
const goLine = `"${goExe}" build -trimpath -ldflags "${ld}" -o "${outExe}" .`;
|
|
121
158
|
fs.writeFileSync(cmdFile, ['@echo off', 'cd /d "' + buildDir + '"', goLine, ''].join('\r\n'), 'utf8');
|
|
@@ -138,7 +175,7 @@ async function pack(options = {}) {
|
|
|
138
175
|
}
|
|
139
176
|
|
|
140
177
|
// 无 Go 时的回落:把 stage 目录整体搬到 output/<name>/,写 run.cmd(置 FLUX_PACKAGED=1)
|
|
141
|
-
function emitPortableFolder({ stage, outAbs, exeBase, mainRel }) {
|
|
178
|
+
function emitPortableFolder({ stage, outAbs, exeBase, mainRel, runtimeName }) {
|
|
142
179
|
const dest = path.join(outAbs, exeBase);
|
|
143
180
|
rmrf(dest);
|
|
144
181
|
copyTree(stage, dest);
|
|
@@ -146,7 +183,7 @@ function emitPortableFolder({ stage, outAbs, exeBase, mainRel }) {
|
|
|
146
183
|
'@echo off',
|
|
147
184
|
'cd /d "%~dp0"',
|
|
148
185
|
'set FLUX_PACKAGED=1',
|
|
149
|
-
`"%~
|
|
186
|
+
`"%~dp0${runtimeName}" "%~dp0${mainRel}" %*`,
|
|
150
187
|
'',
|
|
151
188
|
].join('\r\n');
|
|
152
189
|
fs.writeFileSync(path.join(dest, 'run.cmd'), cmd, 'utf8');
|
package/lib/sea.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// sea.js —— Node SEA「单文件」打包(flux-pack 的 sea 模式)。
|
|
2
|
+
// 对外产物 = 单个品牌化 exe(重命名+注入图标的 node.exe,内含全部 JS)。运行期由内嵌 bootstrap
|
|
3
|
+
// 把「原生 .node + icudtl.dat + app bundle + assets 资源树」自解压到隐藏目录
|
|
4
|
+
// (%LOCALAPPDATA%\<AppName>\run-<stamp>\),再用 createRequire 正常加载 app —— 磁盘上不再出现
|
|
5
|
+
// 可读的 dist/**/*.js 源码树,任务管理器也只看到 <AppName>.exe(无 node.exe 子进程)。
|
|
6
|
+
//
|
|
7
|
+
// 关键约束(Phase 0 spike 已验证):
|
|
8
|
+
// 1) app bundle 不能当 SEA main:main 作用域的 require 是 embedderRequire,require(.node) 会抛
|
|
9
|
+
// ERR_UNKNOWN_BUILTIN_MODULE。→ main 只放 bootstrap;app 作为 asset 落盘后用 createRequire 加载。
|
|
10
|
+
// 2) sea.getRawAsset 返回 ArrayBuffer → 必须 Buffer.from。
|
|
11
|
+
// 3) @napi-rs/canvas 认 NAPI_RS_NATIVE_LIBRARY_PATH(绝对路径优先);自研 rnfd 走 Module._resolveFilename 钩子。
|
|
12
|
+
// 4) skia 需 icudtl.dat 与其同目录;demos 经 process.cwd()/assets 取图 → 解压后 chdir 到 run 根。
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const { execFileSync } = require('child_process');
|
|
17
|
+
const { step, ok, warn, ensureDir, dirSize, fmtMB } = require('./util');
|
|
18
|
+
const { buildAppBundle, resolveEsbuild } = require('./bundle');
|
|
19
|
+
const { brandRuntime } = require('./brand');
|
|
20
|
+
const { renderIconPngs } = require('./icon');
|
|
21
|
+
|
|
22
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
23
|
+
|
|
24
|
+
function resolvePostject(projectRoot) {
|
|
25
|
+
const cand = [projectRoot, PKG_ROOT].filter(Boolean);
|
|
26
|
+
try {
|
|
27
|
+
return require(require.resolve('postject', { paths: cand })).inject;
|
|
28
|
+
} catch {
|
|
29
|
+
/* not here */
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return require('postject').inject;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 在若干 node_modules 根里找第一个匹配相对路径的文件
|
|
39
|
+
function findFirst(root, relParts) {
|
|
40
|
+
const p = path.join(root, ...relParts);
|
|
41
|
+
return fs.existsSync(p) ? p : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 递归列目录内所有文件(相对 base)
|
|
45
|
+
function walkFiles(base, rel = '') {
|
|
46
|
+
const out = [];
|
|
47
|
+
const abs = path.join(base, rel);
|
|
48
|
+
let items;
|
|
49
|
+
try {
|
|
50
|
+
items = fs.readdirSync(abs, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
for (const it of items) {
|
|
55
|
+
const r = rel ? path.join(rel, it.name) : it.name;
|
|
56
|
+
if (it.isDirectory()) out.push(...walkFiles(base, r));
|
|
57
|
+
else if (it.isFile()) out.push(r);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 收集需内嵌为 SEA assets 的原生/数据文件。
|
|
64
|
+
* @returns {{rnfd:string,skia:string,icu:string}} 绝对路径
|
|
65
|
+
*/
|
|
66
|
+
function locateNatives(projectRoot) {
|
|
67
|
+
const roots = [path.join(projectRoot, 'node_modules')];
|
|
68
|
+
const pick = (rel) => {
|
|
69
|
+
for (const r of roots) {
|
|
70
|
+
const f = findFirst(r, rel);
|
|
71
|
+
if (f) return f;
|
|
72
|
+
}
|
|
73
|
+
// 兜底:全局扫 node_modules(同名 .node 可能嵌在 pkg 副本里)
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
const rnfd = pick(['react-native-flux-desktop', 'react-native-flux-desktop.win32-x64-msvc.node']);
|
|
77
|
+
const skia = pick(['@napi-rs', 'canvas-win32-x64-msvc', 'skia.win32-x64-msvc.node']);
|
|
78
|
+
const icu = pick(['@napi-rs', 'canvas-win32-x64-msvc', 'icudtl.dat']);
|
|
79
|
+
const miss = [];
|
|
80
|
+
if (!rnfd) miss.push('react-native-flux-desktop.win32-x64-msvc.node');
|
|
81
|
+
if (!skia) miss.push('skia.win32-x64-msvc.node');
|
|
82
|
+
if (!icu) miss.push('icudtl.dat');
|
|
83
|
+
if (miss.length) throw new Error(`未找到原生依赖:${miss.join(', ')}\n(sea 模式需在项目 node_modules 里存在这些 .node/dat)`);
|
|
84
|
+
return { rnfd, skia, icu };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 生成 bootstrap.cjs 源码(SEA main)。manifest 以 JSON 字面量注入。
|
|
88
|
+
function makeBootstrap(manifest) {
|
|
89
|
+
return `// 自动生成:react-native-flux-desktop-packer sea bootstrap(请勿手改)
|
|
90
|
+
const sea = require('node:sea');
|
|
91
|
+
const fs = require('fs'), path = require('path'), os = require('os'), Module = require('module');
|
|
92
|
+
const MANIFEST = ${JSON.stringify(manifest)};
|
|
93
|
+
const get = (k) => { const a = sea.getRawAsset(k); return a ? Buffer.from(a) : null; };
|
|
94
|
+
const APPDIR = process.env.FLUX_SEA_APP || MANIFEST.appName;
|
|
95
|
+
const dir = path.join(process.env.LOCALAPPDATA || os.tmpdir(), APPDIR, 'run-' + MANIFEST.stamp);
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
// 1) 原生 + 资源树:按 stamp 幂等自解压
|
|
99
|
+
const marker = path.join(dir, '.extracted');
|
|
100
|
+
if (!fs.existsSync(marker)) {
|
|
101
|
+
for (const r of MANIFEST.resources) {
|
|
102
|
+
const buf = get(r.key);
|
|
103
|
+
if (!buf || !buf.length) continue;
|
|
104
|
+
const target = path.join(dir, r.rel);
|
|
105
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
106
|
+
fs.writeFileSync(target, buf);
|
|
107
|
+
}
|
|
108
|
+
fs.writeFileSync(marker, MANIFEST.stamp);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2) app bundle:单个不透明 blob,仅在内容变化时重写
|
|
112
|
+
const appPath = path.join(dir, 'app.cjs');
|
|
113
|
+
const appBuf = get(MANIFEST.appKey);
|
|
114
|
+
const appMarker = path.join(dir, '.app');
|
|
115
|
+
const sig = appBuf.length + ':' + MANIFEST.stamp;
|
|
116
|
+
if (!fs.existsSync(appMarker) || fs.readFileSync(appMarker).toString() !== sig) {
|
|
117
|
+
fs.writeFileSync(appPath, appBuf);
|
|
118
|
+
fs.writeFileSync(appMarker, sig);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3) @napi-rs/canvas:绝对路径直指解压后的 skia(最高优先级)
|
|
122
|
+
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = path.join(dir, MANIFEST.skiaRel);
|
|
123
|
+
|
|
124
|
+
// 4) 自研原生:bare/相对 .node 重定向到 dir(全局钩子,对 createRequire 加载的模块同样生效)
|
|
125
|
+
const RNFD = 'react-native-flux-desktop.win32-x64-msvc.node';
|
|
126
|
+
const RNFD_ABS = path.join(dir, RNFD);
|
|
127
|
+
const origResolve = Module._resolveFilename;
|
|
128
|
+
Module._resolveFilename = function (request) {
|
|
129
|
+
if (typeof request === 'string') {
|
|
130
|
+
if (request === 'react-native-flux-desktop-win32-x64-msvc') return RNFD_ABS;
|
|
131
|
+
if (request.indexOf(RNFD) >= 0) return RNFD_ABS;
|
|
132
|
+
}
|
|
133
|
+
return origResolve.apply(this, arguments);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// 5) 数据/日志隔离 + cwd(demos 经 process.cwd()/assets 取图)
|
|
137
|
+
process.env.FLUX_PACKAGED = '1';
|
|
138
|
+
if (MANIFEST.appDirEnv) process.env.FLUX_APP_DIR = MANIFEST.appDirEnv;
|
|
139
|
+
try { process.chdir(dir); } catch (e) {}
|
|
140
|
+
|
|
141
|
+
// 6) 用 createRequire 正常加载 app(普通 require,绕开 embedderRequire)
|
|
142
|
+
Module.createRequire(path.join(dir, 'boot.js'))(appPath);
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 把 PE 子系统由控制台(3)改为 GUI(2):node.exe 是控制台子系统,双击会闪一个黑框。
|
|
147
|
+
// 启动器旧路靠 go 的 -H windowsgui 隐框;SEA 无启动器,故直接改 exe 的 OptionalHeader.Subsystem。
|
|
148
|
+
// PE 头(=e_lfanew) 后:4 字节签名 + 20 字节 COFF File Header + Optional Header;Subsystem 在 optional
|
|
149
|
+
// header 偏移 68(PE32/PE32+ 同)→ 绝对 peOff + 4 + 20 + 68 = peOff + 92。
|
|
150
|
+
function toGuiSubsystem(exePath) {
|
|
151
|
+
try {
|
|
152
|
+
const fd = fs.openSync(exePath, 'r+');
|
|
153
|
+
const mz = Buffer.alloc(2);
|
|
154
|
+
fs.readSync(fd, mz, 0, 2, 0);
|
|
155
|
+
if (mz.toString('latin1') !== 'MZ') { fs.closeSync(fd); return false; }
|
|
156
|
+
const ptr = Buffer.alloc(4);
|
|
157
|
+
fs.readSync(fd, ptr, 0, 4, 0x3c);
|
|
158
|
+
const peOff = ptr.readUInt32LE(0);
|
|
159
|
+
const sig = Buffer.alloc(4);
|
|
160
|
+
fs.readSync(fd, sig, 0, 4, peOff);
|
|
161
|
+
if (sig.toString('latin1') !== 'PE\0\0') { fs.closeSync(fd); return false; }
|
|
162
|
+
const subOff = peOff + 4 + 20 + 68;
|
|
163
|
+
const buf = Buffer.alloc(2);
|
|
164
|
+
fs.readSync(fd, buf, 0, 2, subOff);
|
|
165
|
+
const cur = buf.readUInt16LE(0);
|
|
166
|
+
let done = false;
|
|
167
|
+
if (cur === 3) { // IMAGE_SUBSYSTEM_WINDOWS_CUI -> GUI
|
|
168
|
+
buf.writeUInt16LE(2, 0);
|
|
169
|
+
fs.writeSync(fd, buf, 0, 2, subOff);
|
|
170
|
+
done = true;
|
|
171
|
+
}
|
|
172
|
+
fs.closeSync(fd);
|
|
173
|
+
return done;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
warn(`GUI 子系统切换失败(忽略,仍会有控制台窗口):${e.message}`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 执行 sea 模式打包。
|
|
182
|
+
* @param {object} a { projectRoot, cfg, work, outAbs, exeBase, fileBase, entryAbs, assetsDirRel }
|
|
183
|
+
* @returns {Promise<string>} 产物 exe 路径
|
|
184
|
+
*/
|
|
185
|
+
async function packSea(a) {
|
|
186
|
+
const { projectRoot, cfg, work, outAbs, fileBase, entryAbs } = a;
|
|
187
|
+
if (!resolveEsbuild(projectRoot)) throw new Error('sea 模式需要 esbuild:在 packer 目录 `npm i esbuild postject`');
|
|
188
|
+
const postject = resolvePostject(projectRoot);
|
|
189
|
+
if (!postject) throw new Error('sea 模式需要 postject:在 packer 目录 `npm i esbuild postject`');
|
|
190
|
+
|
|
191
|
+
const seaDir = path.join(work, 'sea');
|
|
192
|
+
ensureDir(seaDir);
|
|
193
|
+
|
|
194
|
+
// ---- 1) bundle app ----
|
|
195
|
+
step('[s1/4] esbuild 打包应用为单 bundle');
|
|
196
|
+
const bundle = path.join(seaDir, 'app.cjs');
|
|
197
|
+
const r = await buildAppBundle({ projectRoot, entryAbs, outfile: bundle });
|
|
198
|
+
ok(`app bundle:${fmtMB(r.size)}(全部 JS 已内联,无 dist 源码树)`);
|
|
199
|
+
|
|
200
|
+
// ---- 2) 收集 assets(原生 + 数据 + 资源树)+ stamp ----
|
|
201
|
+
step('[s2/4] 收集原生/资源并生成 bootstrap');
|
|
202
|
+
const nat = locateNatives(projectRoot);
|
|
203
|
+
const resources = [];
|
|
204
|
+
const assetsMap = {};
|
|
205
|
+
let idx = 0;
|
|
206
|
+
const addRes = (key, rel, abs) => {
|
|
207
|
+
resources.push({ key, rel });
|
|
208
|
+
assetsMap[key] = abs;
|
|
209
|
+
};
|
|
210
|
+
addRes('rnfd', 'react-native-flux-desktop.win32-x64-msvc.node', nat.rnfd);
|
|
211
|
+
addRes('skia', 'skia.win32-x64-msvc.node', nat.skia);
|
|
212
|
+
addRes('icu', 'icudtl.dat', nat.icu);
|
|
213
|
+
|
|
214
|
+
// app.json:应用运行时从 cwd 读它取 name/description/version/icon(如 App.tsx 里
|
|
215
|
+
// readFileSync(process.cwd()/app.json))。sea 下 cwd=解压目录,故须把 app.json 也展到根,
|
|
216
|
+
// 否则读不到会回退成默认 name:'App'(窗口标题/托盘 tooltip 都变 'App')。
|
|
217
|
+
const appJsonAbs = path.join(projectRoot, 'app.json');
|
|
218
|
+
if (fs.existsSync(appJsonAbs)) addRes('appjson', 'app.json', appJsonAbs);
|
|
219
|
+
else warn('未找到 app.json,运行时窗口标题等将回退默认(name:App)');
|
|
220
|
+
|
|
221
|
+
const assetsBase = path.join(projectRoot, a.assetsDirRel || 'assets');
|
|
222
|
+
if (fs.existsSync(assetsBase)) {
|
|
223
|
+
for (const rel of walkFiles(assetsBase)) {
|
|
224
|
+
const key = 'a' + idx++;
|
|
225
|
+
addRes(key, path.join(a.assetsDirRel || 'assets', rel), path.join(assetsBase, rel));
|
|
226
|
+
}
|
|
227
|
+
ok(`资源文件:${idx} 个(来自 ${a.assetsDirRel || 'assets'}/)`);
|
|
228
|
+
} else {
|
|
229
|
+
warn(`无 ${a.assetsDirRel || 'assets'} 目录,跳过资源内嵌`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// stamp:内容变化才重解压(含 app.json,改名字/描述也能刷新)
|
|
233
|
+
const ajSig = fs.existsSync(appJsonAbs) ? (() => { const s = fs.statSync(appJsonAbs); return s.size + ':' + Math.round(s.mtimeMs); })() : 'none';
|
|
234
|
+
const sigSrc = [nat.rnfd, nat.skia, nat.icu].map((p) => { const s = fs.statSync(p); return s.size + ':' + Math.round(s.mtimeMs); }).join('|') + '|' + ajSig + '|' + r.size;
|
|
235
|
+
const stamp = crypto.createHash('md5').update(sigSrc).digest('hex').slice(0, 12);
|
|
236
|
+
|
|
237
|
+
const appDirEnv = cfg.safeAppName || 'Flux';
|
|
238
|
+
const manifest = {
|
|
239
|
+
stamp,
|
|
240
|
+
appName: appDirEnv,
|
|
241
|
+
appDirEnv,
|
|
242
|
+
appKey: 'app',
|
|
243
|
+
skiaRel: 'skia.win32-x64-msvc.node',
|
|
244
|
+
resources,
|
|
245
|
+
};
|
|
246
|
+
const bootstrapPath = path.join(seaDir, 'bootstrap.cjs');
|
|
247
|
+
fs.writeFileSync(bootstrapPath, makeBootstrap(manifest), 'utf8');
|
|
248
|
+
assetsMap[manifest.appKey] = bundle; // app 作为 asset
|
|
249
|
+
|
|
250
|
+
// ---- 3) 生成 blob ----
|
|
251
|
+
step('[s3/4] 生成 SEA blob');
|
|
252
|
+
const blob = path.join(seaDir, 'sea.blob');
|
|
253
|
+
const config = { main: bootstrapPath, output: blob, disableExperimentalSEAWarning: true, useCodeCache: false, assets: assetsMap };
|
|
254
|
+
const cfgPath = path.join(seaDir, 'sea-config.json');
|
|
255
|
+
fs.writeFileSync(cfgPath, JSON.stringify(config, null, 2), 'utf8');
|
|
256
|
+
execFileSync(process.execPath, ['--experimental-sea-config', cfgPath], { stdio: 'inherit', cwd: seaDir });
|
|
257
|
+
ok(`blob:${fmtMB(fs.statSync(blob).size)}(含 JS + ${Object.keys(assetsMap).length} 项内嵌资产)`);
|
|
258
|
+
|
|
259
|
+
// ---- 4) exe:品牌 → 注入 → GUI 子系统 ----
|
|
260
|
+
// 顺序要点:rcedit 在「已注入 SEA blob 的大 exe」上会卡死(实测普通 node.exe 副本仅数百毫秒),
|
|
261
|
+
// 故先在纯净的 node.exe 副本上写图标/版本资源,再 postject 注入 SEA 负载,最后补 GUI 子系统字节。
|
|
262
|
+
step('[s4/4] 组装 exe(品牌 → SEA 注入 → GUI 子系统)');
|
|
263
|
+
ensureDir(outAbs);
|
|
264
|
+
const finalExe = path.join(outAbs, fileBase + '.exe');
|
|
265
|
+
const exe = path.join(seaDir, fileBase + '.exe');
|
|
266
|
+
if (fs.existsSync(exe)) fs.unlinkSync(exe);
|
|
267
|
+
fs.copyFileSync(process.execPath, exe);
|
|
268
|
+
|
|
269
|
+
// 4a) 品牌(图标 + FileDescription)——在 postject 之前,失败仅告警不阻断。
|
|
270
|
+
try {
|
|
271
|
+
const gen = await renderIconPngs({ projectRoot, iconRel: cfg.icon, outDir: path.join(seaDir, 'png') });
|
|
272
|
+
await brandRuntime(exe, {
|
|
273
|
+
projectRoot,
|
|
274
|
+
iconPngPaths: gen ? gen.abs : [],
|
|
275
|
+
name: cfg.name,
|
|
276
|
+
description: cfg.description,
|
|
277
|
+
version: cfg.version,
|
|
278
|
+
});
|
|
279
|
+
} catch (e) {
|
|
280
|
+
warn(`品牌注入跳过(不影响运行):${e.message}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 4b) 注入 SEA 负载(rcedit 已改写资源段,postject 追加 NODE_SEA_BLOB 节,二者共存)。
|
|
284
|
+
await postject(exe, 'NODE_SEA_BLOB', fs.readFileSync(blob), {
|
|
285
|
+
sentinelFuse: 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2',
|
|
286
|
+
macFailsafe: false,
|
|
287
|
+
kind: 'node-sea',
|
|
288
|
+
});
|
|
289
|
+
ok(`已注入 SEA 负载 → ${path.basename(exe)}`);
|
|
290
|
+
|
|
291
|
+
// 4c) GUI 子系统补丁(双击不弹控制台窗口)——须在 postject 之后,避免注入回写覆盖字节补丁。
|
|
292
|
+
if (toGuiSubsystem(exe)) ok('已切为 GUI 子系统(双击不弹控制台窗口)');
|
|
293
|
+
fs.copyFileSync(exe, finalExe);
|
|
294
|
+
ok(`产物:${finalExe}(${fmtMB(fs.statSync(finalExe).size)})`);
|
|
295
|
+
return finalExe;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = { packSea, makeBootstrap, locateNatives, walkFiles };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-flux-desktop-packer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Package a react-native-flux-desktop app into a self-contained Windows .exe (Go launcher + go:embed payload). Install as a devDependency, add `output`/`main` to app.json, then run `flux-pack`.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "keyon",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"os": [
|
|
32
32
|
"win32"
|
|
33
33
|
],
|
|
34
|
+
"optionalDependencies": {
|
|
35
|
+
"rcedit": "^4.0.1",
|
|
36
|
+
"esbuild": "^0.28.2",
|
|
37
|
+
"postject": "^1.0.0-alpha.6"
|
|
38
|
+
},
|
|
34
39
|
"keywords": [
|
|
35
40
|
"react-native-flux-desktop",
|
|
36
41
|
"packer",
|