@yufengtadian/freedom-cli 1.1.10 → 1.1.11
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 +18 -6
- package/lib/build.js +82 -17
- package/lib/cli.js +6 -1
- package/lib/tui.js +331 -0
- package/package.json +2 -2
- package/shell/darwin-arm64/freedom-shell +0 -0
- package/shell/darwin-x64/freedom-shell +0 -0
- package/shell/linux-arm64/freedom-shell +0 -0
- package/shell/linux-x64/freedom-shell +0 -0
package/README.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# freedom-cli
|
|
2
2
|
|
|
3
|
-
Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.1.
|
|
3
|
+
Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.1.11)。
|
|
4
4
|
|
|
5
5
|
基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
|
|
6
6
|
|
|
7
|
-
**v1.1.
|
|
7
|
+
**v1.1.11 核心升级**:不再依赖 Go 工具链。壳层以预编译二进制随 npm 包分发(`shell/<platform>/`),`freedom build` 直接复制壳 + 写入前端与配置即可出包,Windows / macOS / Linux 三平台一次全出,任意前后端语言自由组合。
|
|
8
|
+
|
|
9
|
+
**v1.1.11 新特性**:
|
|
10
|
+
- `freedom tui` 交互式终端界面:菜单化完成打包 / 新建项目 / 修改配置 / 壳管理 / 教程,零依赖、方向键 + 回车导航、支持搜索过滤与多选;
|
|
11
|
+
- 前端构建缓存:源码无改动时跳过重复 `vite build`(秒级复用,典型加速 10 倍+),`--no-cache` 强制全量重建;
|
|
12
|
+
- 多平台分发并行化:`--platform all` 各平台同时复制分发,全平台打包耗时显著缩短。
|
|
8
13
|
|
|
9
14
|
## 安装
|
|
10
15
|
|
|
@@ -22,22 +27,28 @@ freedom init my-app
|
|
|
22
27
|
cd my-app
|
|
23
28
|
npm install
|
|
24
29
|
|
|
25
|
-
# 2.
|
|
30
|
+
# 2. 交互式终端界面(方向键 + 回车选择,q 返回)
|
|
31
|
+
freedom tui
|
|
32
|
+
|
|
33
|
+
# 3. 调整标题栏(可选,随时可改)
|
|
26
34
|
freedom titlebar native # 保留系统原生标题栏(默认)
|
|
27
35
|
freedom titlebar hidden # 隐藏标题栏视觉,仅保留 Windows 原生最小化/最大化/关闭按钮
|
|
28
36
|
freedom titlebar frameless # 完全无边框,按钮由前端自绘(模板已内置示例)
|
|
29
37
|
|
|
30
|
-
#
|
|
38
|
+
# 4. 打包
|
|
31
39
|
freedom build # 默认当前平台
|
|
32
40
|
freedom build --platform all # 三平台全量(win + mac + linux)
|
|
33
41
|
freedom build --platform win-x64 # 仅 Windows
|
|
34
42
|
freedom build --platform mac-arm64 # 仅 macOS Apple Silicon
|
|
35
43
|
freedom build --platform linux-x64 # 仅 Linux
|
|
44
|
+
freedom build --no-cache # 忽略前端构建缓存,强制重建
|
|
36
45
|
```
|
|
37
46
|
|
|
38
47
|
构建产物默认输出到 `dist/`。`dist/<应用名>.exe`(或对应平台可执行文件)为壳层程序,运行时从 `dist/resources/` 加载前端页面与配置,不弹 cmd 黑窗。
|
|
39
48
|
|
|
40
|
-
|
|
49
|
+
**构建缓存**:CLI 会在 `.freedom/vite-dist/` 记录前端源码指纹,源码无改动时复用上次构建产物直接进入分发阶段(秒级完成)。修改了 `src/` / `index.html` / `vite.config.js` 等任一处会触发自动重建;需要强制全量时可加 `--no-cache`。
|
|
50
|
+
|
|
51
|
+
**三平台打包**:壳层二进制随包分发,无需本机安装 Go 或交叉编译工具链。首次构建某平台时若本地缺少对应壳二进制,CLI 自动从 GitHub Releases 下载(`freedom shell download <platform>` 可手动补齐);仅当你想用本机 Go 工具链亲自编译壳时才需要 `freedom shell build`(只产出本机平台)。多平台分发为并行执行,全平台打包耗时大幅缩短。
|
|
41
52
|
|
|
42
53
|
产物目录可在 `freedom.config.js` 的 `outDir` 中调整:默认 `'dist'`,设为 `'.'` 则直接输出到项目根目录(dist 的上级),设为任意相对 / 绝对路径亦可。输出到项目根目录时会自动跳过 `index.html` 副本,避免覆盖项目源文件。
|
|
43
54
|
|
|
@@ -92,8 +103,9 @@ window.freedom.window.minimize(); // 窗口控制
|
|
|
92
103
|
## 命令
|
|
93
104
|
|
|
94
105
|
```
|
|
106
|
+
freedom tui # 交互式终端界面
|
|
95
107
|
freedom init <目录> [--force]
|
|
96
|
-
freedom build [--platform win-x64|mac-arm64|linux-x64|all]
|
|
108
|
+
freedom build [--platform win-x64|mac-arm64|linux-x64|all] [--no-cache]
|
|
97
109
|
freedom titlebar <native|hidden|frameless>
|
|
98
110
|
freedom config [get|set]
|
|
99
111
|
freedom shell list|download <platform>|build <platform>
|
package/lib/build.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// 可跨平台分发、无需在目标机器安装任何语言运行时。
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
|
+
const fsp = fs.promises;
|
|
19
20
|
const path = require('path');
|
|
20
21
|
const { spawnSync } = require('child_process');
|
|
21
22
|
const { copyDir } = require('./utils');
|
|
@@ -73,11 +74,17 @@ async function build(projectDir, opts = {}) {
|
|
|
73
74
|
const platforms = parsePlatforms(opts.platform);
|
|
74
75
|
const autoDownload = opts.autoDownload !== false;
|
|
75
76
|
|
|
76
|
-
// 1)
|
|
77
|
+
// 1) 前端打包(带缓存:源码未变更时复用上次 vite 产物,跳过 npm run build)
|
|
77
78
|
ensureNodeModules(dir);
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
79
|
+
const useCache = opts.noCache !== true;
|
|
80
|
+
if (useCache && !viteNeedsRebuild(dir)) {
|
|
81
|
+
process.stdout.write('[freedom] 前端源码无改动,复用构建缓存...\n');
|
|
82
|
+
} else {
|
|
83
|
+
const vite = run('npm', ['run', 'build'], { cwd: dir });
|
|
84
|
+
if (vite.status !== 0) {
|
|
85
|
+
throw new Error(`前端打包失败:\n${vite.stdout}\n${vite.stderr}`);
|
|
86
|
+
}
|
|
87
|
+
writeViteCacheMarker(dir);
|
|
81
88
|
}
|
|
82
89
|
const distHtml = path.join(dir, '.freedom', 'vite-dist', 'index.html');
|
|
83
90
|
if (!fs.existsSync(distHtml)) {
|
|
@@ -90,15 +97,16 @@ async function build(projectDir, opts = {}) {
|
|
|
90
97
|
const backendDir = path.join(dir, cfg.backendDir || 'backend');
|
|
91
98
|
const hasBackend = !!(cfg.backend && fs.existsSync(backendDir));
|
|
92
99
|
|
|
93
|
-
// 3)
|
|
100
|
+
// 3) 逐平台分发(多平台并行,显著缩短全平台打包耗时)
|
|
94
101
|
// 单平台直接输出到 outDir;多平台各自放到 outDir/<plat>/ 子目录,避免互相覆盖
|
|
95
102
|
const multi = platforms.length > 1;
|
|
96
|
-
const results =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
const results = await Promise.all(
|
|
104
|
+
platforms.map(async (plat) => {
|
|
105
|
+
const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
|
|
106
|
+
const outFile = await emitPlatform({ plat, name, targetDir, html, configJSON, backendDir, hasBackend, autoDownload });
|
|
107
|
+
return { plat, outFile };
|
|
108
|
+
})
|
|
109
|
+
);
|
|
102
110
|
|
|
103
111
|
return { results };
|
|
104
112
|
}
|
|
@@ -117,21 +125,21 @@ async function emitPlatform({ plat, name, targetDir, html, configJSON, backendDi
|
|
|
117
125
|
await downloadShell(plat);
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
await fsp.mkdir(targetDir, { recursive: true });
|
|
121
129
|
|
|
122
130
|
// 壳二进制 -> 应用可执行文件
|
|
123
131
|
const exeName = platformExeName(plat, name);
|
|
124
132
|
const outFile = path.join(targetDir, exeName);
|
|
125
|
-
|
|
133
|
+
await fsp.copyFile(shell, outFile);
|
|
126
134
|
if (!isWinPlat(plat)) {
|
|
127
|
-
|
|
135
|
+
await fsp.chmod(outFile, 0o755);
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
// 写 resources:页面 + 配置 + 后端
|
|
131
139
|
const resDir = path.join(targetDir, 'resources');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
await fsp.mkdir(resDir, { recursive: true });
|
|
141
|
+
await fsp.writeFile(path.join(resDir, 'index.html'), html, 'utf8');
|
|
142
|
+
await fsp.writeFile(path.join(resDir, 'config.json'), configJSON, 'utf8');
|
|
135
143
|
if (hasBackend) {
|
|
136
144
|
copyDir(backendDir, path.join(resDir, 'backend'));
|
|
137
145
|
}
|
|
@@ -164,6 +172,63 @@ function renderConfigJSON(cfg, name) {
|
|
|
164
172
|
return JSON.stringify(obj, null, 2);
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
// ---- 前端构建缓存 ----
|
|
176
|
+
// 追踪影响前端产物的源文件(index.html / vite.config.* / src/**)的最新 mtime,
|
|
177
|
+
// 与上次 vite build 记录值比较:未变更则跳过 npm run build,直接复用 .freedom/vite-dist 产物。
|
|
178
|
+
|
|
179
|
+
function collectViteSources(dir) {
|
|
180
|
+
const files = [];
|
|
181
|
+
const roots = ['index.html', 'vite.config.js', 'vite.config.mjs', 'vite.config.ts', 'vite.config.cjs'];
|
|
182
|
+
for (const r of roots) {
|
|
183
|
+
const p = path.join(dir, r);
|
|
184
|
+
if (fs.existsSync(p)) files.push(p);
|
|
185
|
+
}
|
|
186
|
+
const src = path.join(dir, 'src');
|
|
187
|
+
if (fs.existsSync(src)) {
|
|
188
|
+
const walk = (d) => {
|
|
189
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
190
|
+
const p = path.join(d, entry.name);
|
|
191
|
+
if (entry.isDirectory()) walk(p);
|
|
192
|
+
else files.push(p);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(src);
|
|
196
|
+
}
|
|
197
|
+
return files;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function maxMtime(files) {
|
|
201
|
+
return files.reduce((m, f) => {
|
|
202
|
+
try {
|
|
203
|
+
const t = fs.statSync(f).mtimeMs;
|
|
204
|
+
return t > m ? t : m;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
return m;
|
|
207
|
+
}
|
|
208
|
+
}, 0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function viteCacheMarker(dir) {
|
|
212
|
+
return path.join(dir, '.freedom', 'vite-dist', '.cache-mtime');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function viteNeedsRebuild(dir) {
|
|
216
|
+
const distHtml = path.join(dir, '.freedom', 'vite-dist', 'index.html');
|
|
217
|
+
const marker = viteCacheMarker(dir);
|
|
218
|
+
if (!fs.existsSync(distHtml) || !fs.existsSync(marker)) return true;
|
|
219
|
+
let recorded = 0;
|
|
220
|
+
try {
|
|
221
|
+
recorded = Number(fs.readFileSync(marker, 'utf8'));
|
|
222
|
+
} catch (e) { return true; }
|
|
223
|
+
// 留 1s 容差避免文件系统时间精度抖动
|
|
224
|
+
return maxMtime(collectViteSources(dir)) > recorded + 1000;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function writeViteCacheMarker(dir) {
|
|
228
|
+
fs.mkdirSync(path.join(dir, '.freedom', 'vite-dist'), { recursive: true });
|
|
229
|
+
fs.writeFileSync(viteCacheMarker(dir), String(maxMtime(collectViteSources(dir))), 'utf8');
|
|
230
|
+
}
|
|
231
|
+
|
|
167
232
|
function ensureNodeModules(dir) {
|
|
168
233
|
// 缺陷修复:node_modules 存在但 package.json 更新过(依赖变更)时自动重装。
|
|
169
234
|
const pkgFile = path.join(dir, 'package.json');
|
package/lib/cli.js
CHANGED
|
@@ -14,9 +14,11 @@ function help() {
|
|
|
14
14
|
freedom - Freedom 桌面壳打包工具 v${VERSION}
|
|
15
15
|
|
|
16
16
|
用法:
|
|
17
|
+
freedom tui 进入交互式 TUI 界面(新建/打包/配置/壳管理)
|
|
17
18
|
freedom init [目录] [--force] 在当前/指定目录新建项目模板
|
|
18
19
|
freedom build [--platform <p>] 前端打包并分发桌面应用(默认当前平台)
|
|
19
20
|
--platform win|mac|linux|all 指定目标平台(all = 三平台全量)
|
|
21
|
+
--no-cache 忽略前端构建缓存,强制重新打包
|
|
20
22
|
freedom titlebar <native|hidden|frameless>
|
|
21
23
|
一键切换标题栏策略
|
|
22
24
|
freedom shell list 列出本地已就绪的预编译壳
|
|
@@ -56,6 +58,9 @@ async function run(argv) {
|
|
|
56
58
|
console.log(VERSION);
|
|
57
59
|
return 0;
|
|
58
60
|
|
|
61
|
+
case 'tui':
|
|
62
|
+
return await require('./tui').tui(process.cwd());
|
|
63
|
+
|
|
59
64
|
case 'init': {
|
|
60
65
|
const force = rest.includes('--force');
|
|
61
66
|
const dirArg = rest.filter((a) => a !== '--force')[0];
|
|
@@ -74,7 +79,7 @@ async function run(argv) {
|
|
|
74
79
|
if (platArg) {
|
|
75
80
|
platform = platArg.includes('=') ? platArg.split('=')[1] : rest[rest.indexOf(platArg) + 1];
|
|
76
81
|
}
|
|
77
|
-
const { results } = await build(process.cwd(), { platform });
|
|
82
|
+
const { results } = await build(process.cwd(), { platform, noCache: rest.includes('--no-cache') });
|
|
78
83
|
for (const r of results) {
|
|
79
84
|
console.log(`[freedom] [${r.plat}] 构建完成:${r.outFile}`);
|
|
80
85
|
}
|
package/lib/tui.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// freedom tui —— 零依赖 ANSI 终端交互界面
|
|
4
|
+
// 纯 Node 标准库(readline + ANSI escape)实现,无第三方依赖,安装即用。
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const { init } = require('./init');
|
|
9
|
+
const { build } = require('./build');
|
|
10
|
+
const { setConfig, showConfig } = require('./config');
|
|
11
|
+
const { tutorialFile, hasConfig, ALL_PLATFORMS } = require('./utils');
|
|
12
|
+
|
|
13
|
+
// ---------------- ANSI ----------------
|
|
14
|
+
const ESC = '\x1b';
|
|
15
|
+
const C = {
|
|
16
|
+
reset: `${ESC}[0m`, bold: `${ESC}[1m`, dim: `${ESC}[2m`, rev: `${ESC}[7m`, underline: `${ESC}[4m`,
|
|
17
|
+
fgRed: `${ESC}[31m`, fgGreen: `${ESC}[32m`, fgYellow: `${ESC}[33m`,
|
|
18
|
+
fgCyan: `${ESC}[36m`, fgMagenta: `${ESC}[35m`, fgWhite: `${ESC}[37m`, fgGray: `${ESC}[90m`,
|
|
19
|
+
};
|
|
20
|
+
const CLEAR = `${ESC}[2J${ESC}[H`;
|
|
21
|
+
const HIDE = `${ESC}[?25l`;
|
|
22
|
+
const SHOW = `${ESC}[?25h`;
|
|
23
|
+
|
|
24
|
+
function line(content, color = '') {
|
|
25
|
+
return color + content + C.reset;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class TUI {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.out = process.stdout;
|
|
31
|
+
this.in = process.stdin;
|
|
32
|
+
this.active = false;
|
|
33
|
+
this.keyHandler = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
enter() {
|
|
37
|
+
if (this.active) return;
|
|
38
|
+
this.active = true;
|
|
39
|
+
this.in.setRawMode(true);
|
|
40
|
+
this.in.resume();
|
|
41
|
+
this.in.setEncoding('utf8');
|
|
42
|
+
readline.emitKeypressEvents(this.in);
|
|
43
|
+
this.in.on('keypress', this._onKey);
|
|
44
|
+
this.out.write(HIDE + CLEAR);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
exit() {
|
|
48
|
+
if (!this.active) return;
|
|
49
|
+
this.active = false;
|
|
50
|
+
this.in.removeListener('keypress', this._onKey);
|
|
51
|
+
try { this.in.setRawMode(false); } catch (e) { /* ignore */ }
|
|
52
|
+
this.in.pause();
|
|
53
|
+
this.out.write(SHOW + CLEAR);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_onKey = (str, key) => {
|
|
57
|
+
if (this.keyHandler) this.keyHandler(str, key);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
_waitKey() {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const done = (str, key) => {
|
|
63
|
+
this.keyHandler = null;
|
|
64
|
+
resolve({ str, key });
|
|
65
|
+
};
|
|
66
|
+
this.keyHandler = done;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_renderFrame(title, bodyLines, footer) {
|
|
71
|
+
const w = this.out.columns || 80;
|
|
72
|
+
const top = ` ${C.fgCyan}${C.bold}┌${'─'.repeat(Math.max(10, w - 6))}┐${C.reset}`;
|
|
73
|
+
const titleMid = ` ${C.fgCyan}${C.bold}│${C.reset}${C.fgWhite}${C.bold} ${title}${C.reset}`;
|
|
74
|
+
const bottom = ` ${C.fgCyan}${C.bold}└${'─'.repeat(Math.max(10, w - 6))}┘${C.reset}`;
|
|
75
|
+
const pad = (s) => ` ${s}`;
|
|
76
|
+
const lines = [
|
|
77
|
+
'',
|
|
78
|
+
` ${C.fgCyan}${C.bold}▚ F R E E D O M T U I ${C.fgGray}v1.1.10${C.reset}`,
|
|
79
|
+
'',
|
|
80
|
+
top,
|
|
81
|
+
pad(titleMid),
|
|
82
|
+
...bodyLines.map((l) => pad(typeof l === 'string' ? l : l.text)),
|
|
83
|
+
bottom,
|
|
84
|
+
'',
|
|
85
|
+
pad(footer ? C.fgGray + footer + C.reset : ''),
|
|
86
|
+
'',
|
|
87
|
+
];
|
|
88
|
+
this.out.write(CLEAR + lines.join('\n'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 单选菜单:返回选中下标;null = 取消(q/Esc)
|
|
92
|
+
async menu(title, items, { selected = 0, footer = '↑ ↓ 选择 · Enter 确认 · q 返回' } = {}) {
|
|
93
|
+
let idx = selected;
|
|
94
|
+
const draw = () => {
|
|
95
|
+
const body = items.map((it, i) => {
|
|
96
|
+
if (i === idx) return { text: ` ${C.fgCyan}${C.rev} ▶ ${it} ${C.reset}` };
|
|
97
|
+
return ` ${C.fgGray}${it}${C.reset}`;
|
|
98
|
+
});
|
|
99
|
+
this._renderFrame(title, body, footer);
|
|
100
|
+
};
|
|
101
|
+
draw();
|
|
102
|
+
for (;;) {
|
|
103
|
+
const { key } = await this._waitKey();
|
|
104
|
+
if (key.name === 'up' || key.sequence === '\x1b[A') idx = (idx - 1 + items.length) % items.length;
|
|
105
|
+
else if (key.name === 'down' || key.sequence === '\x1b[B') idx = (idx + 1) % items.length;
|
|
106
|
+
else if (key.name === 'return') return idx;
|
|
107
|
+
else if (key.name === 'q' || key.name === 'escape') return null;
|
|
108
|
+
draw();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 多选:Space 切换,Enter 确认,返回选中的下标数组
|
|
113
|
+
async multiselect(title, items, { checked = [], footer = '↑ ↓ 移动 · 空格 勾选 · Enter 确认 · q 返回' } = {}) {
|
|
114
|
+
let idx = 0;
|
|
115
|
+
const sel = new Set(checked);
|
|
116
|
+
const draw = () => {
|
|
117
|
+
const body = items.map((it, i) => {
|
|
118
|
+
const mark = sel.has(i) ? `${C.fgGreen}${C.bold}●${C.reset}` : `${C.fgGray}○${C.reset}`;
|
|
119
|
+
const cursor = i === idx ? `${C.fgCyan}▶${C.reset}` : ' ';
|
|
120
|
+
const txt = i === idx ? `${C.rev} ${it} ${C.reset}` : it;
|
|
121
|
+
return { text: ` ${cursor} ${mark} ${txt}` };
|
|
122
|
+
});
|
|
123
|
+
this._renderFrame(title, body, footer);
|
|
124
|
+
};
|
|
125
|
+
draw();
|
|
126
|
+
for (;;) {
|
|
127
|
+
const { key } = await this._waitKey();
|
|
128
|
+
if (key.name === 'up' || key.sequence === '\x1b[A') idx = (idx - 1 + items.length) % items.length;
|
|
129
|
+
else if (key.name === 'down' || key.sequence === '\x1b[B') idx = (idx + 1) % items.length;
|
|
130
|
+
else if (key.name === 'space') {
|
|
131
|
+
if (sel.has(idx)) sel.delete(idx); else sel.add(idx);
|
|
132
|
+
} else if (key.name === 'return') return [...sel].sort((a, b) => a - b);
|
|
133
|
+
else if (key.name === 'q' || key.name === 'escape') return null;
|
|
134
|
+
draw();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 文本输入:返回字符串;null = 取消
|
|
139
|
+
async input(title, { initial = '', footer = '输入后回车确认 · Esc 取消' } = {}) {
|
|
140
|
+
let buf = initial;
|
|
141
|
+
const draw = () => {
|
|
142
|
+
this._renderFrame(title, [
|
|
143
|
+
{ text: ' ' + C.fgYellow + '> ' + C.fgWhite + buf + (C.fgCyan + '▌' + C.reset) },
|
|
144
|
+
'',
|
|
145
|
+
{ text: C.fgGray + '(自由填写,回车确认)' + C.reset },
|
|
146
|
+
], footer);
|
|
147
|
+
};
|
|
148
|
+
draw();
|
|
149
|
+
for (;;) {
|
|
150
|
+
const { str, key } = await this._waitKey();
|
|
151
|
+
if (key.name === 'return') return buf;
|
|
152
|
+
if (key.name === 'escape' || key.name === 'q') return null;
|
|
153
|
+
if (key.name === 'backspace') buf = buf.slice(0, -1);
|
|
154
|
+
else if (str && str.length === 1) buf += str;
|
|
155
|
+
draw();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 确认:返回 bool
|
|
160
|
+
async confirm(title, { footer = '← → 选择 · Enter 确认' } = {}) {
|
|
161
|
+
const yes = await this.menu(title, ['是', '否'], { footer });
|
|
162
|
+
return yes === 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 消息:按任意键继续
|
|
166
|
+
async message(title, bodyLines, { footer = '按任意键继续' } = {}) {
|
|
167
|
+
this._renderFrame(title, bodyLines, footer);
|
|
168
|
+
await this._waitKey();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 切换出 TUI 执行任务(任务输出直接走终端),完成后回到 TUI
|
|
172
|
+
async runTask(fn) {
|
|
173
|
+
this.exit();
|
|
174
|
+
try {
|
|
175
|
+
await fn();
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.error(`${C.fgRed}[freedom]${C.reset} 任务失败:${e.message}`);
|
|
178
|
+
}
|
|
179
|
+
this.enter();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------- 主流程 ----------------
|
|
184
|
+
|
|
185
|
+
const CONFIG_KEYS = [
|
|
186
|
+
['name', '应用名 / 窗口标题 / exe 文件名'],
|
|
187
|
+
['width', '窗口宽度'],
|
|
188
|
+
['height', '窗口高度'],
|
|
189
|
+
['minWidth', '窗口最小宽度'],
|
|
190
|
+
['minHeight', '窗口最小高度'],
|
|
191
|
+
['center', '启动居中(true/false)'],
|
|
192
|
+
['debug', '开发者工具(true/false)'],
|
|
193
|
+
['titlebar', '标题栏:native | hidden | frameless'],
|
|
194
|
+
['outDir', '产物目录:dist(默认)| .(项目根)| 任意路径'],
|
|
195
|
+
['backend', '任意语言后端进程(JSON)'],
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
function openBrowser(file) {
|
|
199
|
+
const { spawn } = require('child_process');
|
|
200
|
+
const plat = process.platform;
|
|
201
|
+
const url = `file://${file.replace(/\\/g, '/')}`;
|
|
202
|
+
try {
|
|
203
|
+
if (plat === 'win32') spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
|
|
204
|
+
else if (plat === 'darwin') spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
205
|
+
else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
206
|
+
} catch (e) { /* ignore */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function buildFlow(tui, cwd) {
|
|
210
|
+
// 平台多选:默认勾选当前平台
|
|
211
|
+
const native = require('./utils').nativePlatform();
|
|
212
|
+
const checked = [ALL_PLATFORMS.indexOf(native)];
|
|
213
|
+
const picked = await tui.multiselect('选择目标平台(多选)', ALL_PLATFORMS, { checked });
|
|
214
|
+
if (picked === null) return;
|
|
215
|
+
const platforms = picked.map((i) => ALL_PLATFORMS[i]);
|
|
216
|
+
const platArg = picked.length === ALL_PLATFORMS.length ? 'all' : platforms.join(',');
|
|
217
|
+
|
|
218
|
+
await tui.runTask(async () => {
|
|
219
|
+
const { results } = await build(cwd, { platform: platArg });
|
|
220
|
+
for (const r of results) {
|
|
221
|
+
console.log(`${C.fgGreen}[freedom]${C.reset} [${r.plat}] 构建完成:${r.outFile}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function initFlow(tui, cwd) {
|
|
227
|
+
const name = await tui.input('新建项目', { initial: '' });
|
|
228
|
+
if (name === null) return;
|
|
229
|
+
const dir = await tui.runTask(() => {
|
|
230
|
+
const d = init(name || '.', { force: false });
|
|
231
|
+
console.log(`${C.fgGreen}[freedom]${C.reset} 项目已创建:${d}`);
|
|
232
|
+
console.log(` cd ${d} && npm install && freedom build`);
|
|
233
|
+
});
|
|
234
|
+
void dir;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function configFlow(tui, cwd) {
|
|
238
|
+
for (;;) {
|
|
239
|
+
if (!hasConfig(cwd)) {
|
|
240
|
+
await tui.message('配置', [{ text: C.fgRed + ' 未找到 freedom.config.js,请先创建项目。' + C.reset }]);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const names = ['查看当前配置', ...CONFIG_KEYS.map((k) => k[0]), '返回'];
|
|
244
|
+
const idx = await tui.menu('修改配置', names);
|
|
245
|
+
if (idx === null || idx === names.length - 1) return;
|
|
246
|
+
if (idx === 0) {
|
|
247
|
+
const cfgText = await showConfig(cwd);
|
|
248
|
+
await tui.message('当前配置', cfgText.split('\n').map((l) => ({ text: l })));
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const [key, desc] = CONFIG_KEYS[idx - 1];
|
|
252
|
+
const val = await tui.input(`${key}`, { initial: '' });
|
|
253
|
+
if (val === null) continue;
|
|
254
|
+
setConfig(cwd, key, coerce(val));
|
|
255
|
+
await tui.message('配置', [{ text: C.fgGreen + ` ${key} = ${val}` + C.reset }]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function shellFlow(tui) {
|
|
260
|
+
const { listLocal, downloadShell, buildShell } = require('./shell');
|
|
261
|
+
for (;;) {
|
|
262
|
+
const items = ['查看已就绪壳', '下载壳', '本地编译壳', '返回'];
|
|
263
|
+
const idx = await tui.menu('壳管理', items);
|
|
264
|
+
if (idx === null || idx === items.length - 1) return;
|
|
265
|
+
|
|
266
|
+
if (idx === 0) {
|
|
267
|
+
const ready = listLocal();
|
|
268
|
+
const rows = ready.length
|
|
269
|
+
? ready.map((p) => ({ text: C.fgGreen + ` ✓ ${p}` + C.reset }))
|
|
270
|
+
: [{ text: C.fgGray + ' 暂无已就绪壳' + C.reset }];
|
|
271
|
+
await tui.message('已就绪壳', rows);
|
|
272
|
+
} else if (idx === 1) {
|
|
273
|
+
const p = await tui.menu('选择要下载的壳平台', ALL_PLATFORMS);
|
|
274
|
+
if (p === null) continue;
|
|
275
|
+
await tui.runTask(async () => {
|
|
276
|
+
const dest = await downloadShell(ALL_PLATFORMS[p]);
|
|
277
|
+
console.log(`${C.fgGreen}[freedom]${C.reset} 已下载 ${ALL_PLATFORMS[p]} 壳:${dest}`);
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
const p = await tui.menu('选择要编译的壳平台(需 Go 且仅本机平台)', ALL_PLATFORMS);
|
|
281
|
+
if (p === null) continue;
|
|
282
|
+
await tui.runTask(async () => {
|
|
283
|
+
const dest = buildShell(ALL_PLATFORMS[p]);
|
|
284
|
+
console.log(`${C.fgGreen}[freedom]${C.reset} 已编译 ${ALL_PLATFORMS[p]} 壳:${dest}`);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function tutorialFlow(tui) {
|
|
291
|
+
const file = tutorialFile();
|
|
292
|
+
openBrowser(file);
|
|
293
|
+
await tui.message('教程', [{ text: C.fgGreen + ` 已打开:${file}` + C.reset }]);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function coerce(value) {
|
|
297
|
+
if (value === 'true') return true;
|
|
298
|
+
if (value === 'false') return false;
|
|
299
|
+
const num = Number(value);
|
|
300
|
+
if (value !== '' && Number.isFinite(num) && String(num) === value.trim()) return num;
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function tui(cwd) {
|
|
305
|
+
if (!process.stdin.isTTY) {
|
|
306
|
+
console.error('[freedom] tui 需要交互式终端,请直接在本机终端中运行 freedom tui。');
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
const app = new TUI();
|
|
310
|
+
app.enter();
|
|
311
|
+
let keep = true;
|
|
312
|
+
while (keep) {
|
|
313
|
+
const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '退出'];
|
|
314
|
+
const idx = await app.menu('主菜单', items, {
|
|
315
|
+
footer: `工作目录:${cwd} ↑ ↓ 选择 · Enter 确认 · q 退出`,
|
|
316
|
+
});
|
|
317
|
+
if (idx === null) break;
|
|
318
|
+
switch (idx) {
|
|
319
|
+
case 0: await buildFlow(app, cwd); break;
|
|
320
|
+
case 1: await initFlow(app, cwd); break;
|
|
321
|
+
case 2: await configFlow(app, cwd); break;
|
|
322
|
+
case 3: await shellFlow(app); break;
|
|
323
|
+
case 4: await tutorialFlow(app); break;
|
|
324
|
+
case 5: keep = false; break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
app.exit();
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { tui, TUI };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
2
|
"name": "@yufengtadian/freedom-cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
4
4
|
"description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
|
|
5
5
|
"keywords": ["desktop", "webview", "electron-alternative", "wails", "tauri", "frontend", "cross-platform"],
|
|
6
6
|
"license": "MIT",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|