macOSメニューバー常駐アプリ入門

SwiftUIを使えば、簡単にメニューバー常駐アプリが作れるらしい。


# 完成作品
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2361573/3c2f9a6a-058f-6a50-cee6-d841255ad993.png)



クリップボードの内容の文字数か単語数を表示するシンプルなmacOSのユーティリティアプリ。
例えば大学生がレポートを書くときにコピーするだけで文字数を調べられて便利。


https://github.com/Kota1021/ClipboardCounter


作ったのは以下3ファイルだけ。
- AppDelegate
- LetterCounterApp
- Counter


# 実装

まずは、クリップボードの変化を取得したい。ただ、変化を通知してくれる仕組みは無いようで、
今回はTimerで一定時間ごとに監視してNotificationCenterにpostしてみた。
NotificationCenterに登録するために、iOS14以降では使われていない（？）AppDelegateに今回は復活してもらう。


``` AppDelegate.swift
//  Created by Federico Vitale on 22/05/2019.
//  Copyright © 2019 Federico Vitale. All rights reserved.
//  Modified by Kotaro Matsumoto on 29/07/2023

import Cocoa

class AppDelegate: NSObject, NSApplicationDelegate {
    var timer: Timer!
    let pasteboard: NSPasteboard = .general
    var lastChangeCount: Int = 0

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
            if self.lastChangeCount != self.pasteboard.changeCount {
                self.lastChangeCount = self.pasteboard.changeCount
                NotificationCenter.default.post(name: .NSPasteboardDidChange, object: self.pasteboard)
            }
        }
    }

    func applicationWillTerminate(_ aNotification: Notification) {
        // Insert code here to tear down your application
        timer.invalidate()
    }
}


extension NSNotification.Name {
    public static let NSPasteboardDidChange: NSNotification.Name = .init(rawValue: "pasteboardDidChangeNotification")
}

```
新規ファイルでAppDelegateを作成。
AppDelegateは事実上Appのルートオブジェクトで、UIApplicationと一緒に働き、systemとやりとりする。


``` LetterCounterApp.swift
import SwiftUI
import NaturalLanguage

@main
struct LetterCounterApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    @StateObject private var counter = Counter()
    @State private var menuBarIcon = Counter.Kind.words

    var body: some Scene {
        MenuBarExtra {
            Button("\(counter.characters) Characters") {
                menuBarIcon = .characters
            }
            Button("\(counter.words) Words") {
                menuBarIcon = .words
            }
            Divider()
            Button("Quit") {
                NSApplication.shared.terminate(nil)
            }.keyboardShortcut("q")
        } label: {

            let icon: String =  switch menuBarIcon {
            case .words: "\(counter.words)W"
            case .characters: "\(counter.characters)C"
            }

            Text("\(icon)")
                .onReceive(NotificationCenter.default.publisher(for: .NSPasteboardDidChange)){ notification in
                    guard let pb = notification.object as? NSPasteboard else { return }
                    guard let items = pb.pasteboardItems else { return }
                    guard let item = items.first?.string(forType: .string) else { return } // you should handle multiple types
                    counter.characters = item.count

                    let tokenizer = NLTokenizer(unit: .word)
                    tokenizer.string = item
                    let range = item.startIndex..<item.endIndex
                    let tokenArray = tokenizer.tokens(for: range)
                    counter.words = tokenArray.count
                }
        }

    }
}

```
`App`は`@main` attributeに続けて書くことで、ここがアプリの入り口だとSwiftUIに教える。
`App` protocolは`main()`のデフォルト実装を提供する。
`main()`はsystemがアプリを起動するために呼び出す。
`@NSApplicationDelegateAdaptor`というattributeに続けてAppDelegateを宣言することで、AppDelegateをこのアプリは実装していることをSwiftUIに（？）伝える。

``` Counter.swift
import SwiftUI

class Counter: ObservableObject {
    @Published var characters: Int = 0
    @Published var words: Int = 0

    enum Kind {
        case words, characters
    }
}
```
簡単なカウンター

# 学んだこと
App protocolの必須メソッドである`body`は`Scene`のOpaque Result Typeを返すComputed Property。
`Scene`はviewヒエラルキーのコンテナ。viewヒエラルキーのルートviewと、systemに管理されたlifecycleを持つ。
View protocolに似ているね。

Sceneについて詳しくは


https://developer.apple.com/wwdc22/10061

# 今回詰まったこと
MenuBarExtraのアイコンを状態に基づいて変更したかったが、どこでNotificationCenterからの通知に基づいてstateを変更するかが問題だった。
最初は`init<S>(S, content: () -> Content)`で
```
MenuBarExtra("\(count)") {
    SomeView()
      .onReceive(...)...
}
```
としていたが、なぜかこれではonReceiveが呼ばれなかった。
実装コードのように`init(content: () -> Content, label: () -> Label)`でlabelの中でonReceiveを使うときちんと呼ばれた。<br>
賢い人なんでか教えて。

# 参考：
起動中Dockから非表示にする↓

https://qiita.com/SNQ-2001/items/1ec7bc82dad77a0a206e

全般を参考に↓


https://genjiapp.com/blog/2022/11/03/swiftui-macos-app-menu-bar-extra.html