GreasemonkeyのUserscriptをJetpackとChromeExtensionに移植する際のメモ

先日BibliwoJetpackとChromeExtensionに移植したのでその際のメモです。
GM版とこれら二つを個別に保守するのは御免こうむりたかったため、なるべくシングルソースにしようという方針に沿っています(Chrome版は諸般の事情によりコードを分けてますが)。

GM版を移植する際に検討すべき項目は、主に以下のものがあります。

Jetpackへの移植

ソースコードこちらGM版と全く同じです。

URLに基づいてページ書き換え用のスクリプトを実行するか否か判定する方法

GMではコード先頭にコメントとして
// @include http://*.amazon.co.jp/*
のようにコードをインジェクトするルールが定義されていますが、これをJetpackでも使いたい。
これは、Greasemoneky拡張のコードをそのまま引っ張ってきました(convert2RegExpのあたり参照)。これでルールの非互換性とかを気にせずに済むので。ただし、コードがそれなりにでかくなってしまいます。

ページ書き換え用のスクリプトを実行する方法

Jetpackチュートリアルあたりにも記述があったような気がしますが、jetpack.tabs.onReady()を使ってタブ内のドキュメントが準備できたときに呼ばれるコールバックを登録します。コールバック引数でdocumentがえられるので、それをスクリプト本体に渡して実行します。
問題はdocument以外の変数を必要とする場合でして、どうにかして取得する必要があります。例えば、window変数は以下のようなコードで取得しました。

jetpack.tabs.onReady(function(doc){
   var i, j;
   var unsafeWindow = null;
   var tmpwin;
   function innerWindows(win){
      var ret = [];
      var i;
      ret.push(win);
      for(i = 0; i < win.frames.length; ++i){
         ret.push(win.frames[i]);
      }
      return ret;
   }
    function test(page) {
      return convert2RegExp(page).test(doc.location);
    }
   if(!includes.some(test) || excludes.some(test)){
      return;
   }
   for(i = 0; i < jetpack.tabs.length; ++i){
      tmpwin = innerWindows(jetpack.tabs[i].contentWindow);
      for(j = 0; j < tmpwin.length; ++j){
         if(tmpwin[j].document === doc){
            unsafeWindow = tmpwin[j];
            break;
         }
      }
      if(unsafeWindow){
         break;
      }
   }
   scriptmain(doc);
});

ドキュメントが開かれたら、そのURLが処理対象であるか否か判定。その後、全タブの全windowを検索して、そのdocumentが属しているwindowオブジェクトを取得しています(が使っていません。使わないように処理本体のコードを変更しました)。Jetpack側でwindow変数を渡して欲しいような気もします。
この辺を真面目にやろうとすると、サンドボックスっぽく作らないとダメだと思います。ここで取得したwindowsはunsafeWindowなので、安全なwindowを作ってあげないといけない。

GM_logの移植
GM_log = console.log;

グローバル変数を惜しみなく使いました。

GM_xmlhttpRequest

Jetpack環境下ではクロスオリジンのXHRが使えるので、Greasemoneky拡張で定義されているGM_xmlhttpRequestのコードを必殺コピペで大体動きました。

   GM_xmlhttpRequest = function(details){
     var req;
     var url = details.url;
      function getCallback(event){
         return function(){
            if(details[event]){
               var responseState = {
                 responseText: req.responseText,
                 readyState: req.readyState,
                 responseHeaders: null,
                 status: null,
                 statusText: null,
                 finalUrl: null
               };
               if (4 == req.readyState && 'onerror' != event) {
                  responseState.responseHeaders = req.getAllResponseHeaders();
                  responseState.status = req.status;
                  responseState.statusText = req.statusText;
                  responseState.finalUrl = req.channel.URI.spec;
               }
               details[event](responseState);
            }
         }
      }
     
     if (typeof url != "string") {
       throw new Error("Invalid url: url must be of type string");
     }
     if(!url.match(/(.*?):/)[1]) {
         throw new Error("Invalid url: " + url);
     }
     req = new XMLHttpRequest();
     req.onload = getCallback("onload");
     req.onerror = getCallback("onerror");
     req.onreadystatechange = getCallback("onreadystatechange");

     req.open(details.method, url);

     if (details.overrideMimeType) {
       req.overrideMimeType(details.overrideMimeType);
     }

     if (details.headers) {
       for (var prop in details.headers) {
         req.setRequestHeader(prop, details.headers[prop]);
       }
     }
     var body = details.data ? details.data : null;
     if (details.binary) {
       // xhr supports binary?
       if (!req.sendAsBinary) {
         var err = new Error("Unavailable feature: " +
                 "This version of Firefox does not support sending binary data " +
                 "(you should consider upgrading to version 3 or newer.)");
         throw err;
       }
       req.sendAsBinary(body);
     } else {
       req.send(body);
     }
   };
GM_set/getValue
jetpack.future.import("storage.simple");
GM_getValue = function(key, def){
   var ret = jetpack.storage.simple[key];
   if(ret === undefined){
      ret = def;
   }
   return ret;
};
GM_setValue = function(key, value){
   jetpack.storage.simple[key] = value;
};

storage.simple万歳。

Jetpack化まとめ

基本的にはこんだけです。GM -> Jetpackは簡単! いや本当はGM_registerMenuCommandも移植した方がいいんすけどね、めんどくさくて……。
GM -> Jetpackの変換は自動化できると思います。どなたか作ってみてはいかがでしょう。

ChromeExtensionへの移植

UserscriptってChromeでそのまま動くようになったんでしょ?という方もおられましょうが、実はGM_xmlhttpRequestGM_get/setValueも使えないので、相当数のUserscriptは動かないんです。

主なソースコードこちらGM版、Jetpack版と全く同じです。
マニフェスト
バックグラウンド

今回はcontent_scriptsの仕組みを使いました。特定のURLをもつドキュメントに対し、JavaScriptのコードをインジェクトできる仕組みで、これだけ聞くとGreasemonkeyそのものじゃんと思わなくはないんですが割とその通りの仕組みです。でも制約が結構厳しい。

URLに基づいてページ書き換え用のスクリプトを実行するか否か判定する方法 + ページ書き換え用のスクリプトを実行する方法

マニフェストにマッチパターンを書いて、content_scriptとしてメインのスクリプトを指定しましょう。

"content_scripts": [
  {
    "matches": [
      "http://*.amazon.co.jp/*",
      "http://amazon.co.jp/*",
      "http://www.bk1.jp/*",
      "http://books.rakuten.co.jp/*",
      "http://www.bookoffonline.co.jp/display/*",
      "http://store.shopping.yahoo.co.jp/7andy/*",
      "http://www.jbook.co.jp/p/p.aspx/*"
    ],
    "js": ["bibliwo.user.js"]
  }
],

ただし、マッチの書式はGreasemonkeyのそれとは非互換です。例えば、ホスト名に使える*(半角アスタリスク)は先頭に1つだけです(GMは複数使える)。
今回は、GM版のマッチパターンの方をChrome式に変更しました。大抵、Chrome式マッチパターンで書けば、GM版でも使えるみたいです。

GM_logの移植
GM_log = console.log;
GM_xmlhttpRequest

content_script内で使えるXHRオブジェクトはクロスオリジン可能なXHRではありませんし、殆どのAPIも使えません。

However, content scripts have some limitations. They cannot:
* Use chrome.* APIs (except for parts of chrome.extension)
* Use variables or functions defined by their extension's pages
* Use variables or functions defined by web pages or by other content scripts
* Make cross-site XMLHttpRequests

http://code.google.com/chrome/extensions/content_scripts.html

バックグラウンドページでクロスオリジンXHRオブジェクトを用意して、chrome.extension.getBackgroundPage()でそのオブジェクトにアクセスするなんて手段も使えません(getBackgroundPage()が許可されないため)。
バックグラウンドページでページ読み込みを監視し,読み込まれたらchrome.tabs.executeScript()を使ってそのページに対しクロスオリジンXHRを使用したいスクリプトを実行する方法も試したのですが、executeScript()で実行されるスクリプトはcontent_scriptと同等の制約がかかるらしく(ドキュメント未記述のため不正確かも)、この方法もダメでした。

結局、バックグラウンドページ側でGM_XHR相当の処理を作っておき(バックグラウンド内ではクロスオリジンXHRが使える)、ページ間通信で処理を依頼する方法で実装しました。

XHRを使う側はこちらのコード。簡単ですね。

GM_xmlhttpRequest = function(detail){
   chrome.extension.sendRequest(detail, function(response) {
      if(detail[response.event]){
         detail[response.event](response.res);
      }
   });
};

バックグラウンド側は、

function GM_xmlhttpRequest(details){
 (中略; 殆どJetpack版と同じ)
}

chrome.extension.onRequest.addListener(function(request, sender, sendResponse) {
    request.onload = function(res){
      sendResponse({event : "onload", res : res});
    };
    request.onerror = function(res){
      sendResponse({event : "onerror", res : res});
    };
    request.onreadystatechange = function(res){
      //sendResponseするとコールバックが解除されるため、あえて呼ばない。
      //onreadystatechangeは使っていないので、この実装でとりあえずOK
      //sendResponse({event : "onreadystatechange", res : res});
    };
    GM_xmlhttpRequest(request);
});

こんな感じの実装です。
onreadystatechangeを殺しています。sendResponseを実行するとsendRequest/Response間の接続が切れてしまうそうなので、通信結果が確定したその一回だけsendResponseするようにしました。途中経過も送る必要があるときは、postMessageを使うべきでしょう(参照)。

GM_set/getValue

実は移植できませんでした……。

GM_getValue = function(key, def){
   var ret = localStorage[key];
   if(ret === undefined){
      ret = def;
   }
   return ret;
};
GM_setValue = function(key, value){
   localStorage[key] = value;
};

素のlocastorageを使っているので、ストレージの実体がドメインごとに存在してしまいます。Bibliwoの場合はあんまり不便じゃなかったのでそのまま出してしまいましたが、たいていの場合はダメだと思います。誰か解決策考えてください。

content_script内では殆どのAPIが使えないので、組込みオブジェクトかバックグラウンドページを使うしか実装できないと思うのですが。僕の知ってる限りでは、GM_get/setValueの振る舞いを実現できる組込みオブジェクトは無いですし、XHRと違ってGM_get/setValueは同期関数なのでメッセージングの仕組みを使って実装することもできません(バックグラウンド側とcontent_scriptは違うプロセスで実行されるんじゃないかと思うので、同期でバックグラウンド側と通信するってのはそもそも無理な話だと予想)。

どうにかならないですかねぇ。
というか今回苦労したクロスオリジンXHRとget/setValueって、Chrome上でGreasemonkeyのUserscriptを動かすときの制約そのものなんですよね。割と簡単な実装でGreasemonkey対応をしてるんじゃないかなあと想像した次第。

スクリプト本体でGM_get/setValueを使うのをやめて、非同期の独自APIを使うようにすれば解決します。

//GM版
MY_getData = function(callback, key, dflt){
  callback(GM_getValue(key, dflt));
};
//Chrome版
MY_getData = function(callback, key, dflt){
   chrome.extension.sendRequest({key, dflt}, function(response) {
       callback(response);
   });
};

こんな感じで。でも、なんか、うーん。Chrome側でAPI用意して欲しいなあ。

ChromeExtension化まとめ

マニフェストファイルが必要なのは仕方ないとして、XHRのためにバックグラウンドページも必要になりました。シングルソースへの道は遠い。
しかもGM_get/setValueにはきれいな解決策も見つからないままで、なんかもやもやした感じが晴れません。

総まとめ

ChromeGM_get/setValue問題は残りつつも、Jetpack化、ChromeExtension化はそんなに手間がかかりませんでした。GreasemonkeyAPIはごく少ない上にシンプルなものなんですが、これだけ多くの問題を解決できてるということはAPI設計がそれだけ優れていたということですね。見習わねば。