diff --git a/AGENTS.md b/AGENTS.md index d04c532..b8ba9f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,212 +1,346 @@ # AGENTS.md -指引给 AI agent(及开发者):如何理解、构建、测试、验证、改动 Glaze。 - -## 这是什么 - -Glaze 是 "Tauri-like framework for Racket"——Racket 写后端,Web 技术做前端,桌面应用。 -三层能力: - -1. **本地 HTTP 服务器**(Phase 1,稳定):`glaze/server` -2. **资源打包 / 系统托盘 / 应用打包**(Phase 2,稳定):`glaze/assets` / `glaze/tray` / `glaze/build` -3. **原生 WebView 窗口**(Phase 3,已完成,三平台 CI e2e 验证):`glaze/webview` - -纯 Racket FFI,**不需要 C 编译器**。核心卖点之一是 agent 友好:框架提供 -`webview-title` / `webview-url` / `webview-capture!` 验证 API,让 agent 能以编程方式 -确认 UI 状态(无需人眼看屏幕)。 - -## 各平台 WebView 状态 - -| 平台 | 状态 | 说明 | -|------|------|------| -| macOS | ✅ 本机 + CI e2e | NSWindow + WKWebView(objc FFI),验证 API + devtools | -| Windows | ✅ CI e2e | 历史上卡在"COM apartment"——真相是 `get_CoreWebView2` vtable 索引错(25 被写成 3)。vtable 顺序已对官方 SDK 头文件核对,详见 `webview-windows.rkt` 头注释 | -| Linux | ✅ CI e2e(Xvfb) | 泵 + destroy 回调 + title/url/capture(gdk_pixbuf);注意 ffi-lib 需要 multiarch 绝对路径兜底 | - -## 商业化层(签名 / 许可证 / 更新校验) - -- **菜单派发(`webview-set-menu!`)**:声明式 spec 复用 tray-protocol(`make-menu` + - `make-menu-item`,`#:accel`)。macOS 是 NSApp 主菜单上追加自定义段(标准 Edit/Window - 只装一次——`ensure-app!` 重装会冲掉自定义菜单);macOS 用 target+tag 派发(`GlazeMenuTarget` - 单例),Windows 用 WM_COMMAND 的 LOWORD(wParam),Linux 用 "activate" 信号。 - **两个已踩坑**:① menu-sema 不能在重建菜单的整个过程中持有(build 内部还要申请 tag → 自锁); - 注册和派发必须查同一张表(id-allocator 的表,别另开 hash)。② 加速键在 macOS 真实生效, - Win/Linux 仅展示(v1 限制,写进文档) -- **测试菜单派发**:`performActionForItemAtIndex:` 对 objc-target 菜单项是静默 no-op; - 用 `NSApp sendAction: (item action) to: (item target) from: item` 才是真点击路径 -- **文件对话框**:macos NSSavePanel(AppKit 显式 ffi-lib 加载);Windows comdlg32(UTF-16 - 编解码用 bytes-open-converter "UTF-8"/"UTF-16LE",platform-* 名字在 macOS 不存在); - Linux zenity/kdialog 子进程。#f=取消,后端缺失 RAISE -- **开机自启**:macOS SMAppService(13+、需打包 .app、无 TCC 弹窗);Windows HKCU Run 键 - (reg.exe 子进程);Linux ~/.config/autostart 桌面条目(测试用 XDG_CONFIG_HOME 覆盖) - -- `glaze/license`:RSA-2048/SHA-256 离线许可证,签名走**系统 openssl CLI 子进程**(三平台 - 开箱即有),不引入 crypto 包。`machine-id` = IOPlatformUUID(macOS)/ - /etc/machine-id(Linux)/MachineGuid(Win) 的 SHA-256 摘要。`validate-license` 的失败 - reason 是稳定标签(signature/expired/machine/product/...),改语义先改测试 -- **macOS 打包布局**:`raco distribute` 产出的是扁平 bin/+lib/(各版本形状不一),build-app - 自己组装 `.app`(Contents/MacOS + lib + Info.plist + PkgInfo)。launcher 的 - `@executable_path/../lib` 在 MacOS/ 下深度不变,搬移安全;homebrew CS 版的 framework - 引用是绝对路径(不可重定位),官方发行版才是可分发的——发布用官方 Racket 构建 -- **codesign 顺序陷阱**:嵌套代码(framework dylib)先签、bundle 后签;一把 `--deep` 在 - Apple Silicon 会产出 Team-ID 不匹配的签名(dyld 拒绝映射)。adhoc 身份(`-`)下必须跳过 - `--options runtime`——hardened runtime 的 library validation 会拒绝 adhoc 的自身 framework -- `raco exe` 产出的 launcher 是只读的,`raco distribute` 写段会 EACCES(9.3 实测),build-app - 里已 chmod u+w;codesign 前同样要保证主 exe 可写 -- 签名失败**中止构建**(假装签好的产物比失败更糟);工具链缺失降级响亮告警——与 installer - 的降级语义不同,别搞混 -- 更新 manifest 的可选 `"sha256"` 由 `verify-file-sha256` 校验;`#f` 返回值 = "无法校验", - 永远不当成"校验通过" - -## 快速命令 +本文件给维护 Glaze 的 AI agent 和开发者使用。目标是说明项目当前真正的架构契约、容易踩坑的地方,以及改动后必须怎么验证。 -```bash -# 安装(本地开发,链接方式;仓库根即单一包) -# 注意:--link 的路径末元素必须是包名,"." 不合法,用 "$PWD" -raco pkg install --auto --no-docs --link "$PWD" +## 项目定位 -# 拉取更新后刷新链接包 -raco pkg update --link "$PWD" +Glaze 是一个 **Tauri-like framework for Racket**: -# 编译 -raco make glaze/main.rkt glaze-cli/cli.rkt +- Racket 写业务逻辑与本地 API; +- HTML/CSS/JS 写现代 UI; +- UI 运行在真正的原生桌面窗口内; +- Windows 使用 WebView2,macOS 使用 WKWebView,Linux 使用 WebKitGTK; +- 核心平台层尽量使用纯 Racket FFI,不要求用户安装 C/C++ 编译器。 -# 测试(macOS 上含 WebView e2e;Linux/Windows 自动跳过 macOS 段) -raco test glaze-test/ +### 最重要的产品契约:GUI-first -# 跑 GUI 示例(会开真窗口) -racket -e '(require glaze/server glaze/webview/main) - (define-values (p stop) (start-server #:port 18940 #:public-dir "public")) - (define wv (open-window (format "http://127.0.0.1:~a/" p))) - (sleep 30) (webview-close wv) (stop)' +**Glaze 是桌面 GUI 框架,不是“本地 server + 浏览器”的框架。** + +应用入口 `run-app`、`open-window`、`open-webview` 必须走 Native WebView: + +```text +Racket local server + ↓ +HTML / CSS / JS + ↓ +Native OS window + ↓ +WebView2 / WKWebView / WebKitGTK ``` -## 打包规则(单包多集合) +以下行为禁止重新引入: -仓库根 = 一个包(`info.rkt`,`collection 'multi`)。包级字段(name/deps/version/raco-commands…) -在根 `info.rkt`;但 **`scribblings` 和 `raco-commands` 是集合级字段**,必须放对应集合目录的 -`info.rkt`(`glaze-doc/info.rkt`、`glaze-cli/info.rkt`),raco 和 raco setup 只扫集合信息, -放包根不生效(`raco glaze` 命令会消失)。examples/ 与 scripts/ 带集合级 `compile-omit-paths`, -setup 不编译示例正文。 +- WebView 启动失败后自动打开 Chrome / Edge / Safari; +- `#:fallback-browser?`; +- `raco glaze dev` 退化成 browser-only server; +- 用“应用还能在浏览器里打开”掩盖缺失依赖或 native backend bug。 -- **版本号格式**:Racket `valid-version?` 拒绝尾部 `.0` 分量——写 `"0.5"` 不写 `"0.5.0"` -- **安装路径**:`raco pkg install --link` 的路径末元素必须是包名,`.`/`./` 不合法,用 `"$PWD"` +Native WebView 失败时的正确行为: -## 项目结构 +1. 保留底层真实异常; +2. 明确告诉用户缺少或可能损坏的运行时; +3. 给安装命令 / 官方下载入口; +4. GUI 打包程序尽量弹 OS 级错误框; +5. 终止启动; +6. 如果 `run-app` 已启动本地 server,必须先关闭 server 再抛错。 -仓库根目录即**一个**可安装的 Racket 包(根 `info.rkt`,`collection 'multi`), -每个顶层目录是一个集合(collection): +`open-browser` 仍可保留为显式工具函数,用于打开帮助文档、OAuth、支持页面等外部链接,但绝不能作为应用 UI fallback。 -``` -glaze/ # 仓库根 = `glaze` 包:一次安装装齐下列全部 -├── info.rkt # 包元数据(deps / version / raco-commands) -├── glaze/ # 核心库(collection "glaze") -│ ├── server.rkt # start-server / stop-server(start-dev-server 是别名) -│ ├── browser.rkt # open-browser(跨平台系统浏览器) -│ ├── api.rkt # API 路由值(GET/POST/PUT/DELETE + :param 捕获) -│ ├── api-macros.rkt # define-api-routes(一处声明 = 过程+路由+JS 客户端) -│ ├── events.rkt # 事件总线 → 内置 SSE 端点 /glaze/events -│ ├── assets.rkt # public/ 目录解析、MIME -│ ├── build.rkt # raco exe + distribute 封装 -│ ├── update.rkt # 更新检查(check-update / newer-version?) -│ ├── app.rkt # run-app:服务+窗口+生命周期一键入口 -│ ├── sys/ # 系统集成:剪贴板/通知/open/reveal/单实例(main 调度 + 平台后端) -│ ├── tray/ # 托盘:main.rkt 调度 + tray-{windows,macos,linux,stub}.rkt -│ └── webview/ # WebView:main.rkt 调度 + webview-{windows,macos,linux,stub}.rkt -├── glaze-cli/ # raco glaze init / dev / build -├── glaze-doc/ # scribble 文档(scribblings 声明在其集合级 info.rkt) -├── glaze-test/ # rackunit 套件(main + webview + api + events + hardening + sys) -├── examples/ # showcase / hello / counter / agent-verify / tray-demo / webview-demo(不参与 setup 编译) -└── scripts/ # webview-e2e.rkt(CI 用) -``` +## WebView 缺失反馈 + +公开层在 `glaze/webview/main.rkt`: + +- `webview-install-guidance`:平台依赖安装说明; +- `webview-diagnostic`:底层异常 + 安装说明; +- `webview-last-error`:最近一次 probe / startup 错误; +- `glaze/webview/startup-feedback.rkt`:在交互式桌面环境尝试显示系统错误对话框; +- CI / GitHub Actions 自动关闭错误对话框,避免无人值守任务阻塞; +- `GLAZE_NO_STARTUP_DIALOG=1` 可显式禁用错误对话框。 + +Windows 依赖:Microsoft Edge WebView2 Runtime (Evergreen)。Glaze 自己带 `WebView2Loader.dll`,不要把 Loader DLL 和 Runtime 混为一谈。 + +Linux 依赖:GTK 3 + WebKitGTK + 图形桌面会话(CI 使用 Xvfb)。 + +macOS 的 WKWebView 随系统提供;失败时重点保留初始化错误和运行环境信息。 + +## 平台 WebView 状态 + +| 平台 | 后端 | 状态 | +|---|---|---| +| Windows | Win32 + WebView2 COM FFI | CI 真窗口 e2e | +| macOS | NSWindow + WKWebView objc FFI | 本机 + CI 真窗口 e2e | +| Linux | GtkWindow + WebKitGTK FFI | CI Xvfb 真窗口 e2e | + +三平台 e2e 应至少覆盖:open、页面加载、title/url、截图、navigate、close、on-close。 + +## Windows WebView2 关键历史坑 + +`glaze/webview/webview-windows.rkt` 的 COM vtable 索引必须以官方 WebView2 SDK header 为准,不能靠相邻接口猜。 -## 系统集成(glaze/sys) +曾经最难查的问题不是 COM apartment,而是把 `ICoreWebView2Controller::get_CoreWebView2` 的 vtable slot 写错。错误 slot 会写入 BOOL,再被当成接口指针使用,表现得像随机 COM 生命周期崩溃。 -- 剪贴板(三平台 FFI)、通知(三平台:mac osascript / linux notify-send / - windows WinRT toast 经 PowerShell 子进程,脚本走临时 .ps1 避开命令行转义)、 - open/reveal、单实例锁(派生端口绑定) -- 窗口控制:`webview-set-title!/set-size!/set-fullscreen!`、`webview-focus!`(四后端) -- **AppKit 必须显式加载**:Racket 只链接 Foundation;不加载 AppKit 的进程里 - NSStatusBar/NSPasteboard 等类为 NULL,objc 消息发给 nil 静默返回 nil(曾致 tray 空转) +现有关键约定: -## 加固层 +- COM vtable 函数指针用 `_fpointer` 读取; +- out 参数先检查 HRESULT; +- controller 与 CoreWebView2 接口在 callback 内 AddRef 并保存在 handle; +- 初始化链在同一个 STA / Racket OS thread 上由消息泵推进; +- 不要未经 SDK header 核对就新增 vtable index。 -- `#:api-token`(start-server/run-app):只护 API+SSE;run-app 打开一次性 `?glaze-token=` 引导 URL, - 服务器把 token 换成 HttpOnly cookie 后 302 回净路径;api.js 有意不发凭据(曾经发过 = 任何本地进程 - curl 一下就绕过 token);程序化走 `X-Glaze-Token`;诚实边界写在 README(同用户进程仍可读内存) -- `current-glaze-error-reporter`(parameter):500 路径的异常上报,run-app `#:on-error` 装配; - **必须先 parameterize 再 start-server**(连接线程继承 accept 循环的 parameterization) -- `glaze/update`:`check-update` + `newer-version?`(数值点分比较,"1.10">"1.9");run-app - `#:check-update`/`#:current-version` 通知 + 广播;注意 `#rx` 不支持 `{n}` 量词(用 `#px`) +## macOS WebView 关键点 -## 事件推送 / 宏路由 / 内置端点 +- AppKit 必须显式加载;纯 `racket` 进程默认不保证已加载 AppKit; +- UI 事件通过非阻塞 run-loop pump 服务,不能用会长期阻塞 Racket OS thread 的调用; +- 多窗口共享 run-loop pump,关闭一个窗口不能让其他窗口失去事件服务; +- 打包 `.app` 后和裸 `racket` 进程的生命周期/激活行为并不完全一致,改 backend 后两种路径都要验证。 -- `glaze/events`:`make-event-bus` + `bus-broadcast!` → 内置 SSE 端点 `GET /glaze/events` - (15s keepalive;慢订阅者溢出丢事件不阻塞广播方) -- `glaze/api-macros`:`define-api-routes` 一处声明 = Racket 过程 + 类型化路由 + JS 客户端入口; - path 里的 `:id` 参数自动从 URL 取,其余从 JSON body 取(**symbol 键**) -- 内置端点:`/glaze/api.js`(生成客户端,`#:serve-api-client? #f` 关闭) -- Host 头校验默认开启(只认 127.0.0.1/localhost/[::1]) +## Linux WebView 关键点 -## JS↔Racket 桥接(define-api 已废除) +- WebKitGTK + GTK 3; +- `ffi-lib` 在部分 Debian/Ubuntu multiarch 环境无法靠 soname 自动找到库,所以 backend 有常见绝对路径兜底; +- 不要调用阻塞式 `gtk_main`;用 `g_main_context_iteration(..., FALSE)` 做非阻塞 pump; +- CI 使用 Xvfb;“库存在”与“有图形会话”是两个不同条件。 -前端 `fetch("/api/...")` → Racket JSON。路由是普通值(`glaze/api` 的 GET/POST/PUT/DELETE + -`:param` 捕获),由 `start-server #:api` 或 `run-app #:api` 挂载。**陷阱**:Racket jsexpr 把 -JSON 对象键解析为 symbol(`hash-ref body 'delta`,不是 `"delta"`)——写成字符串键会静默取默认值。 +## 后端契约 -## 后端契约(webview 与 tray 同构) +每个 WebView backend 导出同一组过程: -每个 webview 后端模块必须导出同名 13 个过程,调度层按 `(system-type 'os)` 动态加载: +```text +open-webview +supported? +close +navigate +title +url +capture! +set-title! +set-size! +set-fullscreen! +focus! +set-menu! +closed? +``` -`open-webview` / `supported?` / `close` / `navigate` / `title` / `url` / `capture!` / -`set-title!` / `set-size!` / `set-fullscreen!` / `focus!` / `set-menu!` / `closed?` +公开层再包装成 `webview-*` API。 约定: -- 后端不可用 → `supported?` 返回 `#f`,`open-webview` 抛错(公开层捕获后返回 `#f`) -- 验证 API 拿不到值就返回 `#f`(不许抛错) -- `capture!` 接受 `(or/c #f string? path?)`,返回 PNG 路径或 `#f` -- 公开层(`webview/main.rkt`)再做 `webview-*` 前缀包装;新增能力先扩后端契约,四个后端都要补导出 +- backend 的 `supported?` 是非抛错能力 probe; +- backend 的 `open-webview` 无法启动时可以抛错; +- 公开 `open-window` / `open-webview` **成功返回 `webview?`,失败直接抛带指引的错误**; +- 验证 API 暂时拿不到值时返回 `#f`; +- `capture!` 返回 PNG path 或 `#f`; +- 不允许重新加入 browser fallback。 + +## `run-app` 契约 + +`glaze/app.rkt` 是默认应用入口: + +1. 选择端口; +2. 启动本地 server; +3. 创建 Native WebView; +4. `#:on-ready` 得到 `webview?` + URL; +5. 阻塞直到窗口关闭; +6. 停 server; +7. 返回 `(values 'webview shutdown)`。 + +如果第 3 步失败,必须先 shutdown server,再把 WebView startup error 原样抛出去。 -## Agent 验证工作流(改 webview/tray 后必做) +`#:api-token`、`#:events`、`#:on-error`、update check 等逻辑不能改变上述生命周期。 -改了 FFI 代码后,别只跑单测——真机验证才是权威(macOS 本机即可): +## CLI 契约 -```racket -#lang racket/base -(require glaze/server glaze/webview/main) -;; 1. 起服务 + 开窗口 -;; 2. 轮询 webview-title / webview-url 直到预期值(说明页面真的加载了) -;; 3. webview-capture! 截图 → 用视觉能力看图确认渲染正确 -;; 4. webview-close → 确认 #:on-close 触发 +当前 CLI: + +```text +raco glaze init +raco glaze dev +raco glaze build +raco glaze keygen +raco glaze license +raco glaze help ``` -要点(都是踩过的坑): +`init` 生成的 `main.rkt` 必须直接使用 `run-app`。 -- **不要用固定 sleep 等加载**——轮询 + deadline(首次导航含 WebContent 冷启动约 2s) -- `webview-capture!` 在窗口首次合成上屏前会返回 `#f`,重试几秒 -- 截图能拿到 = 窗口在活跃 Space 上;被全屏应用挡住时 title/url 仍可验证 +`dev` 必须运行项目真实 `main.rkt`,这样 routes、events、window options、token 等与生产行为一致。 -## FFI 发现(改代码前先读) +**不要新增 browser-only `serve` 作为标准应用工作流。** 如果开发者需要测 HTTP endpoint,可直接使用底层 `start-server` / curl;这不是另一套 UI runtime。 -两个后端文件的头部注释沉淀了全部平台级 FFI 结论,改 FFI 前必读: +## 项目结构 -- `glaze/webview/webview-windows.rkt`:COM vtable 调用形式、out 参数两箭头形式、回调内对象生命周期 -- `glaze/webview/webview-macos.rkt`:`_double` 拒绝精确整数、结构体传参必须 `#:type`、`runMode:beforeDate:` vs `nextEventMatchingMask:`(后者不服务 RunLoop 源)、泵线程必须让出调度器 +```text +glaze/ +├── info.rkt +├── glaze/ +│ ├── app.rkt +│ ├── server.rkt +│ ├── api.rkt +│ ├── api-macros.rkt +│ ├── events.rkt +│ ├── browser.rkt +│ ├── build.rkt +│ ├── assets.rkt +│ ├── update.rkt +│ ├── license.rkt +│ ├── dialogs.rkt +│ ├── deeplink.rkt +│ ├── autolaunch.rkt +│ ├── sys/ +│ ├── tray/ +│ └── webview/ +│ ├── main.rkt +│ ├── startup-feedback.rkt +│ ├── webview-windows.rkt +│ ├── webview-macos.rkt +│ ├── webview-linux.rkt +│ └── webview-stub.rkt +├── glaze-cli/ +├── glaze-doc/ +├── glaze-test/ +├── examples/ +└── scripts/ +``` -## 不要破坏的契约 +仓库根是一个 `collection 'multi` 的 Racket 包。集合级 `scribblings` / `raco-commands` 要放在相应 collection 的 `info.rkt`,不要只放根 `info.rkt`。 + +## 快速开发命令 + +```bash +raco pkg install --auto --no-docs --link "$PWD" +raco pkg update --link "$PWD" +raco make glaze/main.rkt glaze-cli/cli.rkt +raco test glaze-test/ +racket examples/hello/main.rkt +racket examples/showcase/main.rkt +racket examples/webview-demo.rkt +``` + +改动 WebView / tray / platform FFI 后,只跑 unit test 不够,必须看三平台 CI e2e。 + +## GUI-first 回归测试 + +`glaze-test/gui-first-test.rkt` 应长期保留以下防回归检查: -- 后端导出契约 + 公开层 `webview-*` 名称(测试和下游依赖) -- `open-window` 返回 `webview?` 或 `#f`(配合 `#:fallback-browser?` 语义) -- `raco glaze` 子命令名与参数 -- tray 公开 API(`make-tray` 等五个) +- `run-app` 不接受 `#:fallback-browser?`; +- `open-window` 不接受 `#:fallback-browser?`; +- `open-webview` 不接受 `#:fallback-browser?`; +- platform install guidance 非空; +- Windows guidance 提到 WebView2 Runtime 与官方入口; +- Linux guidance 提到 WebKitGTK; +- macOS guidance 提到 WKWebView。 -## 已知问题 +如果未来有人为了“容错”想恢复浏览器 fallback,先重新讨论产品定位,而不是直接改代码。 + +## JS ↔ Racket 桥接 + +前端 `fetch("/api/...")` 调本地 Racket API。`define-api-routes` 一处声明同时产生: + +1. Racket procedure; +2. validated route; +3. `/glaze/api.js` 中的 JS client entry。 + +重要:Racket jsexpr 的 JSON object key 是 symbol,例如 `(hash-ref body 'delta)`。 + +SSE 事件流使用同一个 origin;这套 HTTP 机制服务的是**嵌入式 WebView 前端**。它也方便 curl/测试工具验证,但不要因此重新定义为浏览器应用模型。 + +## 安全加固 + +- server 只绑定 loopback; +- Host header 仅允许 `127.0.0.1` / `localhost` / `[::1]`; +- API handler 参数错误 -> 400 JSON;handler 异常 -> 500 JSON; +- `#:api-token` 保护 API + SSE;静态资源与 bootstrap 不直接泄露 token; +- `run-app` 的一次性 `?glaze-token=` URL 换 HttpOnly cookie; +- 程序化客户端用 `X-Glaze-Token`; +- 同用户本地进程仍可能读进程内存,因此这不是强隔离边界。 + +## 系统托盘 + +tray 是**可选能力**,语义与 WebView 不同: + +- native tray backend 不可用时可以降级到 inert stub; +- 不能因为 tray 允许 stub,就推导出主 WebView 也应允许 fallback; +- Windows 用 Shell_NotifyIconW;macOS 用 NSStatusItem/NSMenu;Linux 用 AppIndicator/GTK。 + +菜单 spec 复用 `tray-protocol`。 + +已踩过的菜单坑: + +- 不要在整个重建菜单过程中一直持有 menu semaphore,否则内部 tag 分配可能自锁; +- 注册和派发必须使用同一张 action 表; +- macOS accelerator 真正工作,Windows/Linux 当前主要是展示。 + +## 文件对话框 + +- macOS:NSOpenPanel / NSSavePanel; +- Windows:comdlg32 wide-char API; +- Linux:zenity / kdialog; +- `#f` 表示用户取消;backend 缺失与“用户取消”不能混为一谈。 + +## 开机自启 / Deep Link + +- macOS autolaunch 使用 SMAppService(13+,打包 `.app`); +- Windows autolaunch 使用 HKCU Run; +- Linux 使用 `~/.config/autostart`; +- macOS URL scheme 在构建时写 Info.plist; +- Windows 注册 HKCU protocol; +- Linux 写 desktop entry + xdg-mime。 + +## 打包与签名 + +`glaze/build.rkt` 包装 `raco exe` + `raco distribute`。 + +Windows GUI 构建使用 `raco exe --gui`,因此用户可能看不到 stderr;这是 startup error dialog 必须存在的重要原因。 + +### macOS + +- build-app 自己组装标准 `.app`; +- nested framework/dylib 先签,bundle 后签; +- 不要用一把 `--deep` 代替正确签名顺序; +- ad-hoc 身份 `-` 下不要启用 hardened runtime 的 library validation; +- notarization 使用 `notarytool` + staple。 + +### Windows + +- signtool 支持 SHA-1 thumbprint 或 subject; +- 时间戳默认 RFC-3161; +- WebView2Loader.dll 随 Glaze 分发,但 Edge WebView2 Runtime 是系统运行时依赖。 + +### Installer fallback + +installer toolchain 缺失时降级 zip/tar.gz 并响亮告警是允许的,因为那只是**分发格式**降级;不要把这种语义复制到应用 UI runtime。 + +## License / Update + +- license:RSA-2048/SHA-256,系统 `openssl` CLI; +- `machine-id` 返回稳定摘要,不直接暴露原始系统 ID; +- `validate-license` 的 reason tag 是稳定接口,修改前先改测试; +- update manifest 可带 `sha256`; +- `verify-file-sha256` 返回 `#f` 同时可能代表“不匹配”或“无法校验”,绝不能把 `#f` 当验证成功。 + +## 提交前检查 + +至少完成: + +```bash +raco make glaze/main.rkt glaze-cli/cli.rkt +raco test glaze-test/ +``` + +涉及 WebView / FFI:确认 GitHub Actions 的 Windows、macOS、Ubuntu WebView e2e 全绿。 + +涉及 CLI scaffold/build:确认三平台 package job 里 `raco glaze init sampleapp` + build 全绿。 + +涉及文档 API 签名:确认 Scribble 可以编译。 + +## 不要破坏的契约 -- macOS 多窗口:单一共享泵线程服务所有窗口(0.3.x 是每窗口一个泵线程);0→1 转变触发启动, - 最后一个窗口关闭时退出。多窗口 e2e 在 `webview-test.rkt` -- **后台会话白屏**:从无控制终端的分离会话启动(如 CI 后台任务、`nohup`、某些 agent 工具的后台执行)时, - macOS 窗口可能停在白屏——WebKit 加载/IPC 全通(`webview-title` 正常),但绘制不上屏(窗口合成被冻结)。 - 窗口现已 `orderFrontRegardless` 无条件置前(缓解);本机 `nohup` 探针已验证正常合成+截图。 - 若再现:`webview-title`/`url` 正常而 `webview-capture!` 返回 `#f` 即此症状,优先换前台终端启动, - 而不是排查 glaze 代码 +- Glaze 主应用 = Native WebView GUI; +- Native WebView 失败 = 明确失败 + actionable guidance; +- 不存在浏览器 fallback; +- `run-app` 失败不能遗留 server; +- backend 公开命名与四平台导出一致; +- `webview-title/url/capture!` 的 agent 验证能力保留; +- tray / sys 的“可选能力降级”和主 WebView 的“必须成功”要明确区分; +- 打包、签名失败不能假装成功; +- 文档、示例、CLI scaffold 与真实运行行为必须保持一致。 diff --git a/README.md b/README.md index 6ff918a..924bc06 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,17 @@ Build desktop apps with a [Racket](https://racket-lang.org/) backend and a web f ## Why Glaze? -Racket's `racket/gui` works but is hard to style into a modern product-grade UI. Glaze takes a different approach: serve a local web app from Racket and display it in the system browser (Phase 1) or an embedded WebView (Phase 3). +Racket's `racket/gui` works but is hard to style into a modern product-grade UI. Glaze takes a different approach: Racket serves the local application frontend and displays it inside a **native desktop window** backed by the OS WebView — WebView2 on Windows, WKWebView on macOS, and WebKitGTK on Linux. You get: - **Racket for logic** — the full power of Racket's macro system, contracts, pattern matching - **Web for UI** — Tailwind, Svelte, React, or any web framework +- **Native desktop shell** — a real OS window with an embedded system WebView - **JSON API bridge** — the page calls Racket with plain `fetch("/api/...")` +Glaze is deliberately **GUI-first**. If the required native WebView runtime is missing or broken, startup fails with platform-specific installation/repair instructions. It does **not** silently turn the desktop app into a browser tab. + ### How it compares | | Glaze | Tauri | Electron | wails | @@ -26,31 +29,36 @@ You get: | Native toolchain needed | **none** (pure FFI) | Rust + cargo | none | Go + WebView2 deps | | Binary size | tiny (Racket exe + assets) | small | 100 MB+ | small | | Frontend→backend | HTTP JSON routes (`fetch`) | `invoke()` IPC | Node APIs | bindings | -| Works without webview (browser fallback) | **yes** | no | no | no | +| WebView backends | WebView2 / WKWebView / WebKitGTK | system WebView | bundled Chromium | WebView2/WKWebView | +| Missing WebView behavior | **fail fast + install guidance** | prerequisite error | n/a (bundled) | prerequisite error | | Agent-friendly UI verification (`title`/`url`/screenshot) | **built-in** | via WebDriver | via CDP | limited | -| WebView backends | WebView2 / WKWebView / WebKitGTK | same | bundled Chromium | WebView2/WKWebView | -All three webview backends pass the real-window CI e2e (open, load, capture, navigate, close, on-close). Remaining honest gaps: no typed IPC layer (plain JSON), Linux needs a desktop session or Xvfb. +All three WebView backends pass the real-window CI e2e (open, load, capture, navigate, close, on-close). Remaining honest gaps: no typed IPC layer (plain JSON), Linux needs a desktop session or Xvfb. ## Platform status | Capability | macOS | Windows | Linux | |---|---|---|---| -| HTTP server + browser | ✅ | ✅ | ✅ | +| Local HTTP application server | ✅ | ✅ | ✅ | | System tray | ✅ | ✅ | ✅ (CI-verified) | | JSON API bridge | ✅ | ✅ | ✅ | -| Native webview window | ✅ verified end-to-end | ✅ CI e2e (WebView2) | ✅ CI e2e (Xvfb + WebKitGTK) | +| Native WebView window | ✅ verified end-to-end | ✅ CI e2e (WebView2) | ✅ CI e2e (Xvfb + WebKitGTK) | | `webview-title` / `webview-url` | ✅ | ✅ | ✅ | | `webview-capture!` (screenshot) | ✅ | ✅ (PrintWindow + PowerShell PNG) | ✅ (gdk_pixbuf) | | `#:devtools?` | ✅ (inspectable, macOS 13+) | ✅ (`OpenDevToolsWindow`) | ✅ (WebKitGTK inspector) | -Without a native backend, `run-app` / `open-window` automatically fall back to the system browser — the app still works everywhere. +Native WebView support is mandatory for application startup. `run-app` and `open-window` never open the system browser as a fallback. ## Requirements -| Dependency | Purpose | -|------------|---------| -| [Racket](https://racket-lang.org/) | 7.0 or later (includes `raco`) | +| Platform | Runtime requirement | +|---|---| +| All | [Racket](https://racket-lang.org/) 7.0 or later (includes `raco`) | +| Windows | Microsoft Edge WebView2 Runtime (Evergreen). Glaze ships `WebView2Loader.dll`; install/repair the Runtime if startup says it is unavailable. | +| macOS | WKWebView is built into macOS; run inside a logged-in graphical session. | +| Linux | GTK 3 + WebKitGTK (`libwebkit2gtk-4.1-0` on current Debian/Ubuntu; distro equivalent elsewhere) and a graphical desktop session/Xvfb. | + +When startup cannot initialize the native backend, Glaze preserves the underlying backend error and adds actionable installation/repair guidance. Interactive desktop apps also attempt to show the same diagnosis in an OS-level error dialog, which matters for packaged Windows `--gui` executables that have no console. CI suppresses the dialog automatically; `GLAZE_NO_STARTUP_DIALOG=1` disables it explicitly. ## Quick Start @@ -73,9 +81,11 @@ cd myapp ```bash racket main.rkt +# or +raco glaze dev ``` -A native window opens showing your app served from a local HTTP server; without a WebView backend it falls back to the system browser at `http://127.0.0.1:`. +A native desktop window opens and hosts the frontend served by the local Racket server. If the required WebView runtime is missing, startup stops and tells you what to install instead of opening Chrome/Edge/Safari. > Prefer installing straight from a GitHub checkout instead of the catalog? > ```bash @@ -88,26 +98,28 @@ A native window opens showing your app served from a local HTTP server; without ## CLI Commands ```bash -raco glaze init # Create a new Glaze project -raco glaze dev # Start dev server with auto-open browser +raco glaze init # Create a native Glaze desktop project +raco glaze dev # Run this project's native desktop app raco glaze build # Build a distributable (exe + bundled assets) raco glaze keygen # Create an RSA keypair for license signing raco glaze license # Sign or verify offline license files raco glaze help # Show help ``` +There is intentionally no browser-mode `dev`/`serve` command. Development and production use the same native WebView path so missing dependencies and native-backend failures cannot be hidden by a browser fallback. + ### `build` Package a Glaze project into a platform distribution (`raco exe` + `raco distribute`) with the frontend assets bundled alongside the executable. On macOS the distribution is a proper `.app` bundle with your `--version` stamped into `Info.plist`. ```bash -raco glaze build --name myapp # produces dist/myapp(.exe) + dist/lib + dist/public -raco glaze build --name myapp --version 1.2.0 --installer # + msi / dmg / AppImage (zip/tar.gz fallback) +raco glaze build --name myapp +raco glaze build --name myapp --version 1.2.0 --installer ``` Options: `--name`, `--version`, `--icon <.ico/.icns>`, `--entry ` (default `main.rkt`), `--out ` (default `dist`), `--embed-dlls` (Windows: single-file exe), `--installer`. -> The installer step probes for the native toolchain (WiX / NSIS on Windows, `create-dmg` / `hdiutil` on macOS, `appimagetool` / `linuxdeploy` on Linux) and **degrades gracefully** to a `.zip` / `.tar.gz` when it's absent, printing a warning naming what to install. +> The installer step probes for the native packaging toolchain (WiX / NSIS on Windows, `create-dmg` / `hdiutil` on macOS, `appimagetool` / `linuxdeploy` on Linux) and **degrades gracefully** to a `.zip` / `.tar.gz` when that packaging toolchain is absent, printing a warning naming what to install. This packaging fallback is unrelated to application startup: the app itself still requires a native WebView. ### Code signing & notarization @@ -130,12 +142,10 @@ Details: `--sign` takes a codesign identity (macOS) or a SHA-1 thumbprint / subj ### Licensing (paid apps) -`glaze/license` ships an offline license-key scheme with zero native dependencies — RSA-2048/SHA-256 signatures via the system `openssl` CLI, present on every platform: +`glaze/license` ships an offline license-key scheme with zero native dependencies — RSA-2048/SHA-256 signatures via the system `openssl` CLI: ```bash -# vendor side — once: -raco glaze keygen --out keys # keys/private.pem + keys/public.pem -# per customer (optionally expiry- and machine-bound): +raco glaze keygen --out keys raco glaze license sign --key keys/private.pem --product "MyApp" \ --subject "customer@example.com" --expiry 2027-12-31 --out app.license raco glaze license verify --pub keys/public.pem --product "MyApp" app.license @@ -146,9 +156,8 @@ raco glaze license verify --pub keys/public.pem --product "MyApp" app.license (define r (validate-license "app.license" #:public-key "keys/public.pem" #:product "MyApp")) (unless (hash-ref r 'valid) - (error 'myapp "license invalid: ~a" (hash-ref r 'reason))) ; expired / machine / signature ... + (error 'myapp "license invalid: ~a" (hash-ref r 'reason))) -;; machine binding: a stable per-machine digest of the OS machine id (issue-license ... #:machine-id (machine-id)) ``` @@ -160,8 +169,7 @@ Failure reasons are stable tags (`missing-file`, `malformed`, `signature`, `prod ```racket (define info (check-update manifest-url #:current-version "1.0.0")) -;; app downloads (hash-ref info 'url) ... then: -(verify-file-sha256 artifact (hash-ref info 'sha256)) ; #t / #f (#f = cannot verify) +(verify-file-sha256 artifact (hash-ref info 'sha256)) ``` ## Project Structure @@ -175,26 +183,22 @@ myapp/ └── index.html # Frontend ``` -`main.rkt` starts a local HTTP server serving files from `public/` and opens the browser: +`raco glaze init` generates a native-window entry point. The call is deliberately top-level so the same file also starts correctly when `raco glaze build` packages it through the generated wrapper: ```racket #lang racket/base -(require glaze) - -(define-values (port server) - (start-dev-server #:public-dir "public")) +(require racket/runtime-path + glaze) -(printf "Glaze app running at http://127.0.0.1:~a\n" port) -(open-browser (format "http://127.0.0.1:~a" port)) +(define-runtime-path public "public") -(with-handlers ([exn:break? - (lambda (e) - (stop-server server) - (printf "Server stopped.\n"))]) - (sync never-evt)) +(run-app #:public-dir public + #:title "myapp") ``` +`run-app` starts the local HTTP application server, opens the native WebView window, and shuts the server down when the window closes. A native-backend failure is fatal and includes dependency guidance. + ## Repository Structure One installable package at the repo root; each top-level directory is a Racket collection: @@ -213,46 +217,37 @@ glaze/ # repo root = the `glaze` package (info.rkt) ### `run-app` -The one-call entry: picks a free port, starts the server (static + JSON API), opens the native webview window, and blocks until the window closes. +The one-call entry: picks a free port, starts the server (static + JSON API), opens the native WebView window, and blocks until the window closes. ```racket (run-app #:public-dir "public" #:api (list (GET "api/ping" ...))) -;; webview path: window closed -> server stopped -> (values 'webview shutdown) -;; browser fallback (no native backend): opens browser -> (values 'browser shutdown) +;; window closes -> server stops -> (values 'webview shutdown) ``` +If native WebView startup fails, `run-app` shuts down the local server and raises the same actionable startup error. There is no `#:fallback-browser?` option. + ### `start-server` / `start-dev-server` -Starts a local HTTP server serving static files with SPA fallback, plus optional JSON API routes. `start-dev-server` is a backward-compatible alias. +Starts a local HTTP server serving static files with SPA fallback, plus optional JSON API routes. `start-dev-server` is a backward-compatible alias for the server primitive; it does not define Glaze's application UI mode. ```racket (start-server #:port 8080 #:public-dir "public" #:api (list (GET "api/ping" (lambda (req) (hasheq 'pong #t))))) -;; Returns (values port shutdown-proc); verifies the listener is accepting -;; before returning. -``` - -### `stop-server` - -Stops the server. - -```racket -(stop-server shutdown-proc) ``` ### `open-browser` -Opens a URL in the system default browser (cross-platform: Windows, macOS, Linux). +Low-level utility for opening an external URL in the user's default browser (for example, product documentation or an OAuth page). `run-app` and `open-window` do not call it as a fallback. ```racket -(open-browser "http://127.0.0.1:8080") +(open-browser "https://example.com/docs") ``` ## JavaScript Bridge -The frontend calls Racket with plain `fetch("/api/...")` — Glaze's answer to Tauri's `invoke()`. One code path works in the embedded WebView, in the system-browser fallback, and in dev (curl-able). Routes are ordinary values: +The embedded frontend calls Racket with plain `fetch("/api/...")` — Glaze's answer to Tauri's `invoke()`. The local HTTP bridge is easy to exercise independently with developer tools such as `curl`. ```racket (require glaze) @@ -265,166 +260,106 @@ The frontend calls Racket with plain `fetch("/api/...")` — Glaze's answer to T ``` - Handlers take the request plus captured `:params`; return a jsexpr (auto-wrapped as JSON 200) or a full response. -- `request-json-body` parses the JSON body — note Racket jsexpr parses JSON object keys as **symbols** (`(hash-ref body 'delta)`). +- `request-json-body` parses the JSON body — Racket jsexpr parses JSON object keys as **symbols** (`(hash-ref body 'delta)`). - A handler that raises becomes a 500 JSON error, never a broken connection. - Unmatched requests fall through to static files (SPA `index.html` fallback). -In the page: - -```js -const s = await fetch('/api/counter/bump', - {method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({delta: 5})}).then(r => r.json()); -``` - ### Typed routes, one declaration — `define-api-routes` ```racket (define-api-routes api [(POST "api/counter/bump") - (bump [delta exact-nonnegative-integer? 1]) ; required, checked, or default + (bump [delta exact-nonnegative-integer? 1]) (hasheq 'count (add1 delta))]) ``` -One clause defines a Racket procedure (`bump`), a route (bad input → a 400 -naming the parameter; handler errors → 500), and a JS client entry — the -served `/glaze/api.js` exposes `glaze.api.counterBump({delta: 5})`, plus -`glaze.call(method, path, body)` and `glaze.on(name, fn)`. +One clause defines a Racket procedure, a validated HTTP route, and a JS client entry exposed by `/glaze/api.js`. ### Backend → frontend push (SSE) ```racket (define bus (make-event-bus)) (start-server ... #:events bus) -(bus-broadcast! bus 'count-changed (hasheq 'count 42)) ; from any thread +(bus-broadcast! bus 'count-changed (hasheq 'count 42)) ``` ```js glaze.on('count-changed', s => render(s.count)); ``` -The page can also use `new EventSource('/glaze/events')` directly. Works in -the browser fallback too — same origin, no extra port. +The event stream uses the same local origin as the embedded WebView frontend. ### Security -- Requests are only served for Host headers `127.0.0.1` / `localhost` / - `[::1]` (DNS-rebinding guard; hostile origins get 403). -- API handlers never crash the connection — parameter problems are 400 - JSON, handler exceptions are 500 JSON (and reach `run-app`'s - `#:on-error` for crash reporting hooks). -- Optional API token (`#:api-token`): guards API routes and the SSE stream - (401 otherwise). The app window opens a one-time `?glaze-token=` bootstrap - URL that exchanges the token for an `HttpOnly` cookie (api.js deliberately - hands out nothing); programmatic clients send `X-Glaze-Token`. - Honest scope: defense-in-depth against casual local callers — a process - of the same user can still read the token from process memory. -- Update checks: `run-app #:check-update #:current-version "1.0.0"` - fetches `{"version","url","notes"}`, reports to stderr and broadcasts - `update-available`. Self-replacement stays the app's decision. - -See [`examples/counter/`](examples/counter/) for the complete working app. +- Requests are only served for Host headers `127.0.0.1` / `localhost` / `[::1]`. +- API handler parameter errors become 400 JSON; handler exceptions become 500 JSON and reach `run-app`'s `#:on-error` hook. +- Optional `#:api-token` protects API routes and SSE. The native app window uses a one-time bootstrap URL to obtain an HttpOnly cookie; programmatic clients use `X-Glaze-Token`. +- Update checks remain opt-in through `run-app #:check-update ...`. ## System Integrations (`glaze/sys`) ```racket (require glaze/sys) -(clipboard-set! "hello") ; (clipboard-get) +(clipboard-set! "hello") (notify! "Download finished" "report.pdf is ready") -(open-path "/Users/me/report.pdf") ; default handler -(reveal-path "/Users/me/report.pdf"); Finder/Explorer, selected +(open-path "/Users/me/report.pdf") +(reveal-path "/Users/me/report.pdf") (unless (single-instance? "com.me.app") (exit 0)) ``` -Desktop notifications work on all three platforms (osascript / -notify-send / WinRT toast via PowerShell). - -Window controls (from `glaze/webview`): `webview-set-title!`, -`webview-set-size!`, `webview-set-fullscreen!`. +Window controls include `webview-set-title!`, `webview-set-size!`, `webview-set-fullscreen!`, and `webview-focus!`. ## System Tray -Glaze provides a cross-platform system tray so your app can live in the notification area / menu bar with a right-click (or left-click on macOS) menu. The backend is chosen by platform — pure Racket FFI, no native compilation required: +Glaze provides a cross-platform system tray: -- **Windows** — `Shell_NotifyIconW` via `ffi/unsafe` -- **macOS** — `NSStatusItem` / `NSMenu` via `ffi/unsafe/objc` -- **Linux** — `libayatana-appindicator` + `libgtk-3` via `ffi/unsafe` +- **Windows** — `Shell_NotifyIconW` +- **macOS** — `NSStatusItem` / `NSMenu` +- **Linux** — `libayatana-appindicator` + `libgtk-3` -If a platform's native libraries aren't available at runtime, the tray silently degrades to a no-op so the rest of the app keeps working. - -```racket -(require glaze) - -(define t - (make-tray #:icon #f - #:tooltip "My Glaze App" - #:menu (list (make-menu-item "Quit" - #:action (lambda () (exit 0)))))) -(tray-set-tooltip! t "running") -;; ...later -(tray-close t) -``` - -> **macOS note:** a pure menu-bar app (no Dock icon) requires building as an `.app` bundle with `LSUIElement` set — `raco glaze build` configures this for you. +The tray is an optional integration. If its backend is unavailable it may degrade to an inert stub; that is intentionally different from the mandatory main WebView. ## App Platform APIs -Beyond the server/webview core, Glaze ships the desktop-app odds and ends commercial apps need: - ```racket (require glaze) -;; ---- native file dialogs (NSOpenPanel / comdlg32 / zenity-kdialog) ---- (define f (pick-file #:title "Open report" #:filters '(("Reports" "*.rep" "*.csv")))) (define dir (pick-folder #:title "Where?")) (define out (save-file-dialog #:title "Save as" #:default-name "out.rep")) -;; #f = cancelled; check (dialog-supported?) for a graceful path. -;; ---- menu bar (declarative, three platforms) ---- (webview-set-menu! wv (list (make-menu "File" (list (make-menu-item "Open…" #:accel "CmdOrCtrl+O" #:action open-doc) menu-separator (make-menu-item "Quit" #:action (lambda () (exit 0))))))) -;; macOS accelerators really fire; Windows/Linux show them (v1). - -;; ---- deep links (myapp://...) ---- -(ensure-url-scheme! "myapp") ; Windows registry / Linux xdg; - ; macOS via build --url-scheme -;; ---- launch at login ---- +(ensure-url-scheme! "myapp") (auto-launch-set! "MyApp" #t) (auto-launch-enabled? "MyApp") -;; ---- multi-window ---- (for ([w (all-webviews)]) (webview-focus! w)) -(wait-for-webviews) ; block until every window closes +(wait-for-webviews) ``` ## Examples | Example | What it shows | |---|---| -| [`examples/showcase/`](examples/showcase/) | **Kitchen sink (start here)** — every capability in one window | -| [`examples/hello/`](examples/hello/) | Minimal app — `run-app` in 8 lines | +| [`examples/showcase/`](examples/showcase/) | **Kitchen sink (start here)** — every capability in one native window | +| [`examples/hello/`](examples/hello/) | Minimal native app — `run-app` in 8 lines | | [`examples/counter/`](examples/counter/) | JS↔Racket bridge — `fetch` calls Racket state | -| [`examples/webview-demo.rkt`](examples/webview-demo.rkt) | Webview lifecycle: load, navigate, close, verification APIs | +| [`examples/webview-demo.rkt`](examples/webview-demo.rkt) | Cross-platform native WebView lifecycle: load, navigate, close, verification APIs | | [`examples/agent-verify.rkt`](examples/agent-verify.rkt) | Agent workflow: assert page state + screenshot with no human | | [`examples/tray-demo.rkt`](examples/tray-demo.rkt) | Cross-platform system tray with a working menu | ## Roadmap -- [x] **Phase 1** — Local HTTP server + system browser +- [x] **Phase 1** — Local HTTP server + early browser prototype - [x] **Phase 2** — Frontend asset bundling, system tray, app packaging -- [x] **Phase 3** — Native WebView embedding (WebView2 / WKWebView / WebKitGTK) — *done, verified by the 3-OS CI e2e* - -> **Phase 3 done:** all three backends (macOS WKWebView, Windows WebView2, Linux -> WebKitGTK) pass the real-window CI e2e — open, page load, `webview-title`/`url` -> verification, `webview-capture!` screenshots, `webview-navigate`, close (programmatic -> and OS chrome), and `#:on-close` callbacks; `#:devtools?` and resize-follow on all -> three platforms. Pure Racket FFI throughout, no compiler. Remaining polish -> (not a blocker): multi-window ergonomics. +- [x] **Phase 3** — Native WebView embedding (WebView2 / WKWebView / WebKitGTK) — verified by the 3-OS CI e2e +- [x] **GUI-first contract** — native WebView required; actionable failure instead of browser fallback ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 318b128..690c0f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # Glaze -用 [Racket](https://racket-lang.org/) 做后端、Web 技术做前端,构建桌面应用。一个 Racket 版的 [Tauri](https://tauri.app/) —— 用 Racket 写业务逻辑,用 HTML/CSS/JS 构建界面,打包为桌面应用。 +用 [Racket](https://racket-lang.org/) 做后端、Web 技术做前端,构建桌面应用。一个 Racket 版的 [Tauri](https://tauri.app/) —— 用 Racket 写业务逻辑,用 HTML/CSS/JS 构建界面,最终运行在真正的桌面窗口中。 [![CI](https://github.com/turinglambdaai/glaze/actions/workflows/ci.yml/badge.svg)](https://github.com/turinglambdaai/glaze/actions/workflows/ci.yml) ![Racket](https://img.shields.io/badge/Racket-9F1D20?logo=racket&logoColor=white) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Release](https://img.shields.io/badge/release-0.7.0-C15F3C)](CHANGELOG.md) @@ -10,14 +10,17 @@ ## 为什么选择 Glaze? -Racket 自带的 `racket/gui` 可以用,但很难做出现代化的产品级 UI。Glaze 采用不同的思路:从 Racket 启动本地 Web 服务,用系统浏览器(Phase 1)或嵌入式 WebView(Phase 3)展示。 +Racket 自带的 `racket/gui` 可以用,但很难做出现代化的产品级 UI。Glaze 采用不同的思路:Racket 在本机提供应用前端与 API,然后把 HTML/CSS/JS 渲染在**原生桌面窗口中的系统 WebView** 里:Windows 使用 WebView2,macOS 使用 WKWebView,Linux 使用 WebKitGTK。 你将获得: - **Racket 写逻辑** —— 完整的宏系统、contracts、模式匹配 - **Web 写界面** —— Tailwind、Svelte、React 或任何 Web 框架 +- **真正的桌面外壳** —— 系统原生窗口 + 嵌入式 WebView - **JSON API 桥接** —— 页面用普通 `fetch("/api/...")` 调用 Racket +Glaze 明确采用 **GUI-first** 设计。原生 WebView 运行时缺失或初始化失败时,应用会直接启动失败,并给出当前平台的安装/修复指引;**不会再偷偷退化成 Chrome、Edge 或 Safari 里的一个网页。** + ### 横向对比 | | Glaze | Tauri | Electron | wails | @@ -26,17 +29,17 @@ Racket 自带的 `racket/gui` 可以用,但很难做出现代化的产品级 U | 原生工具链 | **无需**(纯 FFI) | Rust + cargo | 无 | Go + WebView2 依赖 | | 二进制体积 | 极小 | 小 | 100 MB+ | 小 | | 前后端桥接 | HTTP JSON 路由(`fetch`) | `invoke()` IPC | Node API | 绑定层 | -| 无 WebView 时浏览器兜底 | **支持** | 不支持 | 不支持 | 不支持 | +| WebView 后端 | WebView2 / WKWebView / WebKitGTK | 系统 WebView | 自带 Chromium | WebView2/WKWebView | +| WebView 缺失时 | **明确失败 + 安装指引** | 前置依赖错误 | 不适用(自带) | 前置依赖错误 | | Agent 友好的 UI 验证(title/url/截图) | **内置** | 需 WebDriver | 需 CDP | 有限 | -| WebView 后端 | WebView2 / WKWebView / WebKitGTK | 相同 | 自带 Chromium | WebView2/WKWebView | -三个平台的 WebView 后端均通过真窗口 CI e2e(open、加载、截图、导航、关闭、on-close)。剩余诚实差距:IPC 为纯 JSON 无类型层、Linux 需要桌面会话或 Xvfb。 +三个平台的 WebView 后端均通过真窗口 CI e2e(open、加载、截图、导航、关闭、on-close)。剩余诚实差距:IPC 仍是纯 JSON、没有类型层;Linux 需要桌面会话或 Xvfb。 ## 平台支持状态 | 能力 | macOS | Windows | Linux | |---|---|---|---| -| HTTP 服务器 + 浏览器 | ✅ | ✅ | ✅ | +| 本地应用 HTTP 服务 | ✅ | ✅ | ✅ | | 系统托盘 | ✅ | ✅ | ✅(CI 验证) | | JSON API 桥接 | ✅ | ✅ | ✅ | | 原生 WebView 窗口 | ✅ 端到端验证 | ✅ CI e2e(WebView2) | ✅ CI e2e(Xvfb + WebKitGTK) | @@ -44,13 +47,39 @@ Racket 自带的 `racket/gui` 可以用,但很难做出现代化的产品级 U | `webview-capture!`(截图) | ✅ | ✅(PrintWindow + PowerShell 转 PNG) | ✅(gdk_pixbuf) | | `#:devtools?` | ✅(inspectable,macOS 13+) | ✅(`OpenDevToolsWindow`) | ✅(WebKitGTK inspector) | -原生后端不可用时,`run-app` / `open-window` 自动回退系统浏览器 —— 应用在所有平台都能跑。 +原生 WebView 是应用启动的必要条件。`run-app` / `open-window` **不会**在失败时打开系统浏览器。 ## 环境要求 -| 依赖 | 用途 | -|------|------| -| [Racket](https://racket-lang.org/) | 7.0 或更高版本(包含 `raco`) | +| 平台 | 运行时要求 | +|---|---| +| 全平台 | [Racket](https://racket-lang.org/) 7.0 或更高版本(包含 `raco`) | +| Windows | Microsoft Edge WebView2 Runtime(Evergreen)。Glaze 已自带 `WebView2Loader.dll`;若启动提示运行时不可用,请安装或修复 WebView2 Runtime。 | +| macOS | WKWebView 随 macOS 自带;需要在已登录的图形桌面会话中运行。 | +| Linux | GTK 3 + WebKitGTK(当前 Debian/Ubuntu 通常是 `libwebkit2gtk-4.1-0`)以及图形桌面会话/Xvfb。 | + +如果原生后端初始化失败,Glaze 会保留底层错误,并紧接着给出对应平台的安装命令或官方下载地址。交互式桌面程序还会尝试弹出系统错误对话框显示同一份诊断信息——这对 Windows `raco exe --gui` 打包出的无控制台程序尤其重要。CI 会自动禁用错误弹框;也可以通过 `GLAZE_NO_STARTUP_DIALOG=1` 显式关闭。 + +Windows 示例: + +```text +Windows requires Microsoft Edge WebView2 Runtime (Evergreen). +winget install --id Microsoft.EdgeWebView2Runtime -e +https://developer.microsoft.com/microsoft-edge/webview2/#download-section +``` + +Linux 示例: + +```bash +# Debian / Ubuntu +sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0 + +# Fedora +sudo dnf install gtk3 webkit2gtk4.1 + +# Arch +sudo pacman -S gtk3 webkit2gtk-4.1 +``` ## 快速开始 @@ -60,8 +89,6 @@ Racket 自带的 `racket/gui` 可以用,但很难做出现代化的产品级 U raco pkg install --auto glaze ``` -单个 Racket 包:一次安装即包含 `glaze` 库、`raco glaze` CLI 和文档(之后可用 `raco docs` 浏览)。 - ### 2. 创建新项目 ```bash @@ -69,13 +96,15 @@ raco glaze init myapp cd myapp ``` -### 4. 运行 +### 3. 运行 ```bash racket main.rkt +# 或 +raco glaze dev ``` -会打开一个原生窗口展示你的应用(由本地 HTTP 服务器驱动);无 WebView 后端时自动回退系统浏览器,访问 `http://127.0.0.1:<端口>`。 +会打开一个真正的原生桌面窗口,由本地 Racket 服务驱动。如果缺少 WebView2 / WebKitGTK 等依赖,启动会停止并告诉你需要安装什么,绝不会改成浏览器页面继续运行。 > 想直接从 GitHub 检出安装而不走包索引? > ```bash @@ -83,59 +112,51 @@ racket main.rkt > cd glaze > raco pkg install --auto --link "$PWD" > ``` -> 想参与 Glaze 开发,见 [CONTRIBUTING.md](CONTRIBUTING.md)。 ## CLI 命令 ```bash -raco glaze init # 创建新的 Glaze 项目 -raco glaze dev # 启动开发服务器并自动打开浏览器 +raco glaze init # 创建原生 Glaze 桌面项目 +raco glaze dev # 运行当前项目的原生桌面应用 raco glaze build # 构建可分发包(exe + 内置资源) raco glaze keygen # 生成用于许可证签名的 RSA 密钥对 raco glaze license # 签发 / 校验离线许可证文件 raco glaze help # 显示帮助 ``` +Glaze **不提供浏览器模式的 `dev` / `serve` 命令**。开发和发布走同一条 Native WebView 路径,这样依赖缺失或原生后端故障会在开发阶段立即暴露,而不是被 browser fallback 隐藏。 + ### `build` -把 Glaze 项目打包为平台分发产物(`raco exe` + `raco distribute`),前端资源随可执行文件一起分发。macOS 产出标准 `.app` bundle,`--version` 会写入 `Info.plist`。 +把 Glaze 项目打包为平台分发产物(`raco exe` + `raco distribute`),前端资源随可执行文件一起分发。Windows GUI 构建使用 `raco exe --gui`;macOS 产出标准 `.app` bundle。 ```bash -raco glaze build --name myapp # 产出 dist/myapp(.exe) + dist/lib + dist/public -raco glaze build --name myapp --version 1.2.0 --installer # 额外产出 msi / dmg / AppImage(缺失工具链时回落为 zip/tar.gz) +raco glaze build --name myapp +raco glaze build --name myapp --version 1.2.0 --installer ``` 选项:`--name`、`--version`、`--icon <.ico/.icns>`、`--entry `(默认 `main.rkt`)、`--out `(默认 `dist`)、`--embed-dlls`(Windows:单文件 exe)、`--installer`。 -> installer 步骤会探测本机的打包工具链(Windows 的 WiX / NSIS,macOS 的 `create-dmg` / `hdiutil`,Linux 的 `appimagetool` / `linuxdeploy`),**缺失时优雅降级**为 `.zip` / `.tar.gz` 并打印提示告知需要安装什么。 +> installer 步骤缺少 WiX / NSIS / create-dmg / appimagetool 等打包工具时,可以降级为 `.zip` / `.tar.gz` 并响亮告警。这里降级的是**分发格式**,不是应用 UI;应用启动本身没有浏览器 fallback。 ### 代码签名与公证 -未签名的应用会被 macOS Gatekeeper 和 Windows SmartScreen 拦截。`build` 内置了平台签名器: - ```bash -# macOS —— Developer ID 身份 + hardened runtime + 公证: +# macOS raco glaze build --name myapp \ --sign "Developer ID Application: Acme Inc (TEAMID)" \ --notarize acme-notary --installer -# macOS —— ad-hoc 签名(无证书,本地测试 / CI 用): -raco glaze build --name myapp --sign - - -# Windows —— signtool 按证书 SHA-1 指纹签名(带 RFC-3161 时间戳): +# Windows raco glaze build --name myapp --sign 40HEXCHARS --installer ``` -说明:`--sign` 在 macOS 接受 codesign 身份,在 Windows 接受 `signtool` 的证书 SHA-1 指纹(40 位十六进制)或主题名。macOS 默认启用 hardened runtime(`--no-hardened-runtime` 可关;ad-hoc 身份下自动跳过——其 library validation 会拒绝应用自身的 framework)。`--notarize ` 会把构建出的 dmg 提交 `notarytool` 公证并钉上票据。`--entitlements `、`--timestamp-url ` 补齐其余场景。**签名失败会中止构建**;工具链缺失则响亮地降级并告警。 +签名失败会中止构建;缺失签名工具链时会明确告警。 ### 许可证(收费应用) -`glaze/license` 提供零原生依赖的离线许可证方案——RSA-2048/SHA-256 签名走系统 `openssl` CLI(三平台开箱即有): - ```bash -# 开发者侧 —— 一次性: -raco glaze keygen --out keys # keys/private.pem + keys/public.pem -# 按客户签发(可选有效期与机器绑定): +raco glaze keygen --out keys raco glaze license sign --key keys/private.pem --product "MyApp" \ --subject "customer@example.com" --expiry 2027-12-31 --out app.license raco glaze license verify --pub keys/public.pem --product "MyApp" app.license @@ -146,22 +167,7 @@ raco glaze license verify --pub keys/public.pem --product "MyApp" app.license (define r (validate-license "app.license" #:public-key "keys/public.pem" #:product "MyApp")) (unless (hash-ref r 'valid) - (error 'myapp "许可证无效:~a" (hash-ref r 'reason))) ; expired / machine / signature ... - -;; 机器绑定:对系统机器标识做稳定摘要 -(issue-license ... #:machine-id (machine-id)) -``` - -校验失败原因 (`reason`) 是稳定的标签(`missing-file`、`malformed`、`signature`、`product`、`expired`、`machine`、`openssl-unavailable`),可直接用于界面提示。诚实边界:这套方案防的是随手共享许可证——本地攻击者总能给二进制打补丁,它不是防篡改机制。 - -### 更新包完整性 - -`check-update` 会透传 manifest 里可选的 `"sha256"` 字段;下载完更新包后先校验再替换: - -```racket -(define info (check-update manifest-url #:current-version "1.0.0")) -;; 应用自行下载 (hash-ref info 'url) ... 然后: -(verify-file-sha256 artifact (hash-ref info 'sha256)) ; #t / #f(#f = 无法校验) + (error 'myapp "许可证无效:~a" (hash-ref r 'reason))) ``` ## 项目结构 @@ -175,38 +181,18 @@ myapp/ └── index.html # 前端页面 ``` -`main.rkt` 启动本地 HTTP 服务器,从 `public/` 目录提供静态文件并打开浏览器: +`raco glaze init` 现在生成的入口就是原生窗口应用。`run-app` 故意放在顶层,这样源码直接运行、`raco glaze dev` 和 `raco glaze build` 的打包 wrapper 都会启动同一套应用逻辑: ```racket #lang racket/base -(require glaze) - -(define-values (port server) - (start-dev-server #:public-dir "public")) +(require racket/runtime-path + glaze) -(printf "Glaze app running at http://127.0.0.1:~a\n" port) -(open-browser (format "http://127.0.0.1:~a" port)) +(define-runtime-path public "public") -(with-handlers ([exn:break? - (lambda (e) - (stop-server server) - (printf "Server stopped.\n"))]) - (sync never-evt)) -``` - -## 仓库结构 - -仓库根目录即一个可安装的 Racket 包,每个顶层目录对应一个集合(collection): - -``` -glaze/ # 仓库根 = `glaze` 包(info.rkt) -├── glaze/ # 核心库:服务器、API 桥、webview、托盘、系统集成、打包 -├── glaze-cli/ # CLI 工具(raco glaze init / dev / build) -├── glaze-doc/ # 文档(Scribble) -├── glaze-test/ # 测试套件 -├── examples/ # 可运行示例 -└── scripts/ # CI 辅助脚本(webview e2e) +(run-app #:public-dir public + #:title "myapp") ``` ## API @@ -218,203 +204,92 @@ glaze/ # 仓库根 = `glaze` 包(info.rkt) ```racket (run-app #:public-dir "public" #:api (list (GET "api/ping" ...))) -;; webview 路径:窗口关闭 -> 服务器停止 -> (values 'webview shutdown) -;; 浏览器回退(无原生后端):打开浏览器 -> (values 'browser shutdown) +;; 窗口关闭 -> server 停止 -> (values 'webview shutdown) ``` -### `start-server` / `start-dev-server` - -启动本地 HTTP 服务器:静态文件 + SPA 回退 + 可选 JSON API 路由。`start-dev-server` 为兼容别名。 +如果原生 WebView 启动失败,`run-app` 会先停止已经启动的本地 server,再把包含安装/修复指引的错误原样抛出。**不存在 `#:fallback-browser?` 参数。** -```racket -(start-server #:port 8080 - #:public-dir "public" - #:api (list (GET "api/ping" (lambda (req) (hasheq 'pong #t))))) -;; 返回 (values port shutdown-proc);返回前会确认端口已在监听 -``` - -### `stop-server` +### `start-server` / `start-dev-server` -停止服务器。 - -```racket -(stop-server shutdown-proc) -``` +启动本地 HTTP 服务器:静态文件 + SPA 回退 + 可选 JSON API 路由。`start-dev-server` 只是底层 server API 的兼容别名,不代表另一套浏览器 UI 模式。 ### `open-browser` -用系统默认浏览器打开 URL(跨平台:Windows、macOS、Linux)。 +这是一个**低层外部链接工具函数**,例如打开产品文档或 OAuth 页面。`run-app` / `open-window` 不会把它当成 WebView 失败后的退路。 ```racket -(open-browser "http://127.0.0.1:8080") +(open-browser "https://example.com/docs") ``` ## JavaScript 桥接 -前端用普通 `fetch("/api/...")` 调 Racket —— 这是 Glaze 对 Tauri `invoke()` 的回答。同一套代码在嵌入式 WebView、系统浏览器回退、dev 调试(可 curl)下都工作。路由是普通值: +嵌入式前端用普通 `fetch("/api/...")` 调 Racket。本地 HTTP 桥接也方便用 `curl` 等开发工具独立测试。 ```racket (require glaze) (GET "api/ping" (lambda (req) (hasheq 'pong #t))) (POST "api/items/:id/bump" (lambda (req id) (hasheq 'id id 'bumped #t))) -(POST "api/echo" (lambda (req) - (define body (request-json-body req)) - (hasheq 'echo body))) ``` -- Handler 收到 request 加捕获的 `:param`;返回 jsexpr(自动包装为 JSON 200)或完整 response -- `request-json-body` 解析 JSON body —— 注意 Racket jsexpr 把 JSON 对象键解析为 **symbol**(`(hash-ref body 'delta)`) -- handler 抛异常会变成 500 JSON 错误,不会断掉连接 -- 未匹配的请求回落到静态文件(SPA `index.html` 回退) - -页面侧: - -```js -const s = await fetch('/api/counter/bump', - {method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({delta: 5})}).then(r => r.json()); -``` - -### 一处声明,三重产物 —— `define-api-routes` - -```racket -(define-api-routes api - [(POST "api/counter/bump") - (bump [delta exact-nonnegative-integer? 1]) ; 必填+校验,或缺省 - (hasheq 'count (add1 delta))]) -``` - -一个子句同时定义:Racket 过程(`bump`)、路由(坏输入 → 报参数名的 400;过程异常 → 500)、 -JS 客户端入口 —— `/glaze/api.js` 自动提供 `glaze.api.counterBump({delta: 5})`、 -`glaze.call(method, path, body)` 和 `glaze.on(name, fn)`。 +`define-api-routes` 一处声明同时产生 Racket procedure、validated route 和 `/glaze/api.js` 中的 JS client entry。 ### 后端 → 前端推送(SSE) ```racket (define bus (make-event-bus)) (start-server ... #:events bus) -(bus-broadcast! bus 'count-changed (hasheq 'count 42)) ; 任意线程 -``` - -```js -glaze.on('count-changed', s => render(s.count)); +(bus-broadcast! bus 'count-changed (hasheq 'count 42)) ``` -页面也可以直接 `new EventSource('/glaze/events')`。浏览器回退同样可用 —— 同源、无额外端口。 +SSE 与嵌入式 WebView 前端共享同一个本地 origin。 ### 安全 -- 仅服务 Host 为 `127.0.0.1` / `localhost` / `[::1]` 的请求(DNS rebinding 防护,恶意源 403)。 -- API handler 永不断连接 —— 参数问题 400 JSON,过程异常 500 JSON(并送达 `run-app` 的 - `#:on-error`,接崩溃上报钩子)。 -- 可选 API token(`#:api-token`):保护 API 路由与 SSE 流(否则 401)。应用窗口打开一次性的 - `?glaze-token=` 引导 URL,把 token 换成 `HttpOnly` cookie(api.js 有意不发放任何凭据); - 程序化客户端发 `X-Glaze-Token`。诚实边界:对随手本机调用者提高门槛 —— 同用户进程仍可从 - 进程内存读取 token。 -- 更新检查:`run-app #:check-update <清单url> #:current-version "1.0.0"` 拉取 - `{"version","url","notes"}`,stderr 提示并广播 `update-available`。自我替换由应用决策。 - -完整可运行的应用见 [`examples/counter/`](examples/counter/)。 +- 仅服务 Host 为 `127.0.0.1` / `localhost` / `[::1]` 的请求; +- 参数问题返回 400 JSON,handler 异常返回 500 JSON; +- 可选 `#:api-token` 保护 API 路由与 SSE; +- 应用窗口通过一次性的 token bootstrap URL 获取 HttpOnly cookie。 -## 系统集成(`glaze/sys`) +## 系统集成 ```racket (require glaze/sys) -(clipboard-set! "hello") ; (clipboard-get) +(clipboard-set! "hello") (notify! "下载完成" "report.pdf 已就绪") -(open-path "/Users/me/report.pdf") ; 默认处理器打开 -(reveal-path "/Users/me/report.pdf"); Finder/资源管理器中定位 +(open-path "/Users/me/report.pdf") +(reveal-path "/Users/me/report.pdf") (unless (single-instance? "com.me.app") (exit 0)) ``` -桌面通知三平台可用(osascript / notify-send / WinRT toast 经 PowerShell)。 - -窗口控制(`glaze/webview`):`webview-set-title!`、`webview-set-size!`、 -`webview-set-fullscreen!`。 +窗口控制:`webview-set-title!`、`webview-set-size!`、`webview-set-fullscreen!`、`webview-focus!`。 ## 系统托盘 -Glaze 提供跨平台的系统托盘,让你的应用驻留在通知区 / 菜单栏,带右键(macOS 为左键)菜单。后端按平台选择——纯 Racket FFI,无需编译任何原生代码: - -- **Windows** — 通过 `ffi/unsafe` 调 `Shell_NotifyIconW` -- **macOS** — 通过 `ffi/unsafe/objc` 调 `NSStatusItem` / `NSMenu` -- **Linux** — 通过 `ffi/unsafe` 调 `libayatana-appindicator` + `libgtk-3` - -运行时若某平台的原生库不可用,托盘会静默降级为空操作,应用的其余部分照常运行。 - -```racket -(require glaze) - -(define t - (make-tray #:icon #f - #:tooltip "我的 Glaze 应用" - #:menu (list (make-menu-item "退出" - #:action (lambda () (exit 0)))))) -(tray-set-tooltip! t "运行中") -;; ...稍后 -(tray-close t) -``` - -> **macOS 注意**:纯菜单栏应用(不显示 Dock 图标)需要构建为 `.app` bundle 并设置 `LSUIElement`——`raco glaze build` 会为你配置好。 - -## 应用平台 API - -除服务器/webview 核心外,Glaze 内置商业桌面应用所需的周边能力: - -```racket -(require glaze) +系统托盘是**可选能力**。tray backend 缺失时可以退化为 inert stub;这和主 WebView 必须成功启动是两种不同的产品语义。 -;; ---- 原生文件对话框(NSOpenPanel / comdlg32 / zenity-kdialog)---- -(define f (pick-file #:title "打开报告" #:filters '(("报告" "*.rep" "*.csv")))) -(define dir (pick-folder #:title "选择目录")) -(define out (save-file-dialog #:title "另存为" #:default-name "out.rep")) -;; #f = 用户取消;可先用 (dialog-supported?) 做优雅降级判断。 - -;; ---- 菜单栏(声明式,三平台)---- -(webview-set-menu! wv - (list (make-menu "文件" - (list (make-menu-item "打开…" #:accel "CmdOrCtrl+O" - #:action open-doc) - menu-separator - (make-menu-item "退出" #:action (lambda () (exit 0))))))) -;; macOS 快捷键真实生效;Windows/Linux 目前仅展示(v1)。 - -;; ---- 深度链接(myapp://…)---- -(ensure-url-scheme! "myapp") ; Windows 注册表 / Linux xdg; - ; macOS 在构建时 --url-scheme 声明 - -;; ---- 开机自启 ---- -(auto-launch-set! "MyApp" #t) -(auto-launch-enabled? "MyApp") - -;; ---- 多窗口 ---- -(for ([w (all-webviews)]) (webview-focus! w)) -(wait-for-webviews) ; 阻塞直到所有窗口关闭 -``` +- Windows:`Shell_NotifyIconW` +- macOS:`NSStatusItem` / `NSMenu` +- Linux:`libayatana-appindicator` + GTK ## 示例 -| 示例 | 展示内容 | +| 示例 | 内容 | |---|---| -| [`examples/showcase/`](examples/showcase/) | **综合演示(推荐先看)** —— 全部能力一屏尽览 | -| [`examples/hello/`](examples/hello/) | 最小应用 —— 8 行 `run-app` | -| [`examples/counter/`](examples/counter/) | JS↔Racket 桥接 —— `fetch` 调用 Racket 状态 | -| [`examples/webview-demo.rkt`](examples/webview-demo.rkt) | WebView 生命周期:加载、导航、关闭、验证 API | -| [`examples/agent-verify.rkt`](examples/agent-verify.rkt) | Agent 工作流:无人值守断言页面状态 + 截图 | -| [`examples/tray-demo.rkt`](examples/tray-demo.rkt) | 跨平台系统托盘 + 可用菜单 | - -## 路线图 +| [`examples/showcase/`](examples/showcase/) | 综合演示 —— 全部能力在一个原生窗口里 | +| [`examples/hello/`](examples/hello/) | 最小原生应用 | +| [`examples/counter/`](examples/counter/) | JS↔Racket 桥接 | +| [`examples/webview-demo.rkt`](examples/webview-demo.rkt) | 跨平台 Native WebView 生命周期 | +| [`examples/agent-verify.rkt`](examples/agent-verify.rkt) | 无人值守断言页面状态 + 截图 | +| [`examples/tray-demo.rkt`](examples/tray-demo.rkt) | 跨平台系统托盘 | -- [x] **Phase 1** — 本地 HTTP 服务器 + 系统浏览器 -- [x] **Phase 2** — 前端资源打包、系统托盘、应用打包 -- [x] **Phase 3** — 原生 WebView 嵌入(WebView2 / WKWebView / WebKitGTK)— *完成,三平台 CI e2e 验证* +## Roadmap -> **Phase 3 完成:** 三个后端(macOS WKWebView、Windows WebView2、Linux WebKitGTK)均通过 -> 真窗口 CI e2e——open、页面加载、`webview-title`/`url` 验证、`webview-capture!` 截图、 -> `webview-navigate`、关闭(编程与系统按钮)、`#:on-close` 回调;三平台均已支持 -> `#:devtools?` 与窗口缩放跟随。全程纯 Racket FFI,无编译器。剩余打磨(非阻塞):多窗口体验。 +- [x] **Phase 1** —— 本地 HTTP 服务 + 早期浏览器原型 +- [x] **Phase 2** —— 前端资源打包、系统托盘、应用打包 +- [x] **Phase 3** —— 原生 WebView(WebView2 / WKWebView / WebKitGTK),三平台 CI e2e +- [x] **GUI-first 契约** —— WebView 必须可用;失败给出可操作安装指引,不再 fallback 到浏览器 -## 许可证 +## License -基于 [MIT 许可证](LICENSE) 授权。 +MIT License。 diff --git a/docs/gui-first.md b/docs/gui-first.md new file mode 100644 index 0000000..de412f3 --- /dev/null +++ b/docs/gui-first.md @@ -0,0 +1,13 @@ +# GUI-first startup policy + +Glaze is a desktop GUI framework. Application entry points (`run-app`, `open-window`, and `open-webview`) require a working native WebView backend and never fall back to the system browser. + +If native startup fails, Glaze preserves the underlying backend error and adds platform-specific remediation: + +- **Windows:** install or repair Microsoft Edge WebView2 Runtime (Evergreen). The diagnostic includes `winget install --id Microsoft.EdgeWebView2Runtime -e` and Microsoft's official WebView2 download page. Glaze itself ships `WebView2Loader.dll`. +- **macOS:** WKWebView is part of macOS; run from a logged-in graphical session and report the preserved backend error if initialization still fails. +- **Linux:** install GTK 3 + WebKitGTK for the distribution and run inside a graphical desktop session (or Xvfb in CI). + +A packaged GUI application may not have a visible console, especially on Windows where Glaze builds with `raco exe --gui`. For that reason, an interactive startup failure also attempts to show an OS-level error dialog containing the same diagnosis before the exception terminates the application. CI/GitHub Actions suppress the dialog so unattended jobs cannot block. Set `GLAZE_NO_STARTUP_DIALOG=1` to suppress it explicitly. + +This policy is intentional: a desktop application that unexpectedly becomes a browser tab is a different application model and hides dependency/backend failures during development. Optional helpers such as `open-browser` remain available for deliberately opening external documentation, OAuth pages, and similar URLs, but they are not part of native application startup. diff --git a/examples/README.md b/examples/README.md index c1995e5..4c80b67 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,9 +3,9 @@ | 示例 | 一句话 | 展示的能力 | |---|---|---| | [`showcase/`](showcase/) | **一屏看尽全部能力(推荐先看)** | 宏路由全形态(类型校验/400/:path/500)、SSE 事件流 + 后端错误回流(on-error)、系统功能(剪贴板/通知/Finder/窗口控制)、Agent 验证(title/url/capture + 截图回传)、API token(401 演示)、更新检查、托盘、单实例 | -| [`hello/`](hello/) | 8 行最小应用 | run-app 一键入口、静态页面 | +| [`hello/`](hello/) | 8 行最小应用 | run-app 一键入口、原生窗口、静态页面 | | [`counter/`](counter/) | JS↔Racket 桥接主打 | define-api-routes、SSE 广播驱动 UI、api.js 生成客户端、模块可组合(provide api/bus) | -| [`webview-demo.rkt`](webview-demo.rkt) | WebView 生命周期 | 加载/导航/关闭/on-close/验证 API 实时打印、看门狗 | +| [`webview-demo.rkt`](webview-demo.rkt) | 跨平台 WebView 生命周期 | 原生窗口、加载/导航/关闭/on-close/验证 API 实时打印、看门狗 | | [`agent-verify.rkt`](agent-verify.rkt) | 无人值守验证 | agent 工作流:轮询断言 + 截图 + 退出码 | | [`tray-demo.rkt`](tray-demo.rkt) | 跨平台托盘 | make-tray/菜单/tooltip 动态更新 | @@ -17,4 +17,6 @@ racket examples/counter/main.rkt # 桥接 + 事件 racket examples/hello/main.rkt # 最小应用 ``` +Glaze 示例只走原生桌面 WebView,不会退回系统浏览器。若 WebView2 / WebKitGTK 等运行时依赖缺失,启动会直接失败并打印对应平台的安装/修复指引。 + 所有示例均可 `raco glaze build` 打包为独立应用。 diff --git a/examples/webview-demo.rkt b/examples/webview-demo.rkt index bcc8e51..b070b64 100644 --- a/examples/webview-demo.rkt +++ b/examples/webview-demo.rkt @@ -1,10 +1,10 @@ #lang racket/base -;; Glaze WebView demo (macOS). Serves two pages from a temp dir, opens a -;; native NSWindow + WKWebView, auto-navigates to page 2 after a while, and -;; exits when you close the window (red button) or after the watchdog — -;; exercising the whole surface: rendering, JS execution, webview-navigate, -;; verification APIs, and the on-close callback. +;; Glaze native WebView demo (Windows/macOS/Linux). Serves two pages from a +;; temp dir, opens a native desktop window with the platform WebView backend, +;; auto-navigates to page 2 after a while, and exits when you close the window +;; or after the watchdog — exercising rendering, JS execution, +;; webview-navigate, verification APIs, and the on-close callback. ;; ;; Run: racket examples/webview-demo.rkt @@ -21,7 +21,7 @@ (lambda (o) (display #<Glaze Demo
-

Glaze on macOS

-

NSWindow + WKWebView,由 Racket 纯 FFI 创建,无 C 编译

+

Glaze Native WebView

+

Racket + system native window + embedded WebView

0
- -

约 20 秒后 Racket 会调用 webview-navigate 跳到第二页
- 点红色关闭按钮,Racket 侧 on-close 将触发并退出

+ +

Racket will call webview-navigate and switch pages in about 20 seconds.
+ Close the native window at any time to trigger the Racket on-close callback.

HTML o)) #:exists 'replace) @@ -70,14 +70,17 @@ HTML (say "[demo] server up on 127.0.0.1:~a\n" port) (define closed? (box #f)) -(define wv (open-window (format "http://127.0.0.1:~a/" port) - #:title "Glaze · macOS Demo" - #:width 960 - #:height 680 - #:on-close (lambda () (set-box! closed? #t)))) -(unless wv - (error 'demo "webview backend unavailable — run this on macOS")) -(say "[demo] window opened (backend=~a)\n" (webview-backend wv)) +(define wv + (with-handlers ([exn:fail? (lambda (e) + (stop) + (delete-directory/files dir) + (raise e))]) + (open-window (format "http://127.0.0.1:~a/" port) + #:title "Glaze · Native WebView Demo" + #:width 960 + #:height 680 + #:on-close (lambda () (set-box! closed? #t))))) +(say "[demo] native window opened (backend=~a)\n" (webview-backend wv)) (define tmp (path->string (find-system-path 'temp-dir))) diff --git a/glaze-cli/cli.rkt b/glaze-cli/cli.rkt index 47d3556..d42cd22 100644 --- a/glaze-cli/cli.rkt +++ b/glaze-cli/cli.rkt @@ -5,8 +5,6 @@ racket/file racket/string racket/system - glaze/server - glaze/browser glaze/build glaze/license) @@ -14,18 +12,15 @@ (printf "Creating Glaze project: ~a\n" name) (make-directory* name) (make-directory* (build-path name "public")) - (write-file (build-path name "main.rkt") - (string-append "#lang racket/base\n\n" - "(require glaze)\n\n" - "(define-values (port server)\n" - " (start-dev-server #:public-dir \"public\"))\n\n" - "(printf \"Glaze app running at http://127.0.0.1:~a\\n\" port)\n" - "(open-browser (format \"http://127.0.0.1:~a\" port))\n\n" - "(with-handlers ([exn:break?\n" - " (lambda (e)\n" - " (stop-server server)\n" - " (printf \"Server stopped.\\n\"))])\n" - " (sync never-evt))\n")) + (write-file + (build-path name "main.rkt") + (string-append + "#lang racket/base\n\n" + "(require racket/runtime-path\n" + " glaze)\n\n" + "(define-runtime-path public \"public\")\n\n" + "(run-app #:public-dir public\n" + (format " #:title ~s)\n" name))) (write-file (build-path name "public" "index.html") #" @@ -54,16 +49,24 @@ ") - (printf "Done! Run:\n cd ~a\n racket main.rkt\n" name)) + (printf "Done! Run:\n cd ~a\n racket main.rkt\n\nOr use:\n cd ~a\n raco glaze dev\n" + name name)) -(define (dev-server) - (define-values (actual-port server) (start-dev-server #:port 8080 #:public-dir "public")) - (printf "Dev server running at http://127.0.0.1:~a\n" actual-port) - (open-browser (format "http://127.0.0.1:~a" actual-port)) - (with-handlers ([exn:break? (lambda (e) - (stop-server server) - (printf "Server stopped.\n"))]) - (sync never-evt))) +;; `dev` runs the project's real entry point, so routes/events/window options +;; in main.rkt are preserved. Glaze development follows the same native GUI +;; path as the shipped application; there is no browser-mode escape hatch. +(define (dev-app) + (define entry (build-path (current-directory) "main.rkt")) + (unless (file-exists? entry) + (error 'dev "main.rkt not found in ~a; run this command from a Glaze project" + (path->string (current-directory)))) + (define racket-exe (find-executable-path "racket" #f)) + (unless racket-exe + (error 'dev "racket executable not found on PATH")) + (printf "Starting Glaze native app: ~a\n" (path->string entry)) + (define code (system*/exit-code racket-exe (path->string entry))) + (unless (zero? code) + (exit code))) ;; Parse the rest args for `build`. Recognized flags: ;; --name app/bundle name (default: project dir name) @@ -76,12 +79,11 @@ ;; --sign code-signing identity (macOS: codesign identity, ;; "-" = ad-hoc; Windows: cert SHA-1 thumbprint or ;; subject name for signtool) -;; --entitlements

macOS: .entitlements plist for codesign -;; --no-hardened-runtime macOS: disable hardened runtime (notarization -;; needs it; leave it on unless you know better) +;; --entitlements

macOS: path to a .entitlements plist +;; --no-hardened-runtime macOS: disable hardened runtime ;; --timestamp-url Windows: RFC-3161 timestamp server for signtool -;; --notarize macOS: notarytool keychain profile; submits the -;; dmg/app for notarization and staples it +;; --notarize macOS: notarytool keychain profile +;; --url-scheme deep-link URL scheme (repeatable) (define (parse-build-opts rest) (let loop ([args rest] [name #f] @@ -133,7 +135,7 @@ sign entitlements #t ts-url notarize schemes)] [(and (equal? (car args) "--timestamp-url") (pair? (cdr args))) (loop (cddr args) name version icon entry out embed installer - sign entitlements no-hardened (cadr args) notarize)] + sign entitlements no-hardened (cadr args) notarize schemes)] [(and (equal? (car args) "--notarize") (pair? (cdr args))) (loop (cddr args) name version icon entry out embed installer sign entitlements no-hardened ts-url (cadr args) schemes)] @@ -171,13 +173,17 @@ (displayln "Usage: raco glaze [args]") (displayln "") (displayln "Commands:") - (displayln " init Create a new Glaze project") - (displayln " dev Start dev server with auto-open browser") + (displayln " init Create a native Glaze desktop project") + (displayln " dev Run this project's native Glaze desktop app") (displayln " build Build a distributable (raco exe + raco distribute)") (displayln " keygen Create an RSA keypair for license signing") (displayln " license Sign or verify offline license files") (displayln " help Show this help") (displayln "") + (displayln "Glaze requires a working native WebView. If it is missing or broken, startup") + (displayln "fails with platform-specific installation/repair instructions; it never opens") + (displayln "the system browser as a fallback.") + (displayln "") (displayln "build options:") (displayln " --name app/bundle name (default: project dir)") (displayln " --version app version (Info.plist / MSI metadata)") @@ -197,13 +203,11 @@ (displayln " Info.plist entries; call (ensure-url-scheme! ...)") (displayln " at app start on Windows/Linux)")) - (define (write-file path content) (call-with-output-file path (lambda (out) (display content out)) #:exists 'replace)) ;; ---- keygen: create an RSA keypair for license signing ---- -;; raco glaze keygen [--out

] ; writes private.pem + public.pem (define (parse-keygen-opts rest) (let loop ([args rest] [out "keys"]) (cond @@ -242,7 +246,7 @@ (cond [(null? args) (values sub key pub product subject expiry machine out (reverse positional))] - [(and (null? sub) (member (car args) '("sign" "verify"))) + [(and (not sub) (member (car args) '("sign" "verify"))) (loop (cdr args) (car args) key pub product subject expiry machine out positional)] [(and (equal? (car args) "--key") (pair? (cdr args))) (loop (cddr args) sub (cadr args) pub product subject expiry machine out positional)] @@ -308,7 +312,7 @@ (init-project (if (null? rest) "myapp" (car rest)))] - ["dev" (dev-server)] + ["dev" (dev-app)] ["build" (build-command rest)] ["keygen" (keygen-command rest)] ["license" (license-command rest)] diff --git a/glaze-doc/scribblings/glaze.scrbl b/glaze-doc/scribblings/glaze.scrbl index 5721089..b613b4f 100644 --- a/glaze-doc/scribblings/glaze.scrbl +++ b/glaze-doc/scribblings/glaze.scrbl @@ -3,7 +3,9 @@ @title{Glaze} @author{turinglambdaai} -Build desktop apps with Racket backend and web frontend. +Glaze builds desktop applications with a Racket backend and a Web frontend +rendered inside a native OS window. Windows uses WebView2, macOS uses +WKWebView, and Linux uses WebKitGTK. @section{Quick Start} @@ -12,9 +14,14 @@ Build desktop apps with Racket backend and web frontend. $ raco glaze init myapp $ cd myapp $ racket main.rkt + # or: raco glaze dev } -@section{Core API} +A Glaze application is @bold{native-GUI only}. If its native WebView cannot +start, application startup fails with platform-specific installation or repair +guidance. Glaze never substitutes a system-browser tab for the desktop window. + +@section{Application Lifecycle} @defmodule[glaze/app] @@ -25,41 +32,42 @@ Build desktop apps with Racket backend and web frontend. [#:title title string? "Glaze"] [#:width width exact-positive-integer? 1024] [#:height height exact-positive-integer? 768] - [#:fallback-browser? fallback-browser? boolean? #t] [#:events events (or/c #f event-bus?) #f] [#:api-token api-token (or/c #f string? #t) #f] [#:on-close on-close (-> any) (lambda () (void))] - [#:on-error on-error (or/c #f (exn? string? . -> . any)) #f] + [#:on-error on-error (or/c #f procedure?) #f] [#:check-update check-update (or/c #f string?) #f] [#:current-version current-version string? "0.0.0"] - [#:on-ready on-ready (-> (or/c webview? #f) string? any) - (lambda (wv url) (void))]) - (values (or/c 'webview 'browser) procedure?)]{ -The one-call entry: picks a free port (unless @racket[#:port] is given), -starts the server (static + JSON API, optional SSE event bus and API token), -opens the native webview window, and blocks until the window closes. -@racket[on-ready] receives the webview handle and URL as soon as the window -is up — the hook agents use for verification. @racket[on-error], when given, -receives every API-handler failure (the 500 path) for crash reporting. -When @racket[#:api-token] is @racket[#t], a random token is generated -(@racket[make-api-token]) and printed in the browser fallback path. -When @racket[#:check-update] is a manifest URL, a background check runs -(see @secref["update-checks"]). - -Returns @racket['webview] after a window-driven shutdown (server already -stopped) or @racket['browser] immediately after opening the system-browser -fallback (server still running; call the returned shutdown procedure to stop -it). + [#:on-ready on-ready procedure? (lambda (wv url) (void))]) + (values 'webview procedure?)]{ +The one-call application entry point. It selects a free loopback port unless +@racket[#:port] is supplied, starts the static/API server, opens the native +WebView window, invokes @racket[on-ready] with the @racket[webview?] handle and +clean application URL, and blocks until the window closes. + +When the native window closes, the local server is stopped and the procedure +returns @racket[(values 'webview shutdown)]. If native WebView startup fails, +Glaze first stops the local server and then propagates an actionable startup +error. There is intentionally no browser-fallback option. + +@racket[#:api-token] may be a string or @racket[#t]. With @racket[#t], Glaze +generates a random capability token and uses a one-time bootstrap URL to set an +HttpOnly cookie for the embedded frontend. @racket[#:on-error] receives API +handler failures. @racket[#:check-update] wires an update manifest into the +application lifecycle. } -@defproc[(make-api-token) string?]{ A random 32-hex-character capability -token (CSPRNG) for use with @racket[#:api-token]. } +@defproc[(make-api-token) string?]{ +Returns a random 32-hex-character capability token. +} @defparam[current-api-token token string?]{ -Bound by @racket[run-app] so callbacks can read the active token (empty when -the API is open). +Bound by @racket[run-app] so callbacks can read the active API token; the value +is the empty string when API-token protection is disabled. } +@section{Local Server} + @defmodule[glaze/server] @defproc[(start-server @@ -70,548 +78,306 @@ the API is open). [#:api-token api-token (or/c #f string?) #f] [#:serve-api-client? serve-api-client? boolean? #t]) (values exact-nonnegative-integer? procedure?)]{ -Starts a local HTTP server on @racket[127.0.0.1] serving static files from -@racket[public-dir] (SPA index.html fallback) with optional JSON API routes -(see @secref["js-bridge"]). Verifies the listener is accepting before -returning. Returns the port and a shutdown procedure. - -@racket[#:events] mounts the SSE endpoint @litchar{GET /glaze/events} -(see @secref["events"]). @racket[#:api-token] guards the API routes and the -SSE stream (see @secref["security"]). @racket[#:serve-api-client?] controls -the generated JS client at @litchar{GET /glaze/api.js}. -@racket[start-dev-server] is a backward-compatible alias. +Starts the loopback HTTP server that powers the embedded frontend. Static +resources, SPA index fallback, JSON routes, generated API client, and optional +SSE event stream share the same origin. The return values are the actual port +and a shutdown procedure. @racket[start-dev-server] remains a compatibility +alias for this low-level server primitive; it does not define a browser-based +application mode. } -@defproc[(stop-server [shutdown-proc procedure?]) void?]{ -Stops the server. -} - -@defparam[current-glaze-error-reporter reporter - (exn? string? . -> . any)]{ -Receives API-handler failures (the 500 path). Defaults to logging on stderr; -@racket[run-app] parameterizes this to its @racket[#:on-error] callback. -} +@defproc[(stop-server [shutdown-proc procedure?]) void?]{Stops the server.} @defmodule[glaze/browser] @defproc[(open-browser [url string?]) void?]{ -Opens the system browser to the given URL. +Explicitly opens an external URL in the user's default browser. This helper is +appropriate for documentation, OAuth, support pages, and similar external +resources. @racket[run-app], @racket[open-window], and @racket[open-webview] +do not use it as a fallback. } @section[#:tag "js-bridge"]{JavaScript Bridge} @defmodule[glaze/api] -The page calls Racket with plain @litchar{fetch("/api/...")}; Racket answers -JSON. One code path works in the embedded WebView, in the system-browser -fallback, and in dev (curl-able). +The embedded frontend calls Racket through ordinary same-origin HTTP requests. +This keeps the bridge easy to inspect and test with normal developer tools. @defproc[(GET [path string?] [handler procedure?]) route?]{} @defproc[(POST [path string?] [handler procedure?]) route?]{} @defproc[(PUT [path string?] [handler procedure?]) route?]{} -@defproc[(DELETE [path string?] [handler procedure?]) route?]{ -Build a route for @racket[path]. @litchar{":x"} segments capture the -request path segment as a string. The handler receives the web-server -request followed by the captured values and returns a jsexpr (auto-wrapped -as a 200 JSON response) or a full response. -} +@defproc[(DELETE [path string?] [handler procedure?]) route?]{} -@defproc[(request-json-body [req request?]) jsexpr?]{ -Parses the request body as JSON; a missing, empty, or invalid body yields -the empty hash so optional parameters fall back to defaults and required -ones report a clean 400. Racket jsexpr parses JSON object keys as -@bold{symbols}: @racket[(hash-ref body 'delta)]. -} - -@defproc[(json-response [data jsexpr?]) response?]{ A 200 JSON response. } -@defproc[(api-response [data jsexpr?]) response?]{ Same as @racket[json-response]. } -@defproc[(error-response [status exact-nonnegative-integer?] [msg string?]) - response?]{ A JSON error response with the given status code. } +A route handler receives the web-server request followed by any captured +@litchar{:param} path values. Returning a jsexpr produces a JSON 200 response; +a full response value may also be returned. -@defproc[(exn:fail:glaze:bad-param? [v any/c]) boolean?]{ -Raised by @racket[define-api-routes] argument checking; the server maps it -to a 400 naming the parameter. A plain @racket[exn:fail] from a handler -stays a 500. +@defproc[(request-json-body [req request?]) jsexpr?]{ +Parses a JSON request body. Missing, empty, or malformed input yields an empty +hash so route validation can produce a clean client error. JSON object keys in +Racket jsexprs are symbols, for example @racket[(hash-ref body 'delta)]. } -@codeblock|{ -#lang racket/base -(require glaze) -(run-app - #:public-dir "public" - #:api (list - (POST "api/counter/bump" - (lambda (req) - (define body (request-json-body req)) - (bump! (hash-ref body 'delta 1)))))) -}| - -@subsection[#:tag "typed-routes"]{Typed Routes: @racket[define-api-routes]} +@defproc[(json-response [data jsexpr?]) response?]{} +@defproc[(api-response [data jsexpr?]) response?]{} +@defproc[(error-response [status exact-nonnegative-integer?] + [message string?]) response?]{} @defmodule[glaze/api-macros] @defform[(define-api-routes id clause ...)]{ -Each @racket[clause] has the shape +Declares a callable Racket procedure, a validated HTTP route, and a generated +JavaScript client entry from one route clause. @racketblock[ -[(METHOD _path) - (_proc _param ...) - _body ...+]] - -and defines @bold{three} things from one declaration: - -@itemlist[ - @item{a Racket procedure @racket[_proc], callable directly (tests - included);} - @item{a route added to @racket[id] — JSON body keys and @litchar{":x"} path - captures become the procedure's arguments;} - @item{a JS client entry served at @litchar{/glaze/api.js} (see below).}] - -Parameter forms: plain @racket[_id] (required, any value), -@racket[[_id predicate]] (required, checked), or -@racket[[_id predicate default]] (optional with default). Bad input raises -a 400 naming the parameter; handler exceptions stay 500. - -@codeblock|{ -#lang racket/base -(require glaze) (define-api-routes api [(POST "api/counter/bump") (bump [delta exact-nonnegative-integer? 1]) - (begin (bump! delta) (hasheq 'count (count)))] - [(GET "api/items/:id") - (item id) - (hasheq 'id id)]) -(run-app #:public-dir "public" #:api api) -}| + (hasheq 'count (add1 delta))])] + +The generated @litchar{/glaze/api.js} exposes route-specific functions plus +@litchar{glaze.call(...)} and @litchar{glaze.on(...)}. } -@subsection[#:tag "js-client"]{The Generated JS Client} +@section[#:tag "events"]{Event Push} -With routes registered, @litchar{GET /glaze/api.js} serves a client derived -from them: each route becomes a @litchar{glaze.api.*} function (path -params become arguments), plus the generic -@litchar{glaze.call(method, path, body)} and -@litchar{glaze.on(name, fn)} (an EventSource wrapper over -@litchar{/glaze/events}). Disable with -@racket[#:serve-api-client? #f]. +@defmodule[glaze/events] -@verbatim|{ -const s = await glaze.api.counterBump({delta: 5}); -glaze.on('count-changed', s => render(s.count)); -}| +Glaze uses same-origin Server-Sent Events for backend-to-frontend push. -@section[#:tag "events"]{Event Push (SSE)} +@defproc[(make-event-bus) event-bus?]{Creates a broadcast event bus.} +@defproc[(bus-broadcast! [bus event-bus?] + [name (or/c symbol? string?)] + [data jsexpr?]) void?]{ +Broadcasts an event without blocking the producer; a full per-subscriber +backlog drops that event for the slow subscriber only. +} +@defproc[(bus-subscribe! [bus event-bus?]) async-channel?]{} +@defproc[(bus-unsubscribe! [bus event-bus?] [channel async-channel?]) void?]{} +@defproc[(bus-wait [channel async-channel?] [seconds real? 10]) any/c]{} -@defmodule[glaze/events] +@section{Native WebView} -Backend-to-frontend push — Glaze's answer to Tauri's @litchar{emit()} and -Eel's websocket push — over plain Server-Sent Events on the same origin, so -the browser fallback gets push for free. +@defmodule[glaze/webview/main] -@defproc[(make-event-bus) event-bus?]{ A broadcast bus. Pass it to -@racket[start-server]/@racket[run-app] via @racket[#:events] to mount -@litchar{GET /glaze/events} (15s keepalive; per-subscriber bounded backlog -with drop-on-overflow; disconnect cleanup). } +The native WebView is an application prerequisite, not an optional rendering +mode. The backends are WebView2 on Windows, WKWebView on macOS, and WebKitGTK +on Linux. -@defproc[(bus-broadcast! [bus event-bus?] [name (or/c symbol? string?)] - [data jsexpr?]) void?]{ -Deliver @racket[(list name data)] to every subscriber, from any thread. -Non-blocking: a full backlog drops the event for that subscriber only. +@defproc[(open-window + [url string?] + [#:title title string? "Glaze"] + [#:width width exact-positive-integer? 1024] + [#:height height exact-positive-integer? 768] + [#:devtools? devtools? boolean? #f] + [#:on-close on-close (-> any) (lambda () (void))]) + webview?]{ +Opens a native desktop window and loads @racket[url]. If the backend or its +runtime dependency is unavailable, this procedure raises. Before raising in +an interactive desktop process, Glaze also attempts to show an OS-level error +dialog so packaged GUI applications without a console still give the user an +actionable explanation. CI environments suppress the dialog and retain the +exception text in logs. + +There is no @racket[#:fallback-browser?] keyword. } -@defproc[(bus-subscribe! [bus event-bus?]) async-channel?]{ -Register a new subscriber; returns an asynchronous channel of -@racket[(list name data)] pairs (for non-SSE consumers). +@defproc[(open-webview + [url string?] + [#:title title string? "Glaze"] + [#:width width exact-positive-integer? 1024] + [#:height height exact-positive-integer? 768] + [#:devtools? devtools? boolean? #f] + [#:on-close on-close (-> any) (lambda () (void))]) + webview?]{ +Lower-level synonym of @racket[open-window] with the same fail-fast contract. } -@defproc[(bus-unsubscribe! [bus event-bus?] [ch async-channel?]) void?]{} +@defproc[(webview-supported?) boolean?]{ +Non-throwing capability probe for the current platform backend. Actual window +creation remains the authoritative runtime check. +} -@defproc[(bus-wait [ch async-channel?] [secs real? 10]) - (or/c (list/c symbol? jsexpr?) 'timeout)]{ -Blocking receive with timeout — for tests and non-SSE consumers. +@defproc[(webview-last-error) any/c]{Returns the most recent backend probe/startup error.} +@defproc[(webview-install-guidance) string?]{ +Returns platform-specific dependency guidance. Windows guidance names the +Microsoft Edge WebView2 Evergreen Runtime; Linux guidance names GTK 3 and +WebKitGTK packages; macOS explains that WKWebView is part of the OS. } +@defproc[(webview-diagnostic) string?]{Formats the current error and guidance.} + +@defproc[(webview-navigate [wv webview?] [url string?]) void?]{} +@defproc[(webview-close [wv webview?]) void?]{} +@defproc[(webview-title [wv webview?]) (or/c #f string?)]{} +@defproc[(webview-url [wv webview?]) (or/c #f string?)]{} +@defproc[(webview-capture! [wv webview?] + [dest (or/c #f string? path?) #f]) + (or/c #f path?)]{} +@defproc[(webview-set-title! [wv webview?] [title string?]) void?]{} +@defproc[(webview-set-size! [wv webview?] + [width exact-positive-integer?] + [height exact-positive-integer?]) void?]{} +@defproc[(webview-set-fullscreen! [wv webview?] [on? boolean?]) void?]{} +@defproc[(webview-focus! [wv webview?]) void?]{} +@defproc[(webview-set-menu! [wv webview?] [menus list?]) void?]{} +@defproc[(webview-closed? [wv webview?]) boolean?]{} +@defproc[(all-webviews) (listof webview?)]{} +@defproc[(close-all-webviews!) void?]{} +@defproc[(wait-for-webviews [timeout-seconds (or/c #f real?) #f]) boolean?]{} + +@subsection{Startup Dependency Feedback} -In the page: +When native startup fails, Glaze reports the backend error and remediation. +Typical guidance includes: -@verbatim|{ -const es = new EventSource('/glaze/events'); -es.addEventListener('count-changed', e => render(JSON.parse(e.data).count)); -}| +@itemlist[ + @item{Windows: install or repair Microsoft Edge WebView2 Runtime (Evergreen), + with a @exec{winget} command and Microsoft's official download page.} + @item{Debian/Ubuntu: @exec{sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0}.} + @item{Fedora: @exec{sudo dnf install gtk3 webkit2gtk4.1}.} + @item{Arch: @exec{sudo pacman -S gtk3 webkit2gtk-4.1}.} + @item{macOS: WKWebView is built in; use a logged-in graphical session and + report the preserved backend error if initialization still fails.}] + +Set environment variable @envvar{GLAZE_NO_STARTUP_DIALOG} to @litchar{1} to +suppress the interactive error dialog while retaining the exception. Dialogs +are also suppressed automatically under common CI environments. @section{System Integrations} @defmodule[glaze/sys] -Desktop-system integrations beyond the tray, with the same platform -dispatch (and no-op degradation) as @racket[glaze/tray]. All procedures are -best-effort and never raise for environmental reasons. +@defproc[(sys-supported?) boolean?]{} +@defproc[(clipboard-set! [text string?]) boolean?]{} +@defproc[(clipboard-get) string?]{} +@defproc[(notify! [title string?] + [body string? ""] + [#:subtitle subtitle string? ""]) boolean?]{} +@defproc[(open-path [path-or-url (or/c path? string?)]) boolean?]{} +@defproc[(reveal-path [path (or/c path? string?)]) boolean?]{} +@defproc[(single-instance? [app-id any/c]) boolean?]{} + +These helpers are best-effort integrations. Their failure semantics are +separate from the WebView startup contract: the WebView is required for the +application itself, while an optional integration may report failure without +changing the application's rendering model. -@defproc[(sys-supported?) boolean?]{ Whether the current platform backend -loaded its native libraries. } - -@defproc[(clipboard-set! [text string?]) boolean?]{ Places text on the -system clipboard. } +@section{System Tray} -@defproc[(clipboard-get) string?]{ Reads text from the system clipboard -(@litchar{""} when empty or unavailable). } +@defmodule[glaze/tray] -@defproc[(notify! [title string?] [body string? ""] - [#:subtitle subtitle string? ""]) boolean?]{ -Shows a desktop notification; delivery itself is best-effort (OS settings -may suppress it). -} +@defproc[(tray-supported?) boolean?]{} +@defproc[(make-tray [#:icon icon any/c] + [#:tooltip tooltip string?] + [#:menu menu list?] + [#:on-event on-event procedure? (lambda (e) (void))]) + tray?]{} +@defproc[(tray-set-tooltip! [tray tray?] [tooltip string?]) void?]{} +@defproc[(tray-set-icon! [tray tray?] [icon any/c]) void?]{} +@defproc[(tray-set-menu! [tray tray?] [menu list?]) void?]{} +@defproc[(tray-close [tray tray?]) void?]{} + +The tray remains an optional capability. If its native backend is unavailable, +Glaze may use an inert tray stub; this does not weaken the mandatory native +WebView contract for the main window. -@defproc[(open-path [p (or/c path? string?)]) boolean?]{ -Opens a path or URL with the OS default handler. -} +@section[#:tag "security"]{Security} -@defproc[(reveal-path [p (or/c path? string?)]) boolean?]{ -Reveals a file in Finder / Explorer / the file manager, selecting it. -} +The local server binds to loopback and validates Host headers against +@litchar{127.0.0.1}, @litchar{localhost}, and @litchar{[::1]} to reduce DNS +rebinding risk. -@defproc[(single-instance? [app-id any/c]) boolean?]{ -Adjudicates @litchar{"am I the first instance?"} without leaving files -behind: derives a deterministic TCP port from the id and holds a listener on -it for the process lifetime. The second instance's bind fails and gets -@racket[#f]. (A firewall prompt is possible on first run on some systems.) -} +With @racket[#:api-token], API routes and the SSE stream require a capability. +@racket[run-app] opens the native WebView at a one-time bootstrap URL; the +server exchanges the token for an HttpOnly cookie and redirects to the clean +path. Programmatic clients may use the @litchar{X-Glaze-Token} header. -Window controls live in @racket[glaze/webview]: @racket[webview-set-title!], -@racket[webview-set-size!], @racket[webview-set-fullscreen!], -@racket[webview-focus!]. +This is defense in depth against casual local callers, not isolation from +other processes running as the same OS user. @section[#:tag "update-checks"]{Update Checks} @defmodule[glaze/update] -Glaze deliberately stops at @emph{notification} — downloading and replacing -a running app is a per-distribution decision (notarized DMG, MSI upgrade, -AppImage overwrite); the app decides what an @litchar{update-available} -event means. - @defproc[(check-update [manifest-url string?] - [#:current-version current string? "0.0.0"]) + [#:current-version current-version string? "0.0.0"]) (or/c #f hash?)]{ -Fetches a JSON manifest @litchar|{{"version","url","notes"}}| (5s timeout; -HTTPS needs the @racket[openssl] collection) and compares versions -numerically (@litchar{"1.10"} > @litchar{"1.9"}). Returns -@racket[(hasheq 'version _ 'url _ 'notes _ 'sha256 _)] when a newer version -exists, @racket[#f] otherwise. The manifest may carry an optional -@litchar{"sha256"} field (hex digest of the artifact at @racket[_url]); -it is passed through untouched. +Checks a JSON manifest for a newer version. An optional @litchar{sha256} field +is passed through for artifact verification. } - @defproc[(newer-version? [candidate string?] [current string?]) boolean?]{} - -@defproc[(verify-file-sha256 [path (or/c string? path?)] [expected-hex string?]) boolean?]{ -True when the file at @racket[path] has the given SHA-256 digest -(case-insensitive). @racket[#f] means @emph{cannot verify} (missing -openssl, unreadable file) — never treat @racket[#f] as verified. Use it -after downloading an update artifact, before swapping it in. +@defproc[(verify-file-sha256 [path (or/c string? path?)] + [expected-hex string?]) boolean?]{ +Returns @racket[#t] only for a verified digest; @racket[#f] also covers cases +where verification could not be performed. } -@racket[run-app]'s @racket[#:check-update] and @racket[#:current-version] -wire this up: the result is printed to stderr and broadcast as -@litchar{update-available} on the event bus (when @racket[#:events] is -given). - -@section[#:tag "licensing"]{Licensing (Paid Apps)} +@section{Licensing} @defmodule[glaze/license] -An offline license-key scheme with zero native dependencies: RSA-2048 / -SHA-256 signatures computed by the system @racket[openssl] CLI (present on -macOS and Linux out of the box; Git for Windows ships it too). A license -file is JSON claims (@racket[product], @racket[subject], optional -@racket[expiry] and @racket[machine-id]) plus a base64 @racket[signature]. - -Vendor workflow: - -@verbatim{ - $ raco glaze keygen --out keys ; once: private.pem + public.pem - $ raco glaze license sign --key keys/private.pem --product "MyApp" \\ - --subject "Acme Corp" --expiry 2027-12-31 --out app.license - $ raco glaze license verify --pub keys/public.pem --product "MyApp" app.license -} +Glaze includes an offline RSA-2048/SHA-256 licensing helper backed by the +system @exec{openssl} command. @defproc[(issue-license [#:private-key private-key path-string?] [#:product product string?] [#:subject subject string?] [#:expiry expiry (or/c #f string?) #f] - [#:machine-id machine (or/c #f string?) #f] - [#:out out (or/c string? path?) "app.license"]) - path?]{ -Signs and writes a license file; returns its path. -} - + [#:machine-id machine-id (or/c #f string?) #f] + [#:out output (or/c string? path?) "app.license"]) + path?]{} @defproc[(validate-license [license-file (or/c string? path?)] [#:public-key public-key path-string?] [#:product product string?] - [#:machine-id machine string? (machine-id)]) - hash?]{ -Returns @racket[(hasheq 'valid #t 'subject _ 'expiry _ 'machine-id _)] on -success, or @racket[(hasheq 'valid #f 'reason _)] with a stable reason tag: -@racket["missing-file"], @racket["malformed"], @racket["signature"], -@racket["product"], @racket["expired"], @racket["machine"], -@racket["openssl-unavailable"]. -} - + [#:machine-id machine-id string? (machine-id)]) + hash?]{} @defproc[(license-valid? [license-file (or/c string? path?)] [#:public-key public-key path-string?] [#:product product string?] - [#:machine-id machine string? (machine-id)]) + [#:machine-id machine-id string? (machine-id)]) boolean?]{} +@defproc[(machine-id) string?]{} +@defproc[(days-until-expiry [expiry string?]) exact-integer?]{} -@defproc[(machine-id) string?]{ -A stable per-machine digest (64 lowercase hex chars) of the OS machine -identifier — IOPlatformUUID (macOS), @filepath{/etc/machine-id} (Linux), -MachineGuid (Windows) — with a username+hostname fallback. The raw OS -identifier never leaves the function. Honest scope: machine binding is a -courtesy check against casual license sharing, not tamper resistance. -} - -@defproc[(days-until-expiry [expiry string?]) exact-integer?]{ -Days until an @litchar{"YYYY-MM-DD"} date (expiry day inclusive); negative -when past. Raises on a malformed date. -} - -@section[#:tag "dialogs"]{File Dialogs} +@section{File Dialogs} @defmodule[glaze/dialogs] -Native open/save dialogs: @racket[NSOpenPanel]/@racket[NSSavePanel] -(macOS), @racket[GetOpenFileNameW]/@racket[GetSaveFileNameW] (Windows), -@exec{zenity}/@exec{kdialog} (Linux). Dialogs block the calling thread. - +@defproc[(dialog-supported?) boolean?]{} @defproc[(pick-file [#:title title (or/c #f string?) #f] [#:directory directory (or/c #f path-string?) #f] [#:filters filters list? '()]) - (or/c #f path?)]{ -Opens one file. @racket[filters] is a list of -@racket[(list "Human name" "*.txt" "*.md")]. @racket[#f] = cancelled. -} - + (or/c #f path?)]{} @defproc[(pick-files [#:title title (or/c #f string?) #f] [#:directory directory (or/c #f path-string?) #f] [#:filters filters list? '()]) - (listof path?)]{ -Multi-select; empty list = cancelled. -} - + (listof path?)]{} @defproc[(pick-folder [#:title title (or/c #f string?) #f] [#:directory directory (or/c #f path-string?) #f]) (or/c #f path?)]{} - @defproc[(save-file-dialog [#:title title (or/c #f string?) #f] [#:default-name default-name (or/c #f string?) #f] [#:directory directory (or/c #f path-string?) #f] [#:filters filters list? '()]) - (or/c #f path?)]{ -The overwrite prompt is the dialog's; no file is created here. -} - -@defproc[(dialog-supported?) boolean?]{@racket[#f] when no dialog backend -exists on this platform (open/save then raise).} - -@section[#:tag "menus"]{Menu Bar} - -@defmodule[glaze/webview] - -@defproc[(webview-set-menu! [wv webview?] [menus (listof menu?)]) void?]{ -Replaces the custom section of the application menu bar. Menus are -declared with the tray protocol vocabulary: -@racket[(list (make-menu "File" (list (make-menu-item "Open…" #:accel -"CmdOrCtrl+O" #:action open-doc) menu-separator)))]. Accelerator -keystrokes fire for real on macOS; on Windows/Linux they are displayed -next to the label (v1). The platform-standard menus (Edit/Window on macOS) -are preserved. -} - -@defproc[(webview-closed? [wv webview?]) boolean?]{True once the window is -closed — by @racket[webview-close] or the OS chrome.} - -@defproc[(all-webviews) (listof webview?)]{Every window this process -opened that is not yet garbage collected.} - -@defproc[(close-all-webviews!) void?]{Closes every open window (each -delivers its @racket[#:on-close]).} - -@defproc[(wait-for-webviews [timeout-secs (or/c #f real?) #f]) boolean?]{ -Blocks until every open window closes; @racket[#f] on timeout. -} + (or/c #f path?)]{} -@section[#:tag "deeplink-autolaunch"]{Deep Links & Launch at Login} +@section{Deep Links and Launch at Login} @defmodule[glaze/deeplink] @defproc[(ensure-url-scheme! [scheme string?] [#:app-name app-name string? scheme]) - symbol?]{ -Registers @racket[scheme]:// for this executable, idempotently. Windows: -HKCU registry entries. Linux: a desktop entry plus @exec{xdg-mime default}. -macOS: handlers are declared in the packaged Info.plist — pass -@racket[#:url-schemes] to @racket[build-app] (or -@exec{raco glaze build --url-scheme}); the runtime call returns -@racket['build-time]. Receiving the URL is the established -single-instance + argv pattern: the OS launches the executable with the -URL as an argument. + any/c]{ +Windows registers a user-scope URL protocol, Linux writes a desktop entry and +uses @exec{xdg-mime} when available, and macOS URL schemes are declared in the +bundle at build time. } @defmodule[glaze/autolaunch] @defproc[(auto-launch-set! [name string?] [enabled? boolean?]) void?]{} - -@defproc[(auto-launch-enabled? [name string?]) - (or/c boolean? 'requires-approval 'not-registered)]{ -macOS uses SMAppService (macOS 13+, packaged .app, no permission prompt); -Windows the HKCU Run key; Linux autostart desktop entries. -} - -@section[#:tag "signing"]{Code Signing & Notarization} - -Unsigned apps are blocked by macOS Gatekeeper and Windows SmartScreen. -@racket[build-app] and @racket[raco glaze build] drive the platform -signer: - -@itemlist[ - @item{macOS: @exec{codesign} with an identity (@litchar{"-"} = ad-hoc); - nested code (the bundled Racket framework) is signed first, then the - bundle. @racket[#:notarize-profile] submits the built dmg via - @exec{xcrun notarytool}, waits, and staples the ticket.} - @item{Windows: @exec{signtool} with a SHA-1 thumbprint or subject name, - RFC-3161 timestamped by default so signatures outlive the certificate.} -] - -Signing @emph{failures} abort the build; a @emph{missing toolchain} -degrades with a loud warning. On macOS, hardened runtime -(@racket[#:no-hardened-runtime?] disables it) is applied unless the -identity is ad-hoc — its library validation would reject the app's own -ad-hoc-signed framework. - -@section{System Tray} - -@defmodule[glaze/tray] - -Glaze provides a cross-platform system tray backed by pure Racket FFI -(Windows @racket[Shell_NotifyIconW], macOS @racket[NSStatusItem], Linux -@racket[libayatana-appindicator]). When a platform's native libraries are -unavailable, the tray degrades to a no-op stub. - -@defproc[(make-tray - [#:icon icon (or/c #f path?)] - [#:tooltip tooltip string?] - [#:menu items (listof menu-item?)]) - tray?]{ -Creates a system tray icon with the given tooltip and menu. Returns a tray -handle. Never raises for environmental reasons — callers always get a usable -(possibly inert) handle. -} - -@defproc[(tray-set-tooltip! [t tray?] [tooltip string?]) void?]{} -@defproc[(tray-set-icon! [t tray?] [icon path?]) void?]{} -@defproc[(tray-set-menu! [t tray?] [items (listof menu-item?)]) void?]{} -@defproc[(tray-close [t tray?]) void?]{} - -@defproc[(make-menu-item - [label string?] - [#:id id any/c label] - [#:action action (-> any) void] - [#:enabled? enabled? boolean? #t] - [#:checked? checked? boolean? #f]) - menu-item?]{} -@defproc[(menu-separator) menu-item?]{} - -@section{Native WebView} - -@defmodule[glaze/webview/main] - -Opens a native OS window with an embedded WebView pointing at a URL -(typically the local HTTP server Glaze started). Backends: macOS -(@racket[NSWindow] + @racket[WKWebView] via objc FFI), Windows (WebView2 -via COM FFI), Linux (@racket[GtkWindow] + WebKitGTK via FFI) — all three -pass the real-window CI e2e (open, load, capture, navigate, close, -on-close). When the native backend is unavailable, @racket[open-window] -returns @racket[#f] so callers can fall back to @racket[open-browser]. - -@defproc[(open-window - [url string?] - [#:title title string? "Glaze"] - [#:width width exact-positive-integer? 1024] - [#:height height exact-positive-integer? 768] - [#:devtools? devtools? boolean? #f] - [#:on-close on-close (-> any) (lambda () (void))] - [#:fallback-browser? fallback-browser? boolean? #f]) - (or/c webview? #f)]{ -Opens the window and loads @racket[url]. @racket[on-close] runs when the -window closes (programmatic @racket[webview-close] or the user closing it). -@racket[#:devtools?] opens the platform inspector (macOS: inspectable, -13+; Windows: @litchar{OpenDevToolsWindow}; Linux: WebKitGTK inspector). -Returns @racket[#f] when the backend is unavailable; with -@racket[#:fallback-browser?] the system browser is opened instead. -@racket[open-webview] is a synonym. -} - -@defproc[(webview-supported?) boolean?]{ -Whether the current platform backend is available. -} - -@defproc[(webview-navigate [wv webview?] [url string?]) void?]{ -Loads a new URL into an open webview. -} - -@defproc[(webview-close [wv webview?]) void?]{ -Closes the window and stops its event pump. -} - -@defproc[(webview-title [wv webview?]) (or/c #f string?)]{ -Current page title once the first navigation has committed; @racket[#f] -before that or when the backend cannot provide it. -} - -@defproc[(webview-url [wv webview?]) (or/c #f string?)]{ -Current page URL once the first navigation has committed. -} - -@defproc[(webview-capture! - [wv webview?] - [dest (or/c #f string? path?) #f]) - (or/c #f path?)]{ -Captures the window contents as a PNG to @racket[dest] (a fresh temp file by -default). Returns the path, or @racket[#f] when the window is closed or not -currently capturable. Together with @racket[webview-title] and -@racket[webview-url], this lets automated callers — including AI agents — -verify what the UI is showing without a human at the screen. -} - -@defproc[(webview-set-title! [wv webview?] [title string?]) void?]{} -@defproc[(webview-set-size! [wv webview?] - [width exact-positive-integer?] - [height exact-positive-integer?]) void?]{} -@defproc[(webview-set-fullscreen! [wv webview?] [on? boolean?]) void?]{} -@defproc[(webview-focus! [wv webview?]) void?]{} - -@section[#:tag "security"]{Security} - -The server binds to @racket[127.0.0.1] only, and every request passes a -Host-header check: the server must be addressed as @litchar{127.0.0.1} / -@litchar{localhost} / @litchar{[::1]} (with or without port). This closes -the DNS-rebinding hole where a malicious page resolves its own domain to -loopback to reach the app's API; hostile origins get 403. - -The optional API token (@racket[#:api-token]) guards @emph{capabilities} — -API routes and the SSE stream (401 otherwise) — not resources: static files -and the api.js bootstrap stay open. @racket[run-app] opens the window at a -one-time capability URL, @litchar{/?glaze-token=...}: the server exchanges -the token for an @litchar{HttpOnly} @litchar{glaze_token} cookie and -redirects to the clean path (EventSource cannot set headers, but -same-origin requests carry cookies). api.js deliberately hands out nothing, -so a caller that can only read openly-served endpoints cannot mint -credentials. Programmatic clients send @litchar{X-Glaze-Token}. - -@bold{Honest scope:} this raises the bar against casual local callers; a -process running as the same user can still read the token from process -memory — full local-process isolation is not achievable over plain HTTP. +@defproc[(auto-launch-enabled? [name string?]) any/c]{} @section{Packaging} @@ -621,36 +387,37 @@ memory — full local-process isolation is not achievable over plain HTTP. [#:entry entry (or/c string? path?) "main.rkt"] [#:name name (or/c #f string?) #f] [#:version version (or/c #f string?) #f] - [#:icon icon (or/c #f path?) #f] + [#:icon icon any/c #f] [#:out-dir out-dir (or/c string? path?) "dist"] [#:embed-dlls? embed-dlls? boolean? #f] [#:installer? installer? boolean? #f] [#:sign sign (or/c #f string?) #f] - [#:entitlements entitlements (or/c #f path?) #f] + [#:entitlements entitlements any/c #f] [#:no-hardened-runtime? no-hardened-runtime? boolean? #f] [#:timestamp-url timestamp-url (or/c #f string?) #f] - [#:notarize-profile notarize-profile (or/c #f string?) #f]) + [#:notarize-profile notarize-profile (or/c #f string?) #f] + [#:url-schemes url-schemes list? '()]) path?]{ -Builds a Glaze project into a distributable via @racket[raco exe] + -@racket[raco distribute], bundling the project's @racket[public/] with the -executable. On macOS assembles a canonical @tt{.app} bundle (with -@racket[version] stamped into @tt{Info.plist}) and, when -@racket[installer?] is true, produces a dmg (msi on Windows, AppImage on -Linux), falling back to a zip / tar.gz when the native toolchain is -absent. @racket[sign] is a codesign identity on macOS (@litchar{"-"} = -ad-hoc) or a signtool certificate SHA-1 thumbprint / subject on Windows; -@racket[notarize-profile] adds notarization + stapling. See -@secref["signing"]. Signing failures abort the build; a missing toolchain -degrades with a warning. +Builds and distributes the application with @exec{raco exe} and +@exec{raco distribute}. Windows GUI builds use @exec{raco exe --gui}, so they +may not have a visible console; this is why native WebView startup failures +also attempt an OS-level error dialog. + +Installer-toolchain absence may degrade an installer request to an archive +with a loud warning. That packaging fallback is unrelated to runtime startup: +the built application still requires its native WebView. } @section{CLI Commands} @verbatim{ - raco glaze init Create a new project - raco glaze dev Start dev server - raco glaze build Build a distributable (+ optional - installer, signing, notarization) + raco glaze init Create a native desktop project + raco glaze dev Run the project's native main.rkt + raco glaze build Build a distributable / installer raco glaze keygen [--out ] Create an RSA keypair for licenses raco glaze license sign|verify Sign or verify license files + raco glaze help Show help } + +There is intentionally no browser-mode @exec{dev} or @exec{serve} command. +Development and production use the same native WebView startup path. diff --git a/glaze-test/appapi-test.rkt b/glaze-test/appapi-test.rkt index 223570b..cce4cf8 100644 --- a/glaze-test/appapi-test.rkt +++ b/glaze-test/appapi-test.rkt @@ -80,7 +80,8 @@ ;; action (the same dispatch a real click takes) -> marker file appears -> ;; close -> wait-for-webviews. -(when (webview-supported?) +(when (and (eq? (system-type 'os) 'macosx) + (webview-supported?)) ;; AppKit is loaded by the backend; register the class binding locally so ;; the test can query NSApp for the main menu. (import-class NSApplication) diff --git a/glaze-test/gui-first-test.rkt b/glaze-test/gui-first-test.rkt new file mode 100644 index 0000000..e163bad --- /dev/null +++ b/glaze-test/gui-first-test.rkt @@ -0,0 +1,45 @@ +#lang racket/base + +;; Regression tests for Glaze's GUI-first contract. These deliberately avoid +;; opening a real window so they run on every CI host; the platform e2e tests +;; continue to cover successful native-window startup. + +(require rackunit + racket/string + glaze/app + glaze/webview/main) + +(define-values (_open-required open-allowed) (procedure-keywords open-window)) +(define-values (_wv-required wv-allowed) (procedure-keywords open-webview)) +(define-values (_app-required app-allowed) (procedure-keywords run-app)) + +;; Browser fallback used to be a keyword on all three entry points. Keeping +;; this assertion makes it hard to accidentally reintroduce the escape hatch. +(check-true (list? open-allowed)) +(check-true (list? wv-allowed)) +(check-true (list? app-allowed)) +(check-false (member '#:fallback-browser? open-allowed)) +(check-false (member '#:fallback-browser? wv-allowed)) +(check-false (member '#:fallback-browser? app-allowed)) + +;; A missing backend must produce useful remediation text instead of sending +;; the user to a browser. The exact package differs by platform. +(define guidance (webview-install-guidance)) +(check-true (string? guidance)) +(check-true (> (string-length guidance) 20)) +(check-false (string-contains? (string-downcase guidance) "fallback")) + +(case (system-type 'os) + [(windows) + (check-true (string-contains? guidance "WebView2 Runtime")) + (check-true (string-contains? guidance "developer.microsoft.com"))] + [(unix) + (check-true (string-contains? guidance "WebKitGTK")) + (check-true (string-contains? guidance "apt install"))] + [(macosx) + (check-true (string-contains? guidance "WKWebView"))] + [else (void)]) + +(define diagnostic (webview-diagnostic "test backend failure")) +(check-true (string-contains? diagnostic "test backend failure")) +(check-true (string-contains? diagnostic guidance)) diff --git a/glaze-test/webview-test.rkt b/glaze-test/webview-test.rkt index 878b9da..b5bbe91 100644 --- a/glaze-test/webview-test.rkt +++ b/glaze-test/webview-test.rkt @@ -15,6 +15,11 @@ (check-equal? (procedure? open-window) #t "open-window is a procedure") (check-equal? (procedure? open-webview) #t "open-webview is a procedure") (check-equal? (procedure? webview-supported?) #t "webview-supported? is a procedure") +(check-equal? (procedure? webview-last-error) #t "webview-last-error is a procedure") +(check-equal? (procedure? webview-install-guidance) #t "webview-install-guidance is a procedure") +(check-equal? (procedure? webview-diagnostic) #t "webview-diagnostic is a procedure") +(check-true (positive? (string-length (webview-install-guidance))) + "platform install guidance is non-empty") (check-equal? (procedure? webview-navigate) #t "webview-navigate is a procedure") (check-equal? (procedure? webview-close) #t "webview-close is a procedure") (check-equal? (procedure? webview-title) #t "webview-title is a procedure") diff --git a/glaze/api.rkt b/glaze/api.rkt index fd4c3b8..5d55135 100644 --- a/glaze/api.rkt +++ b/glaze/api.rkt @@ -3,9 +3,9 @@ ;; JSON API routes for the frontend <-> Racket bridge. ;; ;; The page calls `fetch("/api/...")`; Racket answers JSON. This is Glaze's -;; answer to Tauri's invoke(): plain HTTP on the same server that serves the -;; frontend, so one code path works in the embedded WebView, in the -;; system-browser fallback, and in dev (curl-able). +;; answer to Tauri's invoke(): plain HTTP on the same local server that serves +;; the embedded WebView frontend. The endpoints are also easy to exercise from +;; tests and developer tools such as curl. ;; ;; Routes are ordinary values: ;; diff --git a/glaze/app.rkt b/glaze/app.rkt index ab6fcd7..7afbcc6 100644 --- a/glaze/app.rkt +++ b/glaze/app.rkt @@ -5,12 +5,13 @@ ;; (run-app #:public-dir "public" #:api (list (GET "api/ping" ...))) ;; ;; picks a free port, starts the server (static + JSON API), opens the native -;; webview window, calls #:on-ready with the handle, and blocks until the -;; window closes. Returns (values kind shutdown): -;; - kind 'webview: window closed, server already stopped; shutdown is a -;; no-op if called again -;; - kind 'browser: no native backend, the system browser was opened and -;; the server keeps running — call shutdown (or exit) to stop +;; WebView window, calls #:on-ready with the handle, and blocks until the +;; window closes. Returns (values 'webview shutdown); shutdown is a no-op if +;; called again after the normal window-close path. +;; +;; Native GUI is the application contract. If the platform WebView cannot +;; start, run-app stops the local server and propagates the actionable startup +;; error from glaze/webview. It never opens the system browser as a fallback. (require racket/random "server.rkt" @@ -57,7 +58,6 @@ #:title [title "Glaze"] #:width [width 1024] #:height [height 768] - #:fallback-browser? [fallback? #t] #:events [event-bus #f] #:api-token [api-token #f] #:on-close [user-on-close (lambda () (void))] @@ -99,7 +99,7 @@ (or on-error (current-glaze-error-reporter))]) (when check-update (define info (do-check-update check-update - #:current-version current-version)) + #:current-version current-version)) (when info (printf "[glaze] update available: ~a (current ~a) — ~a~n" (hash-ref info 'version #f) @@ -107,27 +107,21 @@ (hash-ref info 'url #f)) (when event-bus (bus-broadcast! event-bus 'update-available info)))) + ;; If native GUI startup fails, never leave the local HTTP server behind. + ;; open-window's exception contains the platform-specific install/repair + ;; instructions; preserve it unchanged for the caller/user. (define wv - (open-window open-url - #:title title - #:width width - #:height height - #:on-close (lambda () - (user-on-close) - (semaphore-post closed)) - #:fallback-browser? fallback?)) - (cond - [wv - (on-ready wv url) - (sync closed) - (shutdown) - (values 'webview shutdown)] - [else - ;; Browser fallback: no window to wait on. Leave the server running so - ;; the browser keeps working; caller decides when to exit. - (on-ready #f url) - (printf "[glaze] app served at ~a (system-browser fallback)~n" open-url) - (when token - (printf "[glaze] api token (X-Glaze-Token header): ~a~n" token)) - (printf "[glaze] call the returned shutdown procedure or exit to stop~n") - (values 'browser shutdown)]))) + (with-handlers ([exn:fail? (lambda (e) + (shutdown) + (raise e))]) + (open-window open-url + #:title title + #:width width + #:height height + #:on-close (lambda () + (user-on-close) + (semaphore-post closed))))) + (on-ready wv url) + (sync closed) + (shutdown) + (values 'webview shutdown))) diff --git a/glaze/events.rkt b/glaze/events.rkt index 3f2bdb0..aa7c7b0 100644 --- a/glaze/events.rkt +++ b/glaze/events.rkt @@ -2,8 +2,8 @@ ;; Backend -> frontend event push: a broadcast bus consumed by the SSE ;; endpoint that start-server mounts at /glaze/events (Glaze's answer to -;; Tauri's emit() and Eel's websocket push — plain SSE on the same origin, -;; so the browser fallback gets push for free). +;; Tauri's emit() and Eel's websocket push — plain SSE on the same local +;; origin used by the embedded WebView). ;; ;; (define bus (make-event-bus)) ;; (start-server #:public-dir "public" #:events bus ...) diff --git a/glaze/webview/main.rkt b/glaze/webview/main.rkt index 10eb774..35b2b47 100644 --- a/glaze/webview/main.rkt +++ b/glaze/webview/main.rkt @@ -1,22 +1,23 @@ #lang racket/base -;; Public WebView API (Phase 3). Opens a native OS window with an embedded -;; WebView control pointing at a URL (typically the local HTTP server Glaze -;; started). Dispatches to a platform-specific backend based on -;; (system-type 'os): +;; Public WebView API. Opens a native OS window with an embedded WebView +;; control pointing at a URL (typically the local HTTP server Glaze started). +;; Dispatches to a platform-specific backend based on (system-type 'os): ;; - 'windows -> webview-windows.rkt (Win32 window + WebView2 via COM FFI) ;; - 'macosx -> webview-macos.rkt (NSWindow + WKWebView via objc FFI) ;; - 'unix -> webview-linux.rkt (GtkWindow + WebKitGTK via FFI) ;; -;; Every backend exports the SAME procedure names (open-webview, -;; webview-supported?, close-webview, webview-navigate) and performs its own -;; platform/library gating. If a backend is unavailable, open-webview returns -;; #f so callers can fall back to opening the system browser (Phase 1/2 -;; behavior). +;; Glaze is a desktop GUI framework: native WebView startup is part of the +;; application contract. There is deliberately no browser fallback. If a +;; backend or runtime dependency is unavailable, startup fails with actionable +;; platform-specific installation guidance. (provide open-window open-webview webview-supported? + webview-last-error + webview-install-guidance + webview-diagnostic webview? webview-backend webview-handle @@ -35,12 +36,63 @@ close-all-webviews! wait-for-webviews) -(require (only-in "../browser.rkt" open-browser) +(require "startup-feedback.rkt" (only-in "../tray/tray-protocol.rkt" menu?)) ;; A webview handle wraps the backend-specific handle + the backend tag. (struct webview (backend handle) #:transparent) +;; Keep the most recent native-backend failure so probes and higher-level +;; callers can report the real cause rather than masking it. +(define last-webview-error-box (box #f)) + +(define (webview-last-error) + (unbox last-webview-error-box)) + +(define (remember-webview-error! e) + (set-box! last-webview-error-box e)) + +(define (clear-webview-error!) + (set-box! last-webview-error-box #f)) + +(define (webview-install-guidance) + (case (system-type 'os) + [(windows) + (string-append + "Windows requires Microsoft Edge WebView2 Runtime (Evergreen).\n" + "Install or repair it, then start Glaze again:\n" + " winget install --id Microsoft.EdgeWebView2Runtime -e\n" + "Official download (Evergreen Bootstrapper / Standalone Installer):\n" + " https://developer.microsoft.com/microsoft-edge/webview2/#download-section\n" + "Glaze already ships WebView2Loader.dll. If the Runtime is installed, " + "verify that the Glaze package and Racket architecture match your Windows architecture.")] + [(unix) + (string-append + "Linux requires GTK 3 and WebKitGTK at runtime. Install the packages, then start Glaze again:\n" + " Debian/Ubuntu: sudo apt install libgtk-3-0 libwebkit2gtk-4.1-0\n" + " Fedora: sudo dnf install gtk3 webkit2gtk4.1\n" + " Arch: sudo pacman -S gtk3 webkit2gtk-4.1\n" + "Glaze must also run inside a graphical desktop session (or Xvfb in CI).")] + [(macosx) + (string-append + "WKWebView is built into macOS and normally requires no separate download.\n" + "Run Glaze from a logged-in graphical session. If startup still fails, " + "report the backend error above together with your macOS and Racket versions.")] + [else + "This operating system has no native WebView backend in Glaze."])) + +(define (webview-error->message e) + (cond + [(exn? e) (exn-message e)] + [(string? e) e] + [e (format "~a" e)] + [else "the native backend reported that it is unavailable"])) + +(define (webview-diagnostic [e (webview-last-error)]) + (string-append + "Native WebView could not start: " (webview-error->message e) "\n\n" + (webview-install-guidance))) + ;; Every successfully opened window, weakly held: closed + collected windows ;; disappear from all-webviews on their own. (define open-registry (make-weak-hasheq)) @@ -68,43 +120,44 @@ (define (ref name) (hash-ref (load-backend!) name)) +;; Non-throwing capability probe. A failed probe records the reason when one +;; is available; actual startup through open-window/open-webview is fail-fast. (define (webview-supported?) - (with-handlers ([exn:fail? (lambda (e) #f)]) - ((ref 'supported?)))) - -;; open-window: high-level entry. Opens a native window with a webview -;; rendering `url`. Optional #:title, #:width, #:height, #:on-close. -;; Returns a webview? on success, or #f if the backend is unavailable. -;; With #:fallback-browser? #t the system browser is opened instead when the -;; native backend is unavailable. + (clear-webview-error!) + (with-handlers ([exn:fail? (lambda (e) + (remember-webview-error! e) + #f)]) + (define supported? ((ref 'supported?))) + (unless supported? + (remember-webview-error! + "the platform backend is present but its runtime dependencies are not available")) + supported?)) + +;; open-window: high-level entry. Native GUI is mandatory. If the platform +;; backend cannot start, the call raises with installation/repair guidance. (define (open-window url #:title [title "Glaze"] #:width [width 1024] #:height [height 768] #:devtools? [devtools? #f] - #:on-close [on-close (lambda () (void))] - #:fallback-browser? [fallback? #f]) + #:on-close [on-close (lambda () (void))]) (open-webview url #:title title #:width width #:height height #:devtools? devtools? - #:on-close on-close - #:fallback-browser? fallback?)) + #:on-close on-close)) (define (open-webview url #:title [title "Glaze"] #:width [width 1024] #:height [height 768] #:devtools? [devtools? #f] - #:on-close [on-close (lambda () (void))] - #:fallback-browser? [fallback? #f]) + #:on-close [on-close (lambda () (void))]) + (clear-webview-error!) (define h (with-handlers ([exn:fail? (lambda (e) - (fprintf (current-error-port) - "[glaze] webview backend unavailable (~a); " - (exn-message e)) - (displayln "use open-browser as fallback." (current-error-port)) + (remember-webview-error! e) #f)]) ((ref 'open-webview) url #:title title @@ -113,11 +166,18 @@ #:devtools? devtools? #:on-close on-close))) (cond - [h (define wv (webview (detected-backend) h)) - (hash-set! open-registry wv #t) - wv] - [fallback? (open-browser url) #f] - [else #f])) + [h + (define wv (webview (detected-backend) h)) + (hash-set! open-registry wv #t) + wv] + [else + (unless (webview-last-error) + (remember-webview-error! "the native backend returned unavailable")) + (define diagnostic (webview-diagnostic)) + ;; Packaged GUI apps may have no console. Show the same diagnosis in an + ;; OS-level dialog before raising; CI/automation suppresses the dialog. + (show-webview-startup-error! diagnostic) + (raise-user-error 'open-webview diagnostic)])) (define (detected-backend) (case (system-type 'os) @@ -151,7 +211,6 @@ (define (webview-capture! wv [dest #f]) ((ref 'capture!) (webview-handle wv) dest)) - ;; ---- window controls ---- (define (webview-set-title! wv t) ((ref 'set-title!) (webview-handle wv) t)) (define (webview-set-size! wv width height) diff --git a/glaze/webview/startup-feedback.rkt b/glaze/webview/startup-feedback.rkt new file mode 100644 index 0000000..dba5108 --- /dev/null +++ b/glaze/webview/startup-feedback.rkt @@ -0,0 +1,91 @@ +#lang racket/base + +;; User-visible startup diagnostics that do not depend on the WebView itself. +;; A packaged Windows Glaze application is built with `raco exe --gui`, so it +;; may have no console where stderr is visible. When native WebView startup +;; fails we therefore show a platform-level error dialog before propagating +;; the exception. CI/automation skips dialogs to avoid blocking unattended +;; runs; the exception text remains the source of truth in logs. + +(require ffi/unsafe + racket/string + racket/system) + +(provide show-webview-startup-error!) + +(define (dialog-enabled?) + (and (not (getenv "CI")) + (not (getenv "GITHUB_ACTIONS")) + (not (equal? (getenv "GLAZE_NO_STARTUP_DIALOG") "1")))) + +;; ---- Windows: MessageBoxW, available before WebView2 exists ------------ + +(define (string->utf16-pointer s) + (define cv (bytes-open-converter "UTF-8" "UTF-16LE")) + (define-values (out consumed status) + (bytes-convert cv (string->bytes/utf-8 s))) + (bytes-close-converter cv) + (unless (and (eq? status 'complete) + (= consumed (bytes-length (string->bytes/utf-8 s)))) + (error 'startup-feedback "UTF-16 conversion failed")) + (define p (malloc (+ (bytes-length out) 2) 'raw)) + (memcpy p out (bytes-length out)) + (ptr-set! p _uint16 (quotient (bytes-length out) 2) 0) + p) + +(define (windows-dialog title message) + (define user32 (ffi-lib "user32")) + (define MessageBoxW + (get-ffi-obj "MessageBoxW" + user32 + (_fun _pointer _pointer _pointer _uint -> _int))) + (define title-p (string->utf16-pointer title)) + (define message-p (string->utf16-pointer message)) + (dynamic-wind + void + (lambda () + ;; MB_OK | MB_ICONERROR | MB_SETFOREGROUND + (MessageBoxW #f message-p title-p (bitwise-ior #x00000000 #x00000010 #x00010000))) + (lambda () + (free title-p) + (free message-p)))) + +;; ---- macOS: osascript uses the system dialog service ------------------- + +(define (applescript-escape s) + (string-replace (string-replace (string-replace s "\\" "\\\\") "\"" "\\\"") + "\n" "\\n")) + +(define (macos-dialog title message) + (define osa (find-executable-path "osascript" #f)) + (and osa + (system* osa + "-e" + (format "display alert \"~a\" message \"~a\" as critical buttons {\"OK\"} default button \"OK\"" + (applescript-escape title) + (applescript-escape message))))) + +;; ---- Linux: use the desktop's ordinary dialog helper when available ---- + +(define (linux-dialog title message) + (cond + [(find-executable-path "zenity" #f) + => (lambda (zenity) + (system* zenity "--error" + (string-append "--title=" title) + "--width=640" + (string-append "--text=" message)))] + [(find-executable-path "kdialog" #f) + => (lambda (kdialog) + (system* kdialog "--error" message "--title" title))] + [else #f])) + +(define (show-webview-startup-error! message) + (when (dialog-enabled?) + (with-handlers ([exn:fail? (lambda (e) #f)]) + (case (system-type 'os) + [(windows) (windows-dialog "Glaze cannot start" message)] + [(macosx) (macos-dialog "Glaze cannot start" message)] + [(unix) (linux-dialog "Glaze cannot start" message)] + [else #f]))) + (void))