react-native-flux-desktop-packer 0.1.0
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/LICENSE +21 -0
- package/README.md +111 -0
- package/README.zh-CN.md +111 -0
- package/bin/flux-pack.js +51 -0
- package/launcher/go.mod +3 -0
- package/launcher/main.go +166 -0
- package/launcher/payload.zip +0 -0
- package/lib/config.js +142 -0
- package/lib/icon.js +88 -0
- package/lib/index.js +156 -0
- package/lib/util.js +91 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keyon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# react-native-flux-desktop-packer
|
|
2
|
+
|
|
3
|
+
[**English**](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/react-native-flux-desktop-packer"><img src="https://img.shields.io/npm/v/react-native-flux-desktop-packer?style=flat-square&color=cb3837" alt="npm version"></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/react-native-flux-desktop-packer"><img src="https://img.shields.io/npm/dm/react-native-flux-desktop-packer?style=flat-square" alt="npm downloads"></a>
|
|
8
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT license"></a>
|
|
9
|
+
<img src="https://img.shields.io/badge/platform-win32-lightgrey?style=flat-square" alt="win32 only">
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
Turn a [react-native-flux-desktop](https://www.npmjs.com/package/react-native-flux-desktop) app into a **single self-contained Windows `.exe`** — no Electron, no installer, no Node.js required on the target machine. End users double-click one file and get a native window.
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
app.json ──▶ [1] stage payload dist/ + node_modules/ + assets/ + your node.exe
|
|
18
|
+
──▶ [2] zip payload.zip (Optimal deflate)
|
|
19
|
+
──▶ [3] icon app.png → .syso via go-winres (optional)
|
|
20
|
+
──▶ [4] go build Go launcher with go:embed payload + -H windowsgui
|
|
21
|
+
──▶ [5] deliver <output>/<Name>-<version>.exe
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The launcher is a tiny Go binary with the whole runtime **embedded** (`go:embed`). On first launch it self-extracts to `%LOCALAPPDATA%\<AppName>\app-<content-hash>\` (with zip-slip protection, atomic rename), then runs your entry JS with the bundled `node.exe`. The hash key means a new build re-extracts automatically, while unchanged builds open instantly.
|
|
25
|
+
|
|
26
|
+
No Go on the build machine? The packer **falls back** to a portable folder + `run.cmd` — the app still runs, it's just not one file.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install --save-dev react-native-flux-desktop-packer
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requirements (build machine): Node.js ≥ 18 on Windows. Optional: [Go](https://go.dev) for the single-exe path, [`go-winres`](https://github.com/tc-hib/go-winres) for a custom exe icon — both degrade gracefully when missing.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
**1.** Make sure your app has an `app.json` at the project root:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"name": "MyApp",
|
|
43
|
+
"version": "0.1.0",
|
|
44
|
+
"icon": "assets/icon.png",
|
|
45
|
+
"main": "dist/App.js",
|
|
46
|
+
"output": "output"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Field | Used for |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `name` | window title, exe file name, cache & data directory name (**required**) |
|
|
53
|
+
| `version` | exe file name suffix + Windows version resource |
|
|
54
|
+
| `icon` | PNG embedded as the exe icon (optional; default icon otherwise) |
|
|
55
|
+
| `main` | compiled entry JS; falls back to `package.json`'s `main` |
|
|
56
|
+
| `output` | where the exe lands (default `build`) |
|
|
57
|
+
| `pack` | payload whitelist, default `["dist","node_modules","assets","package.json","app.json"]` |
|
|
58
|
+
|
|
59
|
+
**2.** Build your app, then pack:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npm run build # your tsc step — produces dist/
|
|
63
|
+
npx flux-pack # → output/MyApp-0.1.0.exe
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
CLI flags:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
flux-pack pack the project in the current directory
|
|
70
|
+
flux-pack --project <dir> point at another project root
|
|
71
|
+
flux-pack --keep keep the intermediate .flux-pack/ work dir (debugging)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Programmatic API:**
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const { pack } = require('react-native-flux-desktop-packer');
|
|
78
|
+
await pack({ projectRoot: __dirname, keep: false });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## What ships in the payload
|
|
82
|
+
|
|
83
|
+
The stage copies your `dist/`, `node_modules/`, `assets/`, `package.json` and `app.json`, plus the **`node.exe` running the packer** (N-API is ABI-stable across majors, so the bundled node loads your `.node` addon fine). Platform-specific prebuilds that can't run on win32-x64 (darwin/linux canvas binaries, `ffmpeg-static`) are filtered out to keep the exe small.
|
|
84
|
+
|
|
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
|
+
|
|
87
|
+
## Pre-flight validation
|
|
88
|
+
|
|
89
|
+
The packer fails fast, before copying anything:
|
|
90
|
+
|
|
91
|
+
- missing `app.json` or `name` → error with the exact fix
|
|
92
|
+
- entry JS does not exist → "run your build first"
|
|
93
|
+
- entry JS is **older than any source file** (you forgot to recompile) → error naming both files and mtimes — this one has saved more than one release
|
|
94
|
+
|
|
95
|
+
## Repository layout
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
bin/flux-pack.js CLI entry (npx flux-pack)
|
|
99
|
+
lib/ config reader / icon builder / orchestration
|
|
100
|
+
launcher/ Go launcher source + placeholder payload.zip (go:embed stub)
|
|
101
|
+
test/entry-check.js smoke test for the entry validator
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Related
|
|
105
|
+
|
|
106
|
+
- [react-native-flux-desktop](https://www.npmjs.com/package/react-native-flux-desktop) — the desktop rendering stack this packs apps for.
|
|
107
|
+
- [react-native-flux](https://github.com/keyonByIos/react-native-flux) — parent repository.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
[MIT](./LICENSE)
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# react-native-flux-desktop-packer(中文)
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | **简体中文**
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/react-native-flux-desktop-packer"><img src="https://img.shields.io/npm/v/react-native-flux-desktop-packer?style=flat-square&color=cb3837" alt="npm version"></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/react-native-flux-desktop-packer"><img src="https://img.shields.io/npm/dm/react-native-flux-desktop-packer?style=flat-square" alt="npm downloads"></a>
|
|
8
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT license"></a>
|
|
9
|
+
<img src="https://img.shields.io/badge/platform-win32-lightgrey?style=flat-square" alt="win32 only">
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
把 [react-native-flux-desktop](https://www.npmjs.com/package/react-native-flux-desktop) 应用打包成**单个自包含的 Windows `.exe`**——不用 Electron、不需要安装器、目标机器也不需要装 Node.js。终端用户双击一个文件,直接得到原生窗口。
|
|
13
|
+
|
|
14
|
+
## 工作原理
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
app.json ──▶ [1] 组装载 dist/ + node_modules/ + assets/ + 你的 node.exe
|
|
18
|
+
──▶ [2] 压缩 payload.zip(Optimal deflate)
|
|
19
|
+
──▶ [3] 图标 app.png → .syso(经 go-winres,可选)
|
|
20
|
+
──▶ [4] go build Go 启动器 go:embed 内嵌负载 + -H windowsgui
|
|
21
|
+
──▶ [5] 交付 <output>/<Name>-<version>.exe
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
启动器是一个极小的 Go 二进制,整个运行时**内嵌**其中(`go:embed`)。首次启动时自解压到 `%LOCALAPPDATA%\<AppName>\app-<内容哈希>\`(含 zip-slip 防护、原子 rename),然后用捆绑的 `node.exe` 运行入口 JS。按内容哈希缓存意味着:新版本自动重解压,未变化的构建秒开。
|
|
25
|
+
|
|
26
|
+
打包机没装 Go?packer 会**自动降级**为「便携文件夹 + run.cmd」——应用照样跑,只是不再是单文件。
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install --save-dev react-native-flux-desktop-packer
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
要求(打包机):Windows + Node.js ≥ 18。可选:[Go](https://go.dev)(单 exe 路径)、[`go-winres`](https://github.com/tc-hib/go-winres)(自定义 exe 图标)——缺失时都会优雅降级。
|
|
35
|
+
|
|
36
|
+
## 使用
|
|
37
|
+
|
|
38
|
+
**1.** 项目根目录准备 `app.json`:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"name": "MyApp",
|
|
43
|
+
"version": "0.1.0",
|
|
44
|
+
"icon": "assets/icon.png",
|
|
45
|
+
"main": "dist/App.js",
|
|
46
|
+
"output": "output"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| 字段 | 用途 |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `name` | 窗口标题、exe 文件名、缓存与数据目录名(**必填**) |
|
|
53
|
+
| `version` | exe 文件名后缀 + Windows 版本资源 |
|
|
54
|
+
| `icon` | 嵌入 exe 的 PNG 图标(可选,缺省用默认图标) |
|
|
55
|
+
| `main` | 编译后的入口 JS;缺省回落 `package.json` 的 `main` |
|
|
56
|
+
| `output` | 产物落地目录(默认 `build`) |
|
|
57
|
+
| `pack` | 负载白名单,默认 `["dist","node_modules","assets","package.json","app.json"]` |
|
|
58
|
+
|
|
59
|
+
**2.** 先编译应用,再打包:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npm run build # 你的 tsc 步骤——产出 dist/
|
|
63
|
+
npx flux-pack # → output/MyApp-0.1.0.exe
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
CLI 参数:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
flux-pack 在当前目录的项目打包(读 app.json)
|
|
70
|
+
flux-pack --project <dir> 指定项目根
|
|
71
|
+
flux-pack --keep 保留中间工作目录 .flux-pack/(调试用)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**编程式 API:**
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const { pack } = require('react-native-flux-desktop-packer');
|
|
78
|
+
await pack({ projectRoot: __dirname, keep: false });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 负载里装了什么
|
|
82
|
+
|
|
83
|
+
stage 阶段会拷贝你的 `dist/`、`node_modules/`、`assets/`、`package.json`、`app.json`,外加**正在运行 packer 的那个 `node.exe`**(N-API 跨大版本 ABI 稳定,捆绑的 node 加载你的 `.node` 原生插件没有问题)。win32-x64 上用不到的平台专属预编译包(darwin/linux 的 canvas 二进制、`ffmpeg-static`)会被过滤掉以控制体积。
|
|
84
|
+
|
|
85
|
+
运行时启动器会注入 `FLUX_PACKAGED=1` 和 `FLUX_APP_DIR=<AppName>`,库会据此把 KV/日志数据路由到 `%APPDATA%\<AppName>\`——exe 放进 `Program Files` 也没有可写路径问题。
|
|
86
|
+
|
|
87
|
+
## 打包前硬校验
|
|
88
|
+
|
|
89
|
+
复制动作开始之前,packer 会快速失败:
|
|
90
|
+
|
|
91
|
+
- 缺 `app.json` 或缺 `name` → 报错并给出修复动作
|
|
92
|
+
- 入口 JS 不存在 → 提示先执行编译
|
|
93
|
+
- 入口 JS 比任何源文件都**旧**(忘了重新编译)→ 报错并列出两个文件与修改时间——这一条救过不止一次发版
|
|
94
|
+
|
|
95
|
+
## 仓库结构
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
bin/flux-pack.js CLI 入口(npx flux-pack)
|
|
99
|
+
lib/ 配置读取 / 图标构建 / 主流程编排
|
|
100
|
+
launcher/ Go 启动器源码 + 占位 payload.zip(满足 go:embed 静态检查)
|
|
101
|
+
test/entry-check.js 入口校验器的冒烟测试
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 相关仓库
|
|
105
|
+
|
|
106
|
+
- [react-native-flux-desktop](https://www.npmjs.com/package/react-native-flux-desktop) —— 本工具打包的对象:桌面渲染栈。
|
|
107
|
+
- [react-native-flux](https://github.com/keyonByIos/react-native-flux) —— 父仓库。
|
|
108
|
+
|
|
109
|
+
## 许可证
|
|
110
|
+
|
|
111
|
+
[MIT](./LICENSE)
|
package/bin/flux-pack.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// flux-pack —— react-native-flux-desktop 打包 CLI。
|
|
3
|
+
// 用法(在含 app.json 的项目根执行):
|
|
4
|
+
// flux-pack # 读当前目录 app.json,产 exe 到 app.json.output
|
|
5
|
+
// flux-pack --project <dir> # 指定项目根
|
|
6
|
+
// flux-pack --keep # 保留中间工作目录 .flux-pack(调试用)
|
|
7
|
+
'use strict';
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { pack } = require('../lib');
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const opts = { projectRoot: process.cwd(), keep: false };
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (a === '--project' || a === '-p') opts.projectRoot = path.resolve(argv[++i] || '.');
|
|
16
|
+
else if (a === '--keep') opts.keep = true;
|
|
17
|
+
else if (a === '--help' || a === '-h') opts.help = true;
|
|
18
|
+
}
|
|
19
|
+
return opts;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function help() {
|
|
23
|
+
console.log(
|
|
24
|
+
[
|
|
25
|
+
'flux-pack —— 把 react-native-flux-desktop 应用打包成自包含 Windows exe',
|
|
26
|
+
'',
|
|
27
|
+
'用法:',
|
|
28
|
+
' flux-pack 在当前项目根打包(读 app.json)',
|
|
29
|
+
' flux-pack --project <dir> 指定项目根',
|
|
30
|
+
' flux-pack --keep 保留中间目录 .flux-pack',
|
|
31
|
+
'',
|
|
32
|
+
'app.json 需含:name / version / icon / output / main(main 缺省回落 package.json.main)',
|
|
33
|
+
].join('\n'),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
39
|
+
if (opts.help) {
|
|
40
|
+
help();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
await pack(opts);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error('\n\x1b[31m✘ 打包失败:\x1b[0m' + (e && e.message ? e.message : e));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
main();
|
package/launcher/go.mod
ADDED
package/launcher/main.go
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// 通用自解压启动器(由 react-native-flux-desktop-packer 构建时注入参数)。
|
|
2
|
+
// 把整个运行目录(node.exe + dist + 原生插件 + node_modules)作为 payload.zip 内嵌进本 exe。
|
|
3
|
+
// 双击时解压到 %LOCALAPPDATA%\<AppName>\app-<hash>\(按内容哈希缓存,命中即秒开),
|
|
4
|
+
// 再从缓存目录用 node.exe 跑入口 JS。对外只需这一个 exe,可放任意位置、免装 Node。
|
|
5
|
+
//
|
|
6
|
+
// 构建期经 -ldflags "-X main.Entry=... -X main.AppName=..." 注入:
|
|
7
|
+
//
|
|
8
|
+
// Entry 缓存根下的相对入口 JS 路径(如 dist\Gallery.js)
|
|
9
|
+
// AppName 应用名(用于缓存目录 %LOCALAPPDATA%\<AppName>\)
|
|
10
|
+
// ExeName 出错弹框标题(一般 = 产物 exe 名)
|
|
11
|
+
//
|
|
12
|
+
// GUI 子系统编译(-H windowsgui),HideWindow 隐藏 node 控制台;出错弹框提示。
|
|
13
|
+
package main
|
|
14
|
+
|
|
15
|
+
import (
|
|
16
|
+
"archive/zip"
|
|
17
|
+
"bytes"
|
|
18
|
+
"crypto/sha256"
|
|
19
|
+
_ "embed"
|
|
20
|
+
"encoding/hex"
|
|
21
|
+
"fmt"
|
|
22
|
+
"io"
|
|
23
|
+
"os"
|
|
24
|
+
"os/exec"
|
|
25
|
+
"path/filepath"
|
|
26
|
+
"strings"
|
|
27
|
+
"syscall"
|
|
28
|
+
"unsafe"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// payload 由构建期 go:embed 注入的 payload.zip(真实负载在 launcher-build 目录,
|
|
32
|
+
// launcher/ 下同名为满足 go:embed 静态检查的占位空 zip,构建时不会被使用)。
|
|
33
|
+
//
|
|
34
|
+
//go:embed payload.zip
|
|
35
|
+
var payload []byte
|
|
36
|
+
|
|
37
|
+
// 构建期注入的默认值;留空则回落到通用默认。
|
|
38
|
+
var (
|
|
39
|
+
Entry = "dist\\main.js"
|
|
40
|
+
AppName = "Flux"
|
|
41
|
+
ExeName = "Flux"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
var (
|
|
45
|
+
user32 = syscall.NewLazyDLL("user32.dll")
|
|
46
|
+
msgBoxW = user32.NewProc("MessageBoxW")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
func showFatal(title, msg string) {
|
|
50
|
+
tp, _ := syscall.UTF16PtrFromString(title)
|
|
51
|
+
mp, _ := syscall.UTF16PtrFromString(msg)
|
|
52
|
+
msgBoxW.Call(0, uintptr(unsafe.Pointer(mp)), uintptr(unsafe.Pointer(tp)), 0x10) // MB_ICONERROR
|
|
53
|
+
os.Exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 按内嵌负载哈希定缓存目录,内容变了自动重解
|
|
57
|
+
func cacheDir() (string, error) {
|
|
58
|
+
base := os.Getenv("LOCALAPPDATA")
|
|
59
|
+
if base == "" {
|
|
60
|
+
base = os.Getenv("TEMP")
|
|
61
|
+
}
|
|
62
|
+
if base == "" {
|
|
63
|
+
h, _ := os.UserHomeDir()
|
|
64
|
+
base = filepath.Join(h, ".cache")
|
|
65
|
+
}
|
|
66
|
+
sum := sha256.Sum256(payload)
|
|
67
|
+
tag := hex.EncodeToString(sum[:])[:12]
|
|
68
|
+
app := sanitize(AppName)
|
|
69
|
+
if app == "" {
|
|
70
|
+
app = "Flux"
|
|
71
|
+
}
|
|
72
|
+
return filepath.Join(base, app, "app-"+tag), nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 去掉文件系统/注册表不友好的字符,用于目录名
|
|
76
|
+
func sanitize(s string) string {
|
|
77
|
+
repl := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "-", "?-", "\"-", "<", "-", ">", "-", "|", "-")
|
|
78
|
+
return strings.TrimSpace(repl.Replace(s))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 解压到临时目录再原子 rename,避免半解压损坏;含 zip-slip 防护
|
|
82
|
+
func extractTo(dest string) error {
|
|
83
|
+
zr, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
|
|
84
|
+
if err != nil {
|
|
85
|
+
return err
|
|
86
|
+
}
|
|
87
|
+
tmp := dest + ".tmp"
|
|
88
|
+
_ = os.RemoveAll(tmp)
|
|
89
|
+
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
|
90
|
+
return err
|
|
91
|
+
}
|
|
92
|
+
prefix := filepath.Clean(tmp) + string(os.PathSeparator)
|
|
93
|
+
for _, f := range zr.File {
|
|
94
|
+
target := filepath.Join(tmp, f.Name)
|
|
95
|
+
if filepath.Clean(target) != filepath.Clean(tmp) && !strings.HasPrefix(filepath.Clean(target), prefix) {
|
|
96
|
+
return fmt.Errorf("非法压缩包路径: %s", f.Name)
|
|
97
|
+
}
|
|
98
|
+
if f.FileInfo().IsDir() {
|
|
99
|
+
_ = os.MkdirAll(target, 0o755)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
103
|
+
return err
|
|
104
|
+
}
|
|
105
|
+
rc, err := f.Open()
|
|
106
|
+
if err != nil {
|
|
107
|
+
return err
|
|
108
|
+
}
|
|
109
|
+
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
|
110
|
+
if err != nil {
|
|
111
|
+
rc.Close()
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
_, cpErr := io.Copy(out, rc)
|
|
115
|
+
out.Close()
|
|
116
|
+
rc.Close()
|
|
117
|
+
if cpErr != nil {
|
|
118
|
+
return cpErr
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
_ = os.RemoveAll(dest)
|
|
122
|
+
return os.Rename(tmp, dest)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func main() {
|
|
126
|
+
if len(payload) == 0 {
|
|
127
|
+
showFatal(ExeName, "内嵌负载为空(构建未打包 payload.zip)")
|
|
128
|
+
}
|
|
129
|
+
dest, err := cacheDir()
|
|
130
|
+
if err != nil {
|
|
131
|
+
showFatal(ExeName, "无法确定缓存目录:\n"+err.Error())
|
|
132
|
+
}
|
|
133
|
+
ready := filepath.Join(dest, ".ready")
|
|
134
|
+
if _, err := os.Stat(ready); err != nil {
|
|
135
|
+
if err := extractTo(dest); err != nil {
|
|
136
|
+
showFatal(ExeName, "首次解压失败:\n"+err.Error())
|
|
137
|
+
}
|
|
138
|
+
_ = os.WriteFile(ready, []byte("ok"), 0o644)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
node := filepath.Join(dest, "node.exe")
|
|
142
|
+
entry := filepath.Join(dest, Entry)
|
|
143
|
+
if _, err := os.Stat(node); err != nil {
|
|
144
|
+
showFatal(ExeName, "解压后缺少 node.exe:\n"+node)
|
|
145
|
+
}
|
|
146
|
+
if _, err := os.Stat(entry); err != nil {
|
|
147
|
+
showFatal(ExeName, "解压后缺少入口:\n"+entry)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
args := append([]string{entry}, os.Args[1:]...)
|
|
151
|
+
cmd := exec.Command(node, args...)
|
|
152
|
+
cmd.Dir = dest // cwd=缓存根,资源按 process.cwd 相对解析;插件 __dirname 约束天然满足
|
|
153
|
+
// 关键:置 FLUX_PACKAGED=1,让库的 db.ts dataDir() 走 %APPDATA% 分支,
|
|
154
|
+
// 而非 dev 分支去写不存在的 src/。再透传 FLUX_APP_DIR=<AppName>(已 sanitize,与缓存目录同源),
|
|
155
|
+
// 使数据/日志落到按应用隔离的 %APPDATA%\<AppName>\,避免同框架多项目撞车。继承原环境再追加。
|
|
156
|
+
dataAppName := sanitize(AppName)
|
|
157
|
+
if dataAppName == "" {
|
|
158
|
+
dataAppName = "Flux"
|
|
159
|
+
}
|
|
160
|
+
cmd.Env = append(os.Environ(), "FLUX_PACKAGED=1", "FLUX_APP_DIR="+dataAppName)
|
|
161
|
+
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
162
|
+
if err := cmd.Start(); err != nil {
|
|
163
|
+
showFatal(ExeName, "启动失败:\n"+err.Error())
|
|
164
|
+
}
|
|
165
|
+
_ = cmd.Wait()
|
|
166
|
+
}
|
|
Binary file
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// config.js —— 读取并规整 app.json(消费方项目根)。缺字段回落内置默认,缺文件直接抛清晰错误。
|
|
2
|
+
// app.json 约定字段:
|
|
3
|
+
// name 应用名(窗口标题 / exe 文件名 / 缓存目录名)
|
|
4
|
+
// description 描述(写进 exe 版本资源 FileDescription)
|
|
5
|
+
// version 版本号(写进 exe 版本资源)
|
|
6
|
+
// icon 图标 PNG 路径(相对项目根;缺失则 exe 用默认图标,不阻断打包)
|
|
7
|
+
// output 打包产物目录(相对项目根或绝对;默认 "build")
|
|
8
|
+
// main 编译后入口 JS(相对项目根;缺省回落 package.json 的 main)
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
function readJson(p) {
|
|
13
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadConfig(projectRoot) {
|
|
17
|
+
const appJsonPath = path.join(projectRoot, 'app.json');
|
|
18
|
+
if (!fs.existsSync(appJsonPath)) {
|
|
19
|
+
throw new Error(`未找到 app.json:${appJsonPath}\n(打包器需要在项目根放一个 app.json,至少含 name 字段)`);
|
|
20
|
+
}
|
|
21
|
+
const app = readJson(appJsonPath);
|
|
22
|
+
if (!app.name || typeof app.name !== 'string') {
|
|
23
|
+
throw new Error('app.json 缺少必填字段 name(应用名)');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// package.json 的 main 作为入口兜底
|
|
27
|
+
let pkgMain;
|
|
28
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
29
|
+
if (fs.existsSync(pkgPath)) {
|
|
30
|
+
try {
|
|
31
|
+
pkgMain = readJson(pkgPath).main;
|
|
32
|
+
} catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const main = app.main || pkgMain;
|
|
38
|
+
if (!main || typeof main !== 'string') {
|
|
39
|
+
throw new Error('无法确定入口 JS:请在 app.json 加 "main"(或在 package.json 写 "main")');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const version = typeof app.version === 'string' ? app.version : '0.0.0';
|
|
43
|
+
const output = app.output || 'build';
|
|
44
|
+
|
|
45
|
+
const cfg = {
|
|
46
|
+
projectRoot,
|
|
47
|
+
name: app.name,
|
|
48
|
+
description: typeof app.description === 'string' ? app.description : app.name,
|
|
49
|
+
version,
|
|
50
|
+
icon: typeof app.icon === 'string' ? app.icon : null,
|
|
51
|
+
outputAbs: path.isAbsolute(output) ? output : path.join(projectRoot, output),
|
|
52
|
+
outputRel: output,
|
|
53
|
+
// 归一化入口为平台分隔(Go 里用 dist\\Gallery.js)
|
|
54
|
+
mainRel: main.split('/').join(path.sep),
|
|
55
|
+
appJson: app,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// 打包前一致性校验(尽早失败,避免装完半个负载才报入口问题):
|
|
59
|
+
// 入口产物必须存在且非陈旧——曾踩「app.json 指向历史遗留 dist/Gallery.js,文件在但早于源码,打包成功却跑错/旧产物」。
|
|
60
|
+
validateEntry(cfg, projectRoot);
|
|
61
|
+
|
|
62
|
+
return cfg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 入口产物校验:① 存在(不存在=配置错或未编译);② 非陈旧(产物 mtime 不早于任何源码文件,否则=改了源码没重编)。
|
|
66
|
+
// 二者均为硬错,因为一个旧/错的入口正是本次要拦的事故;报错信息直接给出修复动作(重编 + 校正 main)。
|
|
67
|
+
function validateEntry(cfg, projectRoot) {
|
|
68
|
+
const entryAbs = path.join(projectRoot, cfg.mainRel);
|
|
69
|
+
if (!fs.existsSync(entryAbs)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`入口产物不存在:${cfg.mainRel}\n` +
|
|
72
|
+
`(app.json/package.json 的 main 指向的文件缺失;请先在项目内编译出产物,例如 npm run build,再确认 main 指向当前入口)`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (!fs.statSync(entryAbs).isFile()) {
|
|
76
|
+
throw new Error(`入口不是一个文件:${cfg.mainRel}`);
|
|
77
|
+
}
|
|
78
|
+
const entryMtime = fs.statSync(entryAbs).mtimeMs;
|
|
79
|
+
const newest = newestSourceMtime(projectRoot);
|
|
80
|
+
if (newest && newest.mtimeMs > entryMtime) {
|
|
81
|
+
const rel = path.relative(projectRoot, newest.file);
|
|
82
|
+
throw new Error(
|
|
83
|
+
`入口产物疑似陈旧:${cfg.mainRel}(改于 ${new Date(entryMtime).toISOString()})早于源码 ${rel}(改于 ${new Date(newest.mtimeMs).toISOString()})。\n` +
|
|
84
|
+
`(产物没重编译,或 main 指向了历史遗留文件;请先 npm run build 重新产出,再核对 app.json 的 main)`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 扫描项目源码(排除 node_modules/dist/output/.flux-pack 等产物与构建目录),返回修改时间最新的源文件。
|
|
90
|
+
// 只计 .ts/.tsx(本栈为 TS→JS 管线,dist 由它们编译而来);不计根级 .js 配置(metro/babel 等),
|
|
91
|
+
// 否则打包器自身触碰配置文件会造成假陈旧。
|
|
92
|
+
function newestSourceMtime(projectRoot) {
|
|
93
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'output', 'target', 'release', 'assets', '.git', '.flux-pack']);
|
|
94
|
+
const SRC_EXT = new Set(['.ts', '.tsx']);
|
|
95
|
+
let best = null;
|
|
96
|
+
const walk = (dir) => {
|
|
97
|
+
let items;
|
|
98
|
+
try {
|
|
99
|
+
items = fs.readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const it of items) {
|
|
104
|
+
const abs = path.join(dir, it.name);
|
|
105
|
+
if (it.isDirectory()) {
|
|
106
|
+
if (SKIP_DIRS.has(it.name) || it.name.startsWith('.')) continue;
|
|
107
|
+
walk(abs);
|
|
108
|
+
} else if (SRC_EXT.has(path.extname(it.name))) {
|
|
109
|
+
let st;
|
|
110
|
+
try {
|
|
111
|
+
st = fs.statSync(abs);
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!best || st.mtimeMs > best.mtimeMs) best = { file: abs, mtimeMs: st.mtimeMs };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(projectRoot);
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 版本号补成 4 段(winres fixed file_version 需 a.b.c.d)
|
|
124
|
+
function fourPart(v) {
|
|
125
|
+
const parts = String(v)
|
|
126
|
+
.replace(/^v/, '')
|
|
127
|
+
.split(/[.\-+]/)
|
|
128
|
+
.filter((s) => /^\d+$/.test(s))
|
|
129
|
+
.map((s) => String(parseInt(s, 10) & 0xffff));
|
|
130
|
+
while (parts.length < 4) parts.push('0');
|
|
131
|
+
return parts.slice(0, 4).join('.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// exe / 目录名净化:去非法字符、空格转 '-'
|
|
135
|
+
function safeName(s) {
|
|
136
|
+
return String(s)
|
|
137
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '')
|
|
138
|
+
.replace(/\s+/g, '-')
|
|
139
|
+
.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { loadConfig, fourPart, safeName, validateEntry, newestSourceMtime };
|
package/lib/icon.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// icon.js —— 把 app.json.icon 指的 PNG 编成 exe 资源(.syso)。
|
|
2
|
+
// 流程:@napi-rs/canvas(从消费方 node_modules 解析)缩放多尺寸 → 写 winres/ + winres.json → go-winres make。
|
|
3
|
+
// 任一前置缺失(无图标文件 / 无 canvas / 无 go-winres)都优雅降级返回 null,让打包继续(exe 用默认图标)。
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { ensureDir, which, run, warn, ok } = require('./util');
|
|
7
|
+
const { fourPart } = require('./config');
|
|
8
|
+
|
|
9
|
+
async function buildIconSyso({ projectRoot, iconRel, outDir, name, version, description }) {
|
|
10
|
+
if (!iconRel) {
|
|
11
|
+
warn('app.json 未配置 icon,跳过图标(exe 使用默认图标)');
|
|
12
|
+
return null;
|
|
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
|
+
ensureDir(outDir);
|
|
37
|
+
const winresDir = path.join(outDir, 'winres');
|
|
38
|
+
ensureDir(winresDir);
|
|
39
|
+
const SIZES = [16, 24, 32, 48, 64, 128, 256];
|
|
40
|
+
const img = await canvas.loadImage(iconPath);
|
|
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
|
+
}
|
|
52
|
+
|
|
53
|
+
const v = fourPart(version);
|
|
54
|
+
const json = {
|
|
55
|
+
RT_GROUP_ICON: { APP: { '0000': files } },
|
|
56
|
+
RT_VERSION: {
|
|
57
|
+
'#1': {
|
|
58
|
+
'0000': {
|
|
59
|
+
fixed: { file_version: v, product_version: v },
|
|
60
|
+
info: {
|
|
61
|
+
'0409': {
|
|
62
|
+
FileDescription: description,
|
|
63
|
+
ProductName: name,
|
|
64
|
+
OriginalFilename: name + '.exe',
|
|
65
|
+
FileVersion: v,
|
|
66
|
+
ProductVersion: v,
|
|
67
|
+
InternalName: name,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const jsonPath = path.join(winresDir, 'winres.json');
|
|
75
|
+
fs.writeFileSync(jsonPath, JSON.stringify(json, null, 2));
|
|
76
|
+
|
|
77
|
+
// go-winres make → 产出 rsrc_windows_amd64.syso 于 outDir
|
|
78
|
+
run(goWinres, ['make', '--in', jsonPath, '--out', path.join(outDir, 'rsrc'), '--arch', 'amd64'], { stdio: 'ignore' });
|
|
79
|
+
const syso = path.join(outDir, 'rsrc_windows_amd64.syso');
|
|
80
|
+
if (!fs.existsSync(syso)) {
|
|
81
|
+
warn('go-winres 未产出 .syso(跳过图标)');
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
ok(`图标已编入资源:${path.basename(syso)}`);
|
|
85
|
+
return syso;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { buildIconSyso };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// index.js —— react-native-flux-desktop-packer 主流程(Windows)。
|
|
2
|
+
// 读 app.json → 组装自包含负载(node.exe + dist + node_modules + assets + app.json)→ 压 payload.zip
|
|
3
|
+
// → go-winres 编图标 → go build(go:embed 内嵌负载)产单文件 exe → 落到 app.json.output。
|
|
4
|
+
// 前置:本机 Go(缺则回落「便携文件夹 + .cmd」,仍产可运行 app);图标需 go-winres(缺则默认图标)。
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { step, ok, warn, rmrf, ensureDir, copyTree, dirSize, fmtMB, which, run } = require('./util');
|
|
8
|
+
const { loadConfig, safeName } = require('./config');
|
|
9
|
+
const { buildIconSyso } = require('./icon');
|
|
10
|
+
|
|
11
|
+
const PKG_ROOT = path.join(__dirname, '..');
|
|
12
|
+
const LAUNCHER_DIR = path.join(PKG_ROOT, 'launcher');
|
|
13
|
+
|
|
14
|
+
// node_modules 里剔除:非 win32-x64 的 canvas 预编译包 + 可选大二进制(ffmpeg-static,缺失运行时自动降级)
|
|
15
|
+
function nodeModulesFilter(abs) {
|
|
16
|
+
const n = abs.split(path.sep).join('/');
|
|
17
|
+
if (/@napi-rs\/canvas-(darwin|linux)-/.test(n)) return false;
|
|
18
|
+
if (/\/@napi-rs\/canvas-(android|freebsd|openbsd|wasm32)-/.test(n)) return false;
|
|
19
|
+
if (/\/ffmpeg-static(\/|$)/.test(n)) return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function pack(options = {}) {
|
|
24
|
+
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
25
|
+
const keepWork = !!options.keep;
|
|
26
|
+
const cfg = loadConfig(projectRoot);
|
|
27
|
+
const exeBase = safeName(cfg.name) || 'App';
|
|
28
|
+
// 产物文件名带版本后缀;但注入的 AppName/ExeName 仍用无版本 exeBase(缓存目录稳定、错误框标题干净)
|
|
29
|
+
const fileBase = `${exeBase}-${safeName(cfg.version)}`;
|
|
30
|
+
|
|
31
|
+
console.log('\x1b[1mreact-native-flux-desktop-packer\x1b[0m');
|
|
32
|
+
console.log(` 项目根:${projectRoot}`);
|
|
33
|
+
console.log(` 应用名:${cfg.name} 版本:${cfg.version}`);
|
|
34
|
+
console.log(` 入口 :${cfg.mainRel}`);
|
|
35
|
+
console.log(` 产物到:${cfg.outputAbs}`);
|
|
36
|
+
|
|
37
|
+
const work = path.join(projectRoot, '.flux-pack');
|
|
38
|
+
const stage = path.join(work, 'stage');
|
|
39
|
+
const zip = path.join(work, 'payload.zip');
|
|
40
|
+
const buildDir = path.join(work, 'launcher-build');
|
|
41
|
+
rmrf(work);
|
|
42
|
+
ensureDir(stage);
|
|
43
|
+
|
|
44
|
+
// ---- 1) 组装载 ----
|
|
45
|
+
step('[1/5] 组装自包含负载');
|
|
46
|
+
const include = Array.isArray(cfg.appJson.pack) ? cfg.appJson.pack : ['dist', 'node_modules', 'assets', 'package.json', 'app.json'];
|
|
47
|
+
for (const item of include) {
|
|
48
|
+
const src = path.join(projectRoot, item);
|
|
49
|
+
if (!fs.existsSync(src)) {
|
|
50
|
+
warn(`跳过(不存在):${item}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const st = fs.lstatSync(src);
|
|
54
|
+
if (item === 'node_modules') {
|
|
55
|
+
copyTree(src, path.join(stage, item), (abs) => nodeModulesFilter(abs));
|
|
56
|
+
} else if (st.isDirectory()) {
|
|
57
|
+
copyTree(src, path.join(stage, item));
|
|
58
|
+
} else {
|
|
59
|
+
ensureDir(path.dirname(path.join(stage, item)));
|
|
60
|
+
fs.copyFileSync(src, path.join(stage, item));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// 捆绑当前 node.exe(napi ABI 稳定,跨大版本可加载)
|
|
64
|
+
fs.copyFileSync(process.execPath, path.join(stage, 'node.exe'));
|
|
65
|
+
ok(`node.exe 来自:${process.execPath}`);
|
|
66
|
+
|
|
67
|
+
// 入口存在性校验(相对 stage 根)
|
|
68
|
+
const entryInStage = path.join(stage, cfg.mainRel);
|
|
69
|
+
if (!fs.existsSync(entryInStage)) {
|
|
70
|
+
rmrf(work);
|
|
71
|
+
throw new Error(`入口 JS 不存在:${cfg.mainRel}\n(请先在项目里编译出 dist,例如 npm run build)`);
|
|
72
|
+
}
|
|
73
|
+
ok(`负载体积:${fmtMB(dirSize(stage))}`);
|
|
74
|
+
|
|
75
|
+
// ---- 2) 压 payload.zip ----
|
|
76
|
+
step('[2/5] 压缩负载 payload.zip');
|
|
77
|
+
run('powershell', [
|
|
78
|
+
'-NoProfile',
|
|
79
|
+
'-ExecutionPolicy', 'Bypass',
|
|
80
|
+
'-Command',
|
|
81
|
+
`Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::CreateFromDirectory('${stage}','${zip}',[System.IO.Compression.CompressionLevel]::Optimal,$false)`,
|
|
82
|
+
], { stdio: 'ignore' });
|
|
83
|
+
ok(`payload.zip = ${fmtMB(fs.statSync(zip).size)}`);
|
|
84
|
+
|
|
85
|
+
// ---- 3) 图标 ----
|
|
86
|
+
step('[3/5] 生成 exe 图标资源');
|
|
87
|
+
let syso = null;
|
|
88
|
+
try {
|
|
89
|
+
syso = await buildIconSyso({
|
|
90
|
+
projectRoot,
|
|
91
|
+
iconRel: cfg.icon,
|
|
92
|
+
outDir: work,
|
|
93
|
+
name: cfg.name,
|
|
94
|
+
version: cfg.version,
|
|
95
|
+
description: cfg.description,
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
warn(`图标处理失败(忽略,用默认图标):${e.message}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- 4) go build ----
|
|
102
|
+
step('[4/5] 编译启动器 exe');
|
|
103
|
+
const goExe = which('go');
|
|
104
|
+
ensureDir(cfg.outputAbs);
|
|
105
|
+
let delivered;
|
|
106
|
+
if (!goExe) {
|
|
107
|
+
warn('未检测到 Go,无法产单文件 exe → 回落「便携文件夹 + .cmd」(同样可直接运行)');
|
|
108
|
+
delivered = emitPortableFolder({ stage, outAbs: cfg.outputAbs, exeBase: fileBase, mainRel: cfg.mainRel });
|
|
109
|
+
} else {
|
|
110
|
+
ensureDir(buildDir);
|
|
111
|
+
fs.copyFileSync(path.join(LAUNCHER_DIR, 'main.go'), path.join(buildDir, 'main.go'));
|
|
112
|
+
fs.copyFileSync(path.join(LAUNCHER_DIR, 'go.mod'), path.join(buildDir, 'go.mod'));
|
|
113
|
+
fs.copyFileSync(zip, path.join(buildDir, 'payload.zip'));
|
|
114
|
+
if (syso) fs.copyFileSync(syso, path.join(buildDir, path.basename(syso)));
|
|
115
|
+
const outExe = path.join(buildDir, fileBase + '.exe');
|
|
116
|
+
// 用生成的 .cmd 显式控制引号:go 的 -ldflags 必须是「单个带引号字符串」,
|
|
117
|
+
// 而 Node 直接 spawn go 会把内层引号拆坏。值均用无空格 ASCII(exeBase / mainRel)。
|
|
118
|
+
const ld = `-H windowsgui -X main.Entry=${cfg.mainRel} -X main.AppName=${exeBase} -X main.ExeName=${exeBase}`;
|
|
119
|
+
const cmdFile = path.join(buildDir, 'build-launcher.cmd');
|
|
120
|
+
const goLine = `"${goExe}" build -trimpath -ldflags "${ld}" -o "${outExe}" .`;
|
|
121
|
+
fs.writeFileSync(cmdFile, ['@echo off', 'cd /d "' + buildDir + '"', goLine, ''].join('\r\n'), 'utf8');
|
|
122
|
+
const r = require('child_process').spawnSync('cmd.exe', ['/c', cmdFile], { cwd: buildDir, windowsHide: true, encoding: 'utf8' });
|
|
123
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
124
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
125
|
+
if (r.status !== 0) throw new Error('go build 失败(见上方输出)');
|
|
126
|
+
const finalExe = path.join(cfg.outputAbs, fileBase + '.exe');
|
|
127
|
+
fs.copyFileSync(outExe, finalExe);
|
|
128
|
+
ok(`exe = ${fmtMB(fs.statSync(finalExe).size)}`);
|
|
129
|
+
delivered = finalExe;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- 5) 收尾 ----
|
|
133
|
+
step('[5/5] 完成');
|
|
134
|
+
if (!keepWork) rmrf(work);
|
|
135
|
+
else warn(`保留工作目录:${work}`);
|
|
136
|
+
console.log('\n\x1b[32m✔ 打包完成 →\x1b[0m ' + delivered);
|
|
137
|
+
return { exe: delivered };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 无 Go 时的回落:把 stage 目录整体搬到 output/<name>/,写 run.cmd(置 FLUX_PACKAGED=1)
|
|
141
|
+
function emitPortableFolder({ stage, outAbs, exeBase, mainRel }) {
|
|
142
|
+
const dest = path.join(outAbs, exeBase);
|
|
143
|
+
rmrf(dest);
|
|
144
|
+
copyTree(stage, dest);
|
|
145
|
+
const cmd = [
|
|
146
|
+
'@echo off',
|
|
147
|
+
'cd /d "%~dp0"',
|
|
148
|
+
'set FLUX_PACKAGED=1',
|
|
149
|
+
`"%~dp0node.exe" "%~dp0${mainRel}" %*`,
|
|
150
|
+
'',
|
|
151
|
+
].join('\r\n');
|
|
152
|
+
fs.writeFileSync(path.join(dest, 'run.cmd'), cmd, 'utf8');
|
|
153
|
+
return dest;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { pack };
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// util.js —— 打包用的通用小工具:递归拷贝(可过滤)、命令探测、目录体积、日志。
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
function log(msg) {
|
|
7
|
+
console.log(msg);
|
|
8
|
+
}
|
|
9
|
+
function step(msg) {
|
|
10
|
+
console.log('\n\x1b[36m==> ' + msg + '\x1b[0m');
|
|
11
|
+
}
|
|
12
|
+
function ok(msg) {
|
|
13
|
+
console.log('\x1b[32m ' + msg + '\x1b[0m');
|
|
14
|
+
}
|
|
15
|
+
function warn(msg) {
|
|
16
|
+
console.log('\x1b[33m ! ' + msg + '\x1b[0m');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rmrf(p) {
|
|
20
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureDir(p) {
|
|
24
|
+
fs.mkdirSync(p, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 递归拷贝 src → dest。filter(relAbs, isDir) 返回 false 则跳过该项。
|
|
28
|
+
function copyTree(src, dest, filter) {
|
|
29
|
+
const st = fs.statSync(src);
|
|
30
|
+
if (st.isDirectory()) {
|
|
31
|
+
ensureDir(dest);
|
|
32
|
+
for (const name of fs.readdirSync(src)) {
|
|
33
|
+
const s = path.join(src, name);
|
|
34
|
+
const d = path.join(dest, name);
|
|
35
|
+
const isDir = fs.lstatSync(s).isDirectory();
|
|
36
|
+
if (filter && !filter(s, isDir)) continue;
|
|
37
|
+
copyTree(s, d, filter);
|
|
38
|
+
}
|
|
39
|
+
} else if (st.isFile()) {
|
|
40
|
+
ensureDir(path.dirname(dest));
|
|
41
|
+
fs.copyFileSync(src, dest);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 目录体积(字节)
|
|
46
|
+
function dirSize(p) {
|
|
47
|
+
let total = 0;
|
|
48
|
+
try {
|
|
49
|
+
for (const name of fs.readdirSync(p)) {
|
|
50
|
+
const s = path.join(p, name);
|
|
51
|
+
const st = fs.lstatSync(s);
|
|
52
|
+
if (st.isDirectory()) total += dirSize(s);
|
|
53
|
+
else if (st.isFile()) total += st.size;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
return total;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fmtMB(bytes) {
|
|
62
|
+
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 探测命令是否可用(返回可执行路径或 null)。cmd 可为绝对路径。
|
|
66
|
+
function which(cmd) {
|
|
67
|
+
if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null;
|
|
68
|
+
const probe = spawnSync(cmd, ['--version'], { windowsHide: true });
|
|
69
|
+
if (probe.error || probe.status !== 0) {
|
|
70
|
+
// 有些工具(go)用 version 而非 --version
|
|
71
|
+
const probe2 = spawnSync(cmd, ['version'], { windowsHide: true });
|
|
72
|
+
if (probe2.error || probe2.status !== 0) return null;
|
|
73
|
+
}
|
|
74
|
+
// 用 where 定位真实路径
|
|
75
|
+
const w = spawnSync('where', [cmd], { windowsHide: true });
|
|
76
|
+
if (!w.error && w.status === 0) {
|
|
77
|
+
const first = String(w.stdout).split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0];
|
|
78
|
+
if (first) return first;
|
|
79
|
+
}
|
|
80
|
+
return cmd; // 已知能跑,但拿不到绝对路径
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 运行命令,失败抛错。返回 stdout。
|
|
84
|
+
function run(cmd, args, opts) {
|
|
85
|
+
const r = spawnSync(cmd, args, Object.assign({ stdio: 'inherit', windowsHide: true }, opts || {}));
|
|
86
|
+
if (r.error) throw new Error(`执行失败 ${cmd}: ${r.error.message}`);
|
|
87
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} 退出码 ${r.status}`);
|
|
88
|
+
return r;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { log, step, ok, warn, rmrf, ensureDir, copyTree, dirSize, fmtMB, which, run };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-flux-desktop-packer",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "keyon",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/keyonByIos/react-native-flux-desktop-packer.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/keyonByIos/react-native-flux-desktop-packer#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/keyonByIos/react-native-flux-desktop-packer/issues"
|
|
14
|
+
},
|
|
15
|
+
"private": false,
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node test/entry-check.js"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"flux-pack": "bin/flux-pack.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"lib",
|
|
26
|
+
"launcher"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"os": [
|
|
32
|
+
"win32"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"react-native-flux-desktop",
|
|
36
|
+
"packer",
|
|
37
|
+
"electron-alternative",
|
|
38
|
+
"windows",
|
|
39
|
+
"launcher"
|
|
40
|
+
]
|
|
41
|
+
}
|