肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

Finder快速预览使用扩展模式苹果没告诉你的事

Finder Quick Look

最近我给 Writer 的 Finder 快速预览增加了 Mermaid 支持。Writer 应用内本来就使用 WKWebView 预览 Markdown,Mermaid 也是通过本地 JavaScript 渲染的。既然应用里能显示,那么把同一套渲染代码放进 Quick Look,不就行了吗?

我一开始也是这么想的。

Writer 原来的 Quick Look 使用 data-based preview。扩展读取 Markdown,生成完整的 HTML,然后通过 QLPreviewReply 把 HTML 交给 Finder。标题、列表、表格、代码块这些静态内容都没问题,但 Mermaid 不一样。Mermaid 代码块不是一张现成的图片,需要 JavaScript 解析代码,再生成 SVG。

问题就是,Finder 拿到 HTML,不等于它会像 Safari 或 WKWebView 那样执行里面的 JavaScript。

所以我决定把 Quick Look 改成 view-based extension。也就是不再把一份 HTML 数据交给 Finder,而是由扩展自己提供一个 NSViewController,里面放一个 WKWebView

这下总该和 Writer 应用内的预览一样了吧?

我修改了 Quick Look 扩展以后,在 Finder 中预览 Mermaid 文档,看到的仍然是一个普通代码块。Mermaid 源码还在,流程图没有生成。

这很像是 JavaScript 没有执行。但继续检查之前,必须先确认一件更基础的事:Finder 调用的到底是不是我刚刚修改的扩展?

因为 /Applications 里还安装着旧版 Writer。旧版扩展本来就能把 Markdown 转成 HTML,只是会把 Mermaid 当作普通代码块。所以当时看到的结果并不能证明新代码失败了,只能证明 Finder 找到了某个 Writer Quick Look 扩展。

我删除了 /Applications 里的 Writer。删除之后,Finder 一度退回了系统的纯文本预览。这证明刚才把 Mermaid 显示成代码块的,的确是旧版 Writer。

之后我安装了包含新版 view-based Quick Look 扩展的测试版。Finder 不再显示纯文本,右上角也出现了“Open with Writer”。这说明新版扩展终于被调用了。

可这一次,预览窗口变成了一片空白。

所以这里其实有两个完全不同的问题。Mermaid 显示成代码块,是 Finder 调用了旧版 Writer。真正调用新版扩展之后,遇到的问题才是 WKWebView 白屏。

如果不先排除旧版扩展,我很可能会继续在 Mermaid 初始化代码里找问题。可实际上,前一个结果根本不是新代码生成的。

写一个什么都不做的 Quick Look

我临时创建了一个最小的 macOS 应用,只注册一种自定义文件类型,再给它加入一个最简单的 Quick Look view extension。

这个扩展不解析 Markdown,不读取主题,也没有 Mermaid。它只显示一个原生的 NSView,上面写着:

Quick Look view extension is working

安装之后,Finder 正确显示出了这个界面。

这说明 view-based Quick Look 本身没有问题。Finder 能加载扩展,扩展也能提供自己的视图。

不过这还不够。Writer 真正需要的是 JavaScript,不是一个静态的 NSTextField。于是我又把探针改成 WKWebView,加载一段完全内嵌的 HTML,并运行一小段 JavaScript,让页面显示:

JavaScript check: 6 × 7 = 42

结果,探针也变成了白色。

到这里,问题已经很清楚了。不是 Markdown,不是 Mermaid,也不是 Writer 的主题代码。只要在 Quick Look 扩展里换成 WKWebView,页面就出不来。

本地 JavaScript 为什么需要网络权限?

我查看了系统日志,终于看到了真正的错误:

Application does not have permission to communicate with network resources.
Invalid connection identifier (web process failed to launch)

这就很有意思了。

我加载的是 loadHTMLString,HTML 在内存里,JavaScript也在应用包里,没有 CDN,没有远程图片,甚至连一个 HTTP 请求都没有。你告诉我缺少网络权限?

可日志里写得很清楚,不是页面加载失败,而是 WebKit 的 Web Content 进程根本没有启动成功。

我于是给 Quick Look 扩展加入了:

<key>com.apple.security.network.client</key>
<true/>

重新编译,重新安装。

探针页面显示出来了,JavaScript 也成功计算出了 42

故障排除。

按照苹果文档,这个 entitlement 表示沙盒应用可以主动建立网络连接。从字面上看,一个完全离线的 WKWebView 不应该需要它。可在我当前使用的 macOS 版本和 Quick Look 扩展环境中,没有这个权限,WebKit 的内容进程就无法正常启动。

我不敢说所有 macOS 版本、所有 Quick Look 扩展都一定如此。但至少在实际测试中,这不是推测,也不是所谓“可能和沙盒有关”。系统日志和最小探针已经把因果关系摆在这里了。

苹果告诉你 network.client 是用来联网的,却没有告诉你,在 Quick Look 扩展里,即使只加载一段本地 HTML,它也可能决定 WKWebView 能不能活着启动。

这才是这次最坑的地方。

给了网络权限,文档不就可以偷偷联网了吗?

我本来不愿意给 Quick Look 增加网络权限,就是因为快速预览的内容来自用户文件。Markdown 里可以写远程图片,也可以写 HTML。如果直接让这些内容进入一个能够联网的 WKWebView,那么用户只是在 Finder 里按了一下空格,文档就可能向外部服务器发出请求。

这肯定不行。

但这里要分清两件事。扩展拥有网络 entitlement,是为了让 WebKit 的进程能够正常启动,并不意味着页面里的内容也必须获得网络访问能力。

Writer 的 Quick Look 页面加入了严格的 Content Security Policy:

default-src 'none';
img-src data: blob:;
script-src 'nonce-writer-mermaid';
connect-src 'none';
frame-src 'none';
object-src 'none';

也就是说,页面默认什么都不能加载。图片只允许 data:blob:,脚本只允许 Writer 自己注入并带有指定 nonce 的 Mermaid 脚本,网络连接直接设为 none

在 HTML 进入 WKWebView 之前,扩展还会清理 scriptiframeobject、事件属性和其他主动内容。普通 Markdown 的本地附件会转换成 data: URL,远程图片则继续显示占位内容。

这叫给 WebKit 启动进程的权限,不叫给 Markdown 文件自由上网的权限。两者看起来差不多,实际完全不是一回事。

还有一个容易被忽略的问题

改成 view-based extension 以后,Quick Look 的入口也变了。

原来的实现是生成一个 QLPreviewReply。现在则是由 QLPreviewingController 实现异步的 preparePreviewOfFile(at:),自己创建并维护 WKWebView

如果调用 loadHTMLString 以后立刻告诉 Finder“准备好了”,WebView 其实可能还没有完成导航。于是我让扩展等待 WKNavigationDelegate.didFinish,失败、导航失败或者 Web Content 进程终止时,也会结束等待并返回真正的错误。

这不是为了架构漂亮,而是 Quick Look 的生命周期和普通应用窗口不同。应用里的 WKWebView 一直活着,晚一点显示也没关系。Finder 是来向扩展要一份预览的,扩展什么时候回答“准备完成”,本身就是协议的一部分。

同时,Mermaid 的 Tiny 构建和许可证也必须真正进入 .appex 的资源目录。放在主应用里不算。Finder 运行的是扩展进程,它不会因为资源在 Writer.app 的另一个角落,就自动帮你找到。

最后怎么确认不是自我感觉良好?

这类问题只看 Xcode 构建成功没有意义。Quick Look 扩展编译成功,不等于 Finder 正在使用它。Finder 正在使用它,也不等于 WebKit 进程成功启动。WebKit 启动了,也不等于 JavaScript真的执行了。

我最后做了几层验证:

  1. 删除已安装的 Writer,确认 Finder 退回系统纯文本预览。
  2. 安装最小探针,确认原生 Quick Look view extension 能正常显示。
  3. 给探针加入 WKWebView 和 JavaScript,复现白屏。
  4. 加入 network.client 后,确认 JavaScript 成功算出 6 × 7 = 42
  5. 安装新的 Writer,在 Finder 中预览 Mermaid 流程图,确认显示的是生成后的 SVG,而不是 Mermaid 源码。
  6. 再预览我实际使用的 iCloud Markdown 文档,确认标题、段落、编号和列表都正常。
  7. Writer 和 Quick Look 两个 scheme 都重新构建,16 个 Mermaid 测试全部通过。

其中最有价值的不是后面的测试全部通过,而是那个什么都不做的探针。没有它,我很容易继续怀疑 Markdown 渲染器、主题样式、资源路径或者 Finder 缓存。它把几十个变量砍到只剩一个:Quick Look 扩展里的 WKWebView 为什么启动不了?

总结

这次遇到的坑可以归纳成三件事:

  1. data-based Quick Look 能显示 HTML,不代表它会按照应用内 WKWebView 的方式执行 JavaScript。
  2. view-based Quick Look 可以使用 WKWebView,也可以运行 Mermaid,但在实际测试的系统环境中,即使内容完全离线,扩展仍然需要 com.apple.security.network.client,否则 Web Content 进程可能直接启动失败。
  3. 给扩展网络 entitlement 之后,仍然要使用 CSP 和 HTML 清理限制文档内容。权限是让 WebKit 活下来,不是让预览文件随便联网。

所以,Finder 的快速预览使用扩展模式以后,运行起来的确可以和应用内预览很接近。但这个“很接近”不是把 WKWebView 塞进 NSViewController 就结束了。

真正的结论是:Quick Look 扩展可以执行 JavaScript,也可以渲染 Mermaid。之前那片空白不是平台不支持,而是 WebKit 的进程根本没有成功启动。苹果没告诉你的,恰恰就是这一点。

macOS在工具栏上切换页面,TabView和Picker怎么选?

macOS

目前的SwiftUI,如果你希望通过工具栏的按钮直接切换标签,可以使用TabView,也可以使用Picker,不过在细节上,二者有所不同。

TabView

TabView是最简单的方式,只要你应用的根视图为TabView,那么你的系统架构会自动转成Navigation Tab Bar的方式,自动在工具栏显示Tab的标签。

不过这个方式有一种缺陷,就是应用的标题不会在工具栏上显示。如果你想强制限制,必须引入AppKit然后,覆盖Window的相应设置。

Picker

使用Picker的方式则更为友好。之需要在toolbar里添加Picker,然后使用segment样式,就可以获得同样的显示效果。然后使用Switch切换视图即可。

这么做不如直接使用TabView简单,但是可定制化强,并且不会影响标题的显示。

结论

如果不需要设置标题栏,使用TabView更为简便。否则就使用Picker,这样效果更好。

macOS系统菜单栏显示多图标的两种方式

macOS

一直以来都是使用的菜单栏单图标的方式。今天心血来潮,想在Focus原本的图标旁边新增一个刷新的图标,可以用来快速重置计时器。

把任务分配给AI,AI很快完成了第一版。我运行一看,怎么没看到新图标,再仔细一找,的确是两个图标了,但是两个图标是各自独立的,彼此之间还间隔了几个其它的图标。这和我看到的不一样,我其实希望的是像音乐播放器那样的,几个图标一体的方式。于是我和AI说明了要一体的。AI表示了解,然后重新生成了代码。AI特意解释说,一体之后,点击时就需要用点击的位置进行二次判断,还确定用户点击的是哪个图标。

最终结果我实验了一下,的确和音乐播放器的一样。看来他们也是这么做的。

SwiftUI应用伴随系统登录自动启动后显示macOS应用的窗口的办法

macOS

我们知道SwiftUI应用本身没有应用窗口的概念,它是使用WindowGroup来自动管理窗口的。这在用户手动启动应用的时候没有问题。但是如果你设置了应用伴随系统登录后自动启动后,SwiftUI的应用会存在一些问题。

这是因为这种方式启动应用后,SwiftUI的应用不会主动创建窗口,视图会在用户手动点击应用之后才创建。而这可能不是我们所需要的。因为如果我们有使用onAppear来执行一些代码。我们实际上是希望代码可以在应用启动时就运行,而这个机制会导致执行会推迟到用户点击时。

因此,我们需要保证即便SwiftUI没有生成Window,也要自己主动来生成Window。

问题分析

要解决这个问题,我们首先要了解这个启动的整个过程,然后才能知道如何来改进。具体调试的过程我就不讲了,最终我确认启动的过程是这样的:

  1. 用户登录。
  2. 应用启动。
  3. 应用在后台启动,但是没有主窗口,因此无法自动切换到前台。

我们可以使用NSApplication.shared.windows.count来判断。如果是用户手动打开的应用,SwiftUI会创建SwiftUI.AppKitWindow的窗口。而如果是跟随系统登录后后台启动,则不会有这个窗口。

我使用的判断函数是这个:

func isLaunchedAtLogin() -> Bool {
  NSApplication.shared.windows.count < 2
}

之所以用2,而不是1判断。是因为我还使用了菜单栏图标,菜单栏图标使用的是NSStatusItem,因此还会包含一个叫NSStatusWindow的窗口。

解决方案

func applicationDidFinishLaunching(_ notification: Notification) {
    createContentViewWindow()
}

/// 用户启动时SwiftUI的窗口会先创建,因此window不会为nil,但是有可能存在延迟,因为是通过.updateWindow还获取的。
/// 所以这里使用其他的方式进行判断,而不是使用window是否为nil
private func createContentViewWindow() {
  /// 若是用户点击,会有两个窗口,一个是SwiftUI创建的主窗口,一个status窗口。后者应该是对应菜单栏图标的。
  /// 如果是伴随系统启动,则SwiftUI创建的主窗口不存在,只有status的窗口。
  func isLaunchedAtLogin() -> Bool {
    NSApplication.shared.windows.count < 2
  }

  if isLaunchedAtLogin() {
    let contentView = ContentView()
      .environment(\.managedObjectContext, ModelProvider.shared.container.viewContext)
    let window = NSWindow(
      contentRect: NSRect(x: 0, y: 0, width: 400, height: 240),
      styleMask: [.titled, .closable, .miniaturizable, .resizable],
      backing: .buffered, defer: false)

    window.setFrameAutosaveName("Main Window")
    window.contentView = NSHostingView(rootView: contentView)
    window.center()
    window.makeKeyAndOrderFront(nil)
    self.window = window
  }
}

小结

传统上,如果我们使用loginItem来实现应用伴随系统启动,那么为了区分是用户手动启动,还是伴随系统启动,需要使用传递参数的方式。但是传递参数,就需要使用额外的launcher辅助应用。

设置辅助应用的步骤是很复杂的。因此,我们现在大多都是直接使用下面的代码来直接使用自动伴随系统登录启动。

try SMAppService.mainApp.register()

这个办法虽然大大简化了设置系统启动后启动应用的步骤。但是这么做之后,由于没有辅助应用,也就没法使用传递参数的办法了。

本文给出的使用NSApplication.shared.windows.count,用窗口数量来间接判断的方式,利用了SwiftUI在后台启动后,不会主动创建窗口的特性,解决了这个问题。

苹果表无法解锁macOS问题的一个奇葩的解决办法

macOS

我实在无法找到更适当的词,只能用奇葩来形容这个解决办法。

我开始使用macOS 26 beta系列也有一段时间了,目前在使用的最新版本是beta 7。长久以来,我一直遇到一个有些奇怪的问题,就是每次系统睡眠之后唤醒,苹果表的解锁总是失败。但是进入系统之后,在需要苹果表解锁的其它情况下,比如打开密码应用或者钥匙串,双击苹果表侧键解锁的这个功能却总是能成功。

不过苹果表解锁也不总是失败。我有两台Mac mini,一台是M1,一台是M4,M1的解锁就总是成功,而M4这台就总是失败。

尝试解决这个问题

今天我突发奇想,想要在AI的辅助下解决这个问题。我首先怀疑的时候VPN软件。因为更新到最新的macOS 26 beta之后,原来使用Clash X Pro不好用了,我不得不换成了Clash Verge rev。我问AI有没有可能是VPN软件造成的。AI说有可能。让我关了它再试。我试了。还是一样。

之后我又和AI一起开启了macOS控制台应用,想尝试通过读取日志来找到问题的解决方案。最后发现了一个loginwindow的一条故障日志,故障(红色)是比错误(黄色)等级更高的错误。它说在创建main文件夹的某个临时文件夹时出错,可能是沙盒的问题。我当时也信了,因为我的M4是256G的,为了节省磁盘空间,我将home文件夹设置到了外置的SSD上。我想这种比较罕见的设置,可能是苹果没有考虑过。我甚至还差一点儿就去跟苹果反馈这个问题。但是后来我放弃了。因为我觉得每次输入密码也还好,不算很麻烦,就没有反馈。同时我在想,以后我再买新电脑,一定要多花些钱,买个大一些的硬盘。

山重水复疑无路

的确。我没能解决这个问题。我只是放下了它。然后,我开始着手解决我的另外一个开源应用的小问题。

App Helper,应用助手。是我开源在GitHub上的一个macOS的助手应用。它的其中一个功能,是一键切换HDR模式,即在开启和关闭之间切换。不过,我发现,我的显示器,虽然支持HDR,但是使用不同的连接方式它对于HDR的支持不同。比如用USB-C线直接连接,就不支持HDR,用HDMI或者DisplayPort线连接,就支持。

所以,我的目标是通过系统API检测,在不支持HDR的时候,隐藏这个一键切换的按钮。因为我当前是USB-C的连接,不支持HDR,所以我首先完成了这部分的代码。

因为我还需要测试支持HDR下的部分。于是我同时使用HDMI进行连接。macOS有一个问题,就是你用两根不同的线连接同一个显示器,但是在macOS看来你就是在使用双显示器。它的显示器设置中,同时显示出两台显示器,并且不能设置禁用其中的一个。这样就比较麻烦了。因为我虽然可以通过显示器上的信号源菜单来切换到不同的接口,但是存在一个主副窗口的问题。

最终没办法,我只能将Mac mini上的USB-C连接显示器的那根拔了下来。将HDMI的连接作为唯一的连接,这样可以方便我进行调试。

柳暗花明又一村

这次HDR的选项还是不能显示。经过调试我发现,必须显示器先打开HDR模式,苹果的API才能检测出显示器支持HDR,如果没有开启,那就检测不出来。

那也无妨,大不了我就跟原来一样,不检测了。我又测试一键切换的功能。结果这个功能也不好用。这就比较郁闷了。因为苹果本身并没有提供切换的HDR切换的API,我使用的是脚本调用控件的方式,这个方法生效的前提是控件的位置必须固定。我管它叫数格子,脚本的写法类似,找到xx组的xx格子,然后把它上面的开关打开/关闭。现在系统升级了,位置变了。苹果💊。

算了,心累。我打算彻底去掉这个功能。毕竟,苹果动动手指,我就得重新数格子,并且还需要考虑不同版本macOS的兼容性,实在得不偿失。

休息,休息一会儿

休息结束之后,重新唤醒已经睡眠的Mac,手表传来熟悉的解锁声。我居然成功解锁了这台M4。又试了几次,无论是睡眠之后立即解锁,还是睡眠了几小时之后再解锁,都是次次成功。

“这是为什么呢?”(蔡明)如是说。“排除了一切不可能,那么剩下的那个无论多么不合理,就是唯一的可能。”——福尔摩斯。

我做的最大改动只有一点,使用HDMI连接显示器,并且拿掉了USB-C的连线。所以,这个问题的原因就是使用USB-C连接显示器,会导致苹果表无法解锁macOS。这谁能想到啊,你说是不是奇葩的解决办法?

特别说明,这个USB-C连接显示器,就是两边都是USB-C接口。而HDMI连接,则是用Mac mini独有的HDMI接口连接显示器的HDMI接口。

理论上,这个USB-C除了传递数据,还能同时获得显示器传来最大90瓦的电量。不过由于Mac mini的USB-C不支持反向供电,所以没啥用。如果比MacBook,是可以同时供电的。但是不知道是不是这一点影响了苹果表的解锁。我不是硬件工程师,不敢妄言。但是觉得这个值得提一下。

macOS 26 Beta版至今发现的兼容性问题及解决方案

macOS

我是从beta 4开始当成主力机使用的,因此从beta 4开始记录。之后会不定期更新。

Beta 4

命令system_profiler的参数发生改变

之前要查看USB设备,需要使用

system_profiler SPUSBDataType

但是新系统中,参数改变了,变成了SPUSBHostDataType。并且输出的结果中的属性也有了变化,需要进行对应的修改。

应用伴随系统启动的方式,不同的方法在设置中显示不同

如果要应用伴随系统启动,现在有两种方式:

  1. 使用Login Item应用,注册一个Login Item辅助程序,系统启动它之后,由它来调用主应用。
  2. 直接在主应用中调用SMAppService.mainApp.register()。

方法1是很早就有的方式。方法2是后来新增的方式。不过在新系统中,如果你使用的是方法1,那么在设置的启动项中,只有后台有主应用的名字,系统自动其中中不会有。如果是使用的方法2,那么启动项和后台中都会有主应用的名字。

两种方式都可以成功伴随系统启动。

系统常驻菜单栏图标消失问题的解决(下方2025年7月29日有更新)

新系统中,我发现有的应用的菜单栏常驻图标会消失。进一步调查我发现,如果你的菜单栏常驻图标中使用attributedTitle,并且重复设置了相同的值,常驻图标就会消失。

临时解决方案是,每次更改之前比较值是否发生了改变,变了再重新设置。

Beta 4 2025年7月29日更新

系统常驻菜单栏图标消失问题的解决(更新版)

上面的临时解决方案虽然在系统运行时可以解决问题,但是一旦系统睡眠,唤醒之后还是可能出现同样的问题。经过进一步研究,我发现了更好的解决方案:

假设你的代码是类似这种

class AppDelegate: NSObject, NSApplicationDelegate {
  private var statusItem: NSStatusItem?

  private func setupMenubarTray() {
    let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    self.statusItem = statusItem

    guard let button = statusItem.button else {
      fatalError()
    }
    
    // 其他代码
  }
}

那么将代码改成

class AppDelegate: NSObject, NSApplicationDelegate {
  private var statusItem: NSStatusItem?

  private func setupMenubarTray() {
    if self.statusItem == nil {
      self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
    }

    guard let button = self.statusItem?.button else {
      fatalError()
    }
    
     // 其他代码
  }
}

我想问题应该是出在NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)这个函数。它可能没有正确的释放。导致同时存在多个实例了。

Beta 5 2025年8月7日更新

Beta 5修正了MacCatalyst下.fullScreenCover的显示问题。上一版,会透出后面视图的内容。

双击打开文件SwiftUI版本的实现(基础篇)

macOS

我们知道,在使用AppKit的时候,我们通过NSApplicationDelegate的几个open方法来实现双击打开文件。

@MainActor optional func application(_ application: NSApplication, open urls: [URL])
@MainActor optional func application(_ sender: NSApplication, openFile filename: String) -> Bool
@MainActor optional func application(_ sender: NSApplication, openFiles filenames: [String])

但是在SwiftUI下,情况就有所不同了。我们这里所说的SwiftUI,指的是应用以SwiftUI的框架为主,而不是以AppKit为主,混合使用SwiftUI的情况。后者,和上面的情况没区别。

SwiftUI应用带来的变化

首先是,NSApplicationDelegate的这三个方法都发生了改变,后两个方法直接不会被调用了。而第一个方法经常会返回一个空的数组。这让人非常疑惑。

Handle multiple URLs from Open With in SwiftUI on Mac

经过查询,我知道,原来在SwiftUI下,SwiftUI的View本身可以处理一个打开的URL,而多余的,则需要用

@MainActor optional func application(_ application: NSApplication, open urls: [URL])

来进行处理。

吐个槽:这么做实在有些弱智。

所以我们所需要的正确做法就是:

  1. 在SwiftUI的应用中,使用onOpenURL(perform:)处理单个文件的双击打开。
  2. 如果是多个文件被双击打开,则通过NSApplicationDelegateapplication(_:open:)方法处理剩下的文件。

进阶篇

双击打开文件SwiftUI版本的实现(进阶篇)

macOS 13之后启动项实现的变化

macOS

有时,我们需要在用户开机之后,自动启动某个应用。这在之前,实现起来是比较麻烦的,但是macOS 13简化了它。

我之前写过两篇文章,分别对应macOS 13之前的版本,和macOS 13。

macOS应用登录时启动的实现方式
The Login Item in macOS Ventura

但是其中macOS 13的实现方式存在一些问题。比如我经常习惯的方式是,用户登录后运行某个应用,同时这个应用启动后,自动将自身从Dock移除,然后隐藏到系统菜单栏。但是使用第二篇文章的方式,应用本身并没有正确隐藏,而是显示窗口,同时应用也没有自动从Dock中移除。

当时我的理解是,macOS 13就是这么设计的。但是随着应用的增多,每次登录都要点击多次关闭按钮,让我觉得挺麻烦的,于是打算深入研究这个问题。

之前的实现代码

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService().register()
        } else {
            try? SMAppService().unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
    #endif
}

对比新旧两段代码,我们发现新代码并没有指定"com.parussoft.Stand-Reminder-Launcher",经过阅读SMAppService的文档,我发现,原来等效的代码应该是这个。

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService.loginItem(identifier: "com.parussoft.Stand-Reminder-Launcher").register()
        } else {
            try? SMAppService.loginItem(identifier: "com.parussoft.Stand-Reminder-Launcher").unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
    #endif
}

检验结果

是不是使用等效的代码就可以了呢?让我们验证一下。经过多次重启,以及注销-重新登录的操作,我发现等效的新代码,在macOS 13中并不是次次都能生效。有的时候,会出现主程序并没有被成功呼唤出来的现象。

当时我怀疑是不是并不是每次辅助程序都成功运行了。可是经过我的进一步测试,实际上辅助程序每次都是成功运行的,但是辅助程序调用主程序的时候,并不是每次都能成功调用。

所以我们需要修改的是调用的代码。

旧版

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        UserDefaults.shared.set(true, forKey: UserDefaults.Key.startFromLauncher.rawValue)
        
        let pathComponents = Bundle.main.bundleURL.pathComponents
        let mainRange = 0..<(pathComponents.count - 4)
        let mainPath = pathComponents[mainRange].joined(separator: "/")
        try! NSWorkspace.shared.launchApplication(at: URL(fileURLWithPath: mainPath, isDirectory: false), options: [], configuration: [:])
        NSApp.terminate(nil)
    }
}

改进代码如下,当调用主程序遇到问题时,等待两秒再重试,直到成功调用后,退出辅助程序。

新版

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        UserDefaults.shared.set(true, forKey: UserDefaults.Key.startFromLauncher.rawValue)
        
        let pathComponents = Bundle.main.bundleURL.pathComponents
        let mainRange = 0..<(pathComponents.count - 4)
        let mainPath = pathComponents[mainRange].joined(separator: "/")
        let url = URL(fileURLWithPath: mainPath, isDirectory: false)
        let configuration = NSWorkspace.OpenConfiguration()
        launchMainApp(url, with: configuration)
    }
    
    private func launchMainApp(_ url:URL, with configuration:NSWorkspace.OpenConfiguration) {
        NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in
            if error == nil {
                NSApp.terminate(nil)
            } else {
                DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { [unowned self] in
                    launchMainApp(url, with: configuration)
                }
            }
        }
    }
}

再次测试,发现一切正常,问题解决。

更简单的实现方式

传统的实现方式步骤是这样的:

  1. 创建主程序
  2. 创建辅助程序
  3. 在主程序中注册辅助程序为启动项
  4. 经过一些编译设置,达到如下的效果
    1. 用户登录之后,辅助程序先启动
    2. 辅助程序启动后调用主程序
    3. 辅助程序退出

新版也支持上面的方式。但是新版同时支持了更简单的实现方式。

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    do {
        if shouldEnable {
            try SMAppService.mainApp.register()
        } else {
            try SMAppService.mainApp.unregister()
        }
    } catch {
        print(error)
    }
    #endif
}

只需要将loginItem改为mainApp,就能够实现主应用在用户登录后自动启动。不过这种方式相比于原来的方式,比较粗糙,主程序只能有一种状态,只能隐藏或者不隐藏,而不能根据启动的方式不同来执行不同的任务。

这种方式的好处是简单,只需要有这段代码就可以了。不需要做额外的设置。

理解macOS 13的登录项设置

macOS 13刚出的时候,大家都对于登录项这个设置很陌生,不理解为什么它会分成两栏。根据我这段时间的测试,我目前已经理解了它两栏的含义。

login

如图是我目前系统中的登录项的设置。它有两项,一个叫登录时打开,一个叫允许在后台。很多人不理解它们的区别,下面我来解释一下。

登录时打开

登录时打开,只的是登录时,直接打开主应用。也就是说,应用的代码中应该要包含类似如下的代码:

try SMAppService.mainApp.register()

如果你的应用中不含这样的代码,就算你手动添加应用,添加完成之后,被添加的应用也不会在列表中出现。

允许在后台

允许在后台则是另外的情况,你的应用中需要包含类似如下的代码:

try? SMAppService.loginItem(identifier: "com.parussoft.Stand-Reminder-Launcher").register()

即你的应用中包含了辅助应用。辅助应用被系统在后台唤醒,然后辅助应用调用主应用。根据范围的不同,辅助应用可以分为Login Item,Login Agent以及Login Daemons。

What are the differences between LaunchAgents and LaunchDaemons?

调试时需要的注意事项

在开发者的电脑里,可能同时存在应用的多个版本,比如开发版,正式版等,它们存在于电脑中的多个位置。这可能导致在用户登录后,你看到的现象并非来至于你目前在调试的版本。

解决办法是用聚焦来进行搜索,删除掉应用的所有版本,然后在Xcode中重新运行当前版本,这样就能保证系统目前只有一个版本,方便调试。

允许在后台的删除

允许在后台包含了三种情况,其中Login Item对应的是应用程序本身,删掉应用就会自动删除。另外两种情况则需要找到对应的文件夹,删掉自动添加的配置文件才可以。

实例项目

owenzhao/LoginItem-Sample