macOSアプリでLaunchAtLoginの実装

# LaunchAtLoginの実装

# 追記(2024-03-16)

- より簡単に実装ができるようになったので、OSが対応しているなら下記がおすすめです！
  - [Swift: macOS 13以降でのLaunch At Loginの実装](https://zenn.dev/kyome/articles/94de18ef33bbe6)

---

# 概要
- アプリでよくある`Launch at Login`の実装を行う。
- `LaunchAtLogin`の仕組みとしてはざっくり以下の通り。
    - 起動したいアプリ(以下メインアプリ)にヘルパーアプリを内包させる。
    - ヘルパーアプリをユーザログイン時に起動するよう登録。
    - (ユーザログイン時)ヘルパーアプリからメインアプリを起動させ、ヘルパーアプリを終了させる。

<img width="400" alt="image" src="https://i.imgur.com/lmJoaV0.jpg">

# GitHub
- [qiita\-launch\-at\-login\-demo](https://github.com/pommdau/qiita-launch-at-login-demo)

# 参考
- [How to launch a macOS app at login?](https://theswiftdev.com/how-to-launch-a-macos-app-at-login/)
    - 主に参考にした。ただ記事のが古いのかいくつか手直しが必要。
    - 例えば[launchApplication\(\_:\)](https://developer.apple.com/documentation/appkit/nsworkspace/1531434-launchapplication)はDeprecatedなので`NSWorkspace.shared.urlForApplication()`で置き換える。
- 上記の記事である`@NSApplicationMain`を記述せずに、`main.swift`を作るとうまくいく。
    - [Is there a way to delete Main\.storyboard in SwiftUI macOS project?](https://stackoverflow.com/questions/65055487/is-there-a-way-to-delete-main-storyboard-in-swiftui-macos-project)
- [Swift: macOSでLaunch at loginを実装する](https://zenn.dev/kyome/articles/9bbc84f4654986)
    - 同じ方向性。アプリの起動のコードを参考にしました。
    - https://github.com/Kyome22/ShiftWindow

>`NSWorkspace.shared.urlForApplication()`で本体アプリのファイルパスを取得する。

- ライブラリもあるけど現在はCarthageでしか使えない？SPMで入れてみるもうまく動かず。
    - [sindresorhus/LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin)
- [1024jp/Preferences\-Demo](https://github.com/1024jp/Preferences-Demo)
    - UserDefaultsを使ったCocoaBindingを参考にしました。

# プロジェクト設定
## メインアプリ
- メインのアプリの作成

![](https://i.imgur.com/IfwMrg5.jpg)

- 以下からヘルパーアプリを作成

![](https://i.imgur.com/836vNtx.jpg)

![](https://i.imgur.com/oF5OWgm.jpg)

- メインアプリにヘルパーアプリを内包させる設定

![](https://i.imgur.com/zh0Ydip.jpg)

![](https://i.imgur.com/wLMDEnu.jpg)

- `ServiceManagement.framework`を追加

![](https://i.imgur.com/bj5ojUM.jpg)

## ヘルパーアプリ
- `Build Settings`の`Skip Install`を`YES`

![](https://i.imgur.com/bXywdFN.jpg)

- `Info.plist`に`Application is background only`を追加しValueを`YES`にする

![](https://i.imgur.com/kDqLncp.jpg)

- ヘルパーアプリ側のUIがいらないので下記を削除

![](https://i.imgur.com/bRgrRkv.jpg)

- `Main storyboard file base name`の`Main`を空にする

![](https://i.imgur.com/KkGTVBt.jpg)

# 実装
## メインアプリ
### `LauncherConst.swift`
- ヘルパーアプリのIdentifierを複数箇所で使用するので下記の通り宣言しておく。

```swift
import Foundation

struct LauncherConst {
    static let launcherAppId = "com.gmail.ikeh1024.MainApplicationLauncher"
}
```

### `AppDelegate.swift`
- 起動時にヘルパーアプリが起動している場合に、ヘルパーアプリを終了させるようにする。
- [DistributedNotificationCenter](https://developer.apple.com/documentation/foundation/distributednotificationcenter)で異なるアプリ間でやりとりできる。

```swift
import Cocoa
import ServiceManagement

extension Notification.Name {
    static let killLauncher = Notification.Name("killLauncher")
}

@main
class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // ヘルパーが起動していたら終了させる
        let runningApps = NSWorkspace.shared.runningApplications
        let isRunning = !runningApps.filter { $0.bundleIdentifier == LauncherConst.launcherAppId }.isEmpty
        if isRunning {
            DistributedNotificationCenter.default().post(name: .killLauncher, object: Bundle.main.bundleIdentifier!)
        }
    }
...
}
```

### `ViewContorller.swift`

![](https://i.imgur.com/DDPzDpF.jpg)

- 下記コードはチェックボックスを選択したときのアクション。
- [SMLoginItemSetEnabled\(\_:\_:\)](https://developer.apple.com/documentation/servicemanagement/1501557-smloginitemsetenabled)でヘルパーアプリのIdentifierを登録することで、ログイン時にヘルパーアプリが起動される。

```swift
@IBAction func launchAtLoginCheckboxClicked(_ sender: NSButton) {
    SMLoginItemSetEnabled(LauncherConst.launcherAppId as CFString, GeneralPreferences.shared.launchAtLogin.isOn)
}
```

- `Launch at Login`のチェックボックスは、CocoaBindingを使い、UserDefaultsの`launchAtLogin`をキーとして同期させている。

## ヘルパーアプリ
### `main.swift`
- エントリーポイントとしての実装

```swift
import AppKit

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
```

### `AppDelegate.swift`
- メインアプリの起動に関する設定を諸々行う

```swift
import Cocoa

extension Notification.Name {
    static let killLauncher = Notification.Name("killLauncher")
}

class AppDelegate: NSObject {

    @objc func terminate() {
        NSApp.terminate(nil)
    }
}

extension AppDelegate: NSApplicationDelegate {

    func applicationDidFinishLaunching(_ aNotification: Notification) {

        // メインアプリの起動確認
        let mainAppIdentifier = "com.gmail.ikeh1024.MainApplication"
        let runningApps = NSWorkspace.shared.runningApplications
        let isRunning = !runningApps.filter { $0.bundleIdentifier == mainAppIdentifier }.isEmpty
        
        if !isRunning {
            // メインアプリ起動時に終了させる通知を受け取るための設定
            DistributedNotificationCenter
                .default()
                .addObserver(self,
                             selector: #selector(self.terminate),
                             name: .killLauncher,
                             object: mainAppIdentifier)

            // メインアプリを起動する
            if !isRunning,
               let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: mainAppIdentifier) {
                let config = NSWorkspace.OpenConfiguration()
                NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in
                }
            }
        }
        else {
            // メインアプリが起動していれば何もせずに終了
            self.terminate()
        }
    }
}
```

# ヘルパーアプリが登録されているかの確認方法
- [SMCopyAllJobDictionaries\(\_:\)](https://developer.apple.com/documentation/servicemanagement/1431086-smcopyalljobdictionaries)でヘルパーアプリがログイン時に起動するよう登録されているか確認できる。
    - [Login Item \- cocoa](https://stackoverflow.com/questions/36376735/login-item-cocoa)を参考にした。

```swift
@IBAction func checkSMLoginButtonClicked(_ sender: Any) {
    let jobDicts = SMCopyAllJobDictionaries(kSMDomainUserLaunchd).takeRetainedValue() as NSArray as! [[String:AnyObject]]
    let jobEnabled = jobDicts.filter { $0["Label"] as! String == LauncherConst.launcherAppId }.isEmpty == false
    print("SMLogin: \(jobEnabled)")
}
```

- [SMCopyAllJobDictionaries\(\_:\)](https://developer.apple.com/documentation/servicemanagement/1431086-smcopyalljobdictionaries)はDeprecatedだが、代替ができるまではこれしか無い？みたい。

>[SMLoginItemSetEnabled\(\.\.\.\) GET counterpart](https://stackoverflow.com/questions/8219920/smloginitemsetenabled-get-counterpart)
>On SMCopyAllJobDictionaries inside <ServiceManagement/ServiceManagement.h> For the specific use of testing the state of a login item that may have been   enabled with SMLoginItemSetEnabled() in order to show that state to the   user, this function remains the recommended API. A replacement API for this   specific use will be provided before this function is removed. We can safely use the method until further notice. Just be sure to check the new SDK releases/updates for the new API and this method's obsolescence. – 
Nogurenn
 Feb 7 '19 at 8:15 
 
- 実際にXcode上で覗いてみると`libxpc`で置き換わる予定と記載があった。
 
```swift
@available(macOS, introduced: 10.6, deprecated: 10.10)
public func SMCopyAllJobDictionaries(_ domain: CFString!) -> Unmanaged<CFArray>!

/**
(省略)
 * @discussion
 * This routine is deprecated and will be removed in a future release. A 
 * replacement will be provided by libxpc.
 */
```