Как стать автором
Обновить

Firefox: приватные вкладки, разные API и расширения, не требующие перезапуска

Время на прочтение12 мин
Количество просмотров19K
Расширение Private Tab
Речь пойдет о моем расширении 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:
DOM Inspector

То есть наша задача – выпросить у чего-нибудь, относящегося к <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), но мы не ищем легких путей хотим сделать полноценное restartless расширение (есть еще упрощенный вариант: подключать к каждому окну скрипт, который будет выполняться для каждого окна заново, так проще в первом приближении портировать «традиционные» расширения, но и памяти это будет требовать больше), так что tab.ownerDocument.defaultView.gBrowser.getBrowserForTab(tab) смотрится как-то страшно. Тем более, что в view-source:chrome://browser/content/tabbrowser.xml (это ссылка такая, открывается, разумеется, только в Firefox) там всего лишь
      <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, получать окно из объекта обрабатываемого события.

Вот, пожалуй, и все. Надеюсь, этот поток без заголовков можно читать. Но заголовки, увы, никак не желали расставляться, пришлось предоставить текст самому себе.
Теги:
Хабы:
+28
Комментарии8

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн