@yufengtadian/freedom-cli 1.12.1 → 1.12.13
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 +31 -14
- package/bin/freedom.js +5 -1
- package/lib/build.js +49 -8
- package/lib/cli.js +107 -80
- package/lib/config.js +108 -31
- package/lib/shell.js +73 -20
- package/lib/theme.js +105 -0
- package/lib/tui.js +21 -3
- package/lib/update.js +109 -0
- package/lib/utils.js +14 -1
- package/package.json +1 -1
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/templates/go/go.mod +6 -0
- package/templates/go/go.sum +2 -2
- package/templates/go/pkg/freedom/assets/freedom.js +6 -0
- package/templates/go/pkg/freedom/assets/index.html +1 -1
- package/templates/go/pkg/freedom/backend_proc.go +4 -4
- package/templates/go/pkg/freedom/configfile.go +12 -7
- package/templates/go/pkg/freedom/freedom.go +27 -11
- package/templates/go/pkg/freedom/window_other.go +64 -12
- package/templates/go/pkg/freedom/window_windows.go +244 -29
- package/templates/go/webview_go/.github/workflows/ci.yaml +62 -0
- package/templates/go/webview_go/CHANGELOG.md +15 -0
- package/templates/go/webview_go/LICENSE +22 -0
- package/templates/go/webview_go/README.md +50 -0
- package/templates/go/webview_go/examples/basic/main.go +12 -0
- package/templates/go/webview_go/examples/bind/main.go +38 -0
- package/templates/go/webview_go/glue.c +36 -0
- package/templates/go/webview_go/go.mod +3 -0
- package/templates/go/webview_go/go.sum +0 -0
- package/templates/go/webview_go/libs/mswebview2/LICENSE +27 -0
- package/templates/go/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
- package/templates/go/webview_go/libs/mswebview2/include/vendor.go +2 -0
- package/templates/go/webview_go/libs/mswebview2/vendor.go +2 -0
- package/templates/go/webview_go/libs/mswebview2/version.txt +1 -0
- package/templates/go/webview_go/libs/webview/LICENSE +22 -0
- package/templates/go/webview_go/libs/webview/include/vendor.go +2 -0
- package/templates/go/webview_go/libs/webview/include/webview.h +3871 -0
- package/templates/go/webview_go/libs/webview/vendor.go +2 -0
- package/templates/go/webview_go/libs/webview/version.txt +1 -0
- package/templates/go/webview_go/webview.cc +1 -0
- package/templates/go/webview_go/webview.go +379 -0
- package/templates/go/webview_go/webview_test.go +50 -0
- package/templates/project/freedom.config.js +5 -5
- package/templates/project/index.html +45 -5
- package/templates/project/src/main.js +149 -8
- package/tutorial/tutorial.html +4 -5
package/lib/config.js
CHANGED
|
@@ -4,51 +4,114 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { loadConfig, hasConfig } = require('./utils');
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
name: '
|
|
10
|
-
width:
|
|
11
|
-
height:
|
|
12
|
-
minWidth:
|
|
13
|
-
minHeight:
|
|
14
|
-
center:
|
|
15
|
-
debug:
|
|
16
|
-
titlebar: '
|
|
17
|
-
icon:
|
|
18
|
-
outDir: '
|
|
7
|
+
// 配置文件中可安全写入的字段:类型约束(写入前校验,防止坏值延迟到 build 才爆)。
|
|
8
|
+
const KEY_TYPES = {
|
|
9
|
+
name: 'string',
|
|
10
|
+
width: 'int',
|
|
11
|
+
height: 'int',
|
|
12
|
+
minWidth: 'int',
|
|
13
|
+
minHeight: 'int',
|
|
14
|
+
center: 'bool',
|
|
15
|
+
debug: 'bool',
|
|
16
|
+
titlebar: 'titlebar',
|
|
17
|
+
icon: 'string',
|
|
18
|
+
outDir: 'string',
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
//
|
|
21
|
+
// 把 CLI/TUI 传入的值规范化为配置语义上的目标值;非法即抛错。
|
|
22
|
+
function normalizeValue(key, value) {
|
|
23
|
+
const t = KEY_TYPES[key];
|
|
24
|
+
if (t === 'string') return String(value);
|
|
25
|
+
if (t === 'int') {
|
|
26
|
+
const n = Number(value);
|
|
27
|
+
if (!Number.isInteger(n)) {
|
|
28
|
+
throw new Error(`配置项 ${key} 需要整数值,收到:${JSON.stringify(value)}`);
|
|
29
|
+
}
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
if (t === 'bool') {
|
|
33
|
+
if (typeof value !== 'boolean') {
|
|
34
|
+
throw new Error(`配置项 ${key} 仅接受 true / false`);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
if (t === 'titlebar') {
|
|
39
|
+
const s = String(value);
|
|
40
|
+
if (!['native', 'frameless'].includes(s)) {
|
|
41
|
+
throw new Error('titlebar 取值必须为:native / frameless。');
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
// 扩展键(如 backend):字符串命令简写转 {command,args} 对象,
|
|
46
|
+
// 与 build.js renderConfigJSON 的契约对齐(纯字符串形态不会被打包为后端进程)。
|
|
47
|
+
if (key === 'backend') {
|
|
48
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
const parts = value.trim().split(/\s+/);
|
|
51
|
+
return { command: parts[0], args: parts.slice(1) };
|
|
52
|
+
}
|
|
53
|
+
return value; // 对象/数组形式原样写入
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 把值渲染为合法 JS 字面量。统一走 JSON.stringify:
|
|
59
|
+
// 反斜杠 / 引号自动转义,且不存在 $ 替换模式注入面(配合函数式 replace 使用)。
|
|
60
|
+
function renderLiteral(v) {
|
|
61
|
+
if (v === undefined) return 'undefined';
|
|
62
|
+
if (v === null) return 'null';
|
|
63
|
+
return JSON.stringify(v, null, typeof v === 'object' ? 2 : 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 统计一行内 { 与 } 的差值,用于把多行对象字面量并入替换范围
|
|
67
|
+
//(启发式实现;配置值内含花括号字符串的场景需手动编辑配置文件)。
|
|
68
|
+
function braceDelta(line) {
|
|
69
|
+
let d = 0;
|
|
70
|
+
for (const ch of line) {
|
|
71
|
+
if (ch === '{') d++;
|
|
72
|
+
else if (ch === '}') d--;
|
|
73
|
+
}
|
|
74
|
+
return d;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 把配置写回 freedom.config.js(保留注释与格式,只替换目标键所在行)。
|
|
78
|
+
// 逐行定位且跳过注释行:模板中被注释的示例键(如 // backend: undefined,)不会被误改。
|
|
22
79
|
function setConfig(dir, key, value) {
|
|
23
80
|
const cfgPath = path.join(dir, 'freedom.config.js');
|
|
24
81
|
if (!fs.existsSync(cfgPath)) {
|
|
25
82
|
throw new Error('未找到 freedom.config.js。');
|
|
26
83
|
}
|
|
27
84
|
const text = fs.readFileSync(cfgPath, 'utf8');
|
|
85
|
+
const norm = normalizeValue(key, value);
|
|
86
|
+
const rendered = renderLiteral(norm);
|
|
28
87
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
88
|
+
const lines = text.split('\n');
|
|
89
|
+
const keyRe = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:(.*)$`);
|
|
90
|
+
let replaced = false;
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
if (lines[i].trim().startsWith('//')) continue; // 注释行不参与匹配与改写
|
|
93
|
+
const m = lines[i].match(keyRe);
|
|
94
|
+
if (!m) continue;
|
|
95
|
+
// 值为多行对象时吞并后续行直到花括号闭合,避免残留孤儿括号
|
|
96
|
+
let j = i;
|
|
97
|
+
let depth = braceDelta(lines[i]);
|
|
98
|
+
while (depth > 0 && j + 1 < lines.length) {
|
|
99
|
+
j++;
|
|
100
|
+
depth += braceDelta(lines[j]);
|
|
33
101
|
}
|
|
102
|
+
const trailing = /,\s*$/.test(m[2]) ? ',' : '';
|
|
103
|
+
lines.splice(i, j - i + 1, `${m[1]}${key}: ${rendered}${trailing}`);
|
|
104
|
+
replaced = true;
|
|
105
|
+
break;
|
|
34
106
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const regex = new RegExp(`(${key}\\s*:\\s*)(\"[^\"]*\"|'[^']*'|\\d+|true|false|undefined|null)`, 'g');
|
|
38
|
-
if (!regex.test(text)) {
|
|
39
|
-
throw new Error(`配置项 ${key} 未在 freedom.config.js 中找到,请手动添加。`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let rendered = String(value);
|
|
43
|
-
if (typeof value === 'string') {
|
|
44
|
-
rendered = `'${value.replace(/'/g, "\\'")}'`;
|
|
107
|
+
if (!replaced) {
|
|
108
|
+
throw new Error(`配置项 ${key} 未在 freedom.config.js 中找到(注释行不参与修改),请手动添加。`);
|
|
45
109
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
fs.writeFileSync(cfgPath, updated, 'utf8');
|
|
49
|
-
return value;
|
|
110
|
+
fs.writeFileSync(cfgPath, lines.join('\n'), 'utf8');
|
|
111
|
+
return norm;
|
|
50
112
|
}
|
|
51
113
|
|
|
114
|
+
|
|
52
115
|
async function getConfig(dir) {
|
|
53
116
|
if (!hasConfig(dir)) {
|
|
54
117
|
throw new Error('当前目录不是 Freedom 项目(缺少 freedom.config.js)。');
|
|
@@ -57,6 +120,20 @@ async function getConfig(dir) {
|
|
|
57
120
|
return cfg;
|
|
58
121
|
}
|
|
59
122
|
|
|
123
|
+
// 配置文件中可安全写入的字段及其默认值(用于渲染或回填)。
|
|
124
|
+
const KNOWN_KEYS = {
|
|
125
|
+
name: 'freedom-app',
|
|
126
|
+
width: 1024,
|
|
127
|
+
height: 720,
|
|
128
|
+
minWidth: 400,
|
|
129
|
+
minHeight: 300,
|
|
130
|
+
center: true,
|
|
131
|
+
debug: false,
|
|
132
|
+
titlebar: 'frameless',
|
|
133
|
+
icon: undefined,
|
|
134
|
+
outDir: 'dist',
|
|
135
|
+
};
|
|
136
|
+
|
|
60
137
|
async function showConfig(dir) {
|
|
61
138
|
const cfg = await loadConfig(dir);
|
|
62
139
|
const lines = Object.keys(KNOWN_KEYS).map((k) => {
|
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
|
-
//
|
|
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 <
|
|
56
|
-
// Windows PE:MZ
|
|
57
|
-
if (buf[0] === 0x4d && buf[1] === 0x5a)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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'))
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
|
@@ -145,6 +191,16 @@ async function downloadShell(plat) {
|
|
|
145
191
|
);
|
|
146
192
|
}
|
|
147
193
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
194
|
+
// B13:下载后、落盘前校验格式/架构,防止代理劫持返回错误页或假壳被静默分发。
|
|
195
|
+
const fmt = detectShellFormat(buf);
|
|
196
|
+
const want = expectedFormat(plat);
|
|
197
|
+
if (fmt === 'unknown' || formatFamily(fmt) !== formatFamily(want)) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`下载的壳格式异常:期望 ${want}(${plat}),实际 detect=${fmt}。` +
|
|
200
|
+
`下载地址 ${url} 可能返回了错误页或非本平台假壳,请检查 GitHub Release 资产 ` +
|
|
201
|
+
`${releaseRepo()} 的 ${releaseTag()}。已放弃本次写入。`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
148
204
|
fs.writeFileSync(dest, buf);
|
|
149
205
|
if (process.platform !== 'win32') {
|
|
150
206
|
fs.chmodSync(dest, 0o755);
|
|
@@ -166,7 +222,8 @@ function buildShell(plat) {
|
|
|
166
222
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
167
223
|
|
|
168
224
|
// 本机只能编译本机平台(webview_go 依赖系统 WebView 框架,无法交叉编译)。
|
|
169
|
-
|
|
225
|
+
// 统一走 utils.nativePlatform:Intel Mac 会明确抛"已不支持",避免两套映射语义不一(B44)。
|
|
226
|
+
const native = nativePlatform();
|
|
170
227
|
if (plat !== native) {
|
|
171
228
|
throw new Error(
|
|
172
229
|
`无法在本机(${native})交叉编译 ${plat}:webview_go 依赖系统 WebView 框架。` +
|
|
@@ -188,12 +245,8 @@ function buildShell(plat) {
|
|
|
188
245
|
}
|
|
189
246
|
|
|
190
247
|
function nativePlatformKey() {
|
|
191
|
-
|
|
192
|
-
|
|
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}`;
|
|
248
|
+
// 已废弃:统一使用 utils.nativePlatform(B44),本函数保留仅作内部兜底,勿再调用。
|
|
249
|
+
return nativePlatform();
|
|
197
250
|
}
|
|
198
251
|
|
|
199
252
|
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
|
@@ -192,7 +192,7 @@ const CONFIG_KEYS = [
|
|
|
192
192
|
['minHeight', '窗口最小高度'],
|
|
193
193
|
['center', '启动居中(true/false)'],
|
|
194
194
|
['debug', '开发者工具(true/false)'],
|
|
195
|
-
['titlebar', '标题栏:native |
|
|
195
|
+
['titlebar', '标题栏:native | frameless'],
|
|
196
196
|
['icon', '应用图标:.ico(Windows)/ .icns(macOS)路径'],
|
|
197
197
|
['outDir', '产物目录:dist(默认)| .(项目根)| 任意路径'],
|
|
198
198
|
['backend', '任意语言后端进程(JSON)'],
|
|
@@ -296,6 +296,23 @@ async function tutorialFlow(tui) {
|
|
|
296
296
|
await tui.message('教程', [{ text: C.fgGreen + ` 已打开:${file}` + C.reset }]);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
async function updateFlow(tui) {
|
|
300
|
+
const { checkUpdate, PKG_NAME } = require('./update');
|
|
301
|
+
const r = await checkUpdate({ force: true });
|
|
302
|
+
const lines = [
|
|
303
|
+
{ text: C.fgWhite + C.bold + ` 当前版本:v${r.current}` + C.reset },
|
|
304
|
+
];
|
|
305
|
+
if (r.latest) {
|
|
306
|
+
lines.push({ text: C.fgCyan + ` 最新版本:v${r.latest}` + C.reset });
|
|
307
|
+
lines.push({ text: r.hasUpdate
|
|
308
|
+
? C.fgYellow + ` 发现新版本,可执行:npm install -g ${PKG_NAME}@latest` + C.reset
|
|
309
|
+
: C.fgGreen + ` 已是最新版本` + C.reset });
|
|
310
|
+
} else {
|
|
311
|
+
lines.push({ text: C.fgGray + ` 检查失败:网络不可用,请稍后重试` + C.reset });
|
|
312
|
+
}
|
|
313
|
+
await tui.message('检查版本更新', lines);
|
|
314
|
+
}
|
|
315
|
+
|
|
299
316
|
function coerce(value) {
|
|
300
317
|
if (value === 'true') return true;
|
|
301
318
|
if (value === 'false') return false;
|
|
@@ -313,7 +330,7 @@ async function tui(cwd) {
|
|
|
313
330
|
app.enter();
|
|
314
331
|
let keep = true;
|
|
315
332
|
while (keep) {
|
|
316
|
-
const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '退出'];
|
|
333
|
+
const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '检查更新', '退出'];
|
|
317
334
|
const idx = await app.menu('主菜单', items, {
|
|
318
335
|
footer: `工作目录:${cwd} ↑ ↓ 选择 · Enter 确认 · q 退出`,
|
|
319
336
|
});
|
|
@@ -324,7 +341,8 @@ async function tui(cwd) {
|
|
|
324
341
|
case 2: await configFlow(app, cwd); break;
|
|
325
342
|
case 3: await shellFlow(app); break;
|
|
326
343
|
case 4: await tutorialFlow(app); break;
|
|
327
|
-
case 5:
|
|
344
|
+
case 5: await updateFlow(app); break;
|
|
345
|
+
case 6: keep = false; break;
|
|
328
346
|
}
|
|
329
347
|
}
|
|
330
348
|
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')
|
|
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
|
Binary file
|
package/templates/go/go.mod
CHANGED
|
@@ -3,3 +3,9 @@ module freedom-cli-shell
|
|
|
3
3
|
go 1.22
|
|
4
4
|
|
|
5
5
|
require github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
|
6
|
+
|
|
7
|
+
require golang.org/x/sys v0.28.0
|
|
8
|
+
|
|
9
|
+
// Freedom fork: 使用随包分发的本地 webview_go(标题栏图标与 exe 图标一致、
|
|
10
|
+
// 无边框窗口最大化限定工作区、彻底清理非客户区等定制),避免拉取上游被覆盖。
|
|
11
|
+
replace github.com/webview/webview_go => ./webview_go
|
package/templates/go/go.sum
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
|
2
|
+
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
@@ -52,10 +52,16 @@
|
|
|
52
52
|
minimize: function () { return windowAction('minimize'); },
|
|
53
53
|
maximize: function () { return windowAction('maximize'); },
|
|
54
54
|
unmaximize: function () { return windowAction('unmaximize'); },
|
|
55
|
+
restore: function () { return windowAction('unmaximize'); },
|
|
55
56
|
toggleMaximize: function () { return windowAction('toggleMaximize'); },
|
|
56
57
|
close: function () { return windowAction('close'); },
|
|
57
58
|
isMaximized: function () { return windowAction('isMaximized'); },
|
|
58
59
|
isFrameless: function () { return windowAction('isFrameless'); },
|
|
60
|
+
// 应用图标(data URL)。从 exe 内嵌图标提取,供自绘标题栏显示,不依赖 resources 资源文件夹。
|
|
61
|
+
appIcon: function () { return windowAction('appIcon'); },
|
|
62
|
+
// 标题栏 JS 拖动(WM_NCLBUTTONDOWN + HTCAPTION),比 -webkit-app-region: drag
|
|
63
|
+
// 更稳(保留双击最大化 / 右键菜单等页面事件)。非 Windows 平台为 no-op。
|
|
64
|
+
startDrag: function () { return windowAction('startDrag'); },
|
|
59
65
|
},
|
|
60
66
|
};
|
|
61
67
|
|