macOS 实现软件开启自启动
介绍如何给 macOS 软件实现开机自启动,以及获取自启动注册和开启状态。
开机自启的软件并不都是流氓类软件。一些辅助类软件,比如我常用的 Magnet、Grammarly 等,都支持开机自启动。省去了用户想立马使用这些软件,而又不需要手动去启动台打开它们的繁琐。它们常驻在后台,却不消耗很多资源。
用户可以在设置中随时更改软件的自启动权限。

我最近在开发的一款菜单栏翻译软件最近也加入了这个功能。以下为实现软件自启动的步骤。
流程和框架
目前 Apple 推荐使用 Service Management 来实现自启动。即便应用打开了沙盒模式,也能通过这个框架实现自启动。实现自启动的流程大致如下:
- 创建一个启动项程序来辅助打开主程序。
- 通过 Service Management 将启动项程序被注册进 launchd 中。
- 每次用户开机并且登陆账户后,launchd 就会打开你的启动项程序。
- 通过启动项程序打开主程序,并将启动项程序退出。
创建启动项程序
启动项程序也是一个 Project 中的 Target。我们在 Project 的 Targets 列表中点击「+」号来创建一个新的 Target,类型选择 macOS → App 即可。
新的 Target 一般命名为「主 App 名字 + Helper」,例如我这里命名为 PocketHelper。Bundle Identifier 的命名也是类似。

然后对启动项程序进行配置。打开启动项程序的 「info → Custom macOS Application Target Properties」配置列表,并在其中添加以下两项配置。
- Application is agent (UIElement) → YES
- Application is background only → YES
这两项配置表明启动项程序是一个后台任务,不需要界面,打开后不会在 Dock 栏中显示图标。
然后将启动项程序「Build Settings → Deployment → Skip Install」设置为 YES。这个配置表明在安装应用程序时,无需安装启动项程序。应用程序打包(archive)时也不会将启动项程序包含进去。
但是这样的话,系统 launchd 怎么找到启动项程序呢?这就需要我们将启动项程序内嵌到主程序中。
在主程序的「Build Phases」中添加一个新的「 Copy File」phase,Destination 设置为 Wrapper
,Subpath 设置为 Contents/Library/LoginItems
。这个路径是固定的,注册启动项时,Service Management 会在这个路径里查找启动项;如果路径有误,Service Management 就会注册失败。
然后点击下方列表的「+」,添加启动项程序。添加完成后,可以在主程序的「General →Frameworks, Libraries, and Embedded Content」中看到启动项程序。这个配置告知 Xcode 在打包时将启动项程序拷贝到主程序中。


我们可以在安装包里对应的路径下找到这个启动项程序。

注册启动项
在主程序中注册启动项程序
我们需要通过 Service Management 的 API 将启动项程序注册进 launchd 中。
在 macOS 13.0 上,Service Management 引入了新的 API SMAppService
以及它的两个方法 register()
和 unregister()
。我们需要根据启动项程序的 bundle identifier 生成一个 SMAppService
实例,然后调用它的注册/取消注册方法。
SMAppService.loginItem(identifier: HELPER_APP_IDENTIFIER).register()
register()
和 unregister()
会抛出异常,需要用 try
来处理下。
do {
if loginLaunchEnabled {
try SMAppService.loginItem(identifier: HELPER_APP_IDENTIFIER).register()
} else {
try SMAppService.loginItem(identifier: HELPER_APP_IDENTIFIER).unregister()
}
} catch {
print("Unexpected error: \(error).")
}
以上注册/取消注册的操作建议在用户主动开启或关闭自启动时调用。
在启动项程序中启动主程序
启动项程序也有完整的生命周期。在其被 launchd 启动后,我们在生命周期的 applicationDidFinishLaunching
方法里编写代码来启动主程序。以下代码写在启动项程序的生命周期里。
func applicationDidFinishLaunching(_ notification: Notification) {
// 唤醒主程序
let url: URL? = NSWorkspace.shared.urlForApplication(withBundleIdentifier: MAIN_APP_IDENTIFIER)
guard let url else { return }
let runningApplications = NSWorkspace.shared.runningApplications
for application in runningApplications {
if application.bundleIdentifier == MAIN_APP_IDENTIFIER {
// 主程序已经启动。终止此次自启动流程
NSApp.terminate(nil)
return
}
}
let openConf = NSWorkspace.OpenConfiguration()
openConf.activates = false
NSWorkspace.shared.openApplication(at: url, configuration: openConf, completionHandler: { application, error in
if let error {
print(error.localizedDescription)
}
NSApp.terminate(nil)
})
}
MAIN_APP_IDENTIFIER
是主程序的 bundle identifier。我们通过它来创建一个打开主程序的 URL(Apple 比较推荐的打开 App 的方式)。我们需要先判断主程序是否已经启动,如果是的话,结束此次唤醒行为,同时把启动项程序杀死;如果主程序没有启动的话,使用 NSWorkspace.shared.openApplication
来打开主程序,最后再杀死启动项程序。openConf.activates = false
告知系统启动主程序后,无需将其激活并将窗口前置。
读取自启动状态
为了用户友好,App 不要默认就打开自启动。而是提供一个开关,用户开启后,再注册自启动。用户也可以通过这个开关来关闭自启动。这样 App 就需要读取自启动是否开启,以便更新开关按钮的状态。
通过 SMAppService API(推荐)
SMAppService
提供了一个 status
属性来获取状态。它是一个枚举,包含以下几种状态:
- notRegistered = 0。启动项没有注册。
- enabled = 1。启动项已注册,且已激活。
- requiresApproval = 2。启动项已注册,但是需要用户在设置中手动打开。如果用户在系统设置中撤销对服务运行的同意,则将返回此状态。
- notFound = 3。以当前 bundle identifier 初始化的
SMAppService
找不到启动项。
我们可以通过校验 status
是否等于 enabled
来判定启动项是否已激活。
loginLaunchEnabled = SMAppService.loginItem(identifier: HELPER_APP_IDENTIFIER).status == .enabled
通过 UserDefaults
我们也可以用 UserDefaults
来记录这个开关状态。
/// 是否开启登录时自启动
@Published var loginLaunchEnabled: Bool {
didSet {
UserDefaults.standard.set(loginLaunchEnabled, forKey: USERDEFAULTS_LAUNCH_AT_LOGIN)
do {
if loginLaunchEnabled {
try SMAppService.loginItem(identifier: HELPER_APP_IDENTIFIER).register()
} else {
try SMAppService.loginItem(identifier: HELPER_APP_IDENTIFIER).unregister()
}
} catch {
print("Unexpected error: \(error).")
}
}
}
通过 launchctl 读取状态(不推荐)
launchctl
是一个终端指令。我们在终端中输入如下指令,如果得到一个正确的启动项配置信息,说明启动项已被激活;反之,启动项未被激活。
launchctl list com.yourdomain.helperbundleidentifier
# 输出如下表示已激活:
#{
# "EnableTransactions" = true;
# "LimitLoadToSessionType" = "Aqua";
# "MachServices" = {
# "com.yourdomain.helperbundleidentifier" = mach-port-object;
# };
# "Label" = "com.yourdomain.helperbundleidentifier";
# "OnDemand" = true;
# "LastExitStatus" = 0;
# "Program" = "com.yourdomain.helperbundleidentifier";
#};
# 输出如下表示未激活:
# Could not find service "com.yourdomain.helperbundleidentifier" in domain for port
利用 Process
或 Pipe
可以在程序中执行终端指令,并获取输出。通过判断输出的字符串结果来判定自启动激活状态。
这个方法比较麻烦,且不灵活。不推荐。
通过 SMCopyAllJobDictionaries(_:) 获取(已过时)
这个方法是一个过时的方法,是 Service Management 的旧 API。SMCopyAllJobDictionaries
可以返回指定域名下所有的启动项注册信息。
let jobs = SMCopyAllJobDictionaries(kSMDomainUserLaunchd).takeRetainedValue() as? [[String: AnyObject]]
然后在返回结果中寻找是否有我们的启动项程序标识。若有,说明已注册且激活。
jobs.contains(where: { $0["Label"] as! String == "com.yourdomain.helperbundleidentifier" })
这个方法过时了,且不灵活。也不推荐。
总结
以上就是在 macOS 中实现程序开机登录自启动的方法。Apple 在这自启动这块并没有为难开发者,反而不断优化框架和 API,macOS 13.0 之后的新 API 还是挺好用的。即便是要上架应用商店的沙盒程序,也能注册自启动。
最后,感谢阅读🙏。