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

联通 FTTR 宽带从路由器设置自动重启和穿墙功率

联通 FTTR 宽带从路由器设置自动重启和穿墙功率

几个月前把家里宽带换成了联通的千兆 FTTR 宽带,包含一主一从两个点位。配套光猫设备是华为的星光 F50 尊享版。 主点位放置在客厅茶几上,方便连接电视。从点位放在卧室门口,那里恰好有一个不耽误过路的小拐角可以放路由器。平常我们基本不在客厅活动,其他区域最近的 Wi-Fi 信号源是从路由器,因此我们大多数的设备连接的都是从路由器。从路由器的工作负荷很大。 从路由器个头小主路由器很多,散热不咋地。工作时间久了发热就容易发生数据包堵塞,丢包延迟高。需要把它电源拔掉重启。从宽带开通到现在,数据包堵塞影响网络的情况每个月会发生一次。有一次还影响了居家办公的视频会议。宽带维修师傅也给不出有效的法子,建议就是定期插拔从路由器电源。 从路由器和书房之间隔了两堵墙。信号到我书桌那个位置时,千兆网速已经衰减到只有 400-500Mbps 了,折损将近一半。叠加路由器发热的 debuff,书桌位置的网速最差的时候几乎和百兆宽带差不多。 我尝试过在光猫后台管理将路由器功率设置到「穿墙」模式,但没有任何作用。今天在后台研究了一番发现,原来我之前设置的功率是仅对主路由器生效,从路由器还是标准功率。要修

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

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

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

By Gray