@yufengtadian/freedom-cli 1.0.0 → 1.1.10
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 -6
- package/lib/build.js +124 -104
- package/lib/cli.js +63 -4
- package/lib/shell.js +139 -0
- package/lib/utils.js +47 -0
- package/package.json +4 -3
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/templates/go/gen_config.go +6 -3
- package/templates/go/go.sum +2 -0
- package/templates/go/pkg/freedom/assets/index.html +108 -0
- package/templates/go/pkg/freedom/assets_embed.go +5 -2
- package/templates/go/pkg/freedom/backend_proc.go +10 -0
- package/templates/go/pkg/freedom/configfile.go +121 -0
- package/templates/go/pkg/freedom/freedom.go +16 -6
- package/tutorial/tutorial.html +7 -2
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# freedom-cli
|
|
2
2
|
|
|
3
|
-
Freedom 桌面壳打包工具:把你的 Web
|
|
3
|
+
Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.1.10)。
|
|
4
4
|
|
|
5
|
-
基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK
|
|
5
|
+
基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
|
|
6
|
+
|
|
7
|
+
**v1.1.10 核心升级**:不再依赖 Go 工具链。壳层以预编译二进制随 npm 包分发(`shell/<platform>/`),`freedom build` 直接复制壳 + 写入前端与配置即可出包,Windows / macOS / Linux 三平台一次全出,任意前后端语言自由组合。
|
|
6
8
|
|
|
7
9
|
## 安装
|
|
8
10
|
|
|
@@ -26,10 +28,16 @@ freedom titlebar hidden # 隐藏标题栏视觉,仅保留 Windows 原生
|
|
|
26
28
|
freedom titlebar frameless # 完全无边框,按钮由前端自绘(模板已内置示例)
|
|
27
29
|
|
|
28
30
|
# 3. 打包
|
|
29
|
-
freedom build
|
|
31
|
+
freedom build # 默认当前平台
|
|
32
|
+
freedom build --platform all # 三平台全量(win + mac + linux)
|
|
33
|
+
freedom build --platform win-x64 # 仅 Windows
|
|
34
|
+
freedom build --platform mac-arm64 # 仅 macOS Apple Silicon
|
|
35
|
+
freedom build --platform linux-x64 # 仅 Linux
|
|
30
36
|
```
|
|
31
37
|
|
|
32
|
-
构建产物默认输出到 `dist
|
|
38
|
+
构建产物默认输出到 `dist/`。`dist/<应用名>.exe`(或对应平台可执行文件)为壳层程序,运行时从 `dist/resources/` 加载前端页面与配置,不弹 cmd 黑窗。
|
|
39
|
+
|
|
40
|
+
**三平台打包**:壳层二进制随包分发,无需本机安装 Go 或交叉编译工具链。首次构建某平台时若本地缺少对应壳二进制,CLI 自动从 GitHub Releases 下载(`freedom shell download <platform>` 可手动补齐);仅当你想用本机 Go 工具链亲自编译壳时才需要 `freedom shell build`(只产出本机平台)。
|
|
33
41
|
|
|
34
42
|
产物目录可在 `freedom.config.js` 的 `outDir` 中调整:默认 `'dist'`,设为 `'.'` 则直接输出到项目根目录(dist 的上级),设为任意相对 / 绝对路径亦可。输出到项目根目录时会自动跳过 `index.html` 副本,避免覆盖项目源文件。
|
|
35
43
|
|
|
@@ -78,19 +86,36 @@ window.freedom.window.minimize(); // 窗口控制
|
|
|
78
86
|
## 构建要求
|
|
79
87
|
|
|
80
88
|
- Node.js >= 18
|
|
81
|
-
- Go
|
|
89
|
+
- 不需要 Go 工具链(壳层为随包分发的预编译二进制)
|
|
90
|
+
- 可选:`freedom shell build` 需本机 Go(含 CGO,Windows 需 MSVC 编译环境),仅用于亲自编译壳
|
|
82
91
|
|
|
83
92
|
## 命令
|
|
84
93
|
|
|
85
94
|
```
|
|
86
95
|
freedom init <目录> [--force]
|
|
87
|
-
freedom build
|
|
96
|
+
freedom build [--platform win-x64|mac-arm64|linux-x64|all]
|
|
88
97
|
freedom titlebar <native|hidden|frameless>
|
|
89
98
|
freedom config [get|set]
|
|
99
|
+
freedom shell list|download <platform>|build <platform>
|
|
90
100
|
freedom tutorial
|
|
91
101
|
freedom help
|
|
92
102
|
```
|
|
93
103
|
|
|
104
|
+
## 任意语言后端
|
|
105
|
+
|
|
106
|
+
壳与后端进程通过 stdin/stdout 的 NDJSON/JSON-RPC 桥接(协议语言无关),因此后端可用任意语言实现(Node / Python / Rust / Go / C#…)。
|
|
107
|
+
|
|
108
|
+
在 `freedom.config.js` 中声明后端进程,`freedom build` 会把你的 `backend/` 目录原样分发到 `dist/resources/backend/`,壳启动时自动以 `resources/` 为工作目录拉起后端进程(Windows 下后端黑窗同样隐藏):
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
export default {
|
|
112
|
+
...
|
|
113
|
+
backend: { command: 'node', args: ['backend/main.mjs'] },
|
|
114
|
+
};
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
后端只需读写 stdin/stdout,示例见模板注释与 `tutorial` 教程。
|
|
118
|
+
|
|
94
119
|
## 关于生成产物
|
|
95
120
|
|
|
96
121
|
本工具生成的所有文件(含模板与构建产物)均干净整洁,无任何冗余尾注,可直接作为交付物使用。
|
package/lib/build.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// freedom build:前端打包 + 通用壳分发(v1.1.10,零语言工具链)
|
|
4
|
+
//
|
|
5
|
+
// 流程(不再调用 go build / 不依赖 Go):
|
|
6
|
+
// 1. 前端打包:npm install(如缺依赖)+ vite build -> .freedom/vite-dist/index.html
|
|
7
|
+
// 2. 选择目标平台:--platform <win|mac|linux|all>(默认当前平台)
|
|
8
|
+
// 3. 每个平台:
|
|
9
|
+
// - 取预编译通用壳二进制(包内 shell/<plat>/,缺失自动从 GitHub Releases 下载)
|
|
10
|
+
// - 写 resources/index.html(前端单文件页)
|
|
11
|
+
// - 写 resources/config.json(窗口 + 后端配置,壳运行时读取)
|
|
12
|
+
// - 复制壳为 outDir/<app>[.exe]
|
|
13
|
+
// - 复制 backend/ 到 resources/backend/(配置了 backend 时)
|
|
14
|
+
//
|
|
15
|
+
// 产物不嵌入 HTML,页面与配置均在 exe 同目录 resources/ 下,因此壳可复用、
|
|
16
|
+
// 可跨平台分发、无需在目标机器安装任何语言运行时。
|
|
17
|
+
|
|
3
18
|
const fs = require('fs');
|
|
4
19
|
const path = require('path');
|
|
5
|
-
const os = require('os');
|
|
6
20
|
const { spawnSync } = require('child_process');
|
|
7
|
-
const {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
const { copyDir } = require('./utils');
|
|
22
|
+
const {
|
|
23
|
+
ALL_PLATFORMS,
|
|
24
|
+
isWinPlat,
|
|
25
|
+
platformExeName,
|
|
26
|
+
nativePlatform,
|
|
27
|
+
localShellPath,
|
|
28
|
+
} = require('./utils');
|
|
29
|
+
const { hasShell, downloadShell } = require('./shell');
|
|
14
30
|
|
|
15
31
|
function run(cmd, args, opts = {}) {
|
|
16
32
|
// Windows 下 npm 是 .cmd 批处理,必须经 shell 执行
|
|
@@ -29,12 +45,17 @@ function run(cmd, args, opts = {}) {
|
|
|
29
45
|
return res;
|
|
30
46
|
}
|
|
31
47
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
// --platform 解析:win|mac|linux|all 或平台 key(win-x64 等)
|
|
49
|
+
function parsePlatforms(raw) {
|
|
50
|
+
if (!raw) return [nativePlatform()];
|
|
51
|
+
const v = String(raw).toLowerCase();
|
|
52
|
+
if (v === 'all') return ALL_PLATFORMS;
|
|
53
|
+
const keyMap = { win: 'win-x64', mac: 'darwin-x64', linux: 'linux-x64' };
|
|
54
|
+
if (keyMap[v]) return [keyMap[v]];
|
|
55
|
+
if (ALL_PLATFORMS.includes(v)) return [v];
|
|
56
|
+
throw new Error(
|
|
57
|
+
`未知平台:${raw}。可选:win / mac / linux / all,或 ${ALL_PLATFORMS.join(' / ')}`
|
|
58
|
+
);
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
async function build(projectDir, opts = {}) {
|
|
@@ -47,17 +68,12 @@ async function build(projectDir, opts = {}) {
|
|
|
47
68
|
const cfg = await loadConfig(dir);
|
|
48
69
|
|
|
49
70
|
const name = (cfg.name || 'freedom-app').replace(/[^a-zA-Z0-9_.-]/g, '-');
|
|
50
|
-
const titlebar = TITLEBAR_MAP[cfg.titlebar] || 'freedom.TitleBarNative';
|
|
51
|
-
|
|
52
|
-
// 产物目录:默认 dist,可配置为 '.'(项目根目录,即 dist 的上级)或任意相对/绝对目录。
|
|
53
71
|
const outDir = String(cfg.outDir || 'dist').trim() || 'dist';
|
|
54
72
|
const outDirPath = path.resolve(dir, outDir);
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
checkGo();
|
|
73
|
+
const platforms = parsePlatforms(opts.platform);
|
|
74
|
+
const autoDownload = opts.autoDownload !== false;
|
|
59
75
|
|
|
60
|
-
// 1)
|
|
76
|
+
// 1) 前端打包
|
|
61
77
|
ensureNodeModules(dir);
|
|
62
78
|
const vite = run('npm', ['run', 'build'], { cwd: dir });
|
|
63
79
|
if (vite.status !== 0) {
|
|
@@ -67,98 +83,106 @@ async function build(projectDir, opts = {}) {
|
|
|
67
83
|
if (!fs.existsSync(distHtml)) {
|
|
68
84
|
throw new Error(`前端打包完成但未找到 ${distHtml},请检查 vite 配置(vite-plugin-singlefile)。`);
|
|
69
85
|
}
|
|
86
|
+
const html = fs.readFileSync(distHtml, 'utf8');
|
|
87
|
+
const configJSON = renderConfigJSON(cfg, name);
|
|
70
88
|
|
|
71
|
-
// 2)
|
|
72
|
-
const buildDir = path.join(dir, '.freedom', 'build');
|
|
73
|
-
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
74
|
-
fs.mkdirSync(buildDir, { recursive: true });
|
|
75
|
-
copyDir(goTemplateDir(), buildDir);
|
|
76
|
-
|
|
77
|
-
// 3) 写入前端产物与后端
|
|
78
|
-
fs.copyFileSync(distHtml, path.join(buildDir, 'pkg', 'freedom', 'assets', 'index.html'));
|
|
89
|
+
// 2) 后端目录(若配置了 backend 进程)
|
|
79
90
|
const backendDir = path.join(dir, cfg.backendDir || 'backend');
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
const hasBackend = !!(cfg.backend && fs.existsSync(backendDir));
|
|
92
|
+
|
|
93
|
+
// 3) 逐平台分发
|
|
94
|
+
// 单平台直接输出到 outDir;多平台各自放到 outDir/<plat>/ 子目录,避免互相覆盖
|
|
95
|
+
const multi = platforms.length > 1;
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const plat of platforms) {
|
|
98
|
+
const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
|
|
99
|
+
const outFile = await emitPlatform({ plat, name, targetDir, html, configJSON, backendDir, hasBackend, autoDownload });
|
|
100
|
+
results.push({ plat, outFile });
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
fs.writeFileSync(path.join(buildDir, 'gen_config.go'), genConfig, 'utf8');
|
|
103
|
+
return { results };
|
|
104
|
+
}
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const ldflags = process.platform === 'win32' ? ['-ldflags', '-H windowsgui'] : [];
|
|
101
|
-
const buildRes = run('go', ['build', ...ldflags, '-o', outFile, '.'], {
|
|
102
|
-
cwd: buildDir,
|
|
103
|
-
env: { ...process.env, CGO_ENABLED: '1' },
|
|
104
|
-
});
|
|
105
|
-
if (buildRes.status !== 0) {
|
|
106
|
-
throw new Error(`Go 编译失败:\n${buildRes.stdout}\n${buildRes.stderr}`);
|
|
106
|
+
async function emitPlatform({ plat, name, targetDir, html, configJSON, backendDir, hasBackend, autoDownload }) {
|
|
107
|
+
// 取预编译壳二进制
|
|
108
|
+
const shell = localShellPath(plat);
|
|
109
|
+
if (!fs.existsSync(shell)) {
|
|
110
|
+
if (!autoDownload) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`缺少平台 ${plat} 的壳二进制:${shell}\n` +
|
|
113
|
+
`可运行 freedom shell download ${plat} 下载,或 freedom shell build ${plat} 本地编译。`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(`[freedom] 本地无 ${plat} 壳,尝试自动下载...\n`);
|
|
117
|
+
await downloadShell(plat);
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
121
|
+
|
|
122
|
+
// 壳二进制 -> 应用可执行文件
|
|
123
|
+
const exeName = platformExeName(plat, name);
|
|
124
|
+
const outFile = path.join(targetDir, exeName);
|
|
125
|
+
fs.copyFileSync(shell, outFile);
|
|
126
|
+
if (!isWinPlat(plat)) {
|
|
127
|
+
fs.chmodSync(outFile, 0o755);
|
|
115
128
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
|
|
130
|
+
// 写 resources:页面 + 配置 + 后端
|
|
131
|
+
const resDir = path.join(targetDir, 'resources');
|
|
132
|
+
fs.mkdirSync(resDir, { recursive: true });
|
|
133
|
+
fs.writeFileSync(path.join(resDir, 'index.html'), html, 'utf8');
|
|
134
|
+
fs.writeFileSync(path.join(resDir, 'config.json'), configJSON, 'utf8');
|
|
135
|
+
if (hasBackend) {
|
|
136
|
+
copyDir(backendDir, path.join(resDir, 'backend'));
|
|
119
137
|
}
|
|
120
138
|
|
|
121
|
-
return
|
|
139
|
+
return outFile;
|
|
122
140
|
}
|
|
123
141
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
// 渲染 resources/config.json(与壳 configfile.go 的 runtimeConfigFile 字段对齐)
|
|
143
|
+
function renderConfigJSON(cfg, name) {
|
|
144
|
+
const obj = {
|
|
145
|
+
name,
|
|
146
|
+
title: cfg.title || cfg.name || name,
|
|
147
|
+
titlebar: cfg.titlebar || 'native',
|
|
148
|
+
width: intVal(cfg.width, 1024),
|
|
149
|
+
height: intVal(cfg.height, 720),
|
|
150
|
+
minWidth: intVal(cfg.minWidth, 400),
|
|
151
|
+
minHeight: intVal(cfg.minHeight, 300),
|
|
152
|
+
center: typeof cfg.center === 'boolean' ? cfg.center : true,
|
|
153
|
+
debug: typeof cfg.debug === 'boolean' ? cfg.debug : false,
|
|
154
|
+
};
|
|
155
|
+
if (cfg.backend && Array.isArray(cfg.backend.command) && cfg.backend.command.length > 0) {
|
|
156
|
+
// 兼容 command 为数组形式:command=[cmd, ...args]
|
|
157
|
+
obj.backend = { command: cfg.backend.command[0], args: cfg.backend.command.slice(1) };
|
|
158
|
+
} else if (cfg.backend && typeof cfg.backend.command === 'string' && cfg.backend.command) {
|
|
159
|
+
obj.backend = {
|
|
160
|
+
command: cfg.backend.command,
|
|
161
|
+
args: Array.isArray(cfg.backend.args) ? cfg.backend.args : [],
|
|
162
|
+
};
|
|
129
163
|
}
|
|
164
|
+
return JSON.stringify(obj, null, 2);
|
|
130
165
|
}
|
|
131
166
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
lines.push(` Debug: ${boolVal(cfg.debug, false)},`);
|
|
152
|
-
lines.push(' }');
|
|
153
|
-
lines.push('}');
|
|
154
|
-
lines.push('');
|
|
155
|
-
return lines.join('\n');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function renderBackend(cfg) {
|
|
159
|
-
// 当前版本:后端进程方式由用户在 main 模板内手动接线;
|
|
160
|
-
// gen_config 仅承载窗口配置。此处预留注释说明。
|
|
161
|
-
return '';
|
|
167
|
+
function ensureNodeModules(dir) {
|
|
168
|
+
// 缺陷修复:node_modules 存在但 package.json 更新过(依赖变更)时自动重装。
|
|
169
|
+
const pkgFile = path.join(dir, 'package.json');
|
|
170
|
+
const nmDir = path.join(dir, 'node_modules');
|
|
171
|
+
const marker = path.join(nmDir, '.package-lock.json');
|
|
172
|
+
if (!fs.existsSync(nmDir)) {
|
|
173
|
+
const res = run('npm', ['install'], { cwd: dir, stdio: 'inherit' });
|
|
174
|
+
if (res.status !== 0) throw new Error('npm install 失败。');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (fs.existsSync(pkgFile) && fs.existsSync(marker)) {
|
|
178
|
+
const pkgMtime = fs.statSync(pkgFile).mtimeMs;
|
|
179
|
+
const lockMtime = fs.statSync(marker).mtimeMs;
|
|
180
|
+
if (pkgMtime > lockMtime + 1000) {
|
|
181
|
+
process.stdout.write('[freedom] package.json 已更新,重新安装依赖...\n');
|
|
182
|
+
const res = run('npm', ['install'], { cwd: dir, stdio: 'inherit' });
|
|
183
|
+
if (res.status !== 0) throw new Error('npm install 失败。');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
162
186
|
}
|
|
163
187
|
|
|
164
188
|
function intVal(v, dft) {
|
|
@@ -166,8 +190,4 @@ function intVal(v, dft) {
|
|
|
166
190
|
return Number.isFinite(n) && n >= 0 ? n : dft;
|
|
167
191
|
}
|
|
168
192
|
|
|
169
|
-
|
|
170
|
-
return typeof v === 'boolean' ? v : dft;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
module.exports = { build };
|
|
193
|
+
module.exports = { build, parsePlatforms };
|
package/lib/cli.js
CHANGED
|
@@ -15,9 +15,13 @@ freedom - Freedom 桌面壳打包工具 v${VERSION}
|
|
|
15
15
|
|
|
16
16
|
用法:
|
|
17
17
|
freedom init [目录] [--force] 在当前/指定目录新建项目模板
|
|
18
|
-
freedom build
|
|
18
|
+
freedom build [--platform <p>] 前端打包并分发桌面应用(默认当前平台)
|
|
19
|
+
--platform win|mac|linux|all 指定目标平台(all = 三平台全量)
|
|
19
20
|
freedom titlebar <native|hidden|frameless>
|
|
20
21
|
一键切换标题栏策略
|
|
22
|
+
freedom shell list 列出本地已就绪的预编译壳
|
|
23
|
+
freedom shell download <plat> 从 GitHub Releases 下载预编译壳
|
|
24
|
+
freedom shell build <plat> 本地用 Go 编译壳(需要 Go)
|
|
21
25
|
freedom config 查看当前配置
|
|
22
26
|
freedom config get <key> 读取单个配置项
|
|
23
27
|
freedom config set <key> <value> 修改单个配置项
|
|
@@ -25,6 +29,9 @@ freedom - Freedom 桌面壳打包工具 v${VERSION}
|
|
|
25
29
|
freedom help 显示本帮助
|
|
26
30
|
freedom version 显示版本
|
|
27
31
|
|
|
32
|
+
平台(<p>/<plat>):win-x64 / darwin-x64 / darwin-arm64 / linux-x64 / linux-arm64,
|
|
33
|
+
快捷别名:win / mac / linux / all。
|
|
34
|
+
|
|
28
35
|
标题栏策略说明:
|
|
29
36
|
native 保留系统原生标题栏(默认)
|
|
30
37
|
hidden 隐藏标题栏视觉,仅保留 Windows 原生最小化/最大化/关闭按钮
|
|
@@ -62,13 +69,22 @@ async function run(argv) {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
case 'build': {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
const platArg = rest.find((a) => a.startsWith('--platform') || a.startsWith('-p'));
|
|
73
|
+
let platform;
|
|
74
|
+
if (platArg) {
|
|
75
|
+
platform = platArg.includes('=') ? platArg.split('=')[1] : rest[rest.indexOf(platArg) + 1];
|
|
76
|
+
}
|
|
77
|
+
const { results } = await build(process.cwd(), { platform });
|
|
78
|
+
for (const r of results) {
|
|
79
|
+
console.log(`[freedom] [${r.plat}] 构建完成:${r.outFile}`);
|
|
68
80
|
}
|
|
69
81
|
return 0;
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
case 'shell': {
|
|
85
|
+
return await runShell(rest);
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
case 'titlebar': {
|
|
73
89
|
const mode = rest[0];
|
|
74
90
|
if (!['native', 'hidden', 'frameless'].includes(mode)) {
|
|
@@ -127,6 +143,49 @@ function coerce(value) {
|
|
|
127
143
|
return value;
|
|
128
144
|
}
|
|
129
145
|
|
|
146
|
+
async function runShell(rest) {
|
|
147
|
+
const { listLocal, downloadShell, buildShell } = require('./shell');
|
|
148
|
+
const { ALL_PLATFORMS } = require('./utils');
|
|
149
|
+
const sub = rest[0];
|
|
150
|
+
switch (sub) {
|
|
151
|
+
case undefined:
|
|
152
|
+
case 'list': {
|
|
153
|
+
const ready = listLocal();
|
|
154
|
+
if (ready.length === 0) {
|
|
155
|
+
console.log('[freedom] 本地暂无预编译壳。');
|
|
156
|
+
} else {
|
|
157
|
+
console.log('[freedom] 本地已就绪的壳平台:');
|
|
158
|
+
for (const p of ready) console.log(` ${p}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`[freedom] 可选平台:${ALL_PLATFORMS.join(' / ')}`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
case 'download': {
|
|
164
|
+
const plat = rest[1];
|
|
165
|
+
if (!plat) {
|
|
166
|
+
console.error('[freedom] 用法:freedom shell download <win-x64|darwin-x64|darwin-arm64|linux-x64|linux-arm64>');
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
const dest = await downloadShell(plat);
|
|
170
|
+
console.log(`[freedom] 已下载 ${plat} 壳:${dest}`);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
case 'build': {
|
|
174
|
+
const plat = rest[1];
|
|
175
|
+
if (!plat) {
|
|
176
|
+
console.error('[freedom] 用法:freedom shell build <win-x64|darwin-x64|darwin-arm64|linux-x64|linux-arm64>');
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const dest = buildShell(plat);
|
|
180
|
+
console.log(`[freedom] 已编译 ${plat} 壳:${dest}`);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
console.error(`[freedom] 未知 shell 子命令:${sub}`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
130
189
|
function openBrowser(file) {
|
|
131
190
|
const { spawn } = require('child_process');
|
|
132
191
|
const plat = process.platform;
|
package/lib/shell.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 预编译壳管理:list / download / build
|
|
4
|
+
//
|
|
5
|
+
// 自 v1.1.10 起,Freedom 采用"通用预编译壳"架构:
|
|
6
|
+
// 壳二进制(freedom-shell)是三平台预编译产物,应用内容通过 exe 同目录
|
|
7
|
+
// resources/(index.html + config.json)外部加载,因此打包时用户无需任何
|
|
8
|
+
// Go / CGO / 系统编译工具链。
|
|
9
|
+
//
|
|
10
|
+
// 壳二进制的三种来源(按优先级):
|
|
11
|
+
// 1. 包内自带 shell/<plat>/freedom-shell[.exe](随 npm 包分发)
|
|
12
|
+
// 2. 远程下载 GitHub Releases 资产(freedom shell download <plat>)
|
|
13
|
+
// 3. 本地编译 freedom shell build <plat>(需要 Go + 对应平台编译环境)
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { spawnSync } = require('child_process');
|
|
19
|
+
const {
|
|
20
|
+
shellDir,
|
|
21
|
+
goTemplateDir,
|
|
22
|
+
ALL_PLATFORMS,
|
|
23
|
+
SHELL_EXE_NAME,
|
|
24
|
+
localShellPath,
|
|
25
|
+
} = require('./utils');
|
|
26
|
+
|
|
27
|
+
// GitHub Releases 下载源(可用环境变量覆盖)。
|
|
28
|
+
// 资产命名约定:freedom-shell-<plat>(单文件二进制,不压缩)。
|
|
29
|
+
function releaseRepo() {
|
|
30
|
+
return process.env.FREEDOM_SHELL_REPO || 'yufengtadian/freedom';
|
|
31
|
+
}
|
|
32
|
+
function releaseTag() {
|
|
33
|
+
return process.env.FREEDOM_SHELL_TAG || 'v1.1.10';
|
|
34
|
+
}
|
|
35
|
+
function releaseUrl(plat) {
|
|
36
|
+
return `https://github.com/${releaseRepo()}/releases/download/${releaseTag()}/freedom-shell-${plat}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 列出本地已就绪的壳平台
|
|
40
|
+
function listLocal() {
|
|
41
|
+
const dir = shellDir();
|
|
42
|
+
const ready = [];
|
|
43
|
+
if (fs.existsSync(dir)) {
|
|
44
|
+
for (const name of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
if (!name.isDirectory() || !ALL_PLATFORMS.includes(name.name)) continue;
|
|
46
|
+
const exe = SHELL_EXE_NAME[name.name] || 'freedom-shell';
|
|
47
|
+
if (fs.existsSync(path.join(dir, name.name, exe))) {
|
|
48
|
+
ready.push(name.name);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return ready;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 是否存在指定平台本地壳
|
|
56
|
+
function hasShell(plat) {
|
|
57
|
+
return fs.existsSync(localShellPath(plat));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 下载指定平台壳到包内 shell/<plat>/
|
|
61
|
+
// 返回下载后的绝对路径;失败抛错。
|
|
62
|
+
async function downloadShell(plat) {
|
|
63
|
+
if (!ALL_PLATFORMS.includes(plat)) {
|
|
64
|
+
throw new Error(`未知平台:${plat}。可选:${ALL_PLATFORMS.join(' / ')}`);
|
|
65
|
+
}
|
|
66
|
+
const url = releaseUrl(plat);
|
|
67
|
+
const dest = localShellPath(plat);
|
|
68
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
69
|
+
|
|
70
|
+
process.stdout.write(`[freedom] 下载壳 ${plat} <- ${url}\n`);
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
// 30s 超时:网络挂起时明确报错,避免构建进程无限阻塞
|
|
74
|
+
res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30000) });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
77
|
+
throw new Error(`下载壳 ${plat} 超时(30s)。请检查网络后重试,或手动将壳二进制放入 ${localShellPath(plat)}。`);
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`下载壳 ${plat} 失败:${e.message}。请检查网络,或手动将壳二进制放入 ${localShellPath(plat)}。`);
|
|
80
|
+
}
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`下载壳失败:HTTP ${res.status}。请确认 GitHub 仓库 ${releaseRepo()} 已发布 ` +
|
|
84
|
+
`${releaseTag()} 的资产 freedom-shell-${plat},或手动将壳二进制放入 ${localShellPath(plat)}。`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
88
|
+
fs.writeFileSync(dest, buf);
|
|
89
|
+
if (process.platform !== 'win32') {
|
|
90
|
+
fs.chmodSync(dest, 0o755);
|
|
91
|
+
}
|
|
92
|
+
return dest;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 本地用 Go 编译指定平台壳(需要 Go + 该平台编译环境)。
|
|
96
|
+
// Windows 产物以 GUI 子系统编译(-H windowsgui),运行时无 cmd 黑窗。
|
|
97
|
+
function buildShell(plat) {
|
|
98
|
+
if (!ALL_PLATFORMS.includes(plat)) {
|
|
99
|
+
throw new Error(`未知平台:${plat}。可选:${ALL_PLATFORMS.join(' / ')}`);
|
|
100
|
+
}
|
|
101
|
+
const res = spawnSync('go', ['version'], { encoding: 'utf8' });
|
|
102
|
+
if (res.error || res.status !== 0) {
|
|
103
|
+
throw new Error('未检测到 Go 工具链。请先安装 Go(https://go.dev/dl/),或改用 freedom shell download。');
|
|
104
|
+
}
|
|
105
|
+
const dest = localShellPath(plat);
|
|
106
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
107
|
+
|
|
108
|
+
// 本机只能编译本机平台(webview_go 依赖系统 WebView 框架,无法交叉编译)。
|
|
109
|
+
const native = nativePlatformKey();
|
|
110
|
+
if (plat !== native) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`无法在本机(${native})交叉编译 ${plat}:webview_go 依赖系统 WebView 框架。` +
|
|
113
|
+
`请在目标平台执行 freedom shell build ${plat},或用 freedom shell download ${plat} 拉取 CI 预编译产物。`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const buildDir = goTemplateDir();
|
|
118
|
+
const ldflags = plat.startsWith('win') ? ['-ldflags', '-H windowsgui'] : [];
|
|
119
|
+
const build = spawnSync('go', ['build', ...ldflags, '-o', dest, '.'], {
|
|
120
|
+
cwd: buildDir,
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
env: { ...process.env, CGO_ENABLED: '1' },
|
|
123
|
+
});
|
|
124
|
+
if (build.error || build.status !== 0) {
|
|
125
|
+
throw new Error(`Go 编译失败:\n${build.stdout}\n${build.stderr}`);
|
|
126
|
+
}
|
|
127
|
+
return dest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function nativePlatformKey() {
|
|
131
|
+
const plat = process.platform;
|
|
132
|
+
const arch = process.arch;
|
|
133
|
+
if (plat === 'win32') return 'win-x64';
|
|
134
|
+
if (plat === 'darwin') return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
|
135
|
+
if (plat === 'linux') return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
|
|
136
|
+
return `${plat}-${arch}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { listLocal, hasShell, downloadShell, buildShell, releaseRepo, releaseTag };
|
package/lib/utils.js
CHANGED
|
@@ -21,6 +21,46 @@ function goTemplateDir() {
|
|
|
21
21
|
return path.join(templateDir(), 'go');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function shellDir() {
|
|
25
|
+
return path.join(PKG_ROOT, 'shell');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 平台矩阵:key -> { exe 文件名(壳二进制名) }
|
|
29
|
+
// 通用壳为三平台预编译二进制,应用内容通过 exe 同目录 resources/ 外部加载,
|
|
30
|
+
// 因此同一壳可复用于任意应用,打包时无需任何语言工具链。
|
|
31
|
+
const ALL_PLATFORMS = ['win-x64', 'darwin-x64', 'darwin-arm64', 'linux-x64', 'linux-arm64'];
|
|
32
|
+
|
|
33
|
+
const SHELL_EXE_NAME = {
|
|
34
|
+
'win-x64': 'freedom-shell.exe',
|
|
35
|
+
'darwin-x64': 'freedom-shell',
|
|
36
|
+
'darwin-arm64': 'freedom-shell',
|
|
37
|
+
'linux-x64': 'freedom-shell',
|
|
38
|
+
'linux-arm64': 'freedom-shell',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isWinPlat(plat) {
|
|
42
|
+
return plat.startsWith('win');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 平台 key -> 产物可执行文件名(Windows 带 .exe,其余无扩展名)
|
|
46
|
+
function platformExeName(plat, appName) {
|
|
47
|
+
return isWinPlat(plat) ? `${appName}.exe` : appName;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 把 node 的 process.platform / process.arch 映射为平台 key
|
|
51
|
+
function nativePlatform() {
|
|
52
|
+
const plat = process.platform;
|
|
53
|
+
const arch = process.arch;
|
|
54
|
+
if (plat === 'win32') return 'win-x64';
|
|
55
|
+
if (plat === 'darwin') return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
|
56
|
+
if (plat === 'linux') return arch === 'arm64' ? 'linux-arm64' : 'linux-x64';
|
|
57
|
+
return `${plat}-${arch}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function localShellPath(plat) {
|
|
61
|
+
return path.join(shellDir(), plat, SHELL_EXE_NAME[plat] || 'freedom-shell');
|
|
62
|
+
}
|
|
63
|
+
|
|
24
64
|
function tutorialFile() {
|
|
25
65
|
return path.join(PKG_ROOT, 'tutorial', 'tutorial.html');
|
|
26
66
|
}
|
|
@@ -62,6 +102,13 @@ module.exports = {
|
|
|
62
102
|
templateDir,
|
|
63
103
|
projectTemplateDir,
|
|
64
104
|
goTemplateDir,
|
|
105
|
+
shellDir,
|
|
106
|
+
ALL_PLATFORMS,
|
|
107
|
+
SHELL_EXE_NAME,
|
|
108
|
+
isWinPlat,
|
|
109
|
+
platformExeName,
|
|
110
|
+
nativePlatform,
|
|
111
|
+
localShellPath,
|
|
65
112
|
tutorialFile,
|
|
66
113
|
loadConfig,
|
|
67
114
|
hasConfig,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yufengtadian/freedom-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Freedom WebView desktop shell packaging tool -
|
|
5
|
-
"keywords": ["desktop", "webview", "electron-alternative", "wails", "tauri", "
|
|
3
|
+
"version": "1.1.10",
|
|
4
|
+
"description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
|
|
5
|
+
"keywords": ["desktop", "webview", "electron-alternative", "wails", "tauri", "frontend", "cross-platform"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
15
15
|
"lib",
|
|
16
|
+
"shell",
|
|
16
17
|
"templates",
|
|
17
18
|
"tutorial",
|
|
18
19
|
"postinstall.js",
|
|
Binary file
|
|
@@ -2,9 +2,12 @@ package main
|
|
|
2
2
|
|
|
3
3
|
import "freedom-cli-shell/pkg/freedom"
|
|
4
4
|
|
|
5
|
-
// appConfig
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// appConfig 返回应用窗口配置(编译期默认值)。
|
|
6
|
+
//
|
|
7
|
+
// 通用壳在运行时还会读取 exe 同目录 resources/config.json(由 freedom CLI build
|
|
8
|
+
// 阶段根据 freedom.config.js 生成),其中的字段会覆盖本默认值。因此:
|
|
9
|
+
// - 使用预编译通用壳时,本文件中的值只是兜底,无需手动修改;
|
|
10
|
+
// - 若通过 go build 直接编译本项目(不使用 CLI),此处即为最终配置。
|
|
8
11
|
func appConfig() freedom.Config {
|
|
9
12
|
return freedom.Config{
|
|
10
13
|
Title: "Freedom App",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Freedom</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body { height: 100%; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
12
|
+
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
|
13
|
+
color: #e2e8f0;
|
|
14
|
+
min-height: 100vh;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
user-select: none;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
}
|
|
20
|
+
.main {
|
|
21
|
+
flex: 1;
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: center;
|
|
26
|
+
gap: 18px;
|
|
27
|
+
}
|
|
28
|
+
.logo {
|
|
29
|
+
width: 84px; height: 84px;
|
|
30
|
+
border-radius: 22px;
|
|
31
|
+
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
32
|
+
display: flex; align-items: center; justify-content: center;
|
|
33
|
+
font-size: 40px; font-weight: 800; color: #fff;
|
|
34
|
+
box-shadow: 0 18px 40px rgba(99,102,241,.35);
|
|
35
|
+
}
|
|
36
|
+
h1 { font-size: 26px; font-weight: 700; letter-spacing: 1px; }
|
|
37
|
+
.sub { color: #94a3b8; font-size: 14px; }
|
|
38
|
+
.status {
|
|
39
|
+
margin-top: 8px;
|
|
40
|
+
padding: 8px 16px;
|
|
41
|
+
border-radius: 999px;
|
|
42
|
+
background: rgba(99,102,241,.15);
|
|
43
|
+
border: 1px solid rgba(99,102,241,.4);
|
|
44
|
+
color: #a5b4fc;
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* 无边框模式:前端自绘标题栏(hidden / frameless 时显示) */
|
|
49
|
+
.titlebar {
|
|
50
|
+
display: none;
|
|
51
|
+
height: 36px;
|
|
52
|
+
flex: 0 0 36px;
|
|
53
|
+
background: rgba(15, 23, 42, .92);
|
|
54
|
+
border-bottom: 1px solid rgba(148,163,184,.12);
|
|
55
|
+
align-items: center;
|
|
56
|
+
justify-content: space-between;
|
|
57
|
+
padding-left: 12px;
|
|
58
|
+
-webkit-app-region: drag; /* WebView2 支持:整条标题栏可拖拽窗口 */
|
|
59
|
+
}
|
|
60
|
+
.titlebar .tb-title { font-size: 13px; color: #cbd5e1; letter-spacing: .5px; }
|
|
61
|
+
.tb-btns { display: flex; height: 100%; -webkit-app-region: no-drag; }
|
|
62
|
+
.tb-btn {
|
|
63
|
+
width: 46px; height: 100%;
|
|
64
|
+
display: flex; align-items: center; justify-content: center;
|
|
65
|
+
border: none; background: transparent;
|
|
66
|
+
color: #cbd5e1; font-size: 12px; cursor: default;
|
|
67
|
+
font-family: "Segoe MDL2 Assets", "Segoe UI Symbol", sans-serif;
|
|
68
|
+
}
|
|
69
|
+
.tb-btn:hover { background: rgba(148,163,184,.18); color: #fff; }
|
|
70
|
+
.tb-btn.close:hover { background: #e81123; color: #fff; }
|
|
71
|
+
body.freedom-frameless .titlebar { display: flex; }
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="titlebar" id="titlebar">
|
|
76
|
+
<div class="tb-title" id="tbTitle">Freedom</div>
|
|
77
|
+
<div class="tb-btns">
|
|
78
|
+
<button class="tb-btn" id="minBtn" title="最小化"></button>
|
|
79
|
+
<button class="tb-btn" id="maxBtn" title="最大化"></button>
|
|
80
|
+
<button class="tb-btn close" id="closeBtn" title="关闭"></button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="main">
|
|
84
|
+
<div class="logo">F</div>
|
|
85
|
+
<h1>Freedom</h1>
|
|
86
|
+
<div class="sub">WebView 桌面壳子框架 · 默认占位页</div>
|
|
87
|
+
<div class="status" id="status">初始化中...</div>
|
|
88
|
+
</div>
|
|
89
|
+
<script>
|
|
90
|
+
// 无边框模式:显示自绘标题栏并绑定窗口控制
|
|
91
|
+
if (window.freedom && window.freedom.window) {
|
|
92
|
+
window.freedom.window.isFrameless().then(function (frameless) {
|
|
93
|
+
if (frameless) {
|
|
94
|
+
document.body.classList.add('freedom-frameless');
|
|
95
|
+
window.freedom.window.bindButtons({ min: '#minBtn', max: '#maxBtn', close: '#closeBtn' });
|
|
96
|
+
}
|
|
97
|
+
}).catch(function () { /* 非桌面环境忽略 */ });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 验证 IPC 双向桥接链路(框架内置 __freedom__ping)
|
|
101
|
+
window.freedom.call('__freedom__ping').then(function (r) {
|
|
102
|
+
document.getElementById('status').textContent = '桥接正常 · ' + r;
|
|
103
|
+
}).catch(function (e) {
|
|
104
|
+
document.getElementById('status').textContent = '桥接异常:' + e;
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
107
|
+
</body>
|
|
108
|
+
</html>
|
|
@@ -8,8 +8,11 @@ import _ "embed"
|
|
|
8
8
|
//go:embed assets/freedom.js
|
|
9
9
|
var jsSDK string
|
|
10
10
|
|
|
11
|
-
// indexHTML
|
|
12
|
-
//
|
|
11
|
+
// indexHTML 是内置的默认占位页。
|
|
12
|
+
//
|
|
13
|
+
// 预编译通用壳不嵌入应用专属页面:应用 HTML 来自 exe 同目录 resources/index.html
|
|
14
|
+
//(freedom CLI build 时写入,见 resolveHTML 的加载优先级)。仅当外部页面缺失时
|
|
15
|
+
// 回退到本占位页,保证"直接运行壳"也有可见 UI。
|
|
13
16
|
//
|
|
14
17
|
//go:embed assets/index.html
|
|
15
18
|
var indexHTML string
|
|
@@ -70,6 +70,16 @@ func NewProcBackend(command ...string) *ProcBackend {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// SetDir 设置后端进程的工作目录。
|
|
74
|
+
// 相对路径参数(如 "backend/main.mjs")将相对该目录解析。
|
|
75
|
+
// 通用壳在加载 resources/config.json 时自动设为 resources 目录。
|
|
76
|
+
func (p *ProcBackend) SetDir(dir string) *ProcBackend {
|
|
77
|
+
if dir != "" {
|
|
78
|
+
p.cmd.Dir = dir
|
|
79
|
+
}
|
|
80
|
+
return p
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
// SetTimeout 设置单次调用的最大等待时间(默认 60s)。<=0 表示不超时。
|
|
74
84
|
func (p *ProcBackend) SetTimeout(d time.Duration) *ProcBackend {
|
|
75
85
|
p.timeout = d
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package freedom
|
|
2
|
+
|
|
3
|
+
// 运行时外部资源(resources/)加载逻辑。
|
|
4
|
+
//
|
|
5
|
+
// 通用壳二进制(预编译)本身不嵌入任何应用专属的前端与配置;
|
|
6
|
+
// 应用内容来自 exe 同目录的 resources/ 目录:
|
|
7
|
+
// resources/index.html 前端页面(CLI build 写入)
|
|
8
|
+
// resources/config.json 窗口与后端配置(CLI build 写入)
|
|
9
|
+
//
|
|
10
|
+
// 外部资源缺失时静默回退:配置使用编译期/默认值,页面使用内置占位页。
|
|
11
|
+
// 因此同一份壳二进制可以复用于任意应用,且打包时无需任何语言工具链。
|
|
12
|
+
|
|
13
|
+
import (
|
|
14
|
+
"encoding/json"
|
|
15
|
+
"os"
|
|
16
|
+
"path/filepath"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// runtimeBackend 描述 config.json 中的后端进程配置(任意语言,经 stdio NDJSON 桥接)。
|
|
20
|
+
type runtimeBackend struct {
|
|
21
|
+
Command string `json:"command"`
|
|
22
|
+
Args []string `json:"args"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// runtimeConfigFile 描述 resources/config.json 的磁盘结构。
|
|
26
|
+
// 由 freedom CLI 在 build 阶段根据 freedom.config.js 生成。
|
|
27
|
+
type runtimeConfigFile struct {
|
|
28
|
+
Name string `json:"name"`
|
|
29
|
+
Title string `json:"title"`
|
|
30
|
+
TitleBar string `json:"titlebar"`
|
|
31
|
+
Width int `json:"width"`
|
|
32
|
+
Height int `json:"height"`
|
|
33
|
+
MinWidth int `json:"minWidth"`
|
|
34
|
+
MinHeight int `json:"minHeight"`
|
|
35
|
+
Center *bool `json:"center"`
|
|
36
|
+
Debug *bool `json:"debug"`
|
|
37
|
+
Backend *runtimeBackend `json:"backend"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// resourcesDir 返回 exe 同目录的 resources 目录绝对路径。
|
|
41
|
+
func resourcesDir() (string, error) {
|
|
42
|
+
exe, err := os.Executable()
|
|
43
|
+
if err != nil {
|
|
44
|
+
return "", err
|
|
45
|
+
}
|
|
46
|
+
return filepath.Join(filepath.Dir(exe), "resources"), nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// loadRuntimeConfig 读取 exe 同目录 resources/config.json,将命中的字段覆盖到应用配置。
|
|
50
|
+
// 文件不存在或内容非法时静默回退(返回 nil),保持编译期/默认配置不变。
|
|
51
|
+
func (a *App) loadRuntimeConfig() error {
|
|
52
|
+
dir, err := resourcesDir()
|
|
53
|
+
if err != nil {
|
|
54
|
+
return nil
|
|
55
|
+
}
|
|
56
|
+
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
|
57
|
+
if err != nil {
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
60
|
+
var rc runtimeConfigFile
|
|
61
|
+
if err := json.Unmarshal(data, &rc); err != nil {
|
|
62
|
+
return nil
|
|
63
|
+
}
|
|
64
|
+
if rc.Title != "" {
|
|
65
|
+
a.cfg.Title = rc.Title
|
|
66
|
+
} else if rc.Name != "" {
|
|
67
|
+
a.cfg.Title = rc.Name
|
|
68
|
+
}
|
|
69
|
+
switch rc.TitleBar {
|
|
70
|
+
case "native":
|
|
71
|
+
a.cfg.TitleBar = TitleBarNative
|
|
72
|
+
case "hidden":
|
|
73
|
+
a.cfg.TitleBar = TitleBarHidden
|
|
74
|
+
case "frameless":
|
|
75
|
+
a.cfg.TitleBar = TitleBarFrameless
|
|
76
|
+
}
|
|
77
|
+
if rc.Width > 0 {
|
|
78
|
+
a.cfg.Width = rc.Width
|
|
79
|
+
}
|
|
80
|
+
if rc.Height > 0 {
|
|
81
|
+
a.cfg.Height = rc.Height
|
|
82
|
+
}
|
|
83
|
+
if rc.MinWidth > 0 {
|
|
84
|
+
a.cfg.MinWidth = rc.MinWidth
|
|
85
|
+
}
|
|
86
|
+
if rc.MinHeight > 0 {
|
|
87
|
+
a.cfg.MinHeight = rc.MinHeight
|
|
88
|
+
}
|
|
89
|
+
if rc.Center != nil {
|
|
90
|
+
a.cfg.Center = *rc.Center
|
|
91
|
+
}
|
|
92
|
+
if rc.Debug != nil {
|
|
93
|
+
a.cfg.Debug = *rc.Debug
|
|
94
|
+
}
|
|
95
|
+
// 外部后端进程配置:仅在壳未显式绑定后端时生效。
|
|
96
|
+
// 工作目录设为 resources 目录,使相对路径参数(backend/main.mjs)按
|
|
97
|
+
// resources/ 解析(CLI 会把项目 backend/ 目录分发到 resources/backend/)。
|
|
98
|
+
if rc.Backend != nil && rc.Backend.Command != "" && !a.backendExplicit {
|
|
99
|
+
args := append([]string{rc.Backend.Command}, rc.Backend.Args...)
|
|
100
|
+
pb := NewProcBackend(args...)
|
|
101
|
+
if dir, err := resourcesDir(); err == nil && dir != "" {
|
|
102
|
+
pb.SetDir(dir)
|
|
103
|
+
}
|
|
104
|
+
a.cfg.Backend = pb
|
|
105
|
+
a.backend = pb
|
|
106
|
+
}
|
|
107
|
+
return nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// loadRuntimeHTML 读取 exe 同目录 resources/index.html;文件不存在时返回空串。
|
|
111
|
+
func loadRuntimeHTML() (string, error) {
|
|
112
|
+
dir, err := resourcesDir()
|
|
113
|
+
if err != nil {
|
|
114
|
+
return "", err
|
|
115
|
+
}
|
|
116
|
+
data, err := os.ReadFile(filepath.Join(dir, "index.html"))
|
|
117
|
+
if err != nil {
|
|
118
|
+
return "", err
|
|
119
|
+
}
|
|
120
|
+
return string(data), nil
|
|
121
|
+
}
|
|
@@ -65,10 +65,11 @@ type Config struct {
|
|
|
65
65
|
|
|
66
66
|
// App 是 Freedom 应用实例。
|
|
67
67
|
type App struct {
|
|
68
|
-
cfg
|
|
69
|
-
view
|
|
70
|
-
backend
|
|
71
|
-
|
|
68
|
+
cfg Config
|
|
69
|
+
view webview.WebView
|
|
70
|
+
backend Backend
|
|
71
|
+
backendExplicit bool // 调用方是否显式指定了后端(外部 config.json 不应覆盖显式绑定)
|
|
72
|
+
onReady func(a *App)
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
// New 创建并初始化一个 Freedom 应用。调用 Run() 之前不会显示窗口。
|
|
@@ -85,10 +86,11 @@ func New(cfg Config) *App {
|
|
|
85
86
|
if cfg.Height <= 0 {
|
|
86
87
|
cfg.Height = 768
|
|
87
88
|
}
|
|
88
|
-
|
|
89
|
+
explicit := cfg.Backend != nil
|
|
90
|
+
if !explicit {
|
|
89
91
|
cfg.Backend = NewEmbedBackend()
|
|
90
92
|
}
|
|
91
|
-
return &App{cfg: cfg, backend: cfg.Backend}
|
|
93
|
+
return &App{cfg: cfg, backend: cfg.Backend, backendExplicit: explicit}
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
// OnReady 注册一个回调,在窗口与桥接层就绪、页面加载前执行。
|
|
@@ -121,6 +123,10 @@ func (a *App) Unbind(name string) {
|
|
|
121
123
|
|
|
122
124
|
// Run 启动窗口并进入主事件循环,阻塞直到窗口被关闭。
|
|
123
125
|
func (a *App) Run() {
|
|
126
|
+
// 通用壳:先加载 exe 同目录 resources/config.json 覆盖窗口与后端配置
|
|
127
|
+
//(CLI build 时写入;缺失则使用编译期/默认配置)。
|
|
128
|
+
_ = a.loadRuntimeConfig()
|
|
129
|
+
|
|
124
130
|
html, err := a.resolveHTML()
|
|
125
131
|
if err != nil {
|
|
126
132
|
fmt.Printf("freedom: failed to resolve HTML: %v\n", err)
|
|
@@ -249,7 +255,11 @@ func (a *App) windowControl(action string) (interface{}, error) {
|
|
|
249
255
|
}
|
|
250
256
|
|
|
251
257
|
// resolveHTML 依据配置返回页面内容。
|
|
258
|
+
// 优先级:exe 同目录 resources/index.html(预编译通用壳,CLI build 写入)> cfg.HTML > 内置占位页。
|
|
252
259
|
func (a *App) resolveHTML() (string, error) {
|
|
260
|
+
if html, err := loadRuntimeHTML(); err == nil && html != "" {
|
|
261
|
+
return html, nil
|
|
262
|
+
}
|
|
253
263
|
if a.cfg.HTML != nil {
|
|
254
264
|
return a.cfg.HTML()
|
|
255
265
|
}
|
package/tutorial/tutorial.html
CHANGED
|
@@ -55,8 +55,13 @@ npm install</code></pre>
|
|
|
55
55
|
<p>也可以直接编辑 <code>freedom.config.js</code> 里的 <code>titlebar</code> 字段。</p>
|
|
56
56
|
|
|
57
57
|
<h2>3. 打包</h2>
|
|
58
|
-
<pre><code>freedom build
|
|
59
|
-
|
|
58
|
+
<pre><code>freedom build
|
|
59
|
+
freedom build --platform all # 三平台全量(win + mac + linux)
|
|
60
|
+
freedom build --platform win-x64 # 仅 Windows
|
|
61
|
+
freedom build --platform mac-arm64 # 仅 macOS Apple Silicon
|
|
62
|
+
freedom build --platform linux-x64 # 仅 Linux</code></pre>
|
|
63
|
+
<p>前端经 Vite 打成单文件 HTML 后写入 <code>dist/resources/</code>,与随包分发的预编译壳二进制合并出可执行文件。不再依赖本机 Go 工具链,任一平台都能一键产出三平台产物。产物以 GUI 子系统运行,不会弹出 cmd 黑窗。</p>
|
|
64
|
+
<p><code>resources/</code> 内含壳运行时加载的前端页面 <code>index.html</code> 与配置 <code>config.json</code>;声明了 <code>backend</code> 时 <code>backend/</code> 目录会被原样分发到 <code>resources/backend/</code>,壳启动后自动以 <code>resources/</code> 为工作目录拉起后端进程(黑窗同样隐藏)。</p>
|
|
60
65
|
|
|
61
66
|
<h2>3.5 调整产物目录(终端一键切换)</h2>
|
|
62
67
|
<pre><code>freedom config set outDir . # 输出到项目根目录(dist 的上级)
|