macOSでiPhoneミラーリングの通知をスクリプトで取得したい

## この記事の目的
iPhoneでは、通知をトリガーにして何か別の処理を行うなど、通知による自動化をすることは現状では出来ませんが、macOS 15からは[iPhoneミラーリング](https://support.apple.com/ja-jp/120421)によって、iPhoneの通知をMac端末へ取り込めるようになりました。
これにより、iPhoneの通知をトリガーにWebhookを送ってみるなどの自動化ができる気がしたので、本記事ではその方法を探ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1123247/d668aa0f-dda2-49c2-9dd4-8383cb4a0a5b.png)


## 前提条件
macOS 15.5を使用します。
本記事は公式にドキュメント化されていない内容であるため、本バージョン以外では動作しない可能性があります。
```shell
% sw_vers
ProductName:		macOS
ProductVersion:		15.5
BuildVersion:		24F74
```
また、iPhoneミラーリングの設定を行い、通知が常にMacに対して転送される設定にしておきます。

## macOSの通知センター

macOSでは、画面右上の通知センターに全ての通知がまとめられます。
macOS 15では、SQlite DBとして下記に通知が保存されます[^1]。
```
~/Library/Group Containers/group.com.apple.usernoted/db2/db
```
[^1]: https://x.com/theevilbit/status/1811758367045537990

DBから通知を取得するスクリプト[^2]を実行してみたところ、
ここにiPhoneの通知も入ると思いきや、ここにはMac内で発生した通知しか入っていないことが分かりました。
[^2]: https://x.com/theevilbit/status/1828368209554162078

## iPhoneからの通知はどこに保存される？
`~/Library/Group Containers`の中を眺めていると、`group.com.apple.UserNotifications`という気になるディレクトリを見つけました。
このディレクトリの中にある`Library.plist`を見てみると、iPhone側のパッケージ名があったため、iPhoneミラーリングと関係がありそうです。
plistファイルは`plutil`コマンドを使ってみていきます。

```powershell
$ pwd
/Users/[user name]/Library/Group Containers/group.com.apple.UserNotifications/Library/UserNotifications/Remote/default

$ plutil -convert xml1 -o - ./Library.plist 
```
plutilの出力:
```xml
[中略]
		<string>com.apple.Health</string>
		<string>jp.co.yahoo.YWeatherApp</string>
		<string>com.apple.tips</string>
		<string>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</string>
		<string>YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY</string>
		<string>ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ</string>
[中略]
```
パッケージ名のあとパッケージのUUIDと思われるものが、パッケージ名のアイテム数と同じ数だけあります。
もし同じ順番だとすれば、`jp.co.yahoo.YWeatherApp`のUUIDは`YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY`のはずです[^3]。
[^3]: 比較的単純なので推測で分かりますが、複雑な場合はNSKeyedUnarchiverを使用してデコードする必要があります


`/Users/[user name]/Library/Group Containers/group.com.apple.UserNotifications/Library/UserNotifications/Remote/default/YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY` に移動してみましょう。

中身は以下のようになっています。

```bash
$ ls -a
.				Attachments			Categories.plist
..				AttachmentsList.plist		DeliveredNotifications.plist
```
通知の中身が入っていそうですね。`DeliveredNotifications.plist`の中身を確認してみましょう。

```bash
$ plutil -convert xml1 -o - ./DeliveredNotifications.plist
```
plutilの出力:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1123247/8d11aed8-7848-498b-afc5-ab05a36f8bbb.png)

ありました！　DeliveredNotifications.plistを定期的に監視すれば、オートメーション化できそうですね

## 通知を処理する

ディレクトリの構造的には、iOS内で通知が保存されているディレクトリ（`/private/var/mobile/Library/UserNotifications/`）と似ている印象です。
`DeliveredNotifications.plist`をXMLに変換し、今回作成したPythonスクリプト（記事末尾に記載）に流すと、以下のような出力を得られます。
```shell
$ plutil -convert xml1 -o - ./DeliveredNotifications.plist > input.xml
$ pytnon3 process.py ./input.xml
Notification Time: 2025-06-21 14:49:50
Notification Text: 【動画ニュース】日曜日は関東で猛暑日も　北日本は週明けにかけ大雨のおそれ　来週は各地で梅雨空戻る（気象予報士・及川藍）
AttachmentURL:     file:///Users/[user name]/Library/Group%20Containers/group.com.apple.UserNotifications/Library/UserNotifications/Remote/default/YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY/Attachments/[filename]
--------------------------------------------------
Notification Time: 2025-06-21 10:28:36
Notification Text: 【夏至】一年で最も昼の時間が長い日
冬至と比べてどれくらい長い？
AttachmentURL:     file:///Users/[user name]/Library/Group%20Containers/group.com.apple.UserNotifications/Library/UserNotifications/Remote/default/YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY/Attachments/[filename]
--------------------------------------------------
Notification Time: 2025-06-21 08:01:41
....
```
AttachmentURLが存在する場合、通知に含まれる画像が取得できます。

## アプリのアイコンも取得したい
バンドルIDがplistから取得できるため、最も楽なのは[iTunes Search API](https://performance-partners.apple.com/search-api)を使用して、アイコンを取得することだと思います。
```bash
BUNDLE_ID="jp.co.yahoo.YWeatherApp"
ICON_URL=$(curl -s "https://itunes.apple.com/lookup?bundleId=${BUNDLE_ID}&country=jp&entity=software" | jq -r '.results[0].artworkUrl512')
wget $ICON_URL
```




## 使用したスクリプト

```python:process.py
import sys
import plistlib
import datetime

file_path = sys.argv[1]

with open(file_path, 'rb') as fp:
    data = plistlib.load(fp)

objects = data['$objects']

def get_value(dict_obj, key_name):
    try:
        for i, key_ref in enumerate(dict_obj.get('NS.keys', [])):
            key_string = objects[key_ref.get('CF$UID')]
            if key_string == key_name:
                value_uid = dict_obj.get('NS.objects', [])[i].get('CF$UID')
                return objects[value_uid]
    except (KeyError, ValueError, IndexError, TypeError):
        return None
    return None

for notif_ref in objects[1]['NS.objects']:
    notif_obj = objects[notif_ref['CF$UID']]

    time_str = "N/A"
    try:
        date_obj = get_value(notif_obj, "AppNotificationCreationDate")
        cf_timestamp = date_obj.get('NS.time')
        
        ref_date = datetime.datetime(2001, 1, 1, tzinfo=datetime.timezone.utc)
        utc_time = ref_date + datetime.timedelta(seconds=cf_timestamp)
        
        jst = datetime.timezone(datetime.timedelta(hours=9), name="JST")
        local_time = utc_time.astimezone(jst)
        
        time_str = local_time.strftime('%Y-%m-%d %H:%M:%S')
    except (AttributeError, TypeError):
        pass

    msg = get_value(notif_obj, "AppNotificationMessage")
    if not msg:
        msg = get_value(notif_obj, "AppNotificationTitle")
    msg = msg or "N/A"

    url = "N/A"
    try:
        attachments = get_value(notif_obj, "AppNotificationAttachments")
        attach_obj = objects[attachments['NS.objects'][0]['CF$UID']]
        url_obj = get_value(attach_obj, 'AttachmentURL')
        url = objects[url_obj['NS.relative']['CF$UID']]
    except (AttributeError, KeyError, ValueError, IndexError, TypeError):
        pass

    print(f"Notification Time: {time_str}")
    print(f"Notification Text: {msg}")
    print(f"AttachmentURL:     {url}")
    print("--------------------------------------------------")
```