@yufengtadian/freedom-cli 1.12.13 → 1.12.14

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,9 +1,16 @@
1
1
  # freedom-cli
2
2
 
3
- Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.13)。
3
+ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.14)。
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
+ **v1.12.14 稳定性 / 兼容性 / 打包链路修复**:
8
+ - **Go 壳层并发安全**:`Dispatch` 修复解锁后读共享 index 的数据竞争(消除 UI 事件丢失 / 偶发 panic);`App.view` 增加互斥锁,`Emit / Quit / WindowHandle` 可在任意 goroutine 安全调用(后端事件推送不再与 Run 竞态);
9
+ - **多屏适配**:窗口居中改为按窗口所在监视器工作区(rcWork)居中,副屏 / 负坐标 / 任务栏遮挡下均正确;实现改用纯 syscall,不再依赖 `x/sys/windows` 新版 API(老版本 Go 亦可编译);
10
+ - **窗口与后端健壮性**:`MinWidth / MinHeight` 任一 >0 即生效;后端进程启动前拦截已关闭状态,杜绝孤儿进程 / 二次 Run;前端自绘三按钮回调全部加 `.catch` 兜底,桥接 reject 不再静默失效;`Unbind` 清理 Go 侧绑定表消除泄漏;
11
+ - **Linux 打包根因修复**:壳下载支持代理(`FREEDOM_SHELL_PROXY / ALL_PROXY / HTTPS_PROXY`),直连 GitHub Releases 被墙超时不再导致 linux 产物打包失败;`--platform` 缺值明确报错、zipDir 跨平台(Windows 用 `tar -a`,Linux/macOS 用 `zip`)、`config get` 校验 key、CLI 改用 `process.exitCode` 不截断管道输出;
12
+ - **兼容性**:ARM Windows 识别并提示 x64 仿真运行;DPI 初始化惰性加载,Win7/8 不再 panic。
13
+
7
14
  **v1.12.13 CLI 界面升级与版本检测**:
8
15
  - CLI 交互界面升级为 Claude Code 风格:彩色分组帮助菜单、徽章化命令反馈(✓ / ✗ / ⚠ / ➜)、品牌横幅与版本信息卡;非 TTY(管道 / 重定向)或 `NO_COLOR` 下自动降级为纯文本,脚本调用与 CI 输出不受影响;
9
16
  - 新增版本检测:`freedom version` / `freedom update` 实时查询 npm registry 对比最新版本,发现新版本即给出 `npm install -g @yufengtadian/freedom-cli@latest` 升级命令;每次命令执行后静默检测一次(24 小时缓存,离线不打扰、不阻塞),有新版本自动提示升级;
@@ -121,9 +128,9 @@ freedom config set outDir dist # 恢复默认 dist/
121
128
  | 模式 | 说明 |
122
129
  | --- | --- |
123
130
  | `native` | 保留系统原生标题栏,标题栏图标与 exe 图标一致 |
124
- | `frameless` | 完全无边框,标题栏不存在,客户区铺满窗口,关闭 / 最大化 / 最小化按钮由前端自绘(默认,模板已内置示例)。当前仅 Windows 完整实现;macOS / Linux 由系统窗口管理器托管,回退为原生标题栏且窗口控制动作返回明确错误 |
131
+ | `frameless` | 完全无边框,标题栏不存在,客户区铺满窗口,关闭 / 最大化 / 最小化按钮由前端自绘(默认,模板已内置示例)。三平台完整实现:Windows 经 `WM_NCCALCSIZE` / `WM_NCHITTEST` / `WM_GETMINMAXINFO` 原生层处理,macOS / Linux `set_decorated` + 原生窗口控制(GTK `gtk_window_*` / Cocoa `performMiniaturize:` 等),行为一致(v1.12.12 起) |
125
132
 
126
- `frameless` 模式下(Windows),前端可通过注入的 `window.freedom.window` API 控制窗口(`minimize` / `maximize` / `toggleMaximize` / `close` / `isMaximized` / `isFrameless`),模板已内置自绘标题栏示例;macOS / Linux 上这些动作会 reject,前端应捕获并提示。
133
+ `frameless` 模式下,前端可通过注入的 `window.freedom.window` API 控制窗口(`minimize` / `maximize` / `toggleMaximize` / `close` / `isMaximized` / `isFrameless`),模板已内置自绘标题栏示例;三平台窗口控制动作均已接入原生实现(Windows `WM_NCLBUTTONDOWN`、GTK `gtk_window_begin_move_drag`、Cocoa 原生拖动),拖动标题栏 / 双击最大化 / 右键菜单行为一致。
127
134
 
128
135
  ## 配置(freedom.config.js)
129
136
 
package/bin/freedom.js CHANGED
@@ -8,8 +8,9 @@ run(process.argv.slice(2)).then(async (code) => {
8
8
  if (code === 0 && process.argv[2] !== 'tui') {
9
9
  try { await maybeNotifyUpdate(); } catch (e) { /* 检测失败静默 */ }
10
10
  }
11
- process.exit(code || 0);
11
+ // 用 exitCode 让 Node 自然刷新 stdout 后退出,避免 process.exit 截断管道输出(历史 bug B28)
12
+ process.exitCode = code || 0;
12
13
  }).catch((err) => {
13
14
  console.error('[freedom] 执行失败:', err && err.message ? err.message : err);
14
- process.exit(1);
15
+ process.exitCode = 1;
15
16
  });
package/lib/build.js CHANGED
@@ -308,16 +308,26 @@ ${iconEntry} <key>CFBundleExecutable</key>
308
308
  `;
309
309
  }
310
310
 
311
- // 用系统 tar(Windows bsdtar)把目录压成 zip:跨平台可用,无需额外依赖
311
+ // 把目录压成 .zip:跨平台零额外依赖(历史 bug B27)
312
+ // - Windows:系统 tar 为 bsdtar,`-a` 按扩展名自动选 zip 压缩器,可用;
313
+ // - Linux/macOS:GNU tar 的 `-a` 不支持 .zip(仅 gzip/bzip2/xz 等),
314
+ // 改用系统 zip 命令(Linux/macOS 自带或常见,缺失时给出安装提示)。
312
315
  function zipDir(zipPath, dir) {
313
316
  const parent = path.dirname(dir);
314
317
  const base = path.basename(dir);
315
318
  if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
316
- const res = spawnSync('tar', ['-a', '-c', '-f', zipPath, base], { cwd: parent, encoding: 'utf8' });
319
+ let res;
320
+ if (process.platform === 'win32') {
321
+ res = spawnSync('tar', ['-a', '-c', '-f', zipPath, base], { cwd: parent, encoding: 'utf8' });
322
+ } else {
323
+ res = spawnSync('zip', ['-r', '-q', zipPath, base], { cwd: parent, encoding: 'utf8' });
324
+ }
317
325
  if (res.error || res.status !== 0) {
318
- throw new Error(
319
- `打包 ${base} zip 失败:${(res.stderr || res.stdout || '').trim() || res.error.message}`
320
- );
326
+ const detail = (res.stderr || res.stdout || '').trim() || res.error.message;
327
+ const hint = process.platform !== 'win32'
328
+ ? '\n(Linux/macOS 打包 zip 需要 zip 命令:Ubuntu: sudo apt install zip / macOS: brew install zip)'
329
+ : '';
330
+ throw new Error(`打包 ${base} 为 zip 失败:${detail}${hint}`);
321
331
  }
322
332
  }
323
333
 
package/lib/cli.js CHANGED
@@ -117,7 +117,17 @@ async function run(argv) {
117
117
  const platArg = rest.find((a) => a.startsWith('--platform') || a.startsWith('-p'));
118
118
  let platform;
119
119
  if (platArg) {
120
- platform = platArg.includes('=') ? platArg.split('=')[1] : rest[rest.indexOf(platArg) + 1];
120
+ const inline = platArg.includes('=') ? platArg.split('=')[1] : undefined;
121
+ if (inline) {
122
+ platform = inline;
123
+ } else {
124
+ platform = rest[rest.indexOf(platArg) + 1];
125
+ }
126
+ // 缺值(--platform 后无值 / 紧跟着另一个选项)时明确报错,禁止静默回退当前平台(历史 bug B26)
127
+ if (!platform || platform.startsWith('--')) {
128
+ console.error(`${err('--platform 缺少取值。')} ${dim('用法:')}${paint('freedom build --platform <win|mac|linux|all>', C.fg.cyan)}`);
129
+ return 1;
130
+ }
121
131
  }
122
132
  const { results } = await build(process.cwd(), { platform, noCache: rest.includes('--no-cache') });
123
133
  for (const r of results) {
@@ -161,8 +171,18 @@ async function run(argv) {
161
171
  case 'config': {
162
172
  const sub = rest[0];
163
173
  if (sub === 'get') {
164
- const cfg = await showConfig(process.cwd());
165
- console.log(cfg);
174
+ const key = rest[1];
175
+ if (!key) {
176
+ console.error(`${err('用法:')}${paint('freedom config get <key>', C.fg.cyan)}`);
177
+ return 1;
178
+ }
179
+ const { getConfig } = require('./config');
180
+ const cfg = await getConfig(process.cwd());
181
+ if (!(key in cfg)) {
182
+ console.error(`${err(`配置项 ${key} 不存在。可用键:`)}${paint(Object.keys(cfg).join(' / '), C.fg.cyan)}`);
183
+ return 1;
184
+ }
185
+ console.log(JSON.stringify(cfg[key], null, 2));
166
186
  return 0;
167
187
  }
168
188
  if (sub === 'set') {
package/lib/shell.js CHANGED
@@ -163,6 +163,55 @@ function hasShell(plat) {
163
163
  return fs.existsSync(localShellPath(plat));
164
164
  }
165
165
 
166
+ // ---- 代理支持(修复:本地无 linux/mac 壳时自动下载必失败的历史根因) ----
167
+ // 背景:GitHub Releases 资产重定向到 S3,Node 内置 fetch 直连在受限网络下
168
+ // body 下载会卡死(可拿到 200 响应头但 arrayBuffer 永远不结束)。本机若跑
169
+ // 在代理后(如 socks5://127.0.0.1:10808),必须显式走代理才能拉取壳资产。
170
+ // 方案:优先识别代理环境变量,存在时改用系统 curl(跨平台自带,原生支持
171
+ // socks5h/http/https 代理)下载,Windows 加 --ssl-no-revoke 规避 schannel
172
+ // 证书吊销离线检查。
173
+
174
+ // 解析下载代理:专属变量 > 通用标准变量(大小写各试一次)
175
+ function resolveProxy() {
176
+ const keys = ['FREEDOM_SHELL_PROXY', 'ALL_PROXY', 'all_proxy', 'HTTPS_PROXY', 'https_proxy'];
177
+ for (const k of keys) {
178
+ const v = process.env[k];
179
+ if (v && typeof v === 'string' && v.trim()) return v.trim();
180
+ }
181
+ return null;
182
+ }
183
+
184
+ // curl 是否可用(Windows 10+ 自带 curl.exe;Linux/macOS 系统自带)
185
+ function hasCurl() {
186
+ if (process.env.FREEDOM_NO_CURL === '1') return false;
187
+ try {
188
+ const r = spawnSync('curl', ['--version'], { encoding: 'utf8', windowsHide: true });
189
+ return !r.error && r.status === 0;
190
+ } catch (e) {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ // 用 curl 下载到 dest;成功返回 null,失败返回错误信息
196
+ function curlDownload(url, dest, proxy) {
197
+ const args = [
198
+ '-L', '--fail', '--silent', '--show-error',
199
+ '--connect-timeout', '20', '--max-time', '180', '--retry', '2',
200
+ '--output', dest,
201
+ ];
202
+ if (proxy) args.push('--proxy', proxy);
203
+ if (process.platform === 'win32' && /^https:/i.test(url)) {
204
+ args.push('--ssl-no-revoke'); // 规避 Windows schannel CRYPT_E_REVOCATION_OFFLINE
205
+ }
206
+ args.push(url);
207
+ const r = spawnSync('curl', args, { encoding: 'utf8', windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
208
+ if (r.error || r.status !== 0) {
209
+ const detail = (r.stderr || r.stdout || '').trim() || (r.error && r.error.message) || '未知错误';
210
+ return `curl 下载失败(exit=${r.status}):${detail}`;
211
+ }
212
+ return null;
213
+ }
214
+
166
215
  // 下载指定平台壳到包内 shell/<plat>/
167
216
  // 返回下载后的绝对路径;失败抛错。
168
217
  async function downloadShell(plat) {
@@ -173,24 +222,59 @@ async function downloadShell(plat) {
173
222
  const dest = localShellPath(plat);
174
223
  fs.mkdirSync(path.dirname(dest), { recursive: true });
175
224
 
176
- process.stdout.write(`[freedom] 下载壳 ${plat} <- ${url}\n`);
177
- let res;
178
- try {
179
- // 30s 超时:网络挂起时明确报错,避免构建进程无限阻塞
180
- res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30000) });
181
- } catch (e) {
182
- if (e.name === 'AbortError' || e.name === 'TimeoutError') {
183
- throw new Error(`下载壳 ${plat} 超时(30s)。请检查网络后重试,或手动将壳二进制放入 ${localShellPath(plat)}。`);
225
+ const proxy = resolveProxy();
226
+ let buf;
227
+ if (proxy) {
228
+ process.stdout.write(`[freedom] 下载壳 ${plat}(经代理 ${proxy})<- ${url}\n`);
229
+ if (!hasCurl()) {
230
+ throw new Error(
231
+ `已配置下载代理 ${proxy} 但本机没有 curl,无法经代理下载壳。` +
232
+ `请安装 curl,或临时取消代理变量后直连重试。`
233
+ );
184
234
  }
185
- throw new Error(`下载壳 ${plat} 失败:${e.message}。请检查网络,或手动将壳二进制放入 ${localShellPath(plat)}。`);
186
- }
187
- if (!res.ok) {
188
- throw new Error(
189
- `下载壳失败:HTTP ${res.status}。请确认 GitHub 仓库 ${releaseRepo()} 已发布 ` +
190
- `${releaseTag()} 的资产 freedom-shell-${plat},或手动将壳二进制放入 ${localShellPath(plat)}。`
191
- );
235
+ const tmp = `${dest}.tmp`;
236
+ const curlErr = curlDownload(url, tmp, proxy);
237
+ if (curlErr) {
238
+ try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
239
+ throw new Error(
240
+ `${curlErr}\n下载地址 ${url} 不可达。` +
241
+ `请确认代理 ${proxy} 可用(可执行 curl --proxy ${proxy} ${url} -I 自测),` +
242
+ `或手动将壳二进制放入 ${localShellPath(plat)}。`
243
+ );
244
+ }
245
+ try {
246
+ buf = fs.readFileSync(tmp);
247
+ } finally {
248
+ try { fs.unlinkSync(tmp); } catch (e) { /* 清理失败忽略 */ }
249
+ }
250
+ } else {
251
+ process.stdout.write(`[freedom] 下载壳 ${plat} <- ${url}\n`);
252
+ let res;
253
+ try {
254
+ // 30s 超时:网络挂起时明确报错,避免构建进程无限阻塞
255
+ res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30000) });
256
+ } catch (e) {
257
+ if (e.name === 'AbortError' || e.name === 'TimeoutError') {
258
+ throw new Error(
259
+ `下载壳 ${plat} 超时(30s)。请检查网络后重试,` +
260
+ `或设置代理变量 FREEDOM_SHELL_PROXY=socks5h://127.0.0.1:10808 重试,` +
261
+ `或手动将壳二进制放入 ${localShellPath(plat)}。`
262
+ );
263
+ }
264
+ throw new Error(
265
+ `下载壳 ${plat} 失败:${e.message}。请检查网络(必要时设置 FREEDOM_SHELL_PROXY 走代理),` +
266
+ `或手动将壳二进制放入 ${localShellPath(plat)}。`
267
+ );
268
+ }
269
+ if (!res.ok) {
270
+ throw new Error(
271
+ `下载壳失败:HTTP ${res.status}。请确认 GitHub 仓库 ${releaseRepo()} 已发布 ` +
272
+ `${releaseTag()} 的资产 freedom-shell-${plat},或手动将壳二进制放入 ${localShellPath(plat)}。`
273
+ );
274
+ }
275
+ buf = Buffer.from(await res.arrayBuffer());
192
276
  }
193
- const buf = Buffer.from(await res.arrayBuffer());
277
+
194
278
  // B13:下载后、落盘前校验格式/架构,防止代理劫持返回错误页或假壳被静默分发。
195
279
  const fmt = detectShellFormat(buf);
196
280
  const want = expectedFormat(plat);
package/lib/tui.js CHANGED
@@ -254,7 +254,13 @@ async function configFlow(tui, cwd) {
254
254
  const [key, desc] = CONFIG_KEYS[idx - 1];
255
255
  const val = await tui.input(`${key}`, { initial: '' });
256
256
  if (val === null) continue;
257
- setConfig(cwd, key, coerce(val));
257
+ try {
258
+ setConfig(cwd, key, coerce(val));
259
+ } catch (e) {
260
+ // 抛错必须留在 TUI 内提示,否则绕过 exit() 导致终端残留 raw mode / 光标隐藏(历史 bug B12)
261
+ await tui.message('配置', [{ text: C.fgRed + ` 设置失败:${e.message}` + C.reset }]);
262
+ continue;
263
+ }
258
264
  await tui.message('配置', [{ text: C.fgGreen + ` ${key} = ${val}` + C.reset }]);
259
265
  }
260
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yufengtadian/freedom-cli",
3
- "version": "1.12.13",
3
+ "version": "1.12.14",
4
4
  "description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
5
5
  "keywords": [
6
6
  "desktop",
Binary file
Binary file
Binary file
@@ -4,7 +4,8 @@ import "freedom-cli-shell/pkg/freedom"
4
4
 
5
5
  // main 是 Freedom 壳层应用入口。
6
6
  // 窗口与标题栏配置来自 build 阶段生成的 gen_config.go(appConfig),
7
- // 前端页面由 pkg/freedom assets_embed.go 嵌入(build 阶段把打包后的 index.html 放进去)。
7
+ // 前端页面运行时从 exe 同目录 resources/index.html 加载(build 阶段由 CLI
8
+ // 把打包后的 index.html 与 config.json 写入该目录,壳 SetHtml 内存加载、不占端口)。
8
9
  func main() {
9
10
  cfg := appConfig()
10
11
  app := freedom.New(cfg)
@@ -71,13 +71,19 @@
71
71
  var q = function (s) { return typeof s === 'string' ? document.querySelector(s) : s; };
72
72
  var min = q(sel && sel.min), max = q(sel && sel.max), close = q(sel && sel.close);
73
73
  var self = this;
74
- if (min) min.addEventListener('click', function () { self.minimize(); });
74
+ // 所有桥接调用均带 .catch:页面先于桥接就绪时点击按钮会产生未捕获的
75
+ // Promise rejection(并导致自绘标题栏静默失效),此处兜底为控制台告警。
76
+ if (min) min.addEventListener('click', function () {
77
+ self.minimize().catch(function (e) { console.warn('[freedom] minimize:', e); });
78
+ });
75
79
  if (max) max.addEventListener('click', function () {
76
80
  self.isMaximized().then(function (m) {
77
- if (m) self.unmaximize(); else self.maximize();
78
- });
81
+ if (m) return self.unmaximize(); else return self.maximize();
82
+ }).catch(function (e) { console.warn('[freedom] maximize toggle:', e); });
83
+ });
84
+ if (close) close.addEventListener('click', function () {
85
+ self.close().catch(function (e) { console.warn('[freedom] close:', e); });
79
86
  });
80
- if (close) close.addEventListener('click', function () { self.close(); });
81
87
  };
82
88
 
83
89
  function windowAction(action) {
@@ -97,6 +97,9 @@ func (p *ProcBackend) OnEvent(fn func(event string, data interface{})) {
97
97
  func (p *ProcBackend) start() error {
98
98
  p.mu.Lock()
99
99
  defer p.mu.Unlock()
100
+ if p.closed {
101
+ return fmt.Errorf("freedom: proc backend is closed")
102
+ }
100
103
  if p.stdin != nil {
101
104
  return nil // 已启动
102
105
  }
@@ -8,18 +8,36 @@ import (
8
8
  )
9
9
 
10
10
  var (
11
- user32 = syscall.NewLazyDLL("user32.dll")
12
- getSystemMetrics = user32.NewProc("GetSystemMetrics")
13
- moveWindow = user32.NewProc("MoveWindow")
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")
14
16
  )
15
17
 
16
18
  const (
17
- smCxScreen = 16
18
- smCyScreen = 17
19
+ smCxScreen = 16
20
+ smCyScreen = 17
21
+ monitorDefaultToNear = 2 // MONITOR_DEFAULTTONEAREST
19
22
  )
20
23
 
24
+ type rect struct {
25
+ left, top, right, bottom int32
26
+ }
27
+
28
+ // monitorInfo 对应 Win32 MONITORINFO(cbSize 置 0 时用 struct 大小填充,
29
+ // 32 位下与 64 位下布局一致,字段对齐与 cbSize 校验兼容)。
30
+ type monitorInfo struct {
31
+ cbSize uint32
32
+ rcMonitor rect
33
+ rcWork rect
34
+ dwFlags uint32
35
+ }
36
+
21
37
  // applyCenter 在 Windows 上把窗口置于屏幕中央(需在 SetSize 之后调用)。
22
38
  // webview_go 未提供 SetPosition,这里通过原生 HWND + MoveWindow 定位。
39
+ // 优先居中到窗口当前所在监视器的工作区(rcWork):多屏副屏(负坐标 /
40
+ // 不同分辨率 / 任务栏遮挡)下也能正确居中;API 失败时回退主屏全屏居中。
23
41
  func (a *App) applyCenter() {
24
42
  if !a.cfg.Center || a.view == nil {
25
43
  return
@@ -28,6 +46,27 @@ func (a *App) applyCenter() {
28
46
  if hwnd == 0 {
29
47
  return
30
48
  }
49
+ mon, _, _ := monitorFromWindow.Call(hwnd, monitorDefaultToNear)
50
+ if mon != 0 {
51
+ var mi monitorInfo
52
+ mi.cbSize = uint32(unsafe.Sizeof(mi))
53
+ if r1, _, _ := getMonitorInfo.Call(mon, uintptr(unsafe.Pointer(&mi))); r1 != 0 {
54
+ work := mi.rcWork
55
+ sw := work.right - work.left
56
+ sh := work.bottom - work.top
57
+ x := work.left + (sw-int32(a.cfg.Width))/2
58
+ y := work.top + (sh-int32(a.cfg.Height))/2
59
+ if x < 0 {
60
+ x = 0
61
+ }
62
+ if y < 0 {
63
+ y = 0
64
+ }
65
+ moveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(a.cfg.Width), uintptr(a.cfg.Height), 1)
66
+ return
67
+ }
68
+ }
69
+ // 回退:主屏全屏尺寸居中(原实现)
31
70
  sw, _, _ := getSystemMetrics.Call(smCxScreen)
32
71
  sh, _, _ := getSystemMetrics.Call(smCyScreen)
33
72
  x := int(sw/2) - a.cfg.Width/2
@@ -1,283 +1,303 @@
1
- // Package freedom 是一个从零自研的 WebView 桌面壳子框架(对标 Wails / Tauri)。
2
- //
3
- // Freedom 2.0 架构:
4
- // - shell:跨平台渲染壳。复用各系统自带 WebView 内核(Windows WebView2 / macOS
5
- // WKWebView / Linux WebKitGTK),通过跨平台库 webview_go 绑定,自身不携带浏览器
6
- // 内核。壳负责窗口生命周期、前端资源 embed、后端进程管理、IPC 路由。一份壳代码
7
- // 编译三平台。
8
- // - backend:后端抽象。任意时刻绑定一个后端,前端 window.freedom.call 全部路由过去。
9
- // 内置两种实现:
10
- // * EmbedBackend:Go 方法直接注册在壳进程内(app.Bind,兼容 v1 用法)。
11
- // * ProcBackend:后端是任意语言实现的独立进程(Go/Rust/Python/Node/Java…),
12
- // 通过 stdin/stdout 上的 NDJSON/JSON-RPC 与壳通信。协议语言无关,
13
- // 换一种后端语言无需改壳与前端。
14
- // - ipc:双向桥接。前端 window.freedom.call(method, ...args) -> Promise;
15
- // 后端 app.Emit(event, data) 向所有前端监听器推送事件。
16
- // - assets:前端编译产物(Vite 单文件 HTML)通过 go:embed 嵌入二进制,运行时
17
- // 直接 SetHtml 从内存加载,无需本地 HTTP 端口。
18
- package freedom
19
-
20
- import (
21
- "encoding/json"
22
- "fmt"
23
- "unsafe"
24
-
25
- webview "github.com/webview/webview_go"
26
- )
27
-
28
- // TitleBarMode 描述窗口标题栏策略。
29
- type TitleBarMode string
30
-
31
- const (
32
- // TitleBarNative 保留系统原生标题栏(默认),标题栏图标与 exe 图标一致。
33
- TitleBarNative TitleBarMode = "native"
34
- // TitleBarFrameless 完全无边框,客户区铺满整个窗口。
35
- // 标题栏与系统原生最小化 / 最大化 / 关闭按钮均不存在,
36
- // 需由前端自绘(通过 window.freedom.window.* 控制),三平台行为一致。
37
- TitleBarFrameless TitleBarMode = "frameless"
38
- )
39
-
40
- // Config 描述一个 Freedom 应用的窗口与运行配置。
41
- type Config struct {
42
- // Title 是窗口标题。
43
- Title string
44
- // TitleBar 指定标题栏策略(默认 TitleBarNative)。可通过 freedom CLI 一键切换。
45
- TitleBar TitleBarMode
46
- // Width / Height 是窗口初始尺寸(像素)。
47
- Width int
48
- Height int
49
- // Center 为 true 时窗口在屏幕居中(Windows 生效;macOS/Linux 由窗口管理器决定)。
50
- Center bool
51
- // MinWidth / MinHeight 为窗口最小尺寸(<=0 表示不限制)。
52
- MinWidth int
53
- MinHeight int
54
- // Debug 为 true 时开启 WebView 开发者工具(目标平台支持时)。
55
- Debug bool
56
- // Backend 指定后端适配器。为空时默认使用内嵌 Go 后端(配合 Bind 使用)。
57
- Backend Backend
58
- // HTML 返回要加载到窗口的前端页面内容(内存加载,无本地端口)。
59
- // nil 时使用框架内置的默认占位页。
60
- HTML func() (string, error)
61
- }
62
-
63
- // App 是 Freedom 应用实例。
64
- type App struct {
65
- cfg Config
66
- view webview.WebView
67
- backend Backend
68
- backendExplicit bool // 调用方是否显式指定了后端(外部 config.json 不应覆盖显式绑定)
69
- onReady func(a *App)
70
- }
71
-
72
- // New 创建并初始化一个 Freedom 应用。调用 Run() 之前不会显示窗口。
73
- func New(cfg Config) *App {
74
- if cfg.Title == "" {
75
- cfg.Title = "Freedom App"
76
- }
77
- if cfg.TitleBar == "" {
78
- cfg.TitleBar = TitleBarNative
79
- }
80
- if cfg.Width <= 0 {
81
- cfg.Width = 1024
82
- }
83
- if cfg.Height <= 0 {
84
- cfg.Height = 768
85
- }
86
- explicit := cfg.Backend != nil
87
- if !explicit {
88
- cfg.Backend = NewEmbedBackend()
89
- }
90
- return &App{cfg: cfg, backend: cfg.Backend, backendExplicit: explicit}
91
- }
92
-
93
- // OnReady 注册一个回调,在窗口与桥接层就绪、页面加载前执行。
94
- // 回调内可以安全调用 Emit 向已注入的页面发送初始化事件。
95
- func (a *App) OnReady(fn func(a *App)) {
96
- a.onReady = fn
97
- }
98
-
99
- // Bind 把后端 Go 方法暴露给前端。仅当后端为内嵌 Go 后端(默认)时有效;
100
- // 进程后端的方法由后端进程自身注册,此处调用会返回错误。
101
- //
102
- // - fn 必须是函数。
103
- // - 参数与返回值通过 JSON 编解码,前端用 window.freedom.call(name, ...args) 调用,
104
- // 返回 Promise(resolve 为返回值,reject 为 error 的字符串表示)。
105
- // - 返回值约定:可返回 (T, error) 或仅 (T) 或仅 error 或空。
106
- func (a *App) Bind(name string, fn interface{}) error {
107
- eb, ok := a.backend.(*EmbedBackend)
108
- if !ok {
109
- return fmt.Errorf("freedom: Bind 仅适用于内嵌 Go 后端;当前后端为 %T,方法请在进程后端中注册", a.backend)
110
- }
111
- return eb.Bind(name, fn)
112
- }
113
-
114
- // Unbind 移除先前 Bind 的方法(内嵌后端)。
115
- func (a *App) Unbind(name string) {
116
- if eb, ok := a.backend.(*EmbedBackend); ok {
117
- eb.Unbind(name)
118
- }
119
- }
120
-
121
- // Run 启动窗口并进入主事件循环,阻塞直到窗口被关闭。
122
- func (a *App) Run() {
123
- // 通用壳:先加载 exe 同目录 resources/config.json 覆盖窗口与后端配置
124
- //(CLI build 时写入;缺失则使用编译期/默认配置)。
125
- // 配置存在但非法时打印告警(不中断启动),避免用户手改配置出错时静默无感。
126
- if err := a.loadRuntimeConfig(); err != nil {
127
- fmt.Printf("freedom: warning: %v\n", err)
128
- }
129
-
130
- html, err := a.resolveHTML()
131
- if err != nil {
132
- fmt.Printf("freedom: failed to resolve HTML: %v\n", err)
133
- return
134
- }
135
-
136
- w := webview.New(a.cfg.Debug)
137
- if w == nil {
138
- fmt.Println("freedom: failed to create webview")
139
- return
140
- }
141
- a.view = w
142
- defer func() {
143
- _ = a.backend.Close()
144
- w.Destroy()
145
- }()
146
-
147
- // 进程后端:启动后端进程并把其推送的事件转发到前端。
148
- if pb, ok := a.backend.(*ProcBackend); ok {
149
- pb.OnEvent(func(event string, data interface{}) {
150
- a.Emit(event, data)
151
- })
152
- if err := pb.start(); err != nil {
153
- fmt.Printf("freedom: backend start failed: %v\n", err)
154
- return
155
- }
156
- }
157
-
158
- w.SetTitle(a.cfg.Title)
159
- w.SetSize(a.cfg.Width, a.cfg.Height, webview.HintNone)
160
- if a.cfg.MinWidth > 0 && a.cfg.MinHeight > 0 {
161
- w.SetSize(a.cfg.MinWidth, a.cfg.MinHeight, webview.HintMin)
162
- }
163
- a.applyCenter()
164
- a.applyTitleBar()
165
- a.setWindowIcon()
166
-
167
- // 注入前端 SDK:window.freedom 全局对象。
168
- w.Init(jsSDK)
169
-
170
- // 绑定 IPC 桥接入口:前端 window.__freedom_bridge(method, paramsJson)
171
- // webview_go 的 Bind 会让前端调用返回 Promise,Go 侧结果自动 JSON 序列化回传。
172
- if err := w.Bind("__freedom_bridge", a.bridge); err != nil {
173
- fmt.Printf("freedom: failed to bind bridge: %v\n", err)
174
- return
175
- }
176
- // 框架内置方法:健康检查。
177
- if err := w.Bind("__freedom__ping", func() string { return "pong" }); err != nil {
178
- fmt.Printf("freedom: failed to bind ping: %v\n", err)
179
- return
180
- }
181
- // 框架内置方法:窗口控制(无边框模式下前端自绘按钮使用)。
182
- // 支持动作:minimize / maximize / unmaximize / toggleMaximize / close / isMaximized / isFrameless。
183
- if err := w.Bind("__freedom_window", a.windowControl); err != nil {
184
- fmt.Printf("freedom: failed to bind window control: %v\n", err)
185
- return
186
- }
187
-
188
- if a.onReady != nil {
189
- a.onReady(a)
190
- }
191
-
192
- w.SetHtml(html)
193
- w.Run()
194
- }
195
-
196
- // bridge 是前端调用后端的统一入口(JSON-RPC 风格)。
197
- // 前端 SDK 通过 window.__freedom_bridge(method, paramsJson) 调用,
198
- // 桥接层把请求转发给当前绑定的后端(内嵌 Go 方法或任意语言进程)。
199
- func (a *App) bridge(method string, paramsJSON string) (result json.RawMessage, err error) {
200
- // 防御后端方法 panic 导致整个窗口崩溃:统一转为错误回传前端。
201
- defer func() {
202
- if r := recover(); r != nil {
203
- result = nil
204
- err = fmt.Errorf("freedom: method %q panicked: %v", method, r)
205
- }
206
- }()
207
-
208
- var params []json.RawMessage
209
- if len(paramsJSON) > 0 && paramsJSON != "null" {
210
- if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
211
- return nil, fmt.Errorf("freedom: method %q: invalid params: %w", method, err)
212
- }
213
- }
214
-
215
- // 框架内置方法特判:__freedom__ping webview Bind 注册为全局函数
216
- // window.__freedom__ping(),同时兼容经 freedom.call()/__freedom_bridge 路由的旧写法,
217
- // 避免模板"测试桥接"自检报"method not bound"。
218
- if method == "__freedom__ping" {
219
- return json.RawMessage(`"pong"`), nil
220
- }
221
-
222
- raw, err := a.backend.Handle(method, params)
223
- if err != nil {
224
- return nil, err
225
- }
226
- data, err := json.Marshal(raw)
227
- if err != nil {
228
- return nil, fmt.Errorf("freedom: method %q: cannot marshal result: %w", method, err)
229
- }
230
- return json.RawMessage(data), nil
231
- }
232
-
233
- // Emit 把事件推送到前端。前端通过 window.freedom.on(event, cb) 订阅。
234
- // 线程安全:可从任意 goroutine 调用(进程后端推送的事件亦经由本函数)。
235
- func (a *App) Emit(event string, data interface{}) {
236
- if a.view == nil {
237
- return
238
- }
239
- eb, _ := json.Marshal(event)
240
- db, err := json.Marshal(data)
241
- if err != nil {
242
- db = []byte("null")
243
- }
244
- js := "window.freedom && window.freedom.emit(" + string(eb) + "," + string(db) + ");"
245
- a.view.Dispatch(func() {
246
- a.view.Eval(js)
247
- })
248
- }
249
-
250
- // Quit 关闭窗口并退出应用。可从任意 goroutine 调用。
251
- func (a *App) Quit() {
252
- if a.view != nil {
253
- a.view.Dispatch(func() {
254
- a.view.Terminate()
255
- })
256
- }
257
- }
258
-
259
- // WindowHandle 返回底层原生窗口句柄(Windows 上为 HWND)。
260
- func (a *App) WindowHandle() uintptr {
261
- if a.view == nil {
262
- return 0
263
- }
264
- return uintptr(unsafe.Pointer(a.view.Window()))
265
- }
266
-
267
- // windowControl 处理前端 window.freedom.window.* 的窗口控制请求。
268
- // 具体实现按平台分文件:window_windows.go(Windows)/ window_other.go(macOS、Linux)。
269
- func (a *App) windowControl(action string) (interface{}, error) {
270
- return windowControl(a, action)
271
- }
272
-
273
- // resolveHTML 依据配置返回页面内容。
274
- // 优先级:exe 同目录 resources/index.html(预编译通用壳,CLI build 写入)> cfg.HTML > 内置占位页。
275
- func (a *App) resolveHTML() (string, error) {
276
- if html, err := loadRuntimeHTML(); err == nil && html != "" {
277
- return html, nil
278
- }
279
- if a.cfg.HTML != nil {
280
- return a.cfg.HTML()
281
- }
282
- return defaultHTML, nil
283
- }
1
+ // Package freedom 是一个从零自研的 WebView 桌面壳子框架(对标 Wails / Tauri)。
2
+ //
3
+ // Freedom 2.0 架构:
4
+ // - shell:跨平台渲染壳。复用各系统自带 WebView 内核(Windows WebView2 / macOS
5
+ // WKWebView / Linux WebKitGTK),通过跨平台库 webview_go 绑定,自身不携带浏览器
6
+ // 内核。壳负责窗口生命周期、前端资源 embed、后端进程管理、IPC 路由。一份壳代码
7
+ // 编译三平台。
8
+ // - backend:后端抽象。任意时刻绑定一个后端,前端 window.freedom.call 全部路由过去。
9
+ // 内置两种实现:
10
+ // * EmbedBackend:Go 方法直接注册在壳进程内(app.Bind,兼容 v1 用法)。
11
+ // * ProcBackend:后端是任意语言实现的独立进程(Go/Rust/Python/Node/Java…),
12
+ // 通过 stdin/stdout 上的 NDJSON/JSON-RPC 与壳通信。协议语言无关,
13
+ // 换一种后端语言无需改壳与前端。
14
+ // - ipc:双向桥接。前端 window.freedom.call(method, ...args) -> Promise;
15
+ // 后端 app.Emit(event, data) 向所有前端监听器推送事件。
16
+ // - assets:前端编译产物(Vite 单文件 HTML)通过 go:embed 嵌入二进制,运行时
17
+ // 直接 SetHtml 从内存加载,无需本地 HTTP 端口。
18
+ package freedom
19
+
20
+ import (
21
+ "encoding/json"
22
+ "fmt"
23
+ "sync"
24
+ "unsafe"
25
+
26
+ webview "github.com/webview/webview_go"
27
+ )
28
+
29
+ // TitleBarMode 描述窗口标题栏策略。
30
+ type TitleBarMode string
31
+
32
+ const (
33
+ // TitleBarNative 保留系统原生标题栏(默认),标题栏图标与 exe 图标一致。
34
+ TitleBarNative TitleBarMode = "native"
35
+ // TitleBarFrameless 完全无边框,客户区铺满整个窗口。
36
+ // 标题栏与系统原生最小化 / 最大化 / 关闭按钮均不存在,
37
+ // 需由前端自绘(通过 window.freedom.window.* 控制),三平台行为一致。
38
+ TitleBarFrameless TitleBarMode = "frameless"
39
+ )
40
+
41
+ // Config 描述一个 Freedom 应用的窗口与运行配置。
42
+ type Config struct {
43
+ // Title 是窗口标题。
44
+ Title string
45
+ // TitleBar 指定标题栏策略(默认 TitleBarNative)。可通过 freedom CLI 一键切换。
46
+ TitleBar TitleBarMode
47
+ // Width / Height 是窗口初始尺寸(像素)。
48
+ Width int
49
+ Height int
50
+ // Center 为 true 时窗口在屏幕居中(Windows 生效;macOS/Linux 由窗口管理器决定)。
51
+ Center bool
52
+ // MinWidth / MinHeight 为窗口最小尺寸(<=0 表示不限制)。
53
+ MinWidth int
54
+ MinHeight int
55
+ // Debug 为 true 时开启 WebView 开发者工具(目标平台支持时)。
56
+ Debug bool
57
+ // Backend 指定后端适配器。为空时默认使用内嵌 Go 后端(配合 Bind 使用)。
58
+ Backend Backend
59
+ // HTML 返回要加载到窗口的前端页面内容(内存加载,无本地端口)。
60
+ // nil 时使用框架内置的默认占位页。
61
+ HTML func() (string, error)
62
+ }
63
+
64
+ // App Freedom 应用实例。
65
+ type App struct {
66
+ cfg Config
67
+ viewMu sync.Mutex // 保护 view 字段(Run 写 / Emit·Quit·WindowHandle 任意 goroutine 读)
68
+ view webview.WebView
69
+ backend Backend
70
+ backendExplicit bool // 调用方是否显式指定了后端(外部 config.json 不应覆盖显式绑定)
71
+ onReady func(a *App)
72
+ }
73
+
74
+ // New 创建并初始化一个 Freedom 应用。调用 Run() 之前不会显示窗口。
75
+ func New(cfg Config) *App {
76
+ if cfg.Title == "" {
77
+ cfg.Title = "Freedom App"
78
+ }
79
+ if cfg.TitleBar == "" {
80
+ cfg.TitleBar = TitleBarNative
81
+ }
82
+ if cfg.Width <= 0 {
83
+ cfg.Width = 1024
84
+ }
85
+ if cfg.Height <= 0 {
86
+ cfg.Height = 768
87
+ }
88
+ explicit := cfg.Backend != nil
89
+ if !explicit {
90
+ cfg.Backend = NewEmbedBackend()
91
+ }
92
+ return &App{cfg: cfg, backend: cfg.Backend, backendExplicit: explicit}
93
+ }
94
+
95
+ // OnReady 注册一个回调,在窗口与桥接层就绪、页面加载前执行。
96
+ // 回调内可以安全调用 Emit 向已注入的页面发送初始化事件。
97
+ func (a *App) OnReady(fn func(a *App)) {
98
+ a.onReady = fn
99
+ }
100
+
101
+ // Bind 把后端 Go 方法暴露给前端。仅当后端为内嵌 Go 后端(默认)时有效;
102
+ // 进程后端的方法由后端进程自身注册,此处调用会返回错误。
103
+ //
104
+ // - fn 必须是函数。
105
+ // - 参数与返回值通过 JSON 编解码,前端用 window.freedom.call(name, ...args) 调用,
106
+ // 返回 Promise(resolve 为返回值,reject error 的字符串表示)。
107
+ // - 返回值约定:可返回 (T, error) 或仅 (T) 或仅 error 或空。
108
+ func (a *App) Bind(name string, fn interface{}) error {
109
+ eb, ok := a.backend.(*EmbedBackend)
110
+ if !ok {
111
+ return fmt.Errorf("freedom: Bind 仅适用于内嵌 Go 后端;当前后端为 %T,方法请在进程后端中注册", a.backend)
112
+ }
113
+ return eb.Bind(name, fn)
114
+ }
115
+
116
+ // Unbind 移除先前 Bind 的方法(内嵌后端)。
117
+ func (a *App) Unbind(name string) {
118
+ if eb, ok := a.backend.(*EmbedBackend); ok {
119
+ eb.Unbind(name)
120
+ }
121
+ }
122
+
123
+ // getView / setView 以互斥锁保护 view 字段:Run 在主 goroutine 写,
124
+ // Emit/Quit/WindowHandle 可能被任意 goroutine(如后端事件推送)读,
125
+ // 无保护时 race detector 报数据竞争。
126
+ func (a *App) getView() webview.WebView {
127
+ a.viewMu.Lock()
128
+ defer a.viewMu.Unlock()
129
+ return a.view
130
+ }
131
+
132
+ func (a *App) setView(v webview.WebView) {
133
+ a.viewMu.Lock()
134
+ a.view = v
135
+ a.viewMu.Unlock()
136
+ }
137
+
138
+ // Run 启动窗口并进入主事件循环,阻塞直到窗口被关闭。
139
+ func (a *App) Run() {
140
+ // 通用壳:先加载 exe 同目录 resources/config.json 覆盖窗口与后端配置
141
+ //(CLI build 时写入;缺失则使用编译期/默认配置)。
142
+ // 配置存在但非法时打印告警(不中断启动),避免用户手改配置出错时静默无感。
143
+ if err := a.loadRuntimeConfig(); err != nil {
144
+ fmt.Printf("freedom: warning: %v\n", err)
145
+ }
146
+
147
+ html, err := a.resolveHTML()
148
+ if err != nil {
149
+ fmt.Printf("freedom: failed to resolve HTML: %v\n", err)
150
+ return
151
+ }
152
+
153
+ w := webview.New(a.cfg.Debug)
154
+ if w == nil {
155
+ fmt.Println("freedom: failed to create webview")
156
+ return
157
+ }
158
+ a.setView(w)
159
+ defer func() {
160
+ _ = a.backend.Close()
161
+ w.Destroy()
162
+ }()
163
+
164
+ // 进程后端:启动后端进程并把其推送的事件转发到前端。
165
+ if pb, ok := a.backend.(*ProcBackend); ok {
166
+ pb.OnEvent(func(event string, data interface{}) {
167
+ a.Emit(event, data)
168
+ })
169
+ if err := pb.start(); err != nil {
170
+ fmt.Printf("freedom: backend start failed: %v\n", err)
171
+ return
172
+ }
173
+ }
174
+
175
+ w.SetTitle(a.cfg.Title)
176
+ w.SetSize(a.cfg.Width, a.cfg.Height, webview.HintNone)
177
+ // 最小尺寸:任一项 >0 即生效;未设置的一项传 0 表示该维度不限制
178
+ //(GTK gtk_widget_set_size_request / Cocoa minSize 对 0 均视为"未指定")。
179
+ if a.cfg.MinWidth > 0 || a.cfg.MinHeight > 0 {
180
+ w.SetSize(a.cfg.MinWidth, a.cfg.MinHeight, webview.HintMin)
181
+ }
182
+ a.applyCenter()
183
+ a.applyTitleBar()
184
+ a.setWindowIcon()
185
+
186
+ // 注入前端 SDK:window.freedom 全局对象。
187
+ w.Init(jsSDK)
188
+
189
+ // 绑定 IPC 桥接入口:前端 window.__freedom_bridge(method, paramsJson)
190
+ // webview_go 的 Bind 会让前端调用返回 Promise,Go 侧结果自动 JSON 序列化回传。
191
+ if err := w.Bind("__freedom_bridge", a.bridge); err != nil {
192
+ fmt.Printf("freedom: failed to bind bridge: %v\n", err)
193
+ return
194
+ }
195
+ // 框架内置方法:健康检查。
196
+ if err := w.Bind("__freedom__ping", func() string { return "pong" }); err != nil {
197
+ fmt.Printf("freedom: failed to bind ping: %v\n", err)
198
+ return
199
+ }
200
+ // 框架内置方法:窗口控制(无边框模式下前端自绘按钮使用)。
201
+ // 支持动作:minimize / maximize / unmaximize / toggleMaximize / close / isMaximized / isFrameless。
202
+ if err := w.Bind("__freedom_window", a.windowControl); err != nil {
203
+ fmt.Printf("freedom: failed to bind window control: %v\n", err)
204
+ return
205
+ }
206
+
207
+ if a.onReady != nil {
208
+ a.onReady(a)
209
+ }
210
+
211
+ w.SetHtml(html)
212
+ w.Run()
213
+ }
214
+
215
+ // bridge 是前端调用后端的统一入口(JSON-RPC 风格)。
216
+ // 前端 SDK 通过 window.__freedom_bridge(method, paramsJson) 调用,
217
+ // 桥接层把请求转发给当前绑定的后端(内嵌 Go 方法或任意语言进程)。
218
+ func (a *App) bridge(method string, paramsJSON string) (result json.RawMessage, err error) {
219
+ // 防御后端方法 panic 导致整个窗口崩溃:统一转为错误回传前端。
220
+ defer func() {
221
+ if r := recover(); r != nil {
222
+ result = nil
223
+ err = fmt.Errorf("freedom: method %q panicked: %v", method, r)
224
+ }
225
+ }()
226
+
227
+ var params []json.RawMessage
228
+ if len(paramsJSON) > 0 && paramsJSON != "null" {
229
+ if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
230
+ return nil, fmt.Errorf("freedom: method %q: invalid params: %w", method, err)
231
+ }
232
+ }
233
+
234
+ // 框架内置方法特判:__freedom__ping webview Bind 注册为全局函数
235
+ // window.__freedom__ping(),同时兼容经 freedom.call()/__freedom_bridge 路由的旧写法,
236
+ // 避免模板"测试桥接"自检报"method not bound"。
237
+ if method == "__freedom__ping" {
238
+ return json.RawMessage(`"pong"`), nil
239
+ }
240
+
241
+ raw, err := a.backend.Handle(method, params)
242
+ if err != nil {
243
+ return nil, err
244
+ }
245
+ data, err := json.Marshal(raw)
246
+ if err != nil {
247
+ return nil, fmt.Errorf("freedom: method %q: cannot marshal result: %w", method, err)
248
+ }
249
+ return json.RawMessage(data), nil
250
+ }
251
+
252
+ // Emit 把事件推送到前端。前端通过 window.freedom.on(event, cb) 订阅。
253
+ // 线程安全:可从任意 goroutine 调用(进程后端推送的事件亦经由本函数)。
254
+ func (a *App) Emit(event string, data interface{}) {
255
+ v := a.getView()
256
+ if v == nil {
257
+ return
258
+ }
259
+ eb, _ := json.Marshal(event)
260
+ db, err := json.Marshal(data)
261
+ if err != nil {
262
+ db = []byte("null")
263
+ }
264
+ js := "window.freedom && window.freedom.emit(" + string(eb) + "," + string(db) + ");"
265
+ v.Dispatch(func() {
266
+ v.Eval(js)
267
+ })
268
+ }
269
+
270
+ // Quit 关闭窗口并退出应用。可从任意 goroutine 调用。
271
+ func (a *App) Quit() {
272
+ if v := a.getView(); v != nil {
273
+ v.Dispatch(func() {
274
+ v.Terminate()
275
+ })
276
+ }
277
+ }
278
+
279
+ // WindowHandle 返回底层原生窗口句柄(Windows 上为 HWND)。
280
+ func (a *App) WindowHandle() uintptr {
281
+ if v := a.getView(); v != nil {
282
+ return uintptr(unsafe.Pointer(v.Window()))
283
+ }
284
+ return 0
285
+ }
286
+
287
+ // windowControl 处理前端 window.freedom.window.* 的窗口控制请求。
288
+ // 具体实现按平台分文件:window_windows.go(Windows)/ window_other.go(macOS、Linux)。
289
+ func (a *App) windowControl(action string) (interface{}, error) {
290
+ return windowControl(a, action)
291
+ }
292
+
293
+ // resolveHTML 依据配置返回页面内容。
294
+ // 优先级:exe 同目录 resources/index.html(预编译通用壳,CLI build 写入)> cfg.HTML > 内置占位页。
295
+ func (a *App) resolveHTML() (string, error) {
296
+ if html, err := loadRuntimeHTML(); err == nil && html != "" {
297
+ return html, nil
298
+ }
299
+ if a.cfg.HTML != nil {
300
+ return a.cfg.HTML()
301
+ }
302
+ return defaultHTML, nil
303
+ }
@@ -165,10 +165,11 @@ type webview struct {
165
165
  }
166
166
 
167
167
  var (
168
- m sync.Mutex
169
- index uintptr
170
- dispatch = map[uintptr]func(){}
171
- bindings = map[uintptr]func(id, req string) (interface{}, error){}
168
+ m sync.Mutex
169
+ index uintptr
170
+ dispatch = map[uintptr]func(){}
171
+ bindings = map[uintptr]func(id, req string) (interface{}, error){}
172
+ bindNames = map[string]uintptr{} // name -> index,供 Unbind 清理 bindings 条目
172
173
  )
173
174
 
174
175
  func boolToInt(b bool) C.int {
@@ -261,8 +262,12 @@ func (w *webview) Dispatch(f func()) {
261
262
  for ; dispatch[index] != nil; index++ {
262
263
  }
263
264
  dispatch[index] = f
265
+ // 在锁内保存局部副本再解锁:解锁后读取共享 index 属数据竞争
266
+ //(另一 goroutine 可能在 Lock 内推进 index),race detector 会报,
267
+ // 且极端时序下可能把未就绪的 index 传给 C 层导致回调失配。
268
+ idx := index
264
269
  m.Unlock()
265
- C.CgoWebViewDispatch(w.w, C.uintptr_t(index))
270
+ C.CgoWebViewDispatch(w.w, C.uintptr_t(idx))
266
271
  }
267
272
 
268
273
  //export _webviewDispatchGoCallback
@@ -364,14 +369,27 @@ func (w *webview) Bind(name string, f interface{}) error {
364
369
  for ; bindings[index] != nil; index++ {
365
370
  }
366
371
  bindings[index] = binding
372
+ bindNames[name] = index
373
+ // 锁内保存副本,避免解锁后读取共享 index(与 Dispatch 同类数据竞争)
374
+ idx := index
367
375
  m.Unlock()
368
376
  cname := C.CString(name)
369
377
  defer C.free(unsafe.Pointer(cname))
370
- C.CgoWebViewBind(w.w, cname, C.uintptr_t(index))
378
+ C.CgoWebViewBind(w.w, cname, C.uintptr_t(idx))
371
379
  return nil
372
380
  }
373
381
 
374
382
  func (w *webview) Unbind(name string) error {
383
+ // 清理 Go 侧 bindings / bindNames 条目,避免 Unbind 后永久泄漏。
384
+ // C 侧 binding_context(glue.c calloc,每 Bind 约 16B)在 C++ unbind 中
385
+ // 仅 erase map 不 free,会小幅泄漏;但框架内 Bind 次数固定(3~4 个)、
386
+ // 进程生命周期内不会反复 Bind/Unbind,实际影响可忽略,故不额外改 C 层。
387
+ m.Lock()
388
+ if idx, ok := bindNames[name]; ok {
389
+ delete(bindings, idx)
390
+ delete(bindNames, name)
391
+ }
392
+ m.Unlock()
375
393
  cname := C.CString(name)
376
394
  defer C.free(unsafe.Pointer(cname))
377
395
  C.CgoWebViewUnbind(w.w, cname)