Firefox拡張機能「めりーさんのひつじ」ができるまで

めりーさんのひつじ(http://sourceforge.jp/projects/merysheep/)ver0.1が出来るまでの作業メモ。超長文注意。
開発環境はWindowsXP/Vista + Firefox3.0.8。特に断りのない限り、テキストファイルは全部UTF8Nで書く。エディタは何でも好きなのをどうぞ。

準備から。
firefoxのショートカットをコピーして末尾に"-no-remote -p"を追加して起動。
起動後、適当なプロファイルを作成してそのまま終了。
さっきのショートカットの末尾を"-no-remote -p さっき作ったプロファイルの名前"にする。
firebugとallInOneSideBarを導入。AIOSideBarはべつにどうでもいいけどエラーコンソールが横にでるのは結構便利。
pref.jsを編集して以下を追加。。

user_pref("javascript.options.showInConsole", true);
user_pref("javascript.options.strict", true);
user_pref("browser.dom.window.dump.enabled", true);
user_pref("nglayout.debug.disable_xul_cache", true);

ここまでは初めて拡張を作るときに一回だけやればいい。

拡張の名前を決める。今回は"merysheep"とする。
開発用のディレクトリを作成。今回は"G:\develop\fxExt\mery\merysheep"にした。
開発用のドメイン名を決める。適当でいいけど独自ドメインのURLを持っていればそれを使う。なので今回は"chlice.qee.jp"にした。
これによって拡張のIDが決まる。名前+@+ドメインで、今回は"merysheep@chlice.qee.jp"とする。ドメインを使わずにGUIDを使う方法もある。そのときは拡張のIDは単なるGUIDとなる。プロファイルのextensionsフォルダにGUIDでフォルダができるか、名前@ドメインでフォルダができるか程度の違い。名前+@+ドメインが推奨らしい。
テストプロファイルの拡張フォルダ(%prof/extensions)にさっき作った拡張のIDと同じ名前のファイルを作る。というわけで、"merysheep@chlice.qee.jp"というファイルを作成。その中身には開発用のディレクトリのパスを書く。

G:\develop\fxExt\mery\merysheep

ここからは開発用フォルダでの作業。以降"%dev/"と省略。
マニフェストファイルを作成する。ファイル名"chrome.manifest"で、中身は以下のとおり。太字のところは適当に修正すべし。

content merysheep chrome/content/
overlay chrome://browser/content/browser.xul chrome://merysheep/content/overlay.xul
locale merysheep ja chrome/locale/ja/
locale merysheep en-US chrome/locale/en-US/
skin merysheep classic/1.0 chrome/skin/classic/merysheep/
resource merysheep-modules modules/

これにあわせてフォルダを作る。"%dev/chrome/content", "%dev/chrome/ja", "%dev/chrome/en-US", "%dev/chrome/skin/classic/merysheep/","%dev/modules"をつくればいい。skinはプラットフォームごとに別フォルダを指定するのがセオリーっぽいけどほかのOSの事はわからないのでとりあえず共通スキンだけ用意する。ロケールは日本語と英語を用意。必要に応じて追加すればOK。
インストール用定義ファイルを用意。"install.rdf"を作成。内容は以下のとおり。targetApplicationはFx3.0を指定。他の要素は適当に。typeは拡張機能なら"2"固定。(参考:https://developer.mozilla.org/ja/Install_Manifests)

<?xml version="1.0"?>
<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
  xmlns:em="http://www.mozilla.org/2004/em-rdf#">
  <Description about="urn:mozilla:install-manifest">
    <em:id>merysheep@chlice.qee.jp</em:id>
    <em:type>2</em:type>
    <em:name>Mery Sheep</em:name>
    <em:version>0.1</em:version>
    <em:description>Mery had a little lamb</em:description>
    <em:creator>lord_hollow</em:creator>
    <em:homepageURL>http://chlice.qee.jp/</em:homepageURL>
    <em:optionsURL>chrome://merysheep/content/options.xul</em:optionsURL>
    <em:iconURL>chrome://merysheep/content/icon32.png</em:iconURL>
    <em:targetApplication>
        <Description>
            <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
            <em:minVersion>3.0</em:minVersion>
            <em:maxVersion>3.0.*</em:maxVersion>
        </Description>
    </em:targetApplication>
  </Description>
</RDF>

アイコンを作って"%dev/chrome/content/icon32.png"に置く。形式はpngにしたけどfoxが認識できれば何でもいいんだと思う。ファイル名を変えたいときはinstall.rdfも変えること。
GUIを作っていく。まずはブラウザのオーバーレイから。"%dev/chrome/content/overlay.xul"を作成。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE overlay SYSTEM "chrome://<b>merysheep</b>/locale/locale.dtd">
<overlay xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
    <script type="application/x-javascript" src="chrome://<b>merysheep</b>/content/overlay.js"/>
    <statusbar id="status-bar" class="chromeclass-status">
        <statusbarpanel id="merysheep-status-bar"
            class="statusbarpanel-iconic"
            onclick="msOverlay._test();">
            <label value="&merysheep.statusbar;"/>
        </statusbarpanel>
    </statusbar>
</overlay>

コレでステータスバーに項目を追加できる。画像は保留でラベルのみ。クリックしたらmsOverlay._test()を呼び出すのでそれを"%dev/chrome/content/overlay.js"で定義する。

var msOverlay = {
   _test: function()
   {
        Application.console.log("クリックしました。");
   }
};

ラベルの部分はロケールごとに作成。マニフェストロケール位置に指定したフォルダに"locale.dtd"を作成する。つまり、"%dev/locale/ja/locale.dtd"と"dev/locale/en-US/locale.dtd"を作る。中身はとりあえずoverlay.xulで使ったものだけ。以降、実体参照を増やすたびにここで定義する。

<!ENTITY merysheep.statusbar "めりーさんのひつじ">

設定画面を作る。ファイルはマニフェストで定義したとおりに"%dev/chrome/content/options.xul"。とりあえず何の設定もないスケルトン状態で。

<?xml version="1.0"?>
<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
<prefwindow id="hello.pref" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
    title="設定" bottons="accept,cancel">
    <prefpane>
        <preferences />
        <hbox />
    </prefpane>
</prefwindow>

ここまでやって準備完了。テスト用プロファイルでfirefoxを起動すると、拡張として認識されるようになる。設定ダイアログも開くけど空っぽ。ステータスバーに項目が追加されていて、それをクリックしたらエラーコンソールにメッセージが出力されればOK。

以降、基本的には修正→firefoxの再起動で確認を行うが、拡張で組み込むXPCOMコンポーネントの読み込み時にエラーを出してしまった場合やchrome.manifest,install.rdfを修正したときは一旦拡張を消してから再起動し、再度インストールする必要あり。手順としては、プロファイルフォルダの"extensions/merysheep@chlice.qee.jp"ファイル(拡張のIDと同じ名前のファイル)を削除してFx再起動、さっき削除したファイルを戻してFx再起動、でOK。運用上はデスクトップあたりにファイルを移して再起動してまた戻して再起動、でいいかな。


以降、機能の作りこみ。

まずはメイン部分の作りこみを行う。Firefox全体でひとつだけあればいいようなオブジェクトはJSモジュールとして定義するのがよいので、そのようにする。
jsmはマニフェストファイルで"resource"として指定したフォルダに置くので、"%dev/modules"にファイルを作成する。拡張子は".jsm"が推奨らしいが".js"でも特に問題はないようだ。
そこでメイン機能用のモジュールを書くために"%dev/modules/msCore.jsm"を作った。
JSMで定義した各種シンボル(変数・関数)は隠蔽されるので、公開したいシンボルはそのファイルの中でEXPORTED_SYMBOLSという配列を作ってそこに書く。FUELその他も使えるようにしておきたいので、ファイル名の最初のほうは一律で以下のようにする。

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");

const mzCC = Components.classes;
const mzCI = Components.interfaces;
const mzCR = Components.results;
var Application = mzCC["@mozilla.org/fuel/application;1"].getService(mzCI.fuelIApplication);

var EXPORTED_SYMBOLS = [];

これに公開オブジェクトを追加し、そのシンボルをEXPORTED_SYMBOLSに登録していく。
msCoreには初期化用関数initがあって、それをoverlay.jsの最後のほうから呼び出す。init()の中では使用する全モジュールの初期化を行い、最後に"MerySheep:CoreInitialized"イベントを投げるようにした。このイベントを受け取って、XPCOMコンポーネントの初期化を行う予定。XPCOMはオーバレイと別ルートで初期化されるため、こうしないとXPCOMからはコアが初期化済みかどうかがわからない。

リダイレクタはいろいろ難しいので後回しにして、とりあえずコンテキストメニューに画像保存用のUIを入れることに。overlay.xulに追加。とりあえずmsOverlay.Serveを呼ぶようにしておく。
ついでなので、クリップボードに画像のMD5ハッシュを貼り付ける機能も追加。というよりはこれのほうが簡単そうなのでこっちを先に実装。

<popup id="contentAreaContextMenu">
    <menu id="context-merysheep" label="&merysheep.context;" hidden="true" insertbefore="context-bookmarklink">
    <menupopup>
       <menuitem label="&merysheep.contextServe;" oncommand="msOverlay_Serve();"/>
       <menuitem label="&merysheep.contextCopyMD5;" oncommand="msOverlay_CopyMD5();"/>
    </menupopup>
    </menu>
</popup>

ロケールdtdにcontext, contextServe, contextCopyMD5を追加するのも忘れずに。

<!ENTITY merysheep.context "めりーさん">
<!ENTITY merysheep.contextServe "画像を保管">
<!ENTITY merysheep.contextCopyMD5 "画像のMD5ハッシュをクリップボードにコピー">

メニュー表示の方針は、windowのload/unloadに初期化コードを埋め込んで、その中からpopupshowingにフックする。コアの初期化後であることが望ましいので、そのイベント登録はコア初期化より後で。

var msOverlay = {
    Init: function(){
        $("contentAreaContextMenu").addEventListener("popupshowing", msOverlay.ShowContextMenu.bind(this), false);
    },
    Term: function(){
        $("contentAreaContextMenu").removeEventListener("popupshowing", msOverlay.ShowContextMenu.bind(this), false);
    },
    ShowContextMenu: function(ev){
        if(ev.originalTarget.id != "contentAreaContextMenu") return;
        $("context-merysheep").hidden = true;
        var element = document.popupNode;
        if (!(element instanceof mzCI.nsIImageLoadingContent && element.currentURI)) return;
        document.getElementById("context-merysheep").hidden = false;
    },
    Serve: function(){},
    CopyMD5: function(){},
};
//XULのクリックハンドラ(bindがうまく働かないことがあったので急遽追加)
function msOverlay_Serve(){msOverlay.Serve();}
function msOverlay_CopyMD5(){msOverlay.CopyMD5();}
//コア初期化
msCore.init();
//ポップアップ登録・登録解除開始イベント
window.addEventListener("load",   msOverlay.Init.bind(msOverlay), false);
window.addEventListener("unload", msOverlay.Term.bind(msOverlay), false);

$はdocument.getElementById。prototype.jsによる簡略化記法。bindはPrototype.jsのbind。これがないとイベントハンドラでthisコンテキストが使えない。prototype.jsはoverlay.xulの最初から取り込んでおけばOK。
ShowContextMenuはMDCのContextMenusにいい感じのサンプルがあったのでそれを拝借。これによって、画像のコンテキストメニューのときだけ項目が表示されるようになるらしい。あとはServeとCopyMD5を実装する。

まずはCopyMD5から。MD5を計算するには、nsICryptoHashを使う。そのためにはデータが入っているバイト配列かnsIInputStreamが必要。IMG要素の画像データのバイト列の作り方はよくわからなかったので、nsIInputStreamから計算することに。
まず、ハッシュ取得関数はURIを受け取るものとする。これはイベントハンドラから取得できそうなものがDOMのオブジェクトぐらいしかなくて、そこからURIの取得方法はわかったけどほかの情報の取得方法がよくわからなかったため。
nsIInputStreamを取得するには、URIがfileスキーム(file://)の時はそのファイルを開けばよくて、それ以外のときはキャッシュから取得するのが簡単そうだった。もっと簡単な方法があるかも?MDCのドキュメントに歯抜けが多すぎるせいです?
MD5取得関数は使いまわすのでコア部分に実装する。

var msCore = {
    GetMD5: function(uri){return this.GetHashCode(uri, mzCI.nsICryptoHash.MD5);},
    GetHashCode: function(uri, cryptoCode) {
        var stream;
        if(uri.scheme == "file")
        {    //ファイルならURIからローカルファイルを作ってそれを開く
            stream = this.getInputStreamByLocalFileURI(uri);
        }
        else
        {    //それ以外ならキャッシュからストリームを作る。
            stream = this.BrowserCache.GetInputStream("disk", uri.spec);
        }
        if (stream)
        {    //ストリームが作成できたら、ハッシュを求める
            var ch = mzCC["@mozilla.org/security/hash;1"].createInstance(mzCI.nsICryptoHash);
            ch.init(cryptoCode);
            ch.updateFromStream(stream, 0xffffffff);
            var hash = ch.finish(false);
            var s = [this.toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
            return s;
        }
        return "";
    },
    toHexString: function(charCode){
        return ("0" + charCode.toString(16)).slice(-2);
    },
}

本当はキャッシュがなければnsIHTTPChannelあたりから読み込むようにすべきだが、画像はすでに表示されているんだからこれで大体の場合はいけるはず。no-cacheが指定してあると駄目だったりするかも?なんにせよ面倒なので後回し。
fileスキームからファイルを開いてストリームを作るところ(getInputStreamByLocalFileURI)はpiroさんのサイト(http://piro.sakura.ne.jp/xul/tips/x0011.html)を参考にFx3.0専用でこんな感じ。

var msCore = {
    getInputStreamByLocalFileURI: function(aURI){
        var f = this.convertURIToLocalFile(aURI);
        stream = mzCC["@mozilla.org/network/file-input-stream;1"].createInstance(mzCI.nsIFileInputStream);
        try{
            stream.init(f, 0x01, 0444, 0);
            return stream;
        }catch(e){ return null;}
    },
    convertURIToLocalFile: function(aURI){
        const ioService = mzCC['@mozilla.org/network/io-service;1'].getService(mzCI.nsIIOService);
        var fileHandler = ioService.getProtocolHandler('file').QueryInterface(mzCI.nsIFileProtocolHandler);
        var tempLocalFile = fileHandler.getFileFromURLSpec(aURI.spec);
        return tempLocalFile;
    },

BrowserCache.GetInputStreamの実装は次のように。実際には別ファイルにモジュールとして作成する。

msCore.BrowserCache = {
    GetEntry: function(device, uriSpec){
        const CacheService = mzCC['@mozilla.org/network/cache-service;1'].getService(mzCI.nsICacheService);
        var entry;
        CacheService.visitEntries({
            visitDevice: function(deviceID, deviceInfo){
                return(deviceID == device);
            },
            visitEntry: function(deviceID, info){
                if(info.key != uriSpec) return true;
                entry={id: info.clientID, key: info.key, streamBased: info.isStreamBased()};
            },
        });
        return entry;
    },
    GetInputStreamByEntry: function(entry){
        if (entry && entry.streamBased){
            const CacheService = mzCC['@mozilla.org/network/cache-service;1'].getService(mzCI.nsICacheService);
            var session = CacheService.createSession(entry.id, mzCI.nsICache.STORE_ANYWHERE, entry.streamBased);
            var descr   = session.openCacheEntry(entry.key, mzCI.nsICache.ACCESS_READ, false);
            return descr.openInputStream(0);
        }
        return null;
    },
    GetInputStream: function(device, uriSpec){
        return this.GetInputStreamByEntry(this.GetEntry(device, uriSpec));
    },
};

クロージャ万歳。これでMD5が計算できたので、クリップボードに割り付ける。これは簡単。nsIClipboardHelperを使うだけ。

var msOverlay = {
    CopyMD5: function()
        var md5 = msCore.GetMD5(this.contextTarget.currentURI);
        const gClipboardHelper = mzCC["@mozilla.org/widget/clipboardhelper;1"].getService(mzCI.nsIClipboardHelper);
        gClipboardHelper.copyString(md5);
    },
};

これで、画像のコンテキストメニューMD5クリップボードに貼り付ける機能が実装できたぞ!
使い道は・・・どうだろう。あんまり思いつかないな。「この娘誰?」って時に便利かも?


さて、MD5算出のためにnsIInputStreamが取得できたのだから、それを使えば画像をファイルに保存することも容易いはず。なので先にそれ以外の情報の保存方法について考える。といってもそんなに難しい話は無くて、MozStorageサービスを使用すればいい。SQLiteを使ったデータベースにファイルを保存できるので、素人が書くCSVみたいなショボい形式よりも適当にやっても安全で早いはず。
というわけで、ストレージの使い方。これは簡単。適当に要約すると大体こんな感じ。URIをPRIMARY KEYにもち、そのファイルのハッシュを登録するテーブルを作成してそれを操作するサンプル。実際に使うときはうまい具合にモジュール化すべき。

const SQLCREATETABLE_URI = [
    "CREATE TABLE uri(",
    "    spec   TEXT PRIMARY KEY,",
    "    hash   TEXT NOT NULL",
    ");"
].join("\n");

var file = owner.core.FileIO.getURIDBFile();
var connection = StorageService.openDatabase(file);
if(!connection.tableExists("uri")){
    connection.executeSimpleSQL(SQLCREATETABLE_URI);
}
var updateStatement         = connection.createStatement("INSERT OR REPLACE INTO uri VALUES(?1, ?2);");
var selectFromURIStatement  = connection.createStatement("SELECT hash FROM uri WHERE spec=?1;");

function update(url, hash){
    var spec = null;
    updateStatement.bindStringParameter(0, url);
    updateStatement.bindStringParameter(1, hash);
    updateStatement.execute();
    updateStatement.reset();
}

function select(url){
    var hash = "";
    selectFromURIStatements.bindStringParameter(0, url);
    if(selectFromURIStatement.executeStep()){
        hash = selectFromURIStatement.getString(0);
    }
    selectFromURIStatement.reset();
    return hash;
}

update("url", "md5 hash");
Application.console.log(select("url"));    //print "md5 hash"

最大の肝はなんといっても"connection.createStatement"のところ。これはSQL命令をコンパイルする命令で、中に"?1"とか"?2"とか書いておくことで、後からその部分にbindXxxParameterを使って任意のデータを挿入できる。大量のSQLを発行するときはパフォーマンスが段違いらしい。(参考:https://developer.mozilla.org/ja/Storage)
ステートメントのexecute(もしくはexecuteStep)のあとにresetしなければファイルがロックされてどうのこうのと書いてあるので毎回リセットするようにしてある。newするよりは早いだろうというもくろみ。
パフォーマンスに関わる注意点としては、SQLiteはその設計上INSERTが非常に遅いので、INSERTするときはトランザクションを使わなければいけないことぐらいなのかな?

connection.beginTransaction();
update("url1", "md5 hash 1");
update("url2", "md5 hash 2");
update("url3", "md5 hash 3");
connection.commitTransaction();

こんな感じで。ためしにレコード数1桁のテーブルで更新コマンドを十個ぐらい出すテストプログラムを動かしてみたら、たった一回の実行ではっきり体感できるぐらいのレスポンスの差になったから、トランザクションは必須だと思う。


データの保存方法はめどが立ったのでデータの内容を決める。
画像がロードできたときにMD5を計算して、それとURIを関連付けて保存しておくので、URI←→MD5のテーブルが必要。あとは画像そのものの情報として拡張子とか取得日とかタグとか。それを正規化してテーブルを作る。この辺の詳細は全部出来上がってから公開までに試用しながら必要に応じて変更するつもりで。
折角なので、URIが同じで別の画像がロードされてしまったときのことも考える。ロード後にそのMD5を調べたら既存のMD5と一致しなかった、という状況になるものと思われる(奇跡的にMD5がバッティングする場合を除く)。そこで普通にURIテーブルを書き換えてしまうと、たとえば404専用画像を返していたような場合に大事な画像が無くなって404画像が挿入されることになる。それはまずい。だけど前帰ってきたオブジェクトと今帰ってきたオブジェクトのどちらが正なのかというのは見ている人にしかわからないので、見ている人に選ばせよう。とはいえ、ロード時にプロンプトを出すとウザいことこの上ないので、情報だけためておいて後で一括操作できるようにすべきだろう。つまり、ペンディングリストのようなものが必要になるので、そのためのテーブルも作成する。

  • uri(*uri, hash)
  • info(*hash, 拡張子, 取得日時, 賞味期限, 状態)
  • tags(hash, タグ)
  • pending(uri, hash)

DBを上位オブジェクトからSQLで直接操作するのはフリーダムすぎてリスクが高いので、それぞれのテーブルに対してSQLを発行するラッパーオブジェクトを作成する。さらに、面倒なのでそれを全部まとめて握っておくオブジェクトも作成。

var msDatabase {
    URITable: { ... },	//uri, pendingテーブル操作
    INFOTable: { ... },	//infoテーブル操作
    TagTable: { ... },	//tagsテーブル操作
};

各ラッパーオブジェクトの操作としてはレコードの追加・削除・検索・巡回を持たせておく。もう少し複雑な、たとえば「あるタグと一緒によく使われるタグを調べる」みたいな操作はもうひとつ上のレベルのオブジェクトに持たせることにする。ここはあくまでも単純なIOをラッピングする感じで。SQLをほかのクラスが発行するのを防ぐのが目的なので。

大体の準備が終わったので、コンテキストメニューの「画像を保管」メニューを作りこむ。msOverlay.Serveを実装すればいい。特に難しいことは無くて、MD5計算後に再度ストリームを取得してファイルに書き込み、関連情報をDBに書き込むだけ。
だと思ったけど、キャッシュから作ったnsIInputStreamを使ってnsIFileOutputStreamに入れたらNS_ERROR_NOT_IMPLEMENTED例外を吐いたので、とりあえず一旦バイト列に落としてから保存することにした。正解のやり方はわからない。何とかして実際のファイル名を取得してcopyすべきなのかも。あるいはnsIWebBrowserPersist.saveURIを使う方法もありそうだけど、動いたからとりあえずこれで。

var msFileIO = {
    writeFile: function(fileObj, inStream){
        try{
            var stream = mzCC["@mozilla.org/network/safe-file-output-stream;1"].createInstance(mzCI.nsIFileOutputStream);
            stream.init(fileObj, 0x04 | 0x08 | 0x20, 664, 0);
            //writeFromがNS_ERROR_NOT_IMPLEMENTED例外を吐くので一回完全にロードする。
            var bstream = mzCC["@mozilla.org/binaryinputstream;1"].createInstance(mzCI.nsIBinaryInputStream);
            bstream.setInputStream(inStream);
            var bytes = bstream.readBytes(bstream.available());
            var wrote = stream.write(bytes, bytes.length);
            if (stream instanceof Components.interfaces.nsISafeOutputStream) {
                stream.finish();
            } else {
                stream.close();
            }
            return wrote;
        }catch(e){
            this.core.Log(e, 0);
        }
        return -1;
    },
};

さて、保管する部分ができたので保管した画像を一覧表示する機能を作る。
XULはよくわからないので、まずはお手軽にHTMLで作成することにする。"%dev/chrome/content/list.html"とそこから呼び出すjavascriptを作成して実装。中身は・・・まぁなんてことない普通のDHTMLなので割愛。
実装するとき躓いたことは、リストページのDOMオブジェクトに任意のデータを持たせるとき、たとえば

var img=document.createElement("IMG");
img.md5="....";

という風にすると、オーバーレイ(具体的にはmsOverlay.ShowContextMenu関数)から

var md5=img.md5;

としてもアクセスできなかった(undefinedになる)。なんでだろう。かわりにHTMLから

img.setAttribute("md5","....");

として、オーバーレイから

var md5=img.getAttribute("md5");

こうしたら無事値が取得できた。
あとは、ローカライズしたかったけれどもXULと同じローカライズ方法(locale.dtdを読み込んで実体参照)はchromeのhtmlではできないようなので困ってシマウマ。あとで落ち着いてからXULに書き直してローカライズする。.NETで言うところのFloatLayoutPanelみたいなものがXULにあればよかったんだけど、無さそうなのでどうしようか考え中。

リストを表示するにはステータスバーのボタンを押すことにして、その辺の処理を実装する。今、ステータスバーには文字が表示されている状態でダサいので、ついでにそれをアイコンにしておく。"%dev/chrome/content/overlay.xul"を編集。ステータスバーのアイコンはテーマにあわせて変更できるとcoolなので、そういうのはskinで指定したフォルダにおくべし!多分。

<statusbar id="status-bar" class="chromeclass-status">
    <statusbarpanel id="merysheep-status-bar" class="statusbarpanel-iconic" onclick="msOverlay.openHTMLListPage();">
        <image src="chrome://merysheep/skin/merysheep16.png"/>
    </statusbarpanel>
</statusbar>

ついで、msOverlay.openHTMLListPageの実装。これは新しいタブにlist.htmlを表示するだけかな。オーバーレイ部分なので躊躇無くFUELを使っていける。

var msOverlay={
    openHTMLListPage: function(){
        var uri=mzCC["@mozilla.org/network/io-service;1"].getService(mzCI.nsIIOService).newURI("chrome://merysheep/content/list.html",null,null);
        Application.activeWindow.open(uri).focus();
    },
};

さて、これで一覧表示もできるようになった。
次はリダイレクタだ!ここからが難解。

リダイレクタを作るにはieTabやb2rに習ってnsIContentPolicyを使うとよさげなので、そのためのXPCOMコンポーネントを作る。
そのためには"components"というフォルダにファイルを作らなければならないので、"%dev/components/msRedirector.js"を作成。中身はとりあえず以下のとおり。
コア(msCore)の初期化完了イベントも受け取らなければならないので、nsIObserverも一緒に実装する。

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
const mzCC = Components.classes;
const mzCI = Components.interfaces;
const mzCR = Components.results;
var Application = mzCC["@mozilla.org/fuel/application;1"].getService(mzCI.fuelIApplication);
var gRedirector = null;
function msRedirector(){}

msRedirector.prototype = {
    //implements nsIObserver
    observe: function(aSubject, aTopic, aData){},
    
    //implements nsIContentPolicy
    shouldLoad: function(aContentType, aContentLocation, aRequestOrigin, aContext, aMimeTypeGuess, aExtra)
    {return mzCI.nsIContentPolicy.ACCEPT},
    shouldProcess: function (aContentType, aContentLocation, aRequestOrigin, aContext, aMimeTypeGuess, aExtra)
    {return mzCI.nsIContentPolicy.ACCEPT},

    //XPCOM Registoration
    classDescription: "msRedirector js component",
    contractID: "@merysheep.chlice.qee.jp/redirector;1",
    classID: Components.ID("{e781b0a8-36d6-4510-a9e9-a23234ac7ee5}"),

    _xpcom_factory: {
        createInstance: function(aOuter, aIID) {
            if(aOuter != null) throw mzCR.NS_ERROR_NO_AGGREGATION;
            if(!gRedirector) gRedirector = new msRedirector();
            return gRedirector.QueryInterface(aIID);
        }
    },
    
    _xpcom_categories: [{ category: "app-startup", service: true }],
    QueryInterface: XPCOMUtils.generateQI([
        mzCI.nsIObserver,
        mzCI.nsIContentPolicy
    ])
};

function NSGetModule(aCompMgr, aFileSpec){
    return XPCOMUtils.generateModule([msRedirector]);
}

XPCOMUtilsをインポートしてプロトタイプでXPCOMUtils用のいくつかのメンバーを定義して、"NSGetModule"関数を書くのがコンポーネントの最低要件。classDescriptionとcontractID、classIDは適切に設定。classDescriptionはたぶん好き勝手書いてよくて、contractIDは"@"+ドメイン+識別子+";"+バージョン。ここに適当なデータを書いてしまうとXPCOMのロードに失敗するようなので注意。classIDはuuIDでOK(GUIDGENを使ってさくっと生成)。
リクエストが発生するとshouldLoadが呼び出されるので、ここでうまい具合に処理を書けばいい。ただし、リダイレクトには制限がある。リダイレクトするには、shouldLoadでaContentLocationを書き換えればよいが、レイアウトやDOMノードの再構築にかかわるような書き換えはできない(というようなことがnsIContentPolicy.idlに書いてある)。つまり、imgタグからロードされる画像のリダイレクトはできない(例外になる)。その辺の解決は追々。
まずはこのリダイレクタの初期化を行う。components以下のファイルはFirefoxが勝手にロードして、NSGetModulesを呼び出してくれる。そこでXPCOMUtilsの力を借りてコンポーネントが登録される。
なんだかよくわからないけれども_xpcom_categoriesを上のように設定しておけば、アプリ起動時に有効になるのか?ともかく、そうやって登録されると起動後に"app-startup"イベントが投げられるので、それをオブザーバーで捕まえて最低限の初期化を行う。その後、コアの初期化イベントも捕まえて本格的な初期化をする感じで。

msRedirector.prototype = {
    observe: function(aSubject, aTopic, aData){
        var observerService = mzCC["@mozilla.org/observer-service;1"].getService(mzCI.nsIObserverService);
        switch(aTopic){
        case "app-startup":
            observerService.addObserver(this, "MerySheep:CoreInitialized", false);
            break;
        case "MerySheep:CoreInitialized":
            observerService.removeObserver(this, "MerySheep:CoreInitialized", false);
            this._startup();
            break;
        }
    },
    _startup: function(){
        Components.utils.import("resource://merysheep-modules/msCore.jsm");
        this.core = msCore;
        var categoryManager = mzCC["@mozilla.org/categorymanager;1"].getService(mzCI.nsICategoryManager);
        categoryManager.addCategoryEntry("content-policy", this.classDescription, this.contractID, true, true);
        this.core.Log("Mery's sheep Observer Ready.", 0);
    },
};

カテゴリーマネージャを使えばリクエストをウォッチングできるようになるらしいのでそのように初期化。何がどうなっているのかその詳細は私にはわかりませんが動けばいいのです・・・。
で、肝心のリダイレクタ部分。

msRedirector.prototype = {
    shouldLoad: function(aContentType, aContentLocation, aRequestOrigin, aContext, aMimeTypeGuess, aExtra){
        if(this.core.isTarget(aContentLocation, aContentType)){
            if(aContext && (aContext.tagName == "IMG")){
                aContext.addEventListener("load", msRedirector_loadTag, null);
                aContext.addEventListener("error", msRedirector_errorTag, null);
            }else{
                return this.core.topLevelRedirector(aContentLocation);
            }
        }
        return mzCI.nsIContentPolicy.ACCEPT;
    },
};

コアに「リダイレクト対象かを判定」する関数(isTarget)を追加して、判定を任せる感じで。処理対象であれば、onLoadとonErrorのイベントを仕込む。onAbortは、とりあえず不要かと。。
aContextがIMGタグの場合は(これ本来は右クリックのときと同様にinstanceof mzCI.nsIImageLoadingContentで判定すべきなのかも)リダイレクトできないっちゅーかロケーションを書き換えたら例外を吐き、aContext.srcを書き換えても効果なしだったので、onLoad,onErrorにフックして普通のHTMLのときみたいに処理することにする。そうでないときはリダイレクト可能なので処理をわける。
Loadの時は読み込んだ画像が未登録なら登録するけど、闇雲に登録するわけにはいかないのでとりあえず自動登録については後で考えることにする。Errorの時は登録されていればキャッシュのURIに書き換える。それだけだとコンテキストメニュー表示時にもにょる可能性があるので、処理したことを示すデータをsetAttributeを使って付与しておく。

var msCore={
    isTarget: function(location, contentType){
        if((contentType ==  mzCI.nsIContentPolicy.TYPE_IMAGE ) && (location.scheme == "http")){
            return true;
        }
        return true;
    },
    onLoaded: function(e){},
    onError: function(e){
        var redirect=this.Registor.getRedirectURI(e.target.src);
        if(redirect){
            var img=e.target;
            img.setAttribute("merysheepRegistord",true);
            img.setAttribute("md5", this.Database.URITable.getHashCode(img.src));
            img.title+="{MERYSHEEP SERVED}";
            img.src=redirect.spec;
        }
    },
};

ここまでやってやっとそこそこ使えるものになったので、ver0.1としてリリース。
ソース全文はsourceforgesvnリポジトリ(rev2)を参照のこと。

お疲れ様でした。