ひだまりソケットは壊れない

ソフトウェア開発に関する話を書きます。 最近は主に Android アプリ、Windows アプリ (UWP アプリ)、Java 関係です。

まじめなことを書くつもりでやっています。 適当なことは 「一角獣は夜に啼く」 に書いています。

Firefox のツールバーの中身の位置決定方法などについて調べた (Firefox 拡張機能開発にまつわる内容)

Firefox 拡張機能を開発する時に、Firefox のツールバー周りのことがよくわからなかったので色々調べてみた。 ドキュメントを調べただけじゃなくて、実際の動作を見たり Firefoxソースコード (Firefox 20 beta 5 のソースコード) を見たりして調べたこともあるので、全部が全部ここに書かれていることが正しいとは限らない。 個人用メモ程度。 指摘等歓迎。

toolbar 周りの基本

ユーザーによるカスタマイズが可能なツールバー

  • ツールバー項目はユーザーが自由に移動することができるようになっている
  • Firefox アプリケーションは、ツールバー項目がどこに配置されているのかを管理するためにツールバー項目の要素の id を使用する
    • あるツールバーの中身の id 一覧は toolbar 要素の currentset 属性 に保持される
      • カンマ区切りの文字列
      • スペーサーなどは id ではなくてスペーサーを表す文字列が代わりに使われる (“separator” など)
    • この属性は永続化されるようになっているのでユーザーによる変更が Firefox 再起動後も保持されるようになっている (?)
  • Firefox 拡張でツールバー項目を追加する場合は、toolbar 要素にオーバーレイするのではなく id="BrowserToolbarPalette" の toolbarpalette 要素にオーバーレイするのが良いらしい
  • 新たに追加したツールバー項目を、初期状態で既存のツールバー上に表示させるようにするには、拡張機能がインストールされた後の最初の実行時に JS 側で toolbar 要素の currentSet プロパティ を変更する必要がある。 さらに、Firefox 再起動後にも有効になるように永続化させる必要もある (詳細は後述)

Firefox のデフォルトのツールバーたち

  • Firefox のアプリケーションウィンドウに元から存在するツールバー (toolbar 要素) は id="navigator-toolbox" の toolbox 要素 に結びつけられている
    • ここでいう 「結び付けられている」 というのは、toolbar の toolbox プロパティの値が id="navigator-toolbox" の toolbox 要素である、ということ
  • id="navigator-toolbox" の toolbox 要素は、子要素に id="BrowserToolbarPalette" の toolbarpalette 要素 を持っている

toolbar の currentSet プロパティでツールバーの中身を指定する

  • toolbar の currentSet プロパティを変更 (そのツールバーが子として持つツールバー項目の id 一覧を設定) した時、toolbar 要素の中の子要素も変更される
  • そのとき、指定した currentSet プロパティに渡した id の要素は、その toolbar に結びつけられている toolbox 要素の中から探される
    • すなわち、その指定の id の要素の親要素 (toolbar 要素) の toolbox プロパティが、その toolbox 要素と同一でなければならない
    • あるいは、その toolbox の palette プロパティの値 (toolbarpalette 要素) の子要素でなければならない
  • ユーザー操作による変更ではなく JS 側からの変更を永続化 (Firefox 再起動後にも有効に) するには、currentset 属性を変更して、この属性に対して document.persist メソッドを使う
    • currentSet プロパティと currentset 属性は別物なので、片方を変更したからといってもう一方にも変更が及ぶわけではない (ウィンドウのロード完了時に currentset 属性をもとにして currentSet プロパティが初期化される、という関係)
  • id 指定でツールバーにツールバー項目を追加する方法としては、currentSet プロパティに代入する他にも toolbar の insertItem メソッド を使う方法もある

toolbar の中身がいつ初期化されるのか

  • toolbar の中身が初期化されるのは、
    • Chrome ウィンドウの document の readyState が "complete" になったとき *2
    • toolbar が作られたときに既に Chrome ウィンドウの readyState が "complete" だったならば、toolbar が作られたときだと思われる (DOM ツリーに挿入されたタイミングかもしれない。 ソースコード読んだけどちゃんと理解してない)

toolbar の currentSet プロパティをいじるなら、document の readyState が "complete" になったあとにしないとおかしくなるので注意。

ブートストラップ型の拡張機能でツールバー項目を追加する場合

そういうわけなので、ブートストラップ型の拡張機能を開発している場合で、ツールバー項目を追加する場合には、Chrome ウィンドウの readyState が "complete" になるまでに toolbarpalette にツールバー項目を追加しておくとよい。 そうすると Firefox 側の機能で自動的にツールバー項目が適切な位置に配置される。 (それより遅いタイミングでツールバー項目を追加する場合は、拡張機能側でどこに追加するかを指定しなければならない。)

ちなみにブートストラップ型の拡張機能のインストール直後には既には Chrome ウィンドウは存在しているはずなので、そのタイミングでは適切な位置に拡張機能が挿入する必要がある。 アップデート時にはアップデート前と同じ位置に挿入する必要がある。 上記の 「readyState が "complete" になるまでに...」 というのはあくまで Firefox 再起動後の拡張機能の setup 時の話である。

toolbarpalette 要素にツールバー項目を追加する方法は次の節を参照のこと。

toolbarpalette 要素の扱い

  • toolbarpalette 要素は、toolbar の初期化に合わせて (つまり、基本的には Chrome ウィンドウの readyState が "complete" になったとき) DOM ツリーから取り除かれる
    • 要素自体は toolbox の palette プロパティで参照できるように残される

なので、id="BrowserToolbarPalette" の toolbarpalette 要素に子要素を追加するには、条件分岐しなければならない。

var browserToolbarPaletteElem = document.getElementById("BrowserToolbarPalette") || document.getElementById("navigator-toolbox").palette;
browserToolbarPaletteElem.appendChild(newToolbarItem);

基本的には XUL オーバーレイが使える場合は特に上記のようなことをする必要は無いと思うが、動的にツールバー項目を追加する場合や、XUL オーバーレイが使えないブートストラップ型の拡張機能を開発する場合等は上のようにすれば良いと思われる。

ツールバーの表示、非表示

toolbar の collapsed プロパティをいじることで、ツールバーを非表示にしたり表示したりできる。 アドオンバーは初期状態で非表示なので、拡張機能の初回実行時にアドオンバーにツールバー項目を追加する場合は、ついでにアドオンバーを表示するようにするのが良さそう。

toolbar の collapsed プロパティを false にするだけだと永続化されないので collapased 属性も書き換えて、document.persist メソッドを使って永続化するのが良い。

// 拡張機能の初回起動時の処理; これらは document.readyState が "complete" になったあとに実行すべき (?)
toolbar.collapsed = false;
toolbar.setAttribute("collapsed", "false"); // プロパティを変更したら属性も変更されるのかもしれないが未確認
document.persist(toolbar.id, "collapsed");

ブートストラップ型の拡張機能でのツールバー項目

piroor さんにより公開されているブートストラップ型の XUL ベースの拡張機能のテンプレート (restartless) の中に、ToolbarItem というモジュールがあるので、ブートストラップ型の拡張機能でツールバー項目を使いたい場合はこれを使うとか参考にすると良さそう。

最初にこのモジュールを読んだときは何をしているのか全然分からなかったけど、前提知識としてこの記事に書いたような内容を知っていれば大体読めそうな気がする。

サンプルコード

XUL オーバーレイを用いる Firefox 拡張で、アドオンバーにツールバー項目を表示するようにする

まず、以下のようにオーバーレイを指定する XML ファイルで、id="BrowserToolbarPalette" の toolbarpalette 要素に toolbarbutton 要素を追加するように書いておく。

<?xml version="1.0" ?>
<overlay id="Screenshot-xulOverlay" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
  <toolbarpalette id="BrowserToolbarPalette">
    <toolbarbutton
        id="sample-toolbar-button"
        label="サンプルツールバーボタン"
        image="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="
        tooltiptext="サンプルです"></toolbarbutton>
  </toolbarpalette>

  <script type="text/javascript; version=1.8" src="overlay.js" charset="utf-8" />
</overlay>

上の XML ファイルで読み込まれる overlay.js として以下のようなファイルを作っておく。

(function () { "use strict";

// https://developer.mozilla.org/en-US/docs/Code_snippets/Toolbar のサンプルコードをベースに少し書き換えた関数
function installButton(toolbarId, id, afterId) {
    var doc = window.document;
    // 既にどこかに配置されている場合は位置変更しない
    if (!doc.getElementById(id)) {
        var toolbar = doc.getElementById(toolbarId);

        // If no afterId is given, then append the item to the toolbar
        var before = null;
        if (afterId) {
            var elem = doc.getElementById(afterId);
            if (elem && elem.parentNode == toolbar)
            before = elem.nextElementSibling;
        }

        // 1 回実行すれば, 次に別のウィンドウを開くときにはこの設定が使われる

        toolbar.insertItem(id, before);
        toolbar.setAttribute("currentset", toolbar.currentSet);
        doc.persist(toolbar.id, "currentset"); // 属性値の永続化

        // 追加先のツールバーが非表示なっている可能性があるので, 表示する
        toolbar.setAttribute("collapsed", "false");
        doc.persist(toolbar.id, "collapsed"); // 属性値の永続化
    }
}

window.addEventListener("load", function el(evt) {
    if (evt.target !== window.document) return;
    window.removeEventListener("load", el, false);

    // オーバーレイされたときに毎回この関数を呼び出すと、ユーザーがボタンを
    // 表示したくない場合にもアドオンバーに追加されてしまう。 実際の拡張機能では
    // Preferences などで管理して、最初に 1 回だけ実行するようにすること。
    installButton("addon-bar", "sample-toolbar-button");
}, false);

}).call(this);

そうすると、XUL オーバーレイされるたびに (つまり、新しい Firefox アプリケーションウィンドウが開かれるたびに) JS ファイルが実行され、この拡張機能のツールバーボタンがまだツールバーに表示されていない場合は、アドオンバーに追加されるようになる。 コメント中にも書いているように、実際の Firefox 拡張では、初回起動時のみに実行する様にするなどの配慮が必要である。

ブートストラップ型拡張機能でツールバー項目を追加する

ブートストラップ型の XUL ベースの拡張機能では、XUL オーバーレイが使えない。 なので、ツールバー項目を追加するには JS 側から追加してやる必要がある。 XUL オーバーレイで id="BrowserToolbarPalette" の toolbarpalette 要素にツールバー項目を追加するのと大体同じことをしようと思うと、以下のような感じにすれば良さそう。

下記は bootstrap.js のサンプルコードである。 インストール時や無効になっていた拡張機能が有効にされたときなど、Firefox のアプリケーションウィンドウが既に存在する状態で startup 関数が実行されたときには、既に表示されているウィンドウの toolbarpalette 要素にツールバー項目を追加する。 また、ウィンドウの新規立ち上げを監視し、必要に応じて新しいウィンドウの toolbarpalette 要素にツールバー項目を追加する。 初期状態でどこかのツールバー上に項目を表示したりはしない。 ユーザーによる項目移動がなされた場合は Firefox アプリケーション側で位置が保存されるので、拡張機能側ではどこに表示するかを覚えておくことはしていない。

ブートストラップ型の XUL ベースの拡張機能でツールバーボタンを追加する方法を調べてると 「Firefox 拡張側でツールバーボタンの挿入位置を覚えておくようにして、ツールバーボタン挿入時に適切な位置に挿入するようにすればよい」 と書かれているものがほとんどだったが、Firefox 拡張側で位置を保存しておく必要は必ずしもないんじゃないかなーと思う。

"use strict";

var Cc = Components.classes;
var Ci = Components.interfaces;
var PromptService  = Cc["@mozilla.org/embedcomp/prompt-service;1"].getService(Ci.nsIPromptService);
var windowWatcher  = Cc["@mozilla.org/embedcomp/window-watcher;1"].getService(Ci.nsIWindowWatcher);
var windowMediator = Cc["@mozilla.org/appshell/window-mediator;1"].getService(Ci.nsIWindowMediator);

// window ごとのツールバー項目管理
var ToolbarItemManager = function (win) {
    this._win = win;
};
ToolbarItemManager.prototype.setupToolbarItems = function () {
    var doc = this._win.document;
    if (doc.readyState === "complete") {
        this._addToolbarItemsToPalette();
        this._reinitializeToolbars();
    } else {
        this._addToolbarItemsToPalette();
        // 配置は Firefox がやってくれる
    }
};
ToolbarItemManager.prototype._addToolbarItemsToPalette = function () {
    var doc = this._win.document;
    var toolbarItemElems = this._createToolbarItemElems();
    this._toolbarItemElems = toolbarItemElems;
    var paletteElem = doc.getElementById("BrowserToolbarPalette") ||
                      doc.getElementById("navigator-toolbox").palette;
    if (!paletteElem) {
        throw "デフォルトのツールバーボタンパレットが見つかりません";
    }

    toolbarItemElems.forEach(function (toolbarItemElem) {
        paletteElem.appendChild(toolbarItemElem);
    });
};
ToolbarItemManager.prototype._reinitializeToolbars = function () {
    var win = this._win;
    var doc = this._win.document;
    var navToolboxElem = doc.getElementById("navigator-toolbox");
    var NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
    var toolbarElems = Array.prototype.slice.call(doc.getElementsByTagNameNS(NS, "toolbar"));
    toolbarElems.forEach(function (toolbarElem) {
        if (toolbarElem.toolbox !== navToolboxElem) return;
        var curset = toolbarElem.getAttribute("currentset");
        toolbarElem.currentSet = curset;
    });
};
// パレットに追加するツールバー項目の配列を返す
// ブートストラップ型でない拡張機能における id="BrowserToolbarPalette" の
// toolbarpalette 要素へのオーバーレイに相当
ToolbarItemManager.prototype._createToolbarItemElems = function () {
    var doc = this._win.document;
    var NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
    var e = doc.createElementNS(NS, "toolbarbutton");
    var id = "toolbar";
    e.setAttribute("label", "Test Button! " + Date.now());
    e.setAttribute("id", id);
    // 小さな赤丸
    e.setAttribute("image", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAU" +
            "AAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO" +
            "9TXL0Y4OHwAAAABJRU5ErkJggg==");

    return [e];
};
// 終了処理
ToolbarItemManager.prototype.releaseToolbarItems = function () {
    var toolbarItemElems = this._toolbarItemElems;
    if (!toolbarItemElems) return;

    toolbarItemElems.forEach(function (e) {
        var pe = e.parentNode;
        if (pe) {
            pe.removeChild(e);
        }
    });
};

// ToolbarItemManager を使って全ウィンドウのツールバー項目を管理
var ToolbarItemManagerForAllWindows = function () {
    this._finalizerSet = new Set();
};
var finalizerSet = new Set();
ToolbarItemManagerForAllWindows.prototype.initForWindow = function (win) {
    var finalizerSet = this._finalizerSet;
    var toolbarButtonManager = new ToolbarItemManager(win);

    var finalizer = function () {
        win.removeEventListener("unload", unloadEventListener, false);
        toolbarButtonManager.releaseToolbarItems();
        finalizerSet.delete(finalizer);
        finalizer = void 0;
        unloadEventListener = void 0;
    };
    var unloadEventListener = function (evt) {
        if (evt.target !== win.document) return;
        finalizer.call(null);
    };
    win.addEventListener("unload", unloadEventListener, false);

    finalizerSet.add(finalizer);
    toolbarButtonManager.setupToolbarItems();
};
ToolbarItemManagerForAllWindows.prototype.finalizeForAllWindows = function () {
    var finalizerSet = this._finalizerSet;
    for (var finalizer of finalizerSet) {
        finalizer.call(null);
    }
};

var toolbarItemManagerForAllWindows = new ToolbarItemManagerForAllWindows();

// 既に表示されているウィンドウに対する初期化処理
function _initForWindows() {
    var type = "navigator:browser";
    var enumerator = windowMediator.getEnumerator(type);
    while(enumerator.hasMoreElements()) {
        // |win| は [Object ChromeWindow] である (|window| と同等)。これに何かをする
        var win = enumerator.getNext();
        toolbarItemManagerForAllWindows.initForWindow(win);
    }
}

// 新たに開かれたウィンドウに対する初期化処理を行うためのオブザーバ
var windowObserver = {
    observe: function (aSubject, aTopic, aData) {
        var win = aSubject.QueryInterface(Components.interfaces.nsIDOMWindow);
        var targetLoc = "chrome://browser/content/browser.xul";
        if (aTopic === "domwindowopened") {
            win.addEventListener("DOMContentLoaded", function el(evt) {
                // DOMContentLoaded 前に確認すると win.location.href が "about:blank" だったりした
                if (win.location.href !== targetLoc) {
                    // 対象とする window でない場合はイベントリスナを解除して終了
                    win.removeEventListener("DOMContentLoaded", el, false);
                    return;
                }
                // DOMContentLoaded は何回か発生するので, 対象のものでない場合は何もしない
                if (evt.target !== win.document) return;
                win.removeEventListener("DOMContentLoaded", el, false);
                toolbarItemManagerForAllWindows.initForWindow(win);
            }, false);
        }
    }
};

function install(aData, aReason) {
    PromptService.alert(null, "Bootstrapped Extension Sample", "Installed");
}

function startup(aData, aReason) {
    PromptService.alert(null, "Bootstrapped Extension Sample", "Startup!");

    _initForWindows();
    windowWatcher.registerNotification(windowObserver);
}

function shutdown(aData, aReason) {
    PromptService.alert(null, "Bootstrapped Extension Sample", "Shutdown!");

    windowWatcher.unregisterNotification(windowObserver);
    toolbarItemManagerForAllWindows.finalizeForAllWindows();
}

function uninstall(aData, aReason) {
    PromptService.alert(null, "Bootstrapped Extension Sample", "Uninstall!");
}

*1:というのをどこかで見かけたがどこで見たのかわからない

*2:ちなみに readyState が "complete" になるタイミングは、load イベントの発生するタイミングである