見えない通知を捕まえて通知駆動アプリケーションを作る

iOS5鳴り物入りで登場して、その仕組みがMountain Lionにも取り入れられたと錯覚しがちな通知センターであるが、実は通知センターという考え方は、はるか昔のNeXTSTEPの頃からあった。その証拠に、通知センターを実現するクラス(NSUserNotificationCenter)はNSで始まっている。NSはNeXTSTEPの頭文字なのだ。
NSUserNotificationCenterは、Mountain Lionで導入された、その名のとおりユーザーのための通知センターである。一方、NeXTSTEPの頃から使われている通知センターは、NSNotificationCenterである。Userが抜けている。NSNotificationCenterは、オブジェクト(プロセス)間の通信のための通知センターである。オブジェクトに通知することが目的なので、GUIとして視覚的に見せていないのだ。
ユーザーには見えていないけど、はるか昔のNeXTSTEPの頃から、NSNotificationという通知はOSの中を飛び交っていたのである。*1

通知センター

Aaron Hillegass(アーロン・ヒレガス)氏著「Mac OSX Cocoa プログラミング」(2002-06-28初版)の中で通知の紹介をする部分は、自分が好きなくだりだ。

…中略…
環境設定パネルを開き、(書類ウィンドウの)背景色を変更しました。ところが、既存のウィンドウの背景色は変わりません。ユーザーはそれを見てがっかりしました。そして、あなたに対してメールで要望を伝えてきました。あなたは以下のような返信を書くことになります:「環境設定は書類ウィンドウが生成される時だけ読み込まれます。いったん書類を保存し、閉じた後、再度開いてください。」しかし、そんな返信を書くと、ユーザーから嫌味たらたらのメールが返ってくることでしょう。既存のウィンドウすべてを更新する方が良いのは判っています。しかし、それはいくつ存在するのでしょうか? 開いている書類すべてをまとめて管理しておかなければならないのでしょうか?

すごくありがちなシチュエーション。この問題をエレガントに解決する手段として、通知を使用する方法が紹介されていた。

  • ユーザーが勝手気ままに開いた複数の書類ウィンドウでは、背景色の設定が変更されたら教えてください、と通知センターに登録しておく。
  • 一方、環境設定ウィンドウは、背景色を変更したら通知センターに変更されたことを通知する。
  • すると、通知センターを経由して、すべての書類ウィンドウへ背景色が変更されたことが通知されるのである。
  • その通知がイベントとなって、すべての書類ウィンドウの背景色が変更されるのである。ウィンドウを開き直すことなく。

この例では、アプリケーション内に限定された通知センターを利用しているので、アプリケーション間のメッセージ通信はできないのだが、OSXにはアプリケーション間の通信を目的とした通知センターも用意されている。それが、NSDistributedNotificationCenterである。

[NSDistributedNotificationCenter defaultCenter]

また、OS環境が発信する通知を取りまとめる通知センターもある。それが、NSWorkspaceから取得する通知センターである。

[[NSWorkspace sharedWorkspace] notificationCenter]

通知を見る

ここまで知ると、OSXの中でどのような通知が飛び交っているのか、どうしても覗いてみたくなる。実は、意外とシンプルなCocoaアプリケーションを作ることで、OSの中で飛び交う通知を見ることができるのだ。

NSDistributedNotificationCenterの通知


#import "AppDelegate.h"

@implementation AppDelegate

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
// Insert code here to initialize your application
[[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(notify:) name:nil object:nil];
//[[[NSWorkspace sharedWorkspace] notificationCenter] addObserver:self selector:@selector(notify:) name:nil object:nil];
}

- (void)notify:(NSNotification *)notification
{
NSLog(@"%@ <---%@", notification.name, notification.object);
//NSLog(@"%@ <---%@\n%@", notification.name, notification.object, notification.userInfo);
}

@end

  • これで、NSDistributedNotificationCenterに届くすべての通知が、Xcodeのログに表示されるのだ。
  • 以下のログを見ると、様々なアプリケーションがそれぞれの通知を刻々と発信していることが分かる。
2012-09-18 00:41:16.187 LogNotification[3350:303] applicationChanged <---org.pqrs.KeyRemap4MacBook.notification
2012-09-18 00:41:16.188 LogNotification[3350:303] com.apple.HIToolbox.frontMenuBarShown <---(null)
2012-09-18 00:41:16.526 LogNotification[3350:303] AppleSelectedInputSourcesChangedNotification <---(null)
2012-09-18 00:41:16.526 LogNotification[3350:303] com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged <---(null)
2012-09-18 00:41:16.527 LogNotification[3350:303] com.apple.securityagent.InputPrefsChanged <---com.apple.loginwindow
2012-09-18 00:41:16.527 LogNotification[3350:303] inputSourceChanged <---org.pqrs.KeyRemap4MacBook.notification
2012-09-18 00:41:16.527 LogNotification[3350:303] AppleSelectedInputSourcesChangedNotification <---(null)
2012-09-18 00:41:16.527 LogNotification[3350:303] com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged <---(null)
2012-09-18 00:41:16.528 LogNotification[3350:303] com.apple.securityagent.InputPrefsChanged <---com.apple.loginwindow
2012-09-18 00:41:16.528 LogNotification[3350:303] inputSourceChanged <---org.pqrs.KeyRemap4MacBook.notification
2012-09-18 00:41:19.223 LogNotification[3350:303] com.apple.iTunes.playerInfo <---com.apple.iTunes.player
2012-09-18 00:41:24.066 LogNotification[3350:303] com.apple.iTunes.playerInfo <---com.apple.iTunes.player
  • さらに、NSLogの出力にuserInfoも加えれば、その通知の詳細な内容も見られるのだ!
  • 以下のログは、iTunesで再生ボタンを押した瞬間の通知とそのuserInfoの内容である。
    • Ben E KingのStand By Meを再生したところ。
2012-09-18 00:48:19.704 LogNotification[3396:303] com.apple.iTunes.playerInfo <---com.apple.iTunes.player
{
    Album = "Big Super Artists Best Selection 20 Numbers";
    "Album Rating" = 60;
    "Album Rating Computed" = 1;
    Artist = "Ben E King";
    "Artwork Count" = 0;
    Compilation = 1;
    Genre = "\U30aa\U30fc\U30eb\U30c7\U30a3\U30fc\U30ba";
    "Library PersistentID" = "-1553011500729873731";
    Location = "file://localhost/Users/zari/Music/iTunes/iTunes%20Music/%E3%82%B3%E3%83%B3%E3%83%92%E3%82%9A%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3/Big%20Super%20Artists%20Best%20Selection%2020%20Numbers/05%20Stand%20By%20Me.mp3";
    Name = "Stand By Me";
    PersistentID = "-1675664310883459618";
    "Play Count" = 23;
    "Play Date" = "2009-08-24 01:53:47 +0000";
    "Player State" = Playing;
    "Playlist PersistentID" = "-8934208207880045248";
    Rating = 100;
    "Rating Computed" = 0;
    "Skip Count" = 0;
    "Store URL" = "itms://itunes.com/link?n=Stand%20By%20Me&an=Ben%20E%20King&pn=Big%20Super%20Artists%20Best%20Selection%2020%20Numbers";
    "Total Time" = 181054;
    "Track Count" = 20;
    "Track Number" = 5;
}
NSWorkspaceの通知
  • 今度は、NSWorkspaceの通知を見てみる。


#import "AppDelegate.h"

@implementation AppDelegate

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
// Insert code here to initialize your application
//[[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(notify:) name:nil object:nil];
[[[NSWorkspace sharedWorkspace] notificationCenter] addObserver:self selector:@selector(notify:) name:nil object:nil];
}

- (void)notify:(NSNotification *)notification
{
NSLog(@"%@ <---%@", notification.name, notification.object);
//NSLog(@"%@ <---%@\n%@", notification.name, notification.object, notification.userInfo);
}

@end

  • 以下は実行後、一旦スリープして、復帰したログ。
2012-09-18 01:00:30.592 LogNotification[3494:303] NSWorkspaceDidActivateApplicationNotification <---<NSWorkspace: 0x108e177e0>
2012-09-18 01:00:30.593 LogNotification[3494:303] NSWorkspaceDidDeactivateApplicationNotification <---<NSWorkspace: 0x108e177e0>
2012-09-18 01:00:32.942 LogNotification[3494:303] NSWorkspaceWillSleepNotification <---<NSWorkspace: 0x108e177e0>
2012-09-18 01:00:32.954 LogNotification[3494:303] NSWorkspaceScreensDidSleepNotification <---<NSWorkspace: 0x108e177e0>
2012-09-18 01:00:47.611 LogNotification[3494:303] NSWorkspaceScreensDidWakeNotification <---<NSWorkspace: 0x108e177e0>
2012-09-18 01:00:47.917 LogNotification[3494:303] NSWorkspaceDidWakeNotification <---<NSWorkspace: 0x108e177e0>
  • アプリケーションのアクティブ・ディアクティブ、スリープ・スリープ解除の瞬間を的確に通知していることが分かる。
  • 上記以外にも様々な通知が用意され、タイミングに応じて的確に通知されるのだ。(NSWorkspace Class Reference

AppleScript

  • 以上のことを理解したら、古き良きNSNotificationと最新のNSUserNotificationの融合を図ってみる。
  • 前回のAppleScriptで通知センターに通知する技と、今回のオブジェクト間の通知を知る技を組み合わせるのだ!
  • AppleScriptエディタ >> ファイル >> テンプレートから新規作成 >> Cocoa AppleScript Applet.appを作成して、以下のようにコーディング。
NSDistributedNotificationCenterの通知をユーザー通知センターへ


set |center| to my NSDistributedNotificationCenter's defaultCenter() |center|'s addObserver_selector_name_object_(me, "notify:", missing value, missing value) --すべて通知する
--|center|'s addObserver_selector_name_object_(me, "notify:", "com.apple.iTunes.playerInfo", missing value) --特定の通知のみ

on notify_(notification) set |name| to notification's |name| as text
set object to notification's object as text
set userInfo to notification's userInfo
try
message(object, |name|, userInfo's |Artist|) --|name||Name|は混在できない
on error
message(object, |name|, "") end try
end notify_

on message(title, subtitle, msg) set notification to my NSUserNotification's alloc()'s init() set notification's title to title
set notification's subtitle to subtitle
set notification's |informativeText| to msg
my NSUserNotificationCenter's defaultUserNotificationCenter()'s deliverNotification_(notification) end message

  • たったこれだけで、NSDistributedNotificationCenterの通知を、NSUserNotificationCenterへ転送することになるのだ!
  • OS内部のオブジェクト間の通知が、ユーザーの通知センターへひたすら転送される様子見ていると、何だか楽しい。
  • 普段は聞こえてこない、アプリケーション同士のおしゃべり(あるいは筆談)を聞いているような気になってくる。
NSWorkspaceの通知をユーザー通知センターへ
  • こちらはNSWorkspaceの通知を転送するAppleScript


set |center| to my NSWorkspace's sharedWorkspace()'s notificationCenter() |center|'s addObserver_selector_name_object_(me, "notify:", missing value, missing value) --すべて通知する

on notify_(notification) message("", "", notification's |name|) end notify_

on message(title, subtitle, msg) set notification to my NSUserNotification's alloc()'s init() set notification's title to title
set notification's subtitle to subtitle
set notification's |informativeText| to msg
my NSUserNotificationCenter's defaultUserNotificationCenter()'s deliverNotification_(notification) end message

通知駆動アプリケーション

  • 以上の方法で、AppleScriptでも通知を自由に捕らえられるようになった!

通知を捕まえられて何が嬉しいのか?というと...

  • 今まで実行するタイミングを捕らえられず諦めていた処理も、通知されるタイミングで処理可能になる。
  • スリープ解除等を知るため、on idleハンドラなどで定期的にsyslog等を監視していた処理が不要になる。

AppleScriptの雛形にしておく

  • Lion以降、AppleScriptにもテンプレートというメニューが追加され、よく使う基本的なコード構成を登録しておけるようになった。
  • その実態はどこにあるのかと言うと、/Library/Application Support/Script Editor/Templates/ 以下に保存されたスクリプトなのだ。
  • さらに、ホームフォルダの/Library/Application Support/Script Editor/Templates/ は存在しないが、
  • 自分でScript Editor/Templates/ を追加することで、そこに保存したスクリプトも雛形として活用できるようになるのだ!
  • さっそく、~/Library/Application Support/Script Editor/Templates/ を作成して、以下のスクリプトを追加した。
application_notification.app
  • iTunesで曲の再生が開始された時にユーザー通知センターに通知するアプリケーション。
  • ユーザー通知センターに通知する部分の処理を自分好みに差し替えれば、曲か変わり目で好きなことができるのだ。
  • iTunesの通知を捕まえるのは一例であり、様々なアプリケーションが発信する通知を捕まえて、好みの処理をすればいいのである。


set |center| to my NSDistributedNotificationCenter's defaultCenter() --|center|'s addObserver_selector_name_object_(me, "notify:", missing value, missing value) --すべて通知する
|center|'s addObserver_selector_name_object_(me, "com_apple_iTunes_playerInfo:", "com.apple.iTunes.playerInfo", missing value) --特定の通知だけ

on message(title, subtitle, msg) set notification to my NSUserNotification's alloc()'s init() set notification's title to title
set notification's subtitle to subtitle
set notification's |informativeText| to msg
my NSUserNotificationCenter's defaultUserNotificationCenter()'s deliverNotification_(notification) end message

on notify_(notification) set |name| to notification's |name| as text
set object to notification's object as text
set userInfo to notification's userInfo
try
message(object, |name|, userInfo's |Artist|) --|name||Name|は混在できない
on error
message(object, |name|, "") end try
end notify_

on com_apple_iTunes_playerInfo_(notification) set |name| to notification's |name| as text
set object to notification's object as text
set userInfo to notification's userInfo
if (userInfo's |Player State| as text) is "Playing" then
message(object, |name|, userInfo's |Artist|) --|name||Name|は混在できない
end if
end com_apple_iTunes_playerInfo_

(* on quit my NSDistributedNotificationCenter's defaultCenter()'s removeObserver_(me) continue quit end quit *)

workspace_notification.app
  • マウント・アンマウント・スリープ・スリープ解除のタイミングで、ユーザー通知センターへ通知するアプリケーション。
    • NSWorkspaceの通知は、以下コードの最後に記載したnotificationが使える。
    • 通知センターに必要な通知を知らせて、そのハンドラに自分好みの処理を書けばいいのだ。


set |center| to my NSWorkspace's sharedWorkspace()'s notificationCenter()
--|center|'s addObserver_selector_name_object_(me, "notify:", missing value, missing value) --すべて通知する
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceDidMount:", "NSWorkspaceDidMountNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceWillUnmount:", "NSWorkspaceWillUnmountNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceDidUnmount:", "NSWorkspaceDidUnmountNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceDidMount:", "NSWorkspaceDidMountNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceWillSleep:", "NSWorkspaceWillSleepNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceDidWake:", "NSWorkspaceDidWakeNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceScreensDidSleep:", "NSWorkspaceScreensDidSleepNotification", missing value)
|center|'s addObserver_selector_name_object_(me, "NSWorkspaceScreensDidWake:", "NSWorkspaceScreensDidWakeNotification", missing value)

on message(title, subtitle, msg)
set notification to my NSUserNotification's alloc()'s init()
set notification's title to title
set notification's subtitle to subtitle
set notification's |informativeText| to msg
my NSUserNotificationCenter's defaultUserNotificationCenter()'s deliverNotification_(notification)
end message

on notify_(notification)
message("", "", notification's |name|)
end notify_

on NSWorkspaceDidMount_(notification)
set |userInfo| to notification's |userInfo|
message("マウント", |userInfo|'s NSDevicePath, |userInfo|'s NSWorkspaceVolumeLocalizedNameKey)
end NSWorkspaceDidMount_

on NSWorkspaceWillUnmount_(notification)
set |userInfo| to notification's |userInfo|
message("アンマウントwill", |userInfo|'s NSDevicePath, |userInfo|'s NSWorkspaceVolumeLocalizedNameKey)
end NSWorkspaceWillUnmount_

on NSWorkspaceDidUnmount_(notification)
set |userInfo| to notification's |userInfo|
message("アンマウントdid", |userInfo|'s NSDevicePath, |userInfo|'s NSWorkspaceVolumeLocalizedNameKey)
end NSWorkspaceDidUnmount_

on NSWorkspaceWillSleep_(notification)
message("スリープ", "", "")
end NSWorkspaceWillSleep_

on NSWorkspaceDidWake_(notification)
message("スリープ解除", "", "")
end NSWorkspaceDidWake_

on NSWorkspaceScreensDidSleep_(notification)
message("モニタスリープ", "", "")
end NSWorkspaceScreensDidSleep_

on NSWorkspaceScreensDidWake_(notification)
message("モニタスリープ解除", "", "")
end NSWorkspaceScreensDidWake_

(* NSWorkspaceNotification一覧
NSWorkspaceWillLaunchApplicationNotification NSWorkspaceDidLaunchApplicationNotification NSWorkspaceDidTerminateApplicationNotification NSWorkspaceSessionDidBecomeActiveNotification NSWorkspaceSessionDidResignActiveNotification NSWorkspaceDidHideApplicationNotification NSWorkspaceDidUnhideApplicationNotification NSWorkspaceDidActivateApplicationNotification NSWorkspaceDidDeactivateApplicationNotification NSWorkspaceDidRenameVolumeNotification NSWorkspaceDidMountNotification NSWorkspaceWillUnmountNotification NSWorkspaceDidUnmountNotification NSWorkspaceDidPerformFileOperationNotification NSWorkspaceDidChangeFileLabelsNotification NSWorkspaceActiveSpaceDidChangeNotification NSWorkspaceDidWakeNotification NSWorkspaceWillPowerOffNotification NSWorkspaceWillSleepNotification NSWorkspaceScreensDidSleepNotification NSWorkspaceScreensDidWakeNotification *)


通知を捕らえて、何か面白いことができそうな気がしてきた!

*1:その通知を捕らえて、GUIとして視覚的に見せる仕組みを実現したのがGrowlだと思っている