PIYO - Tech & Life -

バッテリー監視のMacアプリを作ったんだけど難しかった

Mac Swift

久しぶりにMacアプリを作りました(5年ぶりぐらい)。難しかったです。

Macbook Proをドッキングステーション経由の給電で使ってるせいか、たまに接触が悪いのか充電できない状態になることがあります。気がついたらバッテリー切れでMacが死んでしまうのでとても不便です。

バッテリー残量監視アプリはストアにいくつか並んでいますが、これらはたいていMacの通知センターで通知してくれます。おやすみモードを有効にしているときや離席中にはMacの通知では気づけません。そこで、定期的にバッテリー残量を監視してSlackやその他ツールのWebhookのURLにメッセージを投げることができるアプリを自分で作りました。特にストアなどには公開していません。

ステータスバーへの表示

いわゆる常駐アプリのような形で作ったので、ステータスバーへの表示は必須と考えました。

ステータスバーへのアイコン表示は↓のような感じで実現できました。ここではImage AssetとしてIcon.pdfというファイルがあるとします。

(カラーアイコンの表示方法はわかりませんでした。白黒のPDF画像を用意しておくとダークモードかどうかで自動的に色を反転させてくれるようです。)

let statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.squareLength))
if let button = self.statusBarItem.button {
    let size = NSMakeSize(22, 22)
    let image = NSImage(named: "Icon")
    image?.size = size
    button.image = image
}

ステータスバーのアイコンを押したらメニューを出すのがよくあるパターンだと思います。先程のstatusBarItemにメニューを追加すると表示してくれるようになります。

let statusBarMenu = NSMenu(title: "Menu")
statusBarItem.menu = statusBarMenu
statusBarMenu.addItem(
    withTitle: "Preferences...",
    action: #selector(AppDelegate.togglePopover),
    keyEquivalent: "")

statusBarMenu.addItem(NSMenuItem.separator())

statusBarMenu.addItem(
    withTitle: "Quit",
    action: #selector(AppDelegate.quit),
    keyEquivalent: "")

設定、セパレータ、終了の3つを並べています。action引数にはメニューを押したときの挙動をかけます。このアプリでは、設定メニューは設定画面Popoverの切り替えを、終了メニューは単にアプリを終了させる処理を書いています。

Dockに表示させない

Info.plistにLSUIElement = trueな設定を追加することでアプリアイコンをDockに表示させないようにできます。

LSUIElementはバックグラウンドアプリのための設定です。

A Boolean value indicating whether the app is an agent app that runs in the background and doesn’t appear in the Dock

# Info.plist
<key>LSUIElement</key>
<true/>

SwiftUI

SwiftUIが発表されて依頼はじめてマジメに触りました。今回はせいぜい設定画面のみでたくさんの画面を作るわけではないのでSwiftUIを採用してみました。

プレビュー

SwiftUIでUIを定義すると、Xcodeが即座にプレビューを更新してくれます。最初は即座に確認できて便利、、、と思っていました。

しかし、ちょっとタイピングして保存とするとすぐにプレビューを更新し、エディタ部分のフォーカスが失われるのでめちゃくちゃ不便ということがわかりました。しかもしらないうちにプレビューが壊れていたりして、Resumeしないと動かないという。

これは僕の感想ですが、まだ実用的ではないなと思いました。

Toggleのクセ

今回SwiftUIを使ったのは設定画面です。設定画面なので、値を変更したら即座にUserDefaultsに保存しようと考えていました。

当初TextFieldやSliderはonEditingChangedで変更を検知できるので、そのタイミングで保存処理を書いていました。

しかし後述する「ログイン時に起動」をToggle(チェックボックス)で実現しようとしたときに困ったことになりました。ToggleにはonEditingChangedがなかったのです。

プロパティのdidSetで実現できないかとか、もっと苦しいワークアラウンド(空のテキストを返す関数を定義してViewに埋め込み、その関数内でToggleの値を使って処理する)とか、色々考えました。しかしUserDefaultsの保存のコードがうまく動かないし、そもそもワークアラウンドだと関数が何度も呼ばれてしまうなど、あまりよろしくありません。

変更を検知できないなら、ユーザーが保存ボタンを押すしかないだろうということで、保存ボタンをキャンセルボタンを設けることにして逃げました。

ログイン時に起動

常駐アプリであるからには、Macを再起動したときにも勝手に立ち上がって欲しいですよね。これを実現するにはService Management Frameworkという仕組みを使ってログイン時に起動する項目に追加する必要があるようです。

Adding Login Items
Explains how to write background processes that perform work on behalf of applications or serve content over the network.

Service Management Frameworkについての情報(特に日本語情報)はあまり多くないのですが、幸いまるっとライブラリ化されているOSSがあったので、それを活用させてもらうことにしました。

GitHub - sindresorhus/LaunchAtLogin: Add “Launch at Login” functionality to your macOS app in seconds
Add “Launch at Login” functionality to your macOS app in seconds - GitHub - sindresorhus/LaunchAtLogin: Add “Launch at Login” functionality to your macOS app in seconds

導入はとても簡単で、Run Script Phaseにスクリプトを追加することと、コードを少々追加するだけです。

今回の僕のアプリでは設定画面を保存する際に、Toggleの状態に応じて設定を更新してやるだけでいけました。

import LaunchAtLogin

// Toggleのstate
@State private var launchAtLogin = false

// 保存時
LaunchAtLogin.isEnabled = self.launchAtLogin

これだけで実際にログイン時に起動してくれるようになりました。自分で書いていたらMacの作法に乗るのが大変だったと思うのでこのライブラリには感謝しています。