外观
MyGo 架构
本指南解释 MyGo 是如何组织起来的:各层结构、线程模型、Go 如何在不使用 cgo 的情况下调用原生工具包、页面与 Go 如何交换类型化消息、MyGo 如何绘制 原生 UI,以及如何安全地扩展框架。在修改 internal/ 下的任何内容之前, 请先阅读本指南。
目标与约束
- 低开销。 一个 hello-world 应用在 macOS 上约为 7 MB 的二进制文件, 物理内存占用约 35 MB(主要是 AppKit/WebKit),算上 WKWebView 为其页面、 GPU 和网络所运行的进程则约 62 MB,CPU 空闲时为 0%。一个原生 UI 窗口 不会启动 webview 进程:空闲时,macOS 上一个小型窗口占用 44 MB,Linux 上的 counter 示例占用 65 到 67 MB——平铺在 scale 2 的 4K 显示器的半个 屏幕上,其中 15 MB 是窗口的缓冲区,且不加载 Mesa(加载 Mesa 时为 122 到 155 MB)。没有任何轮询:所有工作都由原生事件或显式唤醒驱动。一个 内容已稳定下来的原生 UI 窗口既不请求帧,也不设置定时器 (
TestSettledWindowSleeps):display link 暂停,Go 的调度器进入睡眠, counter 示例在 macOS 上空闲时约占单个核心的 0.005%(AppKit 窗口为 0.002%),在 X11 下不会唤醒任何线程。 - 不用 cgo。 所有内容都以
CGO_ENABLED=0构建,因此可以从任何机器 交叉编译到任何平台。原生 API 在运行时通过 purego(dlopen+ 汇编跳板)在 macOS 和 Linux 上调用,在 Windows 上通过syscall包调用,绝不通过import "C"。 - 两类窗口。 网页显示在系统 webview 中:macOS 上是 WKWebView,Linux 上是 WebKitGTK 4.1(4.0 作为回退),Windows 上是 WebView2(amd64 和 arm64);不捆绑任何浏览器引擎。原生 UI 由 MyGo 自身用 Go 绘制,使用 Metal、Direct3D 11 或 OpenGL,或在 CPU 上绘制,文字则来自系统自带的 引擎(参见原生 UI);如果一个应用的所有窗口都显示 原生 UI,就完全不需要 webview,并且它也能在浏览器中运行——编译为 WebAssembly,绘制在用 WebGL 2 渲染的 canvas 上(参见 浏览器)。在不支持的平台(包括 32 位 Windows 和 Linux)上,
internal/unsupported后端会让App.Run以清晰的错误 失败,同时所有内容仍可编译,因此后端的每个文件,以及与之配套的文字 引擎的每个文件,都在其构建约束中列出该后端支持的架构。 - Bun 仅是开发工具。 它用于构建和测试 TypeScript 桥接,并安装和运行 web 模板的工具(Vite、TypeScript、mygo-cli 包);原生 UI 项目完全不需要 它。应用中不会打包任何与 Bun 相关的内容。
- 优先优秀的 DX,而非 API 对齐。 API 有 Electron 的手感(应用生命 周期、窗口、菜单、对话框),但以 Go 为先:带生成 TypeScript 客户端的 类型化 IPC、用于自定义协议的
http.Handler、类型化事件结构体、可从 任意 goroutine 安全调用的阻塞调用,以及纯用 Go 编写的原生 UI。
仓库布局
. package mygo: the public API
├── app.go lifecycle, quit sequence, Dock, paths (paths.go)
├── window.go Window: the native window, its options, state and events
├── flyout.go NewFlyout: windows owned by a parent, placed next to an anchor
├── page.go Page: the web page a window shows, its loading, Eval and events
├── content.go Content: windows showing native UI instead of a page
├── webview.go WebView: web pages in windows of native UI, under it
├── ipc.go Bind/BindAs, method calls, Event[T], CallerWindow
├── plugin.go Plugin and Use: services bound as "plugin:<name>"
├── channel.go Channel[T]: values streamed to a call's page
├── typescript.go GenerateTypeScript / WriteTypeScript (uses internal/tsgen)
├── protocol.go custom schemes served by http.Handler, FileServer
├── frontend.go the app's frontend: relative URLs, devUrl, mygo://localhost
├── menu.go Menu/MenuItem model, roles, native item updates
├── cookies.go Cookies: the pages' cookies, checked, keyed by name, domain and path, filtered by CookieFilter
├── session.go Session: website data stores chosen per page (PageOptions.Session), ClearData by kind
├── recent_docs.go jumplist.go: recent documents (Open Recent and the system's lists), the jump list
├── dialog.go modules.go clipboard.go: shell, clipboard, screen, theme, tray, shortcuts, notifications
├── watch.go watch_scan.go: WatchFiles, and what a watch saw of its directory
├── document*.go NewDocuments: documents, their saving, recovery copies, watches, menus
├── loop.go main-thread queue: postMain / onMain / await
├── events.go listener lists and the Preventable event types
├── single_instance.go RequestSingleInstanceLock over a Unix socket
├── dev.go signal_*.go IsDev, the `mygo dev` ready signal, quitting on SIGINT/SIGTERM
├── backend_*.go picks the backend per GOOS
├── internal/
│ ├── platform/ the contract every backend implements
│ ├── darwin/ macOS: AppKit + WKWebView through the Objective-C runtime
│ ├── linux/ Linux: GTK 3 + WebKitGTK through dlopen
│ ├── windows/ Windows: Win32 + WebView2 through syscall and COM
│ ├── schemehost/ maps custom scheme URLs to the http://*.localhost URLs WebView2 serves them at
│ ├── web/ browsers (js/wasm): native UI on canvases, through syscall/js
│ ├── unsupported/ stub for other platforms
│ ├── fake/ in-memory backend for unit tests
│ ├── watchdriver/ the goroutine that runs a backend's file watcher
│ ├── bridge/ embeds bridge.js, built from packages/bridge
│ ├── surface/ the connection between a window and its native UI
│ ├── scene/ display lists and glyph atlases, what renderers draw
│ ├── text/ fonts, shaping, line breaking, editing, glyph rasterization
│ ├── rope/ the persistent tree of text chunks text buffers keep
│ ├── diff/ line and word diffs: histogram, Myers, git's indent heuristic
│ ├── vec/ the coverage rasterizer of glyphs and paths
│ ├── raster/ the CPU renderer of scenes
│ ├── gpu/ the instances GPU renderers draw, and gputest/ for their tests
│ ├── gpu/d3d11/ the Direct3D 11 renderer of scenes, and device/ for its devices
│ ├── gpu/metal/ the Metal renderer of scenes
│ ├── gpu/gl/ the OpenGL and WebGL 2 renderer of scenes
│ ├── shader/ the compiler of shaders in Go's syntax, forked from Ebitengine's Kage
│ ├── shaderir/ its IR; glsl/, hlsl/ and msl/ write it for the GPU renderers, interp/ runs it on the CPU
│ ├── svg/ SVG parsing and drawing, for icons and images
│ ├── tsgen/ TypeScript client generator
│ ├── i18ngen/ checks catalogs of messages, writes their Go and TypeScript functions
│ ├── i18nlink/ what package i18n gives MyGo's own catalog only: a catalog that links no dates
│ ├── l10n/ MyGo's own strings: the catalog of the controls and menu roles
│ ├── accelerator/ parses "CmdOrCtrl+Shift+K"
│ ├── menuicon/ menu items' PNG icons as premultiplied, scaled pixels (Windows)
│ ├── update/ update manifests, signatures, archives and delta updates
│ ├── idlemem/ measures apps' memory once idle, for the benchmarks
│ ├── inputlatency/ measures how long input takes to reach the screen in native UI (macOS)
│ └── e2e/ GUI tests and benchmarks against the real backend (MYGO_E2E=1)
├── packages/ Bun workspace (with the examples' frontends):
│ ├── bridge/ the runtime injected into pages (→ internal/bridge/bridge.js)
│ ├── runtime/ mygo-runtime, the npm package apps and generated clients import
│ └── cli/ mygo-cli, the npm package of the CLI, and in npm/ its
│ per-platform binary packages
├── plugins/ official plugins, each a Go package and its npm
│ package (@mygo-plugins/<name>) side by side: fetch,
│ websocket, sqlite, watch; and Go only: updater, the
│ update window, a web page or native UI (updater/native),
│ terminal, a view of native UI running programs with
│ libghostty-vt, glass, game, what games need
│ beyond what apps do (game/gamepad), loading,
│ loading.dev's indicators for native UI, and
│ automation, a socket other processes drive the
│ app through, for end-to-end tests
├── ui/ native UI: views, layout, widgets, text editing, Tester;
│ ui/chart: charts without a look, on ui's public API;
│ ui/editor: the code editor and the diff viewer;
│ ui/shader: shaders in Go's syntax, on every renderer
├── i18n/ languages: matching, CLDR plural rules and parent locales,
│ formatting of numbers and dates (generated by
│ i18n/internal/gen), ICU MessageFormat, catalogs
├── transfer/ immutable data items, representations, lazy providers and drag effects
├── netproxy/ the proxy of the system's settings, for Go's HTTP clients
├── audio/ sound: players, the mixer, the output of each platform, WAV;
│ audio/vorbis and audio/mp3 decode Ogg Vorbis and MP3
├── cmd/mygo/ the CLI: init, generate, dev, build, doctor
├── examples/ hello, todo, frameless, native; counter-native, vibrancy,
│ effort-slider, flyout, menubar-flyout, piano, farm
│ and mayoi (games), code-editor and gallery (native
│ UI), timeline (motion on one playhead); webview (web
│ views in native UI); launcher (a Spotlight-like panel,
│ native UI); wind (a texture of glyphs lit by a
│ wind, two shaders of ui/shader); loading (the
│ loading plugin's indicators); video (a GIF
│ looping behind a window's content)
├── docs/ the user guides, the official plugins' pages
│ (plugins/), and this architecture guide
└── website/ the website, with these docs: TanStack Start, prerendered
and served by Cloudflare Workers as static assets层级
user code ──► package mygo ──► platform.Backend ──► darwin | linux | windows | web | unsupported
▲ │ ▲ │
│ └─ handlers ────┘ (AppHandler, └─ purego ─► AppKit/WebKit, GTK/WebKitGTK
│ WindowHandler)
page (JS) ◄── bridge.js ◄── Eval (batched) ─┘
└──── postMessage(JSON) ──► WindowHandler.Messagepackage mygo承担所有与平台无关的行为:选项默认值、窗口注册表、 事件监听器、退出序列、IPC 路由与编码、菜单、协议处理、信任检查。它 从不直接调用原生 API。internal/platform是一份小巧的同步契约:Backend(事件循环、 窗口工厂、菜单、对话框、剪贴板……)、Window(窗口装饰 + webview), 以及核心实现以接收事件的两个 handler 接口(AppHandler、WindowHandler)。选项到达时已填好全部默认值;后端从不自行制定策略。- 后端把契约翻译成原生调用。它们维持原生对象的生命周期,把原生回调 映射回 Go 对象并上报事件。
把策略留在核心层,才能让 internal/fake 在无 GUI 的情况下测试几乎全部 行为,也让每个后端保持为一个薄薄的翻译层。
线程模型
Cocoa 和 GTK 必须由启动进程的线程来驱动。规则如下:
- 主 goroutine 被锁定在主线程上(
mygo.go的init中调用runtime.LockOSThread),App.Run必须在主 goroutine 上调用。Run初始化后端并阻塞在原生事件循环中。在此之前没有任何后端被初始化 (Linux 后端甚至还没有加载 GTK),但main已经在主线程上运行,onMain会直接调用函数,而 generate 模式从不初始化后端。因此,一个 会触及后端的公开方法,要么把它的值保存在核心层,直到后端能够接手 ——对于设置类调用(SetActivationPolicy经由AppOptions、就绪后的SetMenu、紧随Init之后的Theme.SetSource和Dock.SetMenu) ——要么以needsApp开头:在Run之前于主线程上以调用名 panic, 而不是让它在 Linux 上崩溃、在await中挂起,或在 macOS 上给出错误 答复。只有在所有后端上都能在Init之前工作的调用(Locale、打包 信息、登录项、URL scheme、IsOnBattery)两者都不需要。 - 每个
platform方法都在主线程上调用,每个 handler 回调也在主线程上 运行。 后端从不需要为自己的状态加锁。 - 每个公开方法都可以从任意 goroutine 安全调用。
loop.go提供:postMain(fn)追加到一个 FIFO 队列并调用Backend.Signal,后者使 后端在主线程上调用AppHandler.Dispatch(→loop.drain)。macOS 使用一个被加入所有常用模式的CFRunLoopSource(因此在菜单跟踪、 窗口缩放和模态面板运行时它都会触发);Linux 使用g_idle_add,并用 原子标志去重。onMain(fn)在已处于主线程时直接运行fn,否则投递并等待。关闭 之后,投递的工作会被丢弃,而不是造成死锁。await(ch)等待一个异步的原生结果。在主线程之外,它就是一次普通的 channel 接收。在主线程上,它用Backend.Step泵送原生事件,直到 值到达;产生该值的一方调用deliver,先发送再Backend.Wake唤醒 循环。正是这让Page.Eval、CapturePage和对话框可以在事件监听器 中使用而不会死锁。dispatch source 的嵌套运行是预期之中且安全的。 但在 run loop observer 内部启动的嵌套循环则不然:CFRunLoop 在此 期间不会再运行该 observer,而在 AppKit 显示视图和 Core Animation 提交所在的那个循环里,任何窗口、面板或工作表都不会显示。因此,在 macOS 上,原生 UI 的帧——其 handler(Clicked)可能会显示对话框—— 在该循环之外绘制,但 AppKit 请求的帧(如缩放引起的帧,见internal/darwin/surface.go中的drawSoon)则不然。
- 事件监听器在主线程上同步运行,因此可取消的事件(
OnClose、OnBeforeQuit、OnWillNavigate……)可以立即答复原生工具包。IPC 调用在各自的 goroutine 上运行,所以被绑定的方法可以阻塞。 - 在调用方的 goroutine 上做校验。 任何可能因坏输入而 panic 的东西 (例如
WindowOptions.BackgroundColor)都在切换到主线程之前检查,以便 panic 指向用户的调用。
仅限主线程访问的字段会在注释中标明(例如 Window.native、 Window.trusted)。与其他 goroutine 共享的字段由互斥锁或原子操作保护。
文件监视(watch.go)不使用主线程:每个监视运行在自己的 goroutine 上, 后端的 watcher 又在另一个上(参见监视文件)。
不使用 cgo 的原生互操作
purego 提供三个基础原语,到处都在使用:
purego.SyscallN(fn, args...)用于整数/指针参数。快,无反射;它只分配 参数数组,且该数组会逃逸。浮点数不能用这种方式传递,结构体也不行—— 但对于那些 C 用整数寄存器传递的结构体,如 Core Foundation 的CFRange, 可以按其两个字段传出,并以r1和r2返回。purego.RegisterFunc(&typedFn, addr)用于带浮点数或结构体的签名 (NSRect、CGFloat、GdkRGBA*……)。这些在启动时创建一次;调用它们 使用反射,因此要避开热路径。purego.NewCallback(fn)把 Go 函数变成 C 函数指针。每个进程最多只能 存在约 2000 个回调,且它们永远不会被释放,所以回调在启动时按签名各 创建一次,由用户数据(窗口 id、请求 id)标识目标。绝不要按窗口、按 请求或按调用创建回调。
macOS (internal/darwin)
objc.go加载框架,缓存 selector 和类,并封装objc_msgSend:整数 参数用send(obj, "selector:", args...),其余用预注册的带类型变体 (msgRect、msgSetFloat、msgInitWindow……)。在 amd64 上,返回大于 16 字节结构体的方法经由objc_msgSend_stret调用。- 类在运行时用
objc.RegisterClass定义:MyGoWindow(适合无边框的NSWindow)、MyGoWebView(记录最近一次mouseDown:以供拖拽区域 使用;window_drag.go在该按下之后跟随一个局部事件监视器:StartDrag在指针尚未移动时请求窗口服务器接管(performWindowDragWithEvent:), 指针已移动时则自行移动窗口,或在窗口服务器没有开始时自行移动——这由windowWillMove:告知,而不是窗口的 frame:frame 有延迟)、MyGoWindowDelegate(每个窗口一个对象,同时充当窗口、导航、UI 和脚本 消息的 delegate)、app delegate、scheme handler、菜单和托盘 target。 只采用运行时确实存在的协议(WKScriptMessageHandler未被注册,WebKit 也不需要它)。 - 覆写方法用
sendSuper调用 super(NSSize用sendSuperSize), 并指明它们定义所在的类;绝不用 purego 的objc.ID.SendSuper:那是从 对象的类解析 super,而键值观察会把类替换为一个生成的子类,于是覆写会 调用自身直到栈溢出。TestSuperFromDefinedClass会拒绝这种写法。 - web view 不是 content view。 content view 是一个普通
NSView, 容纳 web view,并在启用活力效果时在其后面放一个NSVisualEffectView。 WebKit 会把 inspector 停靠到 web view 的父视图中、紧邻 web view;如果 父视图是窗口的 frame view,AppKit 从此就会绘制一个残缺的旧式标题栏。 - 内存手动管理。 用
alloc/init创建的对象归调用方所有(+1), 必须释放;便捷构造函数返回自动释放的对象。创建临时对象的代码运行在withPool内,它会把 goroutine 保持在当前线程上直到弹出对象池:对象池 属于压入它的线程,而非主 goroutine 的 goroutine(例如为App.Name读取 bundle 的那些)否则可能在另一个线程上恢复运行,在那里弹出对象池 就会崩溃。delegate 和窗口在windowWillClose:中以autorelease释放, 因为 AppKit 在关闭过程中仍在使用它们。 - Blocks。 传给 Apple API 的完成 handler 用
objc.NewBlock创建,并 在调用结束后立即释放(被调用方会复制它们)。从 Apple 收到的 block(决策 与完成 handler)用callBlock调用;若要在稍后调用它们,先_Block_copy,用完再释放。 - 坐标系。 AppKit 的原点在主屏幕的左下角;MyGo 的原点在左上角。
rectToMac/rectFromMac负责转换。 - 退出。 Cmd+Q 最终到达
applicationShouldTerminate:,它运行核心的 退出序列,然后停止 run loop,使App.Run返回(回复是NSTerminateCancel;AppKit 从不调用exit)。Quit Apple Event(Dock > 退出、AppleScript、注销)由我们在applicationWillFinishLaunching:中 安装自己的 handler 处理,因此发送方会收到成功回复,或者在监听器取消时 收到错误 -128。
Linux (internal/linux)
ffi.go用dlopen加载 GLib、GObject、GIO、GDK、GTK 3、cairo、 GdkPixbuf,以及(在已安装时)AppIndicator。WebKitGTK 4.1(4.0)、 JavaScriptCore 和 libsoup 3(2.4)只有显示网页的窗口才需要,它们随 第一个这样的窗口加载(webKit),或在清除浏览数据时加载:只显示原生 UI 的应用永远不会映射它们,以及它们带来的约 75 个库——省下约 7 MB 和 20 ms 的启动开销。缺少它们时,创建这样的窗口会由errWebKit说明需要 安装什么。来自更新版本 WebKitGTK 的符号采用可选绑定并做特性检测 (webkitWebViewCallAsyncJavascriptFunction != nil)。- 信号用
g_signal_connect_data连接,把窗口 id 作为 user data 传入;Backend.window(data)解析它并忽略已关闭的窗口。GDK 事件结构体按固定 的 64 位偏移读取(field[T])。 - 主线程就是调用
gtk_init_check的那个线程;IsMainThread比较gettid。Step是g_main_context_iteration(NULL, TRUE),Wake是g_main_context_wakeup。 Bounds是重父化(reparenting)X11 窗口管理器加在窗口外围的边框 (frame)的范围(gdk_window_get_frame_extents):GTK 为 frame 定位 (NORTH_WEST gravity),并在其中为窗口定尺寸(不含 frame)。ContentBounds是内容控件所在的位置(gdk_window_get_origin加上它在 菜单栏之下的偏移)。frame 的范围要到窗口管理器为正在显示的窗口加上 frame 之后才知道,这由第一个合成 configure 报告告知;在此之前,窗口 预期它曾有过的 frame,或最近在一个与其当前可调整大小状态相同的带装饰 窗口周围所见的那个 frame(openbox:1、20、1 和底部 5 px,或者 1)。 在 Wayland 上,Bounds中没有 frame。- GTK 的几何变化是异步的:
SetBounds记住请求的矩形——在 GTK 给窗口的 尺寸范围内(constrain)、连同它预期的 frame;即将显示的窗口也是如此, 在窗口管理器放置它之前,X 上的它位于 GTK 创建它的位置,或它原先所在的 位置;Center会随之移动那个矩形。Bounds报告那个矩形,直到某个 configure 事件报告它的尺寸以及(在 X11 上)它的位置,或窗口管理器自己 (合成)的尺寸报告到来,或另一个尺寸——由窗口管理器或用户选定的。正在 被放置的窗口等待窗口管理器的报告:重父化的窗口管理器会先把它的 frame 放在它创建窗口的位置。如果 frame 不是预期的那个,矩形会连同它一起增长, 把请求的内部尺寸包住(SetContentBounds、UseContentSize、以前显示过 的窗口),或者向 GTK 请求新 frame 内部的尺寸。在窗口管理器接受请求之前, 曾发出过一个关于先前尺寸的报告(openbox 在尺寸提示变化时会发一个); GTK 会据此再次请求那个尺寸,所以后端会再次请求新的尺寸。 - 在 Wayland 上,激活窗口的 configure 事件可能带着它最后绘制的缓冲区的 尺寸,那是 GTK 已经接受的一次缩放之前的尺寸(当用 GL 绘制的原生 UI 从 其最初几帧开始缩放时,Mutter 会发一个这样的 configure),而 GTK 会退回 那个尺寸。因此,用 GL 绘制的窗口在未聚焦时保留最近一次请求的 bounds; 一旦聚焦,如果窗口退回了它原有的尺寸,一个 idle 回调会再次请求这些 bounds——除非窗口处于最大化、全屏或平铺状态。GTK 会跳过与它上次所请求 尺寸相同的请求:回调先接受该 configure,再请求窗口当前具有的尺寸。
- 用户无法调整大小的窗口,其尺寸不会小于默认大小(
SetBounds也会设置 它,SetResizable(false)则设为当前大小),也不会小于自然大小——当 窗口的子控件没有自然大小时 GTK 会定为 200x200,web view 就是如此: 包住 web view 的 box 请求的是 1x1。 - 在 Wayland 上,GTK 会自己为无边框窗口或隐藏标题栏的窗口添加装饰 (
useCSD):用gtk_window_set_titlebar设置一个永不显示的GtkBox, 就得到了没有标题栏的客户端装饰,于是 GTK 在页面周围绘制它的阴影和 边框,据此调整窗口大小,并在窗口最大化或全屏时去掉它们。GTK 在计算 窗口尺寸时不含它们(gtk_window_get_size、默认大小、geometry hints), 所以页面保持所请求的尺寸,而 Wayland 没有会被它们移动的位置。设置了DisableShadow的窗口、透明窗口(装饰会在一个自行显示形状的页面周围 勾出一个矩形;GTK 在 app-paintable 窗口上也会绘制装饰)、面板和浮动 窗口、在自行装饰窗口的合成器上隐藏标题栏的窗口,以及 X11 上的所有 窗口,都保持无装饰(gtk_window_set_decorated):一个自行装饰的 X11 窗口把阴影画在自己的 X 窗口内部,而Bounds所计算的 frame extents 反映不出这一点,并且 Mutter 会在不透明的无装饰窗口外围加上它自己的 阴影。GTK 只对装饰样式化一次,所以阴影随窗口而来:SetHasShadow在 Linux 上什么都不改变。 - GTK 不给无装饰的窗口提供缩放边框,因此一个无装饰的无边框窗口——或 隐藏标题栏的窗口——页面外侧 5 px 可用来缩放它(从角落起沿边缘 16 px 可缩放该角)。web view 的
motion-notify-event在那里显示缩放光标,并 保留 WebKit 原来的光标以便恢复;其button-press-event在按下时调用gtk_window_begin_resize_drag,正如 Wayland 合成器所要求的;这两个事件 都不会到达 WebKit。最大化或全屏的窗口无法被缩放,平铺的窗口只能在窗口 管理器允许的边上缩放,与 GTK 自带装饰时相同。 - 对每个 GTK 不自行装饰的窗口(也包括无装饰的窗口),
gtk_window_realize都会向支持org_kde_kwin_server_decoration的 Wayland 合成器(KWin、 COSMIC、Sway)宣告服务端装饰,合成器便在其上绘制自己的标题栏。GTK 不 自行装饰的无边框窗口在显示之前就被 realize,并改为宣告客户端装饰 (gdk_wayland_window_announce_csd,在启动时查找):GDK 为GdkWindow保持该设置,并在每次窗口 map 或合成器以另一种模式答复时重新请求,因此 除非合成器为每个窗口都添加装饰,该窗口不会得到标题栏。在没有该协议时 (Mutter),或在读取gtk_window_set_decorated提示的 X11 上,反正没有 任何东西装饰该窗口。 - 隐藏标题栏(
titlebar.go)是一个无装饰的窗口,其 web view 位于一个GtkOverlay中,上面按gtk-decoration-layout中指明窗口按钮的每一侧 各放一个GtkHeaderBar。每个 bar 只显示那一侧的最小化、最大化和关闭 按钮(decoration-layout),CSS 清除它的背景,并配合TitleBarHeight清除其最小高度。按钮由 GTK 生成(它们需要 bar 位于GtkWindow内), 窗口用不了的按钮会被隐藏,其作用与 header bar 的按钮相同:最小化、 切换最大化、关闭。这些 bar 在 web view 存在之前就测量好——先按其自然 尺寸,用于告知第一个页面的脚本,之后按其分配尺寸;布局设置的改变会 重建它们。自行装饰窗口的 Wayland 合成器 (gdk_wayland_display_prefers_ssd:org_kde_kwin_server_decoration_manager的默认模式,KWin、Hyprland 和 Sway 上为服务端)不会得到 bar:这样的 窗口保留 GTK 关于服务端装饰的宣告,与无边框窗口不同,所以合成器的 标题栏——或平铺式合成器根本没有标题栏——就代替了按钮,无论布局怎么设。 - 除非用户已设置,否则会设置
WEBKIT_DISABLE_DMABUF_RENDERER=1,以避免 在 NVIDIA 驱动、虚拟机和容器中出现空白的 webview。 - XDG desktop portal 调用经由
portalCall(portal.go)发出,它首先用org.freedesktop.host.portal.Registry注册应用的 ID,即其 desktop entry 的名称:portal 只接受在任何其他调用之前先做这一步,并且对于非 桌面环境启动的沙箱之外的应用也需要它。可能涉及用户的方法会在一个 request 对象上以Response信号答复;portalRequest按对象路径把信号 路由到回调。 - 在应用无法抓取按键的 Wayland 上,全局快捷键经由
GlobalShortcutsportal(hotkey_portal.go)。一个会话一次性绑定其快捷键,所以每次 变更都会关闭会话并新建一个绑定全部快捷键的会话;id 是规范化的快捷键, 会话 token 以应用命名,这样桌面环境会保留用户挑选的按键。portal 可能 请求用户确认新的快捷键,并且需要应用的 ID(1.21 及以后的 portal 没有 它就拒绝),所以应用必须已安装。KDE Plasma 5 会绑定CreateSession选项里列出的快捷键,正如 portal 草案所规定的那样:1.17 之前的 portal 会将其传递下去,之后的会丢弃它们,Plasma 5 便什么都不绑定;MyGo 会 记录桌面没有绑定任何按键的快捷键。Activated信号的 activation token 在回调运行期间成为该 display 的 startup notification id,以便回调显示 或聚焦的窗口可以获得焦点。
为什么不用 GTK 4
后端使用 GTK 3,因为 GTK 4 后端会做得更少、能运行的系统更少、也不能给 应用补上它们在 GTK 3 上缺的东西,代价却是要重写 internal/linux 的大部分。
会做得更少。 MyGo API 承诺的一部分功能在 GTK 4 中没有对应物,连改名的 都没有:
- 窗口定位。 GTK 4 没有
gtk_window_move、gtk_window_get_position、gtk_window_set_position或gdk_window_get_frame_extents,在 X11 上 也没有:应用无法把窗口放到任意位置,也无法询问它在哪里。上文中的Bounds、SetBounds、Center和 frame 处理都依赖它们,而浮动窗口用gdk_window_move_to_rect锚定到一个矩形——这个方法属于 GTK 4 已不再 有的GdkWindow。 - 窗口提示。
gtk_window_set_keep_above、gtk_window_set_skip_taskbar_hint和gtk_window_set_type_hint都没了: 没有总在最前,没有不显示在任务栏上的窗口,浮动窗口和面板也没有 utility 或 popup menu 窗口类型。 - 托盘图标。 负责绘制它们的 AppIndicator 接受
GtkMenu,而 GTK 4 连同GtkMenuBar一起移除了GtkMenu,AppIndicator 也没有 GTK 4 版本:后端就得自己在 D-Bus 上实现 StatusNotifierItem 和 dbusmenu 服务。
只有 X11 能通过 GTK 之外的 Xlib 和 EWMH 找回定位和提示(XMoveWindow、 _NET_WM_STATE_ABOVE、_NET_WM_STATE_SKIP_TASKBAR),而 GTK 4.18 已将其 X11 后端标记为弃用,并将在 GTK 5 中移除:GTK 4 后端会把那些特性建立在 GTK 正要抛弃的东西之上。Wayland 也不是迁移的理由:没有任何 GTK 版本能在 Wayland 上放置窗口。
能运行的系统更少。 GTK 4 用 GtkAccessible 取代了 ATK——原生 UI 的 无障碍树实现的正是 ATK(surface_access.go),GTK 3 再把 ATK 桥接到 AT-SPI——而直到 GTK 4.14,应用才能暴露自己绘制的文本 (GtkAccessibleText),屏幕阅读器需要它来朗读原生 UI 的文本字段和编辑器。 Ubuntu 22.04 自带 GTK 4.6,Debian 12 自带 GTK 4.8,两者都仍在支持期内, 而在这些地方 GTK 3 给原生 UI 的无障碍能力与其他地方完全一致。GTK 4 还 意味着 WebKitGTK 6.0,它取代而非叠加 4.1 和 4.0,所以应用将无法再回退到 旧发行版自带的 WebKitGTK 4.0。
对应用毫无增益。 MyGo 窗口显示网页或原生 UI,而原生 UI 由 MyGo 自己 绘制;GTK 自己的控件只出现在标题栏按钮、菜单和对话框里。WebKitGTK 6.0 与 4.1 是同一个 WebKit,基于相同的版本构建,所以页面的渲染和行为都一样。
而且代价是重写。 除上述之外,GTK 4 用事件控制器取代了 button-press-event、motion-notify-event、key-press-event 和 configure-event 信号,并把 GdkEvent 变为不透明类型,而后端是按固定 偏移读取其字段的(field[T]);移除了把 Tab 键交给 web view 的 gtk_widget_event,以及承载原生 UI 之上 web view 的 GtkLayout;把 GtkClipboard 和 gtk_drag_* 变成了 GdkClipboard 和 GtkDragSource/GtkDropTarget;把菜单变成了 popover 中的 GMenuModel; 并移除了 gtk_dialog_run 和 gtk_native_dialog_run——它们运行主循环 直到消息或文件对话框被答复,从而使打开对话框的调用返回答案。上文 bounds 处理所规避的那些窗口管理器怪癖,不得不在 GTK 4 上、在同样的 X11 窗口 管理器和 Wayland 合成器上重新排查一遍。
GTK 3 仍在维护,安装在 MyGo 面向的每个桌面环境上,而且 WebKitGTK 4.1、 AppIndicator 和 gtk-layer-shell 都是针对它构建的。当 WebKitGTK 停止发布 4.1、GTK 3 退出应用所面向的发行版,或者仍在支持的发行版全都带上 GTK 4.14 时,这笔权衡就会转向:那时 GTK 4 后端不再损失 GTK 3 仍能提供的 任何东西,而 GTK 4 缺失的特性则变成其旁边的 X11 专用代码。
Windows (internal/windows)
- Win32 通过
syscall调用(LazyDLLprocs;系统 DLL 从系统目录按绝对 路径加载)。IsMainThread比较包初始化时记录的线程 id。循环是GetMessageW;Signal和Wake向一个隐藏的顶层应用窗口投递消息, 该窗口还接收托盘、热键、主题(WM_SETTINGCHANGE)和显示器广播。Quit投递WM_QUIT,模态循环(对话框、菜单)会转发它,所以退出在 它们运行时也能生效。 - COM。
comCall调用 vtable 方法;其索引来自WebView2.h(常量在webview2.go中)。handler 对象(事件与完成 handler)共享一个 vtable, 其回调只创建一次,并把Invoke路由到每个对象对应的 Go 函数;这些对象 保存在一个 map 中,直到 COM 释放它们。调用封装带有go:uintptrescapes, 因此uintptr(unsafe.Pointer(&x))参数保持有效。 - 不依赖 loader DLL 的 WebView2。
createEnvironment在注册表 (EdgeUpdate\ClientState\<channel>\EBWebView)中找到 Evergreen 运行时, 并调用其EmbeddedBrowserWebView.dll的CreateWebViewEnvironmentWithOptionsInternal,正如WebView2Loader.dll所做的那样;可执行文件旁边的 loader 优先。internal/webview2负责找到运行时,供后端和mygo doctor使用:即一个 目录,其中含有该架构的这个库,且其根目录下有浏览器进程msedgewebview2.exe(只有库是不够的——那是 Edge 浏览器自带的,用它 创建的环境会以ERROR_NOT_SUPPORTED失败),并且 EdgeUpdate 中关于某个 运行时的记录会比它的文件存在得更久。第一个显示网页的窗口会启动环境 (startEnvironment),没有运行时就会失败:显示原生 UI 的窗口不需要 环境,所以所有窗口都显示原生 UI 的应用可以在没有 WebView2 的情况下 运行。环境和每个 controller 都是异步创建的:需要 webview 的窗口方法会 在pending中等待。创建失败会重试,总共三次,间隔分别为一秒和两秒, 正如 Microsoft 所建议的——除非失败原因是ERROR_INVALID_STATE:在高负载 下,WebView2 有时会以ERROR_BUSY或CO_E_SERVER_EXEC_FAILURE失败。 当 webview 始终没有出现时,那些报告结果的调用(Eval、CapturePage、 PrintToPDF)会得到原因,后来的调用也一样:窗口已关闭,或 WebView2 无法 创建它。用户数据位于%LOCALAPPDATA%\<name>\WebView2。 - 自定义 scheme 从
http://<scheme>.localhost/加载,其其他主机从http://<host>.<scheme>.localhost/加载(internal/schemehost:主机由 DNS 标签构成、转小写、不含端口),WebView2 让应用通过WebResourceRequested来应答这些请求。Chromium 把*.localhost视为 安全源,而且与https不同,http不拦截混合内容,所以页面访问ws://和http://URL 与其他后端的自定义 scheme 表现一致。后端双向 映射 URL,因此核心层只见<scheme>://<host>/。响应会被缓冲:WebView2 要求一整个流。带 base URL 的LoadHTML以同样的方式从该 URL 提供文档。 - Eval 经由 DevTools 协议(
Runtime.evaluate配合awaitPromise), 它会报告语法错误并忽略页面的 CSP。编辑角色在用户手势下运行document.execCommand;粘贴会插入剪贴板文本。 - 在文档创建时注入的脚本会在每个 frame 中运行,所以只应在主 frame 运行的 脚本(桥接)被包在
window === window.top中。Iframe 不会得到chrome.webviewhandler。 - ABI。 在 x64 上,大于 8 字节的结构体按引用传递,且 Go 会把前几个 整数参数镜像到 XMM 寄存器中,因此 double 可以按位传递。在 ARM64 上, 16 字节结构体经两个寄存器传递,而
syscall无法传递浮点数,所以缩放 回退到 CSS(abi_*.go);comCallFloat改为通过 purego 调用接受 double 的方法,例如 cookie 的put_Expires。Go 回调只读取整数寄存器:UI Automation 中两个接受 double 的方法经由汇编 thunk 进入(uia_*.s), thunk 把 double 的位移动到其位置对应的整数寄存器,只触碰调用可以改变 的寄存器,然后跳转到回调。 - DPI:进程是逐显示器感知的(v2);后端按窗口或显示器的 DPI 在像素和 DIP 之间转换。无边框窗口以及隐藏标题栏的窗口在
WM_NCCALCSIZE中去掉标题 (caption),但保留侧边和底部边框——Windows 10+ 把这些边框画在窗口外的 不可见区域,所以它们仍可缩放。在 Windows 11 上,隐藏标题栏会保留其顶部 一个像素,位于窗口边框之下;没有它,窗口在未最大化时点击最大化按钮就不 会打开贴靠布局(snap layouts)。没有贴靠布局的 Windows 10 则一个像素都 不保留:只要窗口保留了任何顶部区域,它就绘制完整的标题栏。构建号来自RtlGetVersion,不受任何 manifest 影响。 - 没有 caption 的窗口,其顶边(
topedge.go)——WM_NCCALCSIZE把这块 区域划给了客户区,webview 或 surface 的窗口在那里应答HTCLIENT——是 盖在它们之上的一个子窗口:一个不带位图的分层窗口 (WS_EX_NOREDIRECTIONBITMAP),高度与缩放框一致,应答HTTOP,在其 所及的角落附近应答HTTOPLEFT和HTTOPRIGHT,并把窗口的按下事件继续 传下去,从而实现缩放;Windows Terminal 的 caption 也是同样的做法。在它 之下,--app-region: drag仍能移动窗口。它横跨整个无边框窗口,到隐藏 标题栏的按钮处停止,并在窗口最大化、全屏或不可缩放时隐藏。 - 隐藏标题栏(
titlebar.go)的控件来自一个盖在 webview 窗口之上、紧邻 顶边的子窗口。按钮是一个分层窗口(UpdateLayeredWindow,预乘 BGRA): 用 GDI 在黑色上以白色绘制的 Segoe Fluent Icons 字形(Windows 10 上是 Segoe MDL2 Assets)提供其覆盖范围,叠在用 Chromium 的 alpha 生成的底板 上。它以HTMINBUTTON、HTMAXBUTTON和HTCLOSE应答WM_NCHITTEST, 把WM_NCMOUSEMOVE传给DefWindowProc——由此在 Windows 11 上悬停于 最大化按钮时打开贴靠布局——并从非客户区按钮消息中自行执行按钮动作, 向窗口发送WM_SYSCOMMAND。webview 的窗口是之后才创建的,所以它出现 时,控件和顶边会重新回到 z-order 的顶部。没有 caption 的窗口没有放置 菜单栏的空间:Alt 和 F10 会打开一个弹出菜单,其中是菜单栏自己的各 子菜单。 - 菜单是 Win32 的(
menu.go)。弹出菜单通过 uxtheme 的首选应用模式 (序号 135)跟随外观;菜单栏什么都不跟随,所以由窗口自己绘制,并在深色 模式下依据菜单发给其窗口的 UAH 消息遮住其下方的浅色线条 (darkmenu.go)。Windows 11 仍像 Windows 10 那样绘制 Win32 菜单,所以 在那里 MyGo 按自家菜单的风格(WinUI 的MenuFlyout、文件资源管理器的 风格)绘制菜单项,深浅两种外观都如此(menudraw.go):弹出项为自绘 (MFT_OWNERDRAW,在该菜单所属窗口的WM_MEASUREITEM和WM_DRAWITEM中测量和绘制,托盘菜单则由应用窗口负责),每项绘制进一个位图,其高亮的 圆角经过抗锯齿处理,然后拷贝出去,之后再从 DC 中排除该区域,以免 Windows 把子菜单箭头画在 V 形箭头之上;WM_INITMENUPOPUP为菜单设置 菜单项的背景色。菜单项保留其文本字符串,助记键、屏幕阅读器和测试都 读取它们。含自绘项的菜单会失去 Windows 11 的边框,其窗口沿两条边呈 直角,处在窗口类的投影(CS_DROPSHADOW)之下:在菜单显示期间(从WM_ENTERMENULOOP到WM_EXITMENULOOP,以及环绕TrackPopupMenuEx—— 它在之前就创建了自己的窗口),主线程上的一个WH_CALLWNDPROC钩子会 在类#32768窗口的WM_CREATE中把它们设为圆角 (DWMWCP_ROUNDSMALL,用 Windows 菜单的颜色描边),并从窗口类上摘掉 投影,改用 DWM 围绕窗口的投影。菜单栏依据WM_UAHMEASUREMENUITEM增大 尺寸,其菜单项获得带圆角的内嵌底板。对比主题保留系统颜色:菜单栏重新 交还给 Windows,弹出项采用系统颜色。Windows 10 保留其原生菜单。 - 活力效果设置
DWMWA_SYSTEMBACKDROP_TYPE(Windows 11 22H2)并把 frame 扩展到客户区之上,垫在透明的 webview 或原生 UI 之后。这种材质只 显示在创建时不带重定向日位图的窗口中(WS_EX_NOREDIRECTIONBITMAP, Windows 之后既不添加也不移除它)——不透明的表面会盖住材质,所以SetVibrancy只在以Vibrancy创建的窗口中显示它。透明的原生 UI 窗口 (Transparent配合Content)同样在不带重定向日位图的情况下创建, 在有 DirectComposition 可用时的浮动窗口也是如此:其帧保持透明的部分会 透出窗口背后的内容。这样的窗口不显示任何 GDI 绘制的内容:它没有菜单栏 (与没有 caption 的窗口一样),而其隐藏标题栏的按钮——同样是带分层且无 位图的——通过 DirectComposition 显示其像素(compositor.go),运行在 窗口共享的一个 Direct3D 11 设备上(internal/gpu/d3d11/device,它不 链接渲染器的任何部分)。若某设备在一次绘制失败后被判定无效,或其 GPU 设备触发了 removed 事件(ID3D11Device4::RegisterDeviceRemovedEvent, 由一个 goroutine 等待),就重新创建设备,所有窗口的按钮都重新绘制; 未能绘制的按钮借助应用窗口的定时器重试,一秒后重试,且连续每次失败后 等待更久。没有 DirectComposition 时,带隐藏标题栏或原生 UI 的窗口保留 它的位图:它绘制的内容会显示,而材质不会显示。原生 UI 在那里在 GPU 上 绘制进一个用于合成的交换链,按其 alpha 预乘(d3d11.NewComposed), 使用渲染器自己的 DirectComposition 设备,而在内存中绘制的帧则像窗口 控件那样显示。即将绘制的渲染器会接管 surface 的窗口(Surface.Native), 不再使用那些设备:一个窗口至多只有一个 DirectComposition 目标。ui.Context.Vibrancy告知视图材质是否正在显示(platform.MaterialSurface)。 - 消息框是任务对话框(comctl32 v6,对没有 manifest 的可执行文件,从 shell32 的 manifest 激活);其结构体由手工打包和排布。通知是通知区域的 汽球提示,Windows 10+ 将其显示为提示条。
浏览器 (internal/web)
在 GOOS=js GOARCH=wasm 时,backend_js.go 选择 internal/web,它在 页面上显示原生 UI 窗口(WindowOptions.Surface);网页窗口、菜单、托盘、 通知、全局快捷键和文件对话框会报告 errUnsupported 或什么都不做,因为 浏览器里没有东西可以充当它们。为浏览器构建不会给桌面构建增加任何东西: 后端、load_js.go 和 canvas_js.go 只在 js 下编译,而契约只增加了 一个字段 SurfaceNative.Canvas。
体积。 只有网页才用到的部分会占据浏览器构建产物的绝大部分,而 服务器是整个文件发送的(Cloudflare 的静态文件至多 25 MiB):根目录里 服务于网页的文件都把这些排除在外。
ipc.go、channel.go、bridge.go(窗口对桥接说的一切:它的脚本、消息、Eval)、protocol.go(自定义 scheme、前端的 scheme、它们的下载)、typescript.go、updater.go(以及relaunchNow)、single_instance.go、window_state.go和random.go都有孪生文件(_js.go),保留同样的 API 却什么都不做,或报告没有页面, 并且不依赖它们所需的包:net/http、net、crypto、encoding/json、带 go/parser 的 internal/tsgen、internal/update。那里只缺少接受 net/http 类型的Protocol.Handle、HandleFunc和FileServer。Bind什么都不 绑定,于是没有任何东西调用reflect.Value.Method——否则会让链接器保留 接口中每个类型的每个导出方法。internal/platform把http.Header作为 其类型,即map[string][]string,这样 ui 也不必导入 net/http(仅导入它 就多出 2.3 MB);而用于预编译 shader 的gpu.SourceSum不为浏览器构建, 连同 crypto/sha256 一起。根目录的测试和internal/e2e只针对桌面。 留下来的主要是运行时、ui 及其各个包,以及图像编解码器(ui.Image):website/docs-demo约 9.8 MB(gzip 后 2.6 MB)。线程。 调用
Run的 Go 主 goroutine 就是主线程,像internal/fake那样通过 goroutine ID 判断。JavaScript 在自己的 goroutine 上调用 Go 的 函数(js.FuncOf),而浏览器的事件循环在等待:Backend.sync通过tasks把事件交给主 goroutine 并等待它,以便 handler 仍能调用preventDefault。由 Go 调用 JavaScript 所触发的回调(focus()引发focus)在发起调用的 goroutine——即主 goroutine——上运行,因此立即执行。Step用于嵌套循环(await、PopupMenu),像Run一样领取任务,使 浏览器的事件在此期间继续;嵌套循环不得在 task 内启动——task 的回调正占着 浏览器的事件循环——而只能从Dispatch启动。窗口是类名为
mygo-window的元素,填满 ID 为mygo的元素(没有 则以页面为准),每个元素内含一个 canvas 和一个 textarea。大小由页面 设定;Bounds读取元素的尺寸。surface 的大小是 canvas 的 CSS 尺寸 (DIP)乘以devicePixelRatio,由ResizeObserver和窗口的resize事件监视;其后备存储在每帧之前被设为该帧的尺寸,即ceil(width × scale), 与 ui 包计算的一致。RequestFrame请求requestAnimationFrame;Occluded对应document.hidden——浏览器在隐藏时不提供帧;visibilitychange则带来SurfaceShown。绘制。 canvas 的 WebGL 2 上下文(
gl.NewContext:预乘 alpha,无 深度、模板和抗锯齿)在处理一帧期间是当前上下文,正如 GtkGLArea 的上下文 在其 render 信号期间是当前的一样;SurfaceNative.Canvas为ui/gpu_js.go指明它。WebGL 用 JavaScript 对象命名其对象,GL 用数字:load_js.go为它们编号(编号同时作为对象上的一个属性,用于读回FRAMEBUFFER_BINDING),并实现gl.go在当前上下文上调用的 GL 函数, 上传时把 Go 的字节拷进一个复用的Uint8Array,其大小由像素存储的解包 设置(从glPixelStorei跟踪)决定。有WEBGL_blend_func_extended时, shader 像在桌面上一样写入第二种颜色;没有它(只混合一种颜色)就无法绘制 亚像素字形——浏览器自己的文字也从没有过。丢失的上下文会被保留 (preventDefault),一旦恢复就重新编号(Context.Renew),使渲染器 从头开始。浏览器没有 WebGL 2 时,canvas 取 2D 上下文,PresentPixels把在内存中绘制的帧放进一个ImageData。文字(
internal/text/canvas_js.go)用的是浏览器自己的:由 2D 上下文 测量和绘制,因此它查找字体、回退和排版的方式与页面相同。canvas 给出的 是宽度,不是字形:引擎的字形是字素簇(go-text 的分词器),每个簇结束于 其所在单词穿过它的文字结束之处,并在其自身宽度之前开始,从而使单词的 字距调整移动它所作用的簇;字形单独绘制,用测量时对应字号的字体并缩放 canvas(在像素对应的尺寸下,系统字体可能采用另一个视觉字号,如 Text 对应的 SF Pro Display,其字形按步进宽度放不下),其覆盖范围读回 (willReadFrequently上下文的getImageData)并裁剪到墨迹。一行文字 就像浏览器整体调用fillText那样逐像素绘制。字体做了浓淡处理:字形以 其文字色调对应的灰色绘制,因为 Chrome 的文字和 AppKit 的一样,颜色越浅 显得越粗(用白色字形绘制深色文字会显得太粗),Thick 用白色;emoji 使用 其字体的一个未做浓淡处理的孪生字体。字母会连接或重排的书写系统(阿拉伯文、 印度诸文字、泰文……)的整段文字各作为一个字形,整体排版。行在 UAX #14 的断行机会处断开,go-text 的 bidi 包给出各段的层次,引擎据此重排 (UAX #9 的 L2)。字形 ID 为各簇的文字编号。字形逐个混合(join为 false),定位到四分之一像素,正如 Skia 的绘制方式。Chrome 在页面和 canvas 之间选取系统字体视觉字号的做法不一致,因此原生 UI 的文字在同一 字体下可能比页面的更宽或更窄。输入。 指针事件(按下时捕获)就是指针,点击由时间和位置都相近的 按下计得,按钮 3 和 4 是后退和前进键;
wheel事件负责滚动,行和页换算为 DIP,格(notch)为 120 而非Precise。textarea 藏在光标处不可见 (SetTextInput),负责键盘:keydown和keyup就是按键,按其输入的 内容(key)识别,否则按位置(code)识别;beforeinput带来输入的文字, 并会被取消以保持 textarea 为空;composition 事件来自输入法。对于会输入 文字的键、浏览器自己的键(F5、F11、F12)以及复制粘贴的键,浏览器保留其 默认行为:内容在按键时完成复制,随后的copy事件把其文字(后端的剪贴板) 带到系统剪贴板,而剪贴板的文字随paste而来,随后发出粘贴命令。在 Mac 上,Command 对应ModCtrl,Control 对应ModSuper,因为 ui 包把浏览器 当作 macOS 以外的另一个系统来对待。拖放文件(
files.go):canvas 上带文件的dragover就是FileDragOver,其答复是dropEffect,并且浏览器的默认行为(打开文件、 离开应用)总是被阻止;drop先读取文件的字节(arrayBuffer,一起 settle;文件夹没有),然后FileDrop给出它们的路径,/mygo-drop/<n>/<name>。Go 的 syscall 包调用wasm_exec.js的fs对象,其函数在浏览器中会失败:后端包装了open、read、fstat、close、stat、lstat和readdir来同步应答这些路径,因为应用会在 其 handler 中读取拖放内容,而此时浏览器正在等待发出拖放的回调;其他路径 则原样传递。上下文菜单(
PopupMenu)是 HTML,按 macOS 的样式设计,运行在一个 嵌套循环中,直到选中一项、点击落在别处、按下 Escape 或页面失去焦点; 所选交给MenuItemClicked。主题:
IsDark和Preferences读取matchMedia,其变化会调用ThemeChanged;根元素的data-mygo-theme(由MutationObserver监视) 优先于媒体查询决定外观。ShowMessageBox使用alert和confirm。历史(
ui/router_js.go):一个窗口(不是Tester)显示的第一个ui.Router会占用该标签页的历史,通过 router 调用的钩子实现 (browserHistory,在其他环境下为 nil)。它的位置放在 URL 的片段中,其 条目的序号(Router.base + at,base计入超过maxHistory后被丢弃的 条目)放在history.state中:Push对应pushState,Replace对应replaceState,Go对应history.go,其popstate已经能在其中找到 该 router。由浏览器自己的前进/后退按钮产生的popstate会转到其序号 对应的条目;落在 router 第一个条目之前或最后一个条目之后的popstate——例如重新加载之后(重新加载会把浏览器条目的序号当作 router 的)—— 会变成 router 的;手动输入的、没有 state 的片段会被 push。
IPC(进程间通信)
页面运行时(packages/bridge → internal/bridge/bridge.js)
packages/bridge/src/bridge.ts 由 Bun 打包成一个 IIFE 并提交进仓库,因此构建应用从不需要 Bun。核心库将其与窗口的配置一起封装(bridge.Script),每个后端都会在文档开始时将其注入主框架。它安装:
window.mygo:call(method, ...args)、on(event, fn)、once、window控件(minimize、toggleMaximize、close……)、platform、windowId、version。它被冻结,页面无法篡改。window.__mygo.receive(messages),供 Go 投递消息使用。dom-ready 通知,以及无边框窗口对
--app-region: drag的处理(mousedown 会被上报,后端在按键按下期间从记录的鼠标事件开始拖动窗口)。隐藏标题栏的窗口的
--mygo-titlebar-*CSS 变量,写在一个构造样式表中(在缺少该能力的引擎中用一个<style>元素),来自mygo:title-bar事件:其控件所占的空间(后端的Window.TitleBar,以 CSS 像素计)。每个后端都在文档开始时投递该事件,位于 bridge 之后的脚本中,因此首个页面在绘制之前就会围绕控件布局;当空间变化时,以及后续任何页面的 DOM 就绪时,核心库会再次发送。文件拖放(
Window.OnFileDrop,mygo-runtime 中的onFileDrop)。与 Tauri 不同——其原生拖放处理会把拖放从页面手中夺走(在 Windows 上,HTML5 拖放需要把它关掉)——MyGo 不干预页面的拖放,而是在旁边读取路径。当拖放携带文件时,drop上的捕获监听器会发送{t:"drop", x, y};随后核心库取回后端在页面看到拖放之前刚刚记录的路径:macOS 上是 web view 子类的performDragOperation:(调用 super),Linux 上是drag-data-received然后是drag-drop(在 WebKit 自己的处理程序之前),而在 Windows 上则是 bridge 通过postMessageWithAdditionalObjects发送的 File 对象(ICoreWebView2File.Path),并让 WebView2 自身的拖放处理保持开启。在页面内开始的拖拽会被忽略(通过dragstart/dragend跟踪),页面自行处理的拖放同样被忽略;在页面不处理被拖文件的地方,冒泡的dragover/drop监听器会取消它们,使拖放到达 Go,而不是让引擎用文件替换页面。路径会交给 Go 监听器,并作为mygo:file-drop事件只交给受信任的页面。以mygo:开头的事件名是保留的。页面的主题色(
theme-color.ts、Page.ThemeColor):head 中第一个media匹配且 content 是颜色的<meta name="theme-color">,由 canvas 的fillStyle解析(不能以 sRGB 序列化的颜色会被绘制出来再读回),在变化时作为{t:"theme-color", v:"#rrggbbaa"}(没有则为"")发送。MutationObserver观察的是 head 而不是 body,因此改动 body 的页面不付出任何代价,并用matchMedia匹配各 meta 的 media,使外观变化能选中另一个。在 DOMContentLoaded 之前它只发送颜色,之后发送它找到的任何结果,都在 dom-ready 之前:核心库在那之前一直保留上一个页面的颜色(从提交起),所以同色的页面之间什么都不改变,并在加载结束时为一个从未上报颜色的页面丢弃它(没有 bridge,例如一张图片)。在每个引擎中行为相同:WKWebView 的themeColor(macOS 12+)需要 KVO,而 WebView2 和 WebKitGTK 没有。让窗口适应其内容(
fit-content.ts、FlyoutOptions.FitContent、WindowOptions.FitContent):bridge 配置中的fit(限制,以页面缩放下的 CSS 像素计,缩放变化时由mygo:fit-content更新)向 MyGo 的样式表添加:where(:root){width:max-content;min-width;max-width;min-height},不带特异性,因此页面自己为html设定的尺寸优先,并在内容高于最大高度时从宽度中留出纵向滚动条的空间。在 DOMContentLoaded 时、dom-ready 之前,以及此后来自根元素的ResizeObserver和窗口的resize事件,它在变化时发送{t:"content-size", w, h, sw, sh},即根元素的边框盒与滚动条的空间(CSS 像素);sh仅在内容比根元素更宽时(body 溢出),例如在最大宽度处。核心库按页面缩放将其换算为 DIP(不足一个像素也占一个)并加以钳制。纵向滚动条的空间仅在内容可能高于窗口允许的高度时计入(其最大高度或工作区):每当内容先于窗口增长时,页面会短暂显示一个滚动条,而在那时计入它会撤销用户刚刚设定的窗口尺寸。它按上报调整浮动窗口的大小,或调整窗口的内容边界,只改变发生变化的那个维度,且在最大化或全屏时不调整,并限制在其显示器的工作区内;与上次采用的尺寸相同的大小什么都不做,因此用户的调整会保持。在每个引擎中行为相同:WebKit 私有的-[WKWebView _setSizeToContentAutoSizeMaximumSize:]未被使用。页内查找(
find.ts、Page.FindInPage):bridge 遍历可见的文本节点,用 CSS Custom Highlight API(一个构造样式表,Content Security Policy 允许)标记匹配项,并滚动到活动项;没有该 API 的引擎改为选中活动匹配项。由于是 JavaScript,它在每个引擎中表现相同。
传输层在两个 WebKit 平台上是 window.webkit.messageHandlers.mygo.postMessage,在 WebView2 上是 window.chrome.webview.postMessage。
mygo-runtime 包(packages/runtime)
应用通过 mygo-runtime npm 包访问被注入的运行时:call、on/once、带类型的 event<T>(name)、currentWindow 控件、isMyGo、isCallError、runtime()(在 MyGo 窗口之外抛出有用的错误)以及公共类型(Runtime、WindowControls、Platform),这些类型与 bridge 共享。它自身不持有传输:它委托给 window.mygo,因此注入的脚本始终是协议的唯一实现。模板依赖 npm 上的 ^<version>,与 Go 模块同步发布。
其 dist/ 未提交。在把某个检出副本配合 mygo init --mygo <checkout> 及其 file: 依赖使用之前,先运行 bun run build。CI 和发布流程也会构建该包。
mygo-cli 包(packages/cli)
CLI 同样发布到 npm,以便项目在 package.json 中锁定版本并从脚本中运行。与 esbuild 类似,每个平台的二进制文件都是独立的包,即 packages/cli/npm 下的 @egoist/mygo-cli-<os>-<cpu>(npm 会把 mygo-cli-win32-x64 这类无 scope 的名字当作垃圾),带有 os 和 cpu 字段;mygo-cli 将它们全部列为可选依赖,因此包管理器只安装匹配的那一个,而其 bin/mygo.js 解析该包并用二进制文件替换自身(Node.js 23.11 及更高版本和 Bun 中用 process.execve;其他情况下它 spawn 二进制文件、等待并传递其退出状态)。MYGO_CLI_BINARY 让它指向另一个构建。
在本仓库的检出中,平台包不含二进制文件,因此工作区示例从源码构建 cmd/mygo。
bun run --cwd packages/cli binaries [platform...] 交叉编译二进制文件(被 git 忽略),并以 mygo.Version 的版本写入清单;bun scripts/publish.ts 发布这些包(见发布)。二进制文件名为 mygo,与一个无关的 npm 包同名:在项目之外,文档说 bunx mygo-cli,从不说 bunx mygo。
线路协议
页面 → Go(每条消息一个 JSON 字符串,以窗口的密钥为前缀;见信任(Trust)):
| 消息 | 含义 |
|---|---|
{"t":"call","id":N,"k":token,"m":"Service.Method","a":[...]} | 调用一个绑定的方法 |
{"t":"chan-ack","c":N,"k":token,"n":S} | 页面已取走通道 N 直到第 S 个的值 |
{"t":"chan-close","c":N,"k":token} | 页面关闭了通道 N |
{"t":"dom-ready"} | DOMContentLoaded 已触发 |
{"t":"theme-color","v":"#rrggbbaa"} | 页面的主题色发生变化;"" 表示没有 |
{"t":"content-size","w":W,"h":H,"sw":SW,"sh":SH} | 适应内容的窗口的内容尺寸,及其滚动条的空间,以 CSS 像素计 |
{"t":"drag"} / {"t":"dblclick"} | 在拖拽区域上的 mousedown / 双击 |
{"t":"context-menu","x":X,"y":Y,"l":link,"lt":text,"i":image,"m":media,"s":selection,"e":true} | 页面的 contextmenu 事件,未被取消,位于 CSS 像素坐标 X、Y:它打开在什么之上,空的部分省略(仅在 OnContextMenu 有监听器时保留) |
Go → 页面,每个主循环轮次合并为一次 __mygo.receive([...]) 求值。JavaScriptCore 用其 JSON 解析器运行形如 a.b(JSON) 的脚本而不编译它,这要快上数倍,除非 inspector 已启用(开发构建):
| 消息 | 含义 |
|---|---|
{"t":"reply","id":N,"k":token,"ok":true,"v":value} | 调用成功 |
{"t":"reply","id":N,"k":token,"ok":false,"e":"message"} | 错误或 panic |
{"t":"chan","c":N,"k":token,"p":value} | 通道 N 的一个值;"a":1 请求确认 |
{"t":"chan","c":N,"k":token,"end":true} | Go 关闭了通道 N |
{"t":"event","n":"name","p":payload} | 带类型的事件 |
k 是每个页面一个的随机令牌:发给一个已经导航离开的页面的回复,永远不会兑现新页面的某个 promise。
调用
WindowHandler.Message在主线程上运行。以{"t":"call",开头的消息连同页面上下文和页面的信任判定一起交给一个 goroutine(handleCall),这些都在主线程上捕获,因此竞态的导航无法改变它们。其余内容都在主线程上解码。handleCall用encoding/json/v2解码信封,查找方法,把每个参数解码到参数类型,调用它并编码结果。panic 会被 recover,连同堆栈跟踪记录日志并作为错误返回。大值尽可能少复制:参数是共享消息的原始值(rawValue),就地从中解码,而结果被编码进回复的一部分(message),flush 时一次性复制进脚本。- 回复通过
Window.enqueue(msg, false)入队,并在主线程上 flush。
绑定的方法可以把 context.Context 作为第一个参数:它携带调用方窗口(CallerWindow),并在页面导航或窗口关闭时取消。Bind 预先校验每个参数和结果类型(tsgen.Validate),因此不支持的类型在启动时失败,而不是在调用时。
Bind 用 reflect.Type.Method 列出一个服务的方法,而做这件事的程序会把接口中每个类型的每个导出方法都保留下来,因为链接器无法判断它调用哪些:对一个原生 UI 应用约为 2.5 MB。因此只有调用 Bind(或 Use 一个带服务的插件)的应用才链接它。MyGo 自身的服务——window.mygo.window 背后的 mygo:window——由 bindFuncs 绑定,从 funcs 绑定,不经过它;go build -ldflags=-dumpdep 会在做这件事的东西上打印 <ReflectMethod>。
JSON 选项(ipc.go 中的 jsonOptions)由调用和事件共享:json/v2 的默认行为(nil slice 编码为 [],名称区分大小写)、time.Duration 以纳秒表示,以及为 JavaScript 转义 U+2028/2029。
事件
NewEvent[T](name) 注册一个带类型的事件。Emit 和 Broadcast 编码一次并按窗口入队。事件会被暂存直到页面报告 dom-ready(每个窗口上限 1024 条),并且每次导航都会重置暂存,所以在创建窗口之后立即发送或在导航期间发送的事件,一旦监听器存在就会被投递。回复从不暂存:模块脚本可能在顶层 await 一个调用,而 DOMContentLoaded 会等待它们。
通道
*Channel[T] 参数(channel.go)向发起调用的页面流式传输值。页面的 Channel 以它自选的 id 进入调用的参数;method.call 为该页面创建 Go 一侧,注册在 Window.channels,并在方法返回时、回复入队之前关闭它,因此页面先得到值、得到结束、再得到结果。页面可以更早关闭它,这会取消调用的上下文——对带通道的调用,上下文派生自页面的上下文;页面离开时同样如此。页面甚至可以在调用的 goroutine 创建它之前就关闭它(在发出请求后立即中止):窗口会记住这样的关闭(closedEarly,有上限,随页面一起重置),调用一旦创建通道就立即关闭它。值像回复一样入队到窗口的发件箱,因此它们被批量发送并与回复保持顺序。
流控防止比页面更快的生产者堆积消息:每半 MiB 消息,一个值会请求确认,页面在取走该值后(处理了它,或把它让给了迭代器)发送确认,而当超过 1 MiB 未被确认时 Send 会等待。它从不在主线程上等待,主线程负责接收确认。
Eval
Page.Eval(code) 使用 webview 原生的异步函数 API(macOS 上的 callAsyncJavaScript,Linux 上的 webkit_web_view_call_async_javascript_function),它会等待 promise 并忽略页面的 Content Security Policy。代码先被包装为 return JSON.stringify({ok, v: await (code)});如果编译失败,说明代码不是表达式,就作为函数体再运行一次。编译错误意味着什么都没执行,因此代码绝不会运行两次。结果以 JSON 字符串传输,并在 Go 中解码。
信任(Trust)
每个页面都会得到运行时,但只有受信任的页面可以调用 Go:前端(mygo: 和 mygo dev 服务器)、自定义 scheme 页面(Protocol.Handle)、file: 和 about: 页面、开发期间的回环 http(s) 开发服务器,以及 PageOptions.TrustedOrigins 中列出的源("*" 信任一切)。该判定在每次提交的导航时重新计算。不受信任的调用会被拒绝,不运行任何 Go 代码。
PageOptions.Untrusted(Window.untrusted,在创建窗口或 web view 时固定)使 isTrusted 无论 URL 如何都为 false,用于从自定义 scheme 提供服务的插件 UI。此后核心库会在剥除密钥之前丢弃该页面超过 maxUntrustedMessage 的消息,在主线程上拒绝它的调用而不是每次用一个 goroutine,忽略它的拖拽区域,把它排除在 Broadcast 和发往其 web view 所在窗口的 Emit 之外,并拒绝其他页面默认无需处理程序就能得到的东西:权限(通过 isTrusted)、新窗口和浏览器、下载,以及页面发起的、目标不同于其已提交页面(Window.origin)所在源的主框架导航。serveScheme 把发起请求的页面放入请求的上下文,使用 CallerWindow 和 CallerPage 的键,因此 scheme 的处理程序知道是哪个页面发来了请求:这就是不受信任页面通往应用的通道。
消息处理程序可从每个框架访问,而信任由主框架的 URL 决定,因此来自其他源的 iframe 绝不能与 Go 通信:每个窗口有一个随机密钥(crypto/rand),只在其配置中传给 bridge 的闭包,由它给每条消息加前缀;handleMessage 丢弃没有前缀的消息。macOS 还额外只接受来自主框架的消息(WKScriptMessage.frameInfo.isMainFrame);WebKitGTK 不暴露框架信息,因此 Linux 依赖密钥。
插件
Use 把插件的服务绑定为一个名为 plugin:<name> 的内部服务:调用到达它的方式与任何绑定方法相同(信任检查、上下文、通道),但 GenerateTypeScript 会跳过它,因为插件自己的 npm 包就是它的客户端。调用一个未被使用的插件会失败,错误信息点名 mygo.Use。plugins/ 中的官方插件把每个 Go 包与其 npm 包放在一起,像 mygo-runtime 一样由 bun run build 构建到 dist/,并以相同版本发布。
fetch 通过
Channel流式传输响应:先是头部(状态、headers、最终 URL),然后是 Go 读取时逐块的 base64 body。JavaScript 一侧围绕通道迭代器之上的一个拉取式ReadableStream构造Response,因此页面的读取通过通道的流控为 Go 定速。中止、取消 body 或页面离开都会关闭通道,从而取消请求的上下文。请求头经过一个普通的Headers,与Request的不同,它不会丢弃禁止的名称。websocket 是在
net/http之上自研的 RFC 6455 客户端(无依赖),它把升级请求保持在 HTTP/1.1 上,并把连接作为 101 响应的 body 交出来,因此代理和客户端的 TLS 设置都生效。Connect在连接存续期间通过通道流式传输连接的事件;页面按顺序编号用Send调用发送,因为调用运行在各自的 goroutine 上,否则会产生竞态,而 Go 按该顺序写入。连接以窗口和页面自选的随机 id 为键。updater 是更新窗口,仿照 Sparkle 的方式,仅建立在
mygo.Updater之上。一个会话(session)是一次检查及其后续(发行说明、下载、重新启动的提议):一个 goroutine 设置会话的视图(标题、消息、进度、发行说明、按钮)并等待响应,无论窗口是否显示,因此后台检查只有在有东西可展示时才显示它,而在检查期间点"检查更新……"只是把它显示出来。窗口的页面是一个用LoadHTML加载的嵌入式 HTML 文件(一个about:blank页面,因此受信任),通过Channel(Watch)监视视图并用Respond回答;每组按钮都有一个提示编号,只有对当前提示的第一个回答有效,因此双击不能回答下一个视图。插件的服务会拒绝来自其他窗口的调用。发行说明是在 Go 中解析的 Markdown(internal/markdown),它把所有 HTML 保留为文本,只把 http(s) 和 mailto URL 做成链接;页面得到的是渲染为转义 HTML 的内容,而带 nonce 的 Content Security Policy 只运行页面自己的脚本:页面可以调用 Go,而说明来自未签名的清单。链接在浏览器中打开(OnWillNavigate)。带发行说明的视图尺寸固定;状态视图请求其文本的高度(Fit)。窗口得到一个自己的空菜单,因此它在 Linux 和 Windows 上没有菜单栏。 窗口通过internal/frontend看到它的会话:会话创建它(NewWindow:它的尺寸、它的菜单、关闭时取消会话),窗口显示视图(Current)、回答(Respond)并请求尺寸(Fit)。plugins/updater/native是同一个窗口的原生 UI 版本:native.New通过 updater 设置的钩子包(frontend.Plugin)构建带自己窗口的插件,因此 updater 包不链接原生 UI,也没有面向其他窗口的 API。它的视图在会话的视图变化时重建,在下一帧测量布局的盒子(Bounds)以请求页面脚本本会请求的尺寸,并用行内文本元素构建说明的段落,其链接用ui.Link。PathUserData中的updater.json保存偏好设置、跳过的版本和上次检查的时间,改变它们的操作会在主线程上调用OnChange的函数,来自它自己的 goroutine,因此没有调用者需要等待主线程;下次检查在其后一个间隔到期,失败后一小时,并在恢复时重新调度,因为计时器在电脑睡眠时会停止。应用运行时安装好的更新会被记住,使检查提议重新启动而不是再次安装。文本是Strings,取Options.Language或App.Locale最匹配的语言(matchLanguage:语言、书写系统、地区;如 TW 之类的地区推断中文书写系统;先取该语言的另一变体,再退回英语),在插件的翻译(translations.go)和应用的翻译之中,其空字段回退到插件的,再回退到英语。页面得到lang,用于挑选 CJK 字体,以及dir;状态文本使用unicode-bidi: plaintext,说明使用dir="auto",因为两者都可能是与窗口不同的语言。页面还报告其按钮需要的宽度,因为翻译可能很长。sqlite 用 Zig 0.16 编译 SQLite 固定版本的 C amalgamation,并通过 purego 加载。一个 C shim 在每个 ABI 上按位传递 double,把每个操作一个的原子取消令牌绑定到 progress 和 busy 处理程序,并在没有 Go 回调的情况下授权 SQL。连接串行化操作;事务通过 BEGIN IMMEDIATE 持有某个连接直到 COMMIT 或回滚。页面客户端用带标签的单元格保留 int64 和 BLOB 值,并把数据库文件限制在 Go 配置的目录内。连接属于其页面,并在导航或应用退出时关闭。
mygo-plugin.json列出六个原生库资产,CLI 像 libghostty-vt 一样打包它们,库资产带校验和。Go 和原生 UI 应用也可以直接打开连接。watch 为页面提供
mygo.WatchFiles,监视应用按名称指定的目录和文件(Roots,运行中授予的则用Resolve)。Watch在监视存续期间通过通道每批流式传输一部分,并返回其结束方式,一个页面据此构造WatchError的码。在调用的上下文未结束前,页面的监视计入由mygo.CallerPage确定的其配额,因为导航会在监视停止之前结束页面上下文;监视随上下文一起停止。terminal 是面向原生 UI 的终端:
Terminal在伪终端中运行一个程序,并用 libghostty-vt(Ghostty 的终端模拟器)模拟它,View用 ui 包绘制它并接收其输入。- 库用 purego 加载(
internal/vt):dlopen或LoadLibrary,然后是符号表;按值接收结构体或浮点数的函数经过purego.RegisterFunc,其余经过SyscallN。C 结构体的 Go 镜像在库加载时对照库以 JSON 描述的布局(ghostty_type_json)校验,包硬编码的枚举值同样如此,打包的单元格(GhosttyCell)按其报告的位位置读取,因此另一个版本的库会加载失败。它的回调(写入 pty、标题、响铃、剪贴板、同步输出、设备属性……)只创建一次,通过 userdata 找到其终端。 - 伪终端(
internal/pty)用纯 Go 打开:/dev/ptmx配合grantpt、unlockpt和ptsname的 ioctl(macOS 上是TIOCPTYGNAME,Linux 上是TIOCGPTN),程序在自己的会话中,以 slave 作为其控制终端;Windows 上是 ConPTY(CreatePseudoConsole、进程属性列表),程序退出后终端关闭其控制台。用户的 shell 以登录 shell 启动(-zsh),在视图的第一帧确定屏幕尺寸时启动(或半秒没有帧之后),使其启动时就是那个尺寸。视图接收的一个按键会告诉 Windows 后端:不要为 Alt 加该键的WM_SYSCHAR打开菜单。 - *线程。*一个 goroutine 读取程序的输出,并在终端的锁下写入模拟器,然后向视图请求一帧;另一个 goroutine 从队列写入键入的内容,因此不读取的程序无法阻塞读取者,而读取者负责应答它的查询。应用的回调在锁释放后运行。一帧只在把模拟器状态复制到渲染状态(
render_state的第一阶段)时取锁;第二把锁守护渲染状态,程序的同步更新(mode 2026)会持有它:渲染保持回调在更新开始时复制屏幕,帧显示那份复制直到更新结束,或最多一秒。锁按该顺序获取。 - *绘制。*视图根据字体以设备像素确定网格尺寸(单元格宽等于最宽的 ASCII 字形,高等于行),只重建渲染状态标记为脏的行:同一字体和颜色的连续单元格,用
ui.Shape排版(通过已排版文本的 LRU,因为输出会重复),并放入其单元格,单元格内的字形居中放置,过宽时缩小,emoji 即是如此;背景和线条跨单元格合并。制表符绘制、块元素和 Powerline 的分隔符都会绘制,与 Ghostty 相同,使线条跨单元格衔接。帧从这些缓存绘制每一行、光标(块状光标下,其单元格的文本以光标文本色再画一遍)、选区(其文本裁剪到其单元格,主题有选中文本色时再以该色重画)、输入法的合成文本和滚动条。 - 主题。
ghostty_themes.go保存其所绑定的 Ghostty 自带的主题(其build.zig.zon指定的 iTerm2-Color-Schemes 归档),在生成的主题数据中每个为 22 种颜色。GhosttyTheme先找用户的主题文件,在 Ghostty 的主题目录中,与 Ghostty 的做法一致。 - 输入随发生而来(
ui.Element.HandleInput):按键由 libghostty-vt 的按键编码器编码,编码器取自终端的模式(legacy、modifyOtherKeys、Kitty 键盘协议)。一个键入文本的按键会等待文本——macOS 和 Windows 在其后发送文本——以便一起编码两者;单独到来的文本(Linux 上或来自输入法)从 US 布局找到其按键。程序请求鼠标报告时,鼠标报告经过鼠标编码器;否则 libghostty-vt 的选择手势把按下和拖动变成选区,由它自己计数点击。按键先经过终端的键绑定再到编码器:Ghostty 该平台的默认绑定,应用的覆盖其上,如同 Ghostty 分层其配置。粘贴在开始时于主线程读取剪贴板(OnPaste或处于 mode 5522 的程序需要所有格式,否则只要文本),终端保留一份快照:程序的读取(OSC 5522)在Write期间应答,在读取者的 goroutine 上、终端的锁下,在那里等待主线程可能死锁。绑定收集库的回复并整块交出,输入队列把它分块写入并报告进度。其他任何对剪贴板的读取(ClipboardRead:OSC 52 的 "?" 和 OSC 5522,除非粘贴已获准)改为排队:绑定扣住库自己的拒绝不放,视图在主线程上读取剪贴板并写入回答,读取者的锁使其安全。 - 分发库。
mygo-plugin.json列出每个平台的构建,作为带 SHA-256 校验和的发布资产发布。CLI 把它们放进应用(见 CLI 的 resources);其他程序把各自的库一次性下载到用户的缓存,已打包的应用从不这样做。
- 库用 purego 加载(
game 提供游戏在应用之外的需要,选择性启用,位于核心
ui之外,只用其公共 API。View是一个可聚焦的盒子,把它的循环保持在ui.Local中:其输入处理程序把按键(按PhysicalKey)、按钮和滚轮收集进下一步的Input,其绘制函数请求下一帧(AnimationFrame),加上自上一帧以来的时间(Painter.Now,最多四分之一秒),并在Draw之前运行其覆盖的步数。Camera是一个ui.Transform,在未旋转时吸附到设备像素。 其gamepad包像audio一样,独立于窗口和主线程。一把 mutex 守护手柄。Pads最多每秒一次请平台的scan查询已连接的设备,把已有的设备交给它以便复用,而消失的设备——来自扫描或读取时——断开其手柄。State在调用方的 goroutine 上把设备读入标准布局(State.Buttons按 W3C 的顺序,Axes的 y 向下):- macOS:通过 purego 的 objc 使用 GameController.framework:
GCController的controllers带extendedGamepad,每个元素的value在 autorelease 池内读取,对旧系统缺少的按钮先respondsToSelector:。该框架在主 run loop 上查找控制器。 - Linux:evdev,在
EVIOCGBIT显示BTN_SOUTH处非阻塞打开;读取把等待的事件排入按键和轴数组,轴按EVIOCGABS的范围缩放。Xbox 控制器(微软和 Valve 的厂商 ID,或xpad驱动)在 BTN_NORTH 和 BTN_WEST 的码下报告 X 和 Y,再换回来。 - Windows:XInput 的四个槽位,在存在 Guide 按钮时通过
XInputGetStateEx(序号 100)。空槽位查询很慢,只在扫描时查询。 - 浏览器:按索引取
navigator.getGamepads(),标准映射与包的顺序一致。 测试用假设备替换scanDevices;在 Linux 上,TestEvdev(MYGO_UINPUT=1)在测试容器内制造 uinput 手柄,在 macOS 上TestGameController从controllerWithExtendedGamepad读取一个控制器。
- macOS:通过 purego 的 objc 使用 GameController.framework:
glass 是面向原生 UI 的 Liquid Glass,按 macOS 26 及更高版本的画法:
glass.Glass,一个ui.Material(交互式玻璃随其元素一起构建,用Animate跟随按压,并在按下时把自己画得更大,如 macOS 27 的:左右固定 1.1 DIP、上下 0.45,从NSGlassEffectView测得),绘制一个效果(glass.Effect,见原生 UI 下的 Effects),其参数以设备像素给出面板的材质。它的着色器(glass.metal、glass.hlsl、glass.glsl,CPU 用pixels.go)取面板背后的内容,模糊,在曲面折射处采样:在边框内按 Snell 定律经苹果的 squircle 轮廓(lens,沿normal)向中间位移,它镜像紧邻边缘内侧的内容;然后映射其亮度与颜色(tone;CPU 把曲线存进表,因为那里的幂运算慢),为其着色,并按其法线朝向光线的程度点亮边缘。默认值遵循 macOS 27 的,从覆盖测试图案的NSGlassEffectView测得;光学遵循 Liquid Glass 的开源复现。glass.Blur,一个背景模糊,是第二个效果(blurEffect):元素背后的内容,模糊,连同其 alpha(效果自己采样背景的 texel,因为 heads 的sampleBackdrop不给 alpha),因此悬在空处时保持透明。被LinearGradient遮罩后,模糊随渐变的 alpha 变化(blurLevels):元素依次绘制各级,每级模糊前面各级所画的内容(模糊像方差那样相加),在期望模糊高于其区段处显示,按深入区段的程度,因此一级的顶部从至多 2 像素倍增到模糊的最大值,两个级之间的像素混合它们的模糊。方形元素的一级只覆盖它显示之处(blurWanted),因此读取更少。把第一级之后的各级限制在元素内,使其不读取周围未模糊的内容,与逐像素变化的模糊相比测得更差,不如让它们照常读取:重复的边缘造成的偏差大于周围的内容。一级可以为其显示的内容调色(blurTone:饱和度、偏移、其上的不透明颜色),glass.ScrollEdge(macOS 的滚动边缘效果)的硬风格用一级以之结霜;软风格是背景的渐变,如 macOS 27 的在遮罩下重放其窗口的背景,没有模糊(从覆盖测试图案的 SwiftUIsafeAreaBar测得,其图层被导出)。两个效果的着色器在 macOS(shaders_darwin.go)和 Windows(shaders_windows.go)上用internal/gen提前编译。loading 是面向原生 UI 的 loading.dev 的 29 个指示器(
loading-dev,React 和 CSS),只用 ui 的公共 API。每个都是RoleProgress的盒子,其绘制函数请求下一帧(AnimationFrame),并根据已运行的周期数计算其姿态:自首次显示起(起点在ui.Local,如同 CSS 动画随其元素开始),或在Paused时保持。各部件按 loading.dev 的负animation-delay错开,关键帧逐段用 CSS 的cubic-bezier缓动(frames、bezier),形状在 SVG 的 viewBox 中经仿射变换构建为ui.Path(affine、shape),圆弧作为三次贝塞尔曲线,仿射变换保持其精确:Atom 在 3D 中旋转的环是椭圆,Flip 的方块逐点做透视投影。圆头端点用StrokePath的;平头端点填充描边的轮廓。渐变是锥形渐变(Comet、Orbit),Radar 的径向淡出是半径递增、彼此叠放的扇形,每个的不透明度使其条带恰为渐变的不透明度,使光束内部没有两条边相接。启用ReduceMotion时,指示器绘制 loading.dev 为减少动效准备的姿态并且不请求帧。这些指示器与浏览器渲染的 loading-dev 在其周期的相同点上、带每个选项地核对过。
带类型的客户端生成(internal/tsgen)
mygo generate 构建应用并以 MYGO_GENERATE=<file> 运行它;App.Run 随后写出客户端(WriteTypeScript)并在触碰 GUI 之前返回。它经由 Bind、BindAs 和 NewEvent 注册的东西(ipc.writeClient)到达生成器:什么都不绑定的应用不写客户端,既不链接生成器也不链接 go/parser(375 KB)。生成器结合:
- 反射保证正确性:类型遵循 json/v2 编码(嵌入结构体按 v2 的冲突规则内联,
omitempty/omitzero成为可选字段,指针成为T | null,[]byte是 base64 字符串,time.Time是字符串,带 JSON 方法的类型是unknown,文本 marshaler 是string,泛型实例化得到形如PageTask的名字)。 - 源码提升可读性,在可获得时:每个绑定方法的入口 PC(
runtime.FuncForPC)定位其文件,用go/parser解析以恢复参数名和文档注释(JSDoc)。带常量的具名 string/number 类型成为联合类型("all" | "done"),包括简单的iota序列。不经由 PC 到达的包用go list定位。没有源码时(例如-trimpath的二进制),客户端仍然正确,只是用arg0这样的名字且没有文档。
输出从 mygo-runtime 导入 call 和 event,并声明接口,每个服务一个对象、方法名用 camelCase,还有一个 events 对象。WriteTypeScript 只在内容变化时重写文件,因此开发服务器不会无谓地重载。
应用显示的语言(locale.go)
应用显示一种语言,localeState.shown 连同用户的语言和地区格式一起持有它:一个由主线程替换的原子指针(updateLocale),因此 App.Locale、菜单、消息目录的 i18n.Locale 和原生 UI 可以从任何 goroutine 免费读取。首次读取会在主线程上算出它。updateLocale 读取后端的 PreferredLanguages、Region,以及一次已保存的语言(SavedLocale,系统保存的地方,否则是 PathUserData 中的 locale 文件;MYGO_LOCALE 覆盖两者),用 i18n.Match 对照 App.Languages() 解析它,并在其变化时告知:原生 UI 的窗口(Conn.LocaleChanged,之后 ui 读取 Conn.Locale 和 Conn.Region 并设置文本系统的语言)、页面(该事件,以及用新语言的 bridge 配置调用 Page.SetUserScripts,供之后加载的页面)、菜单(relabelMenus:标签仍是其角色所取 internal/l10n 之值的项)、后端(SetLanguages:应用的语言,然后是用户的其他语言)和监听器。主线程可能尚未到达的 SetLocale 调用只显示最后一个标签(一个代际计数器)。后端的 LocaleChanged 报告系统语言或格式的变化,updateLocale 对其比较。
文本引擎按样式和跨度接受一种语言(text.Style.Lang),对没有语言的那些则用系统的一种(System.SetLanguage,布局的新代际):Core Text 的 kCTLanguageAttributeName、DirectWrite 文本格式与范围的区域设置(IDWriteTextLayout::SetLocaleName)、Pango 的语言属性。在不书写汉字的语言下的汉字得到引擎自己的选择(langRuns),如同 Chromium 的 LocaleForHan;Core Text 自己就会这样做。
WebView2 的环境通过 ICoreWebView2EnvironmentOptions 接受语言,这是我们自己的一个 COM 对象(webview2_options.go),其 vtable 的回调只创建一次;若某个 runtime 因它失败,就请求一个不带它的环境。
消息目录(i18n、internal/i18ngen、internal/l10n)
包 i18n 是一个叶子(它只导入 golang.org/x/text/language):
- CLDR 数据是生成的,从不手写:
go generate ./i18n运行i18n/internal/gen,它读取其固定版本的 cldr-core 的 npm tarball,对照其 SHA-256 校验,并写出tables.go。复数规则被编译成 Go,每种类型(cardinal、ordinal)一个switch,遍历各不相同的规则集并配一张语言有序表,因此 init 时不构建任何东西;超过 18 位的操作数保留其最后 18 位加上 10^18,这保持了它按十的幂的余数且不等于规则的任何常量。规则按截断查找,如 ICU 所做(CLDR 复数的父区域设置为空);消息沿Parent回退,即 CLDR 的主要父区域设置及其 nonlikelyScript 规则。测试检查 CLDR 给出的每个样例和 ICU 对每种语言 200 个数字的类别(scripts/plurals-golden.ts,用 Node 运行,其 ICU 是上游的;macOS 上的 Bun 用苹果的,有所不同)。 - 格式化数据来自 CLDR 的现代区域设置(有效覆盖 "modern",430 个区域设置),取自 cldr-dates-full、cldr-numbers-full 和 cldr-localenames-full,像 cldr-core 一样固定版本(生成器的
format.go写出format_tables.go)。一个区域设置的数据是一组槽位的记录(按月上下文和宽度排列的月份与星期名、时段、纪元、日期、时间和日期-时间模式、MyGo 所格式化的 skeleton 的 availableFormats、时区的 GMT 格式、默认计数系统和 latn 的数字符号、模式与数字);每个区域设置只存储它与列表中最近的祖先(或与多数区域设置共有值构成的合成根)不同的槽位,作为对字符串池的索引。槽位分为两半,各有自己的条目和池,各自以 DEFLATE 打包进一个常量:数字连同区域设置及其父项(fmtPackedNumbers,3 KB),每个应用都链接它,因为 MyGo 自身的字符串要格式化数字,而日期连同语言的名称(fmtPackedDates,77 KB),只有格式化日期或命名语言的应用才链接。运行时cldr.go各自解压一次,并在某区域设置首次使用时拼好其记录(每个区域设置一个原子指针),其日期在首次格式化日期时(withDates);keyOf缓存某个标签用什么来格式化(其数据区域设置,按Parent和可能书写系统,及其-u-hc、-u-fw、-u-nu)。周数据、时间数据和时段规则是按地区的小表。dtpg.go移植了 ICU 的 DateTimePatternGenerator(dtTypes、距离、以基础骨架为键并按 ICU 顺序排类型的模式映射、adjustFieldTypes、用 atTime 粘合把日期与时间拼在一起),date.go移植了 V8 对样式和小时周期的处理;number.go写十进制字符串,使舍入像 ICU 的那样是十进制的。格式化器按区域设置和选项保存在读锁之下的映射里。scripts/format-golden.ts为每个区域设置记录 Node 的Intl(上游 ICU);TestFormatMatchesICU进行比较,knownDifference列出有意不同的情形及其原因。 - MessageFormat(
message.go、format.go)按 ICU 的 MessagePattern 解析 ICU 的语法(撇号模式 DOUBLE_OPTIONAL,#只在复数自己的情形中),并用上述格式化来格式化数字和日期;复数情形按#写出的那个数来挑选。日期经过timeFormat,由NewCatalog、NewLibraryCatalog和ParseMessage设置(linkTimes):MyGo 自己的消息目录,每个应用都要格式化它而它没有日期(其测试检查这一点),经由internal/i18nlink构建,而它没有,因此不格式化日期的应用不链接任何日期格式化。mygo-runtime/i18n是它基于Intl的 TypeScript 孪生(skeleton 转换为Intl的选项,如同 V8 把它们转回来);i18n/testdata/messages.json保存两者都测试的情形。 - 消息目录读取某语言的文件一次,在其首条消息时(
sync.Once),并缓存一个区域设置查找键所用的标签。库的消息目录(NewLibraryCatalog)在链上每个标签处,先在应用的已注册消息目录下按<namespace>.<key>查找键,再查自己的。Register声明语言,mygo 包用Languages读取;SetLocaleFunc是 mygo 包把应用显示的语言告知Locale的方式,生成的函数读取它。
消息目录按区域设置和键保留每条无参数格式化后的消息,以注册表的版本为准:原生 UI 的控件每帧说出它们的(l10n.In(c.Locale()).Undo()),为一次读锁下的映射查找且无分配。ui 的 context.formats 在语言或系统格式每次变化时算出一次控件跟随的地区(系统未指定时用该语言的 CLDR 地区)及其 Region.FormatLocale;context.formatted 保留日历、日期和时间字段以及颜色选择器写出的日期和数字,因此一帧不重复格式化它此前格式化过的东西。
后端有自己要说的几个词(页面的 alert、confirm 和 prompt 面板的按钮、文件对话框的标题、关于面板的标题):mygo 包在语言变化时通过 platform.SetTexts 以应用的语言给出它们,这样 internal/<os> 就不导入 internal/l10n。
internal/i18ngen 检查消息目录并写出它们的函数:消息目录所在目录中的 catalog_gen.go(一个 embed、消息目录本身、为应用注册,以及每个键一个函数和一个 Messages 的方法)和 TypeScript 模块,后者带着每种语言的回退链携带消息。internal/l10n 是 MyGo 自己的消息目录,由同一套代码生成(go generate ./internal/l10n,带 i18ngen.Options.Own),其键应用改为 mygo.<key>;CLI 对照它检查那些键(l10n.FS,原样写入的文件,只有 CLI 链接它)。它只嵌入英语,作为 messages/en.json,生成器从 en.json 去掉描述后写出(28 KB 中的 7 KB):每个翻译是一个包,locales/<tag>,在被导入时把自己的文件加入消息目录(i18n.Catalog.Add),因此应用只链接它随附的语言。生成器写出那些包和 l10n.Languages;CLI 把该列表传给 internal/i18ngen,其 Go 代码导入适合应用语言的翻译,而 mygo 包在应用启动时告知一个随附了其中某语言却没有翻译的应用。CLI(cmd/mygo/i18n.go)在任何东西编译之前写出这些函数,在 generate、build 和 dev 的每次构建中,其中消息目录的目录被监视而生成的 Go 文件不被监视,因此写它不会触发又一次构建。
自定义协议
Protocol.Handle(scheme, http.Handler) 让页面像加载一个 web 源一样加载 <scheme>://localhost/……(fetch、ES 模块、相对 URL)。Protocol.serve 在一个带 schemeWriter 的 goroutine 上运行处理程序:头部和 body 被缓冲,以 256 KiB 的块交给主线程(以及 Flush 时),缺失时嗅探 content type,panic 变成 500 响应。实现了 platform.SchemeBodyWriter 的后端改为在处理程序的 goroutine 上接收 body,在那里它们可能阻塞它,而块缓冲被复用。后端把响应变成原生响应:
- macOS:
WKURLSchemeHandler;didReceiveResponse:/didReceiveData:/didFinish。被 WebKit 停止的任务会取消请求上下文,之后的写入被忽略(触碰已停止的任务会抛出 Objective-C 异常)。 - Linux:WebKitGTK 要一个
GInputStream,因此响应 body 通过一个管道流式传输(g_unix_input_stream_new),WebKit 在主循环上每次读 8 KiB。处理程序的 goroutine 把 body 写进管道(WriteBody),管道满时等待,因此主循环从不阻塞,而 WebKit 慢慢读取的响应不会在内存中堆积。自定义 scheme 被注册为安全的且支持 CORS。
scheme 在创建时按 webview 注册,所以要在创建窗口之前调用 Protocol.Handle。FileServer(fsys) 服务一个 fs.FS,带 index.html 回退以支持客户端路由。
前端(frontend.go)
应用用不带 scheme 的 URL 加载其 web UI(WindowOptions.URL: "/"、LoadURL("/settings")),resolveURL 将其相对于前端解析,如同 Tauri:
- 在
mygo dev期间,是开发服务器:MYGO_DEV_URL,来自配置的devUrl(仅在IsDev时生效); - 否则是
mygo://localhost/。mygoscheme 注册到每个 webview,除非应用用Protocol.Handle处理它,它服务交给SetFrontend的文件。mygo build从一个嵌入frontendDist的生成文件调用SetFrontend(见 CLI),因此应用代码没有//go:embed,开发构建也不需要构建好的前端。在没有开发服务器的mygo dev期间,它从磁盘服务MYGO_FRONTEND_DIST,而无东西可服务时,给出一个解释如何获得前端的页面。
窗口、生命周期与退出
NewWindow校验选项,在主线程之外调用时等待就绪,构建platform.WindowOptions(应用所有默认值、bridge 和 preload 脚本、已注册的 scheme)并注册窗口。- 窗口的页面 API 在
Page上,它是窗口的一个句柄,除非窗口显示原生 UI,由Window.Page返回;页面的状态留在Window中,IPC 和后端的回调在那里访问它。 - 用户的关闭(
WindowHandler.ShouldClose)和Window.Close都发出OnClose,它可以被阻止。Destroy跳过它。后端同步报告Closed;核心库注销窗口、关闭子窗口、取消页面上下文,并且当它是最后一个窗口且没有退出在进行时,运行OnWindowAllClosed监听器或退出。 - 退出序列(
Application.prepareQuit)是:OnBeforeQuit→ 关闭每个窗口(任何OnClose都能取消)→OnWillQuit→ 停止循环 →App.Run返回 →OnQuit。App.Quit、Cmd+Q、退出 Apple Events 和 SIGINT/SIGTERM 都走它(signal_unix.go;第二个信号立即退出)。 - Windows 上的任务栏状态(
taskbar.go)存在于窗口上,并在 Explorer 发送TaskbarButtonCreated时应用(首次显示、Explorer 重启),因此在显示之前设置的进度和隐藏按钮会保留。 WindowOptions.StateKey(window_state.go)在PathUserData的window-state.json中记住窗口的普通边界和最大化/全屏状态,像 Tauri 的 window-state 插件,或在应用的WindowStateStore(App.SetWindowStateStore)中,它在主线程上一次性加载全部状态并保存全部状态。状态在最后一个移动/调整大小/状态事件后 300 毫秒捕获(过渡会在过程中调整窗口大小,而普通边界只在普通状态下变化),并在窗口关闭时捕获;它在窗口关闭时和应用退出时保存(如果有变化),保存失败时留待下次保存。一个保存的窗口若在任何显示器上都不可见,则保持其尺寸并居中。WindowOptions.Maximized以最大化启动窗口:macOS 上用zoom:,Linux 上在映射之前用gtk_window_maximize,Windows 上首次显示时用SW_SHOWMAXIMIZED,在 Windows 上对隐藏窗口调用Maximize也会等待它被显示。- 深度链接(
deeplink.go)。mygo build和mygo dev把配置的名称、版本、标识符和urlSchemes链接进二进制(-X …packageName=……),Linux 构建就是这样得知它们(IsPackaged、Name、Version),每个平台也是这样知道哪些启动参数是深度链接。这些 scheme 的 URL,以及用RegisterURLScheme注册的,到达OnOpenURL:macOS 上来自 Apple Events,应用就绪后来自启动参数,以及来自第二个实例转发的参数。RegisterURLScheme在 Windows 上写HKCU\Software\Classes\<scheme>,在 Linux 上写一个隐藏的$XDG_DATA_HOME/applications下的<id>.url-handler.desktop,并在$XDG_CONFIG_HOME/mimeapps.list中把它设为默认(xdg-mime default所做的事);在 macOS 上,对 Info.plist 声明的 scheme 调用LSSetDefaultHandlerForURLScheme。mygo build的 Linux.desktop也声明这些 scheme(Exec=…… %u、MimeType=x-scheme-handler/……)。 - 登录时启动(
login.go):SetOpenAtLogin在 macOS 13+ 上向SMAppService.mainAppService注册 bundle(macOS 12 上是一个运行open -a的 launch agent;两者都需要 bundle),在 Linux 上写一个 XDG autostart 条目,在 Windows 上写HKCU\……\CurrentVersion\Run的一个值,在 Windows 上OpenAtLogin还遵从任务管理器的StartupApproved(首字节为奇数:已禁用)。Linux、Windows 和 launch agent 的命令带上--mygo-opened-at-login,该包在 init 时把它从os.Args移除,WasOpenedAtLogin报告它;macOS 登录项则通过启动 Apple Event 中的keyAELaunchedAsLogInItem识别。 Relaunch运行退出序列,然后(在OnQuit之后,它释放单实例锁)以相同的参数和工作目录再次启动可执行文件。开发构建改为以退出码 75 退出,mygo dev再次启动同一个构建,即使这发生在它就绪之前。- 更新(
updater.go、internal/update、cmd/mygo/updates.go),在每个平台上都是纯 Go。mygo keygen创建一对 Ed25519 密钥;配置里有updates(公钥,以及一个 GitHub 仓库或一个基础 URL)时,mygo build把该目标的清单 URL 和公钥链接进应用,当私钥可用时(MYGO_UPDATER_PRIVATE_KEY或updates.privateKey),把安装形态的应用(bundle,否则是可执行文件旁的全部东西)归档为<name>-<version>-<target>.tar.gz(Linux 构建总会得到它,供install.sh使用),对其 SHA-256 签名,并写出update-<target>.json,以 CHANGELOG.md 的## <version>小节作为说明。Updater.Check抓取清单(仅 HTTPS,测试可用回环 HTTP)并按语义比较版本;Update.Install把归档流式下载到应用旁边,校验大小和签名,解开它(仅文件、目录和相对链接)并换入:macOS 上重命名 bundle,Linux 和 Windows 上重命名应用目录的条目,在 Windows 上正在运行的可执行文件被改名移开并在下次启动时删除。随后App.Relaunch从应用当初启动的路径启动新版本。开发构建从不更新。增量更新的工作方式与 Sparkle 相同(internal/update/delta.go):mygo build抓取该目标已发布的清单,下载至多updates.deltas个版本的归档(那份清单的,以及它列为previous的,新清单会继承),检查它们的签名,并从每个写出一份签名的增量。其索引列出新应用的树,macOS 上在 bundle 内,带每个文件的大小、SHA-256 和生成方式:旧应用中内容相同文件的复制(在其路径,移动过的文件则在别处),否则取其路径上文件的补丁或它自己的字节,哪个小用哪个。钉附的票据(Contents/CodeResources)从不由旧应用生成:归档的 bundle 是钉附过的,但用户从磁盘镜像复制的 bundle 没有,因为buildDMG在把应用复制进镜像后才钉附。补丁是 bsdiff 的(bsdiff.go,配合suffix.go的 qsufsort 后缀数组),它们的三个流分开压缩,以便就地从增量文件读取。Update.Install取from是运行版本的那份增量,通过os.Root在旧应用旁边做出新应用,任何文件不匹配即失败,然后下载归档;增量还点名它更新和生成的版本。做出来的树与签名的逐字节一致,因此 bundle 保持其代码签名。mygo build -upload用ghCLI 发布到updates.github:它把发布<tagPrefix><version>创建为草稿,以 changelog 小节作为说明,上传安装程序、更新归档和增量,然后是清单,并在每个平台都就位后,把发布草稿(这使清单成为 "latest")留给开发者。当tagPrefix不是v时,仓库可能持有其他发布,因此应用构建时使用点名<tagPrefix>{version}发布中清单的 feed(update.TaggedFeed),update.ResolveFeed把它变成带该前缀的最新已发布、非预发布的发布的清单,来自 GitHub API 的发布列表,从新到旧。Updater.Check和增量步骤在 Go 中解析它,install.sh在 sh 中(latest_tag,按顺序读tag_name、draft和prerelease字段)。这样的草稿用--latest=false创建,并以同样方式发布,使仓库的 latest 发布仍是其他发布的。当应用开启预发布(Updater.SetPrereleases,一个Check读取一次的atomic.Bool)期间,Check改用update.ResolvePrereleaseFeed解析 latest 发布的 feed(带前缀v)和带标签的 feed:在直到包含最新非预发布发布那一页为止的发布页中,取版本最高(update.Compare)且不是草稿的发布的清单。带updates.s3时,它把同样的文件放进 S3 或兼容服务的一个 bucket,清单最后(cmd/mygo/s3.go):每个对象一次PUT,用纯 Go 的 AWS Signature Version 4 签名(crypto/hmac),其 SHA-256 payload 哈希由服务校验;在 AWS 上 bucket 放在主机名中,在自定义endpoint或带点的 bucket 名时放在路径中,除非pathStyle另有说明。有自己更新服务的应用给Updater.SetSource一个UpdateSource(一个Check读取一次的atomic.Pointer):Check改从其Manifest读清单而不是 feed,而Update保留该来源,其Open代替httpGet下载归档和增量。配置好的 feed 本身就是一个来源(feedSource),因此解析、大小、签名、暂存和交换两种方式下都一样;Enabled仍需要一个带updates的构建,为了公钥。 - 文件关联(配置中的
fileAssociations)由包声明(见下表),其扩展名被链接进二进制;与深度链接一样,启动参数中那些扩展名的文件和第二个实例转发的文件到达OnOpenFile(相对于工作目录的路径,以及来自%U的 file URL)。 - 下载(
downloads.go):带download的链接,以及页面无法显示或作为附件的响应,变成下载。OnWillDownload得到 URL、建议的名称和 Downloads 中的一个默认路径(唯一名称),它可以更改或取消;OnDownloadDone报告结果。WebKit 不能把自定义 scheme 处理程序服务的东西变成下载,而 WebView2 会通过网络抓取它,因此这些被取消,核心库通过该 scheme 的处理程序把 URL 再次服务进文件(SchemeDownload)。 - 权限(
permissions.go):后端把摄像头、麦克风、位置和通知请求路由到WindowHandler.PermissionRequested;SetPermissionHandler决定它们,默认授予受信任的页面(应用自己的、TrustedOrigins),拒绝其他。自定义 scheme 页面在每个平台上都是带真实源的安全上下文,这些 API 需要如此。macos.infoPlist添加诸如 macOS 要求的摄像头和麦克风用途描述之类的键。 - 登录与证书(
auth.go):后端把 HTTP 认证质询(Basic、Digest、NTLM、Negotiate,来自服务器或代理)路由到WindowHandler.AuthRequested,SetAuthHandler用凭据回答,或不给凭据以得到服务器的拒绝;没有处理程序时回答是Default,即 web view 自己的处理。无法验证的服务器证书交给WindowHandler.CertificateError,除非SetCertificateErrorHandler允许,否则为 false;macOS 只在HandlesCertificateErrors为 true 时自己检查证书(SecTrustEvaluateAsyncWithError),Linux 和 Windows 也只在那时提取证书。客户端证书留给平台处理。 - 浮动窗口(
flyout.go、internal/platform/flyout.go):NewFlyout创建一个带platform.WindowOptions.Flyout的窗口,后端把它做成无边框、透明、由其父窗口拥有的弹出窗口,并用Window.PlaceFlyout相对于父窗口内容区中的一个锚点放置它。Flyout.Place在显示器的工作区内解析放置位置,如同 GDK 对gdk_window_move_to_rect所做(maybe_flip_position,然后滑动,然后调整大小,按每个轴),WorkAreaFor挑选显示器如同 GDK 的get_monitor_for_rect;macOS、X11 和 Windows(以物理像素)使用它,Wayland 把锚点交给合成器的 positioner。核心库在窗口移动或调整大小时重新放置其浮动窗口(Windows 上被拥有的窗口和 X11 弹出窗口不会跟随),以及SetAnchor和SetSize改变它们时。可聚焦的浮动窗口在失去键盘时通过WindowHandler.ShouldClose关闭:其面板辞去 key 状态时(macOS)、在其外部按下或抓取被打破时,或合成器解雇弹出窗口时(GTK,如同GtkMenu),以及另一个窗口被激活时(Windows),其激活点击被其拥有者吃掉(MA_ACTIVATEANDEAT),如同菜单和 WinUI 的浮动窗口。在 macOS 上,一个局部监视器吃掉面板锚点上的按下——那只会关闭它——如同NSPopover吃掉其定位矩形上的一次按下:那次按下会让父窗口成为 key,从而在松开、点击锚点并再次打开之前关闭浮动窗口。内容未处理的 Escape 也会关闭浮动窗口:原生 UI 报告一个没有任何快捷键拿走的 Escape(surface.Conn.Escape),既不是上一帧的(routeKeys),也不是处理该键的那帧所构建的,后者在下一帧拿走它(settleKeys):在视图变化时、帧显示变化之前按下的键,去往视图现在发送它的地方,所以在为浮动窗口构建其快捷键的那帧之前抢先输入的 Escape 从不关闭浮动窗口。bridge 报告一个页面的处理程序都未取消的 Escape(一条escape消息)。Flyout.Popover把一个显示在NSPopover中(internal/darwin/popover.go),其窗口由 AppKit 在显示时创建,其 transient 行为询问popoverShouldClose:。Flyout.Tray把一个锚定到托盘图标的Bounds上,没有父窗口:macOS 上是位于NSPopUpMenuWindowLevel的面板或从状态项按钮显示的NSPopover,Windows 上是一个置顶的无主弹出窗口,Linux 上(AppIndicator 不告知边界)是一个无装饰的 utility 顶层窗口,位于工作区中央,可聚焦的一个在失去键盘时关闭。按下图标不解除其浮动窗口(popoverShouldClose:和辞去 key 时的tray.pressed,Windows 上光标位于Shell_NotifyIconGetRect之上),因此其点击切换它。一个鼠标按下的全局监视器在点击应用之外时关闭 macOS 的可聚焦面板,而一个拿走未激活应用键盘的面板从听不到 key 窗口的变化。该设计遵循 GPUI 的WindowKind::AnchoredPopup和 winit 的WindowType::Popup,后者也建模了 xdg_positioner。 - 面板(
WindowOptions.Panel),启动器的窗口:核心库让它们总在最前、不出现在任务栏、不能最小化、最大化或进入全屏,并在内容未处理的 Escape 上关闭它们,如同浮动窗口。macOS 创建一个MyGoPanel,一个NSPanel,从initWithContentRect:起带上 nonactivating 风格(窗口服务器从窗口创建时的风格得知它从不激活其应用;后来才设置风格需要私有的_setPreventsActivation:,正如 Electron 和 tauri-nspanel 所发现的),从不作为主窗口,作为 full-screen auxiliary 出现在每个 Space 上,在 Mission Control 和窗口循环之外,位于 floating 层级(在其之上,输入法的候选窗口会显示在面板后面);Show和Focus使它成为 key 而不用activateIgnoringOtherApps:,其cancelOperation:什么都不做(NSPanel 的那个会越过windowShouldClose:关闭它)。非激活面板的 key 窗口得到按键和主菜单的按键等价物,即使应用不在最前(NSApp.isActive那时报告它为活跃)。Windows 创建一个由隐藏的应用窗口拥有的置顶工具窗口(只有工具窗口能在其他显示器的任务栏上得到按钮,正如 PowerToys 所发现的),其圆角如同 Windows 11 给其他窗口的圆角;应用最大化它时填满工作区(maximizeInWorkArea),否则 Windows 会把没有最大化框的窗口给整个显示器,盖住任务栏;RegisterHotKey快捷键的按键让该进程取得前台,隐藏面板时归还。Linux 创建一个粘滞的 utility 窗口,保持在上方,或在 Wayland 上是 overlay 层的一个 surface(internal/linux/layer.go):libgtk-layer-shell.so.0在第一个面板时加载,如果它存在(0.9 修补 GTK 3 的私有结构且不拦截 libwayland 的任何东西,因此它可以在 GTK 之后加载),并在gtk_layer_is_supported说合成器会说 wlr-layer-shell 时使用,如同 Ulauncher。gtk_layer_init_for_window必须在 GTK realize 窗口之前。位置是来自那里输出边缘的上边距和左边距(gdk_display_get_monitor_at_point),没有位置时合成器把 surface 居中;gtk-layer-shell 按 size request 而非默认尺寸给它定大小。它在显示时请求独占键盘:合成器在 on-demand surface 映射时不给它键盘,而 Sway 会从由 exclusive 变为 on-demand 的 surface 收回键盘。Linux 和 Windows 都不在面板中显示应用菜单。 window.open()和target=_blank经过SetWindowOpenHandler。默认情况下 http(s) URL 在默认浏览器中打开。允许一个则围绕配置或 WebKit 提供的相关 view 创建一个窗口,带它自己的 content manager,因此脚本和消息绝不会在窗口之间泄漏。在 Windows 上,请求的 deferral 扣住弹出窗口直到新窗口的 web view 设置完毕(其脚本、处理程序和筛选器),随后在 WebView2 导航它之前成为弹出窗口(put_NewWindow),因此页面保留其 opener。在 Linux 上,新窗口作为独立页面加载该 URL。UserInitiated说明一个请求是否由用户手势发出,NavigateEvent对导航同样如此:macOS 上是 WebKit 私有的-[WKNavigationAction _isUserInitiated](用respondsToSelector:查找;没有它时,用链接或表单的navigationType),它能看到处理程序调用window.open()或设置location.href的那次点击;Linux 上是webkit_navigation_action_is_user_gesture,尽管 WebKitGTK 在询问之前自己就会拒绝没有手势的window.open()(javascript-can-open-windows-automatically保持关闭),因此处理程序只在 macOS 和 Windows 上看到这些;Windows 上是事件参数的get_IsUserInitiated,WebView2 没有弹窗拦截器。
菜单
Menu/MenuItem 是由模板构建的普通 Go 值。角色(role)会按平台展开为标签、快捷键和子菜单(roleDefaults、roleSubmenu);仅 macOS 可用的角色在其他平台上会被隐藏。每个条目获得一个进程内唯一的 id,并以弱指针注册,因此被丢弃的上下文菜单可以被回收。核心层将不可变的快照(platform.Menu)发送给后端,后端会:
- 构建原生菜单,并按归属者(应用菜单、窗口菜单栏、托盘、弹出菜单)跟踪原生条目,这样
UpdateMenuItem就能就地修改标签/状态,而重建的菜单会释放其条目, - 以原生方式执行编辑类角色(macOS 上交给 first responder,Linux 上用
webkit_web_view_execute_editing_command;在 Windows 上,页面保留它们的按键,由 WebView2 执行,而菜单项通过 DevTools 运行document.execCommand,粘贴时键入剪贴板中的文本),或者以SurfaceCommand的形式把它们发送给显示原生 UI 的窗口;其余一切则通过WindowHandler.MenuItemClicked(属于某个窗口的菜单)或AppHandler.MenuItemClicked(没有所属窗口的菜单)上报;核心层负责切换 checkbox/radio 状态、执行窗口与视图类角色,并携带菜单所属的窗口调用Click——即使此时获得焦点的是某个子菜单。
条目图标(MenuItem.Icon,由 NewMenuIcon 创建)以 *platform.MenuIcon 的形式传递;核心层会在内容相同的 PNG 的图标之间共享同一个值(按内容驻留、以弱引用持有):后端通过指针判断是否发生变化,并将图像解码一次(MenuIcon.Native),用 runtime.AddCleanup 或随显示它的菜单释放自己创建的资源。这些图标的代码由 NewMenuIcon(LinkMenuIcons)链接,而不是由后端的菜单链接。那些会保存快照、留待以后据此构建菜单的后端(Windows 的托盘、新窗口的应用菜单)会用收到的更新(Menu.Apply)保持快照最新。
macOS 会得到一个默认菜单栏(App、File、Edit、View、Window),正是它让 Cmd+C/V/Q 生效;其他平台除非应用自行设置,否则没有菜单栏。
页面自己的上下文菜单(context_menu.go、Page.OnContextMenu)则反方向而行:只要设置了监听器(WindowHandler.HandlesContextMenu),后端就会把 webview 即将显示的菜单描述为一个 platform.ContextMenu——其条目带有 Native 编号和 Name——并调用 WindowHandler.ContextMenu 询问。核心层把它们做成一个由模板条目构成的 Menu(Menu.transient,它不会刷新任何已安装的菜单),触发监听器,然后作答:若快照仍原样显示 webview 的条目就回答 Default,若被阻止则回答 none,否则回答该快照,且每个原生条目至多出现一次。应用给了 Click 的原生条目会以应用条目的身份留在快照里。后端未说明的目标信息,取自桥接层记录的页面 contextmenu 事件(context-menu 记录);它在同一连接上恰好在后端请求之前到达,且只有当其坐标点(乘以页面缩放后)正是该菜单的位置时才会被采用(框架内的点击永远不会传到页面的文档);新页面会丢弃该记录。macOS 和 Linux 就地修改原生菜单;Windows 则自行绘制修改后的菜单(参见平台差异)。它的代码由 OnContextMenu 链接。
剪贴板与拖拽数据(transfer)
transfer.Data 是剪贴板写入与原生拖拽共用的不可变序列化模型:有序的条目,带有可替换的字节表示、MIME 格式、文件/URI 列表以及惰性 provider。每次剪贴板写入或拖拽都会启动一个独立的 provider 缓存;格式探测不读取任何字节,而成功结果、错误以及被恢复的 provider panic 都只缓存一次。带类型的 Drag(value) 仍然是进程本地的,由核心层的临时拖拽注册表解析;它的 token 会被剪贴板写入拒绝。
clipboard.go 负责校验、主线程派发、provider 重入保护,以及延迟的、恰好执行一次的释放钩子。platform.Clipboard 接受快照、返回已实体化的读取结果、在不渲染的情况下探测格式,并刷新/关闭原生所有权。剪贴板 provider 属于应用,而不是某个来源窗口。退出时会先刷新它们再停止事件循环,收尾阶段释放剩余的 provider。在 Linux 上,必须有剪贴板管理器,数据才能在应用退出后保留;失败的 provider 会阻止刷新它提供的 offer。
macOS 会持有 NSPasteboardItemDataProvider 对象直到 AppKit 完成它们,刷新之后则改用即时生成的 NSPasteboardItem。GTK 使用应用自有的 selection get/clear 回调和所有权变更的世代检查;只用剪贴板的应用会绑定 selection 原语,而不需要 surface。Windows 与 OleSetClipboard/OleFlushClipboard 共享拖拽适配器的 IDataObject、虚表(vtable)、原生转换和引用计数对象;外部读取会持有 Win32 剪贴板锁。回调签名只分配一次,并按原生对象或 user data 路由。原生读取会复制字节,使应用数据的生命周期长于原生句柄。API 与格式映射见剪贴板与拖拽数据。
音频(audio)
audio 包独立于窗口和主线程:它既不经过 internal/platform,也不需要应用正在运行。播放器是受同一个互斥锁保护的发声部(voice,audio/mixer.go)。每个发声部从一个源读取帧——源要么是内存中的声音,要么是一个由自己的 goroutine 提前约一秒解码进环形缓冲的流(audio/source.go);混音器从不等待某个流,迟到的流就播放静音。发声部以自身采样率乘以播放速度,用 Catmull-Rom 样条重采样为 48 kHz 立体声,并在每次变化、暂停与恢复时以 5 ms 渐变其增益(音量与声像构成的 2×2 矩阵)。循环发生在源里,因此循环没有间隙;发声部在越过循环点后按长度取模统计帧数,得出 Position。
Position 表示发声部被听到的位置,而不是被混音的位置。混音器统计它混出的帧数,以及其中设备仍持有的最新帧:数量等于写入给它的帧数、上限为其延迟、从设备打开时算起(setHeld)。每个发声部在每段混音的起点和终点标记输出的帧号与自己的源帧号,并忘掉输出已经播放掉的标记;Position 在被听到的那一帧处对这些标记插值,因而能体现速度变化、等待解码器的流,以及正在播完的暂停。seek 或停止会丢弃这些标记,于是位置立刻变为目标处,并停留在那里,直到输出播放完此后混出的内容。
输出(audio/output.go)是一个锁定到单一线程的 goroutine:发声部开始时打开设备,每次混 10 到 21 ms 并写出——当设备持有量足够时写调用会阻塞——空闲五秒后关闭设备。audio.SetLatency 决定设备提前持有多少数据,40 ms 或 250 ms;该值变化时输出会重新打开设备。每个平台提供一个 openDevice,它接受这个延迟:
- macOS:一个 Audio Queue,缓冲区为 512 帧,数量取能容纳该延迟者(4 或 24 个),等所有缓冲区都填入音频后启动;其回调只创建一次,通过 channel 归还缓冲区。主题切换可能让应用的每个线程等待长达 0.2 s,4 个缓冲区撑不过这种停顿。
- Linux:使用 PulseAudio simple API 的
pa_simple_write(libpulse-simple,PipeWire 也提供服务),以其为目标缓冲量;否则使用 ALSA 在default设备上的snd_pcm_writei,两者都通过 purego 调用。 - Windows:共享模式的 WASAPI,事件驱动,缓冲区大小为该延迟,启用
AUTOCONVERTPCM和默认质量重采样器;每 500 ms 比较一次默认端点的 ID,一旦变化就让写入失败,从而重新打开设备。 - 浏览器:一个来自 Blob URL 的 AudioWorklet,排队 1024 帧的数据块(3 块,或 12 块),每用掉一块就发回一个请求;输出在混下一块之前等待请求,延迟变化时要么多请求、要么少应答。每个页面共用一个 AudioContext,使用它创建时的
latencyHint;关闭时挂起它,第一次指针或键盘输入会恢复它。
openDevice 失败时,一个空设备按墙上时钟消费这些帧,并在两秒后失败,以便输出重试设备;audio.Err 会上报该失败。格式像 image 那样注册魔数前缀和构造函数:WAV 内置,audio/vorbis 和 audio/mp3 封装纯 Go 解码器。
文件监视(watch.go)
WatchFiles 保存它对目录的观察结果(watch_scan.go):对每个文件和目录,按路径记录一个 40 字节、不含指针的 fileWatchEntry(os.SameFile 所比较的身份标识、大小、修改时间和模式),而不是它的 os.FileInfo——后者的 syscall.Stat_t 会被垃圾回收器扫描,一条要占 220 到 260 字节——路径和条目名则存放在其所属目录的集合里。读取通过 os.Root 进行,以防路径逃逸出去,且不跟随符号链接。 每个目录都作为独立的 os.Root 从包含它的那个 root 打开,其中内容按相对于它的名字查看,每项一次调用,就像 watchman 和 @parcel/watcher 用 openat 和 fstatat 遍历一样:一个 root 会依次打开更深路径中的每个目录。在 Unix 上,读取一个在 root 中打开的目录时已经顺带对其条目做了 stat(fileWatchEntryInfo)。重新查看全部内容时,每个目录都从包含它的目录列出。若一个目录被符号链接替换,而 root 只在自身内部跟随符号链接,那么无论链接指向哪里,都算作一次替换。 后端的监视器(platform.FileWatch)接收目标——即核心层登记的条目——并上报通知:某个目标,或某个目录目标按名字报出的子项发生了变化。通知只说明去哪里看,不说明发生了什么:去抖之后,核心层重新列出通知点名的目录并对文件做 stat,事件就是它现在看到的与之前看到的之间的差异(fileWatchDiff)。身份(os.SameFile)发生位置移动的条目是一次重命名,除非硬链接使其产生歧义;而随上层目录一起重命名移动的内容则不再多说什么。一个文件在其路径上被另一个文件替换——就像原子保存的替换方式——算作写入,而不是删除加创建:否则跟踪文档的应用会以为它被删了。条目的配对通过查询其身份完成(fileWatchIDs:设备号与 inode,或在 Windows 上是 os.SameFile 读取的卷号和文件索引),因此一个含 n 个文件的目录被重命名只需 n 次查询,而不是 n² 次比较;身份读不出来的条目则要与所有其他条目比较。当系统丢失了变更(inotify 队列溢出、Windows 的 ERROR_NOTIFY_ENUM_DIR、FSEvents 的 MustScanSubDirs 或被丢弃的事件)时,核心层会重新列出每个目录,而不是停止。登记会与扫描结果核对:后端打开目标并将其身份与扫描所见比较,随后扫描再对它做一次 stat,这样一来,中途被替换的条目(编辑器的原子保存)会被重新查看,而不是替换掉另一个条目继续被监视。
被监视的文件经由其所在目录来监视:登记为 ..、文件本身为 .——扫描的 os.Root 就是该目录,目录上报的其他条目的通知会被丢弃。在其路径上被替换的文件会上报为写入。Ignore 在条目登记之前根据目录列表给出的类型询问,因此被忽略的目录永远不会被打开;每个被列出的目录各自记录它忽略的目录,以便把 Subtree 目标关于这些目录内容的通知丢弃。
HintsOnly 把扫描换成 fileWatchHints(watch_hints.go),仍在监视 goroutine 驱动的同一个 fileWatchLooker 后面:没有快照,只有它登记的目录。点名某条目的通知暗示了它所在的目录;点名某个已登记目录、或该目录自身的通知,会让下一次更新检查它是否仍是当初登记的那个,若不是(被移动、删除或替换),就丢弃其下的目标;一个可能是新目录的名字会连同其下内容一起登记。溢出会暗示 . 并重新登记整棵树。彼此嵌套的提示会折叠为最浅的那个。在 Linux 上,目录连同 getdents 给出的类型一起列出(watch_hints_linux.go),notify 遍历时就是这样,因此从不 stat 文件;在其他平台上,递归监视只是根目录的 Subtree 目标。该模式需要显式启用,因为它放弃了快照的用意:精确的事件、重命名、写入与元数据变更的区分,以及具体是哪个文件。
扫描无法查看的条目——没有权限的目录,或被 Windows 占用的文件(pagefile.sys,共享冲突)——会按其原样(如果曾见过)保留,其中的内容不被监视,而不是终止整个监视;只有被监视的目录本身必须可读。
Next 返回一批事件,即一次更新产生的事件。当一批事件在等待 Next 时,更新不会运行(taken 会告知 goroutine 何时被取走):通知仍会标记要查看的内容,这个集合在超过 MaxEntries 后退化为查看全部;因此慢速读取者不会丢事件,也只持有一批。目录消失会以 . 的 FileRemove 加 io.EOF 结束监视,Close 和监视的 context 也是如此。
监视的 goroutine 是其扫描和后端监视器的唯一使用者,而后端监视器的方法由 internal/watchdriver 在自己的 goroutine 上运行:它在等待系统事件之间增删目标(Wake 可打断等待),并用系统上报的内容调用核心层的 notice;notice 通过 channel 把它交给监视的 goroutine,channel 满了就让监视重新查看全部。两者都不使用主线程,因此监视在应用运行之前就能工作,也不必等待它。
- macOS:每个目录目标一个 FSEventStream,覆盖其下整棵树,因此不需要为每个文件打开任何东西,文件目标则由其所在目录的 stream 覆盖。位于某个已在监视其所在树的 stream 之下的目标不需要自己的 stream,这使得非递归目录的监视只保持一个 stream。stream 会点名变更内容(
kFSEventStreamCreateFlagFileEvents)并监听根目录自身(kFSEventStreamCreateFlagWatchRoot),通过启动时创建的唯一回调投递,运行在进程的某条串行 dispatch queue 上,绝不在主线程。Subtree目标上报任意深度的路径;不带Subtree的会丢弃比其条目更深层的变更。stream 的路径用realpath解析,因为 FSEvents 点名的是磁盘上的实际路径,而调用方给的路径在大小写不敏感的卷上可能与之不符。通知只是提示:MustScanSubDirs或队列满会让核心层重新查看全部——就像其他平台上的溢出一样;stream 的 10 ms 延迟低于核心层的去抖,应用看到的合并效果由去抖完成;使用kFSEventStreamCreateFlagNoDefer时,静默期之后的第一个事件立即到达,其后在延迟期内的事件被打包。stream 以SinceNow启动,但 fseventsd 可能上报它启动前一刻发生的变更;核心层的快照是在登记目标时拍摄的,因此这不会造成问题。 - Linux:整个进程一个 inotify 实例,与 fsnotify 相同,每个目录一个 watch,由它点名变更的子项:watch 消耗的是 watch 描述符而不是实例,许多系统对所有应用合计只允许每用户 128 个描述符。一个 goroutine 通过运行时的 poller 读取它,并把每个 watch 的通知排队,由其 driver 的
Wait返回。同一个目录被添加两次——无论在一个还是多个 watch 中——会共享一个 watch 描述符;而一个已移除描述符在其IN_IGNORED之前仍排在队列里的事件,绝不能被当成新描述符的事件,因此后端为每个描述符保存目标的多个世代,每个世代含其 watch 与 ID;直到描述符的最后一个目标被移除时才真正移除它。 - Windows:在 overlapped 句柄上调用
ReadDirectoryChangesW,每个 watch 在自己的完成端口上完成。当一个目录之下有东西开着句柄时,Windows 拒绝重命名该目录,因此递归监视就是对其目录的一个Subtree目标,由它上报路径;其他后端对Subtree返回ErrUnsupported并逐个拿到每个目录。目录自身的句柄在它被重命名时不会报告任何消息,所以核心层同时监视其父目录,当父目录点名它时就去查看该目录。这些句柄不能交给 os 包,否则会被绑到 Go 运行时的完成端口上。已关闭的句柄仍会完成其 I/O,而 I/O 会写入它的缓冲区,因此一次会话要活到其完成为止。驱动器根目录没有可监视的父目录,也无法被重命名。被Lstat视为目录的才会被监视,这排除了 junction 和挂载点,但不排除云文件(OneDrive)的占位符——那些并非名称替身的重解析点(reparse point)。
后端只对内容上报写入(FileWatchWrite):FSEvents 中文件的 ItemModified(它的 ItemCreated——APFS 在 O_TRUNC 写入时也会置位——保持为脏,交由核心层的比较来上报变更)、inotify 的 IN_MODIFY 和 IN_CLOSE_WRITE,以及 Windows 上不含 FILE_NOTIFY_CHANGE_ATTRIBUTES 的变更。属性变化算脏,而核心层只比较大小和修改时间,因此索引器和杀毒软件触碰元数据不会上报任何东西。
文档(document*.go)
NewDocuments 的记账方式遵循 NSDocument 和 NSDocumentController,形态遵循 SwiftUI 2026 年的 Document(在主线程上快照,I/O 在 goroutine 上进行),并带有 VS Code 和 Zed 式的恢复副本。一切都在核心层;后端只负责显示窗口的文档(Window.SetRepresentedFile、SetDocumentEdited)和替换文件(Backend.ReplaceFile)。菜单只能通过第一类注册的那些函数(linkedDocuments)访问文档,这样没有文档的应用就不会因 File 菜单的角色和 Open Recent 而链接这些代码(约 290 KB)。
- 状态。
Document[T]内嵌docBase——不依赖内容类型的那部分状态——由同一个互斥锁保护:变更计数器(changes)、文件已保存到的变更(savedAt)与恢复副本(backedUp)、已编辑标志,以及最后一次读取或写入时文件的印记(修改时间和大小)。docOps是泛型那一半交给其余部分的:capture取出内容及其变更(带Snapshot时在主线程上),markSaved把写出的快照设为已保存的那一版(配合Equal,只有内容不同文档才保持已编辑状态),因此写入期间的编辑仍保持未保存——正如 NSDocument 的变更计数令牌和 Zed 的did_save所做的那样。 - 写入文档文件及其恢复副本时持有其
io互斥锁,磁盘检查同样要拿这把锁,因此监视永远不会把文档自己的写入误认为别的应用的。writeFileSafely先写一个同目录的兄弟文件、flush,再通过后端替换目标文件(NSFileManager、ReplaceFileW、Linux 上带模式和属主的 rename);对于硬链接以及用户无法在其中创建文件的文件夹,则就地写入。保存和自动保存会先 stat 文件:印记与已知不符即为冲突。 - 节奏。 每个文档一个定时器,在编辑停止后触发:就地保存的文档用自动保存延迟,恢复副本用一秒;并会缩短,使得持续编辑、没有停顿的文档至少每 30 或 5 秒被写出一次(
dirtySince)。 - 恢复副本存放在
PathUserData/Recovery/<kind>/<session>,每个运行中的实例一个会话,并带有副本索引,实例运行期间用flock或LockFileEx加锁;启动时,实例接管它能拿到锁的会话,以已编辑状态重新打开其中的文档(文件印记变了则视为冲突),把它们写入自己的会话,然后删除旧会话。 - 监视。 打开文档的每个文件对应一个
WatchFiles,按引用计数共享;无论文件夹里还有什么,开销都相同:一个监视覆盖整个文件夹,而不是其中每个文件。一批事件会让文档检查磁盘(checkDisk),窗口获得焦点时同样如此,监视启动之后也会来这么一次。遇到删除时,会在文件夹里寻找该文件的身份,以便跟踪重命名。 - 关闭与退出从不嵌套事件循环:文档窗口的
OnClose会阻止需要保存或询问的关闭,在 goroutine 上完成这些事(macOS 上对话框以 sheet 形式呈现),然后销毁窗口。退出的OnBeforeQuit监听器会阻止带已编辑文档的退出,就地保存它们、写出它们的恢复副本或询问用户,然后再次调用App.Quit(docQuit.resolved放行这一次),而在退出过程中关闭的窗口不再询问。 - 启动。 启动参数送达之后
whenLaunched运行一次:各文档类型恢复其恢复副本,打开OnOpenFile排队的文件,然后,若没有任何文档打开,就新建一个未命名文档。 - 菜单。 文档角色先于窗口角色交给
documentRole处理;当焦点或文档状态变化时,refreshDocumentMenus为聚焦窗口的文档启用这些角色;updateRecentMenus依据recent-documents.json(recent_docs.go)重建 Open Recent 子菜单——该文件由App.AddRecentDocument和ClearRecentDocuments写入,对文档应用和其他应用一视同仁,写完之后才通知系统;在没有文档的应用里,相关条目会转给OnOpenFile。
原生 UI(ui)
公开 API 为每个窗口保持一个稳定的 *Context。子构建器共享它并临时改变其父级。Element 是一个 16 字节、带校验的值:直接的属主记录、arena 槽位和 32 位世代号。属主在关闭时与引擎分离,并在世代回绕之前退役。旧元素不可能与回收的节点混叠。开发构建和 Tester 会诊断对过期元素的使用;生产环境中相应方法返回空结果或忽略它。Handle 为每个窗口单独保存持久的控件标识。焦点查询读取标识而不依赖构建顺序;焦点请求会等待一个隐藏的控件。Services 提供持久的剪贴板、URL 与重绘访问。
构造函数即时构建并设置控件样式。Context.Key 在状态初始化之前提供 ID。控件像 Dear ImGui 的 widget 那样,以其公开构造函数返回的形式接收输入,因此它绑定的对象——包括局部副本——在下一条语句读取时保有这次编辑(value_input.go)。生成的外观(facade)用 beginBuild 和 endBuild 包住每个构造函数;控件排队的输入会记录当时有多少构造函数正在进行,endBuild 会完成那些比仍在进行的构造函数排得更深的输入:容器的输入(单选按钮组的方向键)会先搁置,直到构建其子项的调用返回,且每个条目只处理一次。在公开构造函数之外——由引擎自身的控件——排队的输入会立即完成,剩下的在构建之后应用。输入遵循用户操作所在的那次构建的选项:构造函数在保留上次构建的选项(编辑即按其生效,editor.withShown、sliderSettings.shown)之后,为本次构建重置其链式选项(ReadOnly、OnPaste、滑块的 Step),而保留下来的 flagDisabled 属于上次构建。Changed、Submitted、点击与快捷键查询在作答前先应用已完成的输入。 ComboboxParts.Chosen 在同一轮中应用输入并报告选择;它不会带入之后的重建。OnChange 和 OnSubmit 在构造和绑定输入之后、重建之前运行。在进入另一轮之前会清除通知,以免一次编辑在新的局部绑定上被重复报告。回调动作只消费一次输入,并在绘制前重建。 私有渲染树使用 node 和 context;internal/uigen 生成带校验的公开外观。FocusBind 把期望的焦点绑定到应用数据;FocusedValue 读取实际焦点,独立于隐藏控件的待处理请求。
关于破坏性的元素 API、mygo migrate-ui,以及 mygo vet 中基于 Go 类型的生命周期检查,参见迁移指南。
设置了 WindowOptions.Content 的窗口显示由 MyGo 自己绘制的用户界面,而不是网页。分层与其他地方一致:工具包(ui)是位于平台契约之上的纯 Go,后端只提供可供绘制的 surface 及其输入,渲染器对两者都一无所知。
view (Go) ──► ui: build, layout, paint ──► internal/scene ──► internal/gpu: d3d11 | metal | gl, or internal/raster
▲ └─ internal/text: DirectWrite | Core Text | Pango | canvas, glyph and mask atlases
│
internal/surface.Conn ◄── content.go (package mygo) ◄── platform.Surface: darwin | linux | windows | web | fakeSurface。 设置
platform.WindowOptions.Surface后,后端会创建一个由 MyGo 绘制、取代 webview 的视图:macOS 上是一个带 layer 的 NSView,Linux 上是一个 GtkGLArea(在 OpenGL 无法在 GPU 上运行的地方用 GtkDrawingArea),Windows 上是一个类名为MyGoSurface的子窗口。platform.Surface提供其原生句柄(用于交换链、layer,或在 GtkGLArea 的render信号绘制一帧时——此时其 context 为当前上下文)、尺寸与缩放,以及显示它的显示器的刷新率(RefreshRate:屏幕的maximumFramesPerSecond、GDK 的gdk_monitor_get_refresh_rate、从EnumDisplaySettingsW取得的显示模式频率);请求一帧(RequestFrame:一个处于暂停的CADisplayLink,其 tick 自行驱动连续绘帧,而暂停后的第一帧会立即绘制,在 run loop 的同一轮中,从后端的队列(drawSoon)取出;尺寸变化的请求则在 AppKit 显示视图时发出;macOS 14 之前用按显示器频率的NSTimer;GTK 用gtk_widget_queue_draw或 frame clock 的 tick 回调;Windows 用InvalidateRect);呈现由 CPU 绘制的像素(PresentPixels:把 CGImage 作为 layer 的内容、在draw信号里用 cairo、在WM_PAINT里用SetDIBitsToDevice;Linux 上还有PresentDamage,附带变化区域);设置光标;并在插入符处开启和关闭输入法(NSTextInputClient、GtkIMContext、IMM32),连同其周围文本,每侧最多 512 个 rune(SetTextInput)。其余一切经WindowHandler.SurfaceEvent传来,以 DIP 为单位:帧、尺寸变化、指针、滚轮、按键、文本、组合输入、焦点、菜单的编辑角色(SurfaceCommand)、拖入与放下的文件,以及辅助技术。鼠标的后退/前进按钮作为按键KeyBack和KeyForward传来,带这类按键的键盘也是如此:macOS 上otherMouseDown:的按钮 3 和 4,以及swipeWithEvent:的滑动手势——向左为后退,与 Safari 相同,触控板翻页滑动和鼠标厂商软件为其按键发送的就是这个(Logi Options+);Linux 上的按钮 8 和 9 以及XF86Back和XF86Forward;Windows 上的WM_XBUTTONUP(需接管,以免 DefWindowProc 又发一条WM_APPCOMMAND)、VK_BROWSER_BACK和VK_BROWSER_FORWARD,以及WM_APPCOMMAND的浏览器命令。输入法组合期间键入的按键只归输入法所有,比如用 Enter 选择候选词:macOS 上存在标记文本时keyDown:不发送KeyPressed,正如 Linux 上 GTK 的输入法会过滤这类按键、Windows 上 IMM32 把它们变成VK_PROCESSKEY。输入法会指明文本与组合输入替换插入符周围文本的哪一段(Replace、From、To):NSTextInputClient 的替换范围、GtkIMContext 的delete-surrounding、IMM32 的重转换(IMR_CONFIRMRECONVERTSTRING)。拖过 surface 的文件(NSDraggingDestination、GTK 拖放目标、OLEIDropTarget)产生FileDragOver事件,其应答由拖拽源展示;放下则产生FileDrop;内容未接收的文件转给OnFileDrop。在 Linux 上,该区域直接接收触摸屏的触点(GDK_TOUCH_MASK,在其event信号中处理,赶在 GTK 的touch-event处理器把第一根手指变成鼠标之前),touch.go把它们转成鼠标和触控板的动作:轻点就在落点点击,静止按住半秒则右键点击,如同 Windows 的按住不放;手指移动超过 8 DIP 就带着其下的内容滚动(Precise),两指则在其间缩放(PointerZoom)并随其中点移动而滚动。一旦该区域收到过触摸,来自触摸屏的按键与移动事件就会被丢弃。辅助技术。 一旦辅助技术索取 surface 的内容,后端就发送
AccessibilityOn,引擎随即把每一帧描述为一棵platform.AccessTree(ui/access.go):按前序排列的节点,带有角色、名称、值、范围、状态、边界和动作,经由UpdateAccessibility交出。后端为每个节点 ID 保留一个原生对象,并把两棵树之间的变化告知系统:macOS 上用NSAccessibilityElement的子类和 AppKit 的通知;Linux 上,在GtkGLArea或GtkDrawingArea子类的 accessible 之下,挂接通过 purego 注册的 GObject 类型的 ATK 对象,配合 ATK 的信号,由 GTK 桥接到 AT-SPI;Windows 上用 UI Automation 片段——带控制模式的 COM 对象,应答WM_GETOBJECT,并配 UI Automation 的事件。 辅助技术做出的操作以AccessAction事件传回,引擎像对待指针或键盘那样执行它们。AccessTree.Announcements是只朗读一次的文本(Context.Announce、提示条、路由):macOS 上以中等优先级向应用投递AXAnnouncementRequested;Linux 上用 AtkObject 的announcement信号(ATK 2.46),AT-SPI 将其承载为Object:Announcement;Windows 上则在树的末尾放一个承载该文本的 polite live 区域,触发LiveRegionChanged,如同 Flutter 的警报:UiaRaiseNotificationEvent返回了S_OK,但其事件到达不了任何客户端,不像提供程序的自动化事件。状态(status,如提示条)也是 polite live 区域(LiveSetting)。 应用自有的 widget 用元素方法设置基类所设置的东西(Checked、Mixed、Expanded、Value、Range、Level、ActiveDescendant)。在窗口内绘制的菜单对应 AppKit 的AXMenu、AXMenuBar和AXMenuItem,其选中项以AXMenuItemMarkChar显示(经由accessibilityAttributeValue:应答,AXInvalid也是如此);ATK 的 menu、menu bar、menu item、check menu item 和 radio menu item;以及 UI Automation 的 Menu、MenuBar 和 MenuItem,对显示选择的条目附加 Toggle。标题对应 WebKit 的AXHeading,其值即层级;ATK 的 heading 带level属性;UI Automation 的 Text 带HeadingLevel。竖直滑块会标明自身(AccessVertical:AXOrientation、ATK 的 vertical 状态、UI Automation 的 Orientation)。范围的步长——按键使值移动多远(AccessNode.Step)——对应 UI Automation 的 SmallChange(且 LargeChange 不小于它)和 ATK 的最小增量,经由get_minimum_increment提供,因为 purego 的回调无法为get_increment返回浮点数;AppKit 没有这个,其增量经由按键传达。只读文本输入是AccessReadOnly,不带ActionSetValue。只要元素的类重写了 setter,AppKit 就认为其值可写,不管isAccessibilitySelectorAllowed:怎么说,所以元素自己应答较旧的accessibilityIsAttributeSettable:——AppKit 本会如此处理,唯独这个值不行:NSAccessibilityElement没有可供调用的实现。List的行(列表项)和Table的行由内容命名,并说明自己在全部行中的位次(PosInSet、SetSize,列表给出总数),因为只有视内的行才会被构建;构建在视外的行是AccessOffscreen,滚动容器中的内容可按请求滚入视野(AccessScrollIntoView),列表的某一行按其位次滚入,这会构建它。选择行的列表以被选行为焦点:列表拥有键盘焦点时,树的Focus就是被选中的行,因此方向键移动选择时会被朗读,聚焦某行也会选中它,正如屏幕阅读器对活动后代(active descendant)的预期。macOS 上,列表是像 AppKit 那样的由行(AXTableRow)组成的表格(AXTable),带AXIndex、AXRowCount、AXVisibleRows、AXSelectedRows和AXSelectedRowsChanged,以及AXScrollToVisible——AppKit 旧式 API 的一个动作,元素此后要应答其全部动作;Linux 上,选择行的列表是带AtkSelection和selection-changed的 list box,行携带posinset与setsize对象属性,AtkComponent的scroll_to(ATK 2.30)负责滚动;Windows 上,行有PositionInSet、SizeOfSet、IsOffscreen、位于其列表Selection中的SelectionItem(触发ElementSelected),以及ScrollItem。可多选行的列表是AccessMultiselectable(ATK 的multiselectable、UIA 的CanSelectMultiple,行此后触发ElementAddedToSelection和ElementRemovedFromSelection,除非是被单独选中),其焦点位于最后选中的行。连接。
content.go通过internal/surface.Conn把内容挂到其窗口上;Conn 携带 surface,并以函数形式携带内容需要应用提供的东西(剪贴板、拖动窗口、外观、隐藏标题栏控件所占的空间、打开 URL、上下文菜单),因此ui既不导入mygo也不导入任何后端,不含原生 UI 的应用也不会链接这些代码。Window.Update和Invalidate把从任意 goroutine 发起的重绘请求合并为主线程上的一帧,由Conn.Changed向内容索取,让它重新构建。Page 的方法返回errNoPage或什么都不做。帧。 引擎(
ui/runtime.go)调用视图构建一帧;若处理函数在构建期间改变了状态,则再构建一次(至多三次),使帧呈现最终结果。在一帧期间关闭的窗口(点击关闭按钮;后端立即上报Closed)会把引擎保留到该帧结束:视图的剩余部分仍用一个可用的Context构建,不再运行任何回调,不做布局也不绘制,引擎随帧结束而关闭。在指针按下开始之际观察Pressed同样会在绘制前重建,因此按下产生的选中及其焦点颜色会一起出现;按住指针不会再要求额外的构建轮次。引擎用 flexbox(layout.go)或网格(grid.go,CSS grid 的条目放置与轨道尺寸算法,支持固定、fr 分数和内容自适应轨道)进行布局,只重新布局自上一帧以来发生变化的部分(见下文);把盒模型提交给元素的状态,连同按绘制顺序排列的命中列表、焦点顺序和标签;绘制出一个scene.Scene;并将其呈现。帧之间的输入作用于上一帧元素的状态。元素的身份由其父级的身份哈希加上其位置或Key构成,因此焦点、滚动偏移、编辑器和动画能在重建中存续。Context对窗口保持稳定;Element值在构建轮次之间会失效,并在访问存储前校验其属主、槽位和世代。ListState只在当前 context、帧和轮次内解析其焦点属主,暴露焦点与快捷键,而不把元素保留在应用状态里。滚动偏移也在布局中处理(ui/scroll.go):请求过ScrollIntoView的元素以及焦点,在盒子定位之前就进入视野,定位时则把每个偏移保持在内容范围之内;ScrollState双向镜像一个偏移,而移动了某个偏移的那一帧,会为读过旧偏移的部分再构建一帧。帧只在被要求时才发生:输入、Invalidate、After,或有东西在动时的AnimationFrame。 视图每帧构建整棵树,但布局会复用未发生变化的部分(ui/layoutcache.go),就像 Blink 复用同一约束空间内未被标记为需要布局的盒子的布局结果、Flutter 跳过约束与其上次布局相同的渲染对象一样。重建的树没有任何标记,因此在布局之前,sameLayout会把每个元素与其状态从上一帧保留的信息比较:它的layoutStyle(内嵌于node中、参与决定其布局的字段)、布局会读取的标志与继承的文本样式、其网格的轨道、其 span、其图像的尺寸,以及其子元素——相同顺序的相同元素,且每个再依次相同。随后,当某个相同的元素得到同样大小的盒子时,layoutBox复用它的上一次布局:其文本的布局与其子元素的盒子,而其间曾以不同尺寸布局过的子元素则重新布局;对它的测量结果(内容宽高)同样复用,于是重新布局的容器能立即测得其未变子元素的尺寸。文本元素的状态还保留其文本的宽度和最近两次布局(heldText),按参数重新找回,正如 Taffy 保留节点的测量值:一个重新布局、文本却相同的元素(比如 List 每帧的行)完全不用查询文本系统的缓存——该缓存的键做哈希的开销比做比较还大。网格只测量其列会读取宽度的那些条目(contributes):对已知可用空间的 fr 列则一个都不测,如同 CSS 的 minmax(0, 1fr)。其他容器的变换与滚动偏移在布局之后应用,不改变布局中的任何东西。布局所依据的信息多于其构建所依据的信息的元素从不被复用,其周围的一切也不复用:List(在布局时构建行)、Form、Table 的自适应列和工具栏(它们在布局时改变内部元素,因此其内部的一切同样不复用)、输入框、附着于另一元素的元素。给node添加字段时必须说明布局是否读取它(TestLayoutInputs);TestLayoutCacheMatches会在随机树逐帧随机变化时,分别启用和禁用缓存(engine.noLayoutCache)进行布局以比对结果。 仅由会动的绘制要求的那一帧(Painter.AnimationFrame、Painter.After,旋转指示器和不确定长度的进度条就是如此)会在自己的时间点重画上一帧的元素(repaintFrame),不构建也不布局;视外的元素不绘制,因此它们不发起请求。其余元素大多也不重画(ui/repaint.go):绘制整个窗口的那一帧会为每个索取帧的 Draw 或 DrawOver 回调保存它绘制的操作和当时的 painter(paintPart);只重绘的那一帧单独运行这些回调,把它们绘制的内容拼接到上一帧的场景中,替换它们原先绘制的部分(repaintParts)。当有其他任何原因要求重绘时(姿态在动、某绘制在往 layer 或 canvas 上画——engine.untracked会记录)、检查器显示期间、拖拽移动期间,以及当这些局部所新增的字形、stop 和词块比整帧原有数量多出partsGrowth那么多时,就整窗全量重绘。TestRepaintPartsMatchWhole将这些帧与全量重绘的帧比对(engine.noParts)。这样的帧在请求时会置上redraw,而 surface 的每个事件都会清除它,使该帧重新构建,以防事件改变了视图显示的内容;任何请求帧的东西也会清除它,Painter.After到期的帧也会连同该标志一起被所请求的帧取代:requestFrame、Conn.Changed(Window.Update、Invalidate,以及经由它们的After定时器)和外观变化。不请求帧的事件(比如指针在不予关注的元素上移动)会让那帧继续处于到期状态。若帧的尺寸不同,或文本系统已忘记其布局(text.System.Generation,当它释放元素布局所持有的字体时),则无论如何都要重新构建。上一帧构建时装定的定时器保持装定;由宿主装定(afterTimer、repaintTimer),Painter.After的则投递到主线程。 请求较少帧数的绘制(Painter.AnimationFrameRate)会让这些帧按显示器刷新的一定间隔到来,取能满足其每秒至少那么多帧的最小间隔(engine.pace),除非窗口中另有东西要求每一帧都来:Painter.AnimationFrame、移动中的姿态、视图动画。其间的刷新什么都不画(paceSkips),除非自那之后有别的东西请求了帧;速率会告知 surface(platform.FrameRateSurface):macOS 设置其 display link 的preferredFrameRateRange,使 ProMotion 显示器在其他东西未要求更高频率时按其刷新。对于请求的帧立即到来而非等到下次刷新的 surface(platform.ImmediateFrameSurface:Windows,其呈现机制自带节帧),若照原样会一遍又一遍地请求直到帧到期:改由重绘定时器在到期时请求,它带来的帧保持节拍的时序,因为定时器触发稍晚。当显示器速率未知时(浏览器),帧在每次刷新到来。 当窗口的任何部分都不可见时(platform.OccludableSurface:macOS 上被隐藏、最小化或被其他窗口遮挡的窗口,其 display link 仍在 tick,前半分钟按显示器速率,之后约 40 Hz),会动的东西不请求帧,由状态变化请求的帧则挂起(holdFrame;两者都算held),正如浏览器不为不可见的窗口绘制、苹果对 Mac 应用的要求:view 函数、Draw以及After驱动的工作都等待,直到窗口有部分显示出来。SurfaceShown在窗口重新露出部分时发出(windowDidChangeOcclusionState:),绘制发生变化的内容,并让挂起的动画从当前时间所处的位置继续;视频(VideoPlayer)则从它停住的地方继续,从那时起计时(engine.shown),就像视图停止构建时那样。视频的帧在 goroutine 上解码,提前两帧,解码进交还回来的帧缓冲供再次解码;视图取走最后一个已到期的帧,跳过其余的,让位图显示它(Bitmap.Update),并用After请求下一帧;若还没有帧到期,则等待 goroutine 唤醒窗口(waiting)。在那之前窗口显示它的上一帧,大约持续一次刷新的时间:期间的抓取(比如在视频通话中共享该窗口)看到的就是那一帧。输入之后仍会构建帧(input,比如按键打到被其他应用遮挡的 key window 上),辅助技术开启时也构建,CapturePage也构建——它会构建被挂起的帧;而在窗口的第一帧到达其 surface 之前没有任何帧会挂起(windowHost.occluded),被设为隐藏的窗口要等到准备好显示为止。窗口获得或失去键盘焦点时,只有上一帧读取过焦点才会绘制一帧(windowFocus,正如焦点环、插入符、选区颜色或聚焦文本输入的 IME 状态会读取它):窗口隐藏再显示时,不会重画任何画出来相同的东西。每一帧在 macOS 上都很要紧:暂停之后的第一个编码了内容的 command buffer 会让 Metal 驱动占用进程 30 到 45 MB 的内存足迹("owned unmapped (graphics)"),并在最后一个之后一两秒归还,纯 Metal 应用也是如此。 一旦视图构建出它以前构建过的东西,一帧几乎不再分配内存:元素来自 context 的 arena,默认主题每轮复制一次,被剪除释放的状态交给新元素(正如进入列表视野的行接手离开视野的行的状态),而不会保留其局部资源、编辑器或回调。arena 以 32 个元素为一块,在出现更小的帧之后释放未用的块,并保留一个空块以备增长;未用的元素和退出过渡的备用 arena 在帧复制完所需内容后交出它们的引用。文本在其状态中保留它对 span 的加工结果(spanCache:span 的文本、其布局样式的编码,以及每个 span 的结束位置),帧只需比较而不必重新加工。设置MYGO_FRAME_STATS后,慢于其阈值的帧会记录各部分耗时、由哪条路径绘制、进程分配了什么,以及垃圾回收器是否运行(ui/framestats.go)。视觉变换与运动(
ui/transform.go、ui/motion.go)。元素的变换——二维,或以 4×4 矩阵(mat4)表示的三维,像 CSS 的 flat transform-style 那样压平到其父级的平面——在布局之后作为射影变换与其祖先复合(scene.Affine,透视时带 G 和 H);盒子保持其原始布局空间。绘制时把复合变换存入场景操作与裁剪,提交时把它保留在状态上,供逆映射的指针输入、插入符、无障碍边界以及附着于视觉边界的覆盖层使用。CPU 渲染器对变换后的图元和裁剪做像素逆映射,并略去射影变换将其置于观察者背后的部分。GPU 构建器保留局部几何,并随每个实例发送变换矩阵的三行;顶点着色器在齐次坐标下投影四边形,使局部坐标在透视中正确插值;片元着色器在设备空间中积分覆盖率:仿射变换用屏幕导数,透视时则在 CPU 渲染器的采样点处——由像素中心经逆变换映射回来。嵌套的变换或圆角裁剪使用缓存的遮罩,与其内部移动的内容无关;以绘制坐标定义的硬裁剪在 GPU 上求值。背景效果把局部采样映射进画面。有 GPU 时,每一帧都在 GPU 上绘制,包括变换内容静止时的变化。Metal 在变换下保持宽色域颜色。渲染细节见变换。运动(
ui/motion.go):元素的每个动画值(AnimateTo、Animate、AnimateWith、Spring、Jump)都是按键索引的channel,带有值、速度及其保持的时间,沿曲线或弹簧运动。弹簧以解析方法求解阻尼振荡器(solveSpring),因此无论帧相隔多远都遵循同一条路径;channel 在接受新目标或新动画类型之前,先按帧的时间采样其路径,于是位置和速度能在中断中存续(曲线的速度即其缓动的斜率)。延迟的目标在 channel 中等待,而旧路径继续运行。Jump设定值并从连续的值导出速度;按下操作保留其最近的移动(dragTrack)——在元素的坐标轴和窗口的坐标轴上——供DragVelocity和DragEnded使用。关键帧序列每个键只保留一个序列,并带显式的重放令牌。所有这些只在运动时请求帧,并在减弱动效下直接结束于目标值。姿态(ui/pose.go、AnimatePose)是在帧提交时被采样的 channel,化为叠加在元素自身变换之后的变换(poseM)和一次淡入淡出;只重绘的帧(repaintFrame)会移动上一帧构建时带姿态的元素(repose)并重新设置其子树的变换(updateWorld),使它们在视图不运行的情况下也能移动。Draggable(ui/draggable.go)为一个偏移保留两个 channel(dragRec),showPose把它们加进姿态的平移:当指针拖动元素时,它们保存按下点在窗口中的移动(dragTrack)——经父级最后的世界变换之逆映射到父级坐标轴——从按下抓住处取出的偏移,因此它们在帧的每次构建轮次中都相同;一旦松手,弹簧以松手时的速度出发,奔向Snap从预测的抛掷中选定的位置,并在只重绘的帧中继续运行,直到静止时视图再构建一次。内容过渡(ui/content.go)保留元素此前显示的内容,并在绘制时将其淡出或滚动换成新内容,计数的是绘制次数(engine.paints)而非构建的帧数,因此只重绘的帧也能推动它们。过渡(
ui/transition.go)以 FLIP 方式为元素做动画,发生在每帧布局之后、place把盒子换算成窗口坐标之前:设置了Transition的元素按其 ID 保留布局给它的位置(相对其父级)、尺寸及其显示内容,并在过渡期间从它此前显示的样子过渡到新的位置或尺寸(父级先行,因为父级每变一个尺寸就重新布局其内容,内容随后自行移动)。随父级或滚动偏移一起移动不算变化;调整窗口大小不做任何动画,减弱动效下只做淡入淡出和颜色。每个运动的分量(位置、尺寸、不透明度,以及Motion的缩放与旋转)都是一个运动channel,因此弹簧能在变化中保持速度,延迟则把它们按住。颜色随元素绘制而变动,在其styleFn之后。Match让一个在place之后出现的元素(animateMatches)移动到上一帧中相同身份的另一个元素显示过的位置(rt.matched,窗口坐标),沿途在每个盒子处重新布局它,跨越父级与滚动偏移。元素只有在其父级上一帧就存在时,才带着它的Enter出现。一个不再被构建、但其父级仍在构建的元素,以惰性副本的形式带着它的Exit离开,在其兄弟之下布局和绘制、不占空间:构建过带Exit元素的帧把自己的 arena 留给下一帧,下一帧在另一个 arena 中构建(Context.spare),于是再下一帧能复制那些已消失的元素(ghostOf),带有各自的状态且没有任何东西引用应用的状态。兄弟不会围绕一个正在变化的尺寸重新布局;它们随自己的过渡移动,共享同一过渡时步调一致。检查器(
ui/inspector.go、inspector_details.go、inspector_panel.go、inspector_overlay.go),如同 Chrome 的开发者工具。开启了 DevTools 的窗口(surface.Conn.DevTools,来自PageOptions.DevTools)可以通过 Toggle Developer Tools 角色(Conn.ToggleDevTools)、F12 或 Alt+Cmd+I(Ctrl+Shift+I)打开它,用 Shift+Cmd+C(Ctrl+Shift+C)进入选取模式。它是一个用视图自身的元素构建的面板,作为根节点的最后一个子元素,带有 key;它使视图变窄了面板的宽度(视图看到一个更窄的窗口);命中测试和绘制则覆盖整个窗口。它的主题是默认主题的一份副本,采用 Chrome 的配色,分浅色和深色,其强调色标示树中选中的行。每帧布局完成后,它记下元素树、打开期间赋予元素的 key(rekey),以及被选中元素的详细信息(describe:其样式以 CSS 规则呈现,含自有与继承的,其盒模型、计算值和属性),当这些内容的哈希发生变化时请求另一帧,面板随即显示之;元素保持不变的帧不再绘制,而帧时刻(Performance 标签页按显示器刷新绘制的部分)不请求帧。树是一个List,列出所显示节点的开闭标签。选取模式在元素之前先接管内容之上的指针。绘制时在内容之上高亮悬停的元素——或在树拥有焦点时被选中的元素——并附一个工具提示。被选中元素的源码位置是它被创建或赋予 key 时调用栈上第一个 package ui 之外的栈帧(callSite),仅对该元素如此。同一父级下两个 key 相同的元素共享一个状态:rekey会上报(duplicateKey),记录一次并列为一个问题;在Tester中它会 panic(engine.strict),应用中不含 package testing。mygo build的生产构建用构建标签mygo_noinspector排除检查器(inspector_off.go顶替它,永不打开),该标签会加入GOFLAGS的标签,除非指定-debug或MYGO_INSPECTOR=1。自动化。
plugins/automation通过 Unix 域套接字(MYGO_AUTOMATION)从另一个进程驱动应用,与检查器一同受门控。核心层只给它一个钩子:插件的init仅在该变量设置时写入surface.Automate,而ui的AttachContent(windowHost.automate,在mygo_noinspector下为空)把每个窗口的surface.Conn和一个surface.Automated交给它——后者读取上一帧的无障碍树及元素的 key(node.key,rekey也会设置它),并判断是否有帧被请求而尚未到来(windowHost.frameAsked)。只有插件通过接口调用Automated的方法,因此不链接它的应用也不会链接这些代码。动作和输入像后端事件一样在主线程上进入Conn.Event;菜单项通过后端的测试钩子(TestPerformMenuItem、TestActivateMenuItem)选取;截图和脚本则通过Window和Page的方法。即时接收的输入。 带
HandleInput的元素在主线程上、在帧之前,按后端上报的原样接收输入(ui/handler.go):按键(连同抬起,ui 平时会忽略抬起)、拥有焦点时的文本、组合输入和编辑命令——除非其外围的Shortcut截走了该键;以及按在其上、在其上移动或在其上滚动的指针。一旦它接下一个按下事件,后续的移动和抬起就都归它,如同按下元素一样。它的TextCaret在该插入符处开启输入法,周围不带文本;InputMethod(false)(TextInputState.NoInputMethod)保留文本但关闭输入法,如同 winit 的set_ime_allowed(false):macOS 跳过interpretKeyEvents:并直接插入事件的characters,Windows 解除 IMM 上下文的关联并保留WM_CHAR,GTK 从 IM 上下文取走焦点并发送 keyval 对应的字符;浏览器做不到。ui.Shape绕过布局缓存来排版文本,供自持字形的 widget 使用,Painter.Glyphs则把它们摆放好的字形绘制出来。 文本输入的OnTextKey同样即时接收不产生文本的按键,排在HandleInput之后,但会先应用排给编辑器的输入(widgetTextInput.prepare,平台的查询也是如此),使回调读到的是该键之前已输入的文本;它接下的键与HandleInput接下的同样处理。焦点外围的快捷键会先行截键,除非编辑器需要它们(editor.wants),比如 Enter。HandleTextInput把自定义元素的应用自有TextInputClient接入同样的平台服务。一个稳定、经焦点校验的适配器提供绝对 UTF-16 文本范围、选择、标记文本、变更回调、范围几何与命中测试。它在被替换/释放时失效;焦点变化会取消旧客户端的标记并重置原生组合。显示元素时(焦点也会如此)滚动到客户端的插入符(clientCaret),而不是元素的盒子——对于一个高度等于其行数总和的代码编辑器,那会把整个文件那么高都滚进来。GTK 和 IMM32 使用有界的周围上下文,而 macOS 可以直接查询任意文档范围。ShapeText和ShapeRichText保留按段划分的TextLayout几何,包括其行(Lines),正如分页阅读器所需;应用自己拥有其缓冲区、选择、渲染、编辑和撤销策略。从右到左(
ui/direction.go)。每个元素都有方向(node.dir):构建时取父级的方向,根取窗口的方向——由surface.Conn.RightToLeft给出(packagemygo依据应用语言决定)——除非被MYGO_DIRECTION或检查器的开关(engine.dirOverride)覆盖;Element.Direction赋予元素自己的方向(dirSet),已在其中的元素随之采用(setDir);AttachTo和Overlay则让所构建的内容继承被请求处的方向。坐标系并不镜像(不同于 Godot):由布局从右到左摆放。Flexbox 翻转行的主轴(flipMain,Reverse再翻回来)和列的交叉轴(flipCross),正如row-reverse和wrap-reverse所为;网格从内容的右侧开始放置条目,内容宽度未知时其宽度即各列之和;绝对定位元素无偏移时靠右,且在相对定位中Right也胜过Left,如 CSS 所规定。逻辑边(PaddingStart、MarginEnd、Start等)在设置时写入元素方向对应的物理侧,并记下哪些是逻辑的(node.logical),以便方向后来改变时对调;逻辑锚点在放置时解析(Anchor.physical)。横向滚动的容器从起始端保持偏移(scrollLeft在放置、显示和滚动条处把它换算为物理偏移),比它窄的内容移到右侧,其滚轮和按键的水平增量取反(scrollBy),竖直滚动条放到左边(scrollBars)。文本获得text.Params.RTL——不含强字符的段落的方向——无论读向如何,行都从右侧起排(text.Right);而从左到右时,每个段落仍像以前一样从其读起处开始。widget 在构建时读取方向(node.rtl、arrows,帧之间的输入用state.rtl)以绘制和步进有方向性的东西;上下文菜单把platform.Menu.RightToLeft带给后端(NSMenu 的userInterfaceLayoutDirection,菜单显示期间还有 surface 视图的,因为 AppKit 那时会从视图的右上角弹出菜单;TPM_LAYOUTRTL | TPM_RIGHTALIGN;gtk_widget_set_direction加东北锚点;web 的dir)。无障碍无需更多:边界就是窗口坐标。偏好设置。
platform.Theme.Preferences读取控件会遵循的桌面设置:macOS 上是controlAccentColor和NSWorkspace的无障碍显示选项,连同它们的通知;Windows 上是 DWM 的AccentColor、SPI_GETCLIENTAREAANIMATION、SPI_GETHIGHCONTRAST和辅助功能的TextScaleFactor,连同WM_SETTINGCHANGE;Linux 上是 GTK 的gtk-enable-animations以及 settings portal 的强调色、对比度和 GNOME 的文本缩放因子(ReadAll、SettingChanged)。变化经由Handler.ThemeChanged传来,外观变化也是如此;packageui在下次变化之前只读取一次,默认主题跟随它们(Theme.follow),Animate跟随减弱动效。packagemygo以同样方式读取:Theme.Preferences()保留它们直到下次变化,还包含减弱透明度——packageui不跟随这一项。页面以ThemePreferences的 JSON 获得它们(其MarshalJSON,强调色为 CSS 颜色或null),也就是 mygo-runtime 的ThemePreferences:tsgen为它导入该类型而非自行声明(runtimeTypes)。桥接之后的一个用户脚本在文档开始时投递mygo:preferences事件;桥接保留最新值供preferences()使用,并设置--mygo-accent和--mygo-text-scaleCSS 变量,桌面没有强调色时则不设置该变量。当它们变化时,核心层向每个页面发送事件并替换那个脚本(Page.SetUserScripts:WebKit 的 content controller 会移除其全部脚本,WebView2 按它报告的 id 移除),这样后来加载的文档从一开始就用新值绘制。列表 (
ui/list.go) 只构建视野内的行,并靠某一行、锚点、以及它的顶部高出内容起点 多少来保持位置,而不是靠指向内容的偏移:行会被测量、变高、出现和消失(在其原处一千行 范围内凭键ListState.Key重新找到),而视野内的行保持不动。构建时,列表按其位置所指示、 就已知高度所能告诉的范围构建可见的行,再加上持有焦点的行和它钉住的表头;布局时 (layoutList,取代 flexbox),它测量这些行,从锚点、一个请求(ScrollTo)或它所跟随的 末端开始放置它们,构建仍然缺失的行,使任何一帧都不露出空隙,并让内容的两端贴住列表的两端。 测量到的高度存放在 64 行为一块的块中,随行被测量而创建,其余的按其平均值估算;内容的尺寸 和它的偏移量,就是这些高度放置各行所得的结果,以 float64 表示(所有滚动偏移都是如此), 于是数百万行能以 DIP 的分数滚动。用FollowAnimation跟随末端时,列表记下最后一行的顶部 被放在哪里;当行变高或新行到来,该行上移,各行显示在其位置下方一段距离,由一条 motion channel 把它带回零(glideToEnd),并按处于末端计数。行相对于列表放置,放置时的偏移量作为scrollBase保留,用于放置与滚入视野。滚轮、按键、滚动条或应用改变的偏移,若是步进(至多 两个视口),位置就移动同样距离,于是尚未测量的行按步进滚动;若是跳转,则在已构建的行被测量 之后,去往已知高度所放置的位置,而跳到末端就去到末端。一次滚入视野(焦点、ScrollIntoView) 从要显示的那一行起重新布局列表;列表重新构建时,窗口 ScrollState 设定的偏移按跳转处理, 每个元素都记得上一次放置它的ListState。滚动条的滑块从被抓住起保持当时的内容尺寸,直到 被放开,因为其间被测量的行会改变它;而移动了一个视图读过其Visible或AtEnd的列表的那 一帧,会请求再构建一帧。列表布局时构建的行像视图那样处理输入,layoutTree随后不再保留 这些处理,若它们改动了什么就请求新的一帧,并布局它们放进覆盖层的内容。ScrollTo显示在 顶部的行,会移到钉在其所在分节之上的表头之下。没有设定尺寸的列表,其高度就是各行的高度 (以已知高度为准);没有行的列表则像滚动容器一样布局其中构建的其他内容。Table是列表之上 的一行列头,其行高与其中最高的单元格齐平,并代列表接收焦点与按键。可选择多行的列表通过Selector接口的未导出方法,按键把所选行保存在Selection[K]中,使应用能读回自己带类型 的键;最后选中的行(光标:Selected,或列表自身的)以及 Shift 扩展所自的行,和选择一样 按键跟随它们的项。键入选择行经由引擎:未被任何快捷键占用的字母、数字和空格在获得焦点的 列表状态中累加(typed,一秒后重置),列表用它匹配ListState.Label。菜单。 上下文菜单和菜单按钮共用
ui/menu.go的机制:一个事件请求某个元素的菜单 (askMenu,附带它是否属于菜单按钮),下一帧运行该元素的菜单函数以收集条目,宿主在帧之后 显示系统的菜单(popupMenu),选中的条目在其后一帧回到那个函数,按位置和标签匹配。菜单 按钮在主按钮按下时打开并接住松开,如同 AppKit 与 GTK 的弹出按钮,Enter、空格和向下键 同样打开它。工具栏的溢出菜单在构建了溢出菜单按钮的那次遍历之后请求它的菜单 (askMenuLater),使下一次遍历构建它。路由 (
ui/router.go)。Router是一部条目历史,每个条目是一个位置(转义后的路径及其 查询串)和显示它的页面,视图每层一个:View的,然后是布局页面内每个Route.View,在 首次构建时创建。压入的条目与那些它保留了其路径部分的布局共享页面(每个条目记下它的各 布局取用了多少段路径),而同一路径的另一个查询串则共享全部页面。每个视图保留它显示过的 条目(对Route.View而言是路由,或布局页面的元素),于是一次变化只过渡页面改变的那一层。 视图在一个以页面为键、标记flagPage的列中构建所显示的页面:提交时给每个元素的状态盖上 它周围页面的印记(state.page),而prune在某个页面周围(经由嵌套页面)的状态是本帧 保留(engine.kept)的路由所构建的页面时,保留本帧没有构建的那些状态:即离当前显示的页面 最近的十个历史页面,布局的视图保留同一布局内的那些。 焦点不会留在被保留在视野之外的页面里。随着显示的页面改变,路由记住离开的页面中曾持有焦点 的元素,并把焦点移入新页面——移到它在那里记得的元素,否则移到页面本身(flagFocusTarget: 可获得焦点,但不是 Tab 停靠点)——除非页面的某个元素在构建时拿走了焦点(AutoFocus), 或者焦点本就在路由之外,那样它留在原处,并朗读页面的标题。路径更深的页面滑入,覆盖在离开 的页面上;离开的页面也被构建,但处于惰性状态(flagInert:绘制,但没有命中、焦点、标签、 无障碍,也不响应Context快捷键),绝对定位在流内页面之上,两者都按各自的 inset 偏移, 如同相对定位;其他页面则单独淡入。一帧中的第一个路由接管窗口的后退和前进键,每个路由也为 其中的焦点接管它们,焦点在内者优先。组合框 (
ui/combobox.go)。组合框是一个文本输入框,在覆盖层中带一个选项弹出面板,没有 遮罩:用户键入和挑选时输入框保持焦点,因为面板内的按下不会移动焦点(flagKeepFocus), 而输入框失去焦点时面板关闭。箭头所在的选项是输入框的活动后代,它在无障碍树中拥有焦点 (如同列表选中的行),并朗读出它是第几个、共几个。输入框周围的字段(组合框的、搜索框的、 标记输入框的)用它们的 Label 为其命名(nameFrom)。各后端把组合框当作文本字段处理 (macOS 的AXComboBox、带 AtkEditableText 的 ATK 组合框、带 Value 与 ExpandCollapse 的 UIA ComboBox);AccessSearch使文本字段成为 AppKit 的AXSearchField。披露控件 (
ui/collapsible.go)。可折叠控件的触发器是RoleDisclosure,打开时为展开态: AppKit 的AXDisclosureTriangle(打开时其值为 1);一个可展开的切换按钮,打开时同时处于 选中与展开态,如同 GTK 的 expander;一个带 ExpandCollapse 的 UIA Button。其面板用Animate动画Progress,并遵循减少动效设置:动画期间,面板被裁剪到其内容在上一帧高度的相应比例, 且其内容持续构建直到完全关闭。文本区域 (
ui/textarea.go,ui/textbuffer.go)。编辑器把文本保存为应用的值的字符串 (共享该字符串),并记录每个段落在 rune 与字节中的起始位置(buffer):当它仍是同一个字符串 时,帧直接与之比较值,而一次编辑只复制一次字节——此前把整篇文本在 rune 与字符串之间转换, 几 MB 就要耗上几毫秒。撤销保留每一步的改动(undoStep),而非文本副本;应用设置一段文本 时,最后一步变成相对其之前文本的一次改动,撤销会将其还原,于是应用不断设置的日志在那里只 保留两段文本,而不是一帧一段。被删除的片段持有自己的字节,被丢弃的历史和输入事件交出自己的 引用,所以删除一个字符不会留住整个旧文档。 段落索引按换行符数量预先分配;扫描 ASCII 时以单词为单位计数 rune,其他文本使用标准 UTF-8 解码器。一次小得多的替换会释放大号的段落索引和高度索引。字素边界取自插入点所在的段落。 文本区域逐段排版(area),反正文本系统本就要断行,从而各行与整篇排版所得的行一致:每个 段落保留其排版,直到某次编辑改变了它或宽度改变;视野内的段落从视图起始的段落(锚点)往下 排版,远离视野的段落交出其排版、保留其高度,排版持有其段落的文本,而不是让旧的文档字符串 存活。未测量的高度由已测量的估算。两棵 Fenwick 树,一棵存已测量的高度,一棵存未测量的个数, 无论估算如何,都能以 O(log n) 给出某段落的顶部和某一高度处的段落。区域像滚动容器一样滚动, 其偏移保存在状态里(flagScrollY,内容与各段落总高齐平),由锚点在视野上方的高度被测量时 维持;一次编辑、插入点移动或按下都会让插入点滚入视野一次。编辑分层。
ui/editor.go持有控件的编辑状态;editor_history.go负责增量撤销,editor_selection.go负责视觉范围集与插入点归属,editor_navigation.go负责按键与指针 手势,editor_layout.go负责布局与绘制。textinput.go构建公开的字符串控件。它们经由editor_input.go使用与自定义控件相同的TextInputClient约定:原生回调同步地查询和改变 状态,而控件在其下一次构建期间发布它绑定的字符串,保持组合控件中的变化传播。预编辑文本是 一段虚拟的文档插入,它的替换与提交构成一次增量撤销事务。段落索引携带 rune、字节和 UTF-16 的起始位置;有界范围的原生查询持有自己的字节,以免留住旧的文档分配。单行控件自行保留当前 排版,使不断变化的输入字符串不进入系统的显示文本缓存。internal/text/caret.go保留双向 边界与折行的两侧逻辑边缘,并按视觉顺序在完整字素之间移动。选择手势产生逻辑范围集:高亮、 复制、替换和撤销使用同样的范围,保留未选中的间隙。索引化文本存储。
internal/rope是一棵持久化的 AVL 树,节点是有界、自持的 UTF-8 块, 以字节、rune、UTF-16 与换行符计数作摘要;rope.Text是从中读取行、偏移与 UTF-16 范围的 值类型,并按批读取行;ui/text_buffer.go与代码编辑器的缓冲区都在其中保存文本。编辑会 复制受影响的块和树路径;快照共享其他块,导出可以流式读取它们。textbuffer_input.go中的TextInputBuffer/TextAreaBuffer把这些根绑定到既有的编辑客户端。原生变更立即发布新的根, 借助版本检查保留并发的程序编辑。外部的根变化会刷新控件并清除过期的历史。索引化缓冲区适配器 从文本树推导行首,而不是移动其后每个段落的绝对偏移。段落排版与高度缓存仍留在控件一侧; 换行符计数的变化会更新这些索引。完整文本只在显式导出/取值查询时生成,绝不作为每次编辑的 绑定更新。代码编辑器与差异查看器 (
ui/editor,internal/diff)。editor包构建在 ui 的公开 API 之上,如同ui/chart,ui 为它提供四个钩子:Painter.TextLine绘制带逐簇颜色的已排版行,Painter.Visible给出用于剔除行的裁剪区,KeepFocus在其补全和查找栏被按下时把焦点留在 编辑器,Access只在读取窗口时向辅助技术描述其文本。其设计追随 Zed:- 缓冲区 (
buffer.go) 持有一份rope.Text和每个版本改动(以字节和行、旧文与新文记录) 的日志。编辑器、高亮器和差异都按各自最后见到的版本号跟随缓冲区(sync):选择区、折叠、 诊断和缓存随其后的改动移动,在插入处带偏向,于是一份缓冲区可由多个编辑器和差异的两侧同时 显示。撤销保留每一步前后的文本,同一编辑器连续同类键入在 300 ms 内会合并成组,每一步都 记录发起它的编辑器的选择区。 - 显示 (
display.go,view.go)。每一行是一条记录行,折行时是多条,被折叠或被差异收起时 没有;块是记录行与记录行之间的行(差异的删除行、占位块、分隔条、注解)。某行的顶部是对 稀疏和的算术:折行多出的行数放在一棵 Fenwick 树里,块与隐藏行的高度连同它们的前缀和; 按高度用二分查找行。只有视野内的行被排版和整形,并像 Zed 的行排版缓存那样保留两帧。折行 的测量优先在视野附近用缓存的字符步进宽度进行(等宽 ASCII 就是算术),每帧完成一份预算, 其余位置用估算。 - 高亮 (
syntax.go)。Highlighter跟随缓冲区的编辑,逐行给出词元;默认实现从上一行结束时 的状态开始对本行分词(lexer.go、lexers.go),保留各行状态,编辑之后只向下继续,直到 某行的起始与从前一致。可选接口把解析器的结构提供给 Select Larger、折叠、括号和缩进,使 tree-sitter 可以作为Language.Highlighter接入而无需改动编辑器。括号匹配靠按批扫描行 (rope.Text.EachLine),依据词元跳过字符串和注释,且只对含括号的行做分词。 - 差异 (
internal/diff,diffmodel.go)。行被驻留(intern)后用 git 的直方图算法比较, 代价超出 xdiff 的上限时退回 Myers 算法,再按 git 的缩进启发式滑动对齐;改动的行按相似度 配对,再对其词做差异,相隔一个字符的词合并(Pierre 的 word-alt)。一次编辑只重新比较它 触及的 hunk 所在的区域,并向周围未改动的行扩展。分屏视图是两个共享滚动的编辑器,其行用 占位块对齐;统一视图是一个显示新文本的编辑器,由块从旧文本的视图绘制被删除的行。未改动 的连续行收起为分隔块之下的隐藏区间。 - 部件 (
parts.go,diffparts.go)。文本周围的条与弹出面板(查找、跳转行、补全、悬停) 以及差异的分隔条、改动操作和侧边槽按钮,都是编辑器放置的元素,各自带上所需的行为,如同 ui 的各个基元:shield把按下的事件交给其内部内容而不是编辑器的处理器,KeepFocus让 键入留在编辑器。每个部件由应用的渲染函数填充,否则用导出的默认实现,渲染函数拿到部件状态 与操作的句柄(FindBar、DiffSeparator等),编辑器在构建后读回其中的值,比如键入的查询。 分隔条由差异视图横跨两侧构建,占据其块留出的空间。
- 缓冲区 (
Markdown (
ui/markdown),与ui/editor一样构建在 ui 的公开 API 之上,其分词器为其中 的代码高亮。解析器ui/markdown/parser不导入任何 ui 的东西,使ui/richtext无需视图即可 读取 Markdown;视图只经它导出的方法访问它(Source、Footnote、Block.Revision与Healed)。它遵循 cmark-gfm 的两阶段流程,在带字节偏移的扁平块与行内元素上进行:一个带 打开块栈的块解析器,逐行处理,在添加块之前关闭那些该行不再匹配的块,使块按文档顺序排列; 行内元素在每块第一次被询问时解析,带有 cmark 防止二次方输入的防护(记住未闭合的开启符栈底、 反引号串、注释和数学)。追加文本会从最后一条完整行之后最外层的打开块起截断重建(restart), CommonMark 使这成立:没有规则会向前看,而重写更早块的规则只涉及打开的块;不完整的最后一行 从不决定什么被闭合。定义的建立或撤销会让查找过其标签的块重新解析行内元素,而重新解析的块, 若文本相同,其行内元素会被复用。一次编辑从其前面的顶层块开始解析,直到某个顶层块在从前有 打开块处、如今无块打开地开始(quiet),然后把旧的后续块平移后拼接回来。流式输出用解析器 留下的开启符补全尾部的行内元素(heal.go),并扣住仍可能变化的行。视图在根元素的局部状态中 保留文档,并按块保留每个段落的跨度,链接作为行内Link元素,代码块的词元按行、文本按 32 行一块保存;它以一定节奏显示文本,用跨度颜色与元素不透明度做淡入,这仅影响绘制。Selectable根的CopyData与SelectedTexts把段落的 rune 映射回源码偏移,以复制带块分隔符的文本、 Markdown,以及该 Markdown 重新解析出的 HTML;ui/richtext粘贴时带上其格式,因为它用同一个 解析器读取 Markdown(ParseMarkdown)。链接只有在ui.SafeToOpen允许时才在系统中打开, 两者皆然。富文本 (
ui/richtext)。richtext包同样构建在 ui 的公开 API 上编辑带属性的文本,如ui/editor:一个可获得焦点的盒子,带文本输入客户端、HandleInput、Draw、Access和它 自己的滚动,ui 为它提供四个钩子:ShapeRichTextWith对齐并缩进段落的各行,Element.SetChanged把它的编辑上报为Changed,Services.ReadClipboardData与WriteClipboardData传递其格式,InputSelect接收辅助技术做出的选择。- 文本 (
text.go,seq.go)。Text是一个值:它的字符存在internal/rope树里,旁边是一 条其段落的持久化序列(一棵以 32 为块大小的 AVL 树,seq),每个段落有身份、样式和它的 run:覆盖其 rune 的属性,最后是换行符的属性,空段落会键入它,如同 Word 的段落标记。run 保持最大化合并。一次编辑拆分并连接两棵树,复制它触及的块与路径;合并或拆分的段落取其首个 rune 来源段落的样式,如同 AppKit 的fixParagraphStyleAttributeInRange:,但应用的ID除外——它跟随文本,绝不来自插入的内容。列表是扁平的,每个条目一层缩进,如同 Quill 与 Automerge 的做法,为 CRDT 留出了空间。 - 文档 (
document.go,commands.go)。Document持有一份文本、选择区、键入属性,以及撤销 的步骤——每步前后的文本,它们共享所有未改变的部分,连同每步改变的区域与选择区。一份按 版本记录的改动区域日志,使编辑器只重新布局应用的改动触及的段落。键入时按 NSTextView 的 方式推导键入属性(插入点前的字符,段落起始处用其后的字符,链接、代码和标签只在内部生效), 这些规则经一个针对 NSTextView 的 Swift 探针实测得出。 - 编辑器 (
view.go,edit.go,input.go,ime.go,paint.go)。它用ShapeRichTextWith逐段排版,为每段保留由当时 run、样式与输入组合构成的排版,并像文本区域那样,从锚点段落起 用两棵 Fenwick 树按高度放置它们。每次编辑都经过文档,或作为一步撤销,或作为某个动作的一 部分;连续键入合并进同一步骤。附件是文本系统的行内对象;首行的缩进属于排版 (TextShape.Indent),位于段落起始的那一侧——从右向左阅读的段落在右侧。Document.VisibleParagraphs在被读取时就布局视野内的段落,如同绘制所做;若绘制在别处找到 的段落与被读取的不同,就请求一帧(Services.Invalidate),如同ListState.Visible触发 另建一帧。 - 进程之外的格式。 剪贴板承载编辑器的 JSON、HTML(粘贴内容由
golang.org/x/net/html读取)与纯文本;浏览器经由剪贴板事件传递各种文本格式。run 的格式以AccessNode.TextRuns传给辅助技术:macOS 上是AXAttributedStringForRange,Linux 上是 ATK 的 run 属性, Windows 上是 UI Automation 的 Text 模式(uia_text.go),其范围是各自独立的 COM 对象。
- 文本 (
文本选择 (
ui/textselection.go)。Selectable用在文本上选择那个段落;用在容器上则让 其所有文本后代共享一个选择。窗口把端点保存为稳定的元素 ID 和 rune 偏移,每帧按构建顺序收集 参与的段落。布局之后,共享范围被投影到各段的编辑器上,由它们的排版绘制高亮。指针手势、键盘 扩展、原生编辑命令和上下文菜单使用同样的端点。嵌套容器各有独立的作用域;控件和Unselectable子树不参与。在滚动边缘附近拖动会持续请求帧,直到滚动停止。行内子元素对其段落只贡献一次, 准备工作会等待其最终文本,使重建能保留选择。文本只在末尾增长的段落保留其选择,比如流式到来 的回复。容器的CopyData用SelectedTexts返回的各部分——每个段落的 ID、文本与选中的 rune——组装 Copy 写出的内容。表格 (
ui/table.go,ui/editable.go)。表格的行属于一个双向滚动的List:列表布局其行 时,宽度不小于各列所要求的(rowMinW),而表头在列表之外,放置时跟随列表的水平偏移 (followX)。用户给出的列顺序与列宽保存在ListState中(TableLayout),按列 ID 记录。 拖动表头在指针移动几个 DIP 后移动其列,越过某个其中线被越过的相邻列;双击表头边缘会在表格 布局时适配列宽,依据本帧构建的单元格的固有宽度(tableFit)。排序由应用负责:表格设置ListState.Sort,多列时用SortBy(每次新建一个切片,由sortClicked生成),并显示排序 状态,也告知辅助技术(AccessSortAscending与AccessSortDescending:即 AppKit 的AXSortDirection、ATK 的sort属性、UIA 的 ItemStatus)。行内的EditableText会读取 正在构建的行(Context.row):列表持有焦点,在回车(macOS)或 F2 时请求编辑所选行的文本, 且只在某行确实有这样的文本时才接管该键;点击某行文本时,若该行在点击前已被选中,则在双击 间隔过后进入编辑。大纲 (
ui/outline.go)。大纲每帧把其树的打开部分摊平成行(OutlineState.flatten),由List或Table按显示构建,以其项为键。行就是树项,带有其深度(AccessNode.Level)和 是否有子项(AccessExpandable)、是否打开:在 macOS 上是AXOutline的行,带AXDisclosureLevel与AXDisclosing,其行的保留方式与表格相同;在 Linux 上是level属性 与可展开状态;在 Windows 上是 Level,无子项的项用 ExpandCollapse 配 LeafNode。窗口内拖放 (
ui/dragdrop.go)。拖动某个值的元素把它记在自己的状态里(Drag),或记在 拖拽开始时给出该值的函数里,比如列表的所选行。当按住的指针移动几个 DIP 后引擎开始拖拽 (dragMove),并跟随指针下方最内层、其accepts接受该值的元素,Drop与DragOver依据 其类型设定接受条件。松开时把值放到它上面,且不算点击(dragEnd);按 Escape 放弃。每帧滚动 指针附近的滚动容器(靠边时),并在指针下重绘来源元素那一帧的样子,带着往返的位移;拖动多行 时在指针旁显示计数。列表和网格接管自己的行(rowDrag、itemDrag),按上一帧各行中线的 位置放置它们。原生数据拖拽 (
transfer、drag.go、ui/data_drag.go)。带有DragData的元素把手势 升级为原生拖拽跟踪。transfer.Data持有带 MIME 标记表示形式的不可变条目;数据提供者惰性 公布并按会话缓存。DropData以协商出的复制/移动效果接受匹配格式。核心持有一个活动源,以 随机令牌标识:各后端把令牌作为数据传输,绝不传 Go 指针。只有核心在源存活期间把它解析回原始 条目与可选的类型化值。完成或取消都会使令牌失效并只调用一次 Done。Surface.StartDataDrag、CancelDataDrag与SetDropFormats分别转换为 AppKit 的会话/粘贴板条目提供者、GTK 的选择 对象,或 OLE 的 IDataObject/IDropSource/IDropTarget。GTK 在放下之后经由异步回调收集被接受的 格式;OLE 在源的输入回调返回后运行其原生模态跟踪器。任何一方都不会在主线程上等待需要该线程 的数据。放下的字节比原生对象存活更久;未处理的文件传输仍走既有的文件放下路径,采用复制语义。 不支持的后端无法创建窗口/表面对象,并在 Init 处继续失败,且不链接任何原生拖拽代码。网格视图 (
ui/gridview.go)。网格视图是由"项的行"构成的List,其列数取自上一帧行的 宽度:当宽度要求不同的列数时,布局请求再来一帧(gridFit)。行位置保存在列表自己的状态里; 网格自行选择项、接管按键,并向辅助技术报告其项而非其行(行为 RoleNone):整体呈现为一个包含 全部项的列表,所选项是其活动后代。侧边栏 (
ui/sidebar.go)。侧边栏是一个接管焦点的元素,其项在构建时记下自己的 ID:按键在 上一帧的 ID 中选择,所选项是侧边栏的活动后代,在无障碍意义上持有焦点,如同组合框的选项。 分节和项都是树项,分节的 RoleNone 容器让所有项都留在侧边栏的树里,其行在 macOS 上按大纲的 方式保留。反馈 (
ui/feedback.go,ui/toast.go)。警告对话框是像对话框那样的模态覆盖层,点击其外 即离开,如同 AppKit 的警告会鸣响:警告角色(AppKit 的AXApplicationAlertDialog、ATK 的 alert、UIA 的 dialog pane),以标题命名、以消息描述。查找栏以其自身为键保存状态,打开时 聚焦其输入框,关闭时把焦点归还。复选框组在各复选框向它注册时发现它们(Context.checks), 随后把一次点击应用到所有。文本输入记下提交它时回车的修饰键(submitMods),编辑器据此识别 Shift+回车:查找栏随之返回上一处。日期、时间与颜色 (
ui/date.go,ui/timeinput.go,ui/colorpicker.go)。DateInput与Calendar共用一个月的网格,按键所在的日期是其活动后代。时间输入的时与分是步进按钮,以字段 命名,可经 type-to-choose 键入(flagTypeSelect)。颜色选取器把颜色保存为色相、饱和度、 明度与 alpha,因为红绿蓝颜色会丢失灰色的色相;当应用设置新颜色时它重新采用应用的颜色。 颜色井(color well)在 AppKit 上是AXColorWell,在 Linux 与 Windows 上是按钮,其值为 十六进制颜色。指示器 (
ui/indicators.go)。仪表与步进器都在一个范围内取值,和滑块、进度条一样 (AccessRole.Ranged):仪表对应 AppKit 的AXLevelIndicator、ATK 的 level bar 与 UIA 的 ProgressBar;步进器对应 AppKit 的AXIncrementor、ATK 的 spin button 与 UIA 的 Spinner, 接收增量。范围滑块的旋钮是各自的滑块,以它命名(nameJoin)。表单 (
ui/form.go)。表单在字段构建时记下其标签盒,并在布局首次测量或排布它之前把它们 统一为最宽者的宽度(intrinsic与boxLayout调用alignLabels),字段集中字段的标签亦 然。表单中字段的那一行,在排布标签与控件后对齐它们的首条基线(alignBaselines),取自本帧 的文本排版;没有文本的控件以其标签行为中心对齐。字段用其标签为其控件命名(nameFrom)。Description与Error经AccessNode.Description与AccessInvalid抵达辅助技术: macOS 上是AXHelp与AXInvalid(较新的 API 没有 invalid 状态,故元素经accessibilityAttributeValue:应答),Linux 上是 ATK 的 description 与invalid-entry, Windows 上是 UIA 的 FullDescription、HelpText(除非被占位符占用)与 IsDataValidForForm。 工具提示描述其元素,如同 AppKit 与 GTK 的做法。提交一帧时,禁用元素内部内容的状态被标记为 禁用——字段集在构建完其内容后才禁用它:下一帧的输入便发现它处于禁用(Element.disabled)。焦点组 (
ui/focusgroup.go)。提交一帧时,记下焦点顺序中每个元素所处的最外层焦点组 (FocusGroup),连同它所在的对话框。Tab 跳过组内除入口以外的元素——入口是上次持有焦点的, 否则是选中的单选按钮或选项卡,再否则是第一个——并离开该组;当焦点周围的元素没有把这些键用作 快捷键时,方向键、Home 与 End 在组内元素间移动焦点,在单选组中还会点击所到达的元素。Toolbar布局(layoutToolbar)放得下的控件,把其余的移出流(绝对定位且不可见,collapsed),并按标签列出它们供溢出菜单使用,菜单中的选择在遍历之后点击它们(clickLater)。覆盖层 (
ui/scope.go)为键盘划定作用域。提交一帧时,记下每个可获得焦点的元素所在的对话框 (被设为Modal的元素,如DialogBase的遮罩,flagModal;弹出面板在其锚点的对话框里), 最上层的对话框(engine.modal,以及modalLayer,即持有它的覆盖层中最顶端的元素),并把 弹出面板的元素在焦点顺序中移到其锚点之后。Tab 在最上层对话框内循环;焦点在其外时会移入其中; 在其外部构建的窗口快捷键不再触发;无障碍树是该对话框所在层及其上层。覆盖层把 Escape 注册为 覆盖层快捷键(OverlayShortcut),焦点及其周围元素未处理的按键会落到它们上,最后注册的 (最顶层的覆盖层)优先。每个元素每帧接收一个键(routeKeys):对它的第二个键起,按键等待 下一帧——上一键已改变的那一帧,就像在菜单里快速连按向下键和回车,回车选中向下键刚高亮的项。 在Overlay顶端构建的每个元素在打开时记下焦点(openers),当它连同其中的焦点一起消失后, 由修剪把焦点交还。附加到另一元素的元素(AttachTo,PopoverBase与选择框、组合框的弹出 面板使用)就是那个元素的弹出面板(Element.popover,提交后还有state.anchor):它的布局 (attachTo)依据此前已布局的各盒、其周围的滚动偏移,对行内目标还依据其段落的排版 (laidOutBox),推算目标在窗口中将要出现的位置,使弹出面板在移动锚点的同一帧跟上锚点;然后 它会换到另一侧,或沿目标的另一头,取更不超出窗口的方案,并沿目标移入窗口内(alongTarget); 垂直滚动的弹出面板先按窗口剩余空间裁剪(roomAlong),比如选择框的长选项列表。弹出面板没有 遮罩:引擎记下每次按下落在什么上面(downs,供一次遍历使用),PressedOutside从最后一次 按下起沿父级上溯,并从弹出面板溯至其锚点——这使得面板内元素的弹出面板中的按下、以及锚点上的 按下,都算在其内部,因为按下会继续传给下方的东西;同一次遍历中,先在其他地方按下、随后在锚点 上按下(比如 web view 的页面上),不会关闭它打开的弹出面板;而本帧首次构建的元素不会被任何 "外部按下"命中,因为快速点击打开它时,打开它的那次按下还在downs里。选择框的弹出面板保留 一个遮罩,接住其外的按下,如同系统弹出菜单的做法。Web views (
webview.go、ui/webview.go、各后端的webview.go)。Window.NewWebView为一个显示Content的窗口创建 web view,其页面住在一个不隶属任何列表的独立Window中, 并设置了host:调用、通道、事件、Eval、自定义 scheme、下载与权限都原样经由窗口页面的 代码处理,CallerWindow返回宿主,CallerPage返回页面,桥接使用宿主的 id,于是页面的窗口 控制对它生效。其原生侧(webViewNative)从后端的platform.WebView取得页面方法,从宿主 取得窗口方法(拖拽区域使用)。ui.WebView负责放置:提交一帧时记下每个 web view 元素显示的 位置(noteWebView:其内容盒取整到整像素,并像其命中测试一样裁剪),绘制时在此前窗口绘制的 内容上打一个洞(OpHole),按边框内缘做圆角、被周围的裁剪区裁切,使在元素之后绘制的内容盖在 页面之上。原生 web view 位于表面对象(surface)之下,表面以其透明帧把它透出来;显示在其上方 的内容与它混合,如同与任何像素混合。呈现一帧后,引擎把每个 web view 的帧与裁剪区,以及在其 裁剪区内、位于其自身之后的命中盒(Covers)交给表面:指针在其显示处属于页面,只要没有绘制 内容夺走指针(WebViewPlacement.At),如同对话框的遮罩覆盖其全部时那样。那里的按下传给页面, 并以WebViewPress回到内容,供PressedOutside记录,并从内容手中取走焦点;指针位于 web view 上方时引擎不设置光标(cursorUnknown),由其页面设置。本帧没有构建的 web view 隐藏但保留其页面。洞之上的字形不算在实心背景上(Painter.holes),以免亚像素字形给页面染色。 不透明度低于满值时,洞只带走下方相应比例的内容(OpHole的Opacity),于是页面与其混合。 该元素是 Tab 停靠点:聚焦它把键盘交给页面(WebView.TabInto,从其末尾可返回),Tab 越过 页面两端时以WebViewTabOut回来,焦点由此继续移动。无障碍树为每个页面设一个节点,位于其 元素内(AccessNode.WebView),各后端用页面自身的无障碍信息填充它,如同 SwiftUI 的WebView的放置方式。各后端:- macOS:WKWebView 放在一个翻转的裁剪视图(
MyGoClipView,其图层按边界遮罩)里,置于裁剪区 处、内容视图中表面之下,按绘制顺序排列;Metal 图层不设不透明,内存中绘制的帧带 alpha。 表面的hitTest:在 web view 接管指针处返回 nil,于是 AppKit 在其下找到页面。web view 的 跟踪区域在其帧的任何位置都能收到鼠标移动:MyGoWebView在内容绘制其上的地方丢弃mouseMoved:、mouseEntered:与cursorUpdate:;指针从页面移到那里时,向 WebKit 发送一 个视图外的离开事件,WebKit 将其视为在该处移动,使页面失去悬停;WebKit 只在hitTest:命中 web view 的地方设置光标,表面在页面之上不设置光标。TabInto把 web view 放到窗口 key view loop 中表面之后(或之前)并选中它,于是 WebKit 聚焦页面的第一个(最后一个)元素;当 Tab 离开页面时,WebKit 选中下一个 key view,MyGoWindow将其转为WebViewTabOut。页面的节点 列出 WKWebView,其accessibilityParent就是那个节点;裁剪视图则不列出任何节点。 - Linux:第一个 web view 把表面的绘制区放进一个
GtkOverlay,覆盖在承载 WebKitWebView 的GtkLayout之上(与GtkFixed不同,它不请求尺寸),作为覆盖层,GTK 会用新的 GL 上下文 重新 realize 它(GL 渲染器重建其对象)。此后 GtkGLArea 带 alpha 通道,内存中绘制的帧绘制在 web view 之上(CAIRO_OPERATOR_OVER)。区域在覆盖层窗口上的输入形状 (gdk_window_input_shape_combine_region)让指针透过到页面,页面窗口据此设置光标;web view 自身的button-press-event通知内容。WebKitGTK 不会在视图获得焦点时聚焦任何元素,所以TabInto先让页面失去焦点,再把已被处理的 Tab 事件传给视图(gtk_widget_event);当 Tab 离开页面时,WebKit 经 GTK 的focus信号移出焦点,视图的处理器将其转为WebViewTabOut。 页面的节点列出视图的 accessible,即一个AtkSocket,web 进程的树嵌入其中;覆盖层是一个 子类,其 accessible 只列出表面的。 - Windows:WebView2 采用可视化承载(visual hosting),一个合成控制器 (
ICoreWebView2Environment3),其 visual 位于表面对象窗口上非最顶层的 DirectComposition 目标中(一个窗口各有一个),建在一个没有渲染设备的自有设备上(DCompositionCreateDevice2): 表面对象的帧在最顶层目标中,覆盖其上。表面对象从第一个 web view 起合成其帧,如同没有重定向 位图的窗口:它发送SurfaceRenew,内容为其渲染器再次创建用于合成的交换链,或通过合成器显示 内存中绘制的帧;GDI 会画在 web view 之下。每个 web view 的 visual 位于一个按其裁剪区裁剪的 visual 中,页面位于其在表面对象像素中的边界。鼠标消息全归表面对象接收,并在 web view 接管 指针处转发(SendMouseInput),或发给按下所落的那个,直到按钮松开(带捕获),指针移开时 发送 leave;它设置页面请求的光标(CursorChanged),按下时把焦点移入页面。TabInto即MoveFocus(下一个或上一个),MoveFocusRequested转为WebViewTabOut。表面对象的放置 目标把页面上方的拖拽交给其合成控制器(ICoreWebView2CompositionController3的DragEnter等)。对于 UI Automation,根节点还应答IRawElementProviderHwndOverride:对 WebView2 显示 页面所用的窗口,它返回页面的节点,该节点以 WebView2 的页面提供器应答(get_AutomationProvider), 如同 WinUI 3 的做法。那个提供器没有子项:页面的元素位于 WebView2 的一个顶层窗口 (Chrome_WidgetWin_1)之下,而GetAutomationProviderForWindow不会把它映射到该窗口, 所以 UI Automation 在那里找到它们(以 2026 年 10 月的运行时观察所得)。
- macOS:WKWebView 放在一个翻转的裁剪视图(
上下文菜单 (
ui/menu.go) 分两帧打开。右键或菜单键从上一帧的状态中标记元素,下一帧运行其ContextMenu函数收集一个platform.Menu;帧结束后,mygo 包用Menu.popup显示它,并投递到 主循环,因为原生菜单在各自的循环中等待。选择以条目的位置和标签回来,由下一帧对函数的再次运行 匹配以报告Chosen;文本输入的菜单则把其编辑命令排入队列。PopoverMenu以同样方式打开,但 把菜单画在窗口内(ui/popovermenu.go):本应显示它的那一帧记下它(menuState.drawn:从元素 角落打开的位置,以及带着高亮打开的子菜单),在其显示期间,每帧运行函数收集platform.Menu, 并据此构建一个点定位的覆盖层,附着在菜单打开处的元素上,使菜单在窗口边缘绕那个点翻转;菜单和 每个打开的子菜单各有一个面板(RoleMenu),各自附着在打开它的条目上。面板为其条目保留焦点, 按键与指针高亮条目(ActiveDescendant),在其外但在打开它那一帧内的按下会关闭菜单——正是 那次按下打开了它。选中的条目立刻再次运行函数,如同系统菜单的选择在下一帧所做,或执行其编辑 命令;覆盖层消失时把焦点还给原先持有它的元素。某次遍历没有构建其元素的菜单会关闭 (resolveMenu)。PopoverMenuButton这样绘制菜单按钮的菜单(drawnMenu.below):由按钮 自身打开时位于其下方(menuState.below,由openMenuButton设置),其面板附着在按钮底边, 必要时翻到上方,按钮显示为展开态;由工具栏溢出菜单打开时,它像上下文菜单一样落在溢出按钮的角 上。由按键或辅助技术打开时(menuState.key),从第一个条目开始,显示期间再按按钮会关闭它 (buildMenu)。场景(Scenes) 是设备像素上的扁平操作列表:圆角矩形,边框每边一个宽度、实线或虚线,填充 颜色、sRGB 或 Oklab 混合的线性渐变、带色标的线性或锥形渐变(
Scene.Stops,操作指向其中, 每个至多 32 个),或条纹;边框在指定处用操作的渐变绘制(BorderPaint);阴影(模糊的圆角矩形, 被投出它的盒裁切,如同 CSS 的 box-shadow,或内阴影——在洞(模糊后)盖不住的地方位于盒内, 如同 box-shadow: inset);字形串,其掩码可带渐变(用渐变绘制的路径);图像,彩色或灰度,模糊或 不模糊(图像的Blur:按方块平均并像背景那样沿行沿列模糊,但在其源矩形之外透明,ui的Element.Blur用其图层绘制);效果(OpEffect);洞(OpHole),变成透明的圆角矩形,此前所 绘尽除,供 web views 使用;以及压入与弹出的裁剪区。 渲染器每帧绘制整个场景,只保留纹理。波浪下划线是描边路径。效果 (
scene.Effect) 是由渲染器之外的包定义的绘制,如同官方插件(glass 插件的 Liquid Glass):每个 GPU 渲染器一个片元着色器,分 Metal Shading Language、HLSL 与 GLSL,渲染器把它 插在自己的头部与尾部之间(effect.metal、effect.hlsl、effect.glsl,位于其着色器公共部分 之后,定义EFFECT时以EffectSource给出),CPU 渲染器有一个对应实现(EffectPixels), 必须绘制出相同的像素。 效果用五个 float4 参数(EffectOp.Params,它们走实例中填充用于颜色和渐变的槽位)绘制其操作 的形状;若它读取背景,则用其背景:先前操作所绘内容、其模糊所及的区域(scene.BackdropOf),按 边长 1、2、4 或 8 像素的方块做平均,使模糊保持几个纹素宽,再沿行、沿列做高斯模糊,每步以每 通道 8 位保存(纹理即如此存储),着色器以双线性采样(sampleBackdrop、BackdropImage.Sample)。 渲染器把效果的颜色乘以其形状的覆盖率、裁剪区与不透明度。 效果携带由其着色器经渲染器加工后提前编译的代码,连同其 SHA-256:当哈希不再匹配(例如其头部 或尾部自那以后改变),渲染器会重新编译源码,此时效果的测试会失败。ui包为各包提供钩子:Element.Material(一个ui.Material,当它是MaterialBuilder时随其元素构建)与Painter.Effect,由于效果是内部类型,只有本模块的包能调用。效果的形状在其操作有的地方也有 连续圆角,渲染器按连续圆角计算覆盖,而效果拿到正半径并按圆形处理。着色器 (
ui/shader) 是应用以 Go 语法编写的效果:internal/shader(Ebitengine 的 Kage 编译器的分叉,支持 uint 与无需常量边界的循环)把它编译为internal/shaderir的 IR,其后端将 其写成供渲染器代码调用的函数(msl、hlsl与glsl的Fragment;只链接当前平台渲染器的 后端),其解释器(interp)是 CPU 端的对应实现。着色器的效果(Effect.Shader)指明其 uniform 的字词写入其代码所读缓冲的位置:Metal 的结构体(buffer 2)、HLSL 的 cbuffer(b2)或 std140 uniform 块(绑定点 0);操作携带这些字词与至多四张图像,位图或画布(EffectOp.Uniforms、Images),绑定到纹理 5 至 8(Direct3D 中为 t6 至 t9)。GLSL 与 HLSL 把它们读作全局变量,所以 着色器插效果的头部与尾部之间;Metal 没有全局变量,所以其着色器是带自己尾部的完整库 (ShaderSource、shader_ps),由ui/shader构建,使不绘制任何着色器的应用不必携带它。ui本身只有Painter.ShaderEffect与Painter.ShaderFill:编译器、其包级变量与 go/parser 都不会进入不绘制着色器的应用。为文本、路径和图标填充的着色器(ShaderFill)把其函数所绘、 带灰度掩码的字形操作,转换为EffectOp.Glyphs指向其掩码字形的效果操作(彩色字形仍保持自己 的操作):构建器为每个字形生成一个实例(如同OpGlyphs),Params[1]置 1,Grad放着色器 要填充的盒的角,着色器尾部的掩码图集与textCoverage覆盖它(位于#ifndef EFFECT之上,供 效果共享),以着色器的颜色代替填充色;raster.maskedEffect是 CPU 端对应实现。元素的效果 (Element.Effect,一个ui.Effect)把元素子树渲染进保存在其状态里的画布,按窗口的缩放、在 元素自己的坐标系中(paintEffect:位置平移,子树中的变换失去元素自身的变换,unworld), 然后用元素的变换绘制该效果。画布以 64 像素为台阶创建(effectLayerStep),内容置于其左上角, 在内容放得下且剩余不足两个台阶时保留,否则原地调整大小(Image.Resize):一个移动时边缘跨越 像素、或文本在增长的元素,保留一个画布和一个纹理,而不是每种尺寸一个。效果得到内容的那部分 (Canvas.region),于是着色器把其外读作透明,如同超出其尺寸的画布;子树所绘被裁剪到那部分, 背景到其边缘为止(Scene.Drawn、Scene.BackdropOf,绘制器、构建器与 CPU 渲染器同样使用), 如同画布边缘。画布是瞬态的(Image.Transient),除非元素被保留(Element.Cache,ui/cache.go):那样它把子树绘入两个场景中的另一个,并经自己的scene.Differ与上一个比较 (layerCache),只有不同的、或缩放与区域不同的场景才提升画布版本并进入帧的Canvases;相同 的场景仍作为画布的场景保留,因为图集本帧持有其字形。窗口的Differ随后发现图像操作相同, 于是元素上的脏区只重绘一张图像。单独保留的元素(无效果、无模糊)渲染的是它所绘内容及其周围 (keptLayer.area:各自的盒与阴影外扩 4,在其坐标系中,被裁剪它的元素裁剪),而不是其盒。其 代码只能经由Cache存储的接口(layerKeeper)到达,从不调用它的应用不会链接它。ui/shader给效果的入口(ColorEffect、DistortionEffect、LayerEffect)一个调用它的Fragment,桥接 把内容作为图像 3(LayerImage)读取,从盒的角在其内的位置(__layerInset)起算。读取其背景 的着色器(backdropAt、backdropSample)是带Backdrop的效果:Painter.EffectBackdrop按构建器将做的方式计算背景(操作边界的scene.BackdropOf),桥接把位置映射到其纹素 (__backdropRow0、__backdropRow1、__backdropArea),渲染器把其背景纹理绑定为图像 2 (gpu.BackdropImage);CPU 端对应实现读scene.BackdropImage。Direct3D 的编译器对一个着色器 的效果需要 0.1 秒到 1 秒以上(渲染器的头部与尾部连同其覆盖率,占小型着色器编译的大头;优化 级别影响甚微),所以ui/shader在编译一个效果时即启动它(d3d11.PrecompileEffect),在 goroutine 上,并发数与 CPU 数相同但少一个。各渲染器共享这段代码(compileEffectCode),等待 剩余部分,结果存放在%LOCALAPPDATA%\MyGo\ShaderCache,文件名由编译源的 SHA-256 决定,每用户 一个目录(如同 Windows 自己的 D3DSCache),超过 32 MB 后移除最久未用者。提前编译的效果与不含 着色器的应用不会链接它。在 macOS 上,metal.PrecompileShader用系统默认设备做同样的事:Metal 库(首次约 85 ms)与面向 BGRA8 窗口的管线(约 60 ms),该设备上的渲染器保留(sharedShader) 它们而不是重新创建;Metal 自己的磁盘缓存为后续启动保留它们,广色域窗口从共享库创建其管线。圆角。 在 macOS 上,圆角是连续的,与 AppKit 和 SwiftUI 的一样(
scene.Op.Continuous),并按 Core Animation 在屏幕上绘制 它们的方式绘制,使用其着色器的函数(QuartzCore 的supercircle_sdf),它与 SwiftUI 路径的贝塞尔曲线最多相差半径的半个百分点: 一段绕对角线的四分之一圆,越靠近边缘弯曲得越少,在距角点 1.528665 倍半径处与边相接,是一条关于曲线包围盒内点坐标比值的一元四次曲线。 对于两条圆角曲线都放不下的短边,曲线按该边的钳制因子向四分之一圆混合, 该因子取自边长除以其两角平均半径,因此正圆的角就是四分之一圆; 一个形状是其各角的交集,所以曲线可能越过一条边的中点,只要该边的另一角 更小;边缘按 Core Animation 的做法做抗锯齿,即按该点的值与其导数之和的 比值(internal/raster/corner.go)。与屏幕上图层的捕获图像相比, 放得下的圆角和圆形每个像素误差在 1/255 以内,胶囊形和控件尺寸的其他短边 在 6/255 以内。CPU 渲染器用试位法在半径与曲线范围各自的四分之一圆之间 求曲线与某行的交点,并为带连续圆角的裁剪区内的操作保留各行的跨度;BenchmarkFrame的整帧耗时约增加五分之一。在其他平台上圆角是圆弧形的, 如同 Windows 和 GTK 的绘制方式,只有 CPU 渲染器和 Metal 绘制连续圆角。文本。
internal/text用系统自带的文本栈排版文本,藏在一个小巧的engine接口之后:Windows 上的 DirectWrite(IDWriteTextLayout,配合一个 用 Go 实现的IDWriteTextRenderer来收集其字形串)、macOS 上的 Core Text(CTTypesetter,系统字体的各字重借助NSFont)以及 Linux 上的 Pango(底层是 fontconfig 与 HarfBuzz,与 GTK 相同);在浏览器中则是画布的 2D 上下文(canvas_js.go,参见浏览器)。它们负责找到字体、 为某字体缺少的字符回退到其他字体、做文字排版、运行双向算法并断行; 字体文件保持内存映射,并与所有其他进程共享。该包把这些引擎给出的行 组装成排版结果,自带空白裁剪、省略号、对齐、插入符与选区, 使这些行为在各处保持一致,并按参数缓存排版结果。引擎也栅格化字形 (IDWriteGlyphRunAnalysis、CTFontDrawGlyphs、cairo)到覆盖图集中, 彩色字形(emoji)到彩色图集中,亚像素字形——即每个像素红、绿、蓝的 覆盖度——也进彩色图集。文本看起来与各平台自己的工具包绘制的一模一样, 逐像素一致,这一点经与 AppKit、Direct2D 和 GTK 3 标签的对照测试得到验证:- 位置。
System.Glyph接受字形的笔位并按引擎的方式放置它: Core Graphics 的亚像素量化(AppKit 保持其开启),对于每 empx像素的 字形,一个像素有min(5, ⌈100/(3·px)⌉)个位置,在笔的左侧; DirectWrite 的 ClearType 每像素六个位置,natural 模式八个, natural symmetric 四个,降采样渲染两个,GDI 与别名渲染则取整像素, 取离笔最近者;GTK 取整像素,因为 Pango 对字形位置做四舍五入。System.Baseline同样吸附基线:在 macOS 上向下吸附到下一个像素 (Core Graphics 在其 y 轴向上的空间中向下取整),在 Windows 上吸附到 最近者,在 Linux 上向上。 - macOS。 Core Text 绘制时带字体平滑,如同 AppKit,除非用户关闭了它 (
AppleFontSmoothing,与 AppKit 一样通过NSUserDefaults读取: Core Graphics 的位图上下文会忽略它),颜色越浅字形加粗越多,按其亮度 分四档:字形按文本颜色的每个text.Shade分别栅格化。text.Thick用于ui.Font加粗的文本(终端的Font.Thicken,如同 Ghostty 的 font-thicken),无论颜色和用户设置如何都按最强档平滑;text.Flat用于ui.Font以Antialiased绘制的文本(如同浏览器绘制-webkit-font-smoothing: antialiased),不做平滑;其他引擎把两者 都按普通文本绘制。 - Windows。 字形采用 DirectWrite 就其字体与字号推荐的渲染模式与 网格贴合(
IDWriteFontFace3的推荐,从约每 DIP 1.9 像素起改用降采样的 natural symmetric 渲染,与 Direct2D 的绘制一致);在系统用它平滑字体之处 用 ClearType(SPI_GETFONTSMOOTHINGTYPE,连同系统渲染参数的像素几何与 ClearType 级别),且字形位于不透明背景之上(根的背景,或周围某元素包住 其全部可见部分的背景,例如浮于材质之上、侧边栏旁的面板);在系统不平滑 字体之处则用别名渲染。渲染器按 Direct2D 的方式混合它们,采用系统渲染 参数的 gamma 与增强对比度(scene.TextParams,Windows Terminal 对 Direct2D 着色器的复现),而对 Direct2D 认为细到不宜抗锯齿的字体族 (如 Courier New)再把对比度提高一半。 - Linux。 Pango 采用 GTK 的字体选项,由 ui 包从
gtk-xft-*读取 (platform.Theme.FontRendering,连同界面字体一起传给System.SetFontRendering):即桌面的抗锯齿、亚像素顺序与微调风格, 度量按整像素微调、字形位置取整,与 GTK 3 标签的设置一致。 亚像素抗锯齿绘制亚像素字形。 - 装饰线。
System.Decorate按平台的方式放置下划线与删除线: 对 AppKit 用 Core Text 自带的几何,按点吸附,下划线低而粗,恰如被其 划过的各字体所愿,并避开下伸部的墨迹;删除线穿过 x 高度的中部, 从文本起点到其宽度向上取整到点;对 Direct2D,下划线位于字体偏移量处, 从吸附后的基线取整到整像素,删除线按排版所得基线绘制,边缘落在其沿 像素横向与纵向八个采样点所在的位置;对 GTK,按 Pango 以整像素给出的 度量,下划线沿一串的墨迹与前进宽度,删除线沿其墨迹。 - 合并字形。 渲染器逐个混合每个字形,如同 Core Graphics 与 Direct2D,但 cairo 先把一串中各字形的覆盖度相加再混合,当相邻字形的 抗锯齿边缘共享一个像素时结果就有差别。在 Linux 上,ui 包让文本系统 把它们合并(
System.GlyphRun):覆盖度相加并钳制,作为一张位图缓存。 用Painter绘制的路径是覆盖图集中的蒙版,由internal/vec栅格化, 并按其形状相对像素网格的位置缓存,精度为四分之一像素,路径的点先移到 该精度上:同一键值的任意路径,谁先画出蒙版,后面的看起来都一样。 一张蒙版放不下的大路径(边长超过 4095 像素)按分块绘制(pathTiles), 每块是整条路径裁剪到该块的蒙版:只绘制可见且被路径边缘到达的块, 以及在填充内部被完整覆盖的块,后者共享一张蒙版。描边的蒙版是其中轴线 的轮廓,每条边缘只画一次(strokeInto):先沿一侧,在拐角与端点处按 路径的连接与端帽转向(Path.Join、Path.Cap),再沿另一侧折回。 其虚线(Path.Dash)在绘制蒙版时从中轴线上切出,起点是每条折线在 虚线图案中的位置,因此被裁剪路径的虚线保持原位;其键值也记录该位置, 在一个图案周期内精确到四分之一像素,连同端帽、连接与虚线。与 Skia 相同:穿过闭合子路径起点的虚线算作一条虚线,虚线与间隔之间的点属于 间隔。方端帽伸出半个描边宽度,斜接连接在斜接上限内伸到其角点所能到达 之处(flatPath.reach,依据蒙版绘制的点),蒙版及其分块也延伸到同样远。 GPU 上不透明的Painter.StrokePolyline是例外:每段是带圆端的旋转填充, 按普通填充的方式绘制,不用蒙版;半透明的以及 CPU 渲染器走蒙版路线, 以免重叠的线段显得更深。 图标也是如此:internal/svg把 SVG 文档(用encoding/xml和自带的小型 CSS 层叠)解析为路径、颜料与图层的节点,并用internal/vec在 CPU 上 绘制,在预乘像素的图层上合成渐变、组不透明度、裁剪路径与蒙版;ui.Icon为每个尺寸保留该绘制的覆盖度,由 GPU 像给字形上色一样为其 着色,ui.Image保留彩色像素,当作图像对待,仅文本颜色变化时原地更新 其纹理。图集记录发生变化的矩形,渲染器因此只上传那些区域。 带浮点参数的 DirectWrite 方法通过 purego 调用,它会设置syscall在 ARM64 上不处理的浮点寄存器。 字距与 OpenType 特性交给各引擎自己的机制,断行也会连同它们一起进行:IDWriteTextLayout1的字符间距加IDWriteTypography,Core Text 的 tracking 加一份带特性设置的字体副本,Pango 的属性。富文本的跨度同理, 它们为若干 rune 区间设置样式(text.Span):DirectWrite 在区间上调用 设置器,Core Text 富文本字符串各区间的字体,Pango 在字节索引之间的属性。Params.Spans把它们编码为字符串保存,Params因此仍是排版缓存的键。 排版复用文字排版阶段丢弃之物:各引擎从每次排版重置的暂存区 (shapeScratch)取得shape返回的字形、串与行,排版把它们复制进 自己的行,这些行共享一次分配;Core Text 保留其缓冲区与各字体的属性, 并通过SyscallN而非反射调用每次排版的函数。跨度的颜色、下划线与 删除线只在绘制时起作用,按每个字形起始的 rune 定位。 带ObjectWidth的跨度把其中的对象替换字符变成内联对象:立在基线上的 方框,行在其周围断开,且不绘制字形(Glyph.Object),例如富文本中的 图片,或带Element.DrawObjects的文本中的公式——它在字形之后绘制 它们。在共享排版中,行把它们的高度与下降(ObjectDescent,仅对对象 编码)计入;各引擎只给出它们的宽度:Core Text 通过串委托,其回调返回 Go 无法返回的浮点数,因此用汇编实现为叶子函数(rundelegate_darwin_*.s); DirectWrite 通过IDWriteInlineObject,其位置由命中测试找到,与其 字形串一样;Pango 通过排版属性;canvas 引擎则按宽度成簇。 跨度的BaselineShift也在共享排版中把其字形与对象移出基线,连同行 为它们留出的空间,以及装饰线绘制所依据的基线(decoRange.shift)。 各引擎按单一宽度断段落,这是 DirectWrite 的排版唯一接受的方式, 所以段落的其他需求由共享排版负责(paragraph.go),在每个引擎上都 一致。首行缩进(Params.Indent)把该行断得更窄,然后从它结束处重新 断其余的行。两端对齐(Params.Justify)加宽按宽度换行的各行(每段的 最后一行除外)的空格以及汉字与假名之后的空间,每个字形按左侧各簇所获 空间平移,插入符与选区随之移动;它在一个TextShape请求时才被链接 进来(System.UseJustify),不对齐文本的应用完全不需要它。各引擎都在 软连字符(U+00AD)处断行并各自随意绘制:排版把行内的软连字符隐藏 (一个无字体无前进宽度的字形,保持插入符的位置),并在因它断开的行 之末绘制连字符——引擎按软连字符的样式取 U+002D 的字形。只有 Pango 断行时为其预留空间,所以连字符放不下的行会再断一次,按连字符宽度 收窄,前提是 Unicode 断行规则允许(否则连字符会溢出,如超长单词一样), 其余部分重新断行(breakLines)。没有缩进与软连字符的段落只排版一次。 在文本内部构建的文本元素(ui/inline.go)是内联的:段落在其Children返回时取得它们的文本,排版时把它们的样式作为叠加在其自身 之上的跨度。提交时,每个元素在段落的排版中获得其各 rune 的矩形框 (Selection),各自一个命中区,因此指针、焦点顺序、工具提示与菜单 对它们与对任何元素一样工作;它们只绘制自己的焦点环,而辅助技术把其中 超越纯文本的部分(如链接)看作段落节点内部的节点。 fontconfig 不知道桌面的界面字体,所以在 Linux 上,system-ui首先 代表 GTK 的gtk-font-name的字体族(platform.Theme.UIFont, 外观每次变化时 ui 包都把它交给SetUIFamily)。
- 位置。
图集生命周期。 首次绘制的蒙版进入图集底部的临时区,该区在下一帧 释放,当另一帧再次绘制它时,它移入顶部的常驻区,因此动画形状永远不会 填满图集。如果图集在一帧中途仍然满了,引擎调用
MakeRoom:重新打包 该帧所画的内容,若占比很大则扩大图集,其余全部遗忘,然后重画该帧: 没有一帧会缺字显示。蒙版图集从 256×256 开始,彩色图集则从一个透明 纹素开始,直到某个彩色或亚像素字形绘制为止。增长同时考虑缺失位图的 宽、高与面积,因此细长的路径在重画时也能放下。第一个字形可以立即 撑大一个空图集,而不必作废任何已绘制的内容。渲染器。
internal/gpu把场景转换为实例化四边形,按共享裁剪矩形 与图像的批次组织,供一个计算圆角矩形有向距离(Evan Wallace 的模糊 圆角盒)、渐变、条纹、边框虚线、图集覆盖度与最内层圆角裁剪的着色器 使用;外层裁剪用裁剪矩形。构建器会省略被后续填充必定不透明的核心 完全遮挡的操作,最多保留八个遮挡物,并把读取背景的效果视为屏障。 其规划器与绘制共享裁剪规则,在变换前先扩大阴影支持范围,并跳过不含 潜在不透明填充的场景。带投影体且仅边框的较大阴影按裁剪条带绘制, 略去其输出为零之处:投影体内部、边框两缘之间,以及边框外缘之外。 带圆角时按行带绘制(gpu/bands.go),每带只绘制外缘可能到达与内缘 必定覆盖之间的列,因此一像素宽的圆形着色的面积约等于其长度而非其 外接矩形;横过圆角的带数以划算其绘制调用为准(bandCost)。带连续 圆角时绕单个矩形绘制。这些裁剪从不改变裁剪蒙版的采样坐标。两种 变换为所有 GPU 后端共享。 带连续圆角(负半径,只有 Metal 支持)的大填充、阴影与图像按九宫格 绘制(gpu/corners.go):四角的盒子保持原样,其间的十字区作为方角的 孪生体绘制,批次所用管线不含曲线代码。远离角盒处,着色器两种算法 算出同一值:填充的像素面积,以及采样错过曲线行、或模糊后两侧舍入为 0 与 1(距曲线 9 sigma)的模糊阴影。每块是各自的绘制调用(其裁剪 矩形),因此只有当孪生体在形状四边形内的像素所省超过各块调用的开销 时才切分形状(pieceCost):小形状处处贴近其圆角,保持完整并入相邻 批次。 Metal 额外选择三种构建器能力:标记裁剪覆盖为一的填充、把不透明核心 与混合边缘拆分、按片段类别批处理。其他渲染器保留原始实例标志与 不分类别的批处理。Metal 每个 pass 绑定一次实例缓冲区,每批以其首个 实例为 base instance 绘制,[[instance_id]]包含该基数;批次仅在其 变化时才绑定管线、图像、裁剪蒙版与裁剪矩形,选择器只查找一次, 裁剪矩形按指针传递(setScissorRects:count:)而非经由反射式 purego 函数:从 Go 发消息每次约半微秒。 效果在自有批次中以自有管线绘制,渲染器在效果首次绘制时创建该管线; 读取背景的效果在背景步骤之后绘制(Builder.Backdrops):渲染器结束 其 pass,读取其所绘内容的背景区域(Metal 从 drawable 读取,故 drawable 不能是framebufferOnly;Direct3D 11 与 OpenGL 从该区域的副本读取,CopySubresourceRegion与glCopyTexSubImage2D,其行序向上),用两个 小型管线(down与blur,裁剪到其计算的纹素,以Load、read或texelFetch读取)对其做平均与模糊,输出到与帧内最大背景同尺寸的 纹理,然后继续该 pass,背景已绑定供效果使用。 模糊绘制的图像在帧的 pass 之前经过同样两个管线,由构建器遇到它时 提出请求(Builder.BlurredImage),从图像纹理写入帧自有的纹理之一, 无论帧格式一律 BGRA8,图像之外按透明读取(gpu.PassClear);OpenGL 读取翻转的画布,不读取上传的图像(gpu.PassUnflipped)。 渐变的色标保存在一个 32 位浮点纹理中,构建器每帧填充 (Builder.Stops,每个色标两个纹素:其颜色,然后其位置与其 Oklab 颜色),绑定到纹理 9(Direct3D 中为 t10),着色器的stopsAt从中 插值,如internal/raster所做。 每个渲染器为定义效果的包的测试离屏绘制场景(RenderOffscreen、NewOffscreen)。画布(
scene.NewCanvas、ui.Canvas)是由自有场景绘制的图像。Canvas.Draw用缩放为 1 的画笔把内容画进其场景,共享窗口的图集, 并把它登记到帧的Scene.Canvases中,先完成的内层画布排在前面。 帧开始前,每个渲染器绘制它列出或显示的、其纹理未持有该版本的画布 (Scene.EachCanvas,画布显示的画布先于画布本身),各自用独立的 构建器,输出到未经效果处理的帧格式纹理:Metal 输出到帧命令缓冲区中 的 BGRA8 渲染目标,Direct3D 11 输出到 BGRA 渲染目标视图(动态实例 缓冲区以 discard 方式映射,供全部使用),OpenGL 输出到帧缓冲对象, 其行序向上,因此显示它的图像实例要翻转纹理坐标(采样器两种行序都 能取源矩形)。 画布的纹理与画布同寿命(weak.Pointer),显示时不重绘;临时画布的 纹理在每个显示它的帧重新绘制,效果纹理则只在绘制它的帧期间存在: 渲染器每 120 帧一次的清扫会释放它们,而不是等画布被回收——画布在 Go 堆中只占少量内存,可能拖延几分钟,而其纹理却占数兆字节。 CPU 渲染器把画布绘制进其Pix(PixVersion)。 在接近直角的圆角处,覆盖度是精确的,即像素落在盒内的面积,因此 细于一像素的线覆盖其应覆盖的量,正如文本装饰所需。填充的边框宽度 随其纹理矩形传递——填充本身不用它;字形的 gamma 比率与对比度则随 其半径传递。每个实例是十三个 float4,包括其两行仿射分量。 着色器写出第二个颜色,即源各色道的 alpha,渲染器用它混合(双源 混合):除亚像素字形外它就是颜色的 alpha,亚像素字形的各亚像素按 各自覆盖度覆盖各色道。没有EXT_blend_func_extended的 OpenGL ES 改用其亚像素的平均值混合。洞是自有批次中的不透明填充 (Batch.Hole),以零源因子混合:目标保留一减去其覆盖度,如同 CPU 渲染器的erase。每个 GPU 渲染器都绘制这些实例:internal/gpu/d3d11使用预先编译为 DXBC 的着色器(在 Windows 上用 系统的d3dcompiler_47.dll编译),因此应用无需携带着色器编译器。 生成的文件记录其来源的 SHA-256(忽略行尾差异,gpu.SourceSum), Metal 的也是如此:早于shader.hlsl的字节码回退到用同一个 DLL 编译该源文件——Windows 自带该 DLL——并且其测试会失败。两条编译 路径都在优化的同时启用 IEEE 严格模式,以避免本地编译器优化器在 变换覆盖采样时崩溃。它绘制到表面窗口的交换链(flip 模型)中, 或在材质之上绘制到 DirectComposition 显示于其上的交换链,没有可用 的硬件设备时使用 WARP。屏幕上至多等待一帧,而非 DXGI 的三帧, 因此连续跟进的帧(如滚动或动画时)提前两帧显示其输入,Present也不会为此阻塞:交换链的帧延迟对象(latency.go)指示一帧的空位, 在此之前画好的帧等到有空位再呈现,因为等待该对象的 goroutine 会 让主线程去呈现(SetPost);由显示器节拍的帧(WM_PAINT,但尺寸 变化的与FrameNow的除外)在此期间不构建(Renderer.Ready)。 每次呈现消耗对象的一个信号,使其计数保持为屏幕能接受的帧数。 新尺寸的帧立即呈现(sync interval 0),不等在其之前排队的帧,并等待 DWM 显示它(DwmFlush),因此实时缩放不会显示拉伸的帧:窗口随该 尺寸的帧取下一个尺寸。DWM 仍可能在该帧之前一个刷新周期就按新尺寸 显示窗口(#222),因此窗口的交换链以不缩放方式(DXGI_SCALING_NONE) 显示其帧,帧之外用内容背景填充(SetBackgroundColor,取自scene.Scene.Background),并且窗口在把表面适配到新尺寸之前,先用 该背景画出表面之外增长的部分(platform.BackgroundSurface、fillUncovered):其间看到的是背景而非黑色。 窗口约达 4K 屏幕大小时,重设缓冲区耗时超过 DWM 一帧,因此在窗口 改变尺寸期间,交换链持有三块比窗口大四分之一的缓冲区,并显示其中 窗口大小的部分(SetSourceSize),直到最后一次变化一秒后的帧 (由定时器请求)再给它两块与窗口同尺寸的缓冲区。 交换链按序翻转(FLIP_SEQUENTIAL),使一帧只画自上一帧以来的变化, 如同 Metal(下文):DXGI 保留每块缓冲区的内容,帧以其绘制的矩形 作为脏矩形呈现(Present1),上一帧在这些矩形之外的改动会复制进 缓冲区,因此无需跟踪缓冲区年龄。帧清除这些矩形 (ID3D11DeviceContext1::ClearView,Direct3D 11.1 才有,WARP 也支持), 并绘制裁剪到各矩形的批次;毫无变化的帧不呈现。在 WARP 上、无 GPU 的运行环境中,1280×760 像素窗口中闪烁的插入符只占整帧重绘 CPU 时间的十分之一;internal/gpu/metal使用预先编译为 Metal 库的 Metal Shading Language 着色器(在 macOS 上用 Xcode 的metal工具编译),免得 首次启动花 100 到 150 毫秒让 Metal 编译源码直到缓存;早于shader.metal的库回退到编译源码,并且其测试会失败。它绘制到 自己添加到表面视图图层上的 CAMetalLayer。命令队列、着色器、采样器 与占位纹理按需创建。改变图层的帧(其尺寸、缩放或像素格式),或 显示 webview(它们随窗口事务移动)的帧,随 Core Animation 事务 呈现(presentsWithTransaction),因此实时缩放不会显示拉伸的帧; 其余帧 GPU 一出结果就呈现(presentDrawable:),每帧省掉 AppKit 显示周期的一次遍历与事务的一次提交:120 Hz 动画占用主线程时间约为 原本的一半。主线程从不等待显示器:每帧画完即呈现,之后由自有线程 上的 goroutine(present.go)取下一帧的 drawable,nextDrawable会在显示器持有其他 drawable 时把它扣住,有时达大半个刷新周期。 此时到来的帧照常构建,其画布与实例就绪,然后等待(pendingFrame); goroutine 拿到 drawable 后,让主线程(SetPost,经由surface.Conn.Post)把帧画入并呈现。display link 节拍的帧 (platform.SurfaceEvent.Paced)在有帧等待时不构建(Renderer.Ready、windowHost.waitDisplay):下一帧在等待帧呈现后立即绘制 (platform.PacedSurface.FrameNow),时间正是其 tick 所请求的, 使运动之物保持节奏;其间 link 照常发 tick。其他帧(如尺寸变化的帧) 先绘制等待中的帧。随事务呈现的帧(改变图层的)在主线程取 drawable,暂停后的第一帧也一样,在取走任何在取者之后。 在 drawable 到来之前构建帧(像过去阻塞等待那样)让 GPU 一样繁忙: 只有 drawable 到来后才构建,在 GPU 耗时占每个刷新周期大部的情况下 绘制的帧少六分之一。实例使用两个帧槽位,供主目标与每个画布, 每个槽位只等待最后使用它的命令缓冲区。原地更新图集或图像纹理 仍需等待所有在途帧。在命令队列上把画布渲染进其纹理保持有序。 图层保留三个 drawable,正如窗口动画时 Core Animation 所创建的: 若只有两个,在 ProMotion 显示屏上,帧要等其中一个达每个刷新周期的 大部,且五分之一的帧画不出来。第三个在窗口动画期间花费一帧的内存。 等待 drawable 与等待实例缓冲区保持分离,因此离屏编码计时不能代表 呈现延迟。不透明填充核心在 BGRA8 中使用不混合管线;float16 保留 混合以保持其插值舍入。片段函数按批次类别与连续圆角特化,通用 着色器作为完全相同的回退。不同类别的小批次连跑(每个至多覆盖 128×128 像素,如盒阴影及其后的填充)合并为一个批次,其函数只包含 这些类别的分支(Batch.Joined):一次绘制调用的 CPU 开销高于这些 像素在合并函数中的开销。工作线程在帧路径之外编译并预热这些管线, 按设备缓存。渲染器释放时先停止并等待该工作线程结束,再释放其 着色器库。float16 阴影保持通用,带小数不透明度的填充避开普通类别, 因为否则特化会改变舍入。函数常量索引 0 到 7 属于渲染器;效果从其 自己的 8 开始。 最后一帧两秒后,恰如驱动释放其自有帧内存之时,定时器收缩 drawable,只留下正在显示的那个,直到下一帧重新创建,并释放 GPU 实例缓冲区、图集/图像纹理与背景纹理:空闲窗口只保留一帧内存。 其 drawable 不是framebufferOnly,因为效果要采样 drawable 作为 其背景。 一帧只画自其 drawable 上次绘制以来的变化。Core Animation 轮流发放 图层的 drawable,每个保留上次画入的帧:渲染器按 drawable 的纹理 (强持有,以免他人占用其地址)记录此后各帧的变化(gpu.Ages, 如同 EGL 的缓冲区年龄),通过与上一场景比较得出(scene.Differ, CPU 渲染器亦如此),遇到效果时把其背景并入变化区域,并在该区域 不到半帧时加载 drawable 而非清除它:把清成帧色的纹素覆盖到该区域 (用downpass,让 GPU 像清除一样转换颜色),并绘制裁剪到该区域 的批次,区域外的批次跳过。该区域至多由八个互不相交的矩形构成 (gpu.Part,取自 Differ 的矩形,gpu.Ages为每个 drawable 保留 这些矩形,相接或近乎相接的合并,超过八个则合并使并集增长最小的 两个):一角中的转圈指示器与另一角中的进度条各自重绘,而非重绘 两者之间的全部;每个批次在其遇到的每个矩形中只画一次,以免混合 两次。效果读取的背景位于单个矩形内,在效果读取前绘制。 Apple 的瓦片式 GPU 于是只为 pass 绘制的瓦片做加载与存储: 3200×2000 像素窗口中闪烁的插入符每帧占 GPU 0.4 毫秒而非 2.4, 转圈指示器 0.4 而非 2.9(M5 上)。改为把上一帧拷贝前移(blit)的 开销与整帧重绘相当:3456×2000 下 0.34 毫秒对 0.43。场景与上一帧 相同的帧什么都不画,也不取 drawable。Apple 并未记录 drawable 保留 所绘内容:未见过的 drawable、新尺寸或新格式的 drawable,以及定时器 收缩后的 drawable 整帧绘制,MYGO_PARTIAL=0则每帧整帧绘制。 像素与整帧绘制逐字节一致:构建器把某操作放入与上一帧不同管线的 情况(为其他类别特化的批次、其间新建的管线)会使该操作在个别处有 一个单位的位移,其边界因此算作变化(pipelineDamage);开始或停止 容纳经变换绘制之操作的裁剪,其边界同样算变化——该裁剪此后用蒙版 切割(scene.Differ);TestPartialFramesMatchWhole把逐帧随机变化 的场景轮流画进三个目标,并与整帧绘制结果逐帧比较。 sRGB 色域之外的颜色(ui.Oklch,如 CSS 的oklch())存于场景的 一张表(scene.Scene.Wide)中,操作与字形以 16 位索引指向其中 (Op.Wide,利用操作其他字段留下的空间);其Color、Color2与BorderColor保存最近的 sRGB 颜色,按 CSS Color 4 的方式求得 (internal/gamut),CPU、OpenGL 与 Direct3D 渲染器绘制这些近似色,gpu.Builder除非标记Wide也如此。窗口宿主决定窗口何时绘制 宽色(wideHold):在能显示超出 sRGB 的屏幕上 (platform.WideGamutSurface、macOS 的canRepresentDisplayGamut:), 从出现此类颜色的一帧起,直到两秒再无此类帧,以免闪烁的插入符每次 闪烁都切换格式;那些帧由 GPU 绘制,而 sRGB 屏幕上的窗口继续在 CPU 上绘制近似色。Metal 渲染器(SetWide)随后把图层切换为扩展 sRGB 色彩空间的RGBA16Float——它保留 0 到 1 之外的分量,系统会 按显示器的色域显示——并用该格式的管线与背景纹理绘制,首次使用时 创建。帧的一个 uniform(顶点与片段全局变量的 z)告知着色器: Oklab 渐变于是保留其混合结果中超出 sRGB 的部分,效果按e.wide读取它,玻璃效果也如此在扩展 sRGB 中取其色调 (ui.Painter.EffectColor);internal/gpu/gl使用 GLSL 3.30 或 GLSL ES 3.00 的着色器,由驱动 在渲染器启动时编译,因为这些版本没有每种驱动都接受的编译形式, 而驱动会在多次启动间缓存其编译结果,Mesa 与 NVIDIA 的驱动如此。 它绘制到 GTK 显示的 GtkGLArea 的帧缓冲中。GL 函数来自 libepoxy, 与 GTK 相同。实例经由逐实例属性绘制,由于 OpenGL ES 3.0 没有 base instance,每批指向其首个实例。区域的create-context向 GDK 请求 OpenGL 3.3,否则请求 OpenGL ES 3.0——仅有 OpenGL ES 的 GPU 如此——下面的探测(glContext)也一样。在浏览器中,同一渲染器用 WebGL 2 绘制,即 OpenGL ES 3.0(参见浏览器): 只有函数来源不同,load_linux.go或load_js.go。一帧只画自 上一帧以来的变化(Renderer.Partial,配合scene.Differ与gpu.Pipelines,与 Metal 相同),画进保留所绘内容的帧缓冲,因此 无需跟踪缓冲区年龄:开着裁剪测试清除区域的矩形,glClear只清除 裁剪内的部分,再绘制裁剪到各矩形的批次。 GTK 在两次渲染之间保留 GtkGLArea 的帧缓冲,但每次分配区域时 (即使尺寸不变)都会重建其缓冲区,内容未定义:SurfaceResize让 下一帧整帧绘制(Invalidate)。画布的 WebGL 2 上下文保留其绘制 缓冲区(preserveDrawingBuffer),浏览器随后在合成每个已绘帧时 拷贝它;在 M5 的 Chrome 中,闪烁的插入符仍占整帧重绘 GPU 时间的 四分之一,毫无变化的帧不碰画布。在 Mesa 的 llvmpipe 上,1280×760 像素窗口中的一次插入符闪烁只占六分之一的时间。
Linux 只在运行于 GPU 上时用 OpenGL 绘制。一个 GL 上下文会永久加载 Mesa:约 50 MB 的库(LLVM,发行版的 Mesa 会链接它,加载即占 19 MB)、 其线程与堆,相当于一个小应用其余部分的全部;第一个原生 UI 窗口就会 加载它。在某个表面拥有上下文之前,后端在一个从不显示的窗口上创建 一个上下文,读取其渲染器。像 Mesa 的 llvmpipe 这样的软件渲染器 (在虚拟机中、在没设
GALLIUM_DRIVER=d3d12的 WSL 中)在 CPU 上重绘 每一帧的每个像素,是 CPU 渲染器在一个动画页面上工作量的十倍;而一旦 窗口有了 GL 上下文,GTK 就用 OpenGL 合成它,所以选择必须在任何表面 拥有上下文之前做出;MYGO_GPU=1跳过探测,只要 GDK 能创建上下文就 用 OpenGL 绘制,从第一帧起,正如 CI 的 GUI 测试在 llvmpipe 上的做法。 设备能先回答的地方就先回答:没有 NVIDIA 的设备、或没有带 3D 能力的 DRM 驱动渲染节点(simpledrm、bochs、VirtualBox 或 Hyper-V 的节点只 显示 CPU 画的内容)时,不创建 GtkGLArea,WSL 的设备没设GALLIUM_DRIVER=d3d12时也不。GtkGLArea 只显示 OpenGL 画入其中的 内容:当其 GL 渲染器仍然失败时,内存中画好的帧也经由 OpenGL (gl.Presenter上传它们并 blit 进区域的帧缓冲)。internal/raster在 CPU 上用同样的公式绘制同样的场景:形状内部的 实心跨度只计算一次,只有阴影的边缘需要计算;普通不透明蒙版字形的 混合用整数完成,渐变、校正/亚像素文本与圆角裁剪边缘保留通用路径。 它只重绘与上一场景不同之处(raster.Renderer,配合scene.Differ, 后者从两端比较两个场景的操作),连同其遇到的读取背景的效果与那些 背景,在互不相交的矩形内进行(AddBackdrops):它是测试的渲染器、 是MYGO_GPU=0的渲染器、是 Linux 上 OpenGL 无法在 GPU 上运行时的 渲染器,也是窗口的 GPU 渲染器失败时回退到的渲染器。非表面的帧(首帧前的捕获)被保留而不绘制:OpenGL 上下文只在表面的 上下文中当前。被绘制的区域只经过其遇到的那些操作(其边界是 damage 的计算依据);大区域在至多八个核上绘制,按每次取 64 行的行带进行, 因为各行绘制量不同,每个像素的结果与整幅绘制时一致。效果在其像素 就绪前被预备(
EffectPixels.Begin),读取背景的效果要读取其他带绘制 的像素,所以区域先绘制到某个效果,计算其背景(backdrop.read,其行 与列也分摊到多个核),再从该效果绘制到下一个效果,按 16 行的带进行, 因为效果开销大且往往很短。玻璃插件的BenchmarkGlass——1360×720 像素带四个面板的窗口——在 M5 上整幅绘制 6.4 毫秒,单核 22 毫秒。BenchmarkFrame在 1844×2044 像素下的一整帧视图,从构建到绘制,在 Ryzen 7 8745HS 上耗时 0.7 毫秒而非 3.2 毫秒:内存带宽与全核较低的主频 使其无法进一步扩展。在 Linux 上,在内存中绘制的表面的
RequestFrame会给区域添加一个 tick 回调,GTK 的帧时钟在其更新阶段、绘制之前运行它:帧在那里绘制,PresentDamage保留其像素并请 GTK 重绘变化之处 (gtk_widget_queue_draw_area),draw信号在同一帧从这些像素绘制。 GTK 保留其缓冲区的其余部分:当合成器仍持有上一缓冲区时,把它从上一 缓冲区拷入新缓冲区,并只告诉合成器变化的部分:计数器的一位数字拷贝 24 KB 像素,而不是 4K 半屏的 15 MB,耗时 0.01 毫秒而不是 1 毫秒。 GTK 自己请求的绘制(如尺寸变化)在draw信号中绘一帧,并在下一帧 重绘其在 GTK 裁剪之外改变的内容。在 Windows 上,
RequestFrame使表面的窗口失效,其WM_PAINT在消息 队列一空就到来,内存中画好的帧立即显示,经由 GDI 或 DirectComposition:没有什么把它们束缚在显示器上,一个请求每帧的绘制 (Painter.AnimationFrame)在 60 Hz 显示器上每秒画 5,000 帧。每次之后, 一个 goroutine 等待 DWM 的下一次合成(DwmFlush,经由platform.RefreshSurface),至少等半个刷新周期,因为它可能立即返回, 最多等两个;节拍帧(WM_PAINT,但尺寸变化的与FrameNow的除外) 等待它正如等待交换链的空位(refreshWaiter、windowHost.waitDisplay): 它一返回就经FrameNow绘制下一帧,主线程其间处理输入。使用 GPU 渲染器时,每帧都在 GPU 上绘制,微小的变化也是,因此手势 绝不会中途切换渲染器:在 CPU 上,一帧重绘玻璃效果或内容滚动其下的 模糊,在大窗口上要 30 到 70 毫秒,而 Metal 只要 2 到 3 毫秒。每个 GPU 渲染器只绘制变化之处(见上文)。
最后一帧两秒后(
frameIdle),宿主释放内存中绘制的帧缓冲——它与窗口 一样大——下一帧将整幅绘制,并告知表面进入空闲(platform.IdleSurface): Linux 的调用会执行 glibc 的malloc_trim。GTK 把它用 OpenGL 合成的 窗口绘进与重绘区域一样大的图像,整帧就是整个窗口,而 glibc 提高了 归放大块内存的阈值,释放时保留了一两块,4K 半屏即 15 到 30 兆字节。 这些定时器经由surface.Conn.Post在主线程上运行。失败的 GPU 渲染器(驱动重置、GPU 拔出、睡眠)会被释放,并在同一帧内 重建另一个(
ui/window.go):GPU 可能已经恢复,而在 Windows 上 WARP 顶上,经由交换链呈现,因为 flip 模型交换链一旦画入窗口,GDI 就无法再 显示帧。没有交换链时,帧画在内存中,并在一段从一秒倍增到半分钟的 等待后重试;在软件中代替窗口原有 GPU 绘制的渲染器以同样方式争取那个 GPU。首帧就没有 GPU 的窗口永久画在内存中。测试。
ui.Tester让视图在内存中的宿主(ui/headless.go)上运行, 使用 CPU 渲染器,按自己的时间(engine.clock)——该时间静止不动, 直到Tester.Advance推动它。其宿主不运行定时器;它记下各定时器的 期限,Advance运行到期的帧。伪后端的表面让核心的测试经由package mygo驱动内容窗口。ui中的BenchmarkFrame测量 CPU 上 一个大窗口的一帧,BenchmarkListScroll测量滚动一百万行列表的帧;BenchmarkDiff*测量 godiff 式窗口的帧——两个文本与富文本列表, 不变或滚动——连同其分配(TestDiffFrameAllocs记录它们)。GPU 渲染器的测试绘制gputest.Scene并与 CPU 渲染器的绘制比较:在 Windows 上于隐藏窗口中,在 macOS 上于离屏纹理中,在 Linux 上于 EGL 无需窗口创建的上下文的帧缓冲中(Mesa 的 surfaceless 平台,无 GPU 时 用 llvmpipe),分别用 OpenGL 3.3 与 OpenGL ES 3.0 各一次。
新控件组合元素(ui/widgets.go),用 Local 或 state 在帧间保存所需 状态,用元素标志(flagClickable、flagFocusable,……)声明其交互, 在 Draw 中绘制,并用 Tester 获得测试。新 GPU 渲染器用着色器的移植版 绘制 gpu.Builder 的实例,用 Atlas.Changes 同步图集,在 ui/gpu_<os>.go 中于 gpuRenderer 之后创建,并有一个测试把其对 gputest.Scene 的绘制与 CPU 渲染器的比较(gputest.Compare)。
CLI (cmd/mygo)
浏览器(
web.go):build -platform web(或js/wasm)以GOOS=js GOARCH=wasm和生产标志把应用编译到<out>/web,其中带有它自己的页面(webPage:WebAssembly 加载期间显示一个转圈动画)以及编译出它的那个 Go 的wasm_exec.js,两者必须匹配。dev -web从内存中提供页面、加载器和最近一次编译成功的构建(webDev),像桌面 循环那样在观察者的变化到来时重新构建,并通过服务器发送事件(/__mygo/events) 告知页面:构建成功之后的reload,以及带着编译器输出的build-error,页面会把 它显示在应用之上,直到某次构建编译通过。init渲染cmd/mygo/template:一个 Go 模块和一个用 Vite 构建的 TypeScript 前端,像示例那样并排放在项目根目录,在resources/icon.png画一个默认图标, 拉取模块,用 Bun 安装 JavaScript 依赖并生成客户端。package.json 从 mygo-cli 运行 CLI(bun run dev、bun run build),而在-mygo <checkout>时则用go run github.com/egoist/mygo/cmd/mygo,其 go.mod 用该检出的副本替换本模块。 mygo.config.ts 从 mygo-cli 导入defineConfig,或在指定检出时按真实位置之间的 相对路径从其packages/cli导入,并设置devUrl、devCommand和buildCommand(即dev:web与build:web脚本,它们运行 Vite 而绝不运行 mygo)、frontendDist(Vite 的dist)和out(build,于是两者不会碰面);vite.config.ts把开发服务器固定到devUrl的端口,且不监视开发中的应用和构建 产物。名称进入模板时按其语言转义(json、printf "%q"、html)。-template native改为渲染cmd/mygo/template/native(前端的模板是template/web):一个原生 UI 应用,带有其视图的测试和一个mygo.json,其模块 把 CLI 作为工具(go get -tool,或在检出的replace旁加一行tool),供go tool mygo dev和build使用;不用 Bun。若配置中没有前端,bindings保持为空,CLI 不写客户端。两个模板都会安装嵌在
cmd/mygo/template/shared/.agents/skills下的 agent 技能。install-skills [dir]在已有目录中刷新同一批打包文件,不读取项目配置, 也不替换无关的技能和额外的自定义文件。配置文件是
mygo.config.ts,或mygo.json(config.go、config_ts.go)。 对于前者,由 Bun(否则为 Node.js 22.6 及以上,22.18 和 23.6 之前的版本需带--experimental-strip-types)运行一个加载器,它导入该配置,等待其默认导出或以{ command }调用它,并把 JSON 写入临时文件;无论哪种情况,JSON 都经过同样的 检查。加载器经由运行时的标准输入传入,绝不放在命令行上:在 Windows 上运行时 可能是一个批处理文件(npm 的bun.cmd),而 cmd.exe 会在换行处截断命令行。 报错会指明正在使用的文件。defineConfig和配置的类型来自packages/cli/index.d.ts;TestConfigTypes使其接口与Config结构体保持同步。generate为主机构建应用,并以生成模式运行它(MYGO_GENERATE;此时RequestSingleInstanceLock立即返回 true)。在此之前,若配置中有i18n,它会 检查消息目录并写出其函数(见"消息目录"一节),dev和build也如此;i18n check只检查。dev -lang以MYGO_LOCALE把语言传给应用(在浏览器中, 经由go.env传入)。dev(dev.go、watch.go)像generate那样写出客户端(前端会导入它),在 项目目录运行devCommand,等待devUrl有响应,然后运行一个开发构建,用MYGO_DEV_URL指向它(没有devUrl时指向磁盘上的frontendDist)。构建的 打包方式与正式版相同:在 macOS 上是一个名为<name> Dev、标识符为<id>.dev的 bundle,放在.mygo/dev/<goos>-<goarch>中,于是只有 bundle 才有的功能 (通知、URL scheme)可用,其数据也与正式应用分开。构建先在暂存目录中组装,再 重命名到位,因此正在运行的构建保留自己的文件。- 构建以
MYGO_DEV=1运行,核心会把它从自己的环境中移除(dev.go):此后App.Relaunch以退出码 75 退出,使mygo dev再次启动同一个构建。正式版 构建忽略它。构建的进程一旦存在就算启动:启动即崩溃的构建会在其退出时报告。 - 重新加载。 有变化(每 250 ms 轮询,去抖动)就重新构建;当可执行文件、 Info.plist 和图标没有变化时,什么都不重启。否则向正在运行的构建发送 SIGTERM (退出流程),3 秒后再发 SIGKILL,新构建在它退出之后才启动:构建从不重叠, 因此单实例锁、web view 的 profile 和应用的文件都是空闲的。只有应用收到 SIGTERM,这让它结束它启动的进程;它退出后仍存活的进程会被杀掉。编译失败的 构建让旧的继续运行;启动失败的构建则在下一次变化之前一个都不剩。
- 监视。 监视的正是构建读取的内容,每次构建后由
go list -deps得出: GOROOT 和模块缓存之外的已编译包的目录(因此本地replace的模块也算在内)、 被嵌入的文件、go.mod/go.sum、配置、图标和资源,但不包括其他平台的平台目录。 前端源码是开发服务器的事,从不触发应用重建。除非构建改变了被监视的内容, 否则它保留观察者的基线,于是在构建期间做出的编辑会触发又一次构建。在 Windows 上,观察者打开目录时共享删除权限(openDir),os.Open则不这样, 从而使删除或重命名它列出的目录不会失败。 - 退出应用会结束
mygo dev;崩溃则等待下一次变化。Ctrl+C 像一次变化那样 停止应用;再按一次会同时杀掉应用和开发服务器。观察者报告变化的路径,重新 构建时会点名它们。在终端键入的行是快捷键(r即使构建没有变化也重启,c、q、h);在 Unix 上,mygo 忽略 SIGTTIN,从而在后台读取终端输入时 只会失败,而不会让它停止。
- 构建以
输出(
console.go),在标准错误上:命令的每一步一行。在终端上(Windows: 一个在enableVT请求后接受 VT 序列的控制台),进行中的步骤有一个转圈动画, 以 12 fps 原地重绘并按宽度截断(termWidth,此外关闭自动换行),带着它的已用 时间和它在做什么:goBuild运行go build -v,显示它打印的导入路径和下载的 模块,其余的行——编译器的错误——留给它的错误;下载显示其字节数。步骤可以嵌套 (构建之中有签名)。CLI 运行的程序打印的内容经由labeled写入器:它们擦掉 转圈动画那一行,带上标签(web │、app │)整行写出,然后再把它画出来;应用 写入一个os.Pipe,而不是exec.Cmd的写入器,后者的Wait会等待持有它的 web view 进程,而带写入器的命令会得到一个WaitDelay。没有终端时,每一步有一 行开始和一行结束,没有颜色,除非设置了FORCE_COLOR(NO_COLOR无论如何都会 关掉颜色)。dev和build以一个横幅开场,横幅上的五个感叹号来自 MyGO!!!!!,用其成员的颜色,在终端上一个接一个点亮(保存光标、向上两行重绘, 直到某一行把它移走)。build生成客户端,运行buildCommand,然后把每个目标用-trimpath -ldflags "-s -w -X …production=1"(Windows 上加-H=windowsgui)和-tags mygo_noinspector编译进暂存目录——后者把原生 UI 的 inspector 排除在外 (除非MYGO_INSPECTOR=1)——于是失败的构建保留先前的产物。frontendDist的嵌入不触碰项目(embed.go):go build -overlay向主包添加一个生成的mygo_frontend_gen.go,其中带有//go:embed all:mygo_frontend和一个对SetFrontend的调用,并把每个frontendDist文件映射进那个虚拟目录,于是前端 可以放在任何地方。macOS 目标变成.appbundle(Info.plist、纯 Go 渲染的.icns,若设了macos.icon还有一个Assets.car,直接照搬或由xcrun actool从 Icon Composer 的.icon编译而来),用macos.signingIdentity签名(真实身份使用强化运行时和时间戳,默认 ad hoc);darwin/universal用纯 Go 的 fat-binary 写入器合并两种架构。Linux 得到一个.desktop条目和图标。生产 标志使IsDev为 false,从而默认禁用 web inspector。资源(
resources.go),与 quickgui 相同:项目resources/目录的内容,加上 配置中resources列出的文件和目录(以其基名),由build和dev一样地复制 到 macOS bundle 的Contents/Resources,以及 Linux 和 Windows 上可执行文件的 旁边;应用用App.Path(PathResources)找到它们(go run时是./resources)。resources/的平台目录命名为<goos>或<goos>-<goarch>(platformDir;其他工具使用的名字,如darwin-x64,是错误),只随该目标的 应用发布,其条目与共享条目合并:merger按忽略大小写把去往同一路径的内容分组, 只深入多个来源共享的目录,于是来自单一来源的整棵树仍是一个条目。对于darwin/universal,darwin-arm64与darwin-amd64成对遍历且名字必须匹配: 相同的文件和链接只发布一份,一个 arm64 和一个 x86_64 的 Mach-O 文件在复制时 合成通用二进制(writeUniversal),其他任何情况都会失败。以点开头的名字被 跳过;这些目录和列出路径的条目若是链接则跟随,其中的链接按链接复制,权限保留。 安装后的路径在忽略大小写后必须唯一(列出的资源从不合并),顶层名字不得替换打包 自身的文件(AppIcon.icns、Assets.car、可执行文件、.desktop条目)。其中的 代码先于应用签名(signNestedCode),因为codesign --deep只覆盖代码目录, 而公证会拒绝未签名的代码:先 Mach-O 文件,再装着它们的 bundle,最深的先来, 因为 bundle 的签名封存其内容(给主可执行文件签名即给整个 bundle 签名)。真实 身份给全部签名,并保留各自的权限(--preserve-metadata),除非macos.helperEntitlements指定了别的;ad hoc 签名只签没有LC_CODE_SIGNATURE的代码及其外围的 bundle,从而保留厂商的签名。Windows 构建用应用的证书或命令 给资源中没有证书表的 PE 映像签名。 应用的包可以在mygo-plugin.json中声明本地库(natives.go),每个平台一个 文件、其 URL 及其 SHA-256,如同终端插件声明 libghostty-vt:目标的go list -deps找到它们,CLI 把每个下载一次到<user cache>/mygo/natives/<sha256>/,校验其 SHA-256,并像列出的资源一样把它 安装到资源的顶层,通用应用的两种架构一起合并。CLI 和插件用internal/download下载到该缓存:5xx、408 或 429 响应,或失败、中断的连接,在 1、2 和 4 秒后重试 (GitHub 的发布下载时不时会回答 502 和 504),而对文件所在目录中.lock的 加锁(flock、LockFileEx)使同时需要该文件的进程只下载一次:其他进程等待, 然后就能找到它。没有这把锁,每个进程都会把自己的一份重命名覆盖到该文件上,而 另一个进程正打开或加载它时,Windows 会拒绝("Access is denied")。resources/icon.png是默认图标。每个平台的输出目录在一个暂存目录中组装,然后 整个替换它,于是移除的资源不会残留;Linux 和 Windows 上的开发构建会移除上一次 构建放置而这一次没有的东西。其他平台的包。Windows 得到
<name> Setup <version>.exe,用 NSIS 制作 (nsis.go):按用户安装到%LOCALAPPDATA%\Programs\<name>(否则由windows.installDirName命名该文件夹),更新器在那里有写权限,后续版本的安装 程序也装到那里(InstallDirRegKey读取卸载条目的InstallLocation),一个 开始菜单快捷方式,一个桌面快捷方式——由完成页面的第二个复选框(MUI 的 "show readme" 那个)创建——以及一个注册在HKCU\…\Uninstall\<identifier>下的卸载程序;/S /D=<dir>静默安装,不创建桌面快捷方式。卸载程序从一个没人 等待的自身副本运行,所以它最后才删除应用的文件夹:卸载完成时文件夹已不存在。 它对每个快捷方式的重试删除最多持续 5 秒,因为资源管理器在新快捷方式出现几秒后 会打开它、且不共享删除权限,其间Delete会失败。windows.installerInclude指定应用自己的脚本,生成的脚本在 MUI2.nsh 和语言之后!include它 (nsisHooks);其中的customInit、customInstall和customUnInstall宏 各自只在!ifmacrodef下插入,分别运行于.onInit、安装节末尾,以及卸载节中RMDir /r "$INSTDIR"之前,如同 electron-builder 的做法;没有它,脚本原样不变。makensis来自已安装的 NSIS,或在 Windows 上——NSIS 在那里很少被安装——来自internal/nsis中Release所固定的那个发布的官方 zip,CLI 将其下载一次,按 其 SHA-256 校验并解压到<user cache>/mygo,如同 Tauri 的做法。它按顺序尝试Release.URLs中的 zip 副本,直到某个返回正确的 SHA-256:先是本仓库nsis-<version>发布的资产,然后是 NSIS 发布它的 SourceForge。SourceForge 的 下载主机曾连续宕机数小时,而其镜像又重定向到它,所以它不是唯一的来源;30 秒内 不发响应头的主机会让位于下一个。更新 NSIS 意味着发布那份副本(见 Releasing)。没有 NSIS 时,其他系统跳过安装 程序:它的 zip 只含 Windows 程序。签了名的应用也有签了名的卸载程序,与 Tauri 相同:!uninstfinalize(NSIS 3.08 及以后)让 makensis 在把生成的卸载程序放进 安装程序之前,对它运行mygo sign-uninstaller,CLI 像给应用签名一样给它签名。 可执行文件和 Windows 配置经由环境变量(MYGO_SIGNER、MYGO_SIGN_SETTINGS) 传给它们,因为 NSIS 会把脚本中的$读成它自己的语法。Linux 得到一个纯 Go 写成的 Debian 包(deb.go),其维护者是linux.maintainer,否则是 package.json 的author,再否则是应用名:应用在/opt/<name>,有linux.command时有一个 用它命名的/usr/bin链接,桌面条目(分类、注释、URL scheme;Exec是应用的 路径)和 hicolor 图标,依赖 GTK 3 和 WebKitGTK 4.1。包里的文件与更新归档相同; 由包管理器安装的应用不自更新(Updater.Enabled检查应用能否写入其安装位置)。 每个 Linux 构建还得到一个install.sh(installscript.go),一个 POSIX sh 脚本,各架构通用,它把归档为该用户安装到~/.local/<name>.app(更新可以替换 它),在有linux.command且那里没有其他程序时链接~/.local/bin/<linux.command>(并移除自己早先的~/.local/bin/<name>链接, 那是旧脚本创建的),在$XDG_DATA_HOME下注册应用的<name>.desktop(带绝对Exec和Icon路径)以及<name>.xml——它定义的类型的 shared-mime-info 包。随后,当ldconfig -p没有列出 WebKitGTK(4.1 或 4.0,即internal/linux加载的库)时,它会给出警告,附上在/etc/os-release所指发行版上安装它的命令, 而在无法判断时保持沉默(没有ldconfig,或缓存里没有 libc,如 NixOS 或 musl)。当用户的桌面条目运行的正是它更新的那个应用时,Update.Install会从 新版本重新注册它们(update.RefreshDesktopEntry,同一套逻辑的 Go 改写,测试 将其与脚本比对)。脚本接受给它的归档,否则用旁边同版本的归档,再否则,在有更新 时,取目标清单的url——用 sed 从mygo build写出的缩进 JSON 中读取。--uninstall只移除指向其安装位置的东西,包括应用注册的 URL 处理条目。在 macOS 主机上,macOS 目标还得到
<slug>-<version>-<arch>.dmg, darwin/universal 则是<slug>-<version>.dmg(dmg.go):CLI 放入应用的一份 副本(ditto --noclone,因为稀疏文件的克隆在 HFS+ 上占用的块比其副本少)、/Applications链接、卷宗图标,以及一个纯 Go 写成的.DS_Store(dsstore.go, 与 dmgbuild 的ds_store包逐字节相同),它在一个文件夹里排好 Finder 窗口的 布局;hdiutil据此创建一个可写的 HFS+ 映像,大小以其中文件所需为准;CLI 设置 卷宗的 Finder 标志;然后hdiutil convert用 LZMA 压缩它。全程不涉及 AppleScript 或 Finder 自动化,因此可以在无头环境和 CI 中工作。映像用真实身份 签名,并在开启macos.notarize时用notarytool公证并装订票据。keygen写出更新签名密钥(见上文 Updates 一节)。
配置放在可选的 mygo.config.ts 或 mygo.json 中(cmd/mygo/config.go):应用 元数据(图标默认为 resources/icon.png)、额外的 resources、前端(devUrl、 devCommand、buildCommand、frontendDist、bindings)以及 macos 一节(最低 系统版本、签名身份、应用与辅助程序的权限、DMG 标题、公证配置文件)。
测试
用 go generate ./ui 重新生成已入库的 UI 封装。本地库和着色器的生成命令列在 AGENTS.md 中。
| 套件 | 命令 | 覆盖 |
|---|---|---|
| core | go test . | 生命周期、退出、文档、IPC、channel、事件、Eval、协议、前端 URL 与提供服务、菜单、信任、单实例、dev 就绪信号(fake 后端);go test -run '^$' -bench . 度量 IPC 与自定义 scheme 的 Go 侧 |
| generator | go test ./internal/tsgen | TS 输出、json/v2 规则、源码查找;运行过 bun install 时用 tsc 检查输出的类型 |
| CLI | go test ./cmd/mygo | 配置、Info.plist、图标、通用二进制、模板、dev 的启动/就绪/停止(测试二进制扮演应用)、观察者与 go list 输入、控制台的输出(按终端显示的方式回放)、资源(平台目录、通用配对、暂存、冲突、dev 放置;为每个操作系统构建)、前端嵌入(用 overlay 编译一个应用)、对照 dmgbuild 基准文件的 .DS_Store、真实的 DMG(hdiutil);加 -short 时跳过构建和工具 |
| runtime | bun run test | 注入的运行时、mygo-runtime 和各插件的包(在真实运行时上对抗一个假的 Go 侧,plugins/fake-go.ts) |
| plugins | go test ./plugins/... | fetch 插件对抗 httptest 服务器,WebSocket 客户端对抗测试服务器(顺序、分片、ping、关闭握手);终端对 libghostty-vt 的绑定(布局、渲染、编码器、选择),它的伪终端,以及通过 Tester 用真实 shell 测试它的视图:键入、程序要求的按键、输入法、鼠标上报、选择与复制、粘贴、回滚缓冲、退出(库会被下载,或由 MYGO_GHOSTTY_VT 指定;-short 跳过它们);自动化插件的协议(请求、错误、以 JSON 表示的元素、输入事件)对抗假应用,以及它的套接字(权限位、陈旧与被占用的路径) |
| native UI | go test ./ui ./internal/text ./internal/scene ./internal/raster ./internal/svg ./internal/gpu/... | GPU 渲染器对抗 CPU 渲染器(Windows 上是 Direct3D,macOS 上是 Metal,Linux 上是 OpenGL);通过 Tester 的视图:输入、焦点、编辑、列表、覆盖层、填满字形图集的帧;文字排版与插入点几何;图集分区与重排;CPU 渲染器对照其公式;SVG 解析与绘制,含 FuzzParse;go test -run '^$' -bench . ./ui 为一帧计时 |
| GUI | MYGO_E2E=1 go test ./internal/e2e | 真实后端:从另一个进程经 plugins/automation 驱动的应用,IPC、channel、协议、Eval、几何、捕获、菜单、window.open、原生 UI(帧、点击、输入法替换键入的文字、文件拖放、辅助技术读取与操作;在 macOS 上还有键入——选中输入法时跳过——以及组合输入;在 Linux 上带 MYGO_GPU=1 时,OpenGL 在 GtkGLArea 里画出的内容,从第一帧起,以及内存中绘制的窗口何时要求用 GPU);Windows 上同样运行(GitHub Actions 的 windows-latest 运行器带有 WebView2) |
浏览器运行渲染器、文字引擎和 ui 包的 js 测试:GOOS=js GOARCH=wasm go test -c 构建一个测试二进制,页面用 wasm_exec.js 加载它,参数放在 go.argv,日志打到 控制台。在 Node 下($(go env GOROOT)/lib/wasm/go_js_wasm_exec),没有页面, WebGL 测试跳过,文字退回桩引擎。
XDG 变量让 URL scheme 测试能够检查 GLib 是否用它注册的处理器打开该 scheme;没有 它们,测试会把文件写进临时目录,而 GLib 看不到那些目录。
internal/fake 在调用 Run 的 goroutine 上运行其循环,并记录被求值的脚本,于是 测试可以断言页面恰好会收到什么。单元测试像真实程序那样,在 TestMain 中于主 goroutine 上运行 App.Run。
Linux GUI 测试在容器中运行,因为不用 cgo 意味着测试二进制可以交叉编译:
sh
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go test -c -o e2e.test ./internal/e2e
docker run --rm -v "$PWD:/work" -w /work -e MYGO_E2E=1 \
-e XDG_DATA_HOME=/tmp/xdg-data -e XDG_CONFIG_HOME=/tmp/xdg-config \
<image with libwebkit2gtk-4.1-0, xvfb, dbus> \
dbus-run-session -- xvfb-run -a ./e2e.testTestGlobalShortcutPortal 像在 Wayland 上那样通过桌面门户绑定全局快捷键,没有 门户时跳过。KDE 的门户在 X11 上也能工作,所以一个加装了 xdg-desktop-portal、 xdg-desktop-portal-kde 和 kglobalacceld(Plasma 6;Plasma 5 用 libkf5globalaccel-bin,由 D-Bus 激活)的镜像可以真跑它:在 dbus-run-session 之外启动 xvfb-run,好让由 D-Bus 激活的门户拿到 DISPLAY,设置 XDG_CURRENT_DESKTOP=KDE,在测试前启动 /usr/lib/*/libexec/kglobalacceld,并 安装 $XDG_DATA_HOME/applications/e2e.test.desktop,以便门户接受测试二进制的 app ID。它在 Debian 13(门户 1.20,Plasma 6.3)和 Debian 12(门户 1.16,Plasma 5.27)上通过;Ubuntu 24.04(门户 1.18,Plasma 5.27)不为任何应用绑定快捷键。
测试中的等待
测试绝不依据某件事花了多长时间来做判断。CI 运行器时快时慢、负载随机,所以先睡 一觉再检查、或几秒后放弃的测试,即使代码正确也时不时失败,而更长的超时只会让这 种情况更少见而非消除。因此:
- 用
internal/wait等待条件或事件本身:wait.Until(t, "the window closed", w.IsDestroyed)、wait.For(t, "the reply", ch),需要 context 的调用用wait.Context(t)。它的保险——一分钟——只是把卡死变成一次说明"什么从未发生" 的失败。它没有经过调参:等到头的等待,要么发现了丢掉事件的竞态,要么发现了 永远无法成立的条件,修复应该做在那里。 - 等待产品自己声称完成了的事,而不是随后才出现的副作用:备份的状态而非它的文件, 监视看到的标识而非其后的 stat,输入队列排空而非数字节数。
- 原生 UI 测试用
Advance拨动ui.Tester的时钟;其他时候时钟静止,于是动画、 延迟和双击不依赖机器。 - 测试中用计数(步数、分配次数)度量性能,只在基准测试中用时间度量,由
bench.yml跟踪。 - 检查"什么都没发生"的等待(对
time.After做select、没有可失败项)可以短暂 等待:慢机器只会让它们漏掉 bug,绝不会让正确的代码失败。
基准测试
.github/workflows/bench.yml 在 GitHub 的 macOS、Linux 和 Windows 运行器上度量 每一次推送到 main 的提交,网站在 /benchmarks 展示结果。scripts/bench.ts 运行它们:
sh
bun scripts/bench.ts run # 所有包的 Go 基准测试,以及应用体积
bun scripts/bench.ts run --e2e # 也包括 internal/e2e 的,需在桌面会话中
bun scripts/bench.ts run ./ui --count 3 # 一个包,较少次数它找出带基准测试的包,一次跑一个(-p 1),每个基准跑六次、每次半秒(-count 6 -benchtime 500ms),保留每项指标的中位数:ns/op、B/op、allocs/op 以及基准 自行报告的指标;MB/s 由 ns/op 推得。它还按 mygo build 构建正式版的方式构建 examples/hello 和 examples/counter-native,不带资源,记录它们的体积;加 --e2e 时还记录它们启动六秒后的内存(go run ./internal/idlemem app...,次数与 --count 相同):应用的进程连同它启动的进程一起计,hello 自己的进程则记作 hello/app。内存取各平台所示的口径:macOS 上是物理占用,Linux 上是比例集合大小, Windows 上是私有工作集。在 macOS 上,裸可执行文件的 WebKit 进程归属于对它负责的 那个进程(终端,或运行器),所以 idlemem 只统计与应用同时启动、且共享同一责任 进程的那些。GitHub 的运行器是虚拟机,显示器为 1024×768、缩放 1:在那里, counter-native 在 macOS 上的占用是 22 MB,而一台带 Retina 和 4K 外接屏的 M5 MacBook 给出 37 MB,其中一半是 AppKit 自己的分配(footprint -p <pid> 可拆解); 在 Windows 上其私有工作集是 14 MB,连同共享页的工作集为 39 MB,已提交内存为 62 MB。同一序列只与自己比较。--out 把结果写成 JSON,附带提交和运行器的 CPU。 internal/e2e 的基准为真实后端计时:页面调用 Go(PageCall、PageCallItems)、 流式传输(PageChannel)、接收事件(PageEvent)、从应用的 scheme 取数 (PageFetch1MB),以及窗口打开直到其 DOM 就绪或其原生 UI 构建了帧 (WindowOpen、ContentWindowOpen)。
工作流的最后一个任务把结果合并进 benchmarks 分支(bun scripts/bench.ts merge):history/<yyyy-mm-dd>.json 保留当天每个被度量的提交,一行一个; latest.json 按序列保留最近 300 个提交,页面打开时从 raw.githubusercontent.com 抓取它,于是无需部署就能看到结果。这些提交写着 [skip ci],Cloudflare 的网站 构建也认它,并且当推送与另一次运行的合并相撞时,会再合并到它上面。手动带 ref 启动时,工作流用当前的脚本度量那个提交,以补全历史。
页面找出使每个序列发生跳变的提交(website/src/lib/benchmarks.ts 中的 findSteps):某个提交自身的值、以及从它起五个值的中位数,与之前五个值的中位数 之差,超过该序列的噪声——噪声取之前二十个值的五倍中位数绝对偏差(不足十五个时取 它们的极差;时间至少 5%,内存和分配至少 1%,体积至少 0.2%,外加 16 字节或一次 分配的底线)。跳变之后,之前的值就被抛在身后。有动的序列需要跳变前十个值、跳变后 两个提交;平稳的序列(分配大多如此)只需五个、之后不必。图表用 ▲ 和 ▼ 标记跳变, 摘要列出被展示的、使结果跳变的提交,还有一张卡片把最新值与展示窗口里最早的几个 比较,好让回归在后续提交之后仍然看得见。GitHub 的运行器每次运行可能分到几种 CPU 中的任一种(Windows 的 EPYC 9V74 跑某些基准比 7763 快 40%),所以计时只在同一 CPU 上度量的提交之间比较,图表把最新提交的 CPU 画成主线、其余画成灰色。
Metal 的 BenchmarkRenderFillRate 基准覆盖 3456×2000 下的嵌套不透明层、大边框 环和堆叠阴影。它用现有的基准框架报告命令缓冲区的 GPU 时间戳;它等待单个离屏帧, 不度量显示调度或输入延迟,那是 internal/inputlatency 度量的(见下文)。 MYGO_FRAME_STATS 包含实例与批次计数、被剔除的操作、不透明核心和 Metal 特化 回退。MYGO_GPU_REQUIRED=1 让 Metal 优化一致性测试在创建不出渲染器时失败。它们 比较原始 BGRA8 与 float16 输出,对照禁用全部优化的版本和来自上游 f2eca9f7 的 着色器,包括画布和像素化精灵。
ui 中的 BenchmarkStressFrame 为这样的视图计时:它们每帧都改变所显示的一切, 窗口 900×640、缩放 2:600 个投着阴影、移动并变色的圆角盒子,300 个数字在变的 标签,裁剪盒子里被平移移动的 1000 行一列,一个 100,000 行的 List 每帧滚 3 行, 以及 12 张带阴影、文字和进度条的卡片组成的仪表盘。它的宿主(stressHost)把场景 交出去而不是在 CPU 上绘制,所以 ns/op 就是一帧被构建、布局和绘制的耗时。 BenchmarkStressFrameGPU 用 Metal(metal.Offscreen)绘制同样的帧,其 GPU-ms 是 GPU 的时间,页面把它显示为 GPU 指标;其 encode-ms 是 CPU 编码每帧的时间 (构建实例、上传图集中变化的部分、编码各 pass),即 Encode 指标,Metal 渲染器的 基准也报告它(BenchmarkRenderPanels:一页卡片要几百个批次)。
页面上对基准测试的说明取自它们的文档注释,站点构建时读取它们:注释应以基准的名字 开头。
输入延迟
go run ./internal/inputlatency 在 macOS 上度量原生 UI 中输入上屏所需的时间: 从事件到达,到显示它的帧抵达显示器,以该帧 drawable 的 presented 回调为准 (presentedTime)。每次运行是一个进程,其窗口在不激活应用的情况下显示,然后往 文本区里键入、用触控板滚动列表或拖动一个盒子,事件落在刷新周期内的任意时刻;或者 没有输入只做动画(anim,以及 heavy,它让 GPU 在每个刷新的大部分时间保持 忙碌),并打印延迟的构成:等主线程、处理事件,再到该帧 present(下一个 tick、 构建、拿一个 drawable),再到屏幕;并就各帧而言,主线程等 drawable 等了多久, 以及多大比例的刷新显示了新帧。窗口服务器会稳定在一种持续整次运行、每次运行又各 不相同的状态,所以要比较多次运行的中位数(-runs,默认六次),并交替构建。
在一台接了 120 Hz 外接显示器的 M5 上(各八次运行,2026 年 10 月),在文本区键入 一个字符平均 26.6 ms 上屏(第 99 百分位 31.9),触控板滚动列表 27.2 ms(31.4), 拖动 27.6 ms(31.7);在 60 Hz 内置屏上则是 33、46 和 43 ms。就滚动的 27 ms 而言:4.2 花在等 display link 的下一个 tick(半个刷新),0.4 花在构建并绘制该帧, 等 drawable 不花时间,22.7 从 drawable 到屏幕:窗口服务器在一次刷新时合成它已 收到的帧,显示器在帧 present 的那次刷新之后的第二次刷新显示那次合成,若帧在前一 次刷新的太晚时刻 present,则是第三次。裸 CAMetalLayer 窗口耗时相同。键入一个 字符的那帧立刻开始(停顿之后的帧在发起它的那次 run loop 遍历中绘制),但处理这个 键要 3.4 ms:文本输入要告诉输入法它改动的文字在哪里(textInputAdapter.changed), 这要对其段落做排版,这里是 1,800 个字符,随后该帧复用排版结果。
测量过而维持原样的做法:
- 在刷新周期的更晚时刻绘制,即把截止定在下一次 vsync 之前而非 tick 上,让帧能 显示更晚的输入:窗口服务器的合成时刻在各次运行中从 vsync 前半毫秒到后一毫秒半 不等,而 GPU 必须在那之前画完。当在 tick 上 present 的帧错过合成时,提前 3 ms 到 vsync 绘制在 29 ms 中省下 2 ms;而在它们赶上合成的那次运行里反而慢 11 ms, 且少画了五分之一的帧。
- 当帧晚一个刷新才显示时跳过一个刷新,以 presented 回调为据:在裸 CAMetalLayer 窗口里,跳过让多数运行从 25.9 ms 降到 17.5 ms,但 MyGo 的帧从 tick 到 present 耗时更长,跳过后仍然偏晚。
- CAMetalDisplayLink 把
preferredFrameLatency设为 1 或 2,比在 CADisplayLink 的 tick 上绘制多花一个刷新(120 Hz 下 33 对 17 到 25 ms),而且还让 layer 不在 其回调之外给出 drawable(同步调整大小的帧)。 - 两个 drawable 或随事务 present,各次运行稳定在相同的状态,没有站得住的 收益,而用两个 drawable 时帧还要等它们。
在 Windows 上,有一帧排在屏幕前面,交换链的帧延迟对象会告诉还剩多少位置 (Direct3D 11 及以上):在一帧等待期间绘制的那帧,在屏幕取走等待中的那帧之后才 present;下一帧在输入随后要求它时构建,读取那次刷新的输入,如同 Present 阻塞时 的情况。Linux 的帧跟随 GTK 的帧时钟。这两者都未度量:GitHub 的运行器和容器里没有 会刷新的显示器。
二进制体积
Go 链接器保留 main 可达的东西:从任何可达之处调用的函数、platform.Backend 以及后端经由的其他接口的每个方法,以及它们触及的数据(首次使用时才读的表也会被 整个链接进来)。所以,一旦每个应用都会运行的某段代码调用了一个可选功能,这个功能 就向每个应用收钱,哪怕那条路径从不执行。以下规则把 counter-native 从 10.8 MB 涨 到 12.2 MB 之后拉回 8.9 MB:
- 可选功能挂在自己的构造入口上。 每个应用都会触及的东西(
App.Run、NewWindow、默认菜单及其角色、init函数、区域设置解析)只通过功能在应用首次 使用时注册的函数调用该功能:文件菜单的文档角色和 Open Recent 调用linkedDocuments,它由第一次NewDocuments填充;App.Run经由Bind和NewEvent注册的东西写出 TypeScript 客户端;消息经由i18n.NewCatalog注册的 东西格式化日期。泛型构造器只在被实例化时才链接,所以它们是注册的好地方。 - 共享路径上不做反射方法查找。 一个可达的
reflect.Type.Method、reflect.Value.Method或MethodByName调用,若名字编译器看不见,链接器就会 保留每个经由接口使用的类型的每个导出方法:counter-native 的 2.5 MB。只有Bind、BindAs和带服务的插件这么干;MyGo 自己的服务用bindFuncs。text/template和html/template也会这样。 - 后端保持薄。
platform.Backend及其控制器的每个方法都会链接进该平台每个 应用,无论用不用,所以它们只做调用转换,把策略、解析和数据留在核心或功能自己的 包里。 - 重的包和数据留在需要的地方:放在子包或插件里(
ui/editor、ui/markdown、plugins/watch)而不是mygo或ui,也不放进init。仅导入 net/http 就 增加 2.3 MB。首次使用时才读的数据会被整个链接进来,所以要拆分共享路径只读其中 一部分的表,CLDR 的数字和日期就是这样;嵌入运行中的应用读的东西,而不是工具读的 东西(internal/l10n嵌入其消息时不带其描述)。
要检查一个改动,就按基准测试的方式,在改动触及的每个平台上,改动前后各构建这两个 应用:
sh
go build -trimpath -tags mygo_noinspector -ldflags "-s -w -X github.com/egoist/mygo.production=1" -o /tmp/counter-native ./examples/counter-native
GOOS=windows go build -trimpath -tags mygo_noinspector -ldflags "-s -w -H=windowsgui -X github.com/egoist/mygo.production=1" -o /tmp/hello.exe ./examples/hello当应用变大时,对不带 -s -w 的构建运行 go tool nm -size -sort size 可以看出 哪些符号涨了;go build -ldflags=-dumpdep ... 2> deps.txt 打印链接器遍历的每条边 a -> b:沿着到某符号的第一条边回溯到 main.main,就能看出它为什么在那里;并 搜索 <ReflectMethod>,它标记着保留每个方法的反射查找。
发布
Go 模块、CLI 和 npm 包共用一个版本号:
sh
bun scripts/version.ts 0.2.0 # mygo.Version, the CLI, every package.json and bun.lock
git commit -am "Release 0.2.0" && git tag v0.2.0 && git push origin main v0.2.0标签触发 .github/workflows/release.yml,它检查标签与各处版本一致 (bun scripts/version.ts --check),然后运行 bun scripts/publish.ts --provenance:它在构建 CLI 各二进制的同时发布 mygo-runtime 和各插件的包,每个 平台包在其二进制构建完成后发布,等 npm 把它们提供给包管理器后再发布 mygo-cli, 跳过 npm 上已有的版本,预发布版本打在 next dist-tag 下。npm 接受一个包几分钟后 才会提供它("Your package is being processed"),而包管理器即使取不到某个平台包 也能安装 mygo-cli;此后只要 bun 的 lockfile 里缺那个包,它就会把这个包排除在外, 即使加 --force。随后工作流向 Go 模块代理请求该标签,并创建 GitHub release。
它不再重复跑测试:ci.yml 在每次推送到 main 时为每个平台跑全部测试,GUI 测试也 包括在内,所以要给 CI 通过的提交打标签。测试之前,每个任务把否则会被测试下载进 用户缓存的东西先下载好(go run ./internal/prefetch):其平台的各插件本地库,以及 Windows 上的 NSIS。它们存放在 Actions 缓存里,键取自固定它们版本的文件(各插件的 mygo-plugin.json 和 internal/nsis/nsis.go),于是测试不依赖网络。
mygo build 下载的 NSIS(internal/nsis/nsis.go 中的 Release)在本仓库的一个 release 上有一份副本;固定新版 NSIS 的 MyGo 发布之前,需要先发布那份副本。下载官方 zip,确认其 SHA-256 与 Release 固定的一致,然后发布它,且不要把这个 release 设为 最新:
sh
curl -fLO https://downloads.sourceforge.net/project/nsis/NSIS%203/3.13/nsis-3.13.zip
sha256sum nsis-3.13.zip
gh release create nsis-3.13 nsis-3.13.zip --latest=false --title "NSIS 3.13" \
--notes "The official NSIS 3.13 zip, which mygo build downloads on Windows."这个标签不触发任何工作流,Go 模块代理也忽略它。
npm 把工作流认证为每个包的可信发布者(本仓库的 release.yml,在 npm 上该包的 设置里配置),而 npm 只对已存在的包允许这么做:首次发布改用仓库的 NPM_TOKEN 密钥。bun scripts/publish.ts --dry-run 展示将要发布什么。
添加功能
- 先设计公开 API,放在
package mygo:带类型、goroutine 安全、有文档、 零值合理。策略(默认值、校验、状态机)留在这里。 - 扩展
internal/platform,只加后端所需的最小机制:仅限主线程、同步,或带 一个恰好在主线程运行一次的回调。 - 在每个后端中实现它:
darwin、linux、windows、web、fake和unsupported(返回platform.ErrUnsupported或零值)。回调只创建一次,绝不 每次调用都创建。 - 接好核心:用
onMain/onMainValue跳转,或对异步的原生结果用postMainawait+deliver。调用需要运行中的应用时以needsApp开头,否则保存设置 直到Run应用它(见线程模型)。
- 测试:经由
internal/fake做单元测试,行为取决于工具包时在internal/e2e加一个 GUI 测试,并在容器里跑 Linux GUI 测试。 - 让不用它的应用保持同样大小(见"二进制体积"):改动前后各构建
examples/counter-native和examples/hello。 - 若页面运行时发生变化,修改
packages/bridge或packages/runtime,运行bun run test、bun run typecheck和bun run build,并提交internal/bridge/bridge.js。若生成的客户端发生变化,更新internal/tsgen/generate.go及其测试。 - 写文档:行为写进 Go 文档注释,平台差异写进 README。
平台差异
| 功能 | macOS | Linux | Windows |
|---|---|---|---|
| 监视文件 | FSEvents,每棵树一条流,每个文件不占描述符 | inotify,按目录 | ReadDirectoryChangesW,递归树用一个,另加父目录以察觉被重命名的目录 |
| 菜单栏 | 应用级菜单栏,安装默认菜单 | 每窗口一个 GTK 菜单栏,默认没有 | 每窗口一个 Win32 菜单栏,默认没有 |
| 菜单项图标 | NSMenuItem 图像,16 pt,模板图像会被着色 | 一个 GtkBox,装着 GtkImage(按控件缩放比的 cairo surface)和标签;菜单栏自身的条目没有;托盘菜单的条目经 DBusMenu 收到 icon-data(dbusmenu_gtk_parse_get_cached_item) | Windows 11:由 MyGo 画在对勾列之后的一列中(预乘 BGRA,internal/menuicon);Windows 10:hbmpItem,一个 32 位预乘 DIB section,随其菜单释放;菜单栏自身的条目没有 |
| 自动隐藏菜单栏 | 忽略 | 菜单栏控件隐藏;can-activate-accel 保住其快捷键;单按 Alt 或 F10 使其显示并打开第一个菜单,直到其失活 | 只在单按 Alt 或 F10 启动的 SC_KEYMENU 菜单循环期间挂上菜单;快捷键来自 webview |
| 托盘 | NSStatusItem,点击事件(Control 点击即右键) | AppIndicator:一个菜单;对带有 OnClick 监听器的图标,激活(双击) | 通知区域图标,点击事件 |
| 全局快捷键 | Carbon 热键 | X11:在根窗口上 XGrabKey(连同 Caps/Num Lock 变体),按键来自 GDK 过滤器。Wayland:XDG GlobalShortcuts 门户(见 Linux) | RegisterHotKey |
| 通知 | UserNotifications,仅限打包后的应用;Group 即 threadIdentifier;可选的 Sender 使用带已签名 entitlement 的通信通知;代理在启动时挂上,以处理启动应用的那次点击 | 经 D-Bus 的 org.freedesktop.Notifications;忽略 Group 和 Sender | 通知区域的气泡(提示条);忽略 Group 和 Sender |
| 移除通知 | removeDeliveredNotificationsWithIdentifiers:;ClearNotifications 移除全部,包括早前运行的 | 总线上的 CloseNotification,只针对本次运行的 | 隐藏气泡,反正它自己也会消失 |
WebViewAvailable(任意 goroutine,Run 之前也行) | WKWebView 就在那里,macOS 总是有 | WebKitGTK 4.1(或 4.0)、JavaScriptCore 和 libsoup 能加载,如同 webKit 为显示页面的窗口加载它们 | internal/webview2 找到一个运行时,或可执行文件旁的 WebView2Loader.dll 导出了 createEnvironment 所需调用的函数;环境一旦建成,就说明这可行。文件都在但浏览器不在的运行时会通过检查,之后在创建 web view 时才失败 |
| 密钥 | Security.framework 登录钥匙串中的通用密码(SecItem*),service 与 account 的用法同 keytar | libsecret 的 org.freedesktop.Secret.Generic 架构(属性 service、account),首次使用时加载,libsecret 0.19 或以后;没有 libsecret、没有会话总线(g_bus_get_sync)或总线上没有 Secret Service(G_DBUS_ERROR_SERVICE_UNKNOWN、NAME_HAS_NO_OWNER、NO_SERVER)时为 ErrUnsupported | Credential Manager 的通用凭据(CredWriteW),目标为 service/account |
| 活力效果 | 全部材质,垫在页面和原生 UI 之后;macOS 26 起用 Liquid Glass(NSGlassEffectView),此前用 popover 和 HUD 材质;无边框窗口按 CornerRadius 裁切;SetActiveAppearance 经由 AppKit 私有的 _setForceMainAppearance: | 忽略 | Windows 11 22H2 的 Mica、Acrylic、Tabbed,垫在页面和原生 UI 之后,只用于创建时带材质的窗口,这类窗口没有菜单栏;Liquid Glass 视作 Acrylic;无边框窗口按 CornerRadius 由 DWM 倒圆角 |
| 强调色与无障碍偏好 | NSColor.controlAccentColor(10.14+)、accessibilityDisplayShouldReduceMotion(10.12+)、accessibilityDisplayShouldReduceTransparency 和 accessibilityDisplayShouldIncreaseContrast(10.10+),配合 NSSystemColorsDidChangeNotification 和 NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification;没有文字缩放 | GTK 的 gtk-enable-animations 与主题名,以及设置门户的强调色、对比度和 GNOME 的 text-scaling-factor(ReadAll、SettingChanged);没有减少透明度 | DWM 的 AccentColor、SPI_GETCLIENTAREAANIMATION、Personalize 的 EnableTransparency、SPI_GETHIGHCONTRAST 和 Accessibility 的 TextScaleFactor,配合 WM_SETTINGCHANGE |
| 首选语言 | 全局的 AppleLanguages(CFPreferencesCopyValue),而非应用自己的 | LANGUAGE,然后 LC_ALL、LC_MESSAGES、LANG;被问到才读,变化不上报 | GetUserPreferredUILanguages,WM_SETTINGCHANGE 的 "intl" |
| 区域格式 | autoupdatingCurrentLocale,其日历的首日星期、骨架 "j" 的小时;NSCurrentLocaleDidChangeNotification 和系统设置的分布式通知 | glibc 在 LC_TIME 下的 nl_langinfo_l(_NL_TIME_FIRST_WEEKDAY、T_FMT),否则按区域 | 用户默认区域的 GetLocaleInfoEx:LOCALE_IFIRSTDAYOFWEEK、LOCALE_STIMEFORMAT |
| 应用语言的保存 | 应用 defaults 里的 AppleLanguages | PathUserData 中的 locale 文件 | PathUserData 中的 locale 文件,以及进程的首选 UI 语言 |
| web view 的语言 | 取自应用的 AppleLanguages,下次启动生效 | webkit_web_context_set_preferred_languages,即时生效 | 环境的 Language,创建时指定 |
Theme.SetSource | NSApp 的外观(Aqua、DarkAqua,或不设) | gtk-application-prefer-dark-theme;浅色时还会把以其深色变体命名的主题(Yaru-dark)顶替其浅色主题(Yaru),否则 GTK 和 WebKitGTK 会把它当成深色;其他来源则重置主题 | 标题栏、菜单和原生 UI,页面则经由 DevTools 协议模拟的 prefers-color-scheme |
| 红绿灯按钮、Dock | 有 | 忽略 | 忽略 |
| 隐藏标题栏 | 全尺寸内容视图之上的 AppKit 红绿灯 | 页面之上 header bar 里的 GTK 标题按钮,按 gtk-decoration-layout;Wayland 合成器负责装饰窗口的地方没有 | 在分层子窗口中绘制的标题按钮,经 DirectComposition 垫在材质之上,按窗口分深浅(SetWindowButtonsTheme);贴靠布局;顶部边缘可调整大小 |
| 进度条 | Dock 图块的内容视图(NSBox:NSProgressIndicator 在那里不绘制),应用级 | 经 D-Bus 的 Unity launcher API(com.canonical.Unity.LauncherEntry),应用级 | ITaskbarList3,按窗口 |
| 文件图标 | NSWorkspace iconForFile:,按目标尺寸画进 sRGB 的 NSBitmapImageRep | GIO 的 standard::icon,或桌面条目的 GAppInfo 图标,经 gtk_icon_theme_lookup_by_gicon | IShellItemImageFactory::GetImage(SIIGBF_ICONONLY),带直接 alpha 的 DIB |
| 已安装的应用 | Applications 文件夹里的 .app bundle,用 NSFileManager displayNameAtPath: 和 NSBundle,在主线程之外;以 openURL: 启动 | g_app_info_get_all 按 g_app_info_should_show 过滤;以 g_app_info_launch 和 GDK 的启动上下文启动 | 在其自己的 STA 中枚举 FOLDERID_AppsFolder;以 ShellExecute 打开 shell:AppsFolder\<ID> 启动 |
| 徽标计数 | Dock 图块标签 | Unity launcher API 计数 | 不显示 |
| 跳过任务栏 | 忽略 | skip-taskbar 提示 | ITaskbarList::DeleteTab(不改动窗口样式) |
| FlashFrame | 提示性的 Dock 弹跳 | urgency 提示 | FlashWindowEx,直到获得焦点 |
| 在所有工作区可见 | NSWindowCollectionBehaviorCanJoinAllSpaces | gtk_window_stick | 忽略 |
| 窗口图标 | 忽略 | gtk_window_set_icon | 按窗口 DPI 的 WM_SETICON |
| URL scheme | Info.plist(urlSchemes);RegisterURLScheme 使应用成为默认处理器 | 桌面条目 + mimeapps.list | HKCU\Software\Classes |
| 下载 | shouldPerformDownload,不可显示或附件式的响应 → WKDownload 代理 | web 上下文上的 download-started / decide-destination;对附件的响应策略 | DownloadStarting(ICoreWebView2_4),取代 WebView2 的下载 UI |
| Cookie | 会话的 WKWebsiteDataStore 的 WKHTTPCookieStore,URL 在 Go 里匹配(platform.CookieMatchesURL),Set 借 NSHTTPCookie 的解析器完成,用一个替身的名字和值;Clear 用 removeDataOfTypes: | 会话 web 上下文的 WebKitCookieManager(2.42+;按 URL 用 get_cookies),存放在用户数据目录的 cookies.sqlite,或持久会话的目录里(set_persistent_storage,否则 WebKitGTK 只把它们留在内存);libsoup 3 或 2.4 分别对应 WebKitGTK 4.1 或 4.0;Clear 用 webkit_website_data_manager_clear | 会话 profile 下窗口 web view 的 ICoreWebView2CookieManager(ICoreWebView2_2);若还没有窗口显示页面,就用应用窗口中该 profile 的一个隐藏 web view,窗口一旦显示页面、或临时会话的存储一旦关闭,就把它关掉;窗口的第一次导航会等待进行中的调用;ClearBrowsingData 同理;变化用 GetCookies 轮询直到显现,2 秒后判为失败;GetCookies(uri)、DeleteAllCookies;put_Expires 以 comCallFloat 调用 |
| 内容规则 | 默认的 WKContentRuleListStore,把编译好的列表存在由 id 和规则哈希组成的标识下(先查它,并移除同一 id 的其他条目),挂到每个窗口的 WKUserContentController;存储给出的失败原因在 NSHelpAnchorErrorKey 里 | 用户数据目录 content-rules 下的 WebKitUserContentFilterStore(2.24+),挂到每个窗口的 WebKitUserContentManager | 在 Go 里匹配(internal/contentrules,按 url-filter 的域名及其字面量的四个字节建索引),从 WebResourceRequested 以 * 过滤器捕获;被拦截的页面回一个空的 403,其他被拦截的请求重定向到 data:,Chromium 会把它当作网络错误失败掉;WebSocket 握手从不经过它;css-display-none 规则做成一张由文档创建脚本采纳的样式表,页面设置 adoptedStyleSheets 时仍然保留;在 goroutine 上解析,不保留任何东西 |
FitContent | 桥,与其他处相同;不用 WebKit 私有的 _setSizeToContentAutoSizeMaximumSize: | 桥 | 桥 |
Page.ThemeColor | 桥,与其他处相同;不用 WKWebView 的 themeColor(macOS 12+) | 桥 | 桥 |
Page.ProcessID | web 内容进程,-[WKWebView _webProcessIdentifier](私有 API,用 respondsToSelector: 查找) | 0:WebKitGTK 没有相应 API | 应用各 web view 共享的浏览器进程,ICoreWebView2::get_BrowserProcessId |
页面的声音(IsAudible、SetAudioMuted) | 私有的 -[WKWebView _isPlayingAudio],键值观察,以及 _mediaMutedState / _setPageMuted:,置 _WKMediaAudioMuted 位、保留捕获相关的位;用 respondsToSelector: 查找,WebKit 不响应时即不可闻也不静音 | webkit_web_view_is_playing_audio 和 notify::is-playing-audio;webkit_web_view_set_is_muted / get_is_muted(2.30+,存在才绑定) | ICoreWebView2_8 的 IsDocumentPlayingAudio、IsDocumentPlayingAudioChanged 和 IsMuted,每次调用都查询;在 web view 创建前设置的静音会放进 setUp,在其第一次导航之前 |
BackgroundThrottling: ThrottlingOff | inactiveSchedulingPolicy 设为 WKInactiveSchedulingPolicyNone(macOS 14+),并关闭私有的 _hiddenPageDOMTimerThrottlingEnabled、_hiddenPageCSSAnimationSuspensionEnabled、_pageVisibilityBasedProcessSuppressionEnabled,当 window.open() 交来打开者的偏好副本时同样设置;私有的 -[WKWebView _setWindowOcclusionDetectionEnabled:NO];都用 respondsToSelector: 查找 | 关闭 web view 设置的 HiddenPageDOMTimerThrottling 和 HiddenPageCSSAnimationSuspension 特性(webkit_settings_set_feature_enabled,2.42+,首次使用时绑定);隐藏和最小化的页面仍会停止绘制 | 由启动环境的页面提出请求时(后来的会记日志),在环境选项的 AdditionalBrowserArguments 里加上 --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows;嵌入式 web view 的控制器仍会随宿主一起变为不可见,从而把其页面移出桌面 |
页面的捕获(CaptureState、SetCaptureState) | WKWebView 的 cameraCaptureState 和 microphoneCaptureState(macOS 12+)以及私有的 _displayCaptureState(macOS 13+),键值观察,其 setter set…CaptureState:completionHandler:(handler 传 nil),用 respondsToSelector: 查找;SetAudioMuted 的 _setPageMuted: 用同一个位管摄像头和麦克风,所以只静音其一时,另一个随后会被取消静音 | webkit_web_view_get_camera_capture_state 及其麦克风和屏幕的同类,连同各自的 notify:: 信号与 setter(2.34+,存在才绑定);webkit_web_view_set_is_muted 会丢掉捕获的静音,由 SetAudioMuted 重新设置 | 无:CaptureNone,设置不生效 |
Autoplay: AutoplayAllow | WKWebViewConfiguration 的 mediaTypesRequiringUserActionForPlayback 设为 WKAudiovisualMediaTypeNone;没有提出请求的 window.open() 窗口,会在打开者的配置副本上恢复新配置的默认值 | 在每次主框架导航上用 webkit_policy_decision_use_with_policies,配合 webkit_website_policies_new_with_policies 的 autoplay WEBKIT_AUTOPLAY_ALLOW(2.30+,存在才绑定;其可变参数的名字/值对按单对绑定,如同 g_object_set):WebKitGTK 依据导航的策略决定自动播放,默认是 WEBKIT_AUTOPLAY_ALLOW_WITHOUT_SOUND,而 media-playback-requires-user-gesture(默认关闭)并不会解除它 | 由启动环境的页面提出请求时(后来的会记日志),在环境选项的 AdditionalBrowserArguments 里加 --autoplay-policy=no-user-gesture-required;此后每个页面无需手势即可播放 |
画中画(PageOptions.PictureInPicture) | 私有的 -[WKPreferences _setAllowsPictureInPictureMediaPlayback:];-[WKWebView _canTogglePictureInPicture]、_togglePictureInPicture 和 _isPictureInPictureActive,用 respondsToSelector: 查找;主框架导航离开时视频还在画中画,就把 _setUsesPageCache: 关掉,直到某次导航离开时视频不在,因为 WebKit 恢复这样的页面时会以为视频还在外面 | 无:TogglePictureInPicture 不做事,IsPictureInPictureActive 为 false | 交给 Chromium,按选项取值;TogglePictureInPicture 经 DevTools 协议的 Runtime.evaluate 带 userGesture 执行 requestPictureInPicture()(或 exitPictureInPicture()),IsPictureInPictureActive 求值 document.pictureInPictureElement |
页面的会话状态(SessionState) | WKWebView 的 interactionState(macOS 12+,用 respondsToSelector: 查找),即 WebKit _sessionStateData 的 NSData;当前进/后退列表随后有当前条目时恢复 | webkit_web_view_get_session_state / _session_state_serialize、_session_state_new / restore_session_state,再对当前条目 go_to_back_forward_list_item(2.12+,首次使用时绑定) | 无:核心只保存 URL 并加载它 |
ClearBrowsingData、Session.ClearData | 会话的 WKWebsiteDataStore,allWebsiteDataTypes 或系统 WebKit 导出的对应种类的 WKWebsiteDataType | 会话 web 上下文的网站数据管理器,WEBKIT_WEBSITE_DATA_ALL 或各种类的类型 | WebView2 profile 的 ClearBrowsingDataAll,或按种类取 COREWEBVIEW2_BROWSING_DATA_KINDS 的 ClearBrowsingData(ICoreWebView2Profile2,经由 Cookie 调用所用的那个 web view) |
会话(PageOptions.Session) | 在每个 web view 的配置上设一个 WKWebsiteDataStore:由名字生成的 UUID 走 dataStoreForIdentifier:(macOS 14+;更早则用 nonPersistentDataStore 保留到应用退出),临时会话用 nonPersistentDataStore,其最后一个页面关闭时释放;window.open 把打开者的配置交过去 | 每个会话一个 WebKitWebContext,由 webkit_web_context_new_with_website_data_manager 创建,其管理器的基础数据与缓存目录放在用户数据和缓存目录下的 Sessions/<UUID>,或用 webkit_website_data_manager_new_ephemeral;web view 以 g_object_new 和 web-context 创建,上下文保留对其的引用;自定义 scheme、语言和下载设置在每个上下文上;临时会话的最后一个页面关闭时解除引用 | 每个会话一个 WebView2 profile(ICoreWebView2Environment10 的控制器选项:put_ProfileName 为 mygo-<UUID>,临时的用 mygo-private-<n> 加 put_IsInPrivateModeEnabled,其每个存储都用新名字);窗口、原生 UI 中的 web view 以及 Cookie 调用用的隐藏 web view 都以这些选项创建 |
HTTP 登录(SetAuthHandler) | webView:didReceiveAuthenticationChallenge:completionHandler:,为会话给出 NSURLCredential;不设置:不弹提示,显示 401 页面;空回答:RejectProtectionSpace | WebKitWebView::authenticate,为会话给出 webkit_credential_new;不设置:WebKitGTK 自己的对话框;空回答:不带凭据完成认证 | ICoreWebView2_10 的 BasicAuthenticationRequested,scheme 和 realm 从其挑战中解析,没有代理标志;不设置:WebView2 的对话框;空回答:Cancel |
证书错误(SetCertificateErrorHandler) | 每个连接的服务器信任挑战,设有处理器时用 SecTrustEvaluateAsyncWithError 检查,放行时对该连接用 credentialForTrust: | load-failed-with-tls-errors,仅主框架;放行用 webkit_web_context_allow_tls_certificate_for_host(应用级)并重新加载 | ICoreWebView2_14 的 ServerCertificateErrorDetected,每个请求,在 NavigationCompleted 失败之后;放行用 ALWAYS_ALLOW(应用级) |
页面的上下文菜单(OnContextMenu) | MyGoWebView 的 -[NSView willOpenMenu:withEvent:],针对菜单里带有 forwardContextMenuAction: 或 WKMenuItemIdentifier* 条目的情况:条目按其标识符命名,否则用 WebKit 的 tag 或 AppKit 的 action,就地清空再重填;目标只来自桥,按事件坐标匹配;应用的条目是 buildItem 造的,发往 mygoContextItemClicked: 和菜单所在的窗口 | WebKitWebView::context-menu:条目按其内置动作命名,标签取自其 GtkAction,状态取自其 GAction;命中测试除选中外都能告知;被改名的内置条目换成同一动作的新条目;应用的条目包一个 GSimpleAction(webkit_context_menu_item_new_from_gaction) | ICoreWebView2_11 的 ContextMenuRequested,带完整目标(Complete);菜单若有改动则 Handled,由 buildMenu 构建,推迟应答后跟踪,选中 WebView2 自带条目时看其 SelectedCommandId;没有 _11 的运行时从不询问 |
| 权限 | WKUIDelegate 的媒体捕获(摄像头、麦克风) | permission-request(摄像头、麦克风、地理位置、通知) | PermissionRequested(同样四种;WebView2 还会问其他) |
| 用户代理 | customUserAgent;UserAgentSuffix 是配置的 applicationNameForUserAgent,window.open() 的窗口不继承它 | WebKitSettings:user-agent;UserAgentSuffix 追加应用细节,在第一个 "/" 处切分 | ICoreWebView2Settings2 的 user agent,后缀追加到设置自带的 user agent 之后;没有 Settings2 的运行时两者都忽略 |
轻扫与捏合(SwipeNavigation、PinchZoom) | WKWebView 的 allowsBackForwardNavigationGestures 和 allowsMagnification,除非要求,两者都关 | WebKitSettings:enable-back-forward-navigation-gestures;WebKitGTK 没有捏合的设置,所以 web view 的 GtkGestureZoom(WebKitGTK 以 wk-view-zoom-gesture 存在其数据里,2.38+)除非开了 PinchZoom 就被设为 GTK_PHASE_NONE:那样它既看不见触控板的捏合,也看不见触摸屏的 | ICoreWebView2Settings6 的 IsSwipeNavigationEnabled 和 ICoreWebView2Settings5 的 IsPinchZoomEnabled,WebView2 里两者默认开,在 setUp、第一次导航之前按选项设置 |
| 浏览器的按键(重新加载、打印、查找、缩放) | 无 | 无 | WebView2 的(F5、Ctrl+R、Ctrl+P、Ctrl+F、Ctrl+Plus……)只在有 inspector 时(ICoreWebView2Settings3 的 AreBrowserAcceleratorKeysEnabled);AcceleratorKeyPressed 仍把每个键交给菜单 |
| 文件关联 | CFBundleDocumentTypes;文件经 application:openURLs: 到达 | 桌面条目 MimeType(%U),.deb 里为应用定义的类型(x-)附带一个 shared-mime-info 包 | 安装程序写入 ProgID 和 OpenWithProgids |
| 文档窗口 | representedURL(代理图标,Command 点击的文件夹菜单)和 setDocumentEdited:(那个圆点),随后红绿灯按钮重新排布 | 核心在标题前加 * | 核心在标题前加 * |
| 替换文档的文件 | -[NSFileManager replaceItemAtURL:withItemAtURL:…](权限、日期、扩展属性、ACL、身份) | 先 chmod 和 lchown 成目标的,再重命名 | ReplaceFileW,遇共享冲突重试;中途停下、目标已被移除时,把替换文件重命名到位 |
| 最近文档 | 共享 NSDocumentController 的 noteNewRecentDocumentURL: / clearRecentDocuments:(Dock 菜单) | gtk_recent_manager_add_item,移除 g_get_application_name() 的条目 | SHAddToRecentDocs(SHARD_PATHW)、IApplicationDestinations::RemoveAllDestinations;进程保留 shell 按其路径推导的 AppUserModelID,安装程序创建的快捷方式也带着它 |
| 跳转列表 | 忽略 | 忽略 | ICustomDestinationList,由 App.JumpList 保存的内容整体提交:Recent 分类、AddUserTasks 为每个任务一个 IShellLinkW(PKEY_Title、说明、工作目录、用 syscall.EscapeArg 转义的参数)、CommitList |
| Dock 菜单 | applicationDockMenu: | 忽略 | 忽略 |
| PrintToPDF | printOperationWithPrintInfo: 存为作业(NSJobSavingURL),适配宽度;核心把文字名称中的部首改写为字体实际绘制的表意文字(pdf_text.go) | WebKitPrintOperation 输出到 GTK 的 "Print to File" | DevTools 的 Page.printToPDF |
| 电源事件 | NSWorkspace 睡眠/唤醒,com.apple.screenIsLocked 分布式通知 | logind 的 PrepareForSleep(系统总线),屏保的 ActiveChanged(GNOME、freedesktop) | WM_POWERBROADCAST、WM_WTSSESSION_CHANGE |
| 正在播放与媒体键 | MPNowPlayingInfoCenter(其 nowPlayingInfo 和 playbackState;封面做成 MPMediaItemArtwork,被问到时才解码其字节)以及 MPRemoteCommandCenter 各命令的目标,信息里提供什么就启用什么;第一次 Set 时加载 MediaPlayer.framework | 一个 MPRIS 播放器(/org/mpris/MediaPlayer2 上的 org.mpris.MediaPlayer2 和 .Player,以 g_dbus_connection_register_object 导出),其总线名 org.mpris.MediaPlayer2.<app>.instance<pid> 只在显示内容期间被持有;每次变化发 PropertiesChanged,位置跳变时发 Seeked;封面写成一个 $XDG_RUNTIME_DIR 下的文件供 mpris:artUrl 引用 | 应用窗口的 SystemMediaTransportControls(ISystemMediaTransportControlsInterop::GetForWindow),带敏捷事件处理程序,缩略图用内存流上的 RandomAccessStreamReference,位置和跳转用时间线属性 |
| KeepAwake | NSProcessInfo activity(显示在 pmset -g assertions 中) | XDG 门户 Inhibit,否则 org.freedesktop.ScreenSaver.Inhibit | PowerCreateRequest |
| IsOnBattery、IdleTime | IOKit 电源、CGEventSourceSecondsSinceLastEventType | /sys/class/power_supply;Mutter 空闲监视器或 GetSessionIdleTime | GetSystemPowerStatus、GetLastInputInfo |
| 网络状态 | nw_path_monitor 在自己的队列上,第一次 Status 会等它的第一个路径(最多一秒);路径可用即在线,路径昂贵或受限即计量 | GNetworkMonitor,network-available(有默认路由)和 network-metered | GetNetworkConnectivityHint、NotifyNetworkConnectivityHintChange;从本地访问往上算在线(none 和 hidden 不算),固定或可变费用、漫游、超过或接近流量上限算计量 |
| 窗口位置 | 遵从 | Wayland 合成器忽略 | 遵从 |
| 无标题栏时的调整大小边缘 | 窗口自身的 | 页面外侧 5 px | 不可见,在窗口之外;沿无边框窗口或隐藏标题栏的顶部,是一个子窗口 |
| 内容保护、点击穿透 | 支持 | 忽略 | 支持 |
| 面板 | 带 nonactivating 样式的 NSPanel,浮动,作为全屏辅助窗口出现在所有 Space | 粘滞的实用窗口,保持在上方,不进任务栏和分页器;Wayland 上条件允许时用 gtk-layer-shell 的 overlay surface | 由隐藏应用窗口拥有的置顶 WS_EX_TOOLWINDOW,倒圆角 |
| 浮动窗口 | 无边框 nonactivating 的 NSPanel 子窗口,或 NSPopover;在可见框架内摆放 | GTK_WINDOW_POPUP(X11 上 override-redirect,在工作区内摆放;Wayland 上用 gdk_window_move_to_rect 的 xdg_popup);可获得焦点的那些抓住 seat | 被拥有的 WS_POPUP + WS_EX_TOOLWINDOW,除非可获得焦点否则加 WS_EX_NOACTIVATE,CS_DROPSHADOW 提供阴影,Windows 11 上由 DWM 倒圆角 |
| 自定义 scheme 的源 | <scheme>://<host> | <scheme>://<host> | http://<scheme>.localhost,另一个 host 时用 http://<host>.<scheme>.localhost(页面的 location) |
LoadHTML 的文档 | 重新加载会去加载 base URL(没有就是 about:blank),所以核心取消它(Navigation.Reload)并重新加载 HTML;不进历史 | 同 macOS | 同 macOS,但进历史:后退到带 base URL 的那个会加载那个 URL |
| window.open | 保留 opener | 独立窗口 | 保留 opener:新窗口的 web view 设置好后成为弹出窗口(put_NewWindow,在推迟应答之下) |
| 原生 UI 的 surface | layer-backed 的 NSView,帧来自 CADisplayLink(macOS 14 之前是按显示器频率的定时器),输入法经 NSTextInputClient | GtkGLArea(没有 GPU 时是 GtkDrawingArea),GtkIMMulticontext | MyGoSurface 子窗口,IMM32 |
| 原生 UI 的数据拖放 | NSDraggingSession、NSPasteboardItemDataProvider、NSDraggingDestination | GTK 拖拽上下文、MIME selection、text/uri-list | OLE IDataObject、IDropSource、IDropTarget、shell 拖拽图像 |
| 原生 UI 的无障碍 | NSAccessibilityElement 子类 | ATK 对象(GObject 类型经 purego 注册),由 GTK 桥接到 AT-SPI | UI Automation 片段(COM 对象;接收 double 的方法用汇编 thunk) |
| 原生 UI 的渲染 | Metal,画进 CAMetalLayer,画完即 present,调整其大小或显示 web view 的帧则配合 Core Animation 事务 | GtkGLArea 的 render 信号里的 OpenGL 3.3 或 ES 3.0;CPU 上用 cairo 绘制,此时 OpenGL 跑在 CPU 上 | Direct3D 11(没有 GPU 时用 WARP),flip 模型交换链按脏矩形 present |
| 原生 UI 中的 web view | WKWebView 放在 surface 之下裁剪视图里;hitTest: 让指针穿过 | WebKitWebView 放在 GtkOverlay 里区域之下的 GtkLayout 中;一个输入形状让指针穿过 | WebView2 合成控制器放在帧之下的 DirectComposition 目标里;surface 把鼠标转发给它 |
| 音频输出 | 默认输出上的 Audio Queue | PulseAudio simple API(PipeWire 也行),否则 ALSA default | WASAPI 共享模式,默认输出变化时重开 |
| 手柄 | GameController.framework,extended gamepad profile | evdev,内核的手柄布局,Xbox 的 X 和 Y 换回来 | XInput,四个插槽,Guide 键用 XInputGetStateEx |