
Речь пойдет о моем расширении Private Tab для Firefox и SeaMonkey: некоторые подробности реализации – и конкретно этого, и расширений, не требующих перезапуска (aka restartless), вообще.
Расширение добавляет приватные вкладки, для которых не будет сохраняться история и будет использоваться отдельный набор cookies.
К сожалению, попытки написать по-простому приводят к многочисленным отвлечениям от основной темы, так что на выходе получилась некая солянка на тему написания расширений. С другой стороны, совсем без отвлечений понятно будет только тем, кому все это уже не нужно.
Несколько сумбурно, но зато в логическом порядке. Надеюсь, так понятнее.
Начать, пожалуй, следует с того, что расширение – всего лишь надстройка над добавленным в Gecko 20 пооконным (или как там следует переводить per-window?) приватным режимом.
Без этих внутренних изменений ничего бы не вышло.
А изменений было много: «Bug 463027 depends on 1646 bugs».
К счастью, хоть получили мы только приватные окна, очень многое внутри сделано в расчете на возможность изменения приватности не только всего окна целиком, но и отдельных вкладок (ну, содержимого отдельных вкладок).
Это если изъясняться человекопонятно. Вообще, если не можешь описать что-то своими словами, следует поставить под сомнение понимание. Так что, хоть и обещаны были внутренности (кровь, кишки! и далее по тексту), но начну я с простого и (надеюсь) доступного.
А простое и понятное сводится к тому, что с каждым окном* связан интерфейс nsILoadContext (недокументированный, хе-хе – в том смысле, что есть только исходный код с комментариями), у которого теперь появилось свойство usePrivateBrowsing. И, что самое главное, весь прочий код учитывает значение этого свойства. Так что всю грязную работу сделали за нас (если честно, к сожалению, не всю).
*Окон на самом деле больше, чем кажется: в данном случае имеется в виду объект window, хорошо знакомый всем JavaScript-программистам. У каждого окна браузера есть такой объект, внутри которого сидит ничуть не менее известный document с самым настоящим DOM-деревом. А дальше с каждой вкладкой связан XUL browser, очень похожий на HTML iframe с уже своим окном, документом и DOM-деревом.
Вообще, тут лучше один раз увидеть: посмотреть на всю эту структуру вживую можно с помощью DOM Inspector'а (тут какая-то инструкция в картинках, или можно поставить Custom Buttons и мою кнопку Attributes Inspector, только для Gecko 20 нужна экспериментальная версия Custom Buttons).
Вот, например, тот самый XUL browser:

То есть наша задача – выпросить у чего-нибудь, относящегося к <browser>'у во вкладке, интерфейс nsILoadContext и переключить usePrivateBrowsing в false.
За выпрашивания отвечают два других интерфейса: nsISupports.QueryInterface() и nsIInterfaceRequestor.getInterface().
Но в данном случае о нас уже позаботились, и есть полезная статья Supporting per-window private browsing, из которой можно узнать про готовый модуль resource://gre/modules/PrivateBrowsingUtils.jsm, в котором уже реализован нужный нам метод:
privacyContextFromWindow: function pbu_privacyContextFromWindow(aWindow) {
return aWindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsILoadContext);
},
Есть, правда, другая тонкость: при изменении значения свойства usePrivateBrowsing в консоль ошибок пишет
Warning: Only internal code is allowed to set the usePrivateBrowsing attribute
Это Bug 800193 — Make the nsILoadContext.usePrivateBrowsing property read-only. К счастью, в результате пока что дело этим предупреждением и ограничивается.
Так что теперь нужно получить объект window, привязанный к вкладке.
Вообще, есть gBrowser.getBrowserForTab(tab), но мы
<method name="getBrowserForTab">
<parameter name="aTab"/>
<body>
<![CDATA[
return aTab.linkedBrowser;
]]>
</body>
</method>
Более того, такой подход много где используется, хоть и, кажется, не документирован.
Итак, теперь у нас есть (если по-честному, то у нас еще нет собственно вкладки, но об этом позже) ссылка на XUL browser вкладки, у которого, в свою очередь, есть свойство contentWindow, ссылающееся на нужный нам DOM window в браузере.
Так что получается что-то вроде вот этого:
var tab = ... // Будем считать, что вкладка у нас уже есть
Components.utils.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
var privacyContext = PrivateBrowsingUtils.privacyContextFromWindow(tab.linkedBrowser.contentWindow);
privacyContext.usePrivateBrowsing = true; // Сделаем вкладку приватной
Теперь, когда мы можем узнать, приватная вкладка или нет и переключить режим приватности для нее, разберемся собственно с вкладками.
Для начала откроем приватную вкладку.
Для открытия вкладок есть gBrowser.addTab(), но есть тонкость: если сначала открыть вкладку, а потом включить для нее приватный режим, браузер во вкладке успеет инициализироваться, и хоть ссылка и не сохранится в истории посещений, но cookies будут использоваться не приватные.
Чтобы понять, почему это происходит, надо посмотреть исходный код:
<method name="addTab">
<parameter name="aURI"/>
<parameter name="aReferrerURI"/>
<parameter name="aCharset"/>
<parameter name="aPostData"/>
<parameter name="aOwner"/>
<parameter name="aAllowThirdPartyFixup"/>
<body>
<![CDATA[
...
// Dispatch a new tab notification. We do this once we're
// entirely done, so that things are in a consistent state
// even if the event listener opens or closes tabs.
var evt = document.createEvent("Events");
evt.initEvent("TabOpen", true, false);
t.dispatchEvent(evt);
if (uriIsNotAboutBlank) {
...
try {
b.loadURIWithFlags(aURI, flags, aReferrerURI, aCharset, aPostData);
} catch (ex) {
Cu.reportError(ex);
}
}
К счастью, видна не только причина (начало загрузки по b.loadURIWithFlags()), но и решение: можно воспользоваться генерируемым событием TabOpen – оповещение приходит как раз до начала загрузки.
И тут уже код становится не так прозрачен, как хотелось бы. Основная идея выглядит так:
window.addEventListener("TabOpen", function waitForTab(e) {
window.removeEventListener(e.type, waitForTab, false);
var tab = e.originalTarget; // e.target не будет работать в SeaMonkey
makeTabPrivate(tab);
}, false);
gBrowser.selectedTab = gBrowser.addTab(); // Будет добавлена пустая вкладка
При этом event.originalTarget отличается от event.target только тем, что, в отличие от последнего, может ссылаться на анонимные узлы, созданные с помощью XBL (а в SeaMonkey как раз своя реализация <tabbrowser>'а).
Ну вот, теперь у нас есть одинокая приватная вкладка.
Ее нужно как-то визуально отделить от других. Я для этого добавляю вкладке атрибут privateTab-isPrivate="true" и меняю ее вид стилями.
Тут важно отметить, что следует избегать потенциальных коллизий с другими расширениями – для этого достаточно добавить всем классам, атрибутам и прочим сущностям отличительный префикс, в данном случае «privateTab-».
Чтобы далеко не отвлекаться, кратко про стили: используется nsIStyleSheetService.loadAndRegisterSheet(). Стили при этом применяются ко всем окнам вообще, так что лучше ограничить их с помощью @-moz-document.
Но из приватной вкладки можно открыть новую вкладку или даже окно. Эти новые вкладки и окна должны оставаться приватными!
Тут есть два пути.
Первый – тем или иным способом переопределить все или некоторые функции, открывающие вкладки. Можно сделать обертку (хороший путь), а можно применить eval-патч (куда больше возможностей, но и откатить его потом сложнее, и такой способ не рекомендуется).
Вообще, с отменой патчей и оберток все не так просто: другое расширение может наложить свой патч между нашим включением и выключением (мы же restartless!). А если это расширение делает eval-патч, то наша обертка его сломает – код-то изменился.
Здесь можно пойти на хитрость: переопределить методы toString() и toSource() функции-обертки (то есть чтобы обертка выглядела как модифицированная оригинальная функция), тогда сторонний патч, скорее всего, отработает нормально. Но теперь нас отключают и надо вернуть все как было. А было-то без патча! И, откатив на запомненное значение функции, мы сломаем все расширения, которые накладывали свои патчи после нас.
Но и тут можно схитрить: при модификации функции добавить в ее код вызов нашей глобальной (относительно окна, которому принадлежит функция, требующая модификации) функции, тогда при обнаружении сторонних правок можно будет устроить
Вот такое запутанное шаманство с обертками-патчерами. Звучит непонятно, да и код не лучше.
В простейшем случае получается вот такое:
Основной код (запоминание оригинала и переопределение toString()/toSource() опущено для демонстрации сути):
var orig = someObject.someMethod;
var patch = window["privateTabMod::someObject.someMethod"] = function() {
// Тут мы что-то делаем
};
someObject.someMethod = function wrapped() {
if(patch.apply(this, arguments))
return undefined;
return orig.apply(this, arguments);
};
И да, тут тоже важно за создавать в window глобальных свойств с именами, которые могут использоваться где-то еще.
При этом toString()/toSource() переопределяются и возвращают вот такое:
function wrapped() {
if(window["privateTabMod::someObject.someMethod"].apply(this, arguments))
return;
// Код оригинальной функции
}
Затем, когда нужно откатить наш патч, мы сравниваем запомненную модифицированную функцию с текущим значением someObject.someMethod. Если совпадают – просто меняем someObject.someMethod на запомненную оригинальную функцию, а если отличаются – меняем window["privateTabMod::someObject.someMethod"] на пустую функцию.
Такое вот длинное отступление.
И да, ничего принципиально плохого в eval-патчах нет, иногда другого способа просто не существует.
Но надо понимать, что когда можно обойтись оберткой, надо обойтись оберткой. Это и интерпретатором оптимизируется лучше, и сломать может только сторонний код, делающий eval-патчи.
Тут, конечно, самое время схватиться за голову и убежать, размахивая руками, но лучше подождать и почитать сначала вот это:
Five wrong reasons to use eval() in an extension by Wladimir Palant
Some reasons why I say that eval is not absolutely dangerous than other hacks и Why I'm using eval() instead of others? by YUKI «Piro» Hiroshi
Если кратко, то обертки ломают eval-патчи (без которых иногда никак не обойтись), а аккуратные eval-патчи не ломают ни другие патчи, ни обертки. Да, это потенциально куда более опасно, но опасно – не значит неприемлемо вообще.
А когда можно обойтись без патчей вообще, надо выбрать именно этот путь – он наименее конфликтный.
Если, конечно, нет желания потом разбираться с совместимостью с кучей разных расширений. И ведь не все расширения одинаково «прямые».
В данном случае есть очень простой способ, пусть и с некоторыми не вполне логичными эффектами: ранее мы уж использовали слушатель события «TabOpen», чтобы заполучить еще не инициализированную вкладку. Тут можно сделать то же самое: при открытии вкладки проверять приватность текущей вкладки, и если она приватная, то делать новую вкладку тоже приватной.
С окнами, в общем-то, аналогично (только нужно вручную получать замену для window.opener, потому как после открытия ссылки в новом окне свойство opener не устанавливается) и проверять приватность активной вкладки окна-родителя.
Но расширение у нас все же про вкладки, так что обойдемся без подробностей, тем более, что про отслеживание открывающихся окон в restartless расширениях еще будет сказана пара слов.
Итак, мы уже можем открыть приватную вкладку и все, что из нее открывается, – тоже приватное.
Основная работа позади, и начинаются приятные мелочи, из которых и строится usability. И порой этих мелочей куда больше, чем основного кода.
Во-первых, есть сохранение сессий и прочие перезапуски. Вкладки при этом должны оставаться приватными.
Для этого есть событие SSTabRestoring, чтобы отследить момент восстановления и интерфейс nsISessionStore, у которого нам понадобится метод persistTabAttribute().
А дальше все просто: надо при восстановлении вкладки проверить наличие ранее установленного атрибута privateTab-isPrivate. Если он есть – сделать вкладку приватной.
Это, конечно, далеко не все – «все» уже растянулось на 2100 строк, а мелочей куда больше, чем кажется – и смена заголовка окна, и смена кнопки приложения (которая рыжая такая, App button), если меню скрыто (кнопку, впрочем, меняет сам Firefox стилями для наличия атрибута privatebrowsingmode у корневого узла DOM-дерева главного окна), и добавление пунктов в разные меню, и обработка сочетаний клавиш (а встроенный XUL key ни разу не restartless), и обработка перетаскивания (можно же вытащить ссылку из приватной вкладки!), и невозможность перетаскивания вкладок между приватным и обычным окнами (а вот и патчи!), и… всего не упомнишь.
Но мы-то ценим свое время, время читателей
Теперь, когда мы разобрались с вкладками (а это можно сделать, не создавая расширения с помощью или уже упомянутого расширения Custom Buttons, или встроенными средствами, выставив devtools.chrome.enabled = true в about:config и запустив Веб разработка – Простой редактор JavaScript aka Scratchpad, Shift+F4 – Окружение – Браузер), пора засовывать наработки в расширение.
Но 2100 строк – плохой пример. И потому, что можно было бы порезать на модули (может быть и порежу, но пока выходит, что нарезка приводит к куче дополнительного кода), и потому, что за строками теряется тот минимум, который нужен, чтобы просто запустить что-нибудь простое.
Так что рассмотрим пример попроще: Tree Style Tab Tweaker (на всякий случай ссылка ведет на конкретную версию, а то вдруг тоже вздуется со временем).
Вообще, «Tweaker» – слишком громко сказано. Это штука для Tree Style Tab, реализующая это «исправление» (немного улучшенное) для issues #384. Суть в том, что при закрытии родительских вкладок закрытая вкладка будет заменяться на вкладку-пустышку для сохранения иерархии вкладок.
Как бы там ни было, так проще отследить основные моменты.
Во-первых, нужен классический install.rdf, без которого вообще ничего не получится, но с флагом «<em:bootstrap>true</em:bootstrap>».
И файл bootstrap.js рядом с install.rdf.
Далее все это упаковывается в обычный ZIP-архив, но с расширением xpi. И все! Ну, все – это когда нужный код уже написан.
Файл bootstrap.js должен обязательно определять специальные функции, которые будет вызывать менеджер дополнений при включении, выключении, запуске браузера и пр.
А дальше расширение все должно делать само: и окна отлавливать, и делать с ними что-нибудь, и убирать за собой тоже должно само.
Это, кстати, весьма печально, потому как API для упрощения этих манипуляций нет (пока нет, я надеюсь).
Тут, впрочем, нужна оговорка: есть еще Jetpack aka Add-on SDK. Почему не он? Лично мне так проще. Куда понятнее возиться с более или менее знакомыми интерфейсами и прочими API, чем изучать новые (причем, очевидно, более ограниченные). Еще там очень много абстракций в исходном коде, так что нормально работать можно только по документации (а она иногда запаздывает, это же open source). Вдобавок что-то не видать сложных расширений на Jetpack, что тоже наводит на мысли. Но если опыта написания расширений нет, наверное, проще начать с Add-on SDK.
Но вернемся к манипуляциям.
Мы будем использовать только функции startup() и shutdown().
Как можно догадаться из названия, startup() вызывается при запуске расширения – будь то запуск браузера или установка/включение, причина при этом передается вторым аргументом.
При включении могут быть уже открытые окна, которые следует обработать. Для этого существует nsIWindowMediator.getEnumerator(), там же есть пример использования.
Результат получается простой:
// Сначала подключим модуль для удобного доступа к различным сервисам:
Components.utils.import("resource://gre/modules/Services.jsm");
// ...
var ws = Services.wm.getEnumerator("navigator:browser");
while(ws.hasMoreElements())
this.initWindow(ws.getNext(), reason);
При этом «navigator:browser» – значение атрибута windowtype корневого узла (XUL window) главного окна браузера. Увидеть его можно с помощью все того же DOM Inspector'а.
Соответственно, initWindow() получает ссылку на окно и причину включения.
Но это только уже открытые окна – нужно еще отслеживать создание новых окон и закрытие уже существующих (чтобы почистить за собой и не устроить потоп).
Для этого воспользуемся методом nsIWindowWatcher.registerNotification().
При этом принимать оповещения может как функция, так и объект, реализующий интерфейс nsIObserver. Последнее удобнее: мы получим правильный this, ссылающийся на наш объект-namespace.
Снова воспользуемся модулем Services.jsm:
Services.ww.registerNotification(this);
Теперь метод observe(subject, topic, data) нашего объекта будет получать оповещения об открытии («domwindowopened» в topic) и закрытии («domwindowclosed») окон.
Однако, в момент получения оповещения «domwindowopened» нельзя узнать, что это за окно – в window.location.href будет about:blank, поэтому надо дождаться загрузки окна:
observe: function(subject, topic, data) {
if(topic == "domwindowopened") // subject - ссылка на окно
subject.addEventListener("load", this, false);
...
Здесь мы тоже воспользуемся трюком для передачи правильного this: слушателем событий может быть объект, реализующий интерфейс EventListener, так что при наступлении события будет вызван метод handleEvent(event) переданного объекта.
А у загрузившегося окна уже можно узнать и location, и тот же windowtype, который мы использовали, чтобы перебирать только нужные окна.
Таким образом, мы обрабатываем открытие и закрытие окон с требуемым windowtype.
А дальше нужно только сделать включение при открытии и очистку при закрытии.
В данном случае нам нужно только добавить слушателя события TabClose:
window.addEventListener("TabClose", this, false);
При наступлении события отработает handleEvent() -> tabCloseHandler(), ну а с вкладками мы уже работали.
Далее при выключении, удалении, обновлении или закрытии браузера будет вызвана функция shutdown(), и сработает windowsObserver.destroy(), с уже знакомым нам перебором всех открытых окон и отключением оповещений.
Так что все довольно просто. Сложности начинаются, когда возникает желание странного. Например, для добавления настраиваемых кнопок тоже нет API, так что получается регулярное велосипедирование в каждом restartless расширении.
Основная, пожалуй, сложность в том, что глобальный объект – вовсе не window, так что надо или создавать по обработчику на окно (все не так плохо, как кажется на первый взгляд: есть же прототипы), или, как это сделано в Private Tab, получать окно из объекта обрабатываемого события.
Вот, пожалуй, и все. Надеюсь, этот поток без заголовков можно читать. Но заголовки, увы, никак не желали расставляться, пришлось предоставить текст самому себе.