ククログ

株式会社クリアコード > ククログ > Firefoxで外部アプリケーションを起動するだけのアドオンをGo言語で作る方法と注意点

Firefoxで外部アプリケーションを起動するだけのアドオンをGo言語で作る方法と注意点

キーワード:golang, バックグラウンド実行, detach

Firefoxのアドオンは、現在はWebExtensionsというAPI群に基づいて開発するようになっています。 このAPI群には「別のブラウザなどの任意のローカルアプリケーションを直接起動する機能」は含まれておらず、そのようなことをしたい場合にはNative Messagingという仕組みで間接的に実現する必要があります。 これは要するに、「コマンドラインオプションやGUIではなく標準入力から与えられる情報を使って、任意のアプリケーションを起動するランチャー」を開発するということです。 関係を図にすると以下のようになります。

+---------------------+
|       Firefox       |   
|+-------------------+|
||Firefox上のアドオン||
|+-------------------+|
|        ↓↑         |
| <WebExtensions API> |
| (Native Messaging)  |
+--------↓↑---------+
     <標準入出力>
         ↓↑
+---------------------+
|      ランチャー     |
+---------------------+
          ↓
   <システムコール>
          ↓
+---------------------+
|外部アプリケーション |
+---------------------+

このNative Messagingの仕組み自体は、細部を除けばGoogle Chrome用拡張機能での仕組みとほぼ同じ仕様です。 そのためFirefoxとChromeに両対応した実装が既にいくつか存在しており、中には上記のようなランチャーとして振る舞う物もあります。 例えばOpen InというプロジェクトではNode.jsベースで開発されたランチャーアプリケーションの実装を使っています。

この記事では、これと似たような物をGo言語で実装する時の注意点を解説します。

起動したい外部アプリケーションが巻き込みで終了されてしまう問題

Go言語では、外部アプリケーションを起動する方法としてexecパッケージを使うのが一般的です。 このパッケージでは同期実行(外部アプリケーションの終了を待ってから次の処理に進む)のexec.Command().Run()と非同期実行(外部アプリケーションを起動した後、すぐに次の処理に進む)のexec.Command().Start()の2つの機能が提供されています。

例えば、FirefoxのWebページ上のコンテキストメニューに「このページをInternet Explorerで開く」のような項目を追加してそこから別のブラウザを起動するというような場合、exec.Command().Run()でIEを起動するとIEを終了するまでの間ずっとランチャーアプリケーションのプロセスが生き続けることになります。 また、そこから起動されるIEのプロセスはFirefoxから見て孫プロセスという扱いになりますので、うっかりその状態でFirefoxを終了すると、孫プロセスになっているIEまでもがまとめて終了されてしまいます。

ということから、このような場面ではexec.Command().Start()の方を使えばよいと考えられるのですが、実際には期待した通りの結果になりません。 こちらで起動した場合でもIEのプロセスは孫プロセスになってしまい、ランチャーがexec.Command().Start()を実行してIEを起動した後でmain()の最後に到達してプロセスが終了すると(Firefoxがランチャーを終了させると)、やはり孫プロセスのIEまで巻き添えで終了されてしまうのです。

プロセスグループを分けての外部アプリケーションの起動

このような現象が発生するのは、Firefox・ランチャー・孫プロセスとして起動されたIEの全てが同じプロセスグループに属しているからです。 言い換えると、ランチャーが起動する孫プロセスについてプロセスグループを分ければ(プロセスをデタッチすれば)、Firefoxやランチャーが終了した後もIEを動作させ続けられると考えられます。 Go言語の場合、一般的にはこれは以下のようにして実現できます。

...
import (
  "os/exec"
  "syscall"
  "log"
)
...

func Launch(path string, args []string) {
  command := exec.Command(path, args...)

  // Windowsの場合
  command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
  // Linux, maxOS (POSIX)の場合
  // command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

  err := command.Start()
  if err != nil {
    log.Fatal(err)
  }
}

システムコールに渡すパラメータを指定する必要があるため、Windowsとそれ以外の環境とでは書き方が変わってきます。

このようにすることで晴れてプロセスグループが分かれてくれて、ランチャー終了後も外部アプリケーションが生き続けるようになってくれる……と思われたのですが、実際にWindows環境のFirefoxで検証してみたところ、残念ながら期待通りの結果は得られませんでした。

上記のようなコードを使って一般的なコマンドラインアプリケーションとして起動する状態にしたランチャーで試す分には、期待通りの振る舞い(ランチャーの終了後も外部アプリケーションのプロセスが残る)を見せました。 しかし、FirefoxのアドオンからNative Messagingの仕組みを経由して起動されるNative Messaging Hostとしてランチャーを動作させると、依然として外部アプリケーションまで終了されてしまうのでした。

Firefoxに固有の事情

これは、Go言語一般の話や、Google Chromeなどと共通の仕組みとしてのNative Messagingではなく、Firefox固有の事情による現象です。

実はWebExtensionsにおけるNative Messagingの説明に記載がありますが、Windowsにおいてこのようなランチャーから外部のプロセスを起動する際は、CREATE_NEW_PROCESS_GROUPではなくCREATE_BREAKAWAY_FROM_JOBという定数で示されるフラグを指定する必要があります。

Go言語の場合はsyscallモジュールにこの定数の定義が含まれていないため、仕様に基づいて0x01000000という数値を直接記述することになります。

func Launch(path string, args []string) {
  command := exec.Command(path, args...)
  // CREATE_BREAKAWAY_FROM_JOB = 0x01000000
  command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x01000000}
  err := command.Start()
  if err != nil {
    log.Fatal(err)
  }
}

WebExtensionsの元になっているGoogle ChromeのNative Messagingの仕様では特にこのような指定は必要ないため、ChromeとFirefoxの両方に対応したNative Messaging Hostを開発する際には注意が必要です。

このようにフラグを指定して実行すると、無事期待する通りの結果を得ることができました。

(この情報は本記事の初版に対するフィードバックで教えていただきました。ご指摘ありがとうございます。)

有効だった回避策(Windows向け)

結論から述べると、この問題は以下のようなバッチファイルで解決できました。

@ECHO OFF

start %*

これは、自身にコマンドライン引数として渡された内容をそのままコマンド列として非同期に実行するというバッチファイルです。 ランチャーから外部アプリケーションを起動する際に、直接起動せずこのバッチファイル(※正確にはcmd.exe)を介して起動するという使い方をします。

この時の各ソフトウェア同士の関係を図にすると、以下のようになります。

+---------------------+
|       Firefox       |   
|+-------------------+|
||Firefox上のアドオン||
|+-------------------+|
|        ↓↑         |
| <WebExtensions APi> |
| (Native Messaging)  |
+--------↓↑---------+
     <標準入出力>
         ↓↑
+---------------------+
|      ランチャー     |
+---------------------+
          ↓
   <システムコール>
          ↓
+---------------------+
|    バッチファイル   |
+---------------------+
          ↓
   <startコマンド>
          ↓
+---------------------+
|外部アプリケーション |
+---------------------+

単純なのですが、これによってバッチファイルから先のプロセスが別のプロセスグループに分かれるようになり、FirefoxからNative Messaging Hostとしてランチャーを起動した場合でも、Firefoxの終了後も外部アプリケーションのプロセスが残り続けるようになりました。 また、このような動作をさせる場合、ランチャーでバッチファイルを起動する際にはcommand.SysProcAttrの指定はあってもなくても結果は変わりませんでした。

なお、バッチファイルを使うとなるとcmd.exe(コマンドプロンプト)のウィンドウが一瞬表示されるのではないかという懸念もありましたが、実際には特にそのようなこともなく自然に外部アプリケーションが起動しました。 これも、Firefoxがランチャーを起動する際に与えている何らかの指定の影響ではないかと考えられます。

まとめ

以上、Go言語で実装したランチャーを経由してFirefoxから外部アプリケーションを起動する際に、Firefoxが終了した後も外部アプリケーションを起動した状態のままとするためには、バッチファイルを使うとよいCREATE_BREAKAWAY_FROM_JOBフラグの指定が必要であるという注意点をご紹介しました。

環境によってはバッチファイルの実行自体が制限されている場合もあるかもしれません。実際に動作するかどうか、はあらかじめ確認を取っていただくことを強くお勧めします。

FirefoxのWebExtensions APIはGoogle Chromeの拡張機能向けAPIを参考に設計されています。そのためChrome用拡張機能の開発でのノウハウの多くを流用することができ、何か詰まった時は「Chrome用拡張機能ではどうするのが普通なのか?」という観点で調べれば解決策が見つかることが多いです。

しかしながら両者は完全に同一のものではなく、前述のようなFirefoxに固有の注意事項というものもあります。本記事の初版公開時には、オフィシャルのドキュメントにある注意書きを見落としたまま先入観からChrome用拡張機能向けの情報だけを調査していた結果、肝心な情報に全く辿り着けないという結果となっていました。検索も万能ではない(検索語句がずれていると必要な情報に辿り着けない)ために頼りすぎる事・その時得られた結果を過信しすぎる事のリスク、先入観に囚われずにオフィシャルの情報を丁寧に読む事の重要性を改めて実感した次第です。