@yufengtadian/freedom-cli 1.12.17 → 1.12.18
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/lib/build.js +73 -8
- package/lib/cli.js +61 -3
- package/lib/init.js +9 -1
- package/lib/security.js +1 -1
- package/lib/utils.js +5 -2
- package/lib/verify.js +236 -0
- package/package.json +39 -39
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/templates/go/build_all.txt +0 -0
- package/templates/go/build_err.txt +0 -0
- package/templates/go/pkg/freedom/assets/freedom.js +19 -16
- package/templates/go/pkg/freedom/backend_proc.go +7 -1
- package/templates/go/pkg/freedom/center_windows.go +36 -18
- package/templates/go/pkg/freedom/center_windows_test.go +32 -0
- package/templates/go/pkg/freedom/configfile.go +23 -2
- package/templates/go/pkg/freedom/freedom.go +64 -8
- package/templates/go/pkg/freedom/syscap_other.go +10 -0
- package/templates/go/pkg/freedom/syscap_windows.go +653 -0
- package/templates/go/pkg/freedom/tray_other.go +10 -0
- package/templates/go/pkg/freedom/tray_windows.go +506 -0
- package/templates/go/pkg/freedom/window_windows.go +28 -16
- package/templates/go/temp_vet_test/main.go +40 -0
- package/templates/go/test_err.txt +0 -0
- package/templates/go/vet_err.txt +1 -0
- package/templates/go/vet_err2.txt +2 -0
- package/templates/project-minimal/freedom.config.js +58 -0
- package/templates/project-minimal/index.html +41 -0
- package/templates/project-minimal/package.json +14 -0
- package/templates/project-minimal/src/main.js +19 -0
- package/templates/project-minimal/vite.config.js +18 -0
package/lib/build.js
CHANGED
|
@@ -112,9 +112,13 @@ async function build(projectDir, opts = {}) {
|
|
|
112
112
|
if (useCache && !viteNeedsRebuild(dir)) {
|
|
113
113
|
process.stdout.write('[freedom] 前端源码无改动,复用构建缓存...\n');
|
|
114
114
|
} else {
|
|
115
|
-
|
|
115
|
+
process.stdout.write('[freedom] 前端打包中(npm run build)...\n');
|
|
116
|
+
// 全量构建时 npm/vite 输出必须实时透传(stdio: inherit),
|
|
117
|
+
// 此前 pipe 缓冲会吞掉构建全程输出,Node 主线程被 spawnSync 同步阻塞,
|
|
118
|
+
// 大前端全量构建时终端长时间零输出,表现如"未响应/卡死"。
|
|
119
|
+
const vite = run('npm', ['run', 'build'], { cwd: dir, stdio: 'inherit' });
|
|
116
120
|
if (vite.status !== 0) {
|
|
117
|
-
throw new Error(
|
|
121
|
+
throw new Error(`前端打包失败(退出码 ${vite.status}),详见上方构建输出。`);
|
|
118
122
|
}
|
|
119
123
|
writeViteCacheMarker(dir);
|
|
120
124
|
}
|
|
@@ -150,6 +154,33 @@ async function build(projectDir, opts = {}) {
|
|
|
150
154
|
})
|
|
151
155
|
);
|
|
152
156
|
|
|
157
|
+
// 5) 产物自检 + 形态树(消除"build 后形态未知 / 需实测"):
|
|
158
|
+
// build 完成后自动校验产物完整性,并打印产物结构;自检发现问题仅告警不阻断,
|
|
159
|
+
// 完整失败信息可用 freedom verify 复核(返回非零退出码,供 CI 使用)。
|
|
160
|
+
const { verifyProduct, renderTree, formatChecks } = require('./verify');
|
|
161
|
+
const v = await verifyProduct(dir);
|
|
162
|
+
if (v.error) {
|
|
163
|
+
process.stdout.write(`[freedom] 产物自检跳过:${v.error}\n`);
|
|
164
|
+
} else if (v.targets.length === 0) {
|
|
165
|
+
process.stdout.write('[freedom] 产物自检跳过:未在产物目录发现可执行文件。\n');
|
|
166
|
+
} else {
|
|
167
|
+
process.stdout.write('\n[freedom] 产物自检:\n');
|
|
168
|
+
for (const t of v.targets) {
|
|
169
|
+
process.stdout.write(` [${t.plat}] ${t.dir}\n`);
|
|
170
|
+
for (const line of formatChecks(t.checks)) {
|
|
171
|
+
process.stdout.write(` ${line}\n`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write('\n[freedom] 产物结构:\n');
|
|
175
|
+
for (const line of renderTree(v.targets).split('\n')) {
|
|
176
|
+
process.stdout.write(` ${line}\n`);
|
|
177
|
+
}
|
|
178
|
+
const failed = v.targets.some((t) => t.checks.some((c) => !c.pass));
|
|
179
|
+
if (failed) {
|
|
180
|
+
process.stdout.write(`\n[freedom] 警告:产物自检存在失败项,请运行 ${'freedom verify'} 复核。\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
153
184
|
return { results };
|
|
154
185
|
}
|
|
155
186
|
|
|
@@ -184,8 +215,10 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
|
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
// 自定义 exe 图标(仅 Windows PE 支持嵌入 .ico 资源;mac 用 .icns 走 .app 分支)
|
|
218
|
+
// 修复:此前引用未传入 emitPlatform 作用域的 cfg 导致 ReferenceError(cfg is not defined),
|
|
219
|
+
// 配置 icon 后 Windows 构建必然失败;改用 emitPlatform 已解构的 name/version。
|
|
187
220
|
if (isWinPlat(plat) && icon) {
|
|
188
|
-
await applyWindowsIcon(outFile, icon);
|
|
221
|
+
await applyWindowsIcon(outFile, icon, name, version);
|
|
189
222
|
}
|
|
190
223
|
|
|
191
224
|
// 写 resources:页面 + 配置 + 后端
|
|
@@ -239,9 +272,11 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
|
|
|
239
272
|
return outFile;
|
|
240
273
|
}
|
|
241
274
|
|
|
242
|
-
// 把 .ico 图标注入到 Windows exe 的 PE 资源(RT_ICON + RT_GROUP_ICON
|
|
275
|
+
// 把 .ico 图标注入到 Windows exe 的 PE 资源(RT_ICON + RT_GROUP_ICON),
|
|
276
|
+
// 并设置版本信息(产品名 / 说明 / 版本),使资源管理器详情可读。
|
|
243
277
|
// 依赖 rcedit(随 npm 包分发的预编译 rcedit-x64.exe,无需 Go/资源编译器)。
|
|
244
|
-
|
|
278
|
+
// 产品名取自 cfg.name,版本取自 cfg.version —— 修复"产物无产品名 / 版本无法配置"。
|
|
279
|
+
async function applyWindowsIcon(exePath, iconPath, name, version) {
|
|
245
280
|
const ext = path.extname(iconPath).toLowerCase();
|
|
246
281
|
if (ext !== '.ico') {
|
|
247
282
|
process.stdout.write(
|
|
@@ -256,8 +291,24 @@ async function applyWindowsIcon(exePath, iconPath) {
|
|
|
256
291
|
return;
|
|
257
292
|
}
|
|
258
293
|
const rcedit = require('rcedit'); // 惰性加载,避免未配置 icon 时增加启动开销
|
|
259
|
-
|
|
260
|
-
|
|
294
|
+
const info = { icon: iconPath };
|
|
295
|
+
// rcedit 4.x 参数结构:FileDescription/ProductName 等字符串必须走
|
|
296
|
+
// version-string(--set-version-string 键值对),file-version/product-version
|
|
297
|
+
// 才是独立的 --set-* 单值参数。旧版平铺的 file-description/product-name 会被静默忽略。
|
|
298
|
+
const versionString = {};
|
|
299
|
+
if (name) {
|
|
300
|
+
versionString['FileDescription'] = name;
|
|
301
|
+
versionString['ProductName'] = name;
|
|
302
|
+
}
|
|
303
|
+
if (Object.keys(versionString).length > 0) {
|
|
304
|
+
info['version-string'] = versionString;
|
|
305
|
+
}
|
|
306
|
+
if (version) {
|
|
307
|
+
info['file-version'] = version;
|
|
308
|
+
info['product-version'] = version;
|
|
309
|
+
}
|
|
310
|
+
await rcedit(exePath, info);
|
|
311
|
+
process.stdout.write(`[freedom] 已注入 exe 图标与版本信息:${iconPath} (${name || '?'} ${version || '?'})\n`);
|
|
261
312
|
}
|
|
262
313
|
|
|
263
314
|
// 解析 cfg.icon:相对项目根或绝对路径 -> 绝对路径;未配置返回 null。
|
|
@@ -497,7 +548,11 @@ function intVal(v, dft) {
|
|
|
497
548
|
// 导致页面 JS/CSS 丢失静默空白。检测到此类引用时明确告警,避免用户无感知翻车。
|
|
498
549
|
function warnIfNotSingleFile(html, distHtml) {
|
|
499
550
|
const refs = [];
|
|
500
|
-
|
|
551
|
+
// M4:此前只检查 script/link,漏掉了 img/iframe/video/audio/source/object/embed/track 等
|
|
552
|
+
// 一切带 URL 引用的标签。壳只 SetHtml 单页内存加载,任何相对路径 / 根路径 / 协议相对的
|
|
553
|
+
// 外部引用(JS、CSS、图片、子页面、音视频、内嵌对象)在无服务器环境下都会失效,
|
|
554
|
+
// 需一并告警,避免"脚本内联了但图片仍空白"这类半翻车。
|
|
555
|
+
const re = /<(?:script|link|img|iframe|video|audio|source|object|embed|track)\b[^>]*(?:src|href|data)\s*=\s*["']([^"']+)["']/gi;
|
|
501
556
|
let m;
|
|
502
557
|
while ((m = re.exec(html)) !== null) {
|
|
503
558
|
const url = m[1];
|
|
@@ -505,6 +560,16 @@ function warnIfNotSingleFile(html, distHtml) {
|
|
|
505
560
|
if (/^(?:data:|blob:|https?:|file:)/i.test(url)) continue;
|
|
506
561
|
refs.push(url);
|
|
507
562
|
}
|
|
563
|
+
// img 的 srcset(逗号分隔多候选 URL)单独扫描
|
|
564
|
+
const srcsetRe = /<img\b[^>]*\bsrcset\s*=\s*["']([^"']+)["']/gi;
|
|
565
|
+
while ((m = srcsetRe.exec(html)) !== null) {
|
|
566
|
+
for (const part of m[1].split(',')) {
|
|
567
|
+
const url = part.trim().split(/\s+/)[0];
|
|
568
|
+
if (!url) continue;
|
|
569
|
+
if (/^(?:data:|blob:|https?:|file:)/i.test(url)) continue;
|
|
570
|
+
refs.push(url);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
508
573
|
if (refs.length > 0) {
|
|
509
574
|
console.warn(
|
|
510
575
|
`[freedom] 警告:${path.basename(distHtml)} 引用了外部资源(${refs.join(', ')})。` +
|
package/lib/cli.js
CHANGED
|
@@ -22,12 +22,13 @@ function help() {
|
|
|
22
22
|
L.push(` ${paint('freedom', C.fg.cyan, C.bold)} ${dim('进入完整 CLI 模式(交互式菜单,新建 / 打包 / 配置 / 壳管理)')}`);
|
|
23
23
|
L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('同上(兼容写法,显式进入交互式模式)')}`);
|
|
24
24
|
L.push(section('项目'));
|
|
25
|
-
L.push(` ${paint('freedom init [目录] [--force]', C.fg.cyan)}
|
|
25
|
+
L.push(` ${paint('freedom init [目录] [--force] [--template full|minimal]', C.fg.cyan)} ${dim('新建项目模板(默认 full:含自绘标题栏示例;minimal:极简无自绘标题栏)')}`);
|
|
26
26
|
L.push(` ${paint('freedom tutorial', C.fg.cyan)} ${dim('再次打开安装教程')}`);
|
|
27
27
|
L.push(section('打包'));
|
|
28
28
|
L.push(` ${paint('freedom build [--platform <p>]', C.fg.cyan)} ${dim('前端打包并分发桌面应用(默认当前平台)')}`);
|
|
29
29
|
L.push(` ${dim('--platform win|mac|linux|all')} ${dim('指定目标平台(all = 三平台全量)')}`);
|
|
30
30
|
L.push(` ${dim('--no-cache')} ${dim('忽略前端构建缓存,强制重新打包')}`);
|
|
31
|
+
L.push(` ${paint('freedom verify [--platform <p>]', C.fg.cyan)} ${dim('校验已构建产物完整性并打印产物结构(CI 可依赖退出码)')}`);
|
|
31
32
|
L.push(` ${paint('freedom dmg [--platform <plat>]', C.fg.cyan)} ${dim('将已构建的 .app 打包为 .dmg(需 macOS)')}`);
|
|
32
33
|
L.push(section('外观'));
|
|
33
34
|
L.push(` ${paint('freedom titlebar <native|frameless>', C.fg.cyan)} ${dim('一键切换标题栏策略')}`);
|
|
@@ -113,9 +114,25 @@ async function run(argv) {
|
|
|
113
114
|
|
|
114
115
|
case 'init': {
|
|
115
116
|
const force = rest.includes('--force');
|
|
116
|
-
const
|
|
117
|
-
|
|
117
|
+
const tmplArg = rest.find((a) => a.startsWith('--template'));
|
|
118
|
+
let template;
|
|
119
|
+
if (tmplArg) {
|
|
120
|
+
template = tmplArg.includes('=') ? tmplArg.split('=')[1] : rest[rest.indexOf(tmplArg) + 1];
|
|
121
|
+
if (!template || template.startsWith('--')) {
|
|
122
|
+
console.error(`${err('--template 缺少取值。')} ${dim('用法:')}${paint('freedom init <目录> --template <full|minimal>', C.fg.cyan)}`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const dirArg = rest.filter((a) => a !== '--force' && !a.startsWith('--template'))[0];
|
|
127
|
+
let dir;
|
|
128
|
+
try {
|
|
129
|
+
dir = init(dirArg || '.', { force, template });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(`${err(e.message)}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
118
134
|
console.log(`${ok('项目已创建:')}${paint(dir, C.fg.cyan, C.bold)}`);
|
|
135
|
+
console.log(` ${dim(`模板:${template === 'minimal' ? 'minimal(极简,无自绘标题栏)' : 'full(完整,含自绘标题栏示例)'}`)}`);
|
|
119
136
|
console.log(` ${dim('下一步:')}`);
|
|
120
137
|
console.log(` ${paint(`cd ${dir}`, C.fg.white)}`);
|
|
121
138
|
console.log(` ${paint('npm install', C.fg.white)}`);
|
|
@@ -161,6 +178,47 @@ async function run(argv) {
|
|
|
161
178
|
return 0;
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
case 'verify': {
|
|
182
|
+
const platArg = rest.find((a) => a.startsWith('--platform') || a.startsWith('-p'));
|
|
183
|
+
let platform;
|
|
184
|
+
if (platArg) {
|
|
185
|
+
const inline = platArg.includes('=') ? platArg.split('=')[1] : undefined;
|
|
186
|
+
platform = inline || rest[rest.indexOf(platArg) + 1];
|
|
187
|
+
if (!platform || platform.startsWith('--')) {
|
|
188
|
+
console.error(`${err('--platform 缺少取值。')} ${dim('用法:')}${paint('freedom verify [--platform <p>]', C.fg.cyan)}`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const { verifyProduct, renderTree, formatChecks } = require('./verify');
|
|
193
|
+
const v = await verifyProduct(process.cwd(), { platform });
|
|
194
|
+
if (v.error) {
|
|
195
|
+
console.error(`${err('校验失败:')}${v.error}`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
if (v.targets.length === 0) {
|
|
199
|
+
console.error(`${err('未在产物目录发现可执行文件。')} ${dim('请先运行')} ${paint('freedom build', C.fg.cyan)} ${dim('生成产物。')}`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
let allPass = true;
|
|
203
|
+
for (const t of v.targets) {
|
|
204
|
+
console.log(`\n${section(`[${t.plat}] ${t.dir}`)}`);
|
|
205
|
+
for (const c of t.checks) {
|
|
206
|
+
console.log(` ${c.pass ? ok('通过') : err('失败')} ${c.name}:${c.detail}`);
|
|
207
|
+
}
|
|
208
|
+
allPass = allPass && t.checks.every((c) => c.pass);
|
|
209
|
+
}
|
|
210
|
+
console.log(`\n${dim('产物结构:')}`);
|
|
211
|
+
for (const line of renderTree(v.targets).split('\n')) {
|
|
212
|
+
console.log(` ${line}`);
|
|
213
|
+
}
|
|
214
|
+
if (allPass) {
|
|
215
|
+
console.log(`\n${ok('产物校验全部通过。')}`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
console.log(`\n${err('产物校验存在失败项,请检查上方输出。')}`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
164
222
|
case 'shell':
|
|
165
223
|
return await runShell(rest);
|
|
166
224
|
|
package/lib/init.js
CHANGED
|
@@ -12,8 +12,16 @@ function init(targetDir, opts = {}) {
|
|
|
12
12
|
throw new Error(`目标目录 ${abs} 非空,请使用空目录,或加 --force 覆盖。`);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// 模板选择:'minimal' 极简(无自绘标题栏 / 无演示内容,默认 native 标题栏);默认完整模板。
|
|
16
|
+
// 非法值快速失败,避免静默回退成默认模板让用户误以为指定成功。
|
|
17
|
+
const template = opts.template || 'full';
|
|
18
|
+
const templateName = template === 'minimal' ? 'project-minimal' : 'project';
|
|
19
|
+
if (template !== 'full' && template !== 'minimal') {
|
|
20
|
+
throw new Error(`未知模板:${template}。可选:full(完整模板,含自绘标题栏示例)/ minimal(极简模板,无自绘标题栏)。`);
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
fs.mkdirSync(abs, { recursive: true });
|
|
16
|
-
copyDir(projectTemplateDir(), abs);
|
|
24
|
+
copyDir(projectTemplateDir(templateName), abs);
|
|
17
25
|
|
|
18
26
|
// 项目名替换到 package.json 与 freedom.config.js
|
|
19
27
|
const name = opts.name || path.basename(abs);
|
package/lib/security.js
CHANGED
package/lib/utils.js
CHANGED
|
@@ -13,8 +13,11 @@ function templateDir() {
|
|
|
13
13
|
return path.join(PKG_ROOT, 'templates');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// 项目模板目录:name 为 templates/ 下的子目录名。
|
|
17
|
+
// 'project' 完整模板(默认):含自绘标题栏(frameless)示例、右键菜单、桥接演示;
|
|
18
|
+
// 'project-minimal' 极简模板:无自绘标题栏 / 无演示内容,默认 native 标题栏,开箱即用。
|
|
19
|
+
function projectTemplateDir(name = 'project') {
|
|
20
|
+
return path.join(templateDir(), name);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
function goTemplateDir() {
|
package/lib/verify.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// freedom verify:产物完整性自检 + 产物形态树。
|
|
4
|
+
// 供两个场景复用:
|
|
5
|
+
// 1. freedom build 完成后自动调用(发现问题输出告警,但不阻断正常构建流程);
|
|
6
|
+
// 2. freedom verify 命令独立运行(发现问题返回非零退出码,供 CI / 发布前检查)。
|
|
7
|
+
// 消除"build 后形态未知 / 需手动实测":自检覆盖可执行文件、resources、安全模式资源形态、
|
|
8
|
+
// 运行时配置一致性、后端目录、Windows PE 头与 high 模式 app.bin 容器头。
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { isWinPlat, platformExeName } = require('./utils');
|
|
13
|
+
|
|
14
|
+
// ---- 产物定位 ----
|
|
15
|
+
|
|
16
|
+
// 依据项目配置探测 outDir 下的产物目标。
|
|
17
|
+
// 布局:
|
|
18
|
+
// 单平台:outDir/<app>.exe(win)/ outDir/<app>(unix)
|
|
19
|
+
// 多平台:outDir/<plat>/<app>.exe 或 outDir/<plat>/<app>
|
|
20
|
+
// 返回 [{ plat, dir, outFile }]。
|
|
21
|
+
function findProducts(projectDir, cfg) {
|
|
22
|
+
const name = (cfg.name || 'freedom-app').replace(/[^a-zA-Z0-9_.-]/g, '-');
|
|
23
|
+
const outDir = String(cfg.outDir || 'dist').trim() || 'dist';
|
|
24
|
+
const outDirPath = path.resolve(projectDir, outDir);
|
|
25
|
+
if (!fs.existsSync(outDirPath)) return [];
|
|
26
|
+
|
|
27
|
+
const winExe = `${name}.exe`;
|
|
28
|
+
const targets = [];
|
|
29
|
+
|
|
30
|
+
// 平台子目录(--platform all 布局):目录名即平台 key,目录内含 <app>[.exe] 或 resources/
|
|
31
|
+
const entries = fs.readdirSync(outDirPath, { withFileTypes: true });
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
if (!e.isDirectory()) continue;
|
|
34
|
+
const dir = path.join(outDirPath, e.name);
|
|
35
|
+
const exePath = path.join(dir, isWinPlat(e.name) ? winExe : name);
|
|
36
|
+
if (fs.existsSync(exePath) || fs.existsSync(path.join(dir, 'resources'))) {
|
|
37
|
+
targets.push({ plat: e.name, dir, outFile: exePath });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// 单平台布局:outDir 根下的可执行文件
|
|
41
|
+
if (targets.length === 0) {
|
|
42
|
+
for (const p of ['win-x64', 'linux-x64', 'darwin-arm64', 'linux-arm64']) {
|
|
43
|
+
const exePath = path.join(outDirPath, isWinPlat(p) ? winExe : name);
|
|
44
|
+
if (fs.existsSync(exePath)) {
|
|
45
|
+
targets.push({ plat: p, dir: outDirPath, outFile: exePath });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return targets;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- 单平台自检 ----
|
|
54
|
+
|
|
55
|
+
function safeStat(p) {
|
|
56
|
+
try {
|
|
57
|
+
return fs.statSync(p);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fmtSize(n) {
|
|
64
|
+
if (n < 1024) return `${n} B`;
|
|
65
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
66
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 单平台产物校验,返回 [{ name, pass, detail }]。
|
|
70
|
+
function checkPlatformProduct({ targetDir, plat, appName, hasBackend }) {
|
|
71
|
+
const checks = [];
|
|
72
|
+
const fail = (name, detail) => checks.push({ name, pass: false, detail });
|
|
73
|
+
const okc = (name, detail) => checks.push({ name, pass: true, detail });
|
|
74
|
+
|
|
75
|
+
// 1) 可执行文件存在且非空
|
|
76
|
+
const exeName = platformExeName(plat, appName);
|
|
77
|
+
const exePath = path.join(targetDir, exeName);
|
|
78
|
+
const exeStat = safeStat(exePath);
|
|
79
|
+
if (!exeStat) {
|
|
80
|
+
fail('可执行文件', `缺失:${exeName}`);
|
|
81
|
+
} else if (exeStat.size <= 0) {
|
|
82
|
+
fail('可执行文件', `${exeName} 为空文件(0 字节)`);
|
|
83
|
+
} else {
|
|
84
|
+
okc('可执行文件', `${exeName}(${fmtSize(exeStat.size)})`);
|
|
85
|
+
// Windows 产物校验 PE 头(MZ),杜绝"假壳 / 空壳"被静默分发
|
|
86
|
+
if (isWinPlat(plat)) {
|
|
87
|
+
let head = '';
|
|
88
|
+
try {
|
|
89
|
+
head = fs.readFileSync(exePath).subarray(0, 2).toString('latin1');
|
|
90
|
+
} catch (e) { /* 读取失败按不通过处理 */ }
|
|
91
|
+
if (head === 'MZ') okc('PE 格式', 'MZ 头有效');
|
|
92
|
+
else fail('PE 格式', '文件头不是 MZ,非有效 Windows 可执行文件');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2) resources 目录
|
|
97
|
+
const resDir = path.join(targetDir, 'resources');
|
|
98
|
+
const resStat = safeStat(resDir);
|
|
99
|
+
if (!resStat || !resStat.isDirectory()) {
|
|
100
|
+
fail('resources 目录', '缺失:resources/');
|
|
101
|
+
return checks; // 后续资源检查依赖该目录,缺失则提前返回
|
|
102
|
+
}
|
|
103
|
+
okc('resources 目录', '存在');
|
|
104
|
+
|
|
105
|
+
// 3) 资源形态与安全模式一致(high: app.bin+.integrity;明文: index.html+config.json;互斥)
|
|
106
|
+
const hasBin = fs.existsSync(path.join(resDir, 'app.bin'));
|
|
107
|
+
const hasIntegrity = fs.existsSync(path.join(resDir, '.integrity'));
|
|
108
|
+
const hasHtml = fs.existsSync(path.join(resDir, 'index.html'));
|
|
109
|
+
const hasConfig = fs.existsSync(path.join(resDir, 'config.json'));
|
|
110
|
+
if (hasBin) {
|
|
111
|
+
if (hasHtml || hasConfig) {
|
|
112
|
+
fail('资源形态', 'high 模式残留明文 index.html/config.json(互斥被破坏)');
|
|
113
|
+
} else {
|
|
114
|
+
okc('资源形态', 'high 模式(app.bin + .integrity)');
|
|
115
|
+
}
|
|
116
|
+
if (!hasIntegrity) fail('完整性清单', '缺失 .integrity');
|
|
117
|
+
else okc('完整性清单', '.integrity 存在');
|
|
118
|
+
// app.bin 容器头校验(FRDM1)
|
|
119
|
+
let head = '';
|
|
120
|
+
try {
|
|
121
|
+
head = fs.readFileSync(path.join(resDir, 'app.bin')).subarray(0, 5).toString('latin1');
|
|
122
|
+
} catch (e) { /* 读取失败按不通过处理 */ }
|
|
123
|
+
if (head === 'FRDM1') okc('app.bin 容器', 'FRDM1 头有效');
|
|
124
|
+
else fail('app.bin 容器', '容器头不是 FRDM1,文件损坏或非本工具产物');
|
|
125
|
+
} else {
|
|
126
|
+
if (hasIntegrity) {
|
|
127
|
+
fail('资源形态', '明文模式残留 high 产物(.integrity)');
|
|
128
|
+
} else {
|
|
129
|
+
okc('资源形态', '明文模式(index.html + config.json)');
|
|
130
|
+
}
|
|
131
|
+
const htmlStat = safeStat(path.join(resDir, 'index.html'));
|
|
132
|
+
if (!hasHtml) fail('前端页面', '缺失 resources/index.html');
|
|
133
|
+
else if (htmlStat.size <= 0) fail('前端页面', 'index.html 为空(0 字节)');
|
|
134
|
+
else okc('前端页面', `index.html(${fmtSize(htmlStat.size)})`);
|
|
135
|
+
if (!hasConfig) {
|
|
136
|
+
fail('运行时配置', '缺失 resources/config.json');
|
|
137
|
+
} else {
|
|
138
|
+
try {
|
|
139
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(resDir, 'config.json'), 'utf8'));
|
|
140
|
+
if (cfg.name !== appName) {
|
|
141
|
+
fail('运行时配置', `config.json name=${cfg.name} 与可执行文件名 ${appName} 不一致`);
|
|
142
|
+
} else {
|
|
143
|
+
okc('运行时配置', `config.json 可解析,name=${cfg.name} 一致`);
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
fail('运行时配置', `config.json 解析失败:${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 4) 后端目录
|
|
152
|
+
if (hasBackend) {
|
|
153
|
+
if (fs.existsSync(path.join(resDir, 'backend'))) okc('后端进程', 'resources/backend 存在');
|
|
154
|
+
else fail('后端进程', '配置了 backend 但 resources/backend 缺失');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return checks;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- 对外入口 ----
|
|
161
|
+
|
|
162
|
+
// 校验项目产物。返回 { ok, targets: [{ plat, dir, outFile, checks }] }。
|
|
163
|
+
async function verifyProduct(projectDir, opts = {}) {
|
|
164
|
+
const dir = path.resolve(projectDir || '.');
|
|
165
|
+
const { loadConfig } = require('./utils');
|
|
166
|
+
let cfg;
|
|
167
|
+
try {
|
|
168
|
+
cfg = await loadConfig(dir);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return { ok: false, targets: [], error: e.message };
|
|
171
|
+
}
|
|
172
|
+
const appName = (cfg.name || 'freedom-app').replace(/[^a-zA-Z0-9_.-]/g, '-');
|
|
173
|
+
const hasBackendDir = !!(cfg.backend && fs.existsSync(path.join(dir, cfg.backendDir || 'backend')));
|
|
174
|
+
|
|
175
|
+
const targets = findProducts(dir, cfg)
|
|
176
|
+
.filter((t) => !opts.platform || t.plat === opts.platform)
|
|
177
|
+
.map((t) => ({ ...t, checks: checkPlatformProduct({ targetDir: t.dir, plat: t.plat, appName, hasBackend: hasBackendDir }) }));
|
|
178
|
+
|
|
179
|
+
const ok = targets.length > 0 && targets.every((t) => t.checks.every((c) => c.pass));
|
|
180
|
+
return { ok, targets, appName };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- 产物形态树 ----
|
|
184
|
+
|
|
185
|
+
// 递归收集目录树:[{ rel, size, isDir, depth }],depth 限制避免深层目录(如 backend/node_modules)爆炸。
|
|
186
|
+
function collectTree(dir, depth, out, maxDepth) {
|
|
187
|
+
let entries;
|
|
188
|
+
try {
|
|
189
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
for (const e of entries) {
|
|
194
|
+
if (e.name === '.integrity') {
|
|
195
|
+
// 完整性清单内容为敏感校验值,仅标注存在与大小,不展开内容
|
|
196
|
+
out.push({ rel: e.name, size: 0, isDir: false, depth, hidden: true });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const abs = path.join(dir, e.name);
|
|
200
|
+
const stat = safeStat(abs);
|
|
201
|
+
if (e.isDirectory()) {
|
|
202
|
+
out.push({ rel: e.name, size: 0, isDir: true, depth });
|
|
203
|
+
if (depth < maxDepth) collectTree(abs, depth + 1, out, maxDepth);
|
|
204
|
+
} else if (stat) {
|
|
205
|
+
out.push({ rel: e.name, size: stat.size, isDir: false, depth });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderTree(targets) {
|
|
212
|
+
const lines = [];
|
|
213
|
+
for (const t of targets) {
|
|
214
|
+
lines.push(`${t.dir}`);
|
|
215
|
+
const maxDepth = 5;
|
|
216
|
+
const tree = collectTree(t.dir, 1, [], maxDepth).sort((a, b) => {
|
|
217
|
+
if (a.depth !== b.depth) return a.depth - b.depth;
|
|
218
|
+
return a.rel.localeCompare(b.rel);
|
|
219
|
+
});
|
|
220
|
+
// 同层排序:目录优先,保证层级清晰
|
|
221
|
+
for (const node of tree) {
|
|
222
|
+
const prefix = ' '.repeat(node.depth) + (node.isDir ? '[dir] ' : ' ');
|
|
223
|
+
const size = node.isDir ? '' : ` (${fmtSize(node.size)})`;
|
|
224
|
+
const hidden = node.hidden ? ' [校验值]' : '';
|
|
225
|
+
lines.push(`${prefix}${node.rel}${size}${hidden}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 校验项 -> 可打印行(build 自动自检 / verify 命令共用;纯文本,跨平台不依赖彩色终端)
|
|
232
|
+
function formatChecks(checks) {
|
|
233
|
+
return checks.map((c) => `${c.pass ? '[通过]' : '[失败]'} ${c.name}:${c.detail}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { verifyProduct, findProducts, checkPlatformProduct, renderTree, formatChecks };
|
package/package.json
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@yufengtadian/freedom-cli",
|
|
3
|
-
"version": "1.12.
|
|
4
|
-
"description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"desktop",
|
|
7
|
-
"webview",
|
|
8
|
-
"electron-alternative",
|
|
9
|
-
"wails",
|
|
10
|
-
"tauri",
|
|
11
|
-
"frontend",
|
|
12
|
-
"cross-platform"
|
|
13
|
-
],
|
|
14
|
-
"license": "MIT",
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public"
|
|
17
|
-
},
|
|
18
|
-
"bin": {
|
|
19
|
-
"freedom": "bin/freedom.js"
|
|
20
|
-
},
|
|
21
|
-
"files": [
|
|
22
|
-
"bin",
|
|
23
|
-
"lib",
|
|
24
|
-
"shell",
|
|
25
|
-
"templates",
|
|
26
|
-
"tutorial",
|
|
27
|
-
"postinstall.js",
|
|
28
|
-
"README.md"
|
|
29
|
-
],
|
|
30
|
-
"scripts": {
|
|
31
|
-
"postinstall": "node postinstall.js"
|
|
32
|
-
},
|
|
33
|
-
"engines": {
|
|
34
|
-
"node": ">=18"
|
|
35
|
-
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"rcedit": "^4.0.1"
|
|
38
|
-
}
|
|
39
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@yufengtadian/freedom-cli",
|
|
3
|
+
"version": "1.12.18",
|
|
4
|
+
"description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"desktop",
|
|
7
|
+
"webview",
|
|
8
|
+
"electron-alternative",
|
|
9
|
+
"wails",
|
|
10
|
+
"tauri",
|
|
11
|
+
"frontend",
|
|
12
|
+
"cross-platform"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"freedom": "bin/freedom.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"lib",
|
|
24
|
+
"shell",
|
|
25
|
+
"templates",
|
|
26
|
+
"tutorial",
|
|
27
|
+
"postinstall.js",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"postinstall": "node postinstall.js"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"rcedit": "^4.0.1"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
@@ -52,16 +52,10 @@
|
|
|
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'); },
|
|
56
55
|
toggleMaximize: function () { return windowAction('toggleMaximize'); },
|
|
57
56
|
close: function () { return windowAction('close'); },
|
|
58
57
|
isMaximized: function () { return windowAction('isMaximized'); },
|
|
59
58
|
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'); },
|
|
65
59
|
},
|
|
66
60
|
};
|
|
67
61
|
|
|
@@ -71,19 +65,14 @@
|
|
|
71
65
|
var q = function (s) { return typeof s === 'string' ? document.querySelector(s) : s; };
|
|
72
66
|
var min = q(sel && sel.min), max = q(sel && sel.max), close = q(sel && sel.close);
|
|
73
67
|
var self = this;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (min) min.addEventListener('click', function () {
|
|
77
|
-
self.minimize().catch(function (e) { console.warn('[freedom] minimize:', e); });
|
|
78
|
-
});
|
|
68
|
+
var ignoreErr = function () { /* 桥接未就绪(如窗口销毁中)时忽略本次操作 */ };
|
|
69
|
+
if (min) min.addEventListener('click', function () { self.minimize().catch(ignoreErr); });
|
|
79
70
|
if (max) max.addEventListener('click', function () {
|
|
80
71
|
self.isMaximized().then(function (m) {
|
|
81
|
-
if (m)
|
|
82
|
-
}).catch(
|
|
83
|
-
});
|
|
84
|
-
if (close) close.addEventListener('click', function () {
|
|
85
|
-
self.close().catch(function (e) { console.warn('[freedom] close:', e); });
|
|
72
|
+
if (m) self.unmaximize(); else self.maximize();
|
|
73
|
+
}).catch(ignoreErr);
|
|
86
74
|
});
|
|
75
|
+
if (close) close.addEventListener('click', function () { self.close().catch(ignoreErr); });
|
|
87
76
|
};
|
|
88
77
|
|
|
89
78
|
function windowAction(action) {
|
|
@@ -106,4 +95,18 @@
|
|
|
106
95
|
window.go = go;
|
|
107
96
|
|
|
108
97
|
window.freedom = freedom;
|
|
98
|
+
|
|
99
|
+
// H3:页面就绪回调。Go 侧 onReady 改为在 __freedom__ready 被调用时触发
|
|
100
|
+
//(此前在 SetHtml 前触发,期间 Emit 的初始化事件因 SDK 尚未建立而丢失)。
|
|
101
|
+
// DOMContentLoaded 后上报就绪;若脚本执行时 DOM 已加载完成则立即上报。
|
|
102
|
+
function signalReady() {
|
|
103
|
+
if (typeof window.__freedom__ready === 'function') {
|
|
104
|
+
try { window.__freedom__ready(); } catch (e) { /* 忽略 */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (document.readyState === 'loading') {
|
|
108
|
+
document.addEventListener('DOMContentLoaded', signalReady);
|
|
109
|
+
} else {
|
|
110
|
+
signalReady();
|
|
111
|
+
}
|
|
109
112
|
})();
|
|
@@ -261,7 +261,13 @@ func (p *ProcBackend) Close() error {
|
|
|
261
261
|
case <-time.After(3 * time.Second):
|
|
262
262
|
if cmd.Process != nil {
|
|
263
263
|
_ = cmd.Process.Kill()
|
|
264
|
-
|
|
264
|
+
// M2:Kill 后进程仍可能不退出(僵死/句柄被占用/权限拒绝),
|
|
265
|
+
// 再等一小段,仍不退则放弃等待,避免 Close 永久阻塞壳退出。
|
|
266
|
+
select {
|
|
267
|
+
case <-done:
|
|
268
|
+
case <-time.After(time.Second):
|
|
269
|
+
fmt.Fprintf(os.Stderr, "freedom: proc backend: process did not exit after kill\n")
|
|
270
|
+
}
|
|
265
271
|
}
|
|
266
272
|
}
|
|
267
273
|
return nil
|