見出し画像

MacでMachPortを用いたプロセス間通信をしてみる REALITY Advent Calendar #23

この記事は日本一メタバースなAdvent Calendarであるところの REALITY Advent Calendar 23日目の記事です。

皆さんお久しぶりです。今年はサンタさんに何をもらうかまだ悩んでいるションローです。

今回はMachPortを用いたプロセス間通信の話ですが、なんで今さらMachPort?という疑問があると思います。

背景としては現在漫喫が開発している「Mac版REALITY for Web会議」をCamTwistなどを使わずに仮想カメラ対応させたい!という思いがあるのですが、それを実現する上でプロセス間通信を行う必要がある事がわかりました。
プロセス間通信の手法は色々あるのですが、いくつかの理由があり今回はMachPortでの実現を検討してみたという感じです。

Macの仮想カメラについて

画像1

簡単に説明すると、DAL Pluginを作った上でそれを特定のディレクトリ(/Library/CoreMediaIO/Plug-Ins/DAL/)以下に配置すると仮想カメラとしてZoom等のカメラの選択肢に表示されるようになります。
この DAL Pluginがいわゆる「仮想カメラ」の本体になります。

以下はOBSのDAL Pluginが読み込まれている様子です


こちらはDAL Pluginがディレクトリに配置されている様子です


DAL PluginはZoomなどのアプリからの要求によって叩き起こされるのですが、この要求に合わせた独自のライフサイクルを持っています。

このライフサイクルに合わせて自身を初期化し、Zoomなどに映像データ(CMSampleBuffer)を送ることで任意の映像を表示することが出来ます。

Macの仮想カメラプラグインの開発方法については @shmdevelop 氏のこちらのスライドや、@k_katsumi 氏のこちらのスライドが非常にわかりやすいので、ぜひご御覧ください。

また、こちらのリポジトリは DAL Plugin を実現する上でミニマムな構成になっており、ビルドして即動作確認可能なので、学習用におすすめです。

仮想カメラプラグインを使って出来ること、出来ないこと

前述の通り、DAL PluginからZoom等のアプリに対してCMSampleBufferを送り、任意の映像を表示することが出来ます。
また、DAL PluginがMacのカメラにアクセスして映像を取得する事が出来ます。

したがって「Webカメラから取得した映像に固定の処理(フィルタなど)をかけて表示する」くらいの要件であればDAL Plugin単体で実現できます。

画像3

一方で、このプラグイン単体ではGUIを持つことが出来ません。

故に「ユーザの操作を元に映像に適用するフィルタを変更する」であったり「ユーザの操作を元に作成された映像を表示する」といった複雑な機能が実現できません。

このような機能を実現するためには「DAL Pluginに対して外部からデータを入力」する必要があります。

画像3

Mac版REALITY for Web会議の目指していること

現状だと Mac版REALITYを用いてZoomなどのビデオ会議ソフトウェアに映像を送る場合、CamTwist等のソフトウェアが別途必要になります。

画像7

実際のところ、この構成でもそこまで手間じゃない上にに有用なのですが、理想的にはCamTwist、OBSのレイヤーもセットで提供することで、より簡易にMac版REALITY for Web会議を使えるようにしたいわけです。

画像7

この構成にすると、インストールが楽ちんになりますね!

これを実現するために、Mac Appから仮想カメラプラグインに対して映像データを送りつける仕組みが必要になりました。

仮想カメラプラグインへのデータ送信方法

@k_katsumi 氏の資料のデータの受け渡しのページで見る限り、外部からデータを渡すいくつかの実現方法があるようですが、今回は MachPort が面白そうだったので使ってみることにしました。

ちなみに面白そうだったというのは半分冗談で、リアルな理由としては今回の実装の参考にできそうなプロダクトが、OBSの仮想カメラプラグインしか見つからず、このプラグインの実装がMachPortを利用していた為です。

そもそも MachPort とは?


画像7

MachPort は MacOS で利用できるIPC(プロセス間通信)の仕組みのことです。読み方は多分マークポートかと思われます。

かなり昔からあるAPIで、MachPortを利用するために必要なNSMachBootstrapServerはmacOS 10.13 High Sierraから非推奨になっており、なんなら Swift からではこのAPIを呼ぶことすら出来ません。

代わりに NSXPCConnection を使えとのことなのですが、前述の通りDAL Plugin では XPC を使うのが難しいです。

幸い自分は Objective-C にもある程度親しみを持っていたので、今回はObjective-C 経由で MachPort を使ってみることにしました。

 MachPortを用いてデータの送受信を行ってみる

Mac環境で MachPort を用いてデータの送受信を行う処理を実装したのはこちらのリポジトリです。

Objective-CでMac向けのCLIを実装し、それぞれ Server と Client の役割をもたせています。
また、Macアプリでのプロセス間通信も動作確認したかったので雑なMac Appも作ってみました。

動作確認方法

READMEに書いていますがとりあえずクローンしたあとに make を叩いてもらって、そのあとは make serve と make client をいい感じに叩くと動きが確認できます


こちらはデモ用のMac AppとCLIの間でプロセス間通信をしている様子です。

実装について

CLI側の説明ですが、Serverディレクトリ以下の main.m がサーバ実装のエントリーポイントです。

int main(int argc, const char * argv[]) {
   @autoreleasepool {
       Server *server = [[Server alloc] init];
       [server run];
   }
   return 0;
}

以下はServerクラスの抜粋ですが、基本的には NSMachBootstrapServer を使ってポートを取得し、RunLoop のInputソースとして NSPort を渡しています。

- (instancetype)init
{
   self = [super init];
   if (self) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
   self.port = [[NSMachBootstrapServer sharedInstance] servicePortWithName:SERVERNAME];
#pragma clang diagnostic pop
       if (self.port == nil) {
           NSLog(@"ポートが開けませんでした");
       }
       self.port.delegate = self;
       NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
       [runLoop addPort:self.port forMode:NSDefaultRunLoopMode];
   }
   return self;
}

- (void)run {
   NSLog(@"サーバー起動");
   NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
   [runLoop run];
}

以下は NSPortDelegate のメソッドを実装しているのですが、これは NSPort 経由でメッセージを受信した際の処理になります。
内容としては、受け取ったメッセージを少し改変し送信元にお繰り返しているだけです。

#pragma mark - NSPortDelegate
- (void)handlePortMessage:(NSPortMessage *)message {
   if ([_delegate respondsToSelector:@selector(server:receiveMessage:)]) {
       [_delegate server:self receiveMessage:message];
       return;
   }
   
   switch (message.msgid) {
       case MessageIDString: {
           NSString *string = [[NSString alloc] initWithData:message.components[0] encoding:NSUTF8StringEncoding];
           NSLog(@"received: %@", string);
           NSPortMessage *response = [[NSPortMessage alloc] initWithSendPort:message.sendPort
                                                                 receivePort:nil
                                                                  components:@[[[NSString stringWithFormat:@"%@ というメッセージ受信したよ", string] dataUsingEncoding:NSUTF8StringEncoding]]];
           response.msgid = message.msgid;
           NSDate *timeout = [NSDate dateWithTimeIntervalSinceNow:1.0];
           [response sendBeforeDate:timeout];
       }
           break;
   }
}

次にクライアント側のコードを見ていきます。
Clientディレクトリ以下の main.m がエントリーポイントです。

ここではコマンドの引数1番目に文字が入っていればそれを送信し、なければ hoge を送るという実装になっています。

int main(int argc, const char * argv[]) {
   @autoreleasepool {
       Client *client = [[Client alloc] init];
       
       if (argc > 1) {
           NSString *string = [[NSString alloc] initWithUTF8String:argv[1]];
           [client sendMessage: string];
       } else {
           [client sendMessage:@"hoge"];
       }
   }
   return 0;
}

以下はメッセージ送信処理になります。
NSMachBootstrapServer で NSPort を取得するという意味ではServer側と同じですが、コールするメソッドが違うので注意が必要です。

データを送る処理の本体は NSPortMessage で実装するのですが、components引数にDataの配列を渡せるので、ここに送りたいデータを突っ込んでいきます。
今回は文字列を送っていますが、仮想カメラプラグインを作る際には CMSampleBuffer(を構築するのに必要な情報)を突っ込むことになります。

- (void)sendMessage:(NSString *)string {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
   NSPort *sendPort = [[NSMachBootstrapServer sharedInstance] portForName:SERVERNAME];
#pragma clang diagnostic pop
   if (sendPort == nil) {
       NSLog(@"サーバに接続できませんでした");
       return;
   }
   
   NSPort *receivePort = [NSMachPort port];
   receivePort.delegate = self;
   
   NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
   [runLoop addPort:receivePort forMode:NSDefaultRunLoopMode];
   
   NSPortMessage *message = [[NSPortMessage alloc] initWithSendPort:sendPort
                                                        receivePort:receivePort
                                                         components:@[[string dataUsingEncoding:NSUTF8StringEncoding]]];
   message.msgid = MessageIDString;
   self.received = NO;
   
   NSDate *timeout = [NSDate dateWithTimeIntervalSinceNow:1.0];
   if (![message sendBeforeDate:timeout]) {
       NSLog(@"メッセージ送信失敗");
   }
   
   while (!self.received) {
       [runLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
   }
}

基本的にはこれだけで MachPort を用いたプロセス間通信が可能になります。

Mac Appについてもこのクラスを利用しているだけなので省略します。
注意点は RunLoop の扱いくらいです。

まとめ

今回は仮想カメラプラグイン実装の前段として、MachPort を用いたプロセス間通信の実装方法についてまとめてみました。

現在仮想カメラプラグイン側に同様の実装を加えて多少動くくらいにはなっているのですが、パフォーマンスがあんまり良くなく、実用に耐えないレベルになっています。

この辺りが改善されてきたらまた別途記事として上げたいと思います!

明日のアドベントカレンダーは

明日の記事は「Unityエディターでのデバッグをちょっと便利にする」です。お楽しみに!