@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 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/`,Windows 下为 `dist/<应用名>.exe`。产物为 GUI 子系统程序,运行时不会弹出 cmd 黑窗。
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 工具链(编译壳层,需支持 CGO;Windows 下需要 MSVC 编译环境与 WebView2 运行时,Win10/11 自带)
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 { goTemplateDir, copyDir, goJSONString } = require('./utils');
8
-
9
- const TITLEBAR_MAP = {
10
- native: 'freedom.TitleBarNative',
11
- hidden: 'freedom.TitleBarHidden',
12
- frameless: 'freedom.TitleBarFrameless',
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
- function checkGo() {
33
- const res = run('go', ['version']);
34
- if (res.status !== 0) {
35
- throw new Error('未检测到 Go 工具链。请先安装 Go(https://go.dev/dl/),并确保 go 命令在 PATH 中。');
36
- }
37
- return res.stdout.trim();
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 exeName = os.platform() === 'win32' ? `${name}.exe` : name;
56
- const outFile = path.join(outDirPath, exeName);
57
-
58
- checkGo();
73
+ const platforms = parsePlatforms(opts.platform);
74
+ const autoDownload = opts.autoDownload !== false;
59
75
 
60
- // 1) 前端打包:npm install(如缺依赖)+ vite build -> .freedom/vite-dist/index.html
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
- if (cfg.backend && fs.existsSync(backendDir)) {
81
- copyDir(backendDir, path.join(buildDir, 'backend'));
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
- // 4) 生成 gen_config.go
85
- const genConfig = renderGenConfig(cfg, name, titlebar);
86
- fs.writeFileSync(path.join(buildDir, 'gen_config.go'), genConfig, 'utf8');
103
+ return { results };
104
+ }
87
105
 
88
- // 5) 编译壳层
89
- fs.mkdirSync(outDirPath, { recursive: true });
90
- if (process.env.FREEDOM_SKIP_CGO) {
91
- console.log(`[freedom] FREEDOM_SKIP_CGO=1,跳过 CGO 编译,壳层源码保留在 .freedom/build。`);
92
- return { outFile: null, buildDir };
93
- }
94
- const tidy = run('go', ['mod', 'tidy'], { cwd: buildDir });
95
- if (tidy.status !== 0) {
96
- throw new Error(`go mod tidy 失败:\n${tidy.stdout}\n${tidy.stderr}`);
97
- }
98
- // Windows GUI 子系统编译(-H windowsgui),运行时不弹出 cmd 黑窗。
99
- // 注意:go 的 flag 必须在位置参数('.')之前,否则会被当作 import path。
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
- // 6) 产物分发到 outDir:
110
- // - exe:已输出到 outDir;
111
- // - 单文件 index.html:outDir 非项目根目录时才写入(避免覆盖项目源文件);
112
- // - backend:随应用一起分发。
113
- if (path.resolve(outDirPath) !== dir) {
114
- fs.copyFileSync(distHtml, path.join(outDirPath, 'index.html'));
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
- if (cfg.backend && fs.existsSync(backendDir)) {
117
- fs.mkdirSync(path.join(outDirPath, 'backend'), { recursive: true });
118
- copyDir(backendDir, path.join(outDirPath, 'backend'));
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 { outFile, buildDir };
139
+ return outFile;
122
140
  }
123
141
 
124
- function ensureNodeModules(dir) {
125
- if (fs.existsSync(path.join(dir, 'node_modules'))) return;
126
- const res = run('npm', ['install'], { cwd: dir, stdio: 'inherit' });
127
- if (res.status !== 0) {
128
- throw new Error('npm install 失败。');
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 renderGenConfig(cfg, name, titlebar) {
133
- const backendBlock = renderBackend(cfg);
134
- const lines = [];
135
- lines.push('package main');
136
- lines.push('');
137
- lines.push('import "freedom-cli-shell/pkg/freedom"');
138
- lines.push('');
139
- lines.push('// appConfig 返回应用窗口配置。');
140
- lines.push('// 本文件由 freedom CLI 在 build 阶段根据 freedom.config.js 生成,');
141
- lines.push('// 不要手动修改(手动修改会在下次 build 时被覆盖)。');
142
- lines.push('func appConfig() freedom.Config {');
143
- lines.push(' return freedom.Config{');
144
- lines.push(` Title: ${goJSONString(cfg.name || name)},`);
145
- lines.push(` TitleBar: ${titlebar},`);
146
- lines.push(` Width: ${intVal(cfg.width, 1024)},`);
147
- lines.push(` Height: ${intVal(cfg.height, 720)},`);
148
- lines.push(` MinWidth: ${intVal(cfg.minWidth, 0)},`);
149
- lines.push(` MinHeight: ${intVal(cfg.minHeight, 0)},`);
150
- lines.push(` Center: ${boolVal(cfg.center, true)},`);
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
- function boolVal(v, dft) {
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 前端打包并编译出桌面应用(产物在 dist/)
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 { outFile } = await build(process.cwd());
66
- if (outFile) {
67
- console.log(`[freedom] 构建完成:${outFile}`);
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.0.0",
4
- "description": "Freedom WebView desktop shell packaging tool - embed any web frontend into a cross-platform desktop app",
5
- "keywords": ["desktop", "webview", "electron-alternative", "wails", "tauri", "go", "frontend"],
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
- // 本文件由 freedom CLI 在 build 阶段根据 freedom.config.js 重新生成,
7
- // 不要手动修改(手动修改会在下次 build 时被覆盖)。
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,2 @@
1
+ github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
2
+ github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
@@ -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="最小化">&#xE921;</button>
79
+ <button class="tb-btn" id="maxBtn" title="最大化">&#xE922;</button>
80
+ <button class="tb-btn close" id="closeBtn" title="关闭">&#xE8BB;</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 是前端打包产物(Vite 单文件 HTML),build 阶段写入 assets/index.html,
12
- // 运行时通过 SetHtml 从内存加载。
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 Config
69
- view webview.WebView
70
- backend Backend
71
- onReady func(a *App)
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
- if cfg.Backend == nil {
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
  }
@@ -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</code></pre>
59
- <p>前端会先经 Vite 打成单文件 HTML,再嵌入 Go 壳层编译出可执行文件。产物默认在 <code>dist/</code> 目录,且以 GUI 子系统编译,运行时不会弹出 cmd 黑窗。</p>
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 的上级)