@yufengtadian/freedom-cli 1.12.16 → 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/README.md +38 -3
- package/lib/build.js +132 -12
- package/lib/cli.js +94 -4
- package/lib/config.js +12 -1
- package/lib/init.js +9 -1
- package/lib/security.js +150 -0
- 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/anti_debug_other.go +8 -0
- package/templates/go/pkg/freedom/anti_debug_windows.go +39 -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 +56 -7
- package/templates/go/pkg/freedom/freedom.go +70 -8
- package/templates/go/pkg/freedom/security.go +227 -0
- package/templates/go/pkg/freedom/security_test.go +153 -0
- 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/freedom.config.js +8 -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/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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package freedom
|
|
4
|
+
|
|
5
|
+
// 反调试(Windows 实现):high 安全模式下调用。
|
|
6
|
+
// 检测常见用户态调试器,发现时静默退出,防止攻击者在调试器下
|
|
7
|
+
// 单步追踪资源解密 / 密钥派生逻辑。
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"os"
|
|
11
|
+
"syscall"
|
|
12
|
+
"unsafe"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
var (
|
|
16
|
+
kernel32dbg = syscall.NewLazyDLL("kernel32.dll")
|
|
17
|
+
procIsDebuggerPresent = kernel32dbg.NewProc("IsDebuggerPresent")
|
|
18
|
+
procCheckRemoteDebuggerPresent = kernel32dbg.NewProc("CheckRemoteDebuggerPresent")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// antiDebugCheck 检测调试器;发现则立即退出进程(退出码 77,无提示)。
|
|
22
|
+
// 仅在 high 安全模式启用时由 freedom.go Run() 调用。
|
|
23
|
+
func antiDebugCheck() {
|
|
24
|
+
if isBeingDebugged() {
|
|
25
|
+
os.Exit(77)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// isBeingDebugged 同时检测本地调试器(IsDebuggerPresent)与远程/内核调试器
|
|
30
|
+
// (CheckRemoteDebuggerPresent),任一命中即认为正在被调试。
|
|
31
|
+
func isBeingDebugged() bool {
|
|
32
|
+
r, _, _ := procIsDebuggerPresent.Call()
|
|
33
|
+
if r != 0 {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
var present int32
|
|
37
|
+
procCheckRemoteDebuggerPresent.Call(uintptr(0), uintptr(unsafe.Pointer(&present)))
|
|
38
|
+
return present != 0
|
|
39
|
+
}
|
|
@@ -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
|
|
@@ -46,30 +46,48 @@ func (a *App) applyCenter() {
|
|
|
46
46
|
mi.cbSize = uint32(unsafe.Sizeof(mi))
|
|
47
47
|
if r1, _, _ := procGetMonitorInfo.Call(mon, uintptr(unsafe.Pointer(&mi))); r1 != 0 {
|
|
48
48
|
work := mi.rcWork
|
|
49
|
-
sw := work.right - work.left
|
|
50
|
-
sh := work.bottom - work.top
|
|
51
|
-
|
|
52
|
-
y :=
|
|
53
|
-
if x < 0 {
|
|
54
|
-
x = 0
|
|
55
|
-
}
|
|
56
|
-
if y < 0 {
|
|
57
|
-
y = 0
|
|
58
|
-
}
|
|
49
|
+
sw := int(work.right - work.left)
|
|
50
|
+
sh := int(work.bottom - work.top)
|
|
51
|
+
// M5:窗口居中坐标双向 clamp。逻辑抽到 clampCentered 便于无头单测。
|
|
52
|
+
x, y := clampCentered(a.cfg.Width, a.cfg.Height, int(work.left), int(work.top), sw, sh)
|
|
59
53
|
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
|
|
60
54
|
return
|
|
61
55
|
}
|
|
62
56
|
}
|
|
63
|
-
//
|
|
57
|
+
// 回退:主屏全屏尺寸居中(M5 同双向 clamp,上界不小于下界 0)
|
|
64
58
|
sw, _, _ := procGetSystemMetrics.Call(smCxScreen)
|
|
65
59
|
sh, _, _ := procGetSystemMetrics.Call(smCyScreen)
|
|
66
|
-
|
|
67
|
-
y :=
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
// 主屏全屏居中:等价于左上 (0,0) 的工作区,同走 clampCentered 双向 clamp。
|
|
61
|
+
x, y := clampCentered(a.cfg.Width, a.cfg.Height, 0, 0, int(sw), int(sh))
|
|
62
|
+
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// clampCentered 计算窗口在其所在工作区内的居中左上角坐标,并做双向 clamp:
|
|
66
|
+
// 窗口小于工作区时严格居中;窗口大于工作区时贴左上缘,保证窗口整体落在
|
|
67
|
+
// 工作区内、可被拖拽恢复(M5——此前只 clamp 下界,窗口超屏时上界 work.right-w
|
|
68
|
+
// 越过下界 work.left,上下界互打架会把坐标钳回负值;现上界不小于下界)。
|
|
69
|
+
func clampCentered(winW, winH, left, top, workW, workH int) (int, int) {
|
|
70
|
+
x := left + (workW-winW)/2
|
|
71
|
+
y := top + (workH-winH)/2
|
|
72
|
+
upperX := left + workW - winW
|
|
73
|
+
if upperX < left {
|
|
74
|
+
upperX = left
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
upperY := top + workH - winH
|
|
77
|
+
if upperY < top {
|
|
78
|
+
upperY = top
|
|
73
79
|
}
|
|
74
|
-
|
|
80
|
+
if x < left {
|
|
81
|
+
x = left
|
|
82
|
+
}
|
|
83
|
+
if x > upperX {
|
|
84
|
+
x = upperX
|
|
85
|
+
}
|
|
86
|
+
if y < top {
|
|
87
|
+
y = top
|
|
88
|
+
}
|
|
89
|
+
if y > upperY {
|
|
90
|
+
y = upperY
|
|
91
|
+
}
|
|
92
|
+
return x, y
|
|
75
93
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//go:build windows
|
|
2
|
+
|
|
3
|
+
package freedom
|
|
4
|
+
|
|
5
|
+
import "testing"
|
|
6
|
+
|
|
7
|
+
// TestClampCentered 覆盖 M5 双向 clamp 的所有分支:
|
|
8
|
+
// 小窗严格居中 / 超屏贴左上(上下界互打架)/ 副屏负坐标 / 右缘与下缘不越界。
|
|
9
|
+
func TestClampCentered(t *testing.T) {
|
|
10
|
+
cases := []struct {
|
|
11
|
+
name string
|
|
12
|
+
winW, winH, left, top int
|
|
13
|
+
workW, workH, wantX, wantY int
|
|
14
|
+
}{
|
|
15
|
+
{"主屏小窗严格居中", 240, 180, 0, 0, 1920, 1040, 840, 430},
|
|
16
|
+
{"主屏超屏贴左上角", 3000, 2000, 0, 0, 1920, 1040, 0, 0},
|
|
17
|
+
{"副屏负坐标居中", 800, 600, -1920, 0, 1920, 1080, -1360, 240},
|
|
18
|
+
{"右缘不越界", 1900, 1000, 0, 0, 1920, 1080, 10, 40},
|
|
19
|
+
{"窗口恰等于工作区", 1920, 1040, 0, 0, 1920, 1040, 0, 0},
|
|
20
|
+
{"副屏超屏上界回贴左上", 3000, 2000, -1920, 0, 1920, 1040, -1920, 0},
|
|
21
|
+
{"高度触下缘", 200, 1040, 0, 0, 1920, 1040, 860, 0},
|
|
22
|
+
}
|
|
23
|
+
for _, c := range cases {
|
|
24
|
+
t.Run(c.name, func(t *testing.T) {
|
|
25
|
+
x, y := clampCentered(c.winW, c.winH, c.left, c.top, c.workW, c.workH)
|
|
26
|
+
if x != c.wantX || y != c.wantY {
|
|
27
|
+
t.Fatalf("clampCentered(%d,%d,%d,%d,%d,%d) = (%d,%d), want (%d,%d)",
|
|
28
|
+
c.winW, c.winH, c.left, c.top, c.workW, c.workH, x, y, c.wantX, c.wantY)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -18,6 +18,42 @@ import (
|
|
|
18
18
|
"path/filepath"
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
+
// secureFatalError 表示 high 安全模式下资源加载/校验失败(app.bin 存在但读取、
|
|
22
|
+
// 解密或完整性校验不通过)。此类错误必须"拒绝运行"(不显示窗口、不回退占位页),
|
|
23
|
+
// 防止资源被篡改/替换/密钥不匹配后静默降级运行(H2)。
|
|
24
|
+
type secureFatalError struct{ err error }
|
|
25
|
+
|
|
26
|
+
func (e *secureFatalError) Error() string { return e.err.Error() }
|
|
27
|
+
func (e *secureFatalError) Unwrap() error { return e.err }
|
|
28
|
+
|
|
29
|
+
// secureFatal 将 loadSecureResources 的错误包装为安全致命错误。
|
|
30
|
+
// loadSecureResources 已区分:app.bin 不存在时返回 hit=false(非 high 产物),
|
|
31
|
+
// 其余错误均为"存在但读取/解密/校验失败",正是需要拒绝运行的场景。
|
|
32
|
+
func secureFatal(err error) error {
|
|
33
|
+
return &secureFatalError{err: err}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// loadRuntimeConfig 读取 exe 同目录 resources/config.json,将命中的字段覆盖到应用配置。
|
|
37
|
+
// 文件不存在时视为未配置(返回 nil,保持编译期/默认配置不变);
|
|
38
|
+
// 文件存在但读取/解析失败时返回具体错误,由调用方打印告警,避免用户手改配置出错时无感知。
|
|
39
|
+
func (a *App) loadRuntimeConfig() error {
|
|
40
|
+
// high 安全模式:配置与页面封装在加密容器 app.bin 内,优先内存解密加载。
|
|
41
|
+
if p, hit, err := loadSecureResources(); err != nil {
|
|
42
|
+
return secureFatal(err)
|
|
43
|
+
} else if hit {
|
|
44
|
+
var rc runtimeConfigFile
|
|
45
|
+
if err := json.Unmarshal([]byte(p.Config), &rc); err != nil {
|
|
46
|
+
return fmt.Errorf("parse encrypted config: %w", err)
|
|
47
|
+
}
|
|
48
|
+
a.applyRuntimeConfig(&rc)
|
|
49
|
+
// high 模式强制关闭 WebView 开发者工具,防止前端源码经 devtools 直接查看。
|
|
50
|
+
a.cfg.Debug = false
|
|
51
|
+
a.secure = true
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
return a.loadRuntimeConfigPlain()
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
// runtimeBackend 描述 config.json 中的后端进程配置(任意语言,经 stdio NDJSON 桥接)。
|
|
22
58
|
type runtimeBackend struct {
|
|
23
59
|
Command string `json:"command"`
|
|
@@ -48,10 +84,8 @@ func resourcesDir() (string, error) {
|
|
|
48
84
|
return filepath.Join(filepath.Dir(exe), "resources"), nil
|
|
49
85
|
}
|
|
50
86
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// 文件存在但读取/解析失败时返回具体错误,由调用方打印告警,避免用户手改配置出错时无感知。
|
|
54
|
-
func (a *App) loadRuntimeConfig() error {
|
|
87
|
+
// loadRuntimeConfigPlain 明文配置路径(非 high 模式):读取 resources/config.json。
|
|
88
|
+
func (a *App) loadRuntimeConfigPlain() error {
|
|
55
89
|
dir, err := resourcesDir()
|
|
56
90
|
if err != nil {
|
|
57
91
|
return err
|
|
@@ -68,10 +102,19 @@ func (a *App) loadRuntimeConfig() error {
|
|
|
68
102
|
if err := json.Unmarshal(data, &rc); err != nil {
|
|
69
103
|
return fmt.Errorf("parse %s: %w", cfgPath, err)
|
|
70
104
|
}
|
|
105
|
+
if err := a.applyRuntimeConfig(&rc); err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// applyRuntimeConfig 把解析后的运行时配置覆盖到应用配置(明文与加密路径共用)。
|
|
112
|
+
func (a *App) applyRuntimeConfig(rc *runtimeConfigFile) error {
|
|
113
|
+
// 窗口标题:config.json 的 title 覆盖编译期默认("Freedom App")。
|
|
114
|
+
// 此前只解析未应用,导致改 freedom.config.js 的 name/title 无效、
|
|
115
|
+
// 打包产物窗口标题永远停留在默认值("无法修改程序名字")。
|
|
71
116
|
if rc.Title != "" {
|
|
72
117
|
a.cfg.Title = rc.Title
|
|
73
|
-
} else if rc.Name != "" {
|
|
74
|
-
a.cfg.Title = rc.Name
|
|
75
118
|
}
|
|
76
119
|
switch rc.TitleBar {
|
|
77
120
|
case "native":
|
|
@@ -112,8 +155,14 @@ func (a *App) loadRuntimeConfig() error {
|
|
|
112
155
|
return nil
|
|
113
156
|
}
|
|
114
157
|
|
|
115
|
-
// loadRuntimeHTML
|
|
158
|
+
// loadRuntimeHTML 返回前端页面内容(high 模式:解密 app.bin 内的 html;
|
|
159
|
+
// 否则读取 exe 同目录 resources/index.html)。文件不存在时返回空串。
|
|
116
160
|
func loadRuntimeHTML() (string, error) {
|
|
161
|
+
if p, hit, err := loadSecureResources(); err != nil {
|
|
162
|
+
return "", secureFatal(err)
|
|
163
|
+
} else if hit {
|
|
164
|
+
return p.HTML, nil
|
|
165
|
+
}
|
|
117
166
|
dir, err := resourcesDir()
|
|
118
167
|
if err != nil {
|
|
119
168
|
return "", err
|