外观
前端
显示网页的窗口展示应用前端的页面:用任意工具构建的 HTML、 CSS 和 JavaScript。新项目使用 TypeScript 和 Vite,但 MyGo 只需要一个开发期间加载用的 URL, 以及一个构建产物目录用于构建。原生 UI的窗口不需要前端, 只有这类窗口的应用也就没有前端。
页面如何加载
窗口用不带 scheme 的 URL 加载前端的页面,例如 / 或 /settings?tab=general:
go
mygo.NewWindow(mygo.WindowOptions{URL: "/"})
win.Page().LoadURL("/settings")它们按以下基准解析:
- 在
mygo dev期间,对应 mygo.config.ts 中devUrl的开发服务器, 由devCommand启动,例如http://localhost:5173/, 以便开发服务器的热更新在应用内生效; - 在构建产物中,对应
mygo://localhost/,它提供frontendDist的文件——mygo build在运行buildCommand后把这些文件 嵌入可执行文件。
ts
export default defineConfig({
devUrl: "http://localhost:5173",
devCommand: "bun run dev:web",
buildCommand: "bun run build:web",
frontendDist: "dist",
});没有 devUrl 时,mygo dev 改为从磁盘提供 frontendDist。 没有文件扩展名且匹配不到文件的路径会提供 index.html, 因此客户端路由可以正常工作。
前端是一个安全上下文,其来源在 macOS 和 Linux 上是 mygo://localhost, 在 Windows 上是 http://mygo.localhost——Windows 上 WebView2 在 http://<scheme>.localhost 下提供自定义协议。localStorage 和 IndexedDB 这类存储归属于某个来源,因此页面在 mygo dev 下 (开发服务器的来源)存储的数据,并不是构建后的应用看到的数据。 需要保留的数据请放在 Go 中,例如放在 App.Path(mygo.PathUserData) 下的文件里,并通过绑定的方法交给页面。
窗口也可以向页面加载其他内容:
win.Page().LoadURL("https://example.com"),一个网页;win.Page().LoadHTML(html, baseURL),一段 HTML 字符串,重新加载时会 再次加载;win.Page().LoadFile("docs/index.html"),一个本地文件,先相对工作目录 解析,再相对可执行文件所在目录解析(以及 macOS 应用的 Resources 目录)。
非应用自身的页面不能调用 Go 方法:见 谁可以调用。
不使用 CLI
mygo build 通过调用 mygo.SetFrontend 嵌入前端, 用 go build 构建的程序也可以用 embed 做同样的事:
go
//go:embed all:dist
var dist embed.FS
func main() {
web, _ := fs.Sub(dist, "dist")
mygo.SetFrontend(web)
// ...
}自定义协议
mygo.Protocol.Handle 用一个 http.Handler 提供一个 URL scheme, 用于应用在运行时生成或读取的内容:页面、图片、用户的文件。 处理程序在自己的 goroutine 上运行,并流式输出响应: 当 webview 落后时写入会等待,因此大文件不会整体驻留在 内存中。页面加载这类 URL 与加载普通网页 URL 一样, 可用 fetch、<img>、ES module 和相对 URL:
go
// 生成到缓存目录的缩略图。
cache, _ := mygo.App.Path(mygo.PathCache)
mygo.Protocol.Handle("thumbs", mygo.FileServer(os.DirFS(cache)))
// http.Handler 能做的任何事。
mygo.Protocol.HandleFunc("api", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
})html
<img src="thumbs://localhost/photo-42.png" />请在创建使用这些 scheme 的窗口之前注册它们。 mygo.FileServer 提供 fs.FS 的内容,带内容类型和范围请求, 并像前端一样回退到 index.html。处理 mygo scheme 会用你的处理程序 取代前端。自定义协议的页面属于应用自身:它们可以调用 Go 方法, 见信任。
音频和视频是例外:WebKitGTK 的媒体播放器不能从自定义协议播放, 所以在 Linux 上,来自自定义协议的 <video> 或 <audio> 会以 MEDIA_ERR_SRC_NOT_SUPPORTED(error.code 为 4)失败,无论处理程序 如何应答。请改为通过回环 HTTP 服务器提供媒体,各平台行为一致, 并把该 URL 交给页面:
go
ln, err := net.Listen("tcp", "127.0.0.1:0") // 一个空闲端口
if err != nil {
log.Fatal(err)
}
go http.Serve(ln, mygo.FileServer(os.DirFS(videos)))
mediaURL := "http://" + ln.Addr().String() + "/" // <video src="{mediaURL}clip.mp4">机器上的每个程序都能访问这样的服务器:只向它提供允许读取的内容, 或在其路径中加入应用的一个密钥。
每个 scheme 都是独立的来源,因此页面获取另一个 scheme 就是 跨源请求:前端读取 api://localhost/,或在 mygo dev 期间开发服务器读取 thumbs://localhost/。 对应用自身页面(可调用 Go 方法的页面)的响应带有允许它们读取响应的 CORS 头,MyGo 也会应答它们的预检请求。 其他网站得不到这些头,除非窗口信任它们 (PageOptions.TrustedOrigins),处理程序也可以自行设置。 在 Windows 上,scheme 通过 http://<scheme>.localhost/ 获取—— WebView2 在那里提供服务——其他主机则通过 http://<host>.<scheme>.localhost/(myapp://abc/ 即 http://abc.myapp.localhost/)。这类主机是 DNS 名称(字母、数字、 连字符和点),并以小写形式呈现;带有其他主机或端口的 URL 不会在那里提供服务。
信任
窗口信任应用自身的页面:它的前端、自定义协议的页面、 file: 和 about: 页面(不带 base URL 的 LoadHTML 显示 about:blank)、PageOptions.TrustedOrigins 中的来源,以及开发 构建中从 localhost 提供的页面。信任跟随页面的 URL—— 无论在窗口还是 web view 中——并随导航而改变。 受信任的页面可以:
- 调用所有用
mygo.Bind绑定的方法,以及应用使用的插件; - 调用
window.mygo.window设置其窗口标题、最小化、 最大化、全屏或关闭窗口; - 无需权限处理程序即可获得摄像头、麦克风、位置和通知;
- 读取所有自定义协议的响应,这些响应带有为其准备的 CORS 头;
- 获取拖放到其上的文件的路径,以及其窗口文档的路径。
因此,绝不要在一个信任它的页面中,用自定义协议提供第三方或 用户提供的 HTML,也不要不带 base URL 用 LoadHTML 加载它: 插件的 UI 或用户 HTML 的预览会获得以上全部能力。 请改为在不受信任的页面中显示它,或从窗口不信任的来源加载, 例如一个 https 网站或 data: URL:它不会调用任何 Go 方法,除非某个权限处理程序授予, 它也不会获得任何权限。
不受信任的页面
PageOptions.Untrusted 使页面无论其 URL 如何都属于他人, 用于显示来自自定义协议的插件 UI 的窗口或 web view。 它不信任任何来源,包括 TrustedOrigins,并且比不受信任来源的 页面更进一步:MyGo 默认允许每个页面做的事,只有当应用的处理程序 决定允许时,不受信任的页面才能做。
- 它对绑定方法和
window.mygo.window的调用会被拒绝, 其拖拽区域不起作用。MyGo 会在解码之前丢弃它发出的超过 4 KiB 的消息, 其余消息在主线程上应答,不为每条消息创建 goroutine。 Broadcast不会到达它,向其 web view 所在窗口Emit也不会。请用EmitPage发送给它。- 没有
SetPermissionHandler它得不到权限,没有SetWindowOpenHandler它打不开窗口或浏览器,没有OnWillDownload监听器它不会下载任何东西, 没有OnWillNavigate监听器它不会跳转到除其页面自身来源以外的任何来源。
它仍然像任何页面一样读取自己的 scheme,其请求会到达处理程序, 处理程序用 mygo.CallerPage(r.Context()) 判断是哪个页面在请求。 这就是插件 UI 与应用通信的方式,而 EmitPage 是应用回应的方式:
go
mygo.Protocol.HandleFunc("plugin", func(w http.ResponseWriter, r *http.Request) {
p := mygo.CallerPage(r.Context())
plugin := pluginOf[p] // 发起该请求的 web view 所属的插件
if plugin == nil || r.URL.Host != plugin.ID {
http.NotFound(w, r)
return
}
if r.Method == http.MethodPost {
msg, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
...
PluginReply.EmitPage(p, reply)
return
}
http.FileServerFS(plugin.Files).ServeHTTP(w, r)
})
view, _ := win.NewWebView(mygo.WebViewOptions{
URL: "plugin://" + plugin.ID + "/",
Page: mygo.PageOptions{Untrusted: true},
})
pluginOf[view.Page()] = pluginjs
// 在插件的页面中。
await fetch("/message", { method: "POST", body: JSON.stringify(msg) });
window.mygo.on("plugin-reply", (reply) => { ... });页面也可以向其他主机和 scheme 发起请求,但读不到它们的响应: 处理程序在根据请求采取行动之前,应像上面那样检查页面。
有些东西会到达每个页面,无论是否受信任:
- 事件:
Broadcast发送到每个窗口的每个页面,Emit发送到 原生 UI 窗口的每个 web view。只发给某个页面请用EmitPage。 - 发给 Go 的消息:不受信任页面的调用会被拒绝,但无论消息多大, 应用都会先接收并解码它们——除非该页面属于不受信任的页面。
window.open和target="_blank"链接会在默认浏览器中打开http(s)URL,除非SetWindowOpenHandler另行决定 (链接与新窗口)。- 下载到 Downloads 目录,除非
OnWillDownload另行决定 (下载)。 - 页面可以导航到任何地方,除非
OnWillNavigate取消它 (页面)。导航到受信任 URL 的页面即受信任。
mygo-runtime 包
MyGo 把它的运行时以 window.mygo 注入每个页面。 新项目依赖的 mygo-runtime npm 包为它提供类型和导入; 生成的客户端构建于其上。
ts
import { currentWindow, isMyGo, onFileDrop, runtime } from "mygo-runtime";
if (isMyGo() && runtime().platform === "darwin") {
document.documentElement.classList.add("mac");
}
await currentWindow.toggleMaximize();| 导出 | 作用 |
|---|---|
call(method, ...args)、on(event, listener)、once(event, listener)、event<T>(name) | 无类型的调用和事件,见绑定 |
Channel | 来自 Go 方法的值流,见 Channel |
isCallError(err) | 判断一个 Promise 拒绝是否来自 Go 错误 |
isMyGo() | 判断页面是否运行在 MyGo 窗口中,而不是开发服务器的浏览器标签页中 |
runtime() | 运行时信息:platform("darwin"、"linux" 或 "win32")、windowId(Go 窗口的 ID())和 version;在 MyGo 之外会抛出异常 |
currentWindow | 页面所在的窗口:minimize、maximize、unmaximize、toggleMaximize、isMaximized、toggleFullScreen、close 和 setTitle |
onFileDrop(listener) | 拖放到窗口上的文件及其路径 |
preferences() | 桌面的强调色和无障碍设置;在 MyGo 之外会抛出异常 |
onPreferences(listener) | preferences() 的变化 |
locale()、direction()、onLocaleChange(listener) | 应用显示的语言(App.Locale),例如 "zh-Hans",其方向 "ltr" 或 "rtl",以及它们的变化;页面从文档开始就拥有这些,即 mygo.locale 和 mygo.dir |
mygo-runtime/i18n 格式化 ICU MessageFormat 消息,与 Go 中的 i18n 包一致。mygo generate 为应用的消息目录 写出的模块 locales.ts 构建于其上,包含每种语言的消息, 由前端的构建打包:页面以应用的语言说出最初的文字,无需等待请求。
在普通浏览器中打开时(这对调整布局很方便), 页面没有运行时:调用会被拒绝,isMyGo() 为 false。
自定义标题栏
无边框窗口没有标题栏和边框,由页面自行绘制。 用 CSS 属性 --app-region: drag 标记可拖动窗口的元素:
go
mygo.NewWindow(mygo.WindowOptions{URL: "/", Frameless: true})css
.titlebar {
--app-region: drag;
height: 40px;
}
.titlebar .search {
--app-region: no-drag;
}ts
import { currentWindow } from "mygo-runtime";
minimizeButton.onclick = () => currentWindow.minimize();
maximizeButton.onclick = () => currentWindow.toggleMaximize();
closeButton.onclick = () => currentWindow.close();该属性可继承:no-drag 可再次从一个区域中排除某些部分, 区域内的按钮、链接、输入框及其他交互元素仍可点击。 双击某个区域的效果与双击标题栏相同。
无边框窗口仍可从边缘调整大小。在 Wayland 下的 Linux 上, GTK 会自行装饰无边框窗口和隐藏标题栏的窗口,如同 GNOME 的应用—— 不带标题栏:它在页面周围绘制阴影和细边框,可在紧贴其外侧处 调整窗口大小,并在窗口最大化或全屏时去掉这些装饰。 在 Linux 的其他情况下,GTK 不给无装饰窗口边框(X11, 或设置了 DisableShadow、Transparent 或 Panel 的窗口), 页面最外侧的 5 px 充当边框:它们显示调整大小的光标, 且页面在那里收不到点击或指针移动。
在 Linux 上,无边框窗口还会保留 App.SetMenu 或 Window.SetMenu 给它的菜单栏,位于页面之上,因而也位于自定义标题栏之上。 请为这样的窗口设置一个自己的空菜单 win.SetMenu(mygo.NewMenu(nil)),改为从页面显示菜单 (Menu.PopupAt,由页面调用的绑定方法提供)。
隐藏标题栏
窗口可以保留其窗口控件而只隐藏标题栏,让页面延伸到 控件之下并绘制标题栏的其余部分:把 TitleBarStyle 设为 mygo.TitleBarHidden。控件保持在窗口顶部:
- 在 macOS 上,是红绿灯按钮。
mygo.TitleBarHiddenInset在它们周围 留出更多空间,TrafficLightPosition移动它们:即关闭按钮左上角 在窗口中的位置。 - 在 Windows 上,是最小化、最大化或还原,以及位于右上角的关闭, 按 Windows 11 绘制自身控件的方式绘制。它们填满标题栏的高度
TitleBarHeight(默认 32),悬停在最大化按钮上会打开贴靠布局。 窗口可从其顶边调整大小。 - 在 Linux 上,是 GTK 自身的标题按钮,位于桌面按钮布局指定的位置, 在
TitleBarHeight内居中(默认为桌面标题栏的高度)。布局决定 显示哪些按钮,可能一个都不显示,平铺窗口管理器常常如此设置。 自行装饰窗口的 Wayland 合成器(如 KWin、Hyprland 和 Sway)没有这些按钮: 它们自己的标题栏承载按钮,而平铺的合成器不显示任何按钮。 页面外侧的像素可调整窗口大小,如同无边框窗口。窗口的菜单栏隐藏, 如同AutoHideMenuBar:Alt 和 F10 在其菜单打开时把菜单栏显示在页面之上, 而其快捷键始终有效。
go
mygo.NewWindow(mygo.WindowOptions{
URL: "/",
TitleBarStyle: mygo.TitleBarHidden,
TitleBarHeight: 40,
})从首次绘制起,页面通过 :root 上的 CSS 变量获知控件所占的空间:
| 变量 | |
|---|---|
--mygo-titlebar-height | 标题栏的高度 |
--mygo-titlebar-inset-left、--mygo-titlebar-inset-right | 控件从左边和右边各占去的空间 |
在全屏时它们是 0px,此时控件隐藏(macOS 上它们随菜单栏一起回来)。 页面的标题栏避开控件,并用 --app-region: drag 标记以移动窗口:
css
.titlebar {
--app-region: drag;
height: var(--mygo-titlebar-height, 40px);
padding-left: var(--mygo-titlebar-inset-left, 0px);
padding-right: var(--mygo-titlebar-inset-right, 0px);
}Transparent: true 让背景透明的页面透出桌面, Vibrancy 在 macOS 和 Windows 11 22H2 及更高版本上在其后方放置模糊材质:
go
mygo.NewWindow(mygo.WindowOptions{
URL: "/",
TitleBarStyle: mygo.TitleBarHidden,
Transparent: true,
Vibrancy: mygo.VibrancySidebar, // Windows 11 上为 Mica
})完整的自定义标题栏见 examples/frameless。原生 UI 以相同方式 显示材质(活力效果), examples/vibrancy 即在嵌入式标题栏下用半透明侧边栏展示。
拖放文件
从 Finder 或资源管理器拖到页面上的文件会带着其路径到达页面, 而 DOM 的 drop 事件不会提供路径:
ts
import { onFileDrop } from "mygo-runtime";
onFileDrop(({ paths, x, y }) => {
const target = document.elementFromPoint(x, y);
// ...
});也会到达 Go,通过 win.OnFileDrop。页面自身的拖放仍然有效: 其 drop 监听器仍能拿到 File 对象,而在页面不处理文件的地方放下文件, 不再会用该文件替换页面。
深色模式
页面通过 prefers-color-scheme 媒体查询跟随系统外观。 在 CSS 中声明 color-scheme: light dark,让表单控件和 滚动条也跟随,并为窗口设置一个在两种外观下都与页面匹配的 BackgroundColor,窗口在页面绘制之前显示它:
css
:root {
color-scheme: light dark;
}go
mygo.NewWindow(mygo.WindowOptions{URL: "/", BackgroundColor: "light-dark(#f5f5f7, #1e1e1e)"})mygo.Theme.SetSource(mygo.ThemeDark) 强制一种外观, 页面通过同一媒体查询看到它。
强调色与无障碍
prefers-reduced-motion 和 prefers-contrast 自行跟随桌面。 桌面使用的强调色,以及它要求文本放大的倍数,是媒体查询不会 告诉页面的信息:它们从首次绘制起就是 :root 上的 CSS 变量。
| 变量 | |
|---|---|
--mygo-accent | 以 #rrggbb 表示的强调色;桌面没有强调色时不设置,因此 var(--mygo-accent, AccentColor) 回退到桌面自身的颜色 |
--mygo-text-scale | 文本放大的倍数;1 是常规大小,因此用 calc(1rem * var(--mygo-text-scale, 1)) |
css
:root {
--accent: var(--mygo-accent, AccentColor);
font-size: calc(1rem * var(--mygo-text-scale, 1));
}mygo-runtime 的 preferences() 返回这些设置本身,从文档开始 就是最新的,onPreferences 通知它们的变化——只有桌面会做出这些变化; 组件读取前者并订阅后者,如同 matchMedia 的用法:
ts
import { onPreferences, preferences, type ThemePreferences } from "mygo-runtime";
const apply = ({ reduceTransparency }: ThemePreferences) =>
document.documentElement.classList.toggle("opaque", reduceTransparency);
apply(preferences());
const off = onPreferences(apply);| 字段 | |
|---|---|
accent | 以 "#rrggbb" 表示的强调色,桌面没有时为 null |
reduceMotion | 桌面请求减少动效,对应 prefers-reduced-motion |
reduceTransparency | 桌面请求不透明表面:macOS 的"降低透明度"、Windows 关闭透明效果;在 Linux 上为 false,因为没有此类设置 |
highContrast | 桌面请求更高对比度,对应 prefers-contrast: more |
textScale | 文本应比常规大多少倍;1 为常规 |
发生导航的页面以当时的值开始:在文档开始告知这些值的脚本, 会在值变化时被替换。Go 一侧用 mygo.Theme.Preferences() 读取它们, 见原生。
由页面决定窗口大小的页面
浮动窗口的页面或带 FitContent 的窗口的页面会决定其大小,无需 ResizeObserver 或自己的绑定方法:MyGo 的运行时按内容的宽度布局 html,在窗口的限制之内,窗口随之采用根元素变化后的大小(见 窗口)。让 html 和 body 采用其内容的大小:给它们设 height: 100% 或 100vh 会让窗口保持现有大小,而给 html 设 width 会取代内容的宽度。 超过最大宽度的文本会换行,超过最大高度的内容会滚动。
链接与新窗口
带 target="_blank" 的链接和 window.open() 会调用窗口的打开 处理程序。没有处理程序时,http(s) URL 在默认浏览器中打开, 其他一律拒绝;用 win.Page().SetWindowOpenHandler 可另行决定:
go
win.Page().SetWindowOpenHandler(func(req mygo.WindowOpenRequest) *mygo.WindowOptions {
if strings.HasPrefix(req.URL, "https://docs.example.com/") {
return &mygo.WindowOptions{Width: 900, Height: 700} // 应用的一个窗口
}
go mygo.Shell.OpenExternal(req.URL) // 默认浏览器
return nil
})req.UserInitiated 区分用户打开的窗口——点击链接,或点击、 按键且其处理程序调用了 window.open()——与脚本自行打开的窗口, 例如来自定时器的弹出窗口或页面加载时弹出的。 请拒绝后者,比如嵌入页面中的广告:
go
win.Page().SetWindowOpenHandler(func(req mygo.WindowOpenRequest) *mygo.WindowOptions {
if !req.UserInitiated {
return nil // 弹出窗口
}
go mygo.Shell.OpenExternal(req.URL)
return nil
})在 Linux 上,WebKitGTK 自身就会拦截没有用户手势的 window.open(), 所以处理程序只会被询问由手势触发的那些。应用自己通过 Page.Eval 运行的脚本,在 macOS 和 Linux 上算作用户手势,WebKit 视其为手势。
win.Page().OnWillNavigate 可以取消页面自身的导航,例如让应用的 窗口停留在应用上:见窗口。
预加载脚本
PageOptions.PreloadScript 在窗口每个页面的自身脚本之前运行 JavaScript,此时 window.mygo 已存在。
它只在顶层 frame 中运行。PreloadAllFrames 会在页面每个来源的 iframe 中,于各自启动时运行它——浏览器需要借此查看从其他网站 嵌入的播放器加载了什么。window.mygo 不在 iframe 中: 要把 iframe 的发现告知页面,请通过 window.top 发送消息。
Web 检查器
开发构建带有 web 检查器:右键点击页面并选择 Inspect Element (Windows 上为 Inspect),或调用 win.Page().OpenDevTools()。 生产构建默认没有,除非用 mygo build -debug 构建,或窗口的页面在 WindowOptions.Page 中设置了 DevTools: mygo.DevToolsEnabled。
在 Windows 上,web 检查器自带 WebView2 自己的快捷键:F5 和 Ctrl+R 重新加载页面(重新加载不会询问 OnWillNavigate), Ctrl+P 打印,Ctrl+F 查找,Ctrl+Plus 缩放——且仅在检查器开启时生效。 没有检查器时(如生产构建),页面没有这些快捷键,与 macOS 和 Linux 一致,只有应用的菜单项会接管这些按键,例如"视图"菜单的 (RoleViewMenu)。