たまに18歳未満の人や心臓の弱い人にはお勧めできない情報が含まれることもあるかもしれない、甘くなくて酸っぱくてしょっぱいチラシの裏。RSSによる簡単な更新情報を利用したりすると、ハッピーになるかも知れませんしそうでないかも知れません。
の動向はもえじら組ブログで。
宣伝。日経LinuxにてLinuxの基礎?を紹介する漫画「シス管系女子」を連載させていただいています。 以下の特設サイトにて、単行本まんがでわかるLinux シス管系女子の試し読みが可能!
最初に要点だけまとめておくと、
という話です。
先に結論だけ書くと、
という話です。以下、実際にどういうケースでこれが役立つかの説明です。
js-ctypesはFirefox 3.6から利用できるMozillaの独自の機能で、平たく言うとC言語の実装の中で定義された関数をJavaScriptから呼べるようにするという物。Pythonにctypesという機能があって、それのJavaScript版がjs-ctypes。
Firefox 3.6(Gecko 1.9.2)ではできる事の制限が厳しかったので使えるケースがあんまり無かったようなんだけど、Firefox 4(Gecko 2.0)では構造体がサポートされたので一気に使える場面が増えた。らしい。
システムモニターをFirefox 4に対応させなきゃねと思ってたんだけど、Compartmentがどうとか色々変更があったのを全部調べてたら絶対自分の手に負えん!!と思ったので、いっそのことjs-ctypesで実装すりゃいいんじゃね? と思って、試行錯誤しながらやってみてる。試行錯誤の様子はリポジトリを見るとバレバレです。
js-ctypesのいい所:
困った所:
parseInt()
する、みたいな癖を付けとくとハマらなくていいと思う。JavaScriptでFirefoxをクラッシュさせたかったらjs-ctypesでメモリ破壊とかやると手っ取り早いですよ! と、数え切れないほどFirefoxをクラッシュさせて思いました。
Firefox Developers Conference 2010の時に再起動不要なアドオンの作り方を調べた時、調査の成果を元にJetpack SDKもといAdd-on SDKを使わずにFirefox 3.6とMinefieldの両方に対応したBootstrapped Extensionを作るためのテンプレを作った。それ以後、新しく作るアドオンでBootstrapped Extensionにできそうな物はなるべくそのように開発していこうと思ってて、Back to Owner Tabは実際そうして作ったアドオンの第1号ということになる。
SDKの使い方や仕組みを調べた時にもちょっと思ったんだけど、Back to Owner Tabを作って、「設定ダイアログを持てないのがこんなに辛いとは……」ということを改めて感じた。しかしBootstrapped Extensionで設定ダイアログを提供するのは簡単には実現できなさそうだったので、ずっと諦めてた。
今回、うまく解決できそうな方法をふと思いついて、実際試してみたら案外うまくいったので、テンプレの一部としてライブラリ化してみた。Back to Owner Tabの設定ダイアログもこれで提供してる。
Firefox 2以降ではXULにprefwindowというウィジェットが存在していて、これはFirefox自体の設定ダイアログにも使われている物で、非常に出来がよい。今ある拡張機能の多くも、これを使って設定ダイアログを提供している。
prefwindowができるまでは「設定値を保持して、変更を監視して、変更があったらUIに反映して、ダイアログがキャンセルされたら変更を破棄して……」てな事をいちいち考えてゼロから設定UIを設計しなきゃいけなかったから、非常に辛かった。そういう思いがあるので、Bootstrapped Extensionでも是非これを使いたかったんだけど、Bootstrapped Extensionの「Chrome URLを登録できない」という制限のせいで無理だった。
どうしてもやりたければ、XUL以外の方法で頑張って設定UIを作るか、XULで物凄く苦労して作るしか無いってことになって、それはどっちも嫌だった。せっかくprefwindowという素晴らしい物が目の前にあるのに、それを使わないでオレオレUIをゼロから作るなんて、馬鹿馬鹿しくてやってられない。
せめて最新のSDKだったら設定ダイアログを作る機能が入ってたりしないだろうか? あるんならそれを丸パクリできないかな? と思ってたんだけど、SDKに設定ダイアログのためのAPIが入るのはまだ先のことらしいと聞いて、それも諦めざるを得なかった。
それで仕方なく「about:configでカスタマイズしてね」ということにしてたんだけど、まあ普通に考えてこれはエンドユーザ向けとしては酷い話なわけですよ。自分で使う時も、マウス操作だけで設定を変えられないのは困る。
そんな風に悶々としていて、ふと、「そういえばuserChrome.jsとかではdata: URIでXULドキュメントを動的に作ってたよな」ということに思い至った。試しにdata: URIにprefwindowで作った設定ダイアログの内容を突っ込んでnsIWindowWatcherのopenWindow()
でウィンドウとして開いてみたら、少なくともMinefield 4.0b11preでは正常に動いてくれた。data: URIでドキュメントを読み込んだりウィンドウを開いたりする時はオープン元のスクリプトの権限が引き継がれるっぽいので、file: URLでXULを読み込ませた時のような問題も起こらないようだ。
また、E4Xを使えばJavaScriptの中に直接XMLを書けて、しかも属性値にJavaScriptの変数を埋め込める。これなら、propertiesファイルベースでのローカライズでもラベル等を埋め込むのが苦にならない。で、そうして作ったE4XのXMLオブジェクトをtoXMLString()
すれば、data: URIで読み込ませるダイアログのためのソースコードが得られる。
ということで、「XULのソースコードを文字列として受け取ってdata: URIにしてウィンドウを開く機能」と「アドオンマネージャでアドオンの設定を開くボタンが押下された時に前述の機能を呼び出す機能」を作れば、Bootstrapped Extensionでもprefwindowベースの設定ダイアログを提供できる事が分かった。
propertiesファイルの内容はローカライズ済みの文字列をXULのstringbundle要素を介して取得する方法が一般的だと思うけど、より低レベルな実装のnsIStringBundleServiceを直接呼べば、JavaScriptコードモジュールやBootstrapped Extensionの中からでもpropertiesファイルの中身を簡単に取り出せる。(stringbundle要素はXBLでそういう処理を行うようにしてあるに過ぎない。)
上記のテンプレには、nsIStringBundleServiceをラップしてstringbundle要素と同じインターフェースで使えるようにするライブラリを既に入れてある。
var bundle = require('path/to/lib/locale.js')
.get('file://.../messages.properties');
var title = bundle.getString('config.title');
という風にすると、ブラウザのUIの言語がjaでmessages.properties.jaというファイルがあればそれが使われて、存在しない場合はmessages.properties.en-US→messages.properties(サフィックス無し)とフォールバックしていく。
Back to Owner Tabに入ってるバージョンのライブラリには含まれてないけど、テンプレのHEADでは resolve('path/to/file')
とすると現在のファイルを起点にして相対パスを解決できるようにしてあので、こういう風にも書ける。
var bundle = require('path/to/lib/locale.js')
.get(resolve('locale/messages.properties'));
なお、Bootstrapped Extensionの場合は読み込んだpropertiesファイルの内容がキャッシュされたままになってしまうとまずいので、shutdown()
の時にはnsIStringBundleServiceのflushBundles()
というメソッドを呼んで、メモリ上のキャッシュを消すようにする必要がある。このライブラリでもそうしてて、自分でnsIStringBundleServiceを使う時はここに気をつけないといけない。
Bootstrapped ExtensionやJavaScriptコードモジュールの名前空間からで既存のDOMWindowを取れない状態でウィンドウを開きたい場合には、nsIWindowWatcherのopenWindow()
を使う必要がある。このメソッドに渡すURIとしてdata: URIを指定すれば、その内容のウィンドウがChrome権限で開かれる。
var uri = 'data:application/vnd.mozilla.xul+xml,'
+ source;
Cc['@mozilla.org/embedcomp/window-watcher;1']
.getService(Ci.nsIWindowWatcher)
.openWindow(null, uri, '_blank',
'chrome,titlebar,toolbar,centerscreen',
null);
ソース文字列の生成は、E4Xを使うと楽にできる。
var xml = <>
<prefwindow id="backtoowner-config"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
title={bundle.getString('title')}>
<prefpane id="prefpane-general"
label={bundle.getString('general')}/>
</prefwindow>
</>;
var source = '<?xml version="1.0"?>'
+'<?xml-stylesheet href="chrome://global/skin/"?>'
+xml.toXMLString();
source = encodeURIComponent(source);
ラベル文字列等に日本語が入る場合を考慮して、ソース文字列はdata: URIに繋げる前にエスケープしておこう(これをしないと文字化けする)。
基本的にはこれでいいんだけど、実際このまま使うと何となく気持ち悪い。というのも、prefwindowは画面上でのウィンドウの位置や大きさ、最後に選択されていたパネルの名前等をプロファイル内のlocalstore.rdfに自動的に保存するんだけど、この時保存されるデータはウィンドウのURIをキーとして紐付けられているから、data: URIのウィンドウだとlocalstore.rdfが肥大化してしまう。しかも、設定項目を追加したりしてウィンドウの中身が変わるとdata: URIも変わるから、前回情報を保存した時のエントリとは別のエントリが作られてしまって、古いエントリは自動的には消えないから、localstore.rdfが際限なく膨らんでいってしまう事になる。長く使うことを考えたら、これはよくない。
で、何かいい方法はないか考えてみた。
document.write()
でウィンドウの中身を動的に書き換える。nsIWindowWatcherのopenWindow()
は開かれたウィンドウのDOMWindowオブジェクトを返す。なので、こんな風にできないかと最初は考えた。
var window = ww.openWindow(...);
window.document
.open('application/vnd.mozilla.xul+xml');
window.document.write(source);
window.document.close();
が、駄目だった。この方法でドキュメントを書き出すと強制的にHTMLのパーサーが使われてしまうらしく、XULのソースを書き出しても全く動作しない状態になってしまった。
次に、DOM操作でやることを考えた。
XUL Documentの中でRangeを作ってcreateContextualFragment()
すると、XULのソース文字列からDOMDocumentFragmentを作ることができる。こうしてルート要素も含めたドキュメント全体を作り直してゴソッと入れ替えたらどうなるだろうか。
var window = ww.openWindow(...);
var range = window.document.createRange();
range.selectNode(window.document.documentElement);
var fragment = range.createContextualFragment(xml.toXMLString());
window.document.replaceChild(fragment, window.document.documentElement);
実際やってみたら、これは期待通りには動いてくれなかった。多分、開かれたウィンドウの内容がまだ完全には読み込まれ切っていない状態でDOM操作を外から無理矢理やろうとしたからなんだろうと思う。
開かれたウィンドウのDOMContentLoadedを待ってからDOMを操作すればprefwindowについてはちゃんと動くみたいなんだけど、dialogとかのもっと汎用的な物も受け付ける事や、ダイアログの中にDOMContentLoadedを待って処理を開始するようなコードを含めたい時に、それでは問題が起こる気がする。
そういうわけで今のバージョンでは、ウィンドウを開く時にnsIVariantの形でソース文字列を渡して、開かれた側のウィンドウの中に1つだけ置いたscript要素でarguments[0]
を受け取ってすぐにドキュメントを書き換えるようにしてみている。要点だけ抜き出すとこんな感じ。
var variant = Cc['@mozilla.org/variant;1'].createInstance(Ci.nsIWritableVariant);
variant.setFromVariant(source);
ww.openWindow(
null,
'data:application/vnd.mozilla.xul+xml,'
+encodeURIComponent(
'<?xml version="1.0"?>\n'
+'<!-- ' + aURI + ' -->\n'
+'<?xml-stylesheet href="chrome://global/skin/"?>\n'
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
<script type="application/javascript"><![CDATA[
var d = document;
var e = d.documentElement;
var r = d.createRange();
r.selectNode(e);
d.replaceChild(r.createContextualFragment(arguments[0]), e);
r.detach();
]]></script>
</window>.toXMLString()
),
'_blank',
'chrome,titlebar,toolbar,centerscreen',
variant
);
URIをコメントで埋め込んでるのは、複数のアドオンでこのライブラリを使ってる時に衝突しないようにしたかったから。
実はここに辿り着くまでに結構紆余曲折があった。最初はルート要素を差し替えられるという事に気づいていなかったので、「ルート要素の属性値の情報」と「子孫要素のソース」をそれぞれ取り出してやってみていた。E4Xは実はよく分かってないからちょっと苦労した。
var content = xml.*.toXMLString(); // 子孫要素だけ抽出
var container = xml.copy(); // ルート要素だけ取り出すためにまず一旦全体を複製
var attributes = container[0].attributes(); // ルート要素の属性のリストを取得
var attributesHash = {};
for each (var attribute in attributes) { // 属性名と値のペアのハッシュにする
let name = attribute.name();
attributesHash[name] = attribute.toString(); // 属性値はtoString()で取れる
delete container[0]['@'+name]; // removeAttribute()に相当する操作はこう書く
}
// 子孫要素を削除して、コンテンツ差し替え用のスクリプトに入れ換える
delete container.*;
container.script = <script type="application/javascript">{this._loader}</script>;
container = container.toXMLString();
最初はこれでいいかと思ってたんだけど、コンテンツ差し替え用のスクリプトが走ってる時点で既にprefwindowのXBLのコンストラクタが実行されてしまっていて、複数のprefpaneがあるときの切り替え機能が働いてなかった。
ルート要素をもう一度作りなおしてドキュメントに挿入すればXBLのコンストラクタが実行されるはず、ということでdocument.removeChild(document.documentElement);
の後でdocument.appendChild(newRoot);
みたいな風な事も試してみたけど、一瞬documentElement
が無くなってしまうのがマズいようで、他のどっかの処理がルート要素を参照するタイミングとぶつかってエラーになってしまった。ここはdocument.replaceChild(newRoot, document.documentElement);
で一気に差し替えてしまうのが正解だった。昔のGeckoはreplaceChild()
でクラッシュする事があったような気がするので無意識に使用をずっと避けてきてたんだけど、Firefox 3.6以降ともなるとさすがに問題なく動いた。
あと、生成した新しいルート要素を挿入しようとするとエラーになる場合もあって、その時は挿入しようとしているノードをdocument.importNode()
に1回通してやれば動いた。まあ最終的にはそれ無しで乗り切れたんだけど。
これも難儀した。
Firefoxのアドオンマネージャは、install.rdfのoptionsURL
に書かれてるURIをwindow.openDialog()
で(Mac OS X以外では)モーダルダイアログとして開くようになっている。しかし前述の通りXULの設定ダイアログを読み込ませたかったらChrome URLにしないといけないので、これはそのままは使えない事になる。
とはいえ、optionsURLにはChrome URLしか使えないという話ではない。
最初は、ここに直接javascript:ダイアログを開くためのコード
という感じのスクリプトを書く事を考えてみた。でもメタデータの置き場所であるinstall.rdfにそういう実装を含めるのはどうかと思うし、そもそもここにjavascript:スキームを使って書いたスクリプトは普通にコンテンツ領域の権限でしか実行されないようなので、nsIObserverServiceを使ってイベントを発行するとかそういう高度な事は全然できなかった(実際試して無理だった)。
次に、nsIWindowWatcherでの監視とかnsIObserverServiceを介して送られるchrome-document-global-createdとかcontent-document-global-createdとかの通知でもって「あらかじめ登録されていたURI」が読み込まれた事を検知して、その読み込み(ウィンドウのオープン処理)をキャンセルして別の設定ダイアログを開くという事を考えた。
これは結構イイ線まで行ったんだけど、「Firefoxのアドオンマネージャが開いたウィンドウ」が一瞬だけ見えてしまうという点をどうしても回避できなかった。openDialog()
の実装を辿ってnsGlobalWindow::OpenDialog() → nsGlobalWindow::OpenInternal() → nsWindowWatcher::OpenWindowJS() → nsWindowWatcher::OpenWindowJSInternal()と掘り返して、何か抜け道はないか探しまくったんだけど、内部で新しいDOMWindowが生成されてウィンドウが実際に画面に出現する事がほとんど確定した後で初めてnsIDocShellのloadURI()
にURIが渡されてる(この間URI文字列はC++の普通の変数で引き回されてるだけでJavaScriptの層からは触りようがない)みたいで、ウィンドウがユーザの目に触れる前にどうこうってのは原理的に無理っぽい。
いや、nsIContentPolicyインターフェースを備えたXPCOMコンポーネントを作ってカテゴリマネージャに登録すれば、もしかしたらそこに割り込む事はなんとかできるかもしれない。けど、設定ダイアログ1つ開くためだけにそこまでする元気はなかったし、求める機能に対してコードが膨大になりすぎるから普通にこれはないわーって感じだしで、そういう方向性は最初から切り捨ててる。
結局、すべてのドキュメントの読み込みをchrome-document-global-createdとcontent-document-global-createdの通知で捕捉して、アドオンマネージャのウィンドウが開かれた時にはボタンのクリック操作を監視するイベントリスナを登録し、「設定」ボタンをユーザがクリックした瞬間を捕捉して、登録済みのURIだったらイベントをキャンセルして別のダイアログを開く、という所に落ち着いた。これだとアドオンマネージャの実装(現在どのアドオンが選択されているのか、そのアドオンの設定ダイアログのURIは何なのか、を取得する方法)にべったりになってしまわざるを得ないんだけど、まあその辺のレイヤだったら変更があっても追随するのはそう大変じゃなかろうと思って、妥協する事にした。
冒頭に書いたけど、ここまでのまとめがBootstrapped Extensionsテンプレートにライブラリとして入ってる。他のファイルの内容に可能な限り依存しないように作ったつもりなので、改造再利用も不可能ではないと思う。実際にどう使うのかというのはBack to Owner Tabでの利用例が参考になるかもしれない(って単にE4XでダイアログをJavaScriptのコードの中に埋め込んでるだけだけど)。
ほんとはFirefox 4に向けて記事を書いていかなきゃいけないんだけど、こういう「あれも駄目でした、これも駄目でした、結局こういう所に落ち着かざるを得ませんでした」という紆余曲折は本に入れてもウケないんじゃないかなーって思ったので、ここに書く事にした。また次に同じような事をしようと思った時に同じ轍を踏まなくてもいいように。技術情報のドキュメントは、未来の自分のために残しておく物なのです。
Bug 591652 – Make the tab view (Panorama) background transparent to reveal glass (if enabled) on Windowsというバグがfixされて、Panoramaの背景が透過されるようになった。Windows Vista以降でAero Glassを有効にしてれば、見事なスケスケになる。
で、ほうほうと思ってチェックインされたパッチを見てみたんだけど、「Aero Glassじゃない時だけ背景を指定する」というコードしかない。ひょっとしてフレームを透過するバックエンドってもうずっと前から入ってたの? そういえばPanorama用のiframeには transparent="true"
という指定がずいぶん前からあったような気がするけど、特に透過されてる様子もなかったから、まだ実装されてないのかと思ってた。
それでMinefield 4.0b10preを起動してDOM InspectorでFirefoxのDOMツリーをいじって試してみたら、browser要素とかiframe要素とかに transparent="true"
という属性の指定を加えて、そのフレーム要素・祖先要素すべて・フレームの中に含まれるドキュメントのbodyを background: transparent !important;
にするだけで、普通のWebページでもデスクトップの壁紙が透けて見えるようになった。これは面白い。
最小構成だとこう。
parent.xul:
<?xml version="1.0"?>
<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
style="-moz-appearance: -moz-win-glass; background: transparent;"
width="200" height="200">
<iframe transparent="true"
flex="1"
style="background: transparent;"
src="child.xul"/>
</window>
child.xul:
<?xml version="1.0"?>
<page xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
style="-moz-appearance: none; background: transparent;">
<label value="Hellom, transparent world!"/>
</page>
この2つのファイルをテキスト形式でデスクトップに保存して、エラーコンソールから window.openDialog('file:///c:/Users/username/Desktop/parent.xul');
とやれば試せる。
Aero Glassじゃない時は普通の背景を表示させたいなら、CSSの方にこう書いておけばいい。
window:-moz-system-metric(windows-compositor) {
background: -moz-field;
}
夢が広がりまくりですね!
ツリー型タブをPersonal Ttitlebarと併用できるようにするためにずいぶん骨を折った。
Firefox 4ではタブバーとtabbrowser要素がDOMツリー的に切り離されて、tabs要素がカスタマイズ可能なtoolbarの中に置かれるようになった。この話は以前に勉強会で発表した(この資料の日付を見て「うわーもうあれから1年近く経とうとしてるのか、Firefox 4の開発どんだけ遅れとんねん」とか「そんな昔のことを『ちょっと前に発表しましたが』とか書こうと思ってしまった自分ってどないやねん」とか思ったのですがそれはどうでもいいです)んだけど、その時こんな話をした。
その時も特には説明しなかったんだけど、何故僕はそうまでしてDOMツリーの変更を避けたがるのか。今回Perosnal Titlebarとの衝突を解決するためにここで散々悩まされたので、改めてここに記しておこうと思う。
Firefoxのツールバーの各機能は、ユーザが任意にドラッグ&ドロップで並べ替えたりアイテムを追加・削除したりできるようになっている。この時内部的には当然だけどDOMツリーが動的に切り貼りされている。
この時、ツールバーから削除されたボタンに対してDOMのイベントを監視し続けるのは変だし、そのボタンへの参照をアドオンの中の変数でずっと持ったままだと、いわゆるメモリリークが起こることもある。なので、僕はツールバーにボタンを追加するアドオンについては、ツールバーのカスタマイズに入る前に一旦「ボタンの終了処理」を行って、ツールバーのカスタマイズが終わった時点でもう一度「ボタンの初期化処理」を走らせるようにしていることが多い。
ちなみに、こういう事をするためにDOMイベントが使えればいいんだけど、Firefox 3.6までのバージョンではそんな配慮は全然無い不親切な設計なので、カスタマイズを開始する関数の BrowserCustomizeToolbar()
とかカスタマイズ終了時に呼ばれる BrowserToolboxCustomizeDone()
とかツールボックスの customizeDone()
メソッドだとかを上書きして処理を挟み込んでやらないといけなかった。Minefieldではいつの頃からか beforecustomization とか aftercustomization とか customizationchange とかのイベントが発行されるようになったので、こういうダーティなことはやらなくても済むようになった。もっと早くからこうしてくれていればよかったのに……いやそれは今はどうでもいい。
自分で追加したツールバーのボタンについてそういう事が必要なのと同様に、例えば標準のWeb検索バーの挙動を変更するセカンドサーチのような「既存のツールバーボタンの挙動を変える物」も、同じような事をしないといけない。特に、既存のボタンにXBLで追加されたメソッドを後から上書きしているようなケースでは。セカンドサーチの例で言うと、こういう感じだ。
handleSearchCommand()
を上書きする。
handleSearchCommand()
メソッドも元に戻ってしまう。
appendChild()
されて、元の要素があった位置に挿入される、という処理が行われるから。Firefox 4でタブバーがツールバーに移動された時も、これと同じ事が起こるんじゃないかとちょっと思ってたけど、実際はそうならなかった。Firefoxの他の部分が「タブがある事」を前提に設計されているせいで、タブバーをツールバーカスタマイズでツールバー上から完全に取り除いたら、他の機能が色々と破綻してしまうから……という事なんだと思われる。なので今のMinefieldでは、タブバーのtabs要素はメニューバーと同様に「ツールバーの中にあるけど動かせない要素」という扱いになっている。
そういう事情で、Firefoxもタブバーまわりのコードは「DOMツリーが動的に編集されても動作するような堅牢に設計」にするための注意は払われていないし、他のタブ周りのアドオンもそういう設計にはなっていない事が多い。というか、そういうアドオンがあまりに多いからFirefox本体の側でもタブバーをカスタマイズで移動できないようにせざるを得なかったって事なんじゃないかと思うんだけど。
Personal Titlebarというアドオンは、Firefox 4の(Windowsでの)タイトルバーに任意のツールバー用のボタンを配置できるようにするという物だ。それだけでなく、タブバーすらカスタマイズで移動できるようにしてしまう。それが問題だった。
前述した通り、Firefoxのツールバーはカスタマイズのモードに入っただけでもDOMツリーが切り貼りされる。Personal Titlebarがあるとタブバーのtabs要素もそういう処理の対象になる。ツールバーのカスタマイズに入るだけでもタブバーの各プロパティが初期化されてしまい、ツリー型タブが上書きしたメソッドも元に戻ってしまって、表示がグチャグチャにぶっ壊れる。これが衝突の真相だった。
これを回避するためには、単純に考えれば「ツールバーのカスタマイズに入る前にすべてを元に戻し、カスタマイズが終わったら再度初期化する」ということをやればいいということになる。でも話はそう簡単にはいかなかった。
beforecustomizationイベントは、普通の同期型のイベントとして発行されていて、このイベントを捕捉したすべてのイベントリスナの中で処理が終わった後、ツールバーカスタマイズのための本来の処理が始まるようになっている。「このアドオンの終了処理は無事に終わりました。ツールバーのカスタマイズを初めても大丈夫ですよ。」という事を、アドオンの側が明示的に通知する手段はない。単にイベントリスナの処理が終わったらその時点で「終了処理が終わった、もうカスタマイズを初めても大丈夫だ」と判断されるような単純な設計だ。
一方で、ツリー型タブはタブバーの表示を縦にしたり横にしたりとダイナミックな切り替えをする時に、タイマーを多用している。何故かというと、XULの属性値やCSSのプロパティを変更した後、そのイベントループ中では結果が適用されないままなので、現在のイベントループを一旦終了させた上で、setTimeout()
で続きの処理を次のイベントループ内で行わないといけない、という場面が多いからだ。
なので、beforecustomization イベントを捕捉した時点でタブバーの「終了処理」を行おうとしても、現在のイベントループの中でできる事までしか終了処理を終わらせられない。中途半端な状態でツールバーのカスタマイズに突入してしまう事になるし、しかも、次のイベントループに回すための続きの処理がツールバーのカスタマイズに突入した後で走ってしまうから、もうあっちもこっちも色々前提が崩れててシッチャカメッチャカな事になる。
必要なのは「今のイベントループの中で終了処理を完結させる」「次のイベントループを待つ必要がある終了処理を行う」この矛盾した2つをどうにかして両立させる事だ。
結論から言うと、XPCOMのスレッドの機能を使えばできる。
doAndWaitDOMEvent : function TSTUtils_doAndWaitDOMEvent() {
var type, target, delay, task;
Array.slice(arguments).forEach(function(aArg) {
switch(typeof aArg) {
case 'string': type = aArg; break;
case 'number': delay = aArg; break;
case 'function': task = aArg; break;
default: target = aArg; break;
}
});
if (!target || !type) {
if (task) task();
return;
}
var done = false;
var listener = function(aEvent) {
setTimeout(function() {
done = true;
}, delay || 0);
target.removeEventListener(type, listener, false);
};
if (task)
setTimeout(function() {
try {
task();
}
catch(e) {
dump(e+'\n');
target.removeEventListener(type, listener, false);
done = true;
}
}, 0);
target.addEventListener(type, listener, false);
var thread = Components
.classes['@mozilla.org/thread-manager;1']
.getService()
.mainThread;
while (!done)
{
thread.processNextEvent(true);
}
},
こんなユーティリティメソッドを作った。DOMイベントターゲット、そのターゲットに到達するはずのDOMイベント名、別のイベントループで実行されなくてはならない処理を含む関数、を引数として受け取り、渡された関数を実行して、今のスレッドの処理を止める。指定されたDOMイベントが発火したら、今のスレッドを止めるための無限ループから抜ける。これで、beforecustomization イベントのタイミングで複雑で長い終了処理を実行できるようになった。
Personal Titlebarというアドオン1つとの衝突を解消する事だけ考えるなら、タブバーだけは移動できないようにPersonal Titlebarのコードに対してさらに変更を加えてしまうという手もあった。ただ、それでは「既にPersonal Titlebarでタブバーの位置が変更されていた所にツリー型タブがインストールされた」という場合には意味がないし、そもそもFirefox本体の側がタブバーをカスタマイズで位置変更できるようにする可能性もある(Add-on SDKベースで作るアドオンなら互換性が担保されるから、ということでSDKベースでない既存のアドオンをバッサリ切り捨てる事になるような変更もFirefox本体に入れやすくなるから)。なので頑張ってみた。
ただ、こういう事が起こる原因になるから、自分が作るアドオンの中では極力DOMツリーはいじらないようにしたいとは、今も変わらず思ってる。高速化のために最上位のDOMツリーをゴッソリ入れ換えるのもNGだし、ましてや、WebページのbodyのinnnerHTML
を文字列として一括置換するなんてのは論外だ(そういう事をしても既存のイベントリスナに影響を与えないといった風に仕様でちゃんと定められていて実装もそうなっているのなら問題ない。仕様や実装がそう変わったんだったら高速化のためにそういう工夫を取り入れるのはむしろ奨励すべき事だとは思う)。
Firefox本体の側で行われた変更なら他のアドオンも追従するだろうけど、僕個人でこうして細々と開発しているアドオンのためにまで、わざわざ他のアドオン作者の人が頑張って対処してくれるとは思えない。僕だって、他の知らないアドオンのために事前に対処はできない。僕にできる事は、「自分が作る物については、他のアドオンに与える影響を小さくするようなるべく配慮する事」、ただそれしかない。
Firefox4 オワタ - alice0775のファイル置き場で既報だけど、Bug 588764 – Content area needs a grey border and shadow around itというバグのパッチでborderのためだけに新たにXULのボックスが追加された。
最初このパッチを見た時、僕は「なんじゃこりゃ」と思った。こんなもんレイアウト目的の空divと同じ発想じゃないか! 何やってんだ! こんなもんCSSのborderでやりゃいいじゃないか! と。(既にタブの中にもレイアウト調整用のボックスが入ってるけど、まあ、それはさておく。)
でも当該バグの最初の方についてるコメントや最初のバージョンのパッチでは、hbox#browser(ブラウズ領域のコンテナ要素)に対してCSSでborderを設定してるんだよね。それで分からなくなった。何でわざわざ、ボーダーのためだけにXUL要素を増やしたのか。そこには何か理由があるんじゃないのか? っていうかそもそも、この追加された要素に付いてるlayer="true"
ってのは何なんだ?
と疑問に思ったので議論の流れを追ってみたら、Bug 590468 – Reduce size of chrome document layer due to status barという別のバグが参照されていた。layerという属性もこのバグのパッチで導入されたらしい。
そっちの議論を読んでみたところ、どうもこういうことらしかった。
<vbox>
<toolbox/>
<hbox id="browser" style="background:transparent">
<browser/>
<hbox>
<hbox id="browser-bottombox/>
</vbox>
となっている。
layer="true"
が指定されたXUL要素は、強制的に専用の描画領域を持つようになる。最近はWebGLとかどんどんネイティブ寄りの所に突っ込んでいってパフォーマンス改善に注力してるようなので、こういう事もまあ必要なんだろう。Firefox 4リリースが近くて大規模な変更を入れられる余裕が無いから、インテリジェントな判断が必要な所について、インテリジェントな判断のためのロジックを実装する代わりに、人力でインテリジェントな判断をあらかじめ下しておこう、という苦肉の策のようだ。
そこで最初の話に戻るんだけど、単純にhbox#browserにCSSでborderを設定してしまうと、こういう事が起こってしまうという指摘がなされたようだ。
layer="true"
と指定する。
Alice0775さんが「やっつけ仕事」と評した事について、僕は最初は単に「空divのようなボックスの使い方」についてだけ言っていたのだと思ったんだけど、このような背景事情を知って、それではなくて「インテリジェントな判断をするためのロジックを組むという真っ当なやり方をせずに、人力で解決する」というアドホックな対応の事をこそ「やっつけ仕事」と評されていたのだと、やっと悟ったわけです。
という風にlayer="true"
が導入された背景を調べた事によって、アドオン作者も「Firefoxのウィンドウ内にXUL要素を追加すると、場合によっては最上位のvboxの描画領域が広がる事でパフォーマンスが低下してしまう」ということを意識しておかないといけないのだなあ、ということが分かった。ああ、もう、実に厄介な話だなあ……
Developers Conferenceでの発表向けの内容に盛り込めそうにない話を書いていく。
話のテーマを決めるにあたって、とにかく最初は1つJetpack SDKでアドオンを書いてみようと思ったんですよ。
でも、「こう書くと動きますよ」って言われてもなんだか信じられなかったというか、どういう経路でどういう風にしてそのコードが読み込まれるのか分からなくて、スクリプトの書き方次第じゃ他のコードに悪影響を及ぼしたりすることもあるんじゃないのか?(prototoype.jsのObject汚染みたいな感じで)とか、名前空間の衝突は大丈夫なのか? とか、そういうどーでもいい所が気になって気になって、仕組みが分からないまま使うのは怖いと思った。何をやってよくて、何をやったら駄目なのか、というのが分からないのが怖かった。
その境目を見極めたくて、Jetpack自体の成り立ちを探ってみたのですよ。
特に謎だったのが、他のモジュールを読み込むrequire()
。これが一体どこで処理されているのか。main.js(Jetpackでアドオンを作る時の主たる実装になるファイルの固定の名前)もどこかからrequire()
されているのなら、require()
の実装を見たら謎が解けるんじゃないかなーと思った。
exports
やrequire()
はここで定義されている。
EXPORTED_SYMBOLS
を使うやり方)にも対応してるみたい。Components.utils.evalInSandbox()
が呼ばれていることが分かった。ライブラリのモジュールもmain.jsも全部これで実行される。
require("chrome")
の実態が、これ。Jetpackとは、この基本的な仕組みに基づいて、個々のモジュールの名前空間を分け、それらを安全に連携できるようにしたもの。と言える。MozLabでmodule_manager.jsというものが過去に使われていて、それを想起させられた。
module_manager.jsはJavaScriptコードモジュールが無かった頃にJavaScriptそのものの言語仕様を超えた所で高度なモジュール化を行おうとしていたらしく、渡されたモジュールの名前からパスを生成してファイルを読み込んでサンドボックス内で実行してそのグローバルオブジェクトを保持し続けて……ということを普通のJavaScriptの機能とmozIJSSubScriptLoaderでやってた。Jetpackの仕組みはそれのずっとスマートなバージョンなのかなと思った。
今までの開発手法しか知らなくてJetpackがさっぱり分からないという僕みたいな時代後れの人でも、securable-module.jsとcuddlefish.jsを起点にすれば、他のモジュールも無理なく読んでいけるんじゃないだろうか。
僕がアドオン開発でXBLの利用を避けることが多いのは、XBLを使ってると、他のアドオンやサードパーティ製のテーマと衝突した時ににっちもさっちもいかなくなってしまうことが多かった(という印象が強い)り、複数のバージョンのFirefoxに対応しようと思うとドツボにハマったりしたからだ。
例えばツリー型タブのようなアドオンを作る時に、「タブのDOMノードに、子タブの一覧を取得するためのchildTabs
というプロパティを追加したいな」と思ったとする。思ったっていうか、タブブラウザ拡張でかつてツリー表示機能を実装した時には実際そうしてたんだけど。それをXBLでやるとこんな風になるだろう。
<binding id="tabbrowser-tab"
extends="chrome://browser/content/tabbrowser.xml#tabbrowser-tab">
<implementation>
<property name="childTabs" readonly="true">
<getter><![CDATA[
...
]]></getter>
</property>
</implementation>
</binding>
同時に、こういうCSSも書くことになる。
.tabbrowser-tab {
-moz-binding: url("mybinding.xml#tabbrowser-tab");
}
XBLではextends
で他のバインディング定義を継承することができる。chrome://browser/content/tabbrowser.xml というのは、Firefox 3.6でタブブラウズ関係の機能を定義してるバインディングなので、これで「Firefox本来のタブの機能に加えて新しい機能を定義する」という事が簡単にできる。
が、これは同時に欠点でもある。XULの機能のかなりの部分はXBLで定義されているので、extends
を書き忘れるとマトモに動かなくなってしまうことが結構ある。だから、独自のバインディングを適用する時は、適用先の要素に既にバインディングが適用されているかどうかを調べて、現在適用されているバインディングのURIを独自のバインディングのextends
に書いておかないといけない。
そして、「元々適用されているバインディングのURI」は簡単には同定できない。Firefoxのバージョンによっても変わるし、WindowsかMac OS Xかによっても変わるし、サードパーティ製のテーマでバインディングが上書きされているかもしれないし、アドオンがバインディングを適用しているかもしれない。ひょっとしたらユーザがuserChrome.cssでバインディングの指定を変えているかもしれない。継承の順番は、自分のXBL→tabbrowser.xml→tabbox.xml→general.xml かもしれないし、自分のXBL→テーマのXBL→tabbrowser.xml→tabbox.xml→general.xml かもしれないし、自分のXBL→他のアドオンのXBL→テーマのXBL→tabbrowser.xml→tabbox.xml→general.xml かもしれない。「こう書いておけば大丈夫」という単一の継承元のURIは存在しないのだ。
さらにタチが悪いことに、XBLの定義ファイルの中に書かれたextends
は動的には書き換えられない。現在適用されているバインディングのURIをgetComputedStyle()
で取得しても、それを後からXBLのextends
に指定するという事はできない。(ひょっとしたらdata: URLを使えばできるかもしれないけど、僕はそんなのやりたくないし見たくもない……)
だから、XBLを使うアドオン同士ではバインディングの優先権の取り合いになる。CSSは後から読み込まれたもの・セレクタの指定の詳細度が高いものほど優先的に適用されるから、後からインストールしたアドオンのせいでそれまで使っていたアドオンのバインディングが適用されなくなる、なんてこともしょっちゅうあった。タブブラウザのタブ要素なんて、激戦区中の激戦区と言っていいだろう。
そう考えると、ほんの2~3個のプロパティだとかメソッドだとかを追加するためだけにこれだけのリスクを負うのは到底割に合わない。それだったら、要素のDOMノードに直接プロパティを加えるのを諦めて、コントローラやサービスになるようなクラスを作ってそっちに必要な処理を集約させる方がずっと楽だ。(だからツリー型タブでは、前述の例のようなバインディングを使う代わりに、gBrowser.treeStyleTab.getChildTabs(aTab)
で子タブの配列を得るように設計した。)
というのが、僕の出した結論だった。
そういう話なので、僕は、「何が何でもXBLを使うな」とまで言うつもりはない。前述のようなバインディングの適用の優先権争いが起こらない場所、例えば独自のXULRunnerアプリケーションだったり、独自のサイドバーパネルだったり……という部分でなら、XBLはいくらでも使っていいと思ってる。
例えば、派生の要素型がたくさんあるようなケースではXBLの継承が威力を発揮する。会社で一時期やってたXULRunnerアプリのプロジェクトでは、継承を多用することで結構工数を削減できた(と思う)。また、anonymous contentsの追加は(特に、既に存在しているDOMツリーのノードの親子関係の間に安全に割り込むような物は)、XBLでなければできないことだ。
ただ、XBLは、1つのウィンドウの中にいろんな人が好き勝手に書いたコードが同居する「Firefoxのアドオン」という分野とは、すこぶる相性が悪い。JavaScriptで書く物でもCSSで書く物でも「最初にやった者勝ち」な所はどこかしらあるけれども、XBLの場合はそれが顕著だし、「後から来た者が上手く隙間に入り込む」という風な余地が全然無い。だから僕は、XBLをなるべく避ける形でコードを書くように努めてるんだな。
XBLはアドオン同士の衝突の原因になりやすい。だからXBLはあまり使わないように僕はしてる。
XBLを使うと、DOMノードにgetterやsetterになってるプロパティを定義したり、独自のメソッドを追加したりできる。でも、それらはJavaScriptのテクニックで代用できないこともない。JavaScriptのレベルで目的を達成するやり方として、僕は最近よく、こんな設計をしてる。
function MyController(aNode) {
this._node = aNode;
this.init();
}
MyController.prototype = {
get property() {
...
},
set property(aVaule) {
...
},
method : function() {
...
},
init : function() {
this._node.addEventListener('...', this, false);
...
},
destroy : function() {
this._node.removeEventListener('...', this, false);
...
},
handleEvent : function(aEvent) {
...
}
};
var node = document.createElement('box');
node.controller = new MyController(node);
かなりの部分はこういったやり方で目的を達成できると思う。ツリー型タブなんかもこれに近い実装になってる。
ただ、オートコンプリートのテキストボックスの挙動を変えるだとかの、本体で定義されている物を置き換える場面では、たまにこの方法だけでは不十分なことがある。例えば<textbox type="autocomplete" />
な要素のmaxDropMarkerRows
や<panel type="autocomplete" />
な要素のoverrideValue
やなんかはreadonlyなプロパティとしてXBLで定義されてしまっているので、これらが返す値を変えたいと思うと結構厄介な事になる。
XBLを使わないでサクッと済ませようとすると、__defineGetter__()
を使う方法がまずは思い浮かぶ。Firefox 3.0以降ではDOMノードに対して__defineGetter__()
を使えるので、上記の例のコードのinit()
あたりでそれを使ってやるという感じだ。実際、XUL/MigemoではoverrideValue
で任意の値を返すためにそうしてる。
でも、この方法はできれば使わない方がいいのかもなと思ってる。そう思ったきっかけは、同じようなことをやるコードの自動テストを書いていた時。setUpとtearDownで毎回ウィンドウを開いたり閉じたりとやってると時間がかかってしょうがないからUxU組み込みのフレームにページを読み込ませて……という風にしてみたら、セキュリティの制限に引っかかってしまった。object.__defineGetter__(name, getter)
のobjectとgetterの属してる名前空間が違うと、Illegal valueとか言われてエラーになってしまった。それでTrunkでの__defineGetter__()
の実装を見てみたら、この両者のコンパートメントが違う場合はゲッタの登録を拒否するような設計になってた。こういうセキュリティの制限を回避してreadonlyなプロパティの働きを置き換えようと思ったら、どうもやはり、XBLを使うしかないようだ。
そもそもなんでoverrideValue
がreadonlyなんだよ、なんで書き換え可能なただのフィールドになってないんだよ、って思って来歴を調べてみたら、nsIAutoCompletePopupインターフェースのoverrideValue
は昔のオートコンプリートの実装におけるgetOverrideValue()
メソッドがその祖先で、当時のコードには「こいつの働きを変えたかったらXBLでオーバーライドしろ」ってコメントが書いてあった。今のFirefoxのオートコンプリートの実装にはこのコメントがなかったので、なんでreadonlyになってるんだよという不満しか抱きようがなかった。getXXXとなってたメソッドをプロパティの形に置き換えるなら、確かにそれはreadonlyになるだろう。
でもどうせプロパティに変えたんだったらwritableにしたってよかったはず。ほんとに、何でこんな設計にするんだろう……