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

碎碎念——投资,不确定性沟通定语

碎碎念——投资,不确定性沟通定语

投资理财 最近因为关税的冲击,美股正在经历一波大跌行情。我个人比较看好纳斯达克,也在一直定投纳斯达克。我是长期主义者,没有精力和时间在短期波动中挣钱,只想在下跌调整中「进货」。 定投分左侧定投和右侧定投。左侧定投是在下跌的过程中定投,而右侧定投是在上涨的过程中定投。左侧定投无法确认底部在哪里,需要源源不断往里投入金钱(行内成为「子弹」);右侧定投无法确认反弹是诱多还是形势已经逆转。我采用的是左侧定投,大跌大加,小跌小加,反弹时停止定投。不论采用哪种定投,殊途同归,都是尽量降低投资成本。 目前网上看衰美股的声音不少,不少人因为恐慌割肉卖出股票。但我们要知道目前美国仍旧是世界第一大国,消费潜力巨大,大型科技公司(苹果、英伟达等)的基本面并没有出现大问题。只是因为特朗普的「量子态」关税政策,导致市场恐慌抛售。我们无需担心纳斯达克、标普指数从此一蹶不振。恰恰相反,现在是买入美股的绝佳时机。苹果、英伟达等大型公司的 PE 值已经降到了合理位置,只要不买妖股,不投机,只关注纳斯达克、标普指数,只买大型公司股票,迟早会取得丰厚盈利的。

By Gray
怀念小时候吃过的食物

怀念小时候吃过的食物

前两天下班骑车回家的路上听到了路旁有人在讨论泡馍。他们口中的泡馍应该是类似西安羊肉泡馍之类的食物。但是我却想起来了小时候吃的不一样的泡馍以及其他吃食。 不一样的泡馍 小时候我们那里普遍比较贫穷,家家户户除了过年过节基本上很难吃到大块肉。小孩子饭量时小时大,中午吃的饭,半晌就又饿了。家里有大葱或者豆糁的话,可以拿着一个馍就着就吃了。整根的葱是最下馍的,葱白部分甜又辣,葱叶里面会有像鼻涕一样的粘液,要把它挤出来才下得嘴吃。豆糁是黄豆的发酵产物,煮熟的大豆加盐发酵几天,黏丝丝的时候团成球,放到发黑就能吃了。吃的时候从球上掰下来几小块就行。豆糁是咸的,因而也能下饭。不过最妙的吃法是将豆糁和鸡蛋一起炒。鸡蛋的香气和豆糁稍微发臭的味道混在一起,形成一种独特的香味。像北京的臭豆腐一样,闻着臭,吃着香。 如果家里没葱没豆糁了,馍又很干,那泡馍就是解决饿肚子的绝好办法。将干硬的馍掰成几瓣,不能太碎小,放到瓷碗里。倒入炒菜的肉味王佐料,或者是平时攒下来的方便面调料。再提溜着暖水瓶,倒进去冒着热气的水。当然香油是少不了的,拿着油光光的瓶子,滴进去几滴喷香的香油。最后用大碗盖住,或者干脆啥也不盖,静等

By Gray
Swift Server Push Notification 配置

Swift Server Push Notification 配置

获取证书 在 Apple Developer 开发者账号 Certificates, Identifiers & Profiles 里选择 Keys。新增一个 key, configure 里选择 Sandbox & Production。下载该 p8 证书,并且保存好(只能下载一次)。 终端 cd 到证书所在路径,输入下面指令。 openssl pkcs8 -nocrypt -in AuthKey_XXXXXXXXX.p8 -out ~/Downloads/key.pem cat key.pem 得到 PRIVATE KEY 字符串,复制好。 服务端配置 服务端有多种技术栈方案,包括 Java、

By Gray
香港游记——一个传统而又现代的城市

香港游记——一个传统而又现代的城市

这是 2024 年的最后一场旅行,从北京到香港,跨越了大半个中国。去香港,一方面是想领略一下它的文化和风光,另一方面是想办一个香港银行卡,买港美股以及海外收付款。 从北京到香港,动卧是一个不错的选择。乘坐 D903 次动车,晚上八点登车,睡一觉,第二天一早就到深圳北了。再从深圳北坐高铁过口岸到香港西九龙,差不多上午九点多就能到达香港。深圳北到西九龙的高铁车次非常多,不用担心买不到票。 密集的建筑 香港给我的初印象就是——这里的楼房真的很密集。不光是住宅区又高又密,商业区的建筑物与建筑物之间也几乎只有街道相隔,很少见到大型的公园或者绿化带。土地利用率很高。这一点和北京差别还是挺大的。北京虽然也是寸土寸金,但是市内绿化面积很高,大型公园也很常见。 街上密集的建筑,让人第一眼看就知道这是香港。 旧与新,传统与现代 在香港,不同地区的风格面貌会相差很多。你既能见到破旧不堪、需要修缮的古老楼房,也能见到银光闪闪、科技感十足的现代化大厦。这种新与旧的切换,传统和现代的反差,总是能给人强烈的震撼。这正是香港的魅力所在。 维多利亚港和中环摩天轮 维多利亚港是香港的中心,是香港旅游

By Gray