🍁

Swift: macOS 13以降でのLaunch At Loginの実装

2022/12/13に公開約2,000字1件のコメント

macOS 13からServiceManagementが強化されSMAppServiceが追加されたことで、ようやくヘルパーアプリに頼らずにログイン項目にアプリを登録できるようになりました。🎉(ヘルパーではなくアプリ本体がログイン項目に登録され、ログイン時に起動するようにできます。)しかも、実装方法もかなりシンプルでわかりやすいAPIとなっております。

実装例
import SwiftUI
import ServiceManagement

struct ContentView: View {
    @State var launchAtLogin: Bool
    
    init() {
        self.launchAtLogin = SMAppService.mainApp.status == .enabled
    }
    
    var body: some View {
        VStack {
            Toggle("launch at login", isOn: $launchAtLogin)
                .onChange(of: launchAtLogin) { [oldValue = launchAtLogin] newValue in
                    do {
                        if newValue {
                            try SMAppService.mainApp.register()
                        } else {
                            try SMAppService.mainApp.unregister()
                        }
                    } catch {
                        Swift.print(error.localizedDescription)
                    }
                    if newValue != (SMAppService.mainApp.status == .enabled) {
                        launchAtLogin = oldValue
                    }
                }
        }
    }
}

ポイント

  • SMAppService.mainApp.statusで現状ログイン項目に入っているのかどうかが判断できます。
  • SMAppService.mainApp.register()でログイン項目に登録できます。(tryがついていますがどんな時にこれが失敗するのか謎です。)
  • SMAppService.mainApp.unregister()でログイン項目から登録解除できます。環境設定から手動で登録解除しているのに、さらにその状態からunregister()を叩くとErrorthrowされます。
  • .onChange(of: flag) { [oldValue = flag] newValue in }のようにトグルを管理するflagをクロージャーでキャプチャーすることで、古い値を参照できます。設定が更新できなかった際にトグルの状態を正常に戻すためにoldValueが必要です。

参考

Discussion

macOS 12までのLaunch At Loginの実装(SwiftUI編)

  1. File -> New -> Target からmacOS App(SwiftUIベース)を追加する
  2. Build Settings -> Deployment -> Skip Install を Yes にする
  3. Info -> Target Properties に Application is background only を追加して YES にする
  4. xcassetsやContentViewなど不要なファイルは削除する
  5. LauncherModelを実装する
    import AppKit
    import Combine
    
    final class LauncherModel: NSObject {
        private var cancellable: AnyCancellable?
    
        override init() {
            super.init()
            cancellable = NotificationCenter.default
                .publisher(for: NSApplication.didFinishLaunchingNotification)
                .sink { [weak self] _ in
                    self?.applicationDidFinishLaunching()
                }
        }
    
        private func applicationDidFinishLaunching() {
            let mainAppID = "com.username.ProductName"
            let workspace = NSWorkspace.shared
            let isRunning = workspace.runningApplications.contains {
                $0.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)
            }
        }
    }
    
  6. Appを実装する
    import SwiftUI
    
    @main
    struct ProductNameLauncherApp: App {
        private let launcherModel = LauncherModel()
    
        var body: some Scene {
            // 何らかのSceneを渡す必要があるので、EmptyView()を詰めたSettingsを渡す
            Settings {
                EmptyView()
            }
        }
    }
    
  7. 本体アプリの方で設定機能を作る
    import ServiceManagement
    import SwiftUI
    
    let helperAppID = "com.username.ProductNameLauncher"
    
    class LaunchAtLoginViewModel: ObservableObject {
        @Published var launchAtLogin: Bool {
            didSet {
                if !SMLoginItemSetEnabled(helperAppID as CFString, launchAtLogin) {
                    launchAtLogin = oldValue
                }
            }
        }
    
        init() {
            let jobDicts = SMCopyAllJobDictionaries(kSMDomainUserLaunchd)
                .takeRetainedValue() as NSArray as! [[String: AnyObject]]
            launchAtLogin = jobDicts.contains { ($0["Label"] as! String) == helperAppID }
        }
    }
    
    struct LaunchAtLoginView: View {
        @StateObject var viewModel = LaunchAtLoginViewModel()
    
        var body: some View {
            Toggle("Launch at login", isOn: $viewModel.launchAtLogin)
        }
    }
    
ログインするとコメントできます