@yufengtadian/freedom-cli 1.13.1 → 1.13.3
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 +40 -0
- package/README.md +119 -15
- package/lib/agents.js +448 -0
- package/lib/build.js +189 -45
- package/lib/cli.js +258 -9
- 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 +170 -63
- package/lib/toolchain.js +339 -0
- package/lib/tui.js +37 -1
- package/lib/utils.js +51 -0
- package/lib/verify.js +48 -12
- package/lib/webkit.js +45 -0
- package/package.json +3 -2
- 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 +327 -0
- package/templates/desktop/freedom.config.js +32 -0
- package/templates/desktop/icon.ico +0 -0
- package/templates/go/go.mod +3 -0
- package/templates/go/pkg/freedom/anti_debug_linux.go +69 -0
- package/templates/go/pkg/freedom/anti_debug_other.go +5 -3
- package/templates/go/pkg/freedom/anti_debug_windows.go +238 -11
- package/templates/go/pkg/freedom/assets/freedom.js +3 -0
- package/templates/go/pkg/freedom/assets_embed.go +1 -0
- package/templates/go/pkg/freedom/backend_proc.go +5 -5
- package/templates/go/pkg/freedom/events_windows.go +5 -5
- package/templates/go/pkg/freedom/freedom.go +64 -10
- package/templates/go/pkg/freedom/msgwindow_windows.go +8 -8
- package/templates/go/pkg/freedom/resources.go +119 -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 +293 -64
- package/templates/go/pkg/freedom/shutdown.go +55 -0
- package/templates/go/pkg/freedom/shutdown_windows.go +47 -0
- package/templates/go/pkg/freedom/store.go +11 -3
- package/templates/go/pkg/freedom/syscap_windows.go +33 -33
- package/templates/go/pkg/freedom/sysint_windows.go +2 -2
- package/templates/go/pkg/freedom/tray_windows.go +18 -18
- package/templates/go/pkg/freedom/updater.go +3 -3
- package/templates/go/pkg/freedom/window_other.go +7 -1
- package/templates/go/pkg/freedom/window_windows.go +185 -17
- package/templates/go/third_party/webview_go/CHANGELOG.md +15 -0
- package/templates/go/third_party/webview_go/FREEDOM-PATCH.md +32 -0
- package/templates/go/third_party/webview_go/LICENSE +22 -0
- package/templates/go/third_party/webview_go/README.md +50 -0
- package/templates/go/third_party/webview_go/glue.c +36 -0
- package/templates/go/third_party/webview_go/go.mod +3 -0
- package/templates/go/third_party/webview_go/go.sum +0 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/LICENSE +27 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/include/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/version.txt +1 -0
- package/templates/go/third_party/webview_go/libs/webview/LICENSE +22 -0
- package/templates/go/third_party/webview_go/libs/webview/include/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/webview/include/webview.h +3598 -0
- package/templates/go/third_party/webview_go/libs/webview/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/webview/version.txt +1 -0
- package/templates/go/third_party/webview_go/webkit2_40.go +21 -0
- package/templates/go/third_party/webview_go/webkit2_41.go +8 -0
- package/templates/go/third_party/webview_go/webview.cc +1 -0
- package/templates/go/third_party/webview_go/webview.go +332 -0
- 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
|
@@ -23,7 +23,9 @@ const {
|
|
|
23
23
|
SHELL_EXE_NAME,
|
|
24
24
|
localShellPath,
|
|
25
25
|
nativePlatform,
|
|
26
|
+
normalizePlatform,
|
|
26
27
|
} = require('./utils');
|
|
28
|
+
const { applyWebkitTags } = require('./webkit');
|
|
27
29
|
|
|
28
30
|
// GitHub Releases 下载源(可用环境变量覆盖)。
|
|
29
31
|
// 资产命名约定:freedom-shell-<plat>(单文件二进制,不压缩)。
|
|
@@ -31,6 +33,17 @@ function releaseRepo() {
|
|
|
31
33
|
return process.env.FREEDOM_SHELL_REPO || 'YUfeng12TA/freedom';
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// 直连下载域与 API 域(测试可指向本地桩服务;默认走 GitHub)。
|
|
37
|
+
function releaseBase() {
|
|
38
|
+
return (process.env.FREEDOM_SHELL_BASE || 'https://github.com').replace(/\/+$/, '');
|
|
39
|
+
}
|
|
40
|
+
function apiBase() {
|
|
41
|
+
return (process.env.FREEDOM_GITHUB_API || 'https://api.github.com').replace(/\/+$/, '');
|
|
42
|
+
}
|
|
43
|
+
function shellAssetName(plat) {
|
|
44
|
+
return `freedom-shell-${plat}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
// 远程壳下载的默认版本:与当前包版本保持一致。
|
|
35
48
|
// 通过环境变量 FREEDOM_SHELL_TAG 可强制覆盖;默认动态读取 package.json 的 version,
|
|
36
49
|
// 避免与包版本漂移(历史曾硬编码 v1.1.10 导致下载到旧壳)。
|
|
@@ -46,7 +59,7 @@ function releaseTag() {
|
|
|
46
59
|
return process.env.FREEDOM_SHELL_TAG || `v${pkgVersion()}`;
|
|
47
60
|
}
|
|
48
61
|
function releaseUrl(plat) {
|
|
49
|
-
return
|
|
62
|
+
return `${releaseBase()}/${releaseRepo()}/releases/download/${releaseTag()}/${shellAssetName(plat)}`;
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
// ---- 壳二进制平台格式校验 ----
|
|
@@ -193,13 +206,14 @@ function hasCurl() {
|
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
// 用 curl 下载到 dest;成功返回 null,失败返回错误信息
|
|
196
|
-
function curlDownload(url, dest, proxy) {
|
|
209
|
+
function curlDownload(url, dest, proxy, extraHeaders) {
|
|
197
210
|
const args = [
|
|
198
211
|
'-L', '--fail', '--silent', '--show-error',
|
|
199
212
|
'--connect-timeout', '20', '--max-time', '180', '--retry', '2',
|
|
200
213
|
'--output', dest,
|
|
201
214
|
];
|
|
202
215
|
if (proxy) args.push('--proxy', proxy);
|
|
216
|
+
for (const h of extraHeaders || []) args.push('-H', h);
|
|
203
217
|
if (process.platform === 'win32' && /^https:/i.test(url)) {
|
|
204
218
|
args.push('--ssl-no-revoke'); // 规避 Windows schannel CRYPT_E_REVOCATION_OFFLINE
|
|
205
219
|
}
|
|
@@ -212,18 +226,124 @@ function curlDownload(url, dest, proxy) {
|
|
|
212
226
|
return null;
|
|
213
227
|
}
|
|
214
228
|
|
|
229
|
+
// 平台参数收口:win / mac / linux 等别名归一到平台 key,识别不了给可读错误。
|
|
230
|
+
function requirePlatform(plat) {
|
|
231
|
+
const key = normalizePlatform(plat);
|
|
232
|
+
if (!key) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`未知平台:${plat}。可选:${ALL_PLATFORMS.join(' / ')}` +
|
|
235
|
+
`(也接受 win / mac / linux 别名,可带架构后缀,如 mac-arm64)`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return key;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 单次 HTTP GET 到 Buffer:配了代理就走 curl(原生支持 socks5),否则用内置 fetch。
|
|
242
|
+
// API 端点需要 Accept 头,故 headers 显式传入。
|
|
243
|
+
async function httpGetBuffer(url, headers) {
|
|
244
|
+
const proxy = resolveProxy();
|
|
245
|
+
if (proxy) {
|
|
246
|
+
if (!hasCurl()) throw new Error(`已配置代理 ${proxy} 但本机无 curl`);
|
|
247
|
+
const tmp = path.join(os.tmpdir(), `freedom-dl-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
248
|
+
const err = curlDownload(url, tmp, proxy, Object.entries(headers || {}).map(([k, v]) => `${k}: ${v}`));
|
|
249
|
+
try {
|
|
250
|
+
if (err) throw new Error(err);
|
|
251
|
+
return fs.readFileSync(tmp);
|
|
252
|
+
} finally {
|
|
253
|
+
try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const res = await fetch(url, {
|
|
257
|
+
headers: Object.assign({ 'User-Agent': 'freedom-cli' }, headers),
|
|
258
|
+
redirect: 'follow',
|
|
259
|
+
signal: AbortSignal.timeout(60000),
|
|
260
|
+
});
|
|
261
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
262
|
+
return Buffer.from(await res.arrayBuffer());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 从 release 资产列表里挑出本平台的壳资产(需带 API url 字段)。
|
|
266
|
+
function selectReleaseAsset(assets, plat) {
|
|
267
|
+
const name = shellAssetName(plat);
|
|
268
|
+
return (Array.isArray(assets) ? assets : []).find(
|
|
269
|
+
(a) => a && a.name === name && typeof a.url === 'string'
|
|
270
|
+
) || null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 经 GitHub Release API 取壳:先按 tag 查 release 拿资产 id,再以 octet-stream 拉字节。
|
|
274
|
+
// 背景(实测):releases/download 会 302 到 S3 签名域,受限网络下响应头拿得到、
|
|
275
|
+
// body 永远不结束;api.github.com 的资产端点由 GitHub 自己代理字节流,可稳定取回。
|
|
276
|
+
async function downloadShellViaApi(plat) {
|
|
277
|
+
const token = process.env.FREEDOM_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
|
|
278
|
+
const auth = token ? { Authorization: `Bearer ${token}` } : {};
|
|
279
|
+
const metaUrl = `${apiBase()}/repos/${releaseRepo()}/releases/tags/${releaseTag()}`;
|
|
280
|
+
let meta;
|
|
281
|
+
try {
|
|
282
|
+
meta = JSON.parse((await httpGetBuffer(metaUrl, Object.assign({ Accept: 'application/json' }, auth))).toString('utf8'));
|
|
283
|
+
} catch (e) {
|
|
284
|
+
throw new Error(`查询 release 元数据失败(${metaUrl}):${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
const asset = selectReleaseAsset(meta && meta.assets, plat);
|
|
287
|
+
if (!asset) {
|
|
288
|
+
throw new Error(`${releaseTag()} 中没有资产 ${shellAssetName(plat)}`);
|
|
289
|
+
}
|
|
290
|
+
return await httpGetBuffer(asset.url, Object.assign({ Accept: 'application/octet-stream' }, auth));
|
|
291
|
+
}
|
|
292
|
+
|
|
215
293
|
// 下载指定平台壳到包内 shell/<plat>/
|
|
216
294
|
// 返回下载后的绝对路径;失败抛错。
|
|
295
|
+
// 两条路径:直连 releases/download(有代理时经 curl)→ 失败回退 Release API 资产端点。
|
|
217
296
|
async function downloadShell(plat) {
|
|
218
|
-
|
|
219
|
-
throw new Error(`未知平台:${plat}。可选:${ALL_PLATFORMS.join(' / ')}`);
|
|
220
|
-
}
|
|
297
|
+
plat = requirePlatform(plat);
|
|
221
298
|
const url = releaseUrl(plat);
|
|
222
299
|
const dest = localShellPath(plat);
|
|
223
300
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
224
301
|
|
|
302
|
+
let buf = null;
|
|
303
|
+
let primaryErr = null;
|
|
304
|
+
try {
|
|
305
|
+
buf = await fetchShellDirect(plat, url);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
primaryErr = e;
|
|
308
|
+
process.stdout.write(
|
|
309
|
+
`[freedom] 直连下载壳 ${plat} 未完成(${String(e.message).split('\n')[0]}),回退 GitHub API 资产端点…\n`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (!buf) {
|
|
313
|
+
try {
|
|
314
|
+
buf = await downloadShellViaApi(plat);
|
|
315
|
+
} catch (apiErr) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`下载壳 ${plat} 失败:直连与 Release API 两条路径都不通。\n` +
|
|
318
|
+
` 直连 ${url}:${primaryErr ? primaryErr.message : '未返回数据'}\n` +
|
|
319
|
+
` API ${apiBase()}/repos/${releaseRepo()}:${apiErr.message}\n` +
|
|
320
|
+
`可设置代理重试(FREEDOM_SHELL_PROXY=socks5h://127.0.0.1:10808),` +
|
|
321
|
+
`或手动将壳二进制放入 ${dest}。`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// B13:下载后、落盘前校验格式/架构,防止代理劫持返回错误页或假壳被静默分发。
|
|
327
|
+
const fmt = detectShellFormat(buf);
|
|
328
|
+
const want = expectedFormat(plat);
|
|
329
|
+
if (fmt === 'unknown' || formatFamily(fmt) !== formatFamily(want)) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`下载的壳格式异常:期望 ${want}(${plat}),实际 detect=${fmt}。` +
|
|
332
|
+
`下载地址 ${url} 可能返回了错误页或非本平台假壳,请检查 GitHub Release 资产 ` +
|
|
333
|
+
`${releaseRepo()} 的 ${releaseTag()}。已放弃本次写入。`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
fs.writeFileSync(dest, buf);
|
|
337
|
+
if (process.platform !== 'win32') {
|
|
338
|
+
fs.chmodSync(dest, 0o755);
|
|
339
|
+
}
|
|
340
|
+
return dest;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 直连路径:代理在位时用 curl(原生支持 socks5/http 代理),否则用内置 fetch。
|
|
344
|
+
// 返回内容 Buffer;网络失败、超时、非 2xx 一律抛错(由 downloadShell 决定回退)。
|
|
345
|
+
async function fetchShellDirect(plat, url, dest) {
|
|
225
346
|
const proxy = resolveProxy();
|
|
226
|
-
let buf;
|
|
227
347
|
if (proxy) {
|
|
228
348
|
process.stdout.write(`[freedom] 下载壳 ${plat}(经代理 ${proxy})<- ${url}\n`);
|
|
229
349
|
if (!hasCurl()) {
|
|
@@ -238,74 +358,40 @@ async function downloadShell(plat) {
|
|
|
238
358
|
try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
|
|
239
359
|
throw new Error(
|
|
240
360
|
`${curlErr}\n下载地址 ${url} 不可达。` +
|
|
241
|
-
`请确认代理 ${proxy} 可用(可执行 curl --proxy ${proxy} ${url} -I
|
|
242
|
-
`或手动将壳二进制放入 ${localShellPath(plat)}。`
|
|
361
|
+
`请确认代理 ${proxy} 可用(可执行 curl --proxy ${proxy} ${url} -I 自测)。`
|
|
243
362
|
);
|
|
244
363
|
}
|
|
245
364
|
try {
|
|
246
|
-
|
|
365
|
+
return fs.readFileSync(tmp);
|
|
247
366
|
} finally {
|
|
248
367
|
try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
|
|
249
368
|
}
|
|
250
|
-
} else {
|
|
251
|
-
process.stdout.write(`[freedom] 下载壳 ${plat} <- ${url}\n`);
|
|
252
|
-
let res;
|
|
253
|
-
try {
|
|
254
|
-
// 30s 超时:网络挂起时明确报错,避免构建进程无限阻塞
|
|
255
|
-
res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30000) });
|
|
256
|
-
} catch (e) {
|
|
257
|
-
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
258
|
-
throw new Error(
|
|
259
|
-
`下载壳 ${plat} 超时(30s)。请检查网络后重试,` +
|
|
260
|
-
`或设置代理变量 FREEDOM_SHELL_PROXY=socks5h://127.0.0.1:10808 重试,` +
|
|
261
|
-
`或手动将壳二进制放入 ${localShellPath(plat)}。`
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
throw new Error(
|
|
265
|
-
`下载壳 ${plat} 失败:${e.message}。请检查网络(必要时设置 FREEDOM_SHELL_PROXY 走代理),` +
|
|
266
|
-
`或手动将壳二进制放入 ${localShellPath(plat)}。`
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
if (!res.ok) {
|
|
270
|
-
throw new Error(
|
|
271
|
-
`下载壳失败:HTTP ${res.status}。请确认 GitHub 仓库 ${releaseRepo()} 已发布 ` +
|
|
272
|
-
`${releaseTag()} 的资产 freedom-shell-${plat},或手动将壳二进制放入 ${localShellPath(plat)}。`
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
buf = Buffer.from(await res.arrayBuffer());
|
|
276
369
|
}
|
|
277
370
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
fs.writeFileSync(dest, buf);
|
|
289
|
-
if (process.platform !== 'win32') {
|
|
290
|
-
fs.chmodSync(dest, 0o755);
|
|
371
|
+
process.stdout.write(`[freedom] 下载壳 ${plat} <- ${url}\n`);
|
|
372
|
+
let res;
|
|
373
|
+
try {
|
|
374
|
+
// 30s 超时:网络挂起时明确报错,避免构建进程无限阻塞
|
|
375
|
+
res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30000) });
|
|
376
|
+
} catch (e) {
|
|
377
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
378
|
+
throw new Error('下载超时(30s)');
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`请求失败:${e.message}`);
|
|
291
381
|
}
|
|
292
|
-
|
|
382
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
383
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
384
|
+
if (!buf.length) throw new Error('响应体为空');
|
|
385
|
+
return buf;
|
|
293
386
|
}
|
|
294
387
|
|
|
295
388
|
// 本地用 Go 编译指定平台壳(需要 Go + 该平台编译环境)。
|
|
296
389
|
// Windows 产物以 GUI 子系统编译(-H windowsgui),运行时无 cmd 黑窗。
|
|
297
390
|
function buildShell(plat) {
|
|
298
|
-
|
|
299
|
-
throw new Error(`未知平台:${plat}。可选:${ALL_PLATFORMS.join(' / ')}`);
|
|
300
|
-
}
|
|
301
|
-
const res = spawnSync('go', ['version'], { encoding: 'utf8' });
|
|
302
|
-
if (res.error || res.status !== 0) {
|
|
303
|
-
throw new Error('未检测到 Go 工具链。请先安装 Go(https://go.dev/dl/),或改用 freedom shell download。');
|
|
304
|
-
}
|
|
305
|
-
const dest = localShellPath(plat);
|
|
306
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
307
|
-
|
|
391
|
+
plat = requirePlatform(plat);
|
|
308
392
|
// 本机只能编译本机平台(webview_go 依赖系统 WebView 框架,无法交叉编译)。
|
|
393
|
+
// 这道判断刻意前置于 Go 探测:平台不匹配是与工具有关的硬事实,先报工具有关
|
|
394
|
+
// 的错误会把「装个 Go 也没用」的场景误导成装 Go 能解决。
|
|
309
395
|
// 统一走 utils.nativePlatform:Intel Mac 会明确抛"已不支持",避免两套映射语义不一(B44)。
|
|
310
396
|
const native = nativePlatform();
|
|
311
397
|
if (plat !== native) {
|
|
@@ -314,16 +400,37 @@ function buildShell(plat) {
|
|
|
314
400
|
`请在目标平台执行 freedom shell build ${plat},或用 freedom shell download ${plat} 拉取 CI 预编译产物。`
|
|
315
401
|
);
|
|
316
402
|
}
|
|
403
|
+
const res = spawnSync('go', ['version'], { encoding: 'utf8' });
|
|
404
|
+
if (res.error || res.status !== 0) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
'未检测到 Go 工具链。执行 freedom toolchain install go 自动安装(默认只打印命令,加 --apply 才执行),' +
|
|
407
|
+
'或手动获取 https://go.dev/dl/ ,或改用 freedom shell download 拉取预编译壳。'
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const dest = localShellPath(plat);
|
|
411
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
317
412
|
|
|
318
413
|
const buildDir = goTemplateDir();
|
|
319
|
-
|
|
320
|
-
|
|
414
|
+
// 剥离符号与调试信息(-s -w)+ 抹掉构建期绝对路径(-trimpath):
|
|
415
|
+
// 通用壳里编译进了 high 模式的密钥派生逻辑,函数名/DWARF 是给逆向者的地图。
|
|
416
|
+
const ldflags = ['-s', '-w'];
|
|
417
|
+
if (plat.startsWith('win')) ldflags.push('-H', 'windowsgui');
|
|
418
|
+
// Linux:新发行版只有 webkit2gtk-4.1,需要 -tags webkit2_41(探测与注入见 lib/webkit.js)。
|
|
419
|
+
const webkit = applyWebkitTags(process.env);
|
|
420
|
+
if (webkit.tags.length) {
|
|
421
|
+
process.stdout.write(`[freedom] 本机 WebKitGTK 仅有 4.1,构建加 -tags ${webkit.tags.join(',')}\n`);
|
|
422
|
+
}
|
|
423
|
+
const build = spawnSync('go', ['build', '-trimpath', '-ldflags', ldflags.join(' '), '-o', dest, '.'], {
|
|
321
424
|
cwd: buildDir,
|
|
322
425
|
encoding: 'utf8',
|
|
323
|
-
env: {
|
|
426
|
+
env: Object.assign({ CGO_ENABLED: '1' }, webkit.env),
|
|
324
427
|
});
|
|
325
428
|
if (build.error || build.status !== 0) {
|
|
326
|
-
|
|
429
|
+
const hint = plat.startsWith('linux') && /webkit2gtk-4\.0/.test(`${build.stdout}${build.stderr}`)
|
|
430
|
+
? `\n提示:本机未装 webkit2gtk-4.0/4.1 开发库。Ubuntu/Debian 执行 ` +
|
|
431
|
+
`sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev(老发行版用 4.0)后重试。`
|
|
432
|
+
: '';
|
|
433
|
+
throw new Error(`Go 编译失败:\n${build.stdout}\n${build.stderr}${hint}`);
|
|
327
434
|
}
|
|
328
435
|
return dest;
|
|
329
436
|
}
|