@yufengtadian/freedom-cli 1.13.1 → 1.13.2
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 +87 -13
- package/lib/agents.js +318 -0
- package/lib/build.js +168 -39
- package/lib/cli.js +160 -4
- package/lib/config.js +20 -1
- package/lib/desktop.js +133 -0
- package/lib/dev.js +149 -0
- package/lib/mcp.js +217 -0
- package/lib/release.js +90 -0
- package/lib/security.js +113 -54
- package/lib/shell.js +5 -2
- package/lib/tui.js +37 -1
- package/lib/verify.js +43 -8
- package/package.json +2 -1
- package/shell/darwin-arm64/freedom-shell +0 -0
- package/shell/linux-x64/freedom-shell +0 -0
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/skill/freedom/SKILL.md +159 -0
- package/templates/desktop/app.html +559 -0
- package/templates/desktop/backend/desktop.mjs +317 -0
- package/templates/desktop/freedom.config.js +29 -0
- package/templates/desktop/icon.ico +0 -0
- package/templates/go/pkg/freedom/anti_debug_windows.go +114 -11
- package/templates/go/pkg/freedom/assets/freedom.js +3 -0
- package/templates/go/pkg/freedom/freedom.go +52 -9
- package/templates/go/pkg/freedom/resources.go +68 -14
- package/templates/go/pkg/freedom/securetemp.go +61 -0
- package/templates/go/pkg/freedom/securetemp_other.go +20 -0
- package/templates/go/pkg/freedom/securetemp_windows.go +25 -0
- package/templates/go/pkg/freedom/security.go +240 -61
- package/templates/go/pkg/freedom/store.go +8 -0
- package/templates/go/pkg/freedom/window_other.go +7 -1
- package/templates/go/pkg/freedom/window_windows.go +185 -17
- package/templates/installer/app.nsi +43 -0
- package/templates/project/freedom.config.js +14 -0
- package/templates/project/freedom.d.ts +77 -0
- package/templates/project-minimal/freedom.config.js +14 -0
- package/templates/project-minimal/freedom.d.ts +77 -0
package/lib/security.js
CHANGED
|
@@ -1,43 +1,53 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// 安全加固模块
|
|
4
4
|
//
|
|
5
5
|
// 为 freedom build 提供两种安全模式:
|
|
6
6
|
// - basic(简单模式):资源压缩 + 壳剥离符号建议,阻止"随手打开 resources/
|
|
7
7
|
// 就能看到前端源码"的随手破解,成本低、完全向后兼容。
|
|
8
|
-
// - high(高危模式):AES-256-CTR + HMAC-SHA256(Encrypt-then-MAC
|
|
9
|
-
//
|
|
10
|
-
// 配套 HMAC
|
|
11
|
-
// 并强制关闭 WebView2 devtools。
|
|
8
|
+
// - high(高危模式):AES-256-CTR + HMAC-SHA256(Encrypt-then-MAC)把前端、配置
|
|
9
|
+
// 与后端源码加密为单一容器 resources/app.bin,运行时由壳在内存中解密(磁盘无明文);
|
|
10
|
+
// 配套 HMAC 完整性校验防篡改;密钥不落盘,并强制关闭 WebView2 devtools。
|
|
12
11
|
//
|
|
13
12
|
// 架构说明(重要):通用壳 = 所有应用共用一份预编译 freedom-shell 二进制,
|
|
14
13
|
// 因此密钥派生所需的"主密钥常量"编译在通用壳内,天然被所有应用共享;
|
|
15
14
|
// high 模式的对抗目标是把攻击者从"直接读明文"抬升到"必须逆向壳二进制、
|
|
16
|
-
// 提取主密钥并复现派生算法"
|
|
17
|
-
// (破坏通用壳 + 零工具链架构),不在本模式范围内。
|
|
15
|
+
// 提取主密钥并复现派生算法"。客户端加密做不到不可逆,本模式不这样宣称。
|
|
18
16
|
//
|
|
19
17
|
// ⚠️ 跨语言同步点:本文件与 templates/go/pkg/freedom/security.go(= 仓库根 security.go)必须保持
|
|
20
|
-
//
|
|
18
|
+
// MASTER_KEY_CIPHER 字节表与掩码算法 out[i] ^= (i*7+0x5A)
|
|
19
|
+
// DERIVE_SALT / MAC_LABEL / PBKDF2_ITER / KEY_LEN / APP_BIN_MAGIC
|
|
20
|
+
// salt/iv/tag 长度、容器头顺序、载荷 JSON 结构(html/config/backend.d/m)
|
|
21
21
|
// 以及加密算法(aes-256-ctr + hmac-sha256)完全一致。
|
|
22
22
|
// 修改任一侧必须同步另一侧,否则 build 出的加密资源壳无法解密。
|
|
23
|
+
// 回归锁:tests/security-frdm2.test.mjs 与 Go security_test.go 互为对方产物的夹具。
|
|
23
24
|
|
|
24
25
|
const crypto = require('crypto');
|
|
25
|
-
const path = require('path');
|
|
26
26
|
|
|
27
27
|
const SECURITY_MODES = ['none', 'basic', 'high'];
|
|
28
28
|
|
|
29
29
|
// ---- 密钥派生参数(必须与 Go security.go 同步)----
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
30
|
+
|
|
31
|
+
// 主密钥明文与位置相关掩码异或后的字节表:使 npm 包与壳二进制里都搜不到完整密钥明文。
|
|
32
|
+
const MASTER_KEY_CIPHER = Buffer.from([
|
|
33
|
+
60, 19, 13, 10, 18, 18, 233, 166, 225, 241, 197, 203, 194, 143, 134, 168,
|
|
34
|
+
174, 183, 245, 178, 135, 158, 128, 158, 112, 51, 42, 97, 44, 31, 22, 10,
|
|
35
|
+
89, 117, 46, 125, 97, 60, 1, 90, 22, 65, 226, 179, 189, 240, 172,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const DERIVE_SALT = 'freedom:derive:v2';
|
|
39
|
+
const MAC_LABEL = 'freedom:mac:v2';
|
|
40
|
+
const PBKDF2_ITER = 600000;
|
|
34
41
|
const KEY_LEN = 32;
|
|
35
42
|
|
|
36
|
-
// resources/app.bin
|
|
37
|
-
//
|
|
38
|
-
const APP_BIN_MAGIC = '
|
|
43
|
+
// resources/app.bin 容器:magic(5B 'FRDM2') + salt(16B) + iv(16B) + tag(16B) + ciphertext
|
|
44
|
+
// tag = HMAC-SHA256(macKey, magic+salt+iv+密文) 截 16B(Encrypt-then-MAC,头部一并认证)。
|
|
45
|
+
const APP_BIN_MAGIC = 'FRDM2';
|
|
46
|
+
const CTR_SALT_LEN = 16;
|
|
39
47
|
const CTR_IV_LEN = 16;
|
|
40
48
|
const CTR_TAG_LEN = 16;
|
|
49
|
+
const APP_BIN_HEADER_LEN = APP_BIN_MAGIC.length + CTR_SALT_LEN + CTR_IV_LEN + CTR_TAG_LEN;
|
|
50
|
+
const APP_BIN_AUTH_LEN = APP_BIN_MAGIC.length + CTR_SALT_LEN + CTR_IV_LEN;
|
|
41
51
|
|
|
42
52
|
// ---- 模式解析 ----
|
|
43
53
|
|
|
@@ -61,53 +71,101 @@ function resolveSecurity(cliValue, cfgValue) {
|
|
|
61
71
|
|
|
62
72
|
// 应用标识:exe 文件名(去扩展名)。
|
|
63
73
|
// CLI build 时 = 目标 exe 名去掉扩展名;壳运行时 = os.Executable() basename 去扩展名。
|
|
64
|
-
//
|
|
74
|
+
// 两侧算法一致(Go strings.TrimSuffix 大小写敏感),故 exe 事后被重命名会导致
|
|
75
|
+
// high 模式无法解密(视为防篡改特性)。
|
|
65
76
|
function appIdentityFor(name) {
|
|
66
|
-
|
|
77
|
+
let s = String(name);
|
|
78
|
+
if (s.endsWith('.exe')) s = s.slice(0, -4);
|
|
79
|
+
if (s.endsWith('.app')) s = s.slice(0, -4);
|
|
80
|
+
return s;
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
// masterSecret 还原主密钥(PBKDF2 的 password),与 Go masterSecret() 同式。
|
|
84
|
+
function masterSecret() {
|
|
85
|
+
const out = Buffer.alloc(MASTER_KEY_CIPHER.length);
|
|
86
|
+
for (let i = 0; i < MASTER_KEY_CIPHER.length; i++) {
|
|
87
|
+
out[i] = MASTER_KEY_CIPHER[i] ^ ((i * 7 + 0x5a) & 0xff);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 容器密钥对:加密与认证分开,避免同一密钥同时服务 AES-CTR 与 HMAC。
|
|
93
|
+
// 与 Go deriveSecurityKey 一致:KEK = PBKDF2(master, DERIVE_SALT:app + 容器随机盐),
|
|
94
|
+
// macKey = HMAC-SHA256(KEK, MAC_LABEL)。
|
|
95
|
+
function deriveKeys(appName, salt) {
|
|
96
|
+
const base = Buffer.concat([
|
|
97
|
+
Buffer.from(DERIVE_SALT + ':' + appIdentityFor(appName), 'utf8'),
|
|
98
|
+
salt,
|
|
99
|
+
]);
|
|
100
|
+
const enc = crypto.pbkdf2Sync(masterSecret(), base, PBKDF2_ITER, KEY_LEN, 'sha256');
|
|
101
|
+
const mac = crypto.createHmac('sha256', enc).update(MAC_LABEL).digest();
|
|
102
|
+
return { enc, mac };
|
|
74
103
|
}
|
|
75
104
|
|
|
76
105
|
// ---- 加密 / 解密(high 模式)----
|
|
77
106
|
|
|
78
|
-
// 载荷结构:{ html: <
|
|
107
|
+
// 载荷结构:{ html, config, backend: { <rel>: { d: base64, m: mode } } }
|
|
108
|
+
// backend 为可选的后端源码表(键 = 相对 resources 的斜杠路径),high 模式下
|
|
109
|
+
// 后端源码进容器,磁盘不再留 resources/backend 明文目录。
|
|
79
110
|
// 返回 app.bin 完整字节。
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const key = deriveKey(name);
|
|
83
|
-
const payload = Buffer.from(JSON.stringify({ html, config: configJSON }), 'utf8');
|
|
111
|
+
function encryptApp(name, html, configJSON, backend) {
|
|
112
|
+
const salt = crypto.randomBytes(CTR_SALT_LEN);
|
|
84
113
|
const iv = crypto.randomBytes(CTR_IV_LEN);
|
|
85
|
-
const
|
|
114
|
+
const k = deriveKeys(name, salt);
|
|
115
|
+
const payloadObj = { html, config: configJSON };
|
|
116
|
+
if (backend && Object.keys(backend).length > 0) {
|
|
117
|
+
payloadObj.backend = {};
|
|
118
|
+
for (const [rel, entry] of Object.entries(backend)) {
|
|
119
|
+
if (!isSafeRelPath(rel)) throw new Error(`后端路径非法,拒绝入容器:${rel}`);
|
|
120
|
+
const buf = Buffer.isBuffer(entry) ? entry : entry.data;
|
|
121
|
+
const mode = Buffer.isBuffer(entry) ? 0o644 : (((entry.mode ?? 0o644)) & 0o7777);
|
|
122
|
+
payloadObj.backend[rel] = { d: buf.toString('base64'), m: mode };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const payload = Buffer.from(JSON.stringify(payloadObj), 'utf8');
|
|
126
|
+
const cipher = crypto.createCipheriv('aes-256-ctr', k.enc, iv);
|
|
86
127
|
const ct = Buffer.concat([cipher.update(payload), cipher.final()]);
|
|
87
|
-
const
|
|
88
|
-
|
|
128
|
+
const header = Buffer.concat([Buffer.from(APP_BIN_MAGIC, 'ascii'), salt, iv]);
|
|
129
|
+
const tag = crypto.createHmac('sha256', k.mac)
|
|
130
|
+
.update(header).update(ct).digest().subarray(0, CTR_TAG_LEN);
|
|
131
|
+
return Buffer.concat([header, tag, ct]);
|
|
89
132
|
}
|
|
90
133
|
|
|
91
|
-
//
|
|
92
|
-
function
|
|
93
|
-
const key = deriveKey(name);
|
|
134
|
+
// 拆分并校验容器头;旧版 FRDM1 等不兼容版本明确拒绝(不静默降级)。
|
|
135
|
+
function splitAppBin(buf) {
|
|
94
136
|
const magic = Buffer.from(APP_BIN_MAGIC, 'ascii');
|
|
95
|
-
if (buf.length <
|
|
96
|
-
throw new Error('app.bin
|
|
137
|
+
if (buf.length < APP_BIN_HEADER_LEN) {
|
|
138
|
+
throw new Error('app.bin 长度不足容器头');
|
|
139
|
+
}
|
|
140
|
+
if (!buf.subarray(0, magic.length).equals(magic)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`app.bin 容器版本不受支持(头为 "${buf.subarray(0, magic.length).toString('ascii')}",要求 ${APP_BIN_MAGIC})`);
|
|
97
143
|
}
|
|
98
144
|
let off = magic.length;
|
|
145
|
+
const salt = buf.subarray(off, off + CTR_SALT_LEN); off += CTR_SALT_LEN;
|
|
99
146
|
const iv = buf.subarray(off, off + CTR_IV_LEN); off += CTR_IV_LEN;
|
|
100
147
|
const tag = buf.subarray(off, off + CTR_TAG_LEN); off += CTR_TAG_LEN;
|
|
101
148
|
const ct = buf.subarray(off);
|
|
149
|
+
return { salt, iv, tag, ct, headerLen: APP_BIN_AUTH_LEN };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 判定容器内相对路径可安全落盘(与 Go isSafeRelPath 同式)。
|
|
153
|
+
function isSafeRelPath(rel) {
|
|
154
|
+
if (rel === '' || rel.includes('\\') || rel.startsWith('/')) return false;
|
|
155
|
+
if (rel.length >= 2 && rel[1] === ':') return false;
|
|
156
|
+
return rel.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
|
|
157
|
+
}
|
|
102
158
|
|
|
103
|
-
|
|
104
|
-
|
|
159
|
+
// 解密 app.bin(供跨语言回归测试与 CLI 自检复用)。返回 { html, config, backend? }。
|
|
160
|
+
function decryptApp(name, buf) {
|
|
161
|
+
const { salt, iv, tag, ct, headerLen } = splitAppBin(buf);
|
|
162
|
+
const k = deriveKeys(name, salt);
|
|
163
|
+
const expect = crypto.createHmac('sha256', k.mac)
|
|
164
|
+
.update(buf.subarray(0, headerLen)).update(ct).digest().subarray(0, CTR_TAG_LEN);
|
|
105
165
|
if (!crypto.timingSafeEqual(tag, expect)) {
|
|
106
|
-
throw new Error('app.bin 认证失败(exe
|
|
166
|
+
throw new Error('app.bin 认证失败(exe 被重命名、资源被篡改,或壳与 CLI 版本不匹配)');
|
|
107
167
|
}
|
|
108
|
-
|
|
109
|
-
// 2) 解密:AES-256-CTR。
|
|
110
|
-
const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv);
|
|
168
|
+
const decipher = crypto.createDecipheriv('aes-256-ctr', k.enc, iv);
|
|
111
169
|
const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
112
170
|
const obj = JSON.parse(plain.toString('utf8'));
|
|
113
171
|
if (typeof obj.html !== 'string' || typeof obj.config !== 'string') {
|
|
@@ -118,17 +176,13 @@ function decryptApp(name, buf) {
|
|
|
118
176
|
|
|
119
177
|
// ---- 完整性清单(high 模式)----
|
|
120
178
|
|
|
121
|
-
// 生成 resources/.integrity:记录 app.bin
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
const hmac = (buf) => crypto.createHmac('sha256',
|
|
127
|
-
|
|
128
|
-
for (const [rel, buf] of Object.entries(backendRelMap || {})) {
|
|
129
|
-
backend[rel] = hmac(buf);
|
|
130
|
-
}
|
|
131
|
-
return { v: 1, appBin: hmac(appBin), backend };
|
|
179
|
+
// 生成 resources/.integrity:记录 app.bin 的 HMAC-SHA256,壳启动时校验,
|
|
180
|
+
// 防容器被整体替换。backend 已进容器,其完整性由容器认证标签一并保证。
|
|
181
|
+
function buildIntegrity(appName, appBin) {
|
|
182
|
+
const { salt } = splitAppBin(appBin);
|
|
183
|
+
const k = deriveKeys(appName, salt);
|
|
184
|
+
const hmac = (buf) => crypto.createHmac('sha256', k.mac).update(buf).digest('hex');
|
|
185
|
+
return { v: 2, appBin: hmac(appBin) };
|
|
132
186
|
}
|
|
133
187
|
|
|
134
188
|
// 生成 .integrity 文件文本。
|
|
@@ -139,12 +193,17 @@ function renderIntegrity(integrity) {
|
|
|
139
193
|
module.exports = {
|
|
140
194
|
SECURITY_MODES,
|
|
141
195
|
APP_BIN_MAGIC,
|
|
196
|
+
APP_BIN_HEADER_LEN,
|
|
197
|
+
CTR_SALT_LEN,
|
|
142
198
|
parseSecurity,
|
|
143
199
|
resolveSecurity,
|
|
144
200
|
appIdentityFor,
|
|
145
|
-
|
|
201
|
+
masterSecret,
|
|
202
|
+
deriveKeys,
|
|
146
203
|
encryptApp,
|
|
147
204
|
decryptApp,
|
|
205
|
+
splitAppBin,
|
|
206
|
+
isSafeRelPath,
|
|
148
207
|
buildIntegrity,
|
|
149
208
|
renderIntegrity,
|
|
150
209
|
};
|
package/lib/shell.js
CHANGED
|
@@ -316,8 +316,11 @@ function buildShell(plat) {
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
const buildDir = goTemplateDir();
|
|
319
|
-
|
|
320
|
-
|
|
319
|
+
// 剥离符号与调试信息(-s -w)+ 抹掉构建期绝对路径(-trimpath):
|
|
320
|
+
// 通用壳里编译进了 high 模式的密钥派生逻辑,函数名/DWARF 是给逆向者的地图。
|
|
321
|
+
const ldflags = ['-s', '-w'];
|
|
322
|
+
if (plat.startsWith('win')) ldflags.push('-H', 'windowsgui');
|
|
323
|
+
const build = spawnSync('go', ['build', '-trimpath', '-ldflags', ldflags.join(' '), '-o', dest, '.'], {
|
|
321
324
|
cwd: buildDir,
|
|
322
325
|
encoding: 'utf8',
|
|
323
326
|
env: { ...process.env, CGO_ENABLED: '1' },
|
package/lib/tui.js
CHANGED
|
@@ -372,4 +372,40 @@ async function tui(cwd) {
|
|
|
372
372
|
return 0;
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
|
|
375
|
+
// 裸 `freedom` 的显示方式选择器:终端 TUI 或 Freedom Desktop(图形窗口)。
|
|
376
|
+
// 先退出 raw mode 再拉起窗口 / 再进主菜单,避免两个界面抢同一个终端。
|
|
377
|
+
async function pickDisplay(cwd) {
|
|
378
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
379
|
+
console.error('[freedom] 当前非交互式终端,请改用具体子命令(freedom help 查看全部命令)。');
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
const app = new TUI();
|
|
383
|
+
app.enter();
|
|
384
|
+
let idx;
|
|
385
|
+
try {
|
|
386
|
+
idx = await app.menu('选择显示方式', ['终端 TUI(纯文本菜单)', 'Freedom Desktop(图形窗口,由 freedom 自身打包)'], {
|
|
387
|
+
footer: '↑ ↓ 选择 · Enter 确认 · q 退出',
|
|
388
|
+
});
|
|
389
|
+
} finally {
|
|
390
|
+
app.exit();
|
|
391
|
+
}
|
|
392
|
+
if (idx === null) return 0;
|
|
393
|
+
if (idx === 1) return desktopFlow(cwd);
|
|
394
|
+
return await tui(cwd);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function desktopFlow(cwd) {
|
|
398
|
+
const { desktop } = require('./desktop');
|
|
399
|
+
const theme = require('./theme');
|
|
400
|
+
try {
|
|
401
|
+
const r = await desktop({});
|
|
402
|
+
process.stdout.write(`${theme.ok('Freedom Desktop 已拉起:')}${r.exe}\n`);
|
|
403
|
+
if (r.rebuilt) process.stdout.write(`${theme.dim(`(本次已重新打包,CLI v${r.version})`)}\n`);
|
|
404
|
+
return 0;
|
|
405
|
+
} catch (e) {
|
|
406
|
+
process.stdout.write(`\n${theme.err(e.message)}\n`);
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
module.exports = { tui, pickDisplay, TUI };
|
package/lib/verify.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { isWinPlat, platformExeName } = require('./utils');
|
|
13
|
+
const { APP_BIN_MAGIC, decryptApp, buildIntegrity } = require('./security');
|
|
13
14
|
|
|
14
15
|
// ---- 产物定位 ----
|
|
15
16
|
|
|
@@ -107,6 +108,7 @@ function checkPlatformProduct({ targetDir, plat, appName, hasBackend }) {
|
|
|
107
108
|
const hasIntegrity = fs.existsSync(path.join(resDir, '.integrity'));
|
|
108
109
|
const hasHtml = fs.existsSync(path.join(resDir, 'index.html'));
|
|
109
110
|
const hasConfig = fs.existsSync(path.join(resDir, 'config.json'));
|
|
111
|
+
let securePayload = null;
|
|
110
112
|
if (hasBin) {
|
|
111
113
|
if (hasHtml || hasConfig) {
|
|
112
114
|
fail('资源形态', 'high 模式残留明文 index.html/config.json(互斥被破坏)');
|
|
@@ -115,13 +117,35 @@ function checkPlatformProduct({ targetDir, plat, appName, hasBackend }) {
|
|
|
115
117
|
}
|
|
116
118
|
if (!hasIntegrity) fail('完整性清单', '缺失 .integrity');
|
|
117
119
|
else okc('完整性清单', '.integrity 存在');
|
|
118
|
-
// app.bin 容器头校验(
|
|
119
|
-
let
|
|
120
|
+
// app.bin 容器头校验(FRDM2)
|
|
121
|
+
let bin = Buffer.alloc(0);
|
|
120
122
|
try {
|
|
121
|
-
|
|
123
|
+
bin = fs.readFileSync(path.join(resDir, 'app.bin'));
|
|
122
124
|
} catch (e) { /* 读取失败按不通过处理 */ }
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
const head = bin.subarray(0, APP_BIN_MAGIC.length).toString('latin1');
|
|
126
|
+
if (head !== APP_BIN_MAGIC) {
|
|
127
|
+
fail('app.bin 容器', `容器头不是 ${APP_BIN_MAGIC}(实际 "${head}"),文件损坏、非本工具产物或旧版产物`);
|
|
128
|
+
} else {
|
|
129
|
+
okc('app.bin 容器', `${APP_BIN_MAGIC} 头有效(${fmtSize(bin.length)})`);
|
|
130
|
+
// 真解一次容器:认证失败 = 产物被篡改或 name 与容器不匹配;同时拿到 backend 供后端检查。
|
|
131
|
+
try {
|
|
132
|
+
securePayload = decryptApp(appName, bin);
|
|
133
|
+
okc('容器解密', `认证通过,html ${fmtSize(securePayload.html.length)} / config ${fmtSize(securePayload.config.length)}`);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
fail('容器解密', e.message);
|
|
136
|
+
}
|
|
137
|
+
// .integrity 与容器一致(防整体替换:清单值由构建期容器盐派生密钥签名)
|
|
138
|
+
if (hasIntegrity) {
|
|
139
|
+
try {
|
|
140
|
+
const list = JSON.parse(fs.readFileSync(path.join(resDir, '.integrity'), 'utf8'));
|
|
141
|
+
const want = buildIntegrity(appName, bin).appBin;
|
|
142
|
+
if (list.appBin !== want) fail('完整性清单', '.integrity 与 app.bin 不一致(产物可能被替换)');
|
|
143
|
+
else okc('完整性清单', '.integrity 与 app.bin 一致');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
fail('完整性清单', e.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
125
149
|
} else {
|
|
126
150
|
if (hasIntegrity) {
|
|
127
151
|
fail('资源形态', '明文模式残留 high 产物(.integrity)');
|
|
@@ -148,10 +172,21 @@ function checkPlatformProduct({ targetDir, plat, appName, hasBackend }) {
|
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
151
|
-
// 4)
|
|
175
|
+
// 4) 后端:明文模式看 resources/backend 目录;high 模式看容器内 backend 条目
|
|
176
|
+
// (容器模式下磁盘不得有明文后端目录,运行时由壳解密到临时目录)。
|
|
152
177
|
if (hasBackend) {
|
|
153
|
-
if (
|
|
154
|
-
|
|
178
|
+
if (hasBin) {
|
|
179
|
+
const n = securePayload && securePayload.backend ? Object.keys(securePayload.backend).length : 0;
|
|
180
|
+
if (n === 0) fail('后端进程', 'high 模式容器内无 backend 条目(后端源码未入容器)');
|
|
181
|
+
else okc('后端进程', `容器内 ${n} 个后端文件(磁盘无明文)`);
|
|
182
|
+
if (fs.existsSync(path.join(resDir, 'backend'))) {
|
|
183
|
+
fail('后端进程', 'high 模式残留明文 resources/backend(与容器互斥被破坏)');
|
|
184
|
+
}
|
|
185
|
+
} else if (fs.existsSync(path.join(resDir, 'backend'))) {
|
|
186
|
+
okc('后端进程', 'resources/backend 存在');
|
|
187
|
+
} else {
|
|
188
|
+
fail('后端进程', '配置了 backend 但 resources/backend 缺失');
|
|
189
|
+
}
|
|
155
190
|
}
|
|
156
191
|
|
|
157
192
|
return checks;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yufengtadian/freedom-cli",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"bin",
|
|
27
27
|
"lib",
|
|
28
28
|
"shell",
|
|
29
|
+
"skill",
|
|
29
30
|
"templates",
|
|
30
31
|
"tutorial",
|
|
31
32
|
"postinstall.js",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: freedom
|
|
3
|
+
description: 用 Freedom(Go 通用 WebView 桌面壳 + freedom CLI)把任意前端与任意语言后端打包成 Windows/macOS/Linux 桌面应用。当任务涉及 freedom.config.js、freedom build/verify/dev、单文件前端内嵌、NDJSON stdio 后端协议、freedom.invoke / freedom.sys / resources/config.json、FRDM1 资源加密、应用自更新清单(keygen/manifest)或桌面壳选型/排障时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Freedom 桌面壳使用指南
|
|
7
|
+
|
|
8
|
+
Freedom = 预编译通用壳(Go + 系统 WebView)+ 外部 `resources/` 资源层 + `freedom` CLI。
|
|
9
|
+
前端构建成**单文件 HTML** 后由壳在内存加载(`SetHtml`),后端可以是**任意语言的子进程**,
|
|
10
|
+
经 NDJSON over stdio 通信。打包不需要 Go / Rust / Node 工具链在目标机器上存在。
|
|
11
|
+
|
|
12
|
+
## 1. 最小项目形态
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
my-app/
|
|
16
|
+
├─ package.json # 必须 "type":"module"(freedom.config.js 用 ESM 书写)
|
|
17
|
+
├─ freedom.config.js # 唯一配置入口
|
|
18
|
+
├─ index.html src/ # 前端(vite + vite-plugin-singlefile 产单文件)
|
|
19
|
+
└─ backend/ # 可选:进程后端脚本,构建时整体拷进 resources/backend/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`freedom init <目录> [--template full|minimal]` 生成骨架;`freedom build` 打包;
|
|
23
|
+
`freedom verify` 校验产物(CI 用退出码)。
|
|
24
|
+
|
|
25
|
+
### freedom.config.js 可用键
|
|
26
|
+
|
|
27
|
+
| 键 | 含义 |
|
|
28
|
+
|----|------|
|
|
29
|
+
| `name` `title` | 产物文件名 / 窗口标题 |
|
|
30
|
+
| `width` `height` `minWidth` `minHeight` `center` `debug` | 窗口几何与调试 |
|
|
31
|
+
| `titlebar` | `frameless`(默认,前端自绘按钮)/ `native` |
|
|
32
|
+
| `icon` | Win `.ico` / mac `.icns`,构建期注入 |
|
|
33
|
+
| `outDir` | 产物目录,默认 `dist` |
|
|
34
|
+
| `security` | `none` / `basic` / `high`(high = resources 加密为 `app.bin` + `.integrity`) |
|
|
35
|
+
| `backend` | `{ command:'node', args:['backend/x.mjs'] }`;缺省为内嵌 Go 后端模式 |
|
|
36
|
+
| `backendDir` | 后端目录名,默认 `backend` |
|
|
37
|
+
| `staticHtml` | **跳过 npm/vite**,直接内嵌指定单文件 HTML(自举与纯静态页用,零网络) |
|
|
38
|
+
| `url` | 壳改为 `Navigate(url)`(http/https 白名单)——`freedom dev` 热更走这条 |
|
|
39
|
+
| `singleInstance` | `true` 时以 `CreateMutexW` 为权威锁做单实例 |
|
|
40
|
+
| `updater` | `{ manifestURL, publicKey, requireSignature }` 应用自更新 |
|
|
41
|
+
|
|
42
|
+
CLI 侧可用 `freedom config set <key> <value>` 修改;改完必须重新 `freedom build` 生效。
|
|
43
|
+
|
|
44
|
+
## 2. 前端 SDK(壳自动注入,无需 import)
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// 调用后端方法(内嵌 Go 绑定名 或 进程后端 method 名),参数按 JSON 数组透传
|
|
48
|
+
const sum = await freedom.invoke('Add', 1, 2);
|
|
49
|
+
|
|
50
|
+
freedom.on('tick', (data) => ...) // 订阅后端 Emit 的事件,返回 unlisten
|
|
51
|
+
freedom.once('ready', cb) // 一次性
|
|
52
|
+
freedom.off('tick', cb); freedom.emit(event, data)
|
|
53
|
+
|
|
54
|
+
freedom.window.minimize() / maximize() / setSize(w,h) / setTitle(t) / setAlwaysOnTop(true)
|
|
55
|
+
freedom.window.close(true) // force 跳过关闭拦截
|
|
56
|
+
freedom.window.create({title,url,html,width,height}) / list() / focusWindow(id) / closeWindow(id)
|
|
57
|
+
|
|
58
|
+
// 系统能力:任意能力名走同一个桥
|
|
59
|
+
freedom.sys('os.info', {}); freedom.sys('clipboard.read', {})
|
|
60
|
+
freedom.clipboard.readText() / writeText(t)
|
|
61
|
+
freedom.shell.open(target) // 白名单:仅 http(s)/mailto 与显式放行项
|
|
62
|
+
freedom.notification.show(title, body)
|
|
63
|
+
freedom.shortcut.register('id','Ctrl+Alt+K')
|
|
64
|
+
freedom.autostart.enable({name}) / isEnabled()
|
|
65
|
+
freedom.protocol.register('myapp')
|
|
66
|
+
freedom.path('data'|'config'|'cache'|'temp', name)
|
|
67
|
+
freedom.store.set(k,v) / get(k) / load() / keys()
|
|
68
|
+
freedom.process.id() / exit(0) / restart()
|
|
69
|
+
freedom.update.check() / install() / pending() / onAvailable(cb)
|
|
70
|
+
freedom.taskbar.setProgress(0.4) / setState('error') / setOverlay(dataURL)
|
|
71
|
+
freedom.dialog.message({title,message,buttons})
|
|
72
|
+
freedom.tray.create({icon: dataURL, tooltip}) / setMenu([...])
|
|
73
|
+
freedom.isDesktop // 是否在壳内(浏览器打开为 false)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`window.go.backend.call(...)` 是 Wails 迁移别名。判断"未在桌面壳内"应给出浏览器降级,
|
|
77
|
+
而不是让 Promise 直接炸页面。
|
|
78
|
+
|
|
79
|
+
## 3. 进程后端协议(NDJSON over stdio)
|
|
80
|
+
|
|
81
|
+
换行分隔 JSON,UTF-8,**stdout 只能写协议帧**(日志请写 stderr 或文件)。
|
|
82
|
+
|
|
83
|
+
```jsonc
|
|
84
|
+
// 壳 → 后端(请求)
|
|
85
|
+
{"id":1,"method":"Add","params":[1,2]}
|
|
86
|
+
// 后端 → 壳(响应:error 为空串表示成功)
|
|
87
|
+
{"id":1,"result":3,"error":""}
|
|
88
|
+
// 后端 → 壳(主动推送,触发前端 freedom.on)
|
|
89
|
+
{"event":"tick","data":{"count":1}}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
后端进程工作目录 = `resources/`,环境变量注入 `FREEDOM_BACKEND=1`、`FREEDOM_IPC=stdio`。
|
|
93
|
+
最小 Node 实现(零依赖):
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import readline from 'node:readline';
|
|
97
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
98
|
+
rl.on('close', () => process.exit(0)); // 壳关闭 stdin 即优雅退出,别等超时
|
|
99
|
+
rl.on('line', (line) => {
|
|
100
|
+
const r = JSON.parse(line);
|
|
101
|
+
const [a, b] = r.params ?? [];
|
|
102
|
+
process.stdout.write(JSON.stringify({ id: r.id, result: a + b, error: '' }) + '\n');
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Go / Python / Rust 同构实现见 `examples/multiproc/backends/`。Rust 侧零依赖单文件、
|
|
107
|
+
`rustc -O` 直编,不走 cargo。
|
|
108
|
+
|
|
109
|
+
## 4. CLI 命令
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
freedom # 选择显示方式:终端 TUI / Freedom Desktop
|
|
113
|
+
freedom tui | desktop # 直达对应显示(desktop 会按需自动重打包再拉起)
|
|
114
|
+
freedom init [dir] [--template full|minimal] [--force]
|
|
115
|
+
freedom build [--platform win|mac|linux|all] [--security none|basic|high] [--installer] [--no-cache]
|
|
116
|
+
freedom verify [--platform <p>] # 产物完整性校验,非零退出码 = 有问题
|
|
117
|
+
freedom dev [--port <n>|--url <u>|--command <cmd>] # HMR 联调壳窗口,改码免重打包
|
|
118
|
+
freedom dmg [--platform darwin-arm64] # macOS 上产出 .dmg(hdiutil)
|
|
119
|
+
freedom config [get <k>|set <k> <v>]
|
|
120
|
+
freedom titlebar <native|frameless> | icon <path> | security <mode>
|
|
121
|
+
freedom shell list|download <plat>|build <plat>
|
|
122
|
+
freedom keygen # 生成自更新 ed25519 密钥对(私钥只在发布方)
|
|
123
|
+
freedom manifest --artifact <产物> --url <下载地址> [--version x] [--notes y]
|
|
124
|
+
freedom agents # Agent 集成支持矩阵(本机证据判定)
|
|
125
|
+
freedom skill install --agent all [--dry-run]
|
|
126
|
+
freedom mcp install --agent <key> [--config <path> --format json|toml|yaml]
|
|
127
|
+
freedom mcp serve # stdio MCP 服务(一般由 agent 拉起,不手敲)
|
|
128
|
+
freedom update | version | tutorial | help
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
自动化/脚本环境请设 `FREEDOM_AUTO_UPDATE=0`,否则 npm 相关命令可能触发自动升级。
|
|
132
|
+
|
|
133
|
+
## 5. 产物与发布
|
|
134
|
+
|
|
135
|
+
- 单平台:`dist/<name>[.exe]` + `dist/resources/{index.html,config.json,backend/}`;多平台落 `dist/<plat>/`。
|
|
136
|
+
- macOS:`--platform mac` 直接产出 `<name>.app.zip`(解压即用);Linux 同理为目录形态。
|
|
137
|
+
- `--installer`:便携 zip 恒产出;Windows 且有 `makensis` 时额外编译 `setup.exe`,否则产出已填充的 `.nsi`。
|
|
138
|
+
- `--security high`:resources 整体加密为 `app.bin`(PBKDF2-HMAC-SHA256 按 exe 名派生密钥 +
|
|
139
|
+
AES-256-CTR + Encrypt-then-MAC)+ `.integrity` 清单;磁盘无明文,校验失败壳拒绝运行。
|
|
140
|
+
参数与 Go 侧 `security.go` 跨语言同步,**改一侧必须改另一侧**。
|
|
141
|
+
- 自更新:`freedom keygen` → 公钥写进 `updater.publicKey` → 发版用 `freedom manifest` 签
|
|
142
|
+
`latest.json` → 壳内 `freedom.update.check()`。清单签名串固定为
|
|
143
|
+
`freedom-update-v1\n{version}\n{url}\n{sha256小写}`。
|
|
144
|
+
|
|
145
|
+
## 6. 坑清单(都真实踩过)
|
|
146
|
+
|
|
147
|
+
1. **前端必须单文件**:壳只读一个 `index.html`,资源请内联(vite-plugin-singlefile)。
|
|
148
|
+
2. **stdout 污染**:进程后端往 stdout 打日志 = 协议解析失败。日志走 stderr。
|
|
149
|
+
3. **后端不退出**:stdin EOF 时必须 `process.exit(0)` / 等价处理,否则每次关窗都等满超时被强杀。
|
|
150
|
+
4. **Bind 回调是异步的**:`freedom.invoke` 的 handler 跑在 worker goroutine,可并发、完成序不保证;
|
|
151
|
+
壳内置的 `__freedom_window/sys/tray` 桥仍跑在 UI 消息泵内,其中涉及子进程/文件 IO 需自行异步化。
|
|
152
|
+
5. **平台面不对齐**:Windows 独有能力(taskbar 进度、原生对话框、托盘)在 Linux 会明确报
|
|
153
|
+
not supported,macOS 部分能力仍在路上——UI 要先 `freedom.sys('os.info')` 判平台再摆按钮。
|
|
154
|
+
6. **PowerShell 脚本**:含中文必须存带 BOM 的 UTF-8,`param()` 必须是首条可执行语句;
|
|
155
|
+
原生命令失败要显式查 `$LASTEXITCODE`。
|
|
156
|
+
7. **Windows 打 zip**:必须用 `%SystemRoot%\System32\tar.exe`(bsdtar)。Git Bash 的 GNU tar
|
|
157
|
+
会抢 PATH,且不认 `-a`、把 `D:\` 当远程主机。
|
|
158
|
+
8. **能力模型**:`Config.Capabilities{Allow,Deny}`(`path.Match`)在 sys/tray/window 派发前判定,
|
|
159
|
+
拒绝零副作用;默认全开。
|