だいぶ実装がアレな SkypeLogger の解説

Mac OS X のアプリケーションを作る方法が Objective-C だけとは、限りません。

今回はそういった意味ではだいぶ実装がアレな拙作 SkypeLogger の実装を見ながら、こんな作り方もあるよねということを紹介したいと思います。

Xcode

SkypeLogger の紹介

まず、SkypeLogger ですが、何をするものかというと、Skype のチャットログを人間が読める形で普通のファイルに書きだしてくれるツールです。Skype.app 自体にログを残す機能はあるのですが、人間が読めないし、機械でも読めない独自の形式1で書き出されるので不便なのです。 そこで、Skype API を使ってチャットの発言の送信、受信イベントをもとにチャットの発言を逐一ファイルに書き込んでいくだけの簡単なお仕事をしていただく、というツールです。

しかしこの Skype API が曲者で、API とは名ばかりで Skype.app と通信するインターフェイスが提供されるだけで、実際に Skype.app が起動していないと使えません。今回のような用途に限ってはそれでも問題ないのですが、ボットを作る、とかだとちょっと面倒2ですね。

SkypeLogger の概要

さて以上を踏まえてこの SkypeLogger の実装についてお話しましょう。仕組みはこうです。

まず Skype.app の起動を検出して自身も同時に起動します。これには SIMBL を使っています。SIMBL とは、Mac OS X の既存の(ほとんど、どんな)アプリケーションでも拡張できるプラグインを作るためのプラグインです。具体的には、Reederを Growl に対応させたりとか、そういうツールを作るのに使います。

動作原理についてはまた後述するとして、全体像を把握するために、ここでは SIMBL は他のアプリの起動と同時にアプリと一緒にプラグインを読み込んでくれるツールと考えてください。

SkypeLogger は、この SIMBL を使って薄い Objective-C で書かれたコードを Skype に読み込ませます。次に、そのコードは ruby をつかって Ruby で書かれた Skype API を使ってログを取るツールを内部的に起動します。これには RubyCocoa を使っています。そしてそのツールが Skype.app と通信をしてログをLogger を使って所定のファイルに書き出していきます。

あとは Objective-C で書かれた部分で Skype.appの終了通知を監視して、Skypeが終了したら、内部的に起動したツールも一緒に終了させます。

と、まあこういう仕組みで動いているのですが、つまり、このツールというかアプリ、Objective-C と、Ruby (と RubyCocoa) の2つを使って書かれています。このあたり、各言語ごとにブリッジが存在している Objective-C ならではですね。

ここまで見るとわかると思うのですが、Ruby で書かれた部分は SIMBL を使う Objective-C で書かれた部分と完全に切り離されていることに気がつくと思います。実際、この Ruby 部分だけでロガーとして別に手動で起動することも可能です。これには諸事情3あってこのような実装になっています4

SIMBL の仕組み

では、まず起点となる SIMBL から見て行きましょう。SIMBL はほとんどのMac OS Xのアプリにプラグイン機能を提供してくれます。つまり、ちょっーっと足らないあと一歩の機能を自分で追加したりすることができます。

さて、その仕組みなのですが、Mac OS X のバージョンアップを重ねるにつれて複雑化してきています。その昔は Mac OS Xのアプリが Library/InputManagers にあるプラグインを自動的に読み込む機能を経由して独自のプラグインを読みこませることをしていましたが、現在は /Library/ScriptingAdditions とデーモンによる複雑な手法になっています5

まず、対象のアプリケーションが起動すると /Library/ScriptingAdditions にあるスクリプト拡張プラグインを読み込みます。が、実行はされません。SIMBL はここにスクリプト拡張プラグインを置いて、対象のアプリケーション内部にスクリプティング用のエンドポイントを提供します。

次にバックグラウンドで動いているデーモン SIMBL Agent がアプリケーションの起動監視をしていて、アプリケーションが起動され次第、事前にスクリプト拡張で作っておいたエンドポイントをスクリプティング経由で叩きます。

その後、対象のアプリケーション内部で Library/Application Support/SIMBL/Plugins から読み込むべきプラグインを探して +[NSBundle bundleWithPath:] で対象のアプリケーションに読み込みます。 詳しい挙動はSIMBLのソースを確認してください。

SIMBL

なんだか面倒な仕組みですが、このような努力の結果、Library/Application Support/SIMBL/Plugins にプラグインを置いておけば、対象のアプリが起動したときに自動で読み込んで実行してくれます。

SIMBL プラグインを作る

ここからは SkypeLoggerのソース を片手にお読みください。

SIMBL プラグインを作るには、Xcode で Bundle を作り、Info.plist に対象とするアプリケーションの Bundle identifier とバージョンを記載しておきます。Skype.app の場合はこんな感じ。

<dict>
  <key>BundleIdentifier</key>
  <string>com.skype.skype</string>
  <key>MaxBundleVersion</key>
  <string>5.2.0.1523</string>
  <key>MinBundleVersion</key>
  <string>2.8.0.851</string>
</dict>

そして、SIMBL がこの Bundle を読み込んだ際に Principal class の +load が呼ばれるのでそこにやりたいことを書きます。

大抵は対象のアプリケーションの一部のメソッドを書き換えたり奪ったりしてやりたいことを追加したりします。 書き換えには Objetive-C のランタイムの機能を使ったり、NSObject のカテゴリを使ったりする手法がありましたが、最近は method_exchangeImplementations を使うと楽です。

しかし、SkypeLogger はアレなのでそういうことはせずに ruby を内部的に起動します。つまり、fork します。SkypeLoggerLoader.m あたりをご覧ください。

pid_t pid;
if((pid = fork()) == 0) {
    ...
    if(execl("/usr/bin/ruby",
             "ruby",
             skypeLoggerPath,
             "-l",
...

とても普通に fork ですね。親プロセス(つまり Skype.app)側では fork 後、アプリケーションの終了通知 NSApplicationWillTerminateNotification を待っておきます。 子プロセス(つまり、Ruby スクリプト)側は Skype API の使用準備を始めます。

親プロセスは、つまり Skype.app の終了通知が来たら、子プロセスに、つまり fork した ruby にシグナルを送って死んでもらいます。 以上、ここまでは Objective-C で書かれています。

Skype API を使う

ここからは Ruby の世界になります。/lib以下をご覧ください。

Mac OS X 標準添付の Ruby では RubyCocoa が提供されていて6require "osx/cocoa"することで Objective-C のクラスとRubyのクラスを行ったり来たりすることができます。

Skype API を使うには Skype.framework を読み込んで +[SkypeAPI setSkypeDelegate:] でコールバックされるクラスを提供して +[SkypeAPI sendSkypeCommand:] でコマンドを送ります。 これを RubyCocoa を使って Ruby で書くとこうなります。

def applicationDidFinishLaunching(notification)
  OSX::SkypeAPI.setSkypeDelegate client
  OSX::SkypeAPI.connect
end
...
def command(cmd)
  res = OSX::SkypeAPI.sendSkypeCommand(cmd).to_s
  ...

簡単ですねー。あんまり深いことは考えなくても動くのでいろいろ楽ですが、ちょっとミスると簡単にプロセスが死ぬので怖いです。

Skype API のバグ? 仕様変更?

Skype API は基本的に同期呼び出しが可能だったはずなのですが、あるバージョンから突然同期呼び出しができなくなりました7。つまり SkypeAPI.sendSkypeCommand() を読んでも結果はすぐに返らずに非同期で SkypeAPI.setSkypeDelegate() で登録したクラスの skypeNotificationReceived() が呼ばれます。

というわけで SkypeLogger では同期呼び出しが出来るか出来ないかを最初のメッセージを受け取った時に判断して以降のメッセージハンドラを差し替えるという キモい かっこいいことをしています。メッセージを受け取るたびに if 文が走るなんてことはありません。

def on_message_sent_or_recieve(id)
  handler_module = if command("GET CHATMESSAGE #{id} CHATNAME").empty?
    ...
    AsynchronousSendSkypeCommand
  else
    ...
    SynchronousSendSkypeCommand
  end
  self.class.class_eval do
    # 使える方のメッセージハンドラモジュールをincludeして
    include handler_module
    # 自分自身を消す
    remove_method :on_message_sent_or_recieve
  end
  # 選んだ方のメッセージハンドラを呼ぶ
  on_message_sent_or_recieve(id)
end

まとめ

Mac OS X のアプリケーションを作るには様々な方法があります。アプリケーションの形態も様々で単体のアプリケーションからこういうようなプラグイン形式のものなどいろいろあります。

AppStore 全盛期にアプリケーションはこれこれこういう形でこう作るんだよ、みたいな風潮がありますが、もっと本当は自由なんですよーってことでまとまったようなのでオシマイ!

感想とか、こうしたほうがいいとか、これはマズいとかは @niw にツイートしてもらえると嬉しいです。

  1. 確か、けっこう頑張って誰かが解析してたはず。 ↩︎

  2. Linux用のバイナリを使ってコンソールで使えるようにしていた記事もあったはず。 ↩︎

  3. 実は最初は内部的に別プロセスの Ruby を起動するようにはせず、直接 RubyCocoa で書かれたコードをSIMBLから呼ぶようにしていました。しかし、どうやら Skype.app は同じプロセスがAPIを使うことを想定していないようで、このような実装だと毎回APIアクセスの許可を求めるダイアログが出てしまう面倒くさい挙動になってしまったので、別プロセスを起動するようにしました。 ↩︎

  4. さらに補足すれば、実際のところ、SIMBLをつかって同時起動しようと思ったのはずいぶん後で、昔は手動で Ruby の部分だけ起動していました。 ↩︎

  5. 年々厳しくなります。 10.8 ではどうなることやら。 ↩︎

  6. 実はこっそり MacRuby も標準搭載されているのですが、PrivateFramework で簡単に使えません。 ↩︎

  7. 5.6 頃から? ↩︎