@yufengtadian/freedom-cli 1.12.15 → 1.12.17

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 CHANGED
@@ -1,9 +1,17 @@
1
1
  # freedom-cli
2
2
 
3
- Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.15)。
3
+ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.17)。
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
+ **v1.12.17 安全模式全面落地**:三档安全模式 `freedom security <none|basic|high>` 正式随包分发——high 档把 resources 加密为 `app.bin`(AES-256-CTR + HMAC-SHA256 + PBKDF2 密钥派生),配合 `.integrity` 完整性校验、anti-debug 与进程隐藏,磁盘无明文、篡改即拒运行;三平台预编译壳经 CI 重建分发,补齐 v1.12.16 仅重编 win-x64 壳的缺口。
8
+
9
+ **v1.12.16 自动更新 + 完整 CLI 模式 + 壳生命周期全面防御**:
10
+ - **自动检测版本并自动更新**:`freedom update` / `freedom check-update` 检测到新版本即自动执行 `npm install -g @yufengtadian/freedom-cli@latest` 升级,**不再需要手动执行 npm 命令**;每次命令执行成功后静默自检,发现新版自动更新(6 小时频控防骚扰,非全局安装给出明确升级指引);TUI 主菜单「检查 / 自动更新」同步接入;
11
+ - **完整 CLI 模式**:无参数运行 `freedom` 直接进入交互式完整 CLI(打包 / 新建项目 / 配置 / 壳管理 / 教程 / 自动更新 / 退出);管道与脚本环境自动降级打印帮助,不卡死;
12
+ - **壳生命周期全面防御(B51-B58 闭环)**:webview 全方法销毁防护、user32 proc 去重、WebView2 缺失可操作提示;`webview_create` 失败(如缺 WebKitGTK)返回 nil 后新壳走明确失败提示而非空指针崩溃;用户 `Bind` 的方法不再被 config.json 进程后端静默覆盖失效;help 文案与自动更新实现对齐,清理死代码;
13
+ - **安全加固(新增 high 安全模式,B59-B62 闭环)**:`freedom security <none|basic|high>` 三档安全模式,high 档把 resources 整体加密为 `app.bin`(AES-256-CTR + HMAC-SHA256,密钥经 PBKDF2 按应用派生),磁盘无明文 HTML/配置,壳内存解密 + `.integrity` 完整性校验(防整体替换 / 篡改 / exe 改名),另含 anti-debug(检测调试器即退出)与进程隐藏;详见下文「安全模式」章节。
14
+
7
15
  **v1.12.15 补发壳生命周期稳定性修复**:v1.12.14 的 npm 上架时间早于修复提交,上架版预编译壳未包含悬垂指针修复(窗口操作 / 代理端口触发随机退出的根因);本版正式把含 B50 修复的预编译壳随 npm 包分发——
8
16
  - 壳销毁生命周期修复:`webview.Destroy()` 后经 Emit / Quit / WindowHandle / binding 回调访问已释放 C 对象(悬垂指针)导致随机退出;新增 `destroyed` 原子标记,Dispatch / Eval / binding 回调销毁后一律跳过原生调用,`Run` 在 `Destroy` 前清空 `App.view`,事件与绑定回调加 `recover` 兜底;
9
17
  - 使用全局包构建的下游应用(如 DSH)升级到本版后,随机退出问题一并消除(可用二进制字符串指纹 `freedom: binding panic` 核对)。
@@ -89,6 +97,7 @@ freedom icon icon.ico
89
97
 
90
98
  # 4. 打包
91
99
  freedom build # 默认当前平台
100
+ freedom build --security high # 以 high 安全模式构建(resources 加密,见「安全模式」)
92
101
  freedom build --platform all # 三平台全量(win + mac + linux)
93
102
  freedom build --platform win-x64 # 仅 Windows
94
103
  freedom build --platform darwin-arm64 # 仅 macOS Apple Silicon
@@ -147,6 +156,7 @@ export default {
147
156
  minHeight: 300,
148
157
  center: true, // 启动居中
149
158
  debug: false, // 开发者工具
159
+ security: 'none', // 安全模式:'none'(明文)| 'basic'(明文+提示)| 'high'(resources 加密,见「安全模式」)
150
160
  titlebar: 'frameless', // frameless(默认,标题栏不存在,三按钮由前端自绘)| native
151
161
  icon: undefined, // 应用图标:Windows 用 .ico(推荐多尺寸),macOS 用 .icns
152
162
  outDir: 'dist', // 产物目录:'dist'(默认)| '.'(项目根目录)| 任意路径
@@ -159,6 +169,35 @@ export default {
159
169
  - macOS:`.icns` 在构建时放入 `.app/Contents/Resources` 并写入 Info.plist 的 `CFBundleIconFile`;
160
170
  - 未配置时使用壳默认图标。`freedom icon <path>` 可直接写入该配置。
161
171
 
172
+ ## 安全模式
173
+
174
+ 默认产物把前端 HTML 与配置以明文写入 `resources/`,任何人解包即可直接读取页面源码与配置。如需防止源码被轻易提取,提供三档安全模式:
175
+
176
+ | 模式 | 资源形态 | 破解难度 | 适用场景 |
177
+ | --- | --- | --- | --- |
178
+ | `none` | 明文 `index.html` + `config.json`(默认,兼容历史产物) | 解包即读 | 公开页面 / 调试 / 快速分发 |
179
+ | `basic` | 同上明文,构建时额外输出加固建议 | 低 | 需要提示、暂不加密 |
180
+ | `high` | 整体加密为 `resources/app.bin` + `.integrity`,磁盘**无任何明文** HTML/配置 | 高(需逆向壳 + 还原派生密钥) | 防源码提取、防资源篡改的正式分发 |
181
+
182
+ **切换方式**(二选一,`--security` 可临时覆盖配置文件):
183
+
184
+ ```bash
185
+ freedom security high # 写入 freedom.config.js 持久生效
186
+ freedom build --security high # 单次构建生效(不改配置)
187
+ freedom build # 读取配置中的 security 值
188
+ ```
189
+
190
+ **high 模式原理**:
191
+ - 构建时:前端 HTML + 配置序列化后,用 **AES-256-CTR** 加密为 `app.bin`(容器头 `FRDM1` + 16B IV + 16B 认证标签),并生成 `.integrity` 完整性清单(`app.bin` 与 `backend/**` 各文件的 HMAC-SHA256);
192
+ - 加密密钥由 **PBKDF2-HMAC-SHA256**(6 万次迭代)按「应用可执行文件名」派生,不同应用密钥不同,暴力破解成本高;
193
+ - 壳启动时:先校验 `.integrity`(防整体替换 / 篡改 / exe 改名),再恒定时间比对 HMAC 认证标签(Encrypt-then-MAC),最后内存中解密加载——**磁盘始终无明文**;
194
+ - 解密 / 校验失败即拒绝运行(不静默回退明文,防降级攻击);
195
+ - 另内置 **anti-debug**(`IsDebuggerPresent` / `CheckRemoteDebuggerPresent` 命中即退出)与进程隐藏加固。
196
+
197
+ **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中。更高强度建议:壳编译时设置 `-ldflags "-s -w"` 剥离符号表(CI 编译壳时已可选开启)、对核心业务保留服务端校验。若需"怎么都解不开",请把真正敏感的密钥 / 逻辑放到你的后端。
198
+
199
+ **互斥规则**:切换安全模式重新构建时,CLI 会自动清理另一模式的遗留产物(`app.bin`/`.integrity` 与明文 `index.html`/`config.json` 只能存其一),避免壳误加载旧资源。
200
+
162
201
  ## 前端
163
202
 
164
203
  前端是标准 Vite 项目,`vite build` 时通过 `vite-plugin-singlefile` 内联为单个 HTML。壳层注入全局对象 `window.freedom`:
@@ -181,8 +220,9 @@ window.freedom.window.minimize(); // 窗口控制
181
220
  ```
182
221
  freedom tui # 交互式终端界面
183
222
  freedom init <目录> [--force]
184
- freedom build [--platform win-x64|darwin-arm64|linux-x64|all] [--no-cache]
223
+ freedom build [--platform win-x64|darwin-arm64|linux-x64|all] [--no-cache] [--security none|basic|high]
185
224
  freedom titlebar <native|frameless>
225
+ freedom security <none|basic|high> # 设置安全模式(写入配置)
186
226
  freedom icon <path> # 设置应用图标(Windows 用 .ico,macOS 用 .icns)
187
227
  freedom config [get|set]
188
228
  freedom shell list|download <platform>|build <platform>
package/bin/freedom.js CHANGED
@@ -2,11 +2,21 @@
2
2
  'use strict';
3
3
 
4
4
  const { run } = require('../lib/cli');
5
- const { maybeNotifyUpdate } = require('../lib/update');
5
+ const { maybeAutoUpdate, formatUpdateResult } = require('../lib/update');
6
+ const theme = require('../lib/theme');
7
+
8
+ const cmd = process.argv[2];
9
+ // TUI(无参 / freedom tui)与显式更新命令内部已处理更新,bin 层不再重复触发
10
+ const handled = cmd === undefined || cmd === 'tui' || cmd === 'update' || cmd === 'check-update';
6
11
 
7
12
  run(process.argv.slice(2)).then(async (code) => {
8
- if (code === 0 && process.argv[2] !== 'tui') {
9
- try { await maybeNotifyUpdate(); } catch (e) { /* 检测失败静默 */ }
13
+ // 命令成功且非 TUI / 非显式更新命令时,静默执行自动更新(检测到新版本自动升级,无需用户手动操作)
14
+ if (code === 0 && !handled) {
15
+ try {
16
+ const res = await maybeAutoUpdate();
17
+ const notice = formatUpdateResult(res, theme);
18
+ if (notice.length) console.log(notice.join('\n'));
19
+ } catch (e) { /* 更新流程失败静默 */ }
10
20
  }
11
21
  // 用 exitCode 让 Node 自然刷新 stdout 后退出,避免 process.exit 截断管道输出(历史 bug B28)
12
22
  process.exitCode = code || 0;
package/lib/build.js CHANGED
@@ -35,6 +35,13 @@ const {
35
35
  localShellPath,
36
36
  } = require('./utils');
37
37
  const { hasShell, downloadShell, validateLocalShell } = require('./shell');
38
+ const {
39
+ SECURITY_MODES,
40
+ resolveSecurity,
41
+ encryptApp,
42
+ buildIntegrity,
43
+ renderIntegrity,
44
+ } = require('./security');
38
45
 
39
46
  function run(cmd, args, opts = {}) {
40
47
  // Windows 下 npm 是 .cmd 批处理,必须经 shell 执行
@@ -118,6 +125,11 @@ async function build(projectDir, opts = {}) {
118
125
  const html = fs.readFileSync(distHtml, 'utf8');
119
126
  warnIfNotSingleFile(html, distHtml);
120
127
  const configJSON = renderConfigJSON(cfg, name);
128
+ // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
129
+ // none = 明文资源(默认,兼容历史产物)
130
+ // basic = 明文资源 + 构建期安全提示(剥符号 / 混淆,后续可用高模式加密)
131
+ // high = resources 整体加密为 app.bin(AES-256-CTR+HMAC)+ .integrity 完整性清单,磁盘无明文
132
+ const security = resolveSecurity(opts.security, cfg.security);
121
133
 
122
134
  // 2) 后端目录(若配置了 backend 进程)
123
135
  const backendDir = path.join(dir, cfg.backendDir || 'backend');
@@ -133,7 +145,7 @@ async function build(projectDir, opts = {}) {
133
145
  const results = await Promise.all(
134
146
  platforms.map(async (plat) => {
135
147
  const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
136
- const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon });
148
+ const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security });
137
149
  return { plat, outFile };
138
150
  })
139
151
  );
@@ -141,7 +153,7 @@ async function build(projectDir, opts = {}) {
141
153
  return { results };
142
154
  }
143
155
 
144
- async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon }) {
156
+ async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security }) {
145
157
  // 取预编译壳二进制
146
158
  const shell = localShellPath(plat);
147
159
  if (!fs.existsSync(shell)) {
@@ -177,10 +189,40 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
177
189
  }
178
190
 
179
191
  // 写 resources:页面 + 配置 + 后端
192
+ // security=high 时整体加密为 app.bin(磁盘无明文 HTML/config),并生成 .integrity 完整性清单;
193
+ // none / basic 保持明文资源(兼容历史产物),basic 额外输出安全加固提示。
180
194
  const resDir = path.join(targetDir, 'resources');
181
195
  await fsp.mkdir(resDir, { recursive: true });
182
- await fsp.writeFile(path.join(resDir, 'index.html'), html, 'utf8');
183
- await fsp.writeFile(path.join(resDir, 'config.json'), configJSON, 'utf8');
196
+ // 互斥清理:切换安全模式时删除另一模式遗留产物,防止壳误加载旧资源
197
+ // (high 产物 app.bin/.integrity 与明文 index.html/config.json 只能存其一)。
198
+ if (security === 'high') {
199
+ for (const legacy of ['index.html', 'config.json']) {
200
+ const p = path.join(resDir, legacy);
201
+ if (fs.existsSync(p)) await fsp.rm(p, { force: true });
202
+ }
203
+ const appBin = encryptApp(name, html, configJSON);
204
+ await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
205
+ const backendRelMap = {};
206
+ if (hasBackend && fs.existsSync(backendDir)) {
207
+ collectDirFiles(backendDir, '', backendRelMap);
208
+ }
209
+ const integrityText = renderIntegrity(buildIntegrity(name, appBin, backendRelMap));
210
+ await fsp.writeFile(path.join(resDir, '.integrity'), integrityText, 'utf8');
211
+ } else {
212
+ for (const legacy of ['app.bin', '.integrity']) {
213
+ const p = path.join(resDir, legacy);
214
+ if (fs.existsSync(p)) await fsp.rm(p, { force: true });
215
+ }
216
+ await fsp.writeFile(path.join(resDir, 'index.html'), html, 'utf8');
217
+ await fsp.writeFile(path.join(resDir, 'config.json'), configJSON, 'utf8');
218
+ if (security === 'basic') {
219
+ process.stdout.write(
220
+ `[freedom] 安全模式 basic:资源仍为明文。加固建议:\n` +
221
+ ` 1. 运行 ${'freedom security high'} 切换到高模式(resources 加密为 app.bin,磁盘无明文);\n` +
222
+ ` 2. 壳侧已内置 anti-debug / 进程隐藏,符号剥离需在 CI 编译壳时设置 ldflags -s -w(见 README 安全章节)。\n`
223
+ );
224
+ }
225
+ }
184
226
  if (hasBackend) {
185
227
  copyDir(backendDir, path.join(resDir, 'backend'));
186
228
  }
@@ -472,4 +514,17 @@ function warnIfNotSingleFile(html, distHtml) {
472
514
  }
473
515
  }
474
516
 
517
+ // 递归收集目录内所有文件:relPath(POSIX 相对路径)-> Buffer 内容,供 .integrity 完整性清单使用。
518
+ function collectDirFiles(dir, prefix, out) {
519
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
520
+ const abs = path.join(dir, entry.name);
521
+ if (entry.isDirectory()) {
522
+ collectDirFiles(abs, prefix + entry.name + '/', out);
523
+ } else {
524
+ out[(prefix + entry.name).replace(/\\/g, '/')] = fs.readFileSync(abs);
525
+ }
526
+ }
527
+ return out;
528
+ }
529
+
475
530
  module.exports = { build, parsePlatforms };
package/lib/cli.js CHANGED
@@ -19,7 +19,8 @@ function help() {
19
19
  L.push(` ${paint('用法:', C.bold, C.fg.white)} ${paint('freedom <command> [options]', C.fg.cyan, C.bold)}`);
20
20
  L.push('');
21
21
  L.push(section('交互式界面'));
22
- L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('进入交互式 TUI(新建 / 打包 / 配置 / 壳管理)')}`);
22
+ L.push(` ${paint('freedom', C.fg.cyan, C.bold)} ${dim('进入完整 CLI 模式(交互式菜单,新建 / 打包 / 配置 / 壳管理)')}`);
23
+ L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('同上(兼容写法,显式进入交互式模式)')}`);
23
24
  L.push(section('项目'));
24
25
  L.push(` ${paint('freedom init [目录] [--force]', C.fg.cyan)} ${dim('在当前 / 指定目录新建项目模板')}`);
25
26
  L.push(` ${paint('freedom tutorial', C.fg.cyan)} ${dim('再次打开安装教程')}`);
@@ -41,7 +42,7 @@ function help() {
41
42
  L.push(` ${paint('freedom config set <key> <value>', C.fg.cyan)} ${dim('修改单个配置项')}`);
42
43
  L.push(section('版本'));
43
44
  L.push(` ${paint('freedom version', C.fg.cyan)} ${dim('显示版本并检测最新版本')}`);
44
- L.push(` ${paint('freedom update', C.fg.cyan)} ${dim('检查新版本并给出升级命令')}`);
45
+ L.push(` ${paint('freedom update', C.fg.cyan)} ${dim('检查新版本并立即自动更新')}`);
45
46
  L.push(` ${paint('freedom help', C.fg.cyan)} ${dim('显示本帮助')}`);
46
47
  L.push('');
47
48
  L.push(section('平台'));
@@ -63,7 +64,15 @@ async function run(argv) {
63
64
  const [cmd, ...rest] = argv;
64
65
 
65
66
  switch (cmd) {
66
- case undefined:
67
+ case undefined: {
68
+ // 无参数:交互式终端进入完整 CLI 模式(TUI);管道 / 脚本环境打印帮助,避免卡死
69
+ if (process.stdin.isTTY && process.stdout.isTTY) {
70
+ return await require('./tui').tui(process.cwd());
71
+ }
72
+ console.log(help());
73
+ return 0;
74
+ }
75
+
67
76
  case 'help':
68
77
  case '--help':
69
78
  case '-h':
@@ -76,7 +85,7 @@ async function run(argv) {
76
85
  const r = await update.checkUpdate({ force: true });
77
86
  console.log(versionCard(r.current, r.latest, r.hasUpdate));
78
87
  if (r.hasUpdate && r.latest) {
79
- console.log(` ${tip('升级命令:')}${paint(`npm install -g ${update.PKG_NAME}@latest`, C.fg.cyan, C.bold)}`);
88
+ console.log(` ${tip('检测到新版本,将自动更新。')}`);
80
89
  console.log('');
81
90
  }
82
91
  return 0;
@@ -86,14 +95,15 @@ async function run(argv) {
86
95
  case 'check-update': {
87
96
  const r = await update.checkUpdate({ force: true });
88
97
  console.log(versionCard(r.current, r.latest, r.hasUpdate));
89
- if (r.hasUpdate && r.latest) {
90
- console.log(` ${tip('检测到新版本,执行以下命令升级:')}`);
91
- console.log(` ${paint(`npm install -g ${update.PKG_NAME}@latest`, C.fg.cyan, C.bold)}`);
92
- console.log('');
93
- } else if (r.latest) {
98
+ if (r.latest && !r.hasUpdate) {
94
99
  console.log(` ${ok('当前已是最新版本。')}`);
95
- } else {
100
+ } else if (!r.latest) {
96
101
  console.log(` ${warn('检查失败:网络不可用或 npm registry 未响应,请稍后重试。')}`);
102
+ } else {
103
+ // 有新版:立即自动更新(freedom update = 强制更新,无需手动执行 npm 命令)
104
+ const res = await update.maybeAutoUpdate({ force: true, r });
105
+ const notice = update.formatUpdateResult(res, theme);
106
+ if (notice.length) console.log(notice.join('\n'));
97
107
  }
98
108
  return 0;
99
109
  }
@@ -129,7 +139,22 @@ async function run(argv) {
129
139
  return 1;
130
140
  }
131
141
  }
132
- const { results } = await build(process.cwd(), { platform, noCache: rest.includes('--no-cache') });
142
+ // 安全模式:--security <none|basic|high>(可选,默认取 freedom.config.js security 字段)
143
+ const secArg = rest.find((a) => a.startsWith('--security'));
144
+ let security;
145
+ if (secArg) {
146
+ const inline = secArg.includes('=') ? secArg.split('=')[1] : undefined;
147
+ security = inline || rest[rest.indexOf(secArg) + 1];
148
+ if (!security || security.startsWith('--')) {
149
+ console.error(`${err('--security 缺少取值。')} ${dim('用法:')}${paint('freedom build --security <none|basic|high>', C.fg.cyan)}`);
150
+ return 1;
151
+ }
152
+ }
153
+ const { results } = await build(process.cwd(), {
154
+ platform,
155
+ noCache: rest.includes('--no-cache'),
156
+ security,
157
+ });
133
158
  for (const r of results) {
134
159
  console.log(`${ok('构建完成')} ${paint(`[${r.plat}]`, C.fg.magenta, C.bold)} ${paint(r.outFile, C.fg.white)}`);
135
160
  }
@@ -168,6 +193,23 @@ async function run(argv) {
168
193
  return 0;
169
194
  }
170
195
 
196
+ case 'security': {
197
+ const mode = rest[0];
198
+ if (!['none', 'basic', 'high'].includes(mode)) {
199
+ console.error(`${err('用法:')}${paint('freedom security <none|basic|high>', C.fg.cyan)}`);
200
+ console.error(` ${dim('none = 明文资源(默认,兼容历史产物)')}`);
201
+ console.error(` ${dim('basic = 明文资源 + 构建期加固提示')}`);
202
+ console.error(` ${dim('high = resources 加密为 app.bin + .integrity 完整性清单,磁盘无明文,壳内存解密')}`);
203
+ return 1;
204
+ }
205
+ setConfig(process.cwd(), 'security', mode);
206
+ console.log(`${ok('安全模式已切换为:')}${paint(mode, C.fg.cyan, C.bold)}`);
207
+ if (mode === 'high') {
208
+ console.log(` ${dim('下一次')} ${paint('freedom build', C.fg.cyan)} ${dim('将加密 resources(app.bin),前端源码与配置不会以明文落盘。')}`);
209
+ }
210
+ return 0;
211
+ }
212
+
171
213
  case 'config': {
172
214
  const sub = rest[0];
173
215
  if (sub === 'get') {
package/lib/config.js CHANGED
@@ -14,6 +14,7 @@ const KEY_TYPES = {
14
14
  center: 'bool',
15
15
  debug: 'bool',
16
16
  titlebar: 'titlebar',
17
+ security: 'security',
17
18
  icon: 'string',
18
19
  outDir: 'string',
19
20
  };
@@ -42,6 +43,13 @@ function normalizeValue(key, value) {
42
43
  }
43
44
  return s;
44
45
  }
46
+ if (t === 'security') {
47
+ const s = String(value);
48
+ if (!['none', 'basic', 'high'].includes(s)) {
49
+ throw new Error('security 取值必须为:none / basic / high。');
50
+ }
51
+ return s;
52
+ }
45
53
  // 扩展键(如 backend):字符串命令简写转 {command,args} 对象,
46
54
  // 与 build.js renderConfigJSON 的契约对齐(纯字符串形态不会被打包为后端进程)。
47
55
  if (key === 'backend') {
@@ -86,7 +94,9 @@ function setConfig(dir, key, value) {
86
94
  const rendered = renderLiteral(norm);
87
95
 
88
96
  const lines = text.split('\n');
89
- const keyRe = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:(.*)$`);
97
+ // 容忍 CRLF 行尾(Windows 编辑器 / git autocrlf 可能产生):JS 正则的 . 不匹配 \r,
98
+ // 直接匹配 (.*)$ 会在行尾残留 \r 导致整行失配(历史隐患)。
99
+ const keyRe = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:(.*)\\r?$`);
90
100
  let replaced = false;
91
101
  for (let i = 0; i < lines.length; i++) {
92
102
  if (lines[i].trim().startsWith('//')) continue; // 注释行不参与匹配与改写
@@ -130,6 +140,7 @@ const KNOWN_KEYS = {
130
140
  center: true,
131
141
  debug: false,
132
142
  titlebar: 'frameless',
143
+ security: 'none',
133
144
  icon: undefined,
134
145
  outDir: 'dist',
135
146
  };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ // 安全加固模块(v1.13.0)
4
+ //
5
+ // 为 freedom build 提供两种安全模式:
6
+ // - basic(简单模式):资源压缩 + 壳剥离符号建议,阻止"随手打开 resources/
7
+ // 就能看到前端源码"的随手破解,成本低、完全向后兼容。
8
+ // - high(高危模式):AES-256-CTR + HMAC-SHA256(Encrypt-then-MAC)加密资源为
9
+ // 单一容器 resources/app.bin,运行时由壳在内存中解密(磁盘无明文);
10
+ // 配套 HMAC 完整性校验防篡改;密钥不落盘(由 exe 文件名派生),
11
+ // 并强制关闭 WebView2 devtools。
12
+ //
13
+ // 架构说明(重要):通用壳 = 所有应用共用一份预编译 freedom-shell 二进制,
14
+ // 因此密钥派生所需的"主密钥常量"编译在通用壳内,天然被所有应用共享;
15
+ // high 模式的对抗目标是把攻击者从"直接读明文"抬升到"必须逆向壳二进制、
16
+ // 提取主密钥并复现派生算法"。真正"每应用唯一密钥"需要每应用独立编译壳
17
+ // (破坏通用壳 + 零工具链架构),不在本模式范围内。
18
+ //
19
+ // ⚠️ 跨语言同步点:本文件与 templates/go/pkg/freedom/security.go 必须保持
20
+ // MASTER_KEY / DERIVE_SALT / PBKDF2_ITER / KEY_LEN / APP_BIN_MAGIC
21
+ // 以及加密算法(aes-256-ctr + hmac-sha256)完全一致。
22
+ // 修改任一侧必须同步另一侧,否则 build 出的加密资源壳无法解密。
23
+
24
+ const crypto = require('crypto');
25
+ const path = require('path');
26
+
27
+ const SECURITY_MODES = ['none', 'basic', 'high'];
28
+
29
+ // ---- 密钥派生参数(必须与 Go security.go 同步)----
30
+ // 固定常量主密钥:参与 PBKDF2 派生,编译进通用壳。此处为 CLI 侧同值。
31
+ const MASTER_KEY = 'freedom-shell::kdf-master::v1::7f4e8c2a9b1d6e3f';
32
+ const DERIVE_SALT = 'freedom:derive:v1';
33
+ const PBKDF2_ITER = 60000;
34
+ const KEY_LEN = 32;
35
+
36
+ // resources/app.bin 容器头:magic(5B 'FRDM1') + iv(16B) + tag(16B) + ciphertext
37
+ // 算法:AES-256-CTR 加密 + HMAC-SHA256(密文) 截断 16B 认证标签。
38
+ const APP_BIN_MAGIC = 'FRDM1';
39
+ const CTR_IV_LEN = 16;
40
+ const CTR_TAG_LEN = 16;
41
+
42
+ // ---- 模式解析 ----
43
+
44
+ // 解析安全模式;非法值抛错。opts.security 优先于 cfg.security,默认 none。
45
+ function parseSecurity(raw) {
46
+ if (!raw) return 'none';
47
+ const v = String(raw).trim().toLowerCase();
48
+ if (!SECURITY_MODES.includes(v)) {
49
+ throw new Error(`未知安全模式:${raw}。可选:${SECURITY_MODES.join(' / ')}`);
50
+ }
51
+ return v;
52
+ }
53
+
54
+ // 归一化:CLI 显式参数 > 项目配置 > 默认 none
55
+ function resolveSecurity(cliValue, cfgValue) {
56
+ if (cliValue) return parseSecurity(cliValue);
57
+ return parseSecurity(cfgValue);
58
+ }
59
+
60
+ // ---- 密钥派生 ----
61
+
62
+ // 应用标识:exe 文件名(去扩展名)。
63
+ // CLI build 时 = 目标 exe 名去掉扩展名;壳运行时 = os.Executable() basename 去扩展名。
64
+ // 两侧使用同一派生算法,故 exe 事后被重命名会导致 high 模式无法解密(视为防篡改特性)。
65
+ function appIdentityFor(name) {
66
+ return String(name).replace(/\.(exe|app)$/i, '');
67
+ }
68
+
69
+ // 与 Go security.go deriveKey 保持一致的 PBKDF2-HMAC-SHA256 派生。
70
+ // 输入 salt = DERIVE_SALT + ':' + appName(避免仅按 exe 名可枚举碰撞)。
71
+ function deriveKey(appName) {
72
+ const salt = Buffer.from(DERIVE_SALT + ':' + appIdentityFor(appName), 'utf8');
73
+ return crypto.pbkdf2Sync(MASTER_KEY, salt, PBKDF2_ITER, KEY_LEN, 'sha256');
74
+ }
75
+
76
+ // ---- 加密 / 解密(high 模式)----
77
+
78
+ // 载荷结构:{ html: <string>, config: <string> }(config 为 config.json 原文 JSON 字符串)。
79
+ // 返回 app.bin 完整字节。
80
+ // 算法:AES-256-CTR 加密 → HMAC-SHA256(密文) 截 16B 认证标签(Encrypt-then-MAC)。
81
+ function encryptApp(name, html, configJSON) {
82
+ const key = deriveKey(name);
83
+ const payload = Buffer.from(JSON.stringify({ html, config: configJSON }), 'utf8');
84
+ const iv = crypto.randomBytes(CTR_IV_LEN);
85
+ const cipher = crypto.createCipheriv('aes-256-ctr', key, iv);
86
+ const ct = Buffer.concat([cipher.update(payload), cipher.final()]);
87
+ const tag = crypto.createHmac('sha256', key).update(ct).digest().subarray(0, CTR_TAG_LEN);
88
+ return Buffer.concat([Buffer.from(APP_BIN_MAGIC, 'ascii'), iv, tag, ct]);
89
+ }
90
+
91
+ // 解密 app.bin(供单测验证 / 未来壳调试工具复用)。返回 { html, config }。
92
+ function decryptApp(name, buf) {
93
+ const key = deriveKey(name);
94
+ const magic = Buffer.from(APP_BIN_MAGIC, 'ascii');
95
+ if (buf.length < magic.length + CTR_IV_LEN + CTR_TAG_LEN || !buf.subarray(0, magic.length).equals(magic)) {
96
+ throw new Error('app.bin 头无效:不是 FRDM1 加密容器');
97
+ }
98
+ let off = magic.length;
99
+ const iv = buf.subarray(off, off + CTR_IV_LEN); off += CTR_IV_LEN;
100
+ const tag = buf.subarray(off, off + CTR_TAG_LEN); off += CTR_TAG_LEN;
101
+ const ct = buf.subarray(off);
102
+
103
+ // 1) 认证:HMAC-SHA256(ct) 前 16 字节与容器头 tag 恒定时间比对。
104
+ const expect = crypto.createHmac('sha256', key).update(ct).digest().subarray(0, CTR_TAG_LEN);
105
+ if (!crypto.timingSafeEqual(tag, expect)) {
106
+ throw new Error('app.bin 认证失败(exe 被重命名或资源被篡改?)');
107
+ }
108
+
109
+ // 2) 解密:AES-256-CTR。
110
+ const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv);
111
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
112
+ const obj = JSON.parse(plain.toString('utf8'));
113
+ if (typeof obj.html !== 'string' || typeof obj.config !== 'string') {
114
+ throw new Error('app.bin 载荷结构无效:缺少 html/config 字段');
115
+ }
116
+ return obj;
117
+ }
118
+
119
+ // ---- 完整性清单(high 模式)----
120
+
121
+ // 生成 resources/.integrity:记录 app.bin 与 backend/ 各文件的 HMAC-SHA256,
122
+ // 壳启动时校验,防止 resources 被整体替换/篡改。
123
+ // 返回 { v, appBin, backend }。
124
+ function buildIntegrity(appName, appBin, backendRelMap) {
125
+ const key = deriveKey(appName);
126
+ const hmac = (buf) => crypto.createHmac('sha256', key).update(buf).digest('hex');
127
+ const backend = {};
128
+ for (const [rel, buf] of Object.entries(backendRelMap || {})) {
129
+ backend[rel] = hmac(buf);
130
+ }
131
+ return { v: 1, appBin: hmac(appBin), backend };
132
+ }
133
+
134
+ // 生成 .integrity 文件文本。
135
+ function renderIntegrity(integrity) {
136
+ return JSON.stringify(integrity, null, 2);
137
+ }
138
+
139
+ module.exports = {
140
+ SECURITY_MODES,
141
+ APP_BIN_MAGIC,
142
+ parseSecurity,
143
+ resolveSecurity,
144
+ appIdentityFor,
145
+ deriveKey,
146
+ encryptApp,
147
+ decryptApp,
148
+ buildIntegrity,
149
+ renderIntegrity,
150
+ };
package/lib/tui.js CHANGED
@@ -213,7 +213,8 @@ async function buildFlow(tui, cwd) {
213
213
  // 平台多选:默认勾选当前平台
214
214
  const native = require('./utils').nativePlatform();
215
215
  const checked = [ALL_PLATFORMS.indexOf(native)];
216
- const picked = await tui.multiselect('选择目标平台(多选)', ALL_PLATFORMS, { checked });
216
+ // linux-arm64 CI 预编译资产:全选时按 all(win-x64/darwin-arm64/linux-x64)处理,避免 404 拖垮构建(见 utils.DIST_PLATFORMS)
217
+ const picked = await tui.multiselect('选择目标平台(多选;linux-arm64 无预编译资产,全选仅含三平台)', ALL_PLATFORMS, { checked });
217
218
  if (picked === null) return;
218
219
  const platforms = picked.map((i) => ALL_PLATFORMS[i]);
219
220
  const platArg = picked.length === ALL_PLATFORMS.length ? 'all' : platforms.join(',');
@@ -303,20 +304,36 @@ async function tutorialFlow(tui) {
303
304
  }
304
305
 
305
306
  async function updateFlow(tui) {
306
- const { checkUpdate, PKG_NAME } = require('./update');
307
- const r = await checkUpdate({ force: true });
308
- const lines = [
309
- { text: C.fgWhite + C.bold + ` 当前版本:v${r.current}` + C.reset },
310
- ];
311
- if (r.latest) {
312
- lines.push({ text: C.fgCyan + ` 最新版本:v${r.latest}` + C.reset });
313
- lines.push({ text: r.hasUpdate
314
- ? C.fgYellow + ` 发现新版本,可执行:npm install -g ${PKG_NAME}@latest` + C.reset
315
- : C.fgGreen + ` 已是最新版本` + C.reset });
307
+ const update = require('./update');
308
+ const r = await update.checkUpdate({ force: true });
309
+ const rows = [{ text: C.fgWhite + C.bold + ` 当前版本:v${r.current}` + C.reset }];
310
+ if (!r.latest) {
311
+ rows.push({ text: C.fgGray + ` 检查失败:网络不可用,请稍后重试` + C.reset });
312
+ return tui.message('检查 / 自动更新', rows);
313
+ }
314
+ rows.push({ text: C.fgCyan + ` 最新版本:v${r.latest}` + C.reset });
315
+ if (!r.hasUpdate) {
316
+ rows.push({ text: C.fgGreen + ` 已是最新版本` + C.reset });
317
+ return tui.message('检查 / 自动更新', rows);
318
+ }
319
+ // 有新版本:直接自动更新(无需用户手动执行 npm 命令)
320
+ let res = null;
321
+ await tui.runTask(async () => {
322
+ res = await update.maybeAutoUpdate({ force: true, r });
323
+ });
324
+ if (res && res.updated) {
325
+ rows.push({ text: C.fgGreen + ` ✓ 已自动更新到 v${res.to}(原 v${res.from})` + C.reset });
326
+ rows.push({ text: C.fgGray + ` 重启本 TUI 后即为新版本` + C.reset });
327
+ } else if (res && res.notGlobal) {
328
+ rows.push({ text: C.fgYellow + ` ⚠ 检测到新版本,当前为非全局安装,请手动执行:npm install -g ${update.pkgName()}@latest` + C.reset });
329
+ } else if (res && res.failed) {
330
+ rows.push({ text: C.fgYellow + ` ⚠ 自动更新失败:${res.detail || '未知原因'}` + C.reset });
331
+ } else if (res && res.throttled) {
332
+ rows.push({ text: C.fgYellow + ` ⚠ 自动更新 6h 内已尝试过,本次跳过` + C.reset });
316
333
  } else {
317
- lines.push({ text: C.fgGray + ` 检查失败:网络不可用,请稍后重试` + C.reset });
334
+ rows.push({ text: C.fgGray + ` 自动更新流程已结束` + C.reset });
318
335
  }
319
- await tui.message('检查版本更新', lines);
336
+ await tui.message('检查 / 自动更新', rows);
320
337
  }
321
338
 
322
339
  function coerce(value) {
@@ -336,7 +353,7 @@ async function tui(cwd) {
336
353
  app.enter();
337
354
  let keep = true;
338
355
  while (keep) {
339
- const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '检查更新', '退出'];
356
+ const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '检查 / 自动更新', '退出'];
340
357
  const idx = await app.menu('主菜单', items, {
341
358
  footer: `工作目录:${cwd} ↑ ↓ 选择 · Enter 确认 · q 退出`,
342
359
  });