@yufengtadian/freedom-cli 1.0.0 → 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 +45 -8
- package/lib/build.js +189 -104
- package/lib/cli.js +68 -4
- package/lib/shell.js +139 -0
- package/lib/tui.js +331 -0
- package/lib/utils.js +47 -0
- package/package.json +5 -4
- 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/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/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/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/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.11",
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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",
|