Skip to content

桌面 API ​

MyGo 封装了桌面应用最常使用的操作系统部分,适用于窗口显示网页、原生 UI 或两者兼有的应用。与 MyGo 的其他部分一样,每个 API 都可以在任意 goroutine 中调用。

对话框 ​

mygo.Dialog 显示系统的文件和消息对话框。它的方法会阻塞直到用户作出应答:请在绑定的方法中调用它们(它们运行在自己的 goroutine 上),或者在事件监听器和菜单项中为它们启动一个 goroutine——后者应当尽快返回。Parent 将对话框附加到某个窗口上,在 macOS 上以 sheet 形式显示。

go
// 选择文件。
paths, err := mygo.Dialog.Open(mygo.OpenDialogOptions{
	Parent:   win,
	Title:    "Import",
	Filters:  []mygo.FileFilter{{Name: "Images", Extensions: []string{"png", "jpg"}}},
	Multiple: true,
}) // nil when canceled

// 选择保存位置。
path, err := mygo.Dialog.Save(mygo.SaveDialogOptions{
	Parent:      win,
	DefaultPath: "notes.json",
}) // "" when canceled

// 询问一个问题。
res, err := mygo.Dialog.Message(mygo.MessageOptions{
	Parent:        win,
	Type:          mygo.MessageWarning,
	Message:       "Delete 3 notes?",
	Detail:        "You cannot undo this.",
	Buttons:       []string{"Delete", "Cancel"},
	CheckboxLabel: "Don't ask again",
})
if res.Button == 0 { /* Delete */ }

// 报告一个错误。
mygo.Dialog.Error("Import failed", err.Error())

OpenDialogOptions 还可以选择目录(Directory)、显示隐藏文件,并在 macOS 上显示一条消息和一个"新建文件夹"按钮。在消息对话框中,Enter 激活 DefaultButton,Escape 激活 CancelButton,其默认值是第一个标签为 Cancel 或 No 的按钮。

通知 ​

go
n := mygo.NewNotification(mygo.NotificationOptions{
	Title: "Export finished",
	Body:  "notes.json was saved to Documents.",
})
n.OnClick(func() { mainWindow().Focus() })
if err := n.Show(); err != nil {
	log.Println(err)
}

Close 会移除一条通知。macOS 只为打包后的应用显示通知,也就是 mygo dev 和 mygo build 生成的应用;mygo.NotificationsSupported() 用于报告应用能否显示通知。Linux 通过桌面的通知服务显示,Windows 则以通知区域的弹出气泡显示。Show 在系统接收到通知后即返回。在 macOS 上,第一条通知会询问用户是否允许通知,Show 会等待答复;当通知不被允许时——无论是通过该答复,还是之后在"系统设置"中设置的——它返回 mygo.ErrNotificationsDenied。

macOS 会把通知保留在通知中心里,即使用户点击之后仍然如此,直到应用将其移除:在 OnClick 中调用 Close 把它带走,或者调用 mygo.ClearNotifications()——它会移除该应用的全部通知,包括之前几次运行的——在应用来到前台时:

go
n.OnClick(func() {
	mainWindow().Focus()
	n.Close()
})

// 用户已在应用中看到的通知不再停留在通知中心。
mygo.App.OnDidBecomeActive(func() { mygo.ClearNotifications() })

Linux 会请求桌面的通知服务关闭它们,而该服务可能早已关闭;Windows 则隐藏它的气泡。

一条通知可能比显示它的那次运行存在得更久:在 macOS 的通知中心里,点击它会再次启动应用。为它指定一个应用自己的 ID,每次点击都会由 App.OnNotificationClick 接收——只要它在 Run 之前注册,启动应用的那次点击也包括在内;显示的 ID 与仍在展示的通知相同的消息会将其替换。Group 把通知归入通知中心的一个堆叠组,例如某个会话的消息(macOS):

go
// ID 的格式为 "<conversation>/<message>"。
mygo.App.OnNotificationClick(func(id string) {
	conversation, _, _ := strings.Cut(id, "/")
	openConversation(conversation)
})

mygo.NewNotification(mygo.NotificationOptions{
	ID:    "ada/" + msg.ID,
	Group: "ada",
	Title: "Ada",
	Body:  msg.Text,
}).Show()

macOS 上的发送者头像 ​

在 macOS 12 及更高版本上,收到的私聊消息可以提供一个发送者,以在应用小图标旁显示醒目的头像:

go
mygo.NewNotification(mygo.NotificationOptions{
	ID: "message:7", Group: "conversation:3",
	Title: "Ada", Subtitle: "Lunch", Body: "See you at noon",
	Sender: &mygo.NotificationSender{
		ID: "user:ada", Name: "Ada", Avatar: avatarPNG,
	},
}).Show()

Sender.ID 在应用内标识这个人;它不会被推断为电子邮件地址或电话号码。Name 默认为 Title。Avatar 存放编码后的图像字节,如 PNG、JPEG 或 SVG;macOS 会在捐赠前将其栅格化为 PNG。应用负责获取这些字节,并决定消息的发送者和内容是否可以共享给系统。NewNotification 会复制发送者和图像,因此应用可以复用它的缓冲区。Group 是会话标识符;不设置时使用通知的 ID。这个 API 表示的是收到的私聊消息,不是群聊或发出的消息。通信通知的布局由 macOS 控制;副标题包含在正文中消息的上方。

在应用的 Apple App ID 上启用 Communication Notifications(通信通知),并将 INSendMessageIntent 添加到 macos.infoPlist.NSUserActivityTypes。使用 com.apple.developer.usernotifications.communication 签名并嵌入匹配的描述文件,Developer ID 分发也不例外:设置 macos.provisioningProfile,mygo build 会在签名前将其复制到 Contents/embedded.provisionprofile(参见描述文件)。描述文件必须授权应用的标识符、证书和 entitlements。不要把该受限 entitlement 加入 ad-hoc 构建:macOS 可能拒绝启动它。

MyGo 在把收到的消息捐赠给 SiriKit 并更新其通知内容之前检查已签名的 entitlement。缺少该能力、发送者信息不完整或头像无效,或捐赠/内容转换失败时,它发送普通通知。Windows、Linux 和不支持的平台保持原有行为。ID、声音、分组、点击处理程序、Close 和 ClearNotifications 使用相同的通知生命周期。

剪贴板 ​

go
mygo.Clipboard.WriteText("hello")
text := mygo.Clipboard.ReadText()

mygo.Clipboard.WriteHTML("<b>hello</b>")
mygo.Clipboard.WriteImage(png) // a PNG
img := mygo.Clipboard.ReadImage()

formats := mygo.Clipboard.AvailableFormats()
mygo.Clipboard.Clear()

页面也可以使用 Web 的 navigator.clipboard。

Clipboard.Write(transfer.Data) 和 Clipboard.Read(formats...) 共用原生的拖放模型:有序条目、备选表示、文件列表、自定义 MIME 格式以及惰性提供者。Formats 无需请求数据即可发现可移植的名称;ReadFormat、ReadFiles、WriteFiles 和 Flush 提供显式的协商、文件传输与持久化。有关提供者的生命周期和原生格式映射,请参见剪贴板与拖放数据。剪贴板示例演示了这些操作。

系统 Shell ​

go
mygo.Shell.OpenExternal("https://example.com") // the default browser
mygo.Shell.OpenPath("/Users/ada/report.pdf")   // the default app for the file
mygo.Shell.ShowItemInFolder(path)              // Finder, Explorer, the file manager
mygo.Shell.TrashItem(path)                     // to the trash
mygo.Shell.Beep()

TrashItem 永远不会彻底删除:当条目无法放入废纸篓时,它会失败并让条目留在原地。在 Windows 上,这包括对回收站来说过大的条目,或位于没有回收站的驱动器上的条目——网络共享和许多可移动驱动器就是如此——否则 Windows 会不加询问地直接删除它们。

FileIcon 返回文件管理器为文件、文件夹或应用程序显示的图标,以 PNG 形式给出,尺寸为按像素请求的大小:传入以 DIP 为单位的尺寸乘以显示器的缩放系数。

go
png, err := mygo.Shell.FileIcon("/Applications/Safari.app", 64)

在 macOS 上,它是 Finder 的图标(NSWorkspace),取自最贴合该尺寸的表示绘制;符号链接显示其指向对象的图标,没有 Finder 的箭头。在 Windows 上,它是资源管理器的图标(IShellItemImageFactory,只有图标,不含缩略图),路径也可以指向一个 shell 条目,例如通过 shell:AppsFolder\<AppUserModelID> 指向"开始"菜单中的应用。在 Linux 上,它来自图标主题中该文件内容类型对应的图标;对于 desktop 条目(.desktop),是其应用程序的图标,若主题中没有,则用 desktop 条目自身的图标。

InstalledApps 列出计算机上安装的应用程序,按名称排序,如同启动器显示的那样;LaunchApp 启动其中一个,或将其带到前台:

go
go func() {
	apps, err := mygo.Shell.InstalledApps() // reads the disk: off the main thread
	if err != nil {
		return
	}
	for _, app := range apps {
		fmt.Println(app.Name, app.ID)
	}
	icon, _ := mygo.Shell.FileIcon(apps[0].Path, 64)
	_ = mygo.Shell.LaunchApp(apps[0])
}()

每个应用都有系统显示的 Name(以用户的语言)、一个 ID,以及 FileIcon 据以绘制图标的 Path:

应用列表IDPath
macOS/Applications、/System/Applications、~/Applications 以及 CoreServices 的 Applications 中的 .app bundle,以及它们子文件夹(实用工具)中的 bundle,外加 Finder;位于符号链接之后的应用只解析一次链接,在链接处取,如 /Applications/Safari.appbundle 标识符;没有标识符的 bundle 则用其路径该 bundle
Windows"开始"菜单"所有应用"中的应用,桌面应用和打包应用皆含(shell 的 Applications 文件夹)AppUserModelID;没有该 ID 的桌面应用,则用其在已知文件夹下的路径shell:AppsFolder\<ID>
Linux菜单中显示的 desktop 条目(排除 NoDisplay、Hidden 或面向其他桌面的条目)desktop 文件 ID,如 org.gnome.Nautilus.desktop该 desktop 条目

从其他 goroutine 调用时,InstalledApps 不会阻塞主线程,并在几十毫秒内返回。绘制图标每个需要几毫秒,且在主线程上进行:只绘制要显示的图标,并把它们保留下来。

显示器 ​

mygo.Screen 描述已连接的显示器,使用设备无关像素,原点位于主显示器的左上角:

go
primary := mygo.Screen.PrimaryDisplay()
fmt.Println(primary.Bounds, primary.WorkArea, primary.ScaleFactor)

for _, d := range mygo.Screen.Displays() { /* ... */ }

cursor := mygo.Screen.CursorScreenPoint()
d := mygo.Screen.DisplayNearestPoint(cursor)

mygo.Screen.OnDisplaysChanged(func() { /* rearrange windows */ })

WorkArea 不包括菜单栏、Dock 和任务栏。DisplayMatching(rect) 返回与给定矩形重叠最多的显示器,即窗口所在的显示器。

深色模式 ​

mygo.Theme 是浅色或深色外观,页面通过 prefers-color-scheme 媒体查询跟随它:

go
dark := mygo.Theme.IsDark()
mygo.Theme.OnUpdated(func() { log.Println("dark:", mygo.Theme.IsDark()) })

mygo.Theme.SetSource(mygo.ThemeDark) // or ThemeLight, or ThemeSystem to follow the system

SetSource 可以在 App.Run 之前调用,例如传入用户保存在应用偏好设置中的外观:应用随后就会以该外观启动。

强调色与无障碍 ​

mygo.Theme.Preferences() 返回桌面自身控件的强调色以及这些控件遵循的无障碍设置,一次全部读取:

go
p := mygo.Theme.Preferences()
p.Accent             // a color.NRGBA (image/color), A 0 where the desktop has none
p.ReduceMotion       // the desktop asks for less motion
p.ReduceTransparency // the desktop asks for opaque surfaces, e.g. no vibrancy
p.HighContrast       // the desktop asks for more contrast
p.TextScale          // how many times larger text should be; 1 is the usual size

强调色是带直通 alpha 的 8 位 sRGB;ui.RGB(p.Accent.R, p.Accent.G, p.Accent.B) 可将其转换为 ui.Color,供你自己绘制时使用。

ReduceMotion 对应 macOS 的"减弱动态效果"、Windows 关闭"动画效果"以及关闭 GNOME 动画;ReduceTransparency 对应 macOS 的"降低透明度"和 Windows 关闭"透明效果",Linux 桌面没有这一设置,因此在 Linux 上为 false;HighContrast 对应 macOS 的"增强对比度"、Windows 的对比度主题,以及 Linux 上 settings portal(或 HighContrast GTK 主题)的高对比度设置。macOS 没有文本缩放,因此 TextScale 在其上为 1。当 Linux 桌面的 settings portal 没有强调色时,以及在浏览器中,强调色缺失。

当桌面改变其中任何一项时,Theme.OnUpdated 会触发,SetSource 也是如此。读取这些值可能需要调用桌面(如 Linux 上的 settings portal),因此 MyGo 只读取一次,并保留结果直到下次变化。ui 包的默认主题已经跟随强调色、对比度和文本缩放,Animate 则遵循减弱动态效果。与其他需要应用运行的调用一样,在 App.Run 之前于 main 中调用 Preferences 会 panic;由其他 goroutine 调用时,它会等待应用启动。

页面从文档开始加载起就能获取它们,即 mygo-runtime 的 preferences() 以及 --mygo-accent 和 --mygo-text-scale CSS 变量,参见前端指南;prefers-reduced-motion 和 prefers-contrast 已经跟随桌面设置。通过事件发送或由绑定方法返回的 ThemePreferences 就是同一份 JSON,生成的客户端会用 mygo-runtime 的 ThemePreferences 为其标注类型。

电源 ​

go
// 在长时间任务期间保持计算机唤醒,第二个参数为 true 时还保持显示器常亮。
release := mygo.Power.KeepAwake("Exporting the video", false)
defer release()

mygo.Power.OnSuspend(func() { pauseSync() })
mygo.Power.OnResume(func() { resumeSync() })
mygo.Power.OnLockScreen(func() { hideSecrets() })
mygo.Power.OnUnlockScreen(func() { /* ... */ })

onBattery := mygo.Power.IsOnBattery()
idle := mygo.Power.IdleTime() // since the last keyboard or mouse input

传给 KeepAwake 的原因会显示在系统列出"阻止睡眠的项目"的地方。在 Linux 上,屏保启动时调用 OnLockScreen,而屏保启动通常意味着锁屏;在不报告空闲时间的桌面上,IdleTime 为 0。

Now Playing 与媒体键 ​

mygo.NowPlaying 在系统显示媒体的地方展示应用正在播放的内容:macOS 上控制中心和菜单栏中的"正在播放",Windows 上音量浮动窗口和锁屏界面的媒体控件,以及 Linux 上的 MPRIS 客户端,如 GNOME 和 KDE 的媒体控件与 playerctl。当它有展示内容时,键盘和耳机的媒体键以及这些控件都会向应用发送命令:

go
mygo.NowPlaying.Set(mygo.NowPlayingInfo{
	Title: "Spring", Artist: "Vivaldi", Album: "The Four Seasons",
	Artwork:  cover, // PNG or JPEG
	Duration: 3*time.Minute + 20*time.Second,
	Commands: mygo.MediaPlay | mygo.MediaPause | mygo.MediaNext | mygo.MediaPrevious | mygo.MediaSeek,
})
mygo.NowPlaying.OnCommand(func(cmd mygo.MediaCommand, pos time.Duration) {
	switch cmd {
	case mygo.MediaPause:
		player.Pause()
		mygo.NowPlaying.SetPlayback(mygo.PlaybackPaused, pos) // where playback is
	case mygo.MediaSeek:
		player.Seek(pos)
		mygo.NowPlaying.SetPlayback(mygo.PlaybackPlaying, pos)
	case mygo.MediaNext:
		playNext() // and Set the next track's info
	}
})
mygo.NowPlaying.Clear() // the app no longer shows in the controls

当状态为 PlaybackPlaying 时,系统会自行推进播放位置,因此应用只在播放发生跳转时才需要再次设置它。播放/暂停键在状态为播放中时发送 MediaPause,否则发送 MediaPlay;只有信息中提供的命令才会到达 OnCommand(Commands 为 0 时提供播放、暂停和停止)。pos 是 MediaSeek 要跳转到的位置,对其他命令而言则是命令到来时的播放位置。使用 audio 包的播放器进行播放的应用,可以让 Control 负责播放、暂停、停止和跳转,并显示其状态:

go
release := mygo.NowPlaying.Control(music) // an *audio.Player
defer release()

Control 会监听播放器的结束事件;当应用自行播放、暂停或跳转后,用 NowPlaying.Sync 同步显示。在浏览器中,NowPlaying 就是页面的 Media Session。

网络 ​

mygo.Network 告知计算机是否联网,以及连接是否为按流量计费——例如手机热点或按量计费的套餐,此时应用应暂缓大文件下载:

go
status := mygo.Network.Status()
if status.Online && !status.Metered {
	syncNow()
}

mygo.Network.OnChanged(func(s mygo.NetworkStatus) {
	log.Printf("online: %v, metered: %v", s.Online, s.Metered)
})

Online 表示系统与计算机之外的网络建立了连接,与浏览器的 navigator.onLine 相同;它并不能证明有服务器在应答:认证门户背后的网络,或通往互联网的路径已中断的网络,同样算作在线。macOS 和 Linux 以存在外出路由为准(已满足的路径,GLib 的 network-available),Windows 则以连接级别达到本地访问或更高为准。在 macOS 上,Metered 指昂贵的路径(蜂窝网络、手机热点)或低数据模式;在 Linux 上指按流量计费的连接;在 Windows 上指费用为固定或可变的连接,或处于漫游、超出或接近数据限额的连接。在浏览器中,Online 即 navigator.onLine,Metered 为 false。

Status 在任意 goroutine 中都是安全的;在 macOS 上,首次调用会等待系统的第一次应答,这通常在几毫秒内到达。从第一次调用 OnChanged 开始就会监听变化,其监听器运行在主线程上,且仅在状态变化时触发;它返回一个用于移除监听器的函数。

页面本来就有 navigator.onLine。要在 UI 中显示原生状态,请在监听器中广播一个类型化事件,这样生成的客户端就能收到它:

go
// 连接变化时,Connectivity 以 {online, metered} 的形式推送到每个窗口。
var Connectivity = mygo.NewEvent[mygo.NetworkStatus]("connectivity")

func init() {
	mygo.Network.OnChanged(func(s mygo.NetworkStatus) { Connectivity.Broadcast(s) })
}

代理 ​

网页通过 webview 访问网络,webview 遵循用户在系统设置中配置的代理,包括其绕过列表和配置脚本(PAC 文件):WebView2 与 Chromium 相同,读取 Windows 的代理设置;WKWebView 读取 macOS 的设置;WebKitGTK 通过 GIO 的默认代理解析器,读取 GNOME 的设置,或通过 libproxy 读取 KDE 的设置。

Go 的 http.DefaultClient 只读取 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY。netproxy 包让 Go 的请求像页面一样遵循系统设置,MyGo 自身的请求也使用它:即更新器的请求,以及 fetch 和 websocket 插件的请求(除非其选项中指定了客户端)。你自己的客户端可以通过它的 transport 启用:

go
import "github.com/egoist/mygo/netproxy"

client := &http.Client{Transport: netproxy.Transport(), Timeout: time.Minute}

或者,对于你自己的 transport,设置 t.Proxy = netproxy.Proxy。对每个请求而言:

  1. 回环主机(localhost、*.localhost、127.0.0.0/8、::1)直接连接。
  2. 当设置了 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY 或 NO_PROXY(或其小写形式)时,由环境变量决定,与 http.ProxyFromEnvironment 相同,因此为应用某一次运行设置的代理优先。
  3. 否则由系统针对该 URL 的设置决定:在 Windows 上读取用户设置,由 WinHTTP 完成,支持自动检测(WPAD)和配置脚本;在 macOS 上由 CFNetwork 读取"系统设置 > 网络 > 代理"中的设置,支持配置脚本;在 Linux 上使用 GIO 的默认代理解析器,与 WebKitGTK 相同。没有结果时,请求直接连接。

系统对某个协议和主机的应答会缓存 5 秒,因此在应用运行期间开启、关闭或更改代理,会对之后的请求生效。进行中的请求(如下载或流)会保持其连接。Go 的 transport 支持 HTTP 和 HTTPS 代理以及 SOCKS5,不支持 SOCKS4;只有当系统的应答中包含代理密码时才会发送它,GNOME 的设置可以包含密码。在浏览器中,Go 的请求由浏览器通过其自身的代理发出。

机密存储 ​

mygo.Secrets 将小型机密(如访问令牌和 API 密钥)保存在系统的凭据存储中。凭据存储会为用户加密它们,不同于放在用户数据目录里的文件:macOS 上是登录钥匙串,Linux 上是 Secret Service(通过 libsecret 使用 GNOME Keyring、KWallet、KeePassXC 等),Windows 上是凭据管理器。

go
if err := mygo.Secrets.Set("github-token", []byte(token)); err != nil {
	log.Println(err)
}

token, err := mygo.Secrets.Get("github-token")
if errors.Is(err, mygo.ErrSecretNotFound) {
	// 请用户登录。
}

mygo.Secrets.Delete("github-token") // on sign out; nil when there is none

单条机密最大为 mygo.MaxSecretSize(2560)字节——这是凭据管理器能保存的上限,键最长 256 字节。要保存更多内容,可用存放在 Secrets 中的密钥将其加密后写入用户数据目录中的文件。这些方法在任意 goroutine 中都是安全的,也可以在 Run 之前调用;当系统向用户提问(例如解锁钥匙串)时它们可能阻塞;在主线程上调用时,应用在此期间仍会继续处理事件。要在页面中使用它们,请绑定一个调用这些方法的后端方法,这样只有页面需要的内容才会跨越到页面中。

存储以"服务"和"账户"两级保存机密:Secrets 将键作为账户,将应用标识符(mygo.config.ts 的 identifier)作为服务。mygo dev 的开发应用使用 <identifier>.dev,因此它的机密与已安装应用的机密相互隔离;没有标识符而运行的应用(如通过 go run)则使用其名称。Secrets.Service 可以选择另一个服务,以便与应用的其他程序(如其命令行工具)共享机密,或读取 Electron 应用用 keytar 保存的机密——keytar 会在应用传入的服务名之下,以与 Secrets 相同的方式保存它们:

go
// 对应 Electron 应用中的 keytar.getPassword("MyApp", "refresh-token")
token, err := mygo.Secrets.Service("MyApp").Get("refresh-token")

Secrets 防的是其他用户和被盗的磁盘,防不了同一用户下的其他应用:macOS 在其他应用读取前会询问用户,重建后的应用若未用同一身份签名,读取前同样会询问;而 Linux 和 Windows 则允许直接读取。

在没有凭据存储的地方,这些方法会失败,返回的错误满足 errors.Is(err, errors.ErrUnsupported):在浏览器中,以及在缺少 libsecret(Debian 和 Ubuntu 上的 libsecret-1-0 包)、会话总线或总线上的 Secret Service 的 Linux 上,如服务器和极简桌面。此时应用可以退而求其次,例如每次都要用户输入机密:

go
token, err := mygo.Secrets.Get("github-token")
switch {
case errors.Is(err, errors.ErrUnsupported):
	// 没有凭据存储:询问用户,保存在内存中。
case errors.Is(err, mygo.ErrSecretNotFound):
	// 请用户登录。
}

监视文件 ​

mygo.WatchFiles 监视一个文件或目录的变化;设置 Recursive 还会监视其中的目录。它在所有内容都纳入监视后返回,Next 等待下一批变化并成批返回;请在自己的 goroutine 中使用它:

go
w, err := mygo.WatchFiles(ctx, dir, mygo.FileWatchOptions{
	Recursive: true,
	Ignore: func(p string, isDir bool) bool {
		return isDir && (path.Base(p) == "node_modules" || path.Base(p) == ".git")
	},
})
if err != nil {
	return err
}
go func() {
	defer w.Close()
	for {
		events, err := w.Next(ctx)
		if err != nil {
			return // io.EOF once closed, or once dir went away
		}
		for _, e := range events {
			log.Println(e.Op, e.Path)
		}
	}
}()

事件的 Path 相对于被监视的对象(w.Root(),即解析了符号链接后的路径),以斜杠分隔,. 表示其自身;Op 是 mygo.FileCreate、FileWrite、FileRemove 或 FileRename,重命名时 OldPath 给出来源路径。已经存在的内容不会被报告。变化会先等待 50 毫秒的静默期(Debounce,为负值时立即报告)才被上报,且最长不超过 500 毫秒(MaxDelay)。监视器将看到的内容与此前看到的内容作比较,因此事件说明的是差异,而不是造成差异的每一步:把它们当作"重新读取文件"的提示即可。所谓写入,是指文件大小或修改时间发生变化的文件、系统报告被写入的文件,或路径上被另一个文件替换的文件(编辑器和原子保存就是这样替换的:临时文件被重命名覆盖到目标路径):权限及其他元数据(索引器和杀毒软件会触碰它们)不会被报告;目录的"写入"也不报告,目录的变化体现为其条目的变化。

在 Next 取走上一批事件之前,监视器不会再次扫描:当应用繁忙时,期间的变化会归入下一批,因此既不丢失也不堆积。监视器无法查看的内容(如无权限的目录)会被跳过,而不会终止监视。Ignore 按路径排除文件和目录:被忽略的目录永远不会被打开,也不计入 MaxEntries。

文件通过其所在目录被监视,正如 fsnotify 所建议的:被替换的文件(编辑器保存时就是如此)报告为 . 的写入,被删除的文件报告为删除,重新出现的文件报告为创建。当目录本身,或文件所在的目录被删除、重命名或替换时,最后一批事件中会包含一条 . 的 FileRemove,随后 Next 返回 io.EOF。

调用 Close,或取消传给 WatchFiles 的 context,都会停止监视:Next 返回 io.EOF,并且在释放其句柄后 Done 会被关闭。应用退出时会停止它的所有监视。当目录中的文件和目录数量超过 MaxEntries 时,监视会以 mygo.ErrWatchLimit 停止:macOS 上是 65536(一条 FSEvents 流覆盖整个目录树),其他平台是 8192(其后端可能为每个条目占用一个句柄)。watch 插件让页面监视应用选定的目录和文件,并说明各平台的监视方式。

HintsOnly 以精度换取开销。监视器不保存快照,也不对文件调用 stat:它只报告 mygo.FileHint 事件,每个事件指出一个目录——系统说该目录内或其下有变化;当系统丢失了变化时,则报告 .。哪个文件变了、如何变的、是否被重命名,都无从得知。它适合开发用的重载器——任何变化都触发重新构建:

go
w, err := mygo.WatchFiles(ctx, dir, mygo.FileWatchOptions{
	Recursive: true,
	HintsOnly: true, // only "something under this directory changed"
	Ignore: func(p string, isDir bool) bool {
		return isDir && path.Base(p) == "node_modules"
	},
})

在文件身份重要的场合绝不要使用它:跟踪文档、同步,或任何会依据它无法得知的路径采取行动的用途。启用它时,Ignore 只会被询问目录(即使被忽略文件发生变化,也仍会给出该文件所在目录的提示),MaxEntries 计算的是纳入监视的目录数。在 macOS 和 Windows 上,递归的仅提示监视只纳入其根目录,由系统整体监视;在 Linux 上,它纳入每个目录,读取其条目的类型而不对条目调用 stat。符号链接仍然不被跟随,目录之外的一切不被监视;被监视的目录消失时,仍会以一条 . 的 FileRemove 结束监视。

全局快捷键 ​

全局快捷键在应用处于后台时也能工作,例如把应用带到前台:

go
err := mygo.GlobalShortcut.Register("CmdOrCtrl+Shift+Space", func() {
	win.Show()
	win.Focus()
})

该函数运行在主线程上。快捷键采用accelerator 语法;用 Unregister、UnregisterAll 和 IsRegistered 管理它们。当快捷键已被占用时——被本应用占用,或在系统能够告知的情况下被其他应用占用——Register 会失败。

在 Linux 的 X11 下,应用自己抓取按键。在 Wayland 下应用无法这样做,桌面会通过 XDG desktop portal 为应用绑定快捷键,KDE Plasma 6 和 GNOME 48 及以后版本提供了该接口:桌面可能要求用户确认新的快捷键,并允许用户另选按键;而且它要求应用已经安装,因为它通过 desktop 条目识别应用。快捷键在 Register 返回后很快即可生效。对于函数所显示或聚焦的窗口,当桌面提供按键激活令牌时(如 GNOME),窗口凭该令牌获得焦点。