В прошлой статье я рассказывал, как написать расширение для браузера Google Chrome. Моя попытка перейти с Mozilla Firefox на Google Chrome, в силу моих личных пристрастий, не увенчалась успехом, поэтому я вернулся на свой любимый браузер. Итак, в этой статье я постараюсь рассказать о том, как написать add-on для Mozilla Firefox.
Для написания дополнений Firefox существуют два подхода: использование XUL и новый, рекомендуемый подход, с использованием легковесного JavaScript SDK.
В этой статье речь пойдёт об использовании второго подхода — JavaScript SDK. Сразу же приведу ссылки на tutorial`ы и guide`ы, где Вы сможете найти много полезной информации.
По заверениям Mozilla, новые API имеют ряд преимуществ:

  • простота в использовании высокоуровневого API;
  • Mozilla обещает обратную совместимость для всех будущих версий API;
  • тот же API будет использоваться в мобильной версии браузера;
  • повышенная безопасность;
  • использование опыта наработок Mozilla для повышения юзабилити;
  • больше нет необходимости перезапускать браузер после установки дополнения.

Однако, у XUL подхода есть свои собственные плюсы:

  • поддержка локализации;
  • прямой доступ XPCOM;
  • расширяемый пользовательский интерфейс.

Нужно отметить, что SDK поддерживает базовую локализацию, а доступ к XPCOM может осуществляться через низкоуровневый API.
Итак, я надеюсь вернуться к XUL как-нибудь в другой раз, а сейчас приступим к написанию расширения.

Сегодняшнее расширение будет помечать просмотренные видео на youtube`е.

SDK

Для того, чтобы писать расширения для Firefox нам понадобится пакет SDK, который можно скачать здесь. Кроме того, Вы можете воспользоваться онлайн IDE — Add-on Builder. На видео ниже приведена демонстрация Add-on Builder`а.

Я буду пользоваться скачанным пакетом SDK. После загрузки SDK Вам нужно распаковать архив в удобное место. В архиве, по большому счёту, нас интересует только файл bin/cfx (для пользователей UNIX-подобных ОС) или bincfx.bat для пользователей MS Windows. Я пользуюсь операционной системой Debian GNU/Linux. Я создал символьную ссылку на файл cfx в каталоге /usr/bin, чтобы не писать полный путь к скачанной папке.
Для того, чтобы ознакомиться со справкой программы cfx нужно запустить её без параметров (из консоли). Нас интересует секция

Supported Commands:
docs - view web-based documentation
init - create a sample addon in an empty directory
test - run tests
run - run program
xpi - generate an xpi

Здесь перечислены допустимые команды. Для того, чтобы создать заготовку проекта нужно запустить cfx с параметром init:

$ cfx init
* lib directory created
* data directory created
* test directory created
* doc directory created
* README.md written
* package.json written
* test/test-main.js written
* lib/main.js written
* doc/main.md written

Your sample add-on is now ready.
Do "cfx test" to test it and "cfx run" to try it. Have fun!

В текущем каталоге будет создана структура пакета дополнения:

youtubemarker
├── data
├── doc
│   └── main.md
├── lib
│   └── main.js
├── package.json
├── README.md
└── test
└── test-main.js

Сейчас нас интересуют два файла: package.json и lib/main.js. В первом файле сосредоточена информация о нашем дополнении

{
"name": "youtubemarker",
"fullName": "youtubemarker",
"description": "a basic add-on",
"author": "",
"license": "MPL 2.0",
"version": "0.1"
}

Мы можем вписать свои данные и дополнить объект необходимыми полями. Спецификация объекта приведена здесь.
Поле id будет создано при первом запуске нашего add-on`а.

$ cfx run
No 'id' in package.json: creating a new ID for you.
package.json modified: please re-run 'cfx run'
{
"name": "youtubemark",
"license": "MPL 2.0",
"author": "brainstream",
"version": "0.1",
"fullName": "YouTube Marker",
"id": "jid1-02UvWxeWz56WUA",
"description": "Marks viewed videos"
}

Если при запуске cfx run программа не смогла найти Ваш Firefox, то путь к его исполняемому файлу нужно указать в опции -b, например так:

$ cfx run -b /opt/firefox/firefox

Кроме опции -b нам понадобится использовать опцию -p, которая задаёт профиль, с которым запускается Firefox. Дело в том, что по умолчанию, Firefox запускается каждый раз со временным профилем и вся сохранённая информация теряется. Запустив cfx следующим образом:

$ cfx run -p profile

мы создадим новый профиль в каталоге profile, который будет располагаться в текущем каталоге. Все последующие запуски

$ cfx run -p profile

будут использовать этот профиль. Сюда Вы можете поставить те дополнения, которые Вам нужны для работы с Вашим дополнением, здесь же будут храниться куки и все данные, которые мы сохраним в процессе работы нашего дополнения.

Встраиваемый скрипт

Прейдём непосредственно к коду дополнения. В add-on`ах Firexox используется два вида скриптов: код в дополнении и встраиваемый в страницу код. Различия между ними состоят в доступе к тем или иным API.
Начнём со скрипта, который будет работать на странице youtube`а:

youTubeMarker = {
start: function() {
youTubeMarker.runListing();
youTubeMarker.processPage();
},

runListing: function() {
self.on("message", function(message) {
if(message == null) return;
switch(message.type) {
case "mark":
youTubeMarker.markElement(message.id);
break;
}
});
},

processPage: function() {
if(document.getElementById("watch-video-container") != null) {
youTubeMarker.processWatch();
} else if(document.getElementById("feed") != null) {
youTubeMarker.processPane("feed-item-container", "feed-video-title");
} else if(document.getElementById("browse-main-column") != null) {
youTubeMarker.processPane("browse-item", "yt-uix-sessionlink");
} else if(document.getElementById("search-main") != null) {
youTubeMarker.processPane("result-item-video", "yt-uix-sessionlink");
}
},

processWatch: function() {
self.postMessage({
type: "watch",
url: document.URL
});
},

processPane: function(itemClass, linkClass) {
var items = document.getElementsByClassName(itemClass);
for(var i in items) {
var links = items[i].getElementsByClassName(linkClass);
if(links.length > 0) {
var url = links[0].getAttribute("href");
var id = "youtube-marker-" + youTubeMarker.lastId++;
items[i].id = id;
youTubeMarker.requestMarking(url, id);
}
}
},

lastId: 0,

requestMarking: function(url, id) {
self.postMessage({
type: "mark",
url: url,
id: id
});
},

markElement: function(id) {
var element = document.getElementById(id);
if(element == null) return;
element.style.opacity = 0.4;
}
};

youTubeMarker.start();

Сохраним этот скрипт под именем content.js в каталоге data нашего add-on`а. В этом коде есть всего две особенности, которые выделятся из обычного JavaScript кода: использование методов self.on и self.postMessage. Эти методы используются для коммуникации между скриптом дополнения и встраиваемым скриптом. Метод on принимает первым параметром имя события, а вторым — обработчик. Метод postMessage отсылает скрипту дополнения JSON объект. К сожалению, более подробной документации по этим методам и объекту self мне найти не удалось.
Итак, скрипт запускается методом start, который организует подписку на сообщения от add-on`а и запускает парсинг страницы. Если страница является страницой просмотра видео, то скрипт отправляет сообщение о том, что видео просмотрено. Если же страница является списком (главная страница, обзор видео или результаты поиска), то для каждого контейнера устанавливается ID и отсылается URL видео с этим ID дополнению с запросом на пометку. Если запрос возвращается, то видео помечается полупрозрачностью.

Основной скрипт

Перейдём к скрипту main.js, основному скрипту дополнения.

var pageMod = require("page-mod");
var data = require("self").data;
var simpleStorage = require("simple-storage");
var querystring = require("api-utils/querystring");

pageMod.PageMod({
include: ["*.youtube.com"],
contentScriptFile: data.url("content.js"),
contentScriptWhen: "end",
onAttach: function(worker) {
worker.on("message", function(message) {
processMessage(worker, message);
});
}
});

function processMessage(worker, message) {
if(message == null) return;
switch(message.type) {
case "mark":
if(!isVideoWatched(message.url)) return;
worker.postMessage({
type: "mark",
id: message.id,
});
return;
case "watch":
saveWatchedUrl(message.url);
return;
}
}

function unifyUrl(url) {
var query = querystring.parse(url.split("?")[1]);
return query.v;
}

function isVideoWatched(url) {
return simpleStorage.storage[unifyUrl(url)] === true;
}

function saveWatchedUrl(url) {
simpleStorage.storage[unifyUrl(url)] = true;
}

Этот файл представляет куда больший интерес. В начале файла мы получаем ссылки на необходимые модули путём вызова функции require. Этой функции передаётся имя модуля высокоуровневого или низкоуровневого API.
Для хранения данных, переданных из встраиваемого скрипта, используется модуль simple-storage, который позволяет хранить данные точно так же, как и localStorage в DOM API. Мы вычленяем из URL идентификатор видео и сохраняем булеву переменную с этим именем в storage.
Объект PageMod из модуля page-mod позволяет запускать скрипты на страницах. Этот объект содержит шаблоны URL (include), на которых следует выполнять инъекцию, имя файла скрипта (contentScriptFile) или сам скрипт (contentScript). Кроме того, можно указать, в какой момент времени нужно запустить скрипт в опции contentScriptWhen, которая может принимать следующие значения:

  • «start»: скрипт выполнится сразу же, как элемент документа будет вставлен в DOM;
  • «ready»: скрипт будет выполнен после полной загрузки DOM;
  • «end»: запуск скрипта произойдёт после загрузки всего контента (DOM, JavaScript`ы, таблицы стилей и картинки).

Так же объект позволяет подписаться на события, которые происходят при аттаче скрипта и при ошибке. В примере приведён первый случай. На обработчике onAttach мы запускаем прослушивание сообщений от встроенного скрипта. В сообщении о подключении скрипта нам приходит объект Page модуля page-worker, через который возможна коммуникация со встроенным скриптом. Здесь нам доступны методы on и postMessage полностью идентичные тем, что мы использовали во встраиваемом скрипте из объекта self.
Адрес скрипта, хранящегося в каталоге data можно получить, использовав объект data из модуля self. Метод data.url() вернёт ссылку, которую мы вставляем в свойство contentScriptFile объекта PageMod.
Низкоуровневый модуль api-utils/querystring позволяет распарсить строку запроса в URL страницы.

Теперь наше дополнение готово к первому запуску. Запустите

$ cfx run -p profile

и посмотрите на youtube.com пару видео с вашего фида или из обзора. Вернитесь на страницу со списком и увидите, что просмотренные видео полупрозрачны.

Добавляем интерактивность

Add-on работает. Теперь нам хочется интерактивности. Давайте добавим пункт в контекстное меню, который будет отображаться при правом щелчке на блок с видео в списках.
Прежде чем приступить к описанию процесса создания меню, необходимо провести небольшой рефакторинг уже написанного кода: вынесем переменную worker из обработчика события onAttach в глобальную область видимости.

var globalWorker;

pageMod.PageMod({
include: ["*.youtube.com"],
contentScriptFile: data.url("content.js"),
contentScriptWhen: "end",
onAttach: function(worker) {
globalWorker = worker;
worker.on("message", function(message) {
processMessage(message);
});
}
});

function processMessage(message) {
if(message == null) return;
switch(message.type) {
case "mark":
if(!isVideoWatched(message.url)) return;
globalWorker.postMessage({
type: "mark",
id: message.id,
});
return;
case "watch":
saveWatchedUrl(message.url);
return;
}
}

Строки, в которые нужно внести правки, выделены полужирным шрифтом.

Теперь добавим пункт меню.

var menu = require("context-menu");
function createMenu() {
var containers = [
"feed-item-container",
"browse-item",
"result-item-video"
];
for(var i in containers) {
menu.Item({
label: "Mark/unmark as viewd",
context: [
menu.URLContext("*.youtube.com"),
menu.SelectorContext("." + containers[i] + " *")
],
data: containers[i],
contentScriptFile: data.url("markmenu.js"),
onMessage: function (message) {
processMenuMessage(message);
}
});
}
}

createMenu();

function processMenuMessage(message) {
if(message == null) return;
switch(message.type) {
case "mark":
saveWatchedUrl(message.url);
break;
case "unmark":
removeWatchedUrl(message.url);
break;
}
globalWorker.postMessage({
type: message.type,
id : message.id,
});
}

Кроме меню, нам понадобится функция для удаления записей из simpleStorage. Для того, чтобы удалить запись из simpleStorage достаточно применить к этой записи оператор delete.

function removeWatchedUrl(url) {
var unifiedUrl = unifyUrl(url);
if(simpleStorage.storage[unifiedUrl] === true) {
delete simpleStorage.storage[unifiedUrl];
}
}

Итак, наш пункт меню будет переключать состояние элемента с просмотренного на не просмотренное и наоборот. Для создания контекстных меню используется модуль context-menu. Мы хотим добавить один пункт меню, поэтому будем использовать объект класса Item. Кроме того, можно добавлять собственные вложенные меню, используя класс Menu. Из-за того, что мы имеем три разных списка, на элементах которых хотим отображать наш пункт меню, нам придётся создать три почти одинаковых объекта. Через свойство label задаётся отображаемый текст. Огромный интерес же, для нас, представляет свойство context. Это свойство задаёт один или более (как в нашем случае) контекст, для которого отображается пункт меню. Для задания контекста должны использоваться следующие функции модуля context-menu:

  • PageContext() не задаёт ограничений, меню будет отображаться на всех элементах страницы;
  • SelectionContext() отобразит меню, если пользователь сделал выделение;
  • SelectorContext(selector) даёт возможность задать CSS селектор тех эелементов, на которых будет отображаться меню;
  • URLContext(matchPattern) задаёт шаблон URL страниц, на которых отображается меню.

В опцию context можно передать либо один из указанных контекстов, либо их массив. Если передать массив контектов, то отображаться меню будет только в случае удовлетворения всем котекстам сразу.
В нашем случае нужно передать два контекста: первый — контекст на ограничение URL, а второй — на HTML элементы, которые находятся в контейнерах-элементах списков.
В поле data помещаются данные, которые будут переданы обработчику нажатия меню.
Обработчик onMessage принимает сообщение подобное  тому, что приходит из встраиваемого скрипта, с той лишь разницей, что поле type принимает два значения: «mark» и «unmark».
Для того, чтобы снять метку, нужно немного дописать метод markElement в файле content.js:

markElement: function(id, mark) {
var element = document.getElementById(id);
if(element == null) return;
element.style.opacity = mark === true ? 0.4 : 1.0;
}

Мы добавили флаг того, нужно ли поставить метку или снять её. С той же целью допишем метод runListing

runListing: function() {
self.on("message", function(message) {
if(message == null) return;
switch(message.type) {
case "mark":
youTubeMarker.markElement(message.id, true);
break;
case "unmark":
youTubeMarker.markElement(message.id, false);
break;
}
});
},

При создании пункта меню, в поле contentScriptFile мы указали новый файл — markmenu.js. Давайте взглянем на него.

youTubeMarkerMarkMenu = {
start: function() {
self.on("click", function(source, data) {
for(var element = source; element != null; element = element.parentElement) {
if(element.className.indexOf(data) < 0) continue;
var url = youTubeMarkerMarkMenu.findUrl(data, element);
if(url == null) return;
self.postMessage({
type: element.style.opacity == 0.4 ? "unmark" : "mark",
id: element.id,
url: url
});
return;
}
});
},

findUrl: function(className, container) {
var linkclass = null;
switch(className) {
case "feed-item-container":
linkclass = "feed-video-title";
break;
case "browse-item":
case "result-item-video":
linkclass = "yt-uix-sessionlink";
break;
default:
return null;
}
var links = container.getElementsByClassName(linkclass);
if(links.length < 1) return null;
return links[0].getAttribute("href");
}
};

youTubeMarkerMarkMenu.start();

Здесь мы видим уже знакомый нам метод self.on. Теперь он принимает сообщение click и два параметра: элемент, на котором произошёл клик и данные, записанные в параметр data при создании элемента меню. В эти данные мы записали класс контейнера, который необходимо найти. С помощью цикла ищем этот контейнер вверх по дереву. Найдя элемент, получаем URL видео и отправляем сообщение основному скрипту. Что происходит дальше Вы уже видели.

Создание пакета

После того, как Ваш add-on закончен, Вы можете создать *.xpi файл для установки его в браузер. Для этого введите команду

$ cfx xpi

и в Вашем рабочем каталоге появится файл с расширением xpi. Для команды

$ cfx xpi

так же можно использовать опцию -b, если cfx не смогла найти Вашего браузера.

Заключение

В этой статье я показал лишь очень малую часть того, что можно сделать с помощью Add-on SDK. Но надеюсь этого будет достаточно для того, чтобы понять общий подход к написанию дополнений для Mozilla Firefox.
Исходные тексты примера можно скачать отсюда.