🍁

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

2022/12/13に公開
1

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

実装例
import SwiftUI
import ServiceManagement

class ContentViewState: ObservableObject {
    @Published var launchAtLogin: Bool

    init() {
        launchAtLogin = SMAppService.mainApp.status == .enabled
    }

    func toggleLaunchAtLogin(_ isOn: Bool) {
        do {
            if isOn {
                try SMAppService.mainApp.register()
            } else {
                try SMAppService.mainApp.unregister()
            }
        } catch {
            Swift.print(error.localizedDescription)
        }
        launchAtLogin = SMAppService.mainApp.status == .enabled
    }
}

struct ContentView: View {
    @StateObject var state = ContentViewState()

    var body: some View {
        VStack {
            Toggle(isOn: Binding<Bool>(
                get: { state.launchAtLogin },
                set: { state.toggleLaunchAtLogin($0) }
            )) {
                Text(verbatim: "launch at login")
            }
        }
    }
}

ポイント

  • SMAppService.mainApp.statusで現状ログイン項目に入っているのかどうかが判断できます。
  • SMAppService.mainApp.register()でログイン項目に登録できます。(tryがついていますがXcodeでRunした時はnot permittedとなるようです。動作確認するにはリリースしないといけない?謎です。)
  • SMAppService.mainApp.unregister()でログイン項目から登録解除できます。環境設定から手動で登録解除しているのに、さらにその状態からunregister()を叩くとErrorthrowされます。
  • ユーザーが状態を切り替えようとした際に失敗しうるAPIなので、UI側はプロパティとToggleを直接Bindingするのではなく、Binding<Bool>(get:set:)のsetでAPIのレスポンスに合わせてプロパティの値を更新するようにします。

参考

Discussion

KyomeKyome

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)
        }
    }