🍁

Swift: macOSでLaunch at loginを実装する

2021/05/19に公開
4

Launch at login(ログイン時に起動)の実装方法をまとめる。
なお、本体アプリの Bundle Identifier をcom.sample.ProductName、ヘルパーアプリの Bundle Identifier をcom.sample.ProductNameLauncherとする。

本体アプリ起動用のヘルパーアプリを実装する

Info.plist の編集

  1. File -> New -> Target -> macOS -> App でヘルパーアプリのターゲットを追加する。
  2. ヘルパーアプリの Info.plist を編集してApplication is background onlyキーにYESを設定する。
Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    ・・・
    <key>LSBackgroundOnly</key>
    <true/>
</dict>
</plist>

インストールをスキップするようにする

ヘルパーアプリの Build Settings でskip installと検索し、出てきた項目に YES を設定する。

不要なものを削除

ViewController.swift、Main.storyboard、Assets.xcassets は不要なので削除する。同時に、TARGETS -> General -> Deployment Info の Main Interface を空欄にし、App Icons も Don't use asset catalogs を選ぶ。

main.swift を追加して編集する

main.swift
import Cocoa

let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

AppDelegate.swift の編集

AppDelegate.swift
import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let mainAppId = "com.sample.ProductName"
        let workspace = NSWorkspace.shared
        let isRunning = workspace.runningApplications.contains { app in
            app.bundleIdentifier == mainAppId
        }
        if !isRunning, let url = workspace.urlForApplication(withBundleIdentifier: mainAppId) {
            let config = NSWorkspace.OpenConfiguration()
            NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in
                DispatchQueue.main.async {
                    NSApp.terminate(nil)
                }
            }
        } else {
            NSApp.terminate(nil)
        }
    }
    
}

要点

  • 本体アプリが起動中かどうかNSWorkspace.shared.runningApplicationsで確認する。
  • 起動中だった場合は用がないのでヘルパーアプリを終了する。
  • 起動していなかった場合は本体アプリを起動する。
    • NSWorkspace.shared.urlForApplication()で本体アプリのファイルパスを取得する。
    • NSWorkspace.shared.openApplication()で指定したパスのアプリを起動する。
    • completionHandlerで結果がどうであれヘルパーアプリを終了する。

ヘルパーアプリを LoginItems に追加する

  1. 本体アプリの Build Phases に Copy Files の項目を追加する。
  2. Destination をWrapperに指定し、Subpath にContents/Library/LoginItemsと記入する。
  3. Products の中のヘルパーアプリのビルドを追加する。

これで、本体アプリのパッケージ下にContents/Library/LoginItemsのディレクトリができ、そこにヘルパーアプリがコピーされて入るようになる。このパスは後述のSMLoginItemSetEnabled()の仕様に準ずる。

Launch at login の設定切り替えができるようにする

環境設定のViewController
import Cocoa
import ServiceManagement

class PreferencesViewController: NSViewController {

    @IBOutlet weak var launchCheckBox: NSButton!

    let userDefaults = UserDefaults.standard

    override func viewWillAppear() {
        super.viewWillAppear()
        launchCheckBox.state = userDefaults.bool(forKey: "launchAtLogin") ? .on : .off
    }

    @IBAction func toggleLaunch(_ sender: NSButton) {
        let flag = sender.state == .on
        if SMLoginItemSetEnabled("com.sample.ProductNameLauncher" as CFString, flag) {
            userDefaults.setValue(flag, forKey: "launchAtLogin")
        } else {
            sender.state = userDefaults.bool(forKey: "launchAtLogin") ? .on : .off
        }
    }

}

要点

  • 本体アプリで環境設定のビューを作り、チェックボックスを配置する。
  • Launch at login の設定状態を記録するため UserDefaults を使う。
  • View が表示される時に設定状態がチェックボックスに反映されるようにしておく。
  • チェックボックスが切り替えられた時の実装をする。
    • チェックボックスの状態を Bool の変数にする。
    • SMLoginItemSetEnabled()を使って、ヘルパーアプリをログインアイテムに登録/登録解除する。
    • 登録/登録解除に成功した場合は UserDefaults の値を更新する。
    • 失敗した場合は UserDefaults の値に基づいてチェックボックスの状態を戻す。

SMLoginItemSetEnabled() に登録するとどうなる?

  • 登録されたヘルパーアプリは登録された瞬間に起動する。
  • 登録されたヘルパーアプリはログイン時にも自動で起動するようになる。
  • 登録されたヘルパーアプリは自身で終了しない限り、クラッシュやプロセスの強制終了で終了しても再起動する。要はデーモン化される。(Activity Monitor で処理を強制終了してもすぐに別のプロセスとして立ち上がったのを確認済み)

動作検証の仕方(未確認)

同じ Bundle Identifier のアプリがあるとうまく動作しないため、基本的に開発中にデバッグすることはできない。ただし、以下の条件を満たせばできるかもしれない。

  • 配布版がインストールされている場合、削除(アンインストール)する。
  • アーカイブを作り、Window -> Organizer -> Distribute App -> Copy App でアプリのコピーをどこかに置く。
  • 過去のアーカイブを消す(不要かも)。
  • プロジェクトをクリーンして DerivedData 配下の成果物を消す。

この状態でコピーのアプリを起動して Launch at Login を有効にして Mac を再起動する。要は同じ Bundle Identifier のアプリが存在しない状態にすればいいはず。

Discussion

KyomeKyome

ところで、なぜ本体アプリでこの設定をできるようにしてくれないのかApple。。。

KyomeKyome

今回まとめた実装方法だと既存の文献の方法よりいい点がある。

  • NSWorkspace.shared.openApplicationcompletionHandlerを用いてアプリの終了を行うため、本体アプリ側が起動した後にヘルパーアプリに通知して終了する仕組みを実装しなくていい。
  • 本体アプリのファイルパスを取得するのにテクいことをしなくていい。

逆に欠点としては、本体アプリと同じ Bundle Identifier のアプリが他に存在した場合、正しく起動できるか不明瞭である。

KyomeKyome

BetterSnapTool が 完璧なLaunch at loginを実装していることを発見。
ユーザのログイン項目とちゃんと連動している。ドユコト?