@yufengtadian/freedom-cli 1.12.15 → 1.12.16

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
@@ -4,6 +4,11 @@ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
+ **v1.12.16 自动更新 + 完整 CLI 模式 + 壳生命周期全面防御**:
8
+ - **自动检测版本并自动更新**:`freedom update` / `freedom check-update` 检测到新版本即自动执行 `npm install -g @yufengtadian/freedom-cli@latest` 升级,**不再需要手动执行 npm 命令**;每次命令执行成功后静默自检,发现新版自动更新(6 小时频控防骚扰,非全局安装给出明确升级指引);TUI 主菜单「检查 / 自动更新」同步接入;
9
+ - **完整 CLI 模式**:无参数运行 `freedom` 直接进入交互式完整 CLI(打包 / 新建项目 / 配置 / 壳管理 / 教程 / 自动更新 / 退出);管道与脚本环境自动降级打印帮助,不卡死;
10
+ - **壳生命周期全面防御(B51-B58 闭环)**:webview 全方法销毁防护、user32 proc 去重、WebView2 缺失可操作提示;`webview_create` 失败(如缺 WebKitGTK)返回 nil 后新壳走明确失败提示而非空指针崩溃;用户 `Bind` 的方法不再被 config.json 进程后端静默覆盖失效;help 文案与自动更新实现对齐,清理死代码。
11
+
7
12
  **v1.12.15 补发壳生命周期稳定性修复**:v1.12.14 的 npm 上架时间早于修复提交,上架版预编译壳未包含悬垂指针修复(窗口操作 / 代理端口触发随机退出的根因);本版正式把含 B50 修复的预编译壳随 npm 包分发——
8
13
  - 壳销毁生命周期修复:`webview.Destroy()` 后经 Emit / Quit / WindowHandle / binding 回调访问已释放 C 对象(悬垂指针)导致随机退出;新增 `destroyed` 原子标记,Dispatch / Eval / binding 回调销毁后一律跳过原生调用,`Run` 在 `Destroy` 前清空 `App.view`,事件与绑定回调加 `recover` 兜底;
9
14
  - 使用全局包构建的下游应用(如 DSH)升级到本版后,随机退出问题一并消除(可用二进制字符串指纹 `freedom: binding panic` 核对)。
package/bin/freedom.js CHANGED
@@ -2,11 +2,21 @@
2
2
  'use strict';
3
3
 
4
4
  const { run } = require('../lib/cli');
5
- const { maybeNotifyUpdate } = require('../lib/update');
5
+ const { maybeAutoUpdate, formatUpdateResult } = require('../lib/update');
6
+ const theme = require('../lib/theme');
7
+
8
+ const cmd = process.argv[2];
9
+ // TUI(无参 / freedom tui)与显式更新命令内部已处理更新,bin 层不再重复触发
10
+ const handled = cmd === undefined || cmd === 'tui' || cmd === 'update' || cmd === 'check-update';
6
11
 
7
12
  run(process.argv.slice(2)).then(async (code) => {
8
- if (code === 0 && process.argv[2] !== 'tui') {
9
- try { await maybeNotifyUpdate(); } catch (e) { /* 检测失败静默 */ }
13
+ // 命令成功且非 TUI / 非显式更新命令时,静默执行自动更新(检测到新版本自动升级,无需用户手动操作)
14
+ if (code === 0 && !handled) {
15
+ try {
16
+ const res = await maybeAutoUpdate();
17
+ const notice = formatUpdateResult(res, theme);
18
+ if (notice.length) console.log(notice.join('\n'));
19
+ } catch (e) { /* 更新流程失败静默 */ }
10
20
  }
11
21
  // 用 exitCode 让 Node 自然刷新 stdout 后退出,避免 process.exit 截断管道输出(历史 bug B28)
12
22
  process.exitCode = code || 0;
package/lib/cli.js CHANGED
@@ -19,7 +19,8 @@ function help() {
19
19
  L.push(` ${paint('用法:', C.bold, C.fg.white)} ${paint('freedom <command> [options]', C.fg.cyan, C.bold)}`);
20
20
  L.push('');
21
21
  L.push(section('交互式界面'));
22
- L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('进入交互式 TUI(新建 / 打包 / 配置 / 壳管理)')}`);
22
+ L.push(` ${paint('freedom', C.fg.cyan, C.bold)} ${dim('进入完整 CLI 模式(交互式菜单,新建 / 打包 / 配置 / 壳管理)')}`);
23
+ L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('同上(兼容写法,显式进入交互式模式)')}`);
23
24
  L.push(section('项目'));
24
25
  L.push(` ${paint('freedom init [目录] [--force]', C.fg.cyan)} ${dim('在当前 / 指定目录新建项目模板')}`);
25
26
  L.push(` ${paint('freedom tutorial', C.fg.cyan)} ${dim('再次打开安装教程')}`);
@@ -41,7 +42,7 @@ function help() {
41
42
  L.push(` ${paint('freedom config set <key> <value>', C.fg.cyan)} ${dim('修改单个配置项')}`);
42
43
  L.push(section('版本'));
43
44
  L.push(` ${paint('freedom version', C.fg.cyan)} ${dim('显示版本并检测最新版本')}`);
44
- L.push(` ${paint('freedom update', C.fg.cyan)} ${dim('检查新版本并给出升级命令')}`);
45
+ L.push(` ${paint('freedom update', C.fg.cyan)} ${dim('检查新版本并立即自动更新')}`);
45
46
  L.push(` ${paint('freedom help', C.fg.cyan)} ${dim('显示本帮助')}`);
46
47
  L.push('');
47
48
  L.push(section('平台'));
@@ -63,7 +64,15 @@ async function run(argv) {
63
64
  const [cmd, ...rest] = argv;
64
65
 
65
66
  switch (cmd) {
66
- case undefined:
67
+ case undefined: {
68
+ // 无参数:交互式终端进入完整 CLI 模式(TUI);管道 / 脚本环境打印帮助,避免卡死
69
+ if (process.stdin.isTTY && process.stdout.isTTY) {
70
+ return await require('./tui').tui(process.cwd());
71
+ }
72
+ console.log(help());
73
+ return 0;
74
+ }
75
+
67
76
  case 'help':
68
77
  case '--help':
69
78
  case '-h':
@@ -76,7 +85,7 @@ async function run(argv) {
76
85
  const r = await update.checkUpdate({ force: true });
77
86
  console.log(versionCard(r.current, r.latest, r.hasUpdate));
78
87
  if (r.hasUpdate && r.latest) {
79
- console.log(` ${tip('升级命令:')}${paint(`npm install -g ${update.PKG_NAME}@latest`, C.fg.cyan, C.bold)}`);
88
+ console.log(` ${tip('检测到新版本,将自动更新。')}`);
80
89
  console.log('');
81
90
  }
82
91
  return 0;
@@ -86,14 +95,15 @@ async function run(argv) {
86
95
  case 'check-update': {
87
96
  const r = await update.checkUpdate({ force: true });
88
97
  console.log(versionCard(r.current, r.latest, r.hasUpdate));
89
- if (r.hasUpdate && r.latest) {
90
- console.log(` ${tip('检测到新版本,执行以下命令升级:')}`);
91
- console.log(` ${paint(`npm install -g ${update.PKG_NAME}@latest`, C.fg.cyan, C.bold)}`);
92
- console.log('');
93
- } else if (r.latest) {
98
+ if (r.latest && !r.hasUpdate) {
94
99
  console.log(` ${ok('当前已是最新版本。')}`);
95
- } else {
100
+ } else if (!r.latest) {
96
101
  console.log(` ${warn('检查失败:网络不可用或 npm registry 未响应,请稍后重试。')}`);
102
+ } else {
103
+ // 有新版:立即自动更新(freedom update = 强制更新,无需手动执行 npm 命令)
104
+ const res = await update.maybeAutoUpdate({ force: true, r });
105
+ const notice = update.formatUpdateResult(res, theme);
106
+ if (notice.length) console.log(notice.join('\n'));
97
107
  }
98
108
  return 0;
99
109
  }
package/lib/tui.js CHANGED
@@ -213,7 +213,8 @@ async function buildFlow(tui, cwd) {
213
213
  // 平台多选:默认勾选当前平台
214
214
  const native = require('./utils').nativePlatform();
215
215
  const checked = [ALL_PLATFORMS.indexOf(native)];
216
- const picked = await tui.multiselect('选择目标平台(多选)', ALL_PLATFORMS, { checked });
216
+ // linux-arm64 CI 预编译资产:全选时按 all(win-x64/darwin-arm64/linux-x64)处理,避免 404 拖垮构建(见 utils.DIST_PLATFORMS)
217
+ const picked = await tui.multiselect('选择目标平台(多选;linux-arm64 无预编译资产,全选仅含三平台)', ALL_PLATFORMS, { checked });
217
218
  if (picked === null) return;
218
219
  const platforms = picked.map((i) => ALL_PLATFORMS[i]);
219
220
  const platArg = picked.length === ALL_PLATFORMS.length ? 'all' : platforms.join(',');
@@ -303,20 +304,36 @@ async function tutorialFlow(tui) {
303
304
  }
304
305
 
305
306
  async function updateFlow(tui) {
306
- const { checkUpdate, PKG_NAME } = require('./update');
307
- const r = await checkUpdate({ force: true });
308
- const lines = [
309
- { text: C.fgWhite + C.bold + ` 当前版本:v${r.current}` + C.reset },
310
- ];
311
- if (r.latest) {
312
- lines.push({ text: C.fgCyan + ` 最新版本:v${r.latest}` + C.reset });
313
- lines.push({ text: r.hasUpdate
314
- ? C.fgYellow + ` 发现新版本,可执行:npm install -g ${PKG_NAME}@latest` + C.reset
315
- : C.fgGreen + ` 已是最新版本` + C.reset });
307
+ const update = require('./update');
308
+ const r = await update.checkUpdate({ force: true });
309
+ const rows = [{ text: C.fgWhite + C.bold + ` 当前版本:v${r.current}` + C.reset }];
310
+ if (!r.latest) {
311
+ rows.push({ text: C.fgGray + ` 检查失败:网络不可用,请稍后重试` + C.reset });
312
+ return tui.message('检查 / 自动更新', rows);
313
+ }
314
+ rows.push({ text: C.fgCyan + ` 最新版本:v${r.latest}` + C.reset });
315
+ if (!r.hasUpdate) {
316
+ rows.push({ text: C.fgGreen + ` 已是最新版本` + C.reset });
317
+ return tui.message('检查 / 自动更新', rows);
318
+ }
319
+ // 有新版本:直接自动更新(无需用户手动执行 npm 命令)
320
+ let res = null;
321
+ await tui.runTask(async () => {
322
+ res = await update.maybeAutoUpdate({ force: true, r });
323
+ });
324
+ if (res && res.updated) {
325
+ rows.push({ text: C.fgGreen + ` ✓ 已自动更新到 v${res.to}(原 v${res.from})` + C.reset });
326
+ rows.push({ text: C.fgGray + ` 重启本 TUI 后即为新版本` + C.reset });
327
+ } else if (res && res.notGlobal) {
328
+ rows.push({ text: C.fgYellow + ` ⚠ 检测到新版本,当前为非全局安装,请手动执行:npm install -g ${update.pkgName()}@latest` + C.reset });
329
+ } else if (res && res.failed) {
330
+ rows.push({ text: C.fgYellow + ` ⚠ 自动更新失败:${res.detail || '未知原因'}` + C.reset });
331
+ } else if (res && res.throttled) {
332
+ rows.push({ text: C.fgYellow + ` ⚠ 自动更新 6h 内已尝试过,本次跳过` + C.reset });
316
333
  } else {
317
- lines.push({ text: C.fgGray + ` 检查失败:网络不可用,请稍后重试` + C.reset });
334
+ rows.push({ text: C.fgGray + ` 自动更新流程已结束` + C.reset });
318
335
  }
319
- await tui.message('检查版本更新', lines);
336
+ await tui.message('检查 / 自动更新', rows);
320
337
  }
321
338
 
322
339
  function coerce(value) {
@@ -336,7 +353,7 @@ async function tui(cwd) {
336
353
  app.enter();
337
354
  let keep = true;
338
355
  while (keep) {
339
- const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '检查更新', '退出'];
356
+ const items = ['打包桌面应用', '新建项目', '修改配置', '壳管理', '打开教程', '检查 / 自动更新', '退出'];
340
357
  const idx = await app.menu('主菜单', items, {
341
358
  footer: `工作目录:${cwd} ↑ ↓ 选择 · Enter 确认 · q 退出`,
342
359
  });
package/lib/update.js CHANGED
@@ -1,21 +1,42 @@
1
1
  'use strict';
2
2
 
3
- // 版本检测:零依赖(https 内置),查询 npm registry 最新版本并对比本地版本。
4
- // - 结果缓存到 ~/.freedom/update-cache.json,24h 内不重复联网(离线不打扰)
5
- // - compareVersions 手写 semver 比较(仅处理 x.y.z 数字前缀,满足语义版本场景)
6
- // - 所有联网失败均静默降级,绝不阻塞主流程
3
+ // 版本检测与自动更新:零依赖(https 内置)
4
+ //
5
+ // 版本检测:
6
+ // - 查询 npm registry 最新版本并对比本地版本,结果缓存 24h(离线不打扰)
7
+ // 自动更新:
8
+ // - 检测到新版本时自动执行 `npm install -g <pkg>@latest`,无需用户手动升级
9
+ // - 仅当当前包为 npm 全局安装时自动更新(npm link / 本地目录安装仅提示手动命令)
10
+ // - 自动更新带 6h 频控(FREEDOM_AUTO_UPDATE_FORCE=1 可强制),失败静默降级不阻塞主流程
11
+ // 开关:
12
+ // - FREEDOM_AUTO_UPDATE=0 / false / off 可关闭自动更新(仅保留版本提示)
13
+ // 所有联网 / 更新失败均静默降级,绝不阻塞主流程。
14
+ // compareVersions 手写 semver 比较(仅处理 x.y.z 数字前缀,满足语义版本场景)。
7
15
 
8
16
  const fs = require('fs');
9
17
  const os = require('os');
10
18
  const path = require('path');
11
19
  const https = require('https');
20
+ const { spawn, execFileSync } = require('child_process');
12
21
  const { packageRoot } = require('./utils');
13
22
 
14
23
  const PKG_NAME = '@yufengtadian/freedom-cli';
15
24
  const REGISTRY = `https://registry.npmjs.org/${encodeURIComponent(PKG_NAME)}/latest`;
16
25
  const CACHE_FILE = path.join(os.homedir(), '.freedom', 'update-cache.json');
17
- const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 小时
26
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 版本检测缓存 24h
27
+ const AUTO_UPDATE_GAP = 6 * 60 * 60 * 1000; // 自动更新尝试频控 6h
18
28
  const REQUEST_TIMEOUT = 4000;
29
+ const UPDATE_TIMEOUT = 120000; // npm install 超时 120s
30
+
31
+ // 实际安装的包名:优先读 package.json(fork / 改名时自动跟随),兜底 PKG_NAME
32
+ function pkgName() {
33
+ try {
34
+ const pkg = require(path.join(packageRoot(), 'package.json'));
35
+ return typeof pkg.name === 'string' && pkg.name ? pkg.name : PKG_NAME;
36
+ } catch (e) {
37
+ return PKG_NAME;
38
+ }
39
+ }
19
40
 
20
41
  function currentVersion() {
21
42
  return require(path.join(packageRoot(), 'package.json')).version;
@@ -34,11 +55,22 @@ function compareVersions(a, b) {
34
55
  return 0;
35
56
  }
36
57
 
37
- function readCache() {
58
+ // ---- 缓存 ----
59
+
60
+ // 读取原始缓存(不校验 TTL),供自动更新频控使用;损坏返回 null
61
+ function readRawCache() {
38
62
  try {
39
63
  const j = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
40
- if (j && j.latest && Date.now() - j.ts < CACHE_TTL) return j;
41
- } catch (e) { /* 无缓存或损坏,忽略 */ }
64
+ return j && typeof j === 'object' ? j : null;
65
+ } catch (e) {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // 读取有效缓存:latest 存在且未过期才返回(版本检测用)
71
+ function readCache() {
72
+ const j = readRawCache();
73
+ if (j && j.latest && Date.now() - j.ts < CACHE_TTL) return j;
42
74
  return null;
43
75
  }
44
76
 
@@ -49,30 +81,38 @@ function writeCache(data) {
49
81
  } catch (e) { /* 写缓存失败静默 */ }
50
82
  }
51
83
 
52
- // npm registry 拉取 latest 版本;失败 / 超时返回 null
84
+ function clearCache() {
85
+ try { fs.unlinkSync(CACHE_FILE); } catch (e) { /* 忽略 */ }
86
+ }
87
+
88
+ // ---- 联网检测 ----
89
+ // 从 npm registry 拉取 latest 版本;失败 / 超时返回 null。
90
+ // B54:timeout 时显式 settle,避免 https 请求超时后 Promise 永不 resolve(悬空)。
53
91
  function fetchLatest(timeout = REQUEST_TIMEOUT) {
54
92
  return new Promise((resolve) => {
93
+ let settled = false;
94
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
55
95
  const req = https.get(REGISTRY, {
56
96
  headers: { 'user-agent': 'freedom-cli', accept: 'application/json' },
57
97
  timeout,
58
98
  }, (res) => {
59
99
  if (res.statusCode !== 200) {
60
100
  res.resume();
61
- return resolve(null);
101
+ return done(null);
62
102
  }
63
103
  let body = '';
64
104
  res.setEncoding('utf8');
65
105
  res.on('data', (chunk) => { body += chunk; });
66
106
  res.on('end', () => {
67
107
  try {
68
- resolve(JSON.parse(body).version || null);
108
+ done(JSON.parse(body).version || null);
69
109
  } catch (e) {
70
- resolve(null);
110
+ done(null);
71
111
  }
72
112
  });
73
113
  });
74
- req.on('timeout', () => req.destroy());
75
- req.on('error', () => resolve(null));
114
+ req.on('timeout', () => { req.destroy(); done(null); });
115
+ req.on('error', () => done(null));
76
116
  });
77
117
  }
78
118
 
@@ -89,21 +129,131 @@ async function checkUpdate({ force = false } = {}) {
89
129
  return { current, latest, hasUpdate };
90
130
  }
91
131
 
92
- // 静默异步通知:仅在检测到新版本时打印一行升级提示,不阻塞调用方
93
- async function maybeNotifyUpdate() {
132
+ // ---- 全局安装判定 ----
133
+
134
+ function npmGlobalRoot() {
94
135
  try {
95
- const r = await checkUpdate();
96
- if (r.hasUpdate && r.latest) {
97
- const theme = require('./theme');
98
- console.log('');
99
- console.log(` ${theme.paint('➜', theme.C.fg.magenta, theme.C.bold)} ${theme.paint(`新版本可用 ${r.latest}(当前 ${r.current})`, theme.C.fg.yellow)}`);
100
- console.log(` ${theme.dim('运行')} ${theme.paint(`npm install -g ${PKG_NAME}@latest`, theme.C.fg.cyan)} ${theme.dim('升级,或')} ${theme.paint('freedom update', theme.C.fg.cyan)} ${theme.dim('查看详情')}`);
101
- console.log('');
102
- }
103
- } catch (e) { /* 检测失败静默 */ }
136
+ const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
137
+ const out = execFileSync(cmd, ['root', '-g'], {
138
+ encoding: 'utf8',
139
+ timeout: 10000,
140
+ windowsHide: true,
141
+ shell: process.platform === 'win32',
142
+ });
143
+ return out.trim() || null;
144
+ } catch (e) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ // target 是否位于 root 目录内(含直接子级);任一为空返回 false
150
+ function isPathInside(root, target) {
151
+ if (!root || !target) return false;
152
+ const rel = path.relative(root, target);
153
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
154
+ }
155
+
156
+ // 当前安装是否为 npm 全局安装。
157
+ // 仅全局安装才允许自动更新;npm link / 本地目录安装时仅提示手动命令,
158
+ // 避免自动安装出一份与当前工作副本无关的全局副本(行为不可预期)。
159
+ function isGlobalInstall() {
160
+ const root = npmGlobalRoot();
161
+ if (!root) return false;
162
+ return isPathInside(root, packageRoot());
163
+ }
164
+
165
+ // ---- 自动更新执行 ----
166
+
167
+ // 执行 `npm install -g <pkg>@<latest>`;返回 { ok, detail }
168
+ function performAutoUpdate(latest, { timeoutMs = UPDATE_TIMEOUT } = {}) {
169
+ return new Promise((resolve) => {
170
+ let settled = false;
171
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
172
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
173
+ const child = spawn(
174
+ npm,
175
+ ['install', '-g', `${pkgName()}@${latest}`, '--no-audit', '--no-fund', '--loglevel=error'],
176
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32' }
177
+ );
178
+ let out = '';
179
+ let err = '';
180
+ child.stdout.on('data', (d) => { out += d; });
181
+ child.stderr.on('data', (d) => { err += d; });
182
+ const timer = setTimeout(() => {
183
+ try { child.kill(); } catch (e) { /* ignore */ }
184
+ done({ ok: false, detail: `更新超时(${Math.round(timeoutMs / 1000)}s)` });
185
+ }, timeoutMs);
186
+ child.on('error', (e) => { clearTimeout(timer); done({ ok: false, detail: e.message }); });
187
+ child.on('close', (code) => {
188
+ clearTimeout(timer);
189
+ if (code === 0) done({ ok: true, detail: (out.trim() || err.trim() || '') });
190
+ else done({ ok: false, detail: (err.trim() || out.trim() || `npm 退出码 ${code}`) });
191
+ });
192
+ });
193
+ }
194
+
195
+ // 自动更新频控是否到期:距上次尝试超过 AUTO_UPDATE_GAP 才算到期
196
+ function autoUpdateDue(now = Date.now()) {
197
+ const raw = readRawCache();
198
+ if (!raw || !raw.lastAutoAttempt) return true;
199
+ return now - raw.lastAutoAttempt >= AUTO_UPDATE_GAP;
200
+ }
201
+
202
+ // 自动更新完整流程。返回结构化结果供调用方打印。
203
+ // r:可传入预取的 checkUpdate 结果,避免重复联网;force:跳过缓存与频控。
204
+ async function maybeAutoUpdate({ force = false, r = null } = {}) {
205
+ // 开关:FREEDOM_AUTO_UPDATE=0 / false / off 关闭自动更新
206
+ const flag = (process.env.FREEDOM_AUTO_UPDATE || '').toLowerCase();
207
+ if (flag === '0' || flag === 'false' || flag === 'off') return { skipped: 'disabled' };
208
+
209
+ const info = r || (await checkUpdate({ force }));
210
+ if (!info.latest) return { offline: true };
211
+ if (!info.hasUpdate) return { upToDate: true, latest: info.latest };
212
+ if (!isGlobalInstall()) {
213
+ return { notGlobal: true, latest: info.latest, current: info.current };
214
+ }
215
+
216
+ const forceFlag = (process.env.FREEDOM_AUTO_UPDATE_FORCE || '').toLowerCase();
217
+ const skipThrottle = force || forceFlag === '1' || forceFlag === 'true';
218
+ if (!skipThrottle && !autoUpdateDue()) {
219
+ return { throttled: true, latest: info.latest, current: info.current };
220
+ }
221
+
222
+ // 先标记尝试再执行,防止多进程并发 / 失败后风暴重试
223
+ writeCache({ latest: info.latest, lastAutoAttempt: Date.now() });
224
+ const res = await performAutoUpdate(info.latest);
225
+ if (res.ok) {
226
+ clearCache(); // 更新成功后清缓存,下次以新版本为基准重新检测
227
+ return { updated: true, from: info.current, to: info.latest, detail: res.detail };
228
+ }
229
+ return { failed: true, latest: info.latest, current: info.current, detail: res.detail };
230
+ }
231
+
232
+ // 把自动更新结果渲染为终端提示行(供 CLI 打印;TUI 自行拼装)。
233
+ // upToDate / offline / skipped 不产生提示(静默),返回空数组。
234
+ function formatUpdateResult(res, theme) {
235
+ const { paint, C, ok, warn, tip, dim } = theme;
236
+ const lines = [];
237
+ if (res.updated) {
238
+ lines.push(` ${ok(`已自动更新到 v${res.to}(原 v${res.from})。`)}`);
239
+ lines.push(` ${dim('下次运行')} ${paint('freedom', C.fg.cyan)} ${dim('即为新版本。')}`);
240
+ } else if (res.notGlobal) {
241
+ lines.push(` ${warn(`检测到新版本 v${res.latest}(当前 v${res.current})。`)}`);
242
+ lines.push(` ${dim('当前为非全局安装,无法自动更新,请手动执行:')}${paint(`npm install -g ${pkgName()}@latest`, C.fg.cyan, C.bold)}`);
243
+ } else if (res.failed) {
244
+ lines.push(` ${warn('自动更新失败:')}${dim(res.detail || '未知原因')}`);
245
+ lines.push(` ${dim('可稍后运行')} ${paint('freedom update', C.fg.cyan)} ${dim('重试,或手动执行:')}${paint(`npm install -g ${pkgName()}@latest`, C.fg.cyan, C.bold)}`);
246
+ } else if (res.throttled) {
247
+ lines.push(` ${tip(`新版本 v${res.latest} 存在,6h 内已尝试过自动更新,本次跳过。`)}`);
248
+ lines.push(` ${dim('运行')} ${paint('freedom update', C.fg.cyan)} ${dim('可立即强制更新。')}`);
249
+ }
250
+ return lines;
104
251
  }
105
252
 
106
253
  module.exports = {
107
- PKG_NAME, REGISTRY, CACHE_FILE, CACHE_TTL,
108
- currentVersion, compareVersions, checkUpdate, maybeNotifyUpdate,
254
+ PKG_NAME, REGISTRY, CACHE_FILE, CACHE_TTL, AUTO_UPDATE_GAP,
255
+ pkgName, currentVersion, compareVersions, checkUpdate,
256
+ readRawCache, readCache, writeCache, clearCache,
257
+ npmGlobalRoot, isPathInside, isGlobalInstall,
258
+ performAutoUpdate, autoUpdateDue, maybeAutoUpdate, formatUpdateResult,
109
259
  };
package/package.json CHANGED
@@ -1,39 +1,39 @@
1
- {
2
- "name": "@yufengtadian/freedom-cli",
3
- "version": "1.12.15",
4
- "description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
5
- "keywords": [
6
- "desktop",
7
- "webview",
8
- "electron-alternative",
9
- "wails",
10
- "tauri",
11
- "frontend",
12
- "cross-platform"
13
- ],
14
- "license": "MIT",
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "bin": {
19
- "freedom": "bin/freedom.js"
20
- },
21
- "files": [
22
- "bin",
23
- "lib",
24
- "shell",
25
- "templates",
26
- "tutorial",
27
- "postinstall.js",
28
- "README.md"
29
- ],
30
- "scripts": {
31
- "postinstall": "node postinstall.js"
32
- },
33
- "engines": {
34
- "node": ">=18"
35
- },
36
- "dependencies": {
37
- "rcedit": "^4.0.1"
38
- }
39
- }
1
+ {
2
+ "name": "@yufengtadian/freedom-cli",
3
+ "version": "1.12.16",
4
+ "description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
5
+ "keywords": [
6
+ "desktop",
7
+ "webview",
8
+ "electron-alternative",
9
+ "wails",
10
+ "tauri",
11
+ "frontend",
12
+ "cross-platform"
13
+ ],
14
+ "license": "MIT",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "bin": {
19
+ "freedom": "bin/freedom.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "lib",
24
+ "shell",
25
+ "templates",
26
+ "tutorial",
27
+ "postinstall.js",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "postinstall": "node postinstall.js"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "dependencies": {
37
+ "rcedit": "^4.0.1"
38
+ }
39
+ }
Binary file
@@ -3,18 +3,9 @@
3
3
  package freedom
4
4
 
5
5
  import (
6
- "syscall"
7
6
  "unsafe"
8
7
  )
9
8
 
10
- var (
11
- user32 = syscall.NewLazyDLL("user32.dll")
12
- getSystemMetrics = user32.NewProc("GetSystemMetrics")
13
- moveWindow = user32.NewProc("MoveWindow")
14
- monitorFromWindow = user32.NewProc("MonitorFromWindow")
15
- getMonitorInfo = user32.NewProc("GetMonitorInfoW")
16
- )
17
-
18
9
  const (
19
10
  smCxScreen = 16
20
11
  smCyScreen = 17
@@ -38,19 +29,22 @@ type monitorInfo struct {
38
29
  // webview_go 未提供 SetPosition,这里通过原生 HWND + MoveWindow 定位。
39
30
  // 优先居中到窗口当前所在监视器的工作区(rcWork):多屏副屏(负坐标 /
40
31
  // 不同分辨率 / 任务栏遮挡)下也能正确居中;API 失败时回退主屏全屏居中。
32
+ //
33
+ // user32 过程句柄(procGetSystemMetrics / procMoveWindow / procMonitorFromWindow /
34
+ // procGetMonitorInfo)统一声明在 window_windows.go,避免两套重复 NewProc。
41
35
  func (a *App) applyCenter() {
42
- if !a.cfg.Center || a.view == nil {
36
+ if !a.cfg.Center {
43
37
  return
44
38
  }
45
- hwnd := uintptr(unsafe.Pointer(a.view.Window()))
39
+ hwnd := a.WindowHandle()
46
40
  if hwnd == 0 {
47
41
  return
48
42
  }
49
- mon, _, _ := monitorFromWindow.Call(hwnd, monitorDefaultToNear)
43
+ mon, _, _ := procMonitorFromWindow.Call(hwnd, monitorDefaultToNear)
50
44
  if mon != 0 {
51
45
  var mi monitorInfo
52
46
  mi.cbSize = uint32(unsafe.Sizeof(mi))
53
- if r1, _, _ := getMonitorInfo.Call(mon, uintptr(unsafe.Pointer(&mi))); r1 != 0 {
47
+ if r1, _, _ := procGetMonitorInfo.Call(mon, uintptr(unsafe.Pointer(&mi))); r1 != 0 {
54
48
  work := mi.rcWork
55
49
  sw := work.right - work.left
56
50
  sh := work.bottom - work.top
@@ -62,13 +56,13 @@ func (a *App) applyCenter() {
62
56
  if y < 0 {
63
57
  y = 0
64
58
  }
65
- moveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
59
+ procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
66
60
  return
67
61
  }
68
62
  }
69
63
  // 回退:主屏全屏尺寸居中(原实现)
70
- sw, _, _ := getSystemMetrics.Call(smCxScreen)
71
- sh, _, _ := getSystemMetrics.Call(smCyScreen)
64
+ sw, _, _ := procGetSystemMetrics.Call(smCxScreen)
65
+ sh, _, _ := procGetSystemMetrics.Call(smCyScreen)
72
66
  x := int(sw/2) - a.cfg.Width/2
73
67
  y := int(sh/2) - a.cfg.Height/2
74
68
  if x < 0 {
@@ -77,5 +71,5 @@ func (a *App) applyCenter() {
77
71
  if y < 0 {
78
72
  y = 0
79
73
  }
80
- moveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
74
+ procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
81
75
  }
@@ -110,7 +110,14 @@ func (a *App) Bind(name string, fn interface{}) error {
110
110
  if !ok {
111
111
  return fmt.Errorf("freedom: Bind 仅适用于内嵌 Go 后端;当前后端为 %T,方法请在进程后端中注册", a.backend)
112
112
  }
113
- return eb.Bind(name, fn)
113
+ if err := eb.Bind(name, fn); err != nil {
114
+ return err
115
+ }
116
+ // B56:用户显式绑定方法 = 显式指定内嵌后端。置位后外部 resources/config.json
117
+ // 的 backend 配置不再覆盖(loadRuntimeConfig 仅当 !backendExplicit 时生效),
118
+ // 避免"用户 Bind 的方法被 config.json 的进程后端静默替换而全部失效"。
119
+ a.backendExplicit = true
120
+ return nil
114
121
  }
115
122
 
116
123
  // Unbind 移除先前 Bind 的方法(内嵌后端)。
@@ -152,7 +159,11 @@ func (a *App) Run() {
152
159
 
153
160
  w := webview.New(a.cfg.Debug)
154
161
  if w == nil {
155
- fmt.Println("freedom: failed to create webview")
162
+ if !webview2Available() {
163
+ fmt.Println("freedom: failed to create webview: 未检测到 WebView2 Runtime,请安装 Microsoft Edge WebView2 Runtime(https://go.microsoft.com/fwlink/?linkid=2124703)后重试")
164
+ } else {
165
+ fmt.Println("freedom: failed to create webview")
166
+ }
156
167
  return
157
168
  }
158
169
  a.setView(w)
@@ -24,6 +24,10 @@ func (a *App) applyTitleBar() {
24
24
  // setWindowIcon 在 macOS / Linux 上为空实现(图标由应用包 / 窗口管理器决定)。
25
25
  func (a *App) setWindowIcon() {}
26
26
 
27
+ // webview2Available 非 Windows 平台恒返回 true(macOS WKWebView / Linux WebKitGTK,
28
+ // 不依赖 WebView2 Runtime)。
29
+ func webview2Available() bool { return true }
30
+
27
31
  // windowControl 处理前端 window.freedom.window.* 请求(macOS / Linux 实现)。
28
32
  //
29
33
  // 窗口控制类动作(最小化/最大化/还原/关闭/查询)直接转发到 webview 层的原生
@@ -13,6 +13,7 @@ import (
13
13
  "syscall"
14
14
 
15
15
  "golang.org/x/sys/windows"
16
+ "golang.org/x/sys/windows/registry"
16
17
  )
17
18
 
18
19
  // 进程级 DPI 感知:让窗口坐标 / WebView 渲染统一按物理像素工作。
@@ -232,6 +233,25 @@ func exeAppIconDataURL() string {
232
233
  return "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
233
234
  }
234
235
 
236
+ // webview2Available 检测 Windows 是否已安装 WebView2 Runtime。
237
+ // webview_create(WEBVIEW_EDGE)失败的最常见原因是缺少 WebView2 Runtime,
238
+ // 检测其安装注册表键(EdgeUpdate Clients GUID)判断状态,便于给出可操作提示。
239
+ // 返回值仅用于决定错误文案,不参与窗口创建逻辑。
240
+ func webview2Available() bool {
241
+ guid := `{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`
242
+ key := `SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\` + guid
243
+ if k, err := registry.OpenKey(registry.LOCAL_MACHINE, key, registry.QUERY_VALUE|registry.READ); err == nil {
244
+ k.Close()
245
+ return true
246
+ }
247
+ key2 := `SOFTWARE\Microsoft\EdgeUpdate\Clients\` + guid
248
+ if k, err := registry.OpenKey(registry.LOCAL_MACHINE, key2, registry.QUERY_VALUE|registry.READ); err == nil {
249
+ k.Close()
250
+ return true
251
+ }
252
+ return false
253
+ }
254
+
235
255
  func isZoomed(hwnd uintptr) bool {
236
256
  r, _, _ := procIsZoomed.Call(hwnd)
237
257
  return r != 0
@@ -199,6 +199,13 @@ func New(debug bool) WebView { return NewWindow(debug, nil) }
199
199
  func NewWindow(debug bool, window unsafe.Pointer) WebView {
200
200
  w := &webview{}
201
201
  w.w = C.webview_create(boolToInt(debug), window)
202
+ // B55:webview_create 在目标平台 WebView 初始化失败时可能返回 NULL
203
+ //(如 Linux 缺 WebKitGTK、macOS WebKit 不可用),此时返回 nil 让调用方
204
+ //(freedom.go Run 的 w == nil 分支)走失败提示,而非持 nil webview_t
205
+ // 继续调用 SetTitle/SetSize/Init 等 C API 触发空指针崩溃。
206
+ if w.w == nil {
207
+ return nil
208
+ }
202
209
  return w
203
210
  }
204
211
 
@@ -209,52 +216,85 @@ func (w *webview) Destroy() {
209
216
  }
210
217
 
211
218
  func (w *webview) Run() {
219
+ if destroyed.Load() {
220
+ return
221
+ }
212
222
  C.webview_run(w.w)
213
223
  }
214
224
 
215
225
  func (w *webview) Terminate() {
226
+ if destroyed.Load() {
227
+ return
228
+ }
216
229
  C.webview_terminate(w.w)
217
230
  }
218
231
 
219
232
  func (w *webview) Window() unsafe.Pointer {
233
+ if destroyed.Load() {
234
+ return nil
235
+ }
220
236
  return C.webview_get_window(w.w)
221
237
  }
222
238
 
223
239
  func (w *webview) Navigate(url string) {
240
+ if destroyed.Load() {
241
+ return
242
+ }
224
243
  s := C.CString(url)
225
244
  defer C.free(unsafe.Pointer(s))
226
245
  C.webview_navigate(w.w, s)
227
246
  }
228
247
 
229
248
  func (w *webview) SetHtml(html string) {
249
+ if destroyed.Load() {
250
+ return
251
+ }
230
252
  s := C.CString(html)
231
253
  defer C.free(unsafe.Pointer(s))
232
254
  C.webview_set_html(w.w, s)
233
255
  }
234
256
 
235
257
  func (w *webview) SetTitle(title string) {
258
+ if destroyed.Load() {
259
+ return
260
+ }
236
261
  s := C.CString(title)
237
262
  defer C.free(unsafe.Pointer(s))
238
263
  C.webview_set_title(w.w, s)
239
264
  }
240
265
 
241
266
  func (w *webview) SetDecorated(decorated bool) {
267
+ if destroyed.Load() {
268
+ return
269
+ }
242
270
  C.webview_set_decorated(w.w, boolToInt(decorated))
243
271
  }
244
272
 
245
273
  func (w *webview) WindowControl(action WindowAction) int {
274
+ if destroyed.Load() {
275
+ return -1
276
+ }
246
277
  return int(C.webview_window_control(w.w, C.webview_window_action_t(action)))
247
278
  }
248
279
 
249
280
  func (w *webview) BeginMoveDrag() int {
281
+ if destroyed.Load() {
282
+ return -1
283
+ }
250
284
  return int(C.webview_window_begin_move_drag(w.w))
251
285
  }
252
286
 
253
287
  func (w *webview) SetSize(width int, height int, hint Hint) {
288
+ if destroyed.Load() {
289
+ return
290
+ }
254
291
  C.webview_set_size(w.w, C.int(width), C.int(height), C.webview_hint_t(hint))
255
292
  }
256
293
 
257
294
  func (w *webview) Init(js string) {
295
+ if destroyed.Load() {
296
+ return
297
+ }
258
298
  s := C.CString(js)
259
299
  defer C.free(unsafe.Pointer(s))
260
300
  C.webview_init(w.w, s)
@@ -294,6 +334,11 @@ func _webviewDispatchGoCallback(index unsafe.Pointer) {
294
334
  delete(dispatch, uintptr(index))
295
335
  m.Unlock()
296
336
  if f != nil {
337
+ // 入队时 destroyed 可能为 false,但回调真正执行时窗口可能已销毁,
338
+ // 二次检查:销毁后 UI 线程即将退出,未执行的派发任务直接丢弃。
339
+ if destroyed.Load() {
340
+ return
341
+ }
297
342
  f()
298
343
  }
299
344
  }
@@ -335,6 +380,9 @@ func _webviewBindingGoCallback(w C.webview_t, id *C.char, req *C.char, index uin
335
380
  }
336
381
 
337
382
  func (w *webview) Bind(name string, f interface{}) error {
383
+ if destroyed.Load() {
384
+ return errors.New("freedom: webview destroyed")
385
+ }
338
386
  v := reflect.ValueOf(f)
339
387
  // f must be a function
340
388
  if v.Kind() != reflect.Func {
@@ -413,6 +461,9 @@ func (w *webview) Bind(name string, f interface{}) error {
413
461
  }
414
462
 
415
463
  func (w *webview) Unbind(name string) error {
464
+ if destroyed.Load() {
465
+ return errors.New("freedom: webview destroyed")
466
+ }
416
467
  // 清理 Go 侧 bindings / bindNames 条目,避免 Unbind 后永久泄漏。
417
468
  // C 侧 binding_context(glue.c calloc,每 Bind 约 16B)在 C++ unbind 中
418
469
  // 仅 erase map 不 free,会小幅泄漏;但框架内 Bind 次数固定(3~4 个)、