macOS 实现软件开启自启动

介绍如何给 macOS 软件实现开机自启动,以及获取自启动注册和开启状态。

macOS 实现软件开启自启动
Photo by Richard Horvath / Unsplash

开机自启的软件并不都是流氓类软件。一些辅助类软件,比如我常用的 Magnet、Grammarly 等,都支持开机自启动。省去了用户想立马使用这些软件,而又不需要手动去启动台打开它们的繁琐。它们常驻在后台,却不消耗很多资源。

用户可以在设置中随时更改软件的自启动权限。

登录项设置

我最近在开发的一款菜单栏翻译软件最近也加入了这个功能。以下为实现软件自启动的步骤。

流程和框架

目前 Apple 推荐使用 Service Management 来实现自启动。即便应用打开了沙盒模式,也能通过这个框架实现自启动。实现自启动的流程大致如下:

  1. 创建一个启动项程序来辅助打开主程序。
  2. 通过 Service Management 将启动项程序被注册进 launchd 中。
  3. 每次用户开机并且登陆账户后,launchd 就会打开你的启动项程序。
  4. 通过启动项程序打开主程序,并将启动项程序退出。

创建启动项程序

启动项程序也是一个 Project 中的 Target。我们在 Project 的 Targets 列表中点击「+」号来创建一个新的 Target,类型选择 macOS → App 即可。

新的 Target 一般命名为「主 App 名字 + Helper」,例如我这里命名为 PocketHelper。Bundle Identifier 的命名也是类似。

创建启动项 Target

然后对启动项程序进行配置。打开启动项程序的 「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 在打包时将启动项程序拷贝到主程序中。

添加 Copy File
检查 Embedded Content

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

在主程序包中可以找到启动项程序

注册启动项

在主程序中注册启动项程序

我们需要通过 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

利用 ProcessPipe 可以在程序中执行终端指令,并获取输出。通过判断输出的字符串结果来判定自启动激活状态。

这个方法比较麻烦,且不灵活。不推荐。

通过 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 还是挺好用的。即便是要上架应用商店的沙盒程序,也能注册自启动。


最后,感谢阅读🙏。

Read more

iOS 18 初体验

iOS 18 初体验

错过了凌晨的的 WWDC24 发布会,今早从各大媒体中获悉了此次版本更新的主要内容。与之前爆料的内容相近,此次更新主要是针对 AI 、桌面和隐私等。 到公司后发现 iOS 18 的开发者预览版已经可以安装了。于是到工位立马插电开始下载更新系统。这也是我第一次在自己日常使用的设备上安装 beta 版系统,之前都是在测试机上尝鲜。 下面是系统更新之后的一些体验。 控制中心更灵活了 * 控制中心左上角增加了一个加号➕按钮,点击后可以添加更多控制中心选项(长按空白区域也能触发)。这个功能是把「设置」中的控制中心设置挪到了控制中心面板上。 * 右上角增加了一个电源按钮,点击后可以选择是否要滑动关机,取消后立即进入锁定状态,必须使用密码才能解锁。 * 控制中心支持翻页了。可以上下滑动切换页面,目前我的设备上分页分别为「常用」「音乐」「网络连接」。如果开启了「家庭」的话,还会多一个家庭的分页。实测这个分页会影响控制中心的关闭手势—想要上滑关闭控制中心时,系统却将其识别成了上滑翻页。子页面可以通过长按移出和添加。 * 控制中心选项按钮支持调节大小了,并且支持了更多类型的选项(甚至可以

By Gray
绘画临摹·其一

绘画临摹·其一

新 iPad Pro 到手后,我第一时间把几年前买的 procreate 重新下载安装到了新设备上。我自知我没有多少艺术细胞,但还是按耐不住内心创作的渴望。尤其是前段时间看了《月亮和六便士》之后,这种渴望就愈来愈强了。 生命在于创作。绘画是一种创作的形式,也是表达和记录生活的一种方式。我在小红书上收藏了一些绘画的笔记,照着临摹了一些。 第一张我给它起名叫《日》。图层比较简单,依靠一些色彩和高斯模糊特效就可以完成。整体效果还是不错的~ 第二幅是雨天景色,主题色是绿。原作者画的很棒,但是我临摹的不太行,好多细节没有处理好,比如山峦的边缘没有涂抹好,山峦缺乏层次感,山峦和水面(是的,底下是水…)的交界处也没有清晰的表示出来,水面颜色不够通透。原作里面有几头牛,我实在画不出来,索性放弃了。 原作链接: 小红书 端午粽子简笔画。这个还是比较简单的。 原帖: 小红书 蓝天白云。 原帖: 小红书

By Gray