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

《漫步华尔街(第12版)》读书笔记

《漫步华尔街(第12版)》读书笔记

股票分析 基本面分析 * 基本面分析的四个基本决定因素 * 预期增长率 * 复合增长(复利)对投资决策有很重要的意义。 * 一只股票的股利增长和盈利增长率越高,理性投资者应愿意为其支付越高的价格。 * 推论:一只股票的超常增长率持续时间越长,理性投资者应愿意为其支付越高的价格。 * 预期股利支付率 * 对于预期增长率相同的两只股票来说,持有股利支付率越高的股票,较之股利支付率低的股票,会使你的财务状况更好。 * 在其他条件相同的情况下,一家公司发放的现金股利占其盈利的比例越高,理性投资者应愿意为其股票支付越高的价格。 * 特例,很多处于强劲增长阶段的公司,往往不支付任何股利。这时候不满足「在其他条件相同的情况下」。 * 风险程度 * 在其他条件相同的情况下,一家公司的股票风险越低,理性投资者(以及厌恶风险的投资者)应愿意为其股票支付越高的价格。 * 市场利率水平 * 在其他条件相同的情况下,市场利率越低,理性投资者应愿意为股票支付越高的价格。 * 举例,银行存款利率

By Gray
2025 端午日本九日游

2025 端午日本九日游

从日本回来后就一直忙个不停,忙着搬家和工作。这周末终于有时间回顾和记录一下日本的旅游行程。 这次出国游是年初就规划好的。端午节假期三天再加上节后请假四天,以及周末,总共能休 9 天。5 月 31 号出发,6 月 9 号凌晨的航班飞回北京。 出发前的准备 机票和酒店 越临近出发日期,机票和酒店就越贵。所以我们早早地就把机票和酒店定了。 去程机票订的山航,青岛转机,5 月 31 号从北京出发抵达青岛,在青岛玩一天,翌日早上从青岛飞往关西机场。回程机票订的海南航空,从东京羽田机场直飞北京,是凌晨两三点的红眼航班。 本次行程要去关西(京都、大阪、奈良)、关东(东京、富士山)。关西三个城市很近,一直住在京都即可,从京都往返大阪和奈良。关东就住在东京。京都的酒店订在了京都站附近,出站走几步就能到,交通非常便利。东京的酒店订在了马喰町附近,附近有很多地铁线路,包括浅草线、

By Gray
2025 关税危机中学到的投资经验

2025 关税危机中学到的投资经验

充足的现金流很重要 好的买入机会不会每天都出现,但当它出现的时候,你最好还有筹码可以投入。 有些人手里握不住钱,一有闲钱就赶紧买入基金、股票,生怕错过了机会,让钱白搭手里。市场是疯狂的、充满变数的,尤其是在特朗普上台后,一句话就可能让股市涨停或跌停。那些专业的理财投资机构尚不能预测市场,何况我们这些散户呢。在不稳定的市场中,我们要学习巴菲特,备好现金,耐心等待买入(抄底)机会。 不要提前打光子弹 美股标普 500 指数从 2 月中旬到 3 月中旬累计跌了约 10%。如果这时候你觉得已经跌了很多,可以 all in 抄底了,那么你就会错过 4 月上旬的那次狂跌——一周跌了约 10%。没有人能预测市场,除了此刻的股市指挥家特朗普。散户们能学到的经验就是「永远不要提前打光子弹」,你以为的谷底其实只是个半山腰。 相信自己,保持耐心 在美股大跌的时段里,小红书、v2ex

By Gray