Skip to content

前端 ​

显示网页的窗口展示应用前端的页面:用任意工具构建的 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()] = plugin
js
// 在插件的页面中。
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)。