🌊

雑にmacOSのメニューバーアプリを作る

に公開

普段はterraformやGolangでインフラ・バックエンドのコードを書いているのですが、少し前から趣味でSwiftUIを使ったmacOSのメニューバーアプリを書くようになったので、雑な作り方についてまとめてみたいと思います。

経緯

インシデント通知にPagerDutyを使っているのですがmacOSの公式クライアントは存在せず、macOS上で通知を受け取るためにoncall-statusというGolang製のメニューバーアプリを使っていました。

https://github.com/mtougeron/oncall-status

しかしoncall-statusは2023年くらいに開発停止。公証(Notarization)されないためかそのうち起動できなくなり、仕方なく自前でビルドしていまいしたが、それもOSのバージョンアップでビルドできなくなりました。

oncall-statusはほぼGolangと通知の部分だけObjective-Cで書かれているので「Golangで自作してみるか」と思いコードを読んでみたところ、Objective-Cの部分が理解できなくて断念。Swiftで全部書いた方がまだ手が出せそうだったのでSwiftの勉強を始めました。

アプリを作る前に

私がアプリを作り始める前にやったことを書いておきます。

  • SwiftUIを使ってmacOSステータスバーアプリをつくる方法 | 株式会社ヌーラボ(Nulab inc.)
    • こちらの記事を読んだことで自分にも作れそうだと思ってアプリを作り始めました
    • macOS 13からMenuBarExtraが追加されたので違う書き方にはなったのですが、NSPopoverの使い方などとても参考になりました
  • Swiftの基本文法の勉強
    • Kindle Unlimitedに登録しているので「Swift 入門」などと検索して出てきた入門書で基本的な文法を勉強しました
    • 組み込みのOptional型がある言語に触っていないと学習コストは高いかもしれません。私の場合はRustと比較して理解することが多かったです
  • iOSアプリの勉強
    • macOSアプリ入門書は見つけることができなかったので、iOSアプリの入門書でSwiftUIやXcodeの使い方を勉強しました
    • iPhone アプリ開発集中講座」という本で勉強しました
  • Apple Developer Programへの登録

今だったらAIでもっと簡単に学習できるかもしれません。

PagerDuty通知アプリを作る

サンプルとして簡易的なPagerDuryインシデント通知アプリを作ります。
完全なサンプルコードはGitHubにおいています。

https://github.com/winebarrel/PDNotify

仕様

  • 起動するとメニューバーに常駐する
  • クリックでメニューが出る
  • インシデントが発生したら通知される
  • 通知をクリックするとブラウザでPagerDutyを開く
  • 設定画面でAPIキーを保存する
  • ログイン時に自動起動する
画面
メニュー
通知
設定画面

Xcodeプロジェクトの作成

XcodeのCreate New Project...macOS > Appでプロジェクトを作成します。

プロジェクトを作成した時点でちっちゃいウィンドウを出すコードが生成されます。

各種設定

アプリを動かすために必要な設定をしていきます。

カテゴリの設定

プロジェクト > TAEGETG > GeneralからApp Categoryを適当に設定します。

メインウィンドウの非表示

アプリ起動時にメインウィンドウを表示しないようにするためプロジェクト > TARGETS > Build SettingsApplication is AgentYESにします。

外部通信の許可

プロジェクト > TARGETS > Signing & CapabilitiesOutgoing Connectionsにチェックを入れます。

キーチェーンアクセスの権限追加

プロジェクト > TARGETS > Signing & Capabilities+ CapabilityからKeychain Sharingを追加します。

アプリの実装

ここからアプリを実装していきます。

ContentView.swiftを削除しPDNotifyApp.swiftWindowGroupMenuBarExtraで置き換えます。
Quitを選択したらアプリを終了、アイコンにはSF Symboles(Appleのシンボルフォント)からmegaphoneを使いました。

PDNotifyApp.swift
struct PDNotifyApp: App {
    var body: some Scene {
        MenuBarExtra {
            Button("Quit") {
                NSApplication.shared.terminate(self)
            }
        } label: {
            Image(systemName: "megaphone")
        }
    }
}

これだけで終了するだけのメニューバーアプリはできました。

Valetの追加

PagerDutyのAPIキーをキーチェーンに保存する必要があるので、キーチェーンの読み書き用にValetというパッケージを追加します。

https://github.com/square/Valet

コンテキストメニューからAdd Package Dependencies...を選択しhttps://github.com/square/Valetを検索してパッケージを追加します。

また、キーチェーンからAPIキーを読み書きするためのenum Vaultも追加します。

Vault.swift
import Foundation
import Valet

// let key = Vault.apiKey、Vault.apiKey = "xxx" などとしてアクセスできる
enum Vault {
    static let shared = Valet.valet(with: Identifier(nonEmpty: Bundle.main.bundleIdentifier)!, accessibility: .whenUnlocked)

    static var apiKey: String {
        get {
            do {
                return try shared.string(forKey: "apiKey")
            } catch KeychainError.itemNotFound {
                // Nothing to do
            } catch {
                print("failed to get apiKey from Valet: \(error)")
            }

            return ""
        }

        set(token) {
            do {
                if token.isEmpty {
                    try shared.removeObject(forKey: "apiKey")
                } else {
                    try shared.setString(token, forKey: "apiKey")
                }
            } catch {
                print("failed to set apiKey to Valet: \(error)")
            }
        }
    }
}

設定画面の追加

キーチェーンを読み書きできるようになったのでAPIキーを保存するための設定画面を追加します。APIキーのほかにPagerDutyのWebページのサブドメインも入力できるようにします。

SettingsView.swift
import SwiftUI

struct SettingsView: View {
    @Binding var apiKey: String
    @AppStorage("subdomain") private var subdomain = "identity"

    var body: some View {
        Form {
            TextField("Subdomain", text: $subdomain)
            SecureField("API Key", text: $apiKey).onChange(of: apiKey) {
                Vault.apiKey = apiKey
            }
        }
        .padding(20)
        .frame(width: 400)
    }
}

#Preview {
    SettingsView(apiKey: .constant(""))
}

メニューには設定画面を開くためのボタンを追加します。

PDNotifyApp.swift
 @main
 struct PDNotifyApp: App {
+    // スリープ時にエラーが出ることがあるのでapiキーはAppに定義して@Bindingで引き回す
+    @State private var apiKey = Vault.apiKey
+    @AppStorage("subdomain") var subdomain: String = "identity"
+
     var body: some Scene {
         MenuBarExtra {
+            SettingsLink {
+                Text("Settings")
+            }
+            Divider()
             Button("Quit") {
                 NSApplication.shared.terminate(self)
             }
         } label: {
             Image(systemName: "megaphone")
         }
+        Settings {
+            SettingsView(apiKey: $apiKey)
+        }
     }
 }

PagerDuty APIの呼び出し

PagerDutyのAPIの呼び出し部分を実装します。

https://developer.pagerduty.com/api-reference/9d0b4b12e36f9-list-incidents

まず、レスポンスのJSONをデシリアライズする構造体を追加します。

PagerDutyAPI.swift
struct Incident: Codable, Identifiable {
    // ほかにも属性はあるが簡易化のためIDのみ定義
    let id: String
}

struct Response: Codable {
    let incidents: [Incident]
}

そして、https://api.pagerduty.com/incidentsを呼び出す部分を実装。

PagerDutyAPI.swift
// (続き)

struct PagerDutyAPI {
    func getIncidents(_ apiKey: String) async throws -> [Incident] {
        var url = URL(string: "https://api.pagerduty.com/incidents")!
        url.append(queryItems: [.init(name: "statuses[]", value: "triggered")])

        var req = URLRequest(url: url)
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue("Token token=\(apiKey)", forHTTPHeaderField: "Authorization")

        let (data, rawResp) = try await URLSession.shared.data(for: req)

        guard let resp = rawResp as? HTTPURLResponse else {
            fatalError("failed to cast URLResponse to HTTPURLResponse")
        }

        if resp.statusCode != 200 {
            throw PagerDutyError.respNotOK
        }

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        let decoded = try decoder.decode(Response.self, from: data)
        return decoded.incidents
    }
}

enum PagerDutyError: Error {
    case respNotOK
}

テストボタンを追加すればメニューから動作確認できます。

PDNotifyApp.swift
     var body: some Scene {
         MenuBarExtra {
+            Button("Test") {
+                Task {
+                    do {
+                        let api = PagerDutyAPI()
+                        let incidents = try await api.getIncidents(apiKey)
+                        print(incidents)
+                    } catch {
+                        print(error)
+                    }
+                }
+            }
             SettingsLink {
                 Text("Settings")
             }

通知の追加

通知処理の部分を実装します。

まず、Appから通知を送るためのenum Notificationを追加します。

Notification.swift
import UserNotifications

enum Notification {
    static func notify(id: String, title: String, body: String, url: String) async {
        let userNotificationCenter = UNUserNotificationCenter.current()

        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.userInfo = ["url": url]
        content.sound = UNNotificationSound.default

        let req = UNNotificationRequest(identifier: "\(Bundle.main.bundleIdentifier!).\(id)", content: content, trigger: nil)
        try? await userNotificationCenter.add(req)
    }
}

つぎにAppDelegate.swiftを追加して、アプリ起動時の通知許可と通知クリック時のブラウザオープン処理を実装します。

AppDelegate.swift
import AppKit
import UserNotifications

class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_: Foundation.Notification) {
        UNUserNotificationCenter.current().delegate = self

        Task {
            do {
                let center = UNUserNotificationCenter.current()

                guard try await center.requestAuthorization(options: [.alert, .sound]) else {
                    print("user notification not authorized")
                    return
                }
            } catch {
                print("user notification authorization request error: \(error)")
            }
        }
    }
}

extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo

        guard let url = userInfo["url"] as? String else {
            fatalError("failed to cast userInfo['url'] to String")
        }

        NSWorkspace.shared.open(URL(string: url)!)
        completionHandler()
    }
}

@NSApplicationDelegateAdaptorを使った変数をAppに定義するとAppDelegateが有効になります。

PDNotifyApp.swift
 @main
 struct PDNotifyApp: App {
+    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
     @State private var apiKey = Vault.apiKey
     @AppStorage("subdomain") var subdomain: String = "identity"

     var body: some Scene {

こちらもテストボタンを追加してメニューから動作確認できます。

PDNotifyApp.swift
     var body: some Scene {
         MenuBarExtra {
+            Button("Test") {
+                Task {
+                    await Notification.notify(
+                        id: "my-id",
+                        title: "title",
+                        body: "body",
+                        url: "https://example.com"
+                    )
+                }
+            }

アプリ起動時に通知の許可を求めるダイアログが表示され、メニューからTestを選択すると通知が表示されます。


インシデントチェックの実装

定期的にPagerDutyのインシデントをチェックする部分を実装します。

まず、インシデントをチェックして通知する関数を追加。

PDNotifyApp.swift
     @State private var apiKey = Vault.apiKey
     @AppStorage("subdomain") var subdomain: String = "identity"

+    private var api = PagerDutyAPI()
+
+    private func checkIncidents() async {
+        do {
+            let incidents = try await api.getIncidents(apiKey)
+
+            if !incidents.isEmpty {
+                for i in incidents {
+                    await Notification.notify(
+                        id: i.id,
+                        title: "PD Incident",
+                        body: i.id,
+                        url: "https://\(subdomain).pagerduty.com/"
+                    )
+                }
+            }
+        } catch {
+            print(error)
+        }
+    }
+
     var body: some Scene {
         MenuBarExtra {
             SettingsLink {

そしてタイマーで定期チェックするようにします。

PDNotifyApp.swift
+    private func scheduleCheck() {
+        let seq = AsyncStream {
+            try? await Task.sleep(for: .seconds(60))
+        }
+
+        Task {
+            for await _ in seq {
+                await checkIncidents()
+            }
+        }
+    }
+
+    init() {
+        scheduleCheck() // XXX: あまりよくない
+    }
+
     var body: some Scene {
         MenuBarExtra {
             SettingsLink {

ログイン時自動起動の実装

メニューにLaunch at loginのチェックボックスを追加して、チェックされていたら自動起動するようにします。
※設定画面にチェックボックスを追加してそちらでON/OFFを切り替えられるようにしてもいいと思います。

まず、自動起動のON/OFFを管理するclass MenuBarStateを追加します。

MenuBarState.swift
import Combine
import ServiceManagement

class MenuBarState: ObservableObject {
    @Published var launchAtLogin = SMAppService.mainApp.status == .enabled
    var cancelable: AnyCancellable?

    init() {
        cancelable = $launchAtLogin.sink { newValue in
            do {
                if newValue {
                    try SMAppService.mainApp.register()
                } else {
                    try SMAppService.mainApp.unregister()
                }
            } catch {
                print("failed to change launchAtLogin: \(error)")
            }
        }
    }
}

そして、メニューの方に自動起動ON/OFFのトグルスイッチを追加します。

PDNotifyApp.swift
     @State private var apiKey = Vault.apiKey
     @AppStorage("subdomain") var subdomain: String = "identity"
+    @ObservedObject private var menuBarState = MenuBarState()
 
     private var api = PagerDutyAPI()
 
@@ -52,6 +53,10 @@ struct PDNotifyApp: App {
 
     var body: some Scene {
         MenuBarExtra {
+            Toggle(isOn: $menuBarState.launchAtLogin) {
+                Text("Launch at login")
+            }
+            .toggleStyle(.checkbox)
             SettingsLink {
                 Text("Settings")
             }

Launch at loginにチェックを入れるとログイン項目にアプリが追加されます。

以上でサンプルアプリは一応完成です。Apple Developer Programに登録していれば、Product > Archiveで配布用のApp Bundleを作成できます。

まとめ

このアプリはサンプルなので、エラー処理がいい加減・インシデントがあると通知が出続ける…等々、まともに使えるものでありませんが、メニューバーアプリの基本的な機能は押さえたつもりです。

もう少しきちんと作ったPagerDutyアプリはGitHub・App Storeに公開しており、業務でも活用しています。

https://github.com/winebarrel/pagercall

機能の多いmacOSアプリを作ろうとするとそれなりに大変だと思いますが、APIをたたいて通知するだけのメニューバーアプリを作る程度ならここに書いたぐらいの作業量です。
macOSアプリはめんどくさそう…と感じている方もちょっと作ってみるのはどうでしょうか。

株式会社カンム

Discussion