@yufengtadian/freedom-cli 1.12.12 → 1.12.14

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.
Files changed (39) hide show
  1. package/README.md +17 -3
  2. package/bin/freedom.js +8 -3
  3. package/lib/build.js +64 -13
  4. package/lib/cli.js +129 -82
  5. package/lib/config.js +0 -7
  6. package/lib/shell.js +170 -33
  7. package/lib/theme.js +105 -0
  8. package/lib/tui.js +27 -3
  9. package/lib/update.js +109 -0
  10. package/lib/utils.js +14 -1
  11. package/package.json +1 -1
  12. package/shell/darwin-arm64/freedom-shell +0 -0
  13. package/shell/linux-x64/freedom-shell +0 -0
  14. package/shell/win-x64/freedom-shell.exe +0 -0
  15. package/templates/go/main.go +2 -1
  16. package/templates/go/pkg/freedom/assets/freedom.js +10 -4
  17. package/templates/go/pkg/freedom/backend_proc.go +7 -4
  18. package/templates/go/pkg/freedom/center_windows.go +44 -5
  19. package/templates/go/pkg/freedom/configfile.go +12 -5
  20. package/templates/go/pkg/freedom/freedom.go +303 -273
  21. package/templates/go/pkg/freedom/window_other.go +6 -2
  22. package/templates/go/webview_go/libs/webview/include/webview.h +31 -0
  23. package/templates/go/webview_go/webview.go +32 -6
  24. package/templates/project/src/main.js +12 -5
  25. package/templates/go/build_out.txt +0 -0
  26. package/templates/go/err.txt +0 -2
  27. package/templates/go/err10.txt +0 -0
  28. package/templates/go/err11.txt +0 -0
  29. package/templates/go/err2.txt +0 -0
  30. package/templates/go/err3.txt +0 -0
  31. package/templates/go/err4.txt +0 -1
  32. package/templates/go/err5.txt +0 -0
  33. package/templates/go/err6.txt +0 -0
  34. package/templates/go/err7.txt +0 -0
  35. package/templates/go/err8.txt +0 -0
  36. package/templates/go/err9.txt +0 -0
  37. package/templates/go/go.mod.bak_gbk +0 -11
  38. package/templates/go/tidy.txt +0 -0
  39. package/templates/go/vet_out.txt +0 -0
package/lib/shell.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  ALL_PLATFORMS,
23
23
  SHELL_EXE_NAME,
24
24
  localShellPath,
25
+ nativePlatform,
25
26
  } = require('./utils');
26
27
 
27
28
  // GitHub Releases 下载源(可用环境变量覆盖)。
@@ -49,27 +50,63 @@ function releaseUrl(plat) {
49
50
  }
50
51
 
51
52
  // ---- 壳二进制平台格式校验 ----
52
- // 读取二进制文件头判断真实平台格式,防止"用 Windows 壳冒充 mac/linux 壳"这类假壳
53
+ // 读取二进制头识别真实平台(含架构),防止"用 Windows 壳冒充 mac/linux 壳"这类假壳
53
54
  // 被静默分发(历史缺陷:shell/<darwin-*>/<linux-*> 曾误填 Windows PE 副本)。
55
+ // 返回精确平台 key(win-x64 / win-arm64 / mac-x64 / mac-arm64 / linux-x64 / linux-arm64)、
56
+ // 仅格式族(win / mac / linux,老壳 / 未知架构)或 'unknown'。
54
57
  function detectShellFormat(buf) {
55
- if (!buf || buf.length < 4) return 'unknown';
56
- // Windows PE:MZ
57
- if (buf[0] === 0x4d && buf[1] === 0x5a) return 'win';
58
- // Mach-O 64 位:CF FA ED FE(32 位为 CE FA ED FE,本项目仅 64 位)
59
- if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) return 'mac';
60
- // ELF:7F 45 4C 46
61
- if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) return 'linux';
58
+ if (!buf || buf.length < 64) return 'unknown';
59
+ // Windows PE:MZ;e_lfanew @0x3C (4 LE) -> "PE\0\0",machine @+4 (2 LE)
60
+ if (buf[0] === 0x4d && buf[1] === 0x5a) {
61
+ const peOff = buf.readUInt32LE(0x3c);
62
+ if (peOff + 6 <= buf.length && buf.readUInt32LE(peOff) === 0x00004550) {
63
+ const machine = buf.readUInt16LE(peOff + 4);
64
+ if (machine === 0x8664) return 'win-x64';
65
+ if (machine === 0xaa64) return 'win-arm64';
66
+ }
67
+ return 'win';
68
+ }
69
+ // Mach-O 64 位:CF FA ED FE(magic 0xfeedfacf 小端);cputype @4 (4 LE)
70
+ if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
71
+ const cpu = buf.readUInt32LE(4);
72
+ if (cpu === 0x01000007) return 'mac-x64';
73
+ if (cpu === 0x0100000c) return 'mac-arm64';
74
+ return 'mac';
75
+ }
76
+ // ELF:7F 45 4C 46;e_machine @18 (2 LE):62=x86_64,183=aarch64
77
+ if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
78
+ const machine = buf.readUInt16LE(18);
79
+ if (machine === 62) return 'linux-x64';
80
+ if (machine === 183) return 'linux-arm64';
81
+ return 'linux';
82
+ }
62
83
  return 'unknown';
63
84
  }
64
85
 
65
- // 平台 key -> 期望的二进制格式族
86
+ // 平台 key -> 期望的二进制平台 key(含架构)
66
87
  function expectedFormat(plat) {
67
- if (plat.startsWith('win')) return 'win';
68
- if (plat.startsWith('darwin')) return 'mac';
69
- if (plat.startsWith('linux')) return 'linux';
88
+ if (plat.startsWith('win')) {
89
+ return plat === 'win-x64' || plat === 'win-arm64' ? plat : 'win';
90
+ }
91
+ if (plat.startsWith('darwin')) {
92
+ if (plat === 'darwin-x64') return 'mac-x64';
93
+ if (plat === 'darwin-arm64') return 'mac-arm64';
94
+ return 'mac';
95
+ }
96
+ if (plat.startsWith('linux')) {
97
+ return plat === 'linux-x64' || plat === 'linux-arm64' ? plat : 'linux';
98
+ }
70
99
  return 'unknown';
71
100
  }
72
101
 
102
+ // 格式族(win / mac / linux)
103
+ function formatFamily(s) {
104
+ if (s.startsWith('win')) return 'win';
105
+ if (s.startsWith('mac')) return 'mac';
106
+ if (s.startsWith('linux')) return 'linux';
107
+ return s;
108
+ }
109
+
73
110
  // 校验本地壳二进制是否为目标平台的真实格式;不匹配返回错误说明,不抛错(供调用方决策)。
74
111
  function validateLocalShell(plat) {
75
112
  const p = localShellPath(plat);
@@ -87,12 +124,21 @@ function validateLocalShell(plat) {
87
124
  if (fmt === 'unknown') {
88
125
  return `壳二进制 ${p} 不是可识别的 PE/Mach-O/ELF 格式(detect=${fmt})。`;
89
126
  }
90
- if (fmt !== want) {
127
+ if (formatFamily(fmt) !== formatFamily(want)) {
91
128
  return `壳二进制 ${p} 格式与目标平台 ${plat} 不匹配:期望 ${want},实际 ${fmt}。` +
92
129
  `这是假壳(历史缺陷曾把 Windows 壳复制到 mac/linux 平台)。` +
93
130
  `请运行 freedom shell build ${plat} 在 ${plat} 本机编译真实壳,` +
94
131
  `或 freedom shell download ${plat} 拉取 CI 预编译产物。`;
95
132
  }
133
+ if (fmt !== want) {
134
+ if (!fmt.includes('-')) {
135
+ // 老壳仅能识别格式族、无架构信息:降级通过并提示(历史二进制)
136
+ console.warn(`[freedom] 警告:壳二进制 ${p} 仅识别为 ${formatFamily(fmt)} 格式(架构未知,detect=${fmt}),按目标 ${plat} 使用。`);
137
+ return null;
138
+ }
139
+ return `壳二进制 ${p} 架构与目标平台 ${plat} 不匹配:期望 ${want},实际 ${fmt}(如 linux-x64 与 linux-arm64 不能混用)。` +
140
+ `请运行 freedom shell download ${plat} 拉取正确的预编译壳。`;
141
+ }
96
142
  return null;
97
143
  }
98
144
 
@@ -117,6 +163,55 @@ function hasShell(plat) {
117
163
  return fs.existsSync(localShellPath(plat));
118
164
  }
119
165
 
166
+ // ---- 代理支持(修复:本地无 linux/mac 壳时自动下载必失败的历史根因) ----
167
+ // 背景:GitHub Releases 资产重定向到 S3,Node 内置 fetch 直连在受限网络下
168
+ // body 下载会卡死(可拿到 200 响应头但 arrayBuffer 永远不结束)。本机若跑
169
+ // 在代理后(如 socks5://127.0.0.1:10808),必须显式走代理才能拉取壳资产。
170
+ // 方案:优先识别代理环境变量,存在时改用系统 curl(跨平台自带,原生支持
171
+ // socks5h/http/https 代理)下载,Windows 加 --ssl-no-revoke 规避 schannel
172
+ // 证书吊销离线检查。
173
+
174
+ // 解析下载代理:专属变量 > 通用标准变量(大小写各试一次)
175
+ function resolveProxy() {
176
+ const keys = ['FREEDOM_SHELL_PROXY', 'ALL_PROXY', 'all_proxy', 'HTTPS_PROXY', 'https_proxy'];
177
+ for (const k of keys) {
178
+ const v = process.env[k];
179
+ if (v && typeof v === 'string' && v.trim()) return v.trim();
180
+ }
181
+ return null;
182
+ }
183
+
184
+ // curl 是否可用(Windows 10+ 自带 curl.exe;Linux/macOS 系统自带)
185
+ function hasCurl() {
186
+ if (process.env.FREEDOM_NO_CURL === '1') return false;
187
+ try {
188
+ const r = spawnSync('curl', ['--version'], { encoding: 'utf8', windowsHide: true });
189
+ return !r.error && r.status === 0;
190
+ } catch (e) {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ // 用 curl 下载到 dest;成功返回 null,失败返回错误信息
196
+ function curlDownload(url, dest, proxy) {
197
+ const args = [
198
+ '-L', '--fail', '--silent', '--show-error',
199
+ '--connect-timeout', '20', '--max-time', '180', '--retry', '2',
200
+ '--output', dest,
201
+ ];
202
+ if (proxy) args.push('--proxy', proxy);
203
+ if (process.platform === 'win32' && /^https:/i.test(url)) {
204
+ args.push('--ssl-no-revoke'); // 规避 Windows schannel CRYPT_E_REVOCATION_OFFLINE
205
+ }
206
+ args.push(url);
207
+ const r = spawnSync('curl', args, { encoding: 'utf8', windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
208
+ if (r.error || r.status !== 0) {
209
+ const detail = (r.stderr || r.stdout || '').trim() || (r.error && r.error.message) || '未知错误';
210
+ return `curl 下载失败(exit=${r.status}):${detail}`;
211
+ }
212
+ return null;
213
+ }
214
+
120
215
  // 下载指定平台壳到包内 shell/<plat>/
121
216
  // 返回下载后的绝对路径;失败抛错。
122
217
  async function downloadShell(plat) {
@@ -127,24 +222,69 @@ async function downloadShell(plat) {
127
222
  const dest = localShellPath(plat);
128
223
  fs.mkdirSync(path.dirname(dest), { recursive: true });
129
224
 
130
- process.stdout.write(`[freedom] 下载壳 ${plat} <- ${url}\n`);
131
- let res;
132
- try {
133
- // 30s 超时:网络挂起时明确报错,避免构建进程无限阻塞
134
- res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30000) });
135
- } catch (e) {
136
- if (e.name === 'AbortError' || e.name === 'TimeoutError') {
137
- throw new Error(`下载壳 ${plat} 超时(30s)。请检查网络后重试,或手动将壳二进制放入 ${localShellPath(plat)}。`);
225
+ const proxy = resolveProxy();
226
+ let buf;
227
+ if (proxy) {
228
+ process.stdout.write(`[freedom] 下载壳 ${plat}(经代理 ${proxy})<- ${url}\n`);
229
+ if (!hasCurl()) {
230
+ throw new Error(
231
+ `已配置下载代理 ${proxy} 但本机没有 curl,无法经代理下载壳。` +
232
+ `请安装 curl,或临时取消代理变量后直连重试。`
233
+ );
234
+ }
235
+ const tmp = `${dest}.tmp`;
236
+ const curlErr = curlDownload(url, tmp, proxy);
237
+ if (curlErr) {
238
+ try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
239
+ throw new Error(
240
+ `${curlErr}\n下载地址 ${url} 不可达。` +
241
+ `请确认代理 ${proxy} 可用(可执行 curl --proxy ${proxy} ${url} -I 自测),` +
242
+ `或手动将壳二进制放入 ${localShellPath(plat)}。`
243
+ );
244
+ }
245
+ try {
246
+ buf = fs.readFileSync(tmp);
247
+ } finally {
248
+ try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
249
+ }
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
+ );
138
268
  }
139
- throw new Error(`下载壳 ${plat} 失败:${e.message}。请检查网络,或手动将壳二进制放入 ${localShellPath(plat)}。`);
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());
140
276
  }
141
- if (!res.ok) {
277
+
278
+ // B13:下载后、落盘前校验格式/架构,防止代理劫持返回错误页或假壳被静默分发。
279
+ const fmt = detectShellFormat(buf);
280
+ const want = expectedFormat(plat);
281
+ if (fmt === 'unknown' || formatFamily(fmt) !== formatFamily(want)) {
142
282
  throw new Error(
143
- `下载壳失败:HTTP ${res.status}。请确认 GitHub 仓库 ${releaseRepo()} 已发布 ` +
144
- `${releaseTag()} 的资产 freedom-shell-${plat},或手动将壳二进制放入 ${localShellPath(plat)}。`
283
+ `下载的壳格式异常:期望 ${want}(${plat}),实际 detect=${fmt}。` +
284
+ `下载地址 ${url} 可能返回了错误页或非本平台假壳,请检查 GitHub Release 资产 ` +
285
+ `${releaseRepo()} 的 ${releaseTag()}。已放弃本次写入。`
145
286
  );
146
287
  }
147
- const buf = Buffer.from(await res.arrayBuffer());
148
288
  fs.writeFileSync(dest, buf);
149
289
  if (process.platform !== 'win32') {
150
290
  fs.chmodSync(dest, 0o755);
@@ -166,7 +306,8 @@ function buildShell(plat) {
166
306
  fs.mkdirSync(path.dirname(dest), { recursive: true });
167
307
 
168
308
  // 本机只能编译本机平台(webview_go 依赖系统 WebView 框架,无法交叉编译)。
169
- const native = nativePlatformKey();
309
+ // 统一走 utils.nativePlatform:Intel Mac 会明确抛"已不支持",避免两套映射语义不一(B44)。
310
+ const native = nativePlatform();
170
311
  if (plat !== native) {
171
312
  throw new Error(
172
313
  `无法在本机(${native})交叉编译 ${plat}:webview_go 依赖系统 WebView 框架。` +
@@ -188,12 +329,8 @@ function buildShell(plat) {
188
329
  }
189
330
 
190
331
  function nativePlatformKey() {
191
- const plat = process.platform;
192
- const arch = process.arch;
193
- if (plat === 'win32') return 'win-x64';
194
- if (plat === 'darwin') return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
195
- if (plat === 'linux') return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
196
- return `${plat}-${arch}`;
332
+ // 已废弃:统一使用 utils.nativePlatform(B44),本函数保留仅作内部兜底,勿再调用。
333
+ return nativePlatform();
197
334
  }
198
335
 
199
336
  module.exports = {
package/lib/theme.js ADDED
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // Claude Code 风格终端 UI 主题:零依赖 ANSI 渲染
4
+ // 非 TTY(管道 / 重定向)或 NO_COLOR 环境变量时自动降级为纯文本,不影响脚本输出解析。
5
+
6
+ const ESC = '\x1b';
7
+
8
+ const C = {
9
+ reset: `${ESC}[0m`,
10
+ bold: `${ESC}[1m`,
11
+ dim: `${ESC}[2m`,
12
+ italic: `${ESC}[3m`,
13
+ underline: `${ESC}[4m`,
14
+ fg: {
15
+ black: `${ESC}[30m`,
16
+ red: `${ESC}[31m`,
17
+ green: `${ESC}[32m`,
18
+ yellow: `${ESC}[33m`,
19
+ blue: `${ESC}[34m`,
20
+ magenta: `${ESC}[35m`,
21
+ cyan: `${ESC}[36m`,
22
+ white: `${ESC}[37m`,
23
+ gray: `${ESC}[90m`,
24
+ },
25
+ bg: {
26
+ red: `${ESC}[41m`,
27
+ green: `${ESC}[42m`,
28
+ yellow: `${ESC}[43m`,
29
+ magenta: `${ESC}[45m`,
30
+ cyan: `${ESC}[46m`,
31
+ gray: `${ESC}[100m`,
32
+ },
33
+ };
34
+
35
+ const COLOR = Boolean(process.stdout.isTTY && process.env.NO_COLOR === undefined);
36
+
37
+ function paint(text, ...codes) {
38
+ if (!COLOR) return text;
39
+ return codes.join('') + text + C.reset;
40
+ }
41
+
42
+ function stripAnsi(s) {
43
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
44
+ }
45
+
46
+ // 符号徽章(Claude Code 风格):✓ / ✗ / ⚠ / ℹ / ➜
47
+ function ok(text) { return paint('✓ ', C.fg.green, C.bold) + text; }
48
+ function err(text) { return paint('✗ ', C.fg.red, C.bold) + text; }
49
+ function warn(text) { return paint('⚠ ', C.fg.yellow, C.bold) + text; }
50
+ function info(text) { return paint('ℹ ', C.fg.cyan, C.bold) + text; }
51
+ function tip(text) { return paint('➜ ', C.fg.magenta, C.bold) + text; }
52
+ function dim(text) { return paint(text, C.fg.gray); }
53
+ function bold(text, color) { return paint(text, C.bold, color ? C.fg[color] : ''); }
54
+
55
+ // 行内标签:[freedom] 加粗
56
+ function tag() { return paint('[freedom]', C.fg.blue, C.bold); }
57
+
58
+ // 水平分隔线
59
+ function rule(ch = '─', color = 'gray') {
60
+ const w = Math.max(8, (process.stdout.columns || 80) - 2);
61
+ return paint(ch.repeat(w), C.fg[color]);
62
+ }
63
+
64
+ // 帮助分组标题:── 分组名 ──
65
+ function section(title) {
66
+ return ` ${paint('──', C.fg.gray)} ${paint(title, C.bold, C.fg.cyan)} ${paint('──', C.fg.gray)}`;
67
+ }
68
+
69
+ // 品牌横幅(ASCII 标识 + 版本徽章)
70
+ function banner(version, extra) {
71
+ const lines = [
72
+ '',
73
+ ` ${paint('▚▚', C.fg.cyan, C.bold)} ${paint('F R E E D O M', C.bold, C.fg.white)} ${paint(`v${version}`, C.fg.gray)}`,
74
+ ` ${paint('│', C.fg.cyan)} Freedom 桌面壳打包工具 · Web 前端一键出三平台桌面应用`,
75
+ ];
76
+ if (extra) lines.push(` ${paint('│', C.fg.cyan)} ${extra}`);
77
+ lines.push('');
78
+ return lines.join('\n');
79
+ }
80
+
81
+ // 版本信息卡
82
+ function versionCard(current, latest, hasUpdate) {
83
+ const lines = [];
84
+ lines.push('');
85
+ lines.push(` ${paint('⚡', C.fg.magenta)} ${paint('freedom', C.bold)} ${paint(`v${current}`, C.fg.white, C.bold)}`);
86
+ lines.push(rule('─'));
87
+ if (hasUpdate && latest) {
88
+ lines.push(
89
+ ` ${paint('➜ 当前版本:', C.fg.gray)}${paint(current, C.fg.white, C.bold)}` +
90
+ ` ${paint('➜ 最新版本:', C.fg.gray)}${paint(latest, C.fg.green, C.bold)} ${paint('(有新版本可升级)', C.fg.yellow)}`
91
+ );
92
+ } else if (latest) {
93
+ lines.push(` ${paint('➜ 当前版本:', C.fg.gray)}${paint(current, C.fg.white, C.bold)} ${paint('(已是最新版本)', C.fg.green)}`);
94
+ } else {
95
+ lines.push(` ${paint('➜ 当前版本:', C.fg.gray)}${paint(current, C.fg.white, C.bold)} ${paint('(离线,未检测到最新版本)', C.fg.gray)}`);
96
+ }
97
+ lines.push('');
98
+ return lines.join('\n');
99
+ }
100
+
101
+ module.exports = {
102
+ C, COLOR, paint, stripAnsi,
103
+ ok, err, warn, info, tip, dim, bold, tag,
104
+ rule, section, banner, versionCard,
105
+ };
package/lib/tui.js CHANGED
@@ -254,7 +254,13 @@ async function configFlow(tui, cwd) {
254
254
  const [key, desc] = CONFIG_KEYS[idx - 1];
255
255
  const val = await tui.input(`${key}`, { initial: '' });
256
256
  if (val === null) continue;
257
- setConfig(cwd, key, coerce(val));
257
+ try {
258
+ setConfig(cwd, key, coerce(val));
259
+ } catch (e) {
260
+ // 抛错必须留在 TUI 内提示,否则绕过 exit() 导致终端残留 raw mode / 光标隐藏(历史 bug B12)
261
+ await tui.message('配置', [{ text: C.fgRed + ` 设置失败:${e.message}` + C.reset }]);
262
+ continue;
263
+ }
258
264
  await tui.message('配置', [{ text: C.fgGreen + ` ${key} = ${val}` + C.reset }]);
259
265
  }
260
266
  }
@@ -296,6 +302,23 @@ async function tutorialFlow(tui) {
296
302
  await tui.message('教程', [{ text: C.fgGreen + ` 已打开:${file}` + C.reset }]);
297
303
  }
298
304
 
305
+ 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 });
316
+ } else {
317
+ lines.push({ text: C.fgGray + ` 检查失败:网络不可用,请稍后重试` + C.reset });
318
+ }
319
+ await tui.message('检查版本更新', lines);
320
+ }
321
+
299
322
  function coerce(value) {
300
323
  if (value === 'true') return true;
301
324
  if (value === 'false') return false;
@@ -313,7 +336,7 @@ async function tui(cwd) {
313
336
  app.enter();
314
337
  let keep = true;
315
338
  while (keep) {
316
- const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '退出'];
339
+ const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '检查更新', '退出'];
317
340
  const idx = await app.menu('主菜单', items, {
318
341
  footer: `工作目录:${cwd} ↑ ↓ 选择 · Enter 确认 · q 退出`,
319
342
  });
@@ -324,7 +347,8 @@ async function tui(cwd) {
324
347
  case 2: await configFlow(app, cwd); break;
325
348
  case 3: await shellFlow(app); break;
326
349
  case 4: await tutorialFlow(app); break;
327
- case 5: keep = false; break;
350
+ case 5: await updateFlow(app); break;
351
+ case 6: keep = false; break;
328
352
  }
329
353
  }
330
354
  app.exit();
package/lib/update.js ADDED
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // 版本检测:零依赖(https 内置),查询 npm registry 最新版本并对比本地版本。
4
+ // - 结果缓存到 ~/.freedom/update-cache.json,24h 内不重复联网(离线不打扰)
5
+ // - compareVersions 手写 semver 比较(仅处理 x.y.z 数字前缀,满足语义版本场景)
6
+ // - 所有联网失败均静默降级,绝不阻塞主流程
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const https = require('https');
12
+ const { packageRoot } = require('./utils');
13
+
14
+ const PKG_NAME = '@yufengtadian/freedom-cli';
15
+ const REGISTRY = `https://registry.npmjs.org/${encodeURIComponent(PKG_NAME)}/latest`;
16
+ const CACHE_FILE = path.join(os.homedir(), '.freedom', 'update-cache.json');
17
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 小时
18
+ const REQUEST_TIMEOUT = 4000;
19
+
20
+ function currentVersion() {
21
+ return require(path.join(packageRoot(), 'package.json')).version;
22
+ }
23
+
24
+ // semver 简单比较:返回 1 / -1 / 0
25
+ function compareVersions(a, b) {
26
+ const pa = String(a || '').replace(/[^\d.]/g, '').split('.').map((n) => parseInt(n, 10) || 0);
27
+ const pb = String(b || '').replace(/[^\d.]/g, '').split('.').map((n) => parseInt(n, 10) || 0);
28
+ for (let i = 0; i < 3; i += 1) {
29
+ const x = pa[i] || 0;
30
+ const y = pb[i] || 0;
31
+ if (x > y) return 1;
32
+ if (x < y) return -1;
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ function readCache() {
38
+ try {
39
+ const j = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
40
+ if (j && j.latest && Date.now() - j.ts < CACHE_TTL) return j;
41
+ } catch (e) { /* 无缓存或损坏,忽略 */ }
42
+ return null;
43
+ }
44
+
45
+ function writeCache(data) {
46
+ try {
47
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
48
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({ ...data, ts: Date.now() }));
49
+ } catch (e) { /* 写缓存失败静默 */ }
50
+ }
51
+
52
+ // 从 npm registry 拉取 latest 版本;失败 / 超时返回 null
53
+ function fetchLatest(timeout = REQUEST_TIMEOUT) {
54
+ return new Promise((resolve) => {
55
+ const req = https.get(REGISTRY, {
56
+ headers: { 'user-agent': 'freedom-cli', accept: 'application/json' },
57
+ timeout,
58
+ }, (res) => {
59
+ if (res.statusCode !== 200) {
60
+ res.resume();
61
+ return resolve(null);
62
+ }
63
+ let body = '';
64
+ res.setEncoding('utf8');
65
+ res.on('data', (chunk) => { body += chunk; });
66
+ res.on('end', () => {
67
+ try {
68
+ resolve(JSON.parse(body).version || null);
69
+ } catch (e) {
70
+ resolve(null);
71
+ }
72
+ });
73
+ });
74
+ req.on('timeout', () => req.destroy());
75
+ req.on('error', () => resolve(null));
76
+ });
77
+ }
78
+
79
+ // 检查更新:force=true 强制联网(忽略缓存);否则优先读缓存
80
+ async function checkUpdate({ force = false } = {}) {
81
+ const current = currentVersion();
82
+ const cache = force ? null : readCache();
83
+ let latest = cache ? cache.latest : null;
84
+ if (!latest) {
85
+ latest = await fetchLatest();
86
+ if (latest) writeCache({ latest });
87
+ }
88
+ const hasUpdate = Boolean(latest && compareVersions(latest, current) > 0);
89
+ return { current, latest, hasUpdate };
90
+ }
91
+
92
+ // 静默异步通知:仅在检测到新版本时打印一行升级提示,不阻塞调用方
93
+ async function maybeNotifyUpdate() {
94
+ try {
95
+ const r = await checkUpdate();
96
+ if (r.hasUpdate && r.latest) {
97
+ const theme = require('./theme');
98
+ console.log('');
99
+ console.log(` ${theme.paint('➜', theme.C.fg.magenta, theme.C.bold)} ${theme.paint(`新版本可用 ${r.latest}(当前 ${r.current})`, theme.C.fg.yellow)}`);
100
+ console.log(` ${theme.dim('运行')} ${theme.paint(`npm install -g ${PKG_NAME}@latest`, theme.C.fg.cyan)} ${theme.dim('升级,或')} ${theme.paint('freedom update', theme.C.fg.cyan)} ${theme.dim('查看详情')}`);
101
+ console.log('');
102
+ }
103
+ } catch (e) { /* 检测失败静默 */ }
104
+ }
105
+
106
+ module.exports = {
107
+ PKG_NAME, REGISTRY, CACHE_FILE, CACHE_TTL,
108
+ currentVersion, compareVersions, checkUpdate, maybeNotifyUpdate,
109
+ };
package/lib/utils.js CHANGED
@@ -28,8 +28,14 @@ function shellDir() {
28
28
  // 平台矩阵:key -> { exe 文件名(壳二进制名) }
29
29
  // 通用壳为三平台预编译二进制,应用内容通过 exe 同目录 resources/ 外部加载,
30
30
  // 因此同一壳可复用于任意应用,打包时无需任何语言工具链。
31
+ // 全部平台 key(含暂无预编译资产来源的 linux-arm64,仅支持显式指定 / 本地编译)。
31
32
  const ALL_PLATFORMS = ['win-x64', 'darwin-arm64', 'linux-x64', 'linux-arm64'];
32
33
 
34
+ // 可分发平台(有预编译壳资产,CI 已产出):--platform all 的取值集合。
35
+ // linux-arm64 无 CI runner 与 GitHub 资产,若列入 all 会在 build 时 404 拖垮整个
36
+ // 全量构建(历史 bug B41);故 all 仅包含以下三平台,linux-arm64 需显式指定。
37
+ const DIST_PLATFORMS = ['win-x64', 'darwin-arm64', 'linux-x64'];
38
+
33
39
  const SHELL_EXE_NAME = {
34
40
  'win-x64': 'freedom-shell.exe',
35
41
  'darwin-arm64': 'freedom-shell',
@@ -55,7 +61,13 @@ function platformExeName(plat, appName) {
55
61
  function nativePlatform() {
56
62
  const plat = process.platform;
57
63
  const arch = process.arch;
58
- if (plat === 'win32') return 'win-x64';
64
+ if (plat === 'win32') {
65
+ if (arch !== 'x64') {
66
+ // ARM64 Windows 没有独立的 win-arm64 壳,x64 壳经系统仿真可运行;给出提示而非静默。
67
+ console.error(`[freedom] 注意:当前为 ${arch} 架构 Windows,将使用 win-x64 壳(x64 仿真运行)。`);
68
+ }
69
+ return 'win-x64';
70
+ }
59
71
  if (plat === 'darwin') {
60
72
  if (arch !== 'arm64') {
61
73
  throw new Error('已不支持 Intel Mac(darwin-x64):请改用 Apple Silicon Mac 构建 darwin-arm64,或直接使用 darwin-arm64 产物。');
@@ -113,6 +125,7 @@ module.exports = {
113
125
  goTemplateDir,
114
126
  shellDir,
115
127
  ALL_PLATFORMS,
128
+ DIST_PLATFORMS,
116
129
  SHELL_EXE_NAME,
117
130
  isWinPlat,
118
131
  isMacPlat,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yufengtadian/freedom-cli",
3
- "version": "1.12.12",
3
+ "version": "1.12.14",
4
4
  "description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
5
5
  "keywords": [
6
6
  "desktop",
Binary file
Binary file
Binary file
@@ -4,7 +4,8 @@ import "freedom-cli-shell/pkg/freedom"
4
4
 
5
5
  // main 是 Freedom 壳层应用入口。
6
6
  // 窗口与标题栏配置来自 build 阶段生成的 gen_config.go(appConfig),
7
- // 前端页面由 pkg/freedom 的 assets_embed.go 嵌入(build 阶段把打包后的 index.html 放进去)。
7
+ // 前端页面运行时从 exe 同目录 resources/index.html 加载(build 阶段由 CLI
8
+ // 把打包后的 index.html 与 config.json 写入该目录,壳 SetHtml 内存加载、不占端口)。
8
9
  func main() {
9
10
  cfg := appConfig()
10
11
  app := freedom.New(cfg)
@@ -71,13 +71,19 @@
71
71
  var q = function (s) { return typeof s === 'string' ? document.querySelector(s) : s; };
72
72
  var min = q(sel && sel.min), max = q(sel && sel.max), close = q(sel && sel.close);
73
73
  var self = this;
74
- if (min) min.addEventListener('click', function () { self.minimize(); });
74
+ // 所有桥接调用均带 .catch:页面先于桥接就绪时点击按钮会产生未捕获的
75
+ // Promise rejection(并导致自绘标题栏静默失效),此处兜底为控制台告警。
76
+ if (min) min.addEventListener('click', function () {
77
+ self.minimize().catch(function (e) { console.warn('[freedom] minimize:', e); });
78
+ });
75
79
  if (max) max.addEventListener('click', function () {
76
80
  self.isMaximized().then(function (m) {
77
- if (m) self.unmaximize(); else self.maximize();
78
- });
81
+ if (m) return self.unmaximize(); else return self.maximize();
82
+ }).catch(function (e) { console.warn('[freedom] maximize toggle:', e); });
83
+ });
84
+ if (close) close.addEventListener('click', function () {
85
+ self.close().catch(function (e) { console.warn('[freedom] close:', e); });
79
86
  });
80
- if (close) close.addEventListener('click', function () { self.close(); });
81
87
  };
82
88
 
83
89
  function windowAction(action) {
@@ -97,6 +97,9 @@ func (p *ProcBackend) OnEvent(fn func(event string, data interface{})) {
97
97
  func (p *ProcBackend) start() error {
98
98
  p.mu.Lock()
99
99
  defer p.mu.Unlock()
100
+ if p.closed {
101
+ return fmt.Errorf("freedom: proc backend is closed")
102
+ }
100
103
  if p.stdin != nil {
101
104
  return nil // 已启动
102
105
  }
@@ -134,12 +137,12 @@ func (p *ProcBackend) Handle(method string, params []json.RawMessage) (interface
134
137
  id := p.nextID
135
138
  ch := make(chan procResp, 1)
136
139
  p.pending[id] = ch
137
- msg := procMessage{ID: id, Method: method, Params: paramsJSON}
138
- line, _ := json.Marshal(msg)
139
- _, err = p.stdin.Write(append(line, '\n'))
140
+ w := p.stdin // 锁内取引用,锁外写,避免持锁阻塞(后端不消费 stdin 时 Write 可能挂起)
140
141
  p.mu.Unlock()
141
142
 
142
- if err != nil {
143
+ msg := procMessage{ID: id, Method: method, Params: paramsJSON}
144
+ line, _ := json.Marshal(msg)
145
+ if _, err := w.Write(append(line, '\n')); err != nil {
143
146
  p.cancel(id, fmt.Errorf("freedom: proc backend write: %w", err))
144
147
  return nil, err
145
148
  }