Как написать свое расширение для браузера

В конце 2020 года мы делали проект со снежинками — писали специальный скрипт, который запускал падающий снег на сайтах. Если бы мы хотели сделать такой снег на любом своём сайте, это не составило бы труда: добавляешь скрипт в код страницы, и готово.

А вот на чужих сайтах была проблема. Скрипт нужно было вставлять через консоль. А если на сайте была настроена политика безопасности, которая запрещает запуск внешних скриптов, то магия не срабатывала. 

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

👉 Что такое расширение

Расширение для Chrome — это небольшая программа, которая выполняется внутри браузера и помогает расширить возможности сайтов. Сила расширения в том, что оно может выполняться прямо из меню браузера и не зависит от политик безопасности. 

Примеры того, что может сделать расширение: 

  • Сохранить ваши пароли и другие личные данные,
  • Выдрать из страницы скрытые данные (например, ссылку на скачивание музыки).
  • Менять что-то на странице, например, отключать рекламу, переводить текст, удалять ненужное.
  • Собирать статистику вашей работы в интернете.
  • Подключаться к другим сервисам (почте, чатам, файлообменникам) и взаимодействовать с ними из браузера.

В этой статье

Мы сделаем самое простое расширение для браузера Chrome, которое позволит запускать скрипт со снежинками на любом сайте, независимо от настроенной политики безопасности. Для этого воспользуемся официальным руководством Google по созданию расширений.

Манифест

В каждом расширении для браузера должен быть манифест — документ, в котором написано:

  • как называется расширение;
  • к чему расширение может получить доступ;
  • какие скрипты будут работать в фоне;
  • как должна выглядеть иконка расширения;
  • что показать или что выполнить, когда пользователь нажмёт на иконку расширения.

Манифест задаёт общие правила для всего расширения, поэтому манифест — единственный обязательный компонент. Можно обойтись без иконок и скриптов, но манифест обязательно должен быть.Каждый манифест хранится в файле manifest.json — создадим пустой файл с таким именем и напишем внутри такое:

{
«name»: «Запускаем снежинки на любом сайте»,
«description»: «Проект журнала Код»,
«version»: «1.0»,
«manifest_version»: 3
}

Первые две строчки — это название и подробное описание расширения. Третья отвечает за номер версии расширения, а последняя говорит браузеру, какая версия манифеста используется в описании. На момент выхода статьи в феврале 2021 года используется третья версия.

Сохраняем файл и всё, расширение готово. Оно ничего не умеет, ничего не делает, зато мы уже можем добавить его в браузер. Для этого запускаем Хром и в адресной строке пишем:

chrome://extensions/

Мы попадаем на страницу, которая нам покажет все установленные расширения:

Делаем своё расширение для браузера за 10 минут

Чтобы добавить своё расширение, в правом верхнем углу включаем режим разработчика, а затем нажимаем «Загрузить распакованное расширение»:

Делаем своё расширение для браузера за 10 минут

Теперь выбираем папку, в которой лежит наш манифест:

Делаем своё расширение для браузера за 10 минут

Отлично, мы только что добавили в браузер новое расширение:

Делаем своё расширение для браузера за 10 минут

Теперь мы можем обновлять наш манифест, класть в ту же папку дополнительные файлы, а для обновления в браузере достаточно будет нажать на круглую стрелку на карточке расширения.

Чтобы было проще работать и тестировать расширение, закрепим его на панели браузера:

Делаем своё расширение для браузера за 10 минут

Иконки

У расширения есть две иконки, которыми мы можем управлять: 

  1. Картинка в карточке расширения на странице настроек.
  2. Иконка на панели браузера.

Чтобы не рисовать всё с нуля, скачаем папку с иконками из того же руководства Google и положим её в ту же папку, что и манифест:

Теперь добавим иконки в манифест. За картинку в карточке отвечает блок icon, а за иконку на панели —  блок action. Разные размеры картинки нужны для того, чтобы на разных мониторах с любой плотностью пикселей иконки выглядели хорошо:

{
  "name": "Запускаем снежинки на любом сайте",
  "description": "Проект журнала Код",
  "version": "1.0",
  "manifest_version": 3,

  "action": {
    "default_icon": {
      "16": "/images/get_started16.png",
      "32": "/images/get_started32.png",
      "48": "/images/get_started48.png",
      "128": "/images/get_started128.png"
    }
  },
  "icons": {
    "16": "/images/get_started16.png",
    "32": "/images/get_started32.png",
    "48": "/images/get_started48.png",
    "128": "/images/get_started128.png"
  }
}

Сохраняем манифест, обновляем расширение на странице настроек и смотрим результат:

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

Настраиваем разрешения

Разрешения — это то, что браузер позволяет делать расширению со страницами и с их содержимым. Для запуска снежинок нам нужно сделать две вещи:

  1. Понять, какая вкладка сейчас активная, чтобы запустить снежинки именно на ней.
  2. Запустить наш скрипт со снежинками.

Чтобы получить доступ к активной вкладке и к запуску скриптов, добавим в манифест такую строку:

"permissions": ["activeTab", "scripting"],

Показываем меню

Если мы сейчас нажмём на иконку расширения на панели браузера, то ничего не призойдёт, потому что мы ничего не запрограммировали. Исправим это — сделаем так, чтобы при нажатии расширение показывало кнопку запуска. Когда расширение будет уметь больше, вместо одной кнопки можно будет показать целое меню.

Чтобы сделать всплывающее меню, добавим в манифест в раздел action такую строку:

    "default_popup": "popup.html",

Она означает, что при нажатии на иконку мы увидим рядом с ней мини-страничку, на которой что-то будет.Создадим в той же папке расширения файл popup.html и добавим в него такой код:

<!DOCTYPE html>
<html lang="ru">
  <head>
  	<meta charset="UTF-8">
    <style type="text/css">

    	/* задаём размеры кнопки и размер текста на кнопке  */
    	button {
	      font-size: 12px;
		  height: 40px;
		  width: 80px;
		}
    </style>
  </head>
  <body>
  	<!-- создаём кнопку на странице -->
    <button id="snow">Запустить снежинки</button>
    <!-- подключаем скрипт, который обработает нажатие на эту кнопку -->
    <script src="popup.js"></script>
  </body>
</html>

Чтобы браузер не ругался, что у нас нет файла popup.js, создадим пустой файл с таким названием и положим его в ту же папку:

Показываем меню расширения

Сохраняем манифест, обновляем его на странице настроек и видим, что у нашего расширения появилось меню с кнопкой:

Показываем меню расширения

Запускаем снежинки

Вся магия будет происходить в файле popup.js — откроем его и добавим такой код:

// получаем доступ к кнопке
let snow = document.getElementById("snow");
// когда кнопка нажата — находим активную вкладку и запускаем нужную функцию
snow.addEventListener("click", async () => {
  // получаем доступ к активной вкладке
  let [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  // выполняем скрипт
  chrome.scripting.executeScript({
  	// скрипт будет выполняться во вкладке, которую нашли на предыдущем этапе
    target: { tabId: tab.id },
    // вызываем функцию, в которой лежит запуск снежинок
    function: snowFall,
  });
});

// запускаем снег
function snowFall() {
}

Последнее, что нам осталось сделать, — положить в функцию snowFall() полный код скрипта из проекта со снежинками и сохранить файл.

Проверка

В прошлый раз мы не смогли запустить скрипт на любой странице Яндекса — мешала политика безопасности. Теперь всё работает:

Проверяем расширение

Скачать упакованное расширение. Перед установкой его нужно распаковать в любую папку.

В отличие от распространенной «клиент-серверной» архитектуры, для децентрализованных приложений характерно:

  • Отсутствие необходимости хранить базу данных с логинами и паролями пользователя. Информация для доступа хранится исключительно у самих пользователей, а подтверждение их достоверности происходит на уровне протокола.
  • Отсутствие необходимости использовать сервер. Логика приложения может выполняться в блокчейн-сети, где возможно и хранение необходимого количества данных.

Существует 2 относительно безопасных хранилища для ключей пользователей — хардверные кошельки и браузерные расширения. Хардверные кошельки в большинстве своем максимально безопасны, однако сложны в использовании и далеко не бесплатны, а вот браузерные расширения являются идеальным сочетанием безопасности и простоты в использовании, а еще могут быть совершенно бесплатны для конечных пользователей.

Учитывая все это, мы захотели сделать максимально безопасное расширение, которое упрощает разработку децентрализованных приложений, предоставляя простой API для работы с транзакциями и подписями.
Об этом опыте мы вам и расскажем ниже.

В статье будет пошаговая инструкция как написать браузерное расширение, с примерами кода и скриншотами. Весь код вы можете найти в репозитории. Каждый коммит логически соответствует разделу данной статьи.

Краткая история браузерных расширений

Браузерные расширения существуют достаточно давно. В Internet Explorer они появились еще в 1999-м году, в Firefox — в 2004-м. Тем не менее, очень долго не было единого стандарта для расширений.

Можно сказать, что он появился вместе с расширениями в четвертой версии Google Chrome. Конечно, никакой спецификации тогда не было, но именно API Chrome стал ее основой: завоевав большую часть рынка браузеров и имея встроенный магазин приложений, Chrome фактически задал стандарт для браузерных расширений.

У Mozilla был свой стандарт, но, видя популярность расширений для Chrome, компания решила сделать совместимый API. В 2015 году по инициативе Mozilla в рамках World Wide Web Consortium (W3C) была создана специальная группа для работы над спецификациями кроссбраузерных расширений.

За основу был взят уже существующий API расширений для Сhrome. Работа велась при поддержке Microsoft (Google в разработке стандарта участвовать отказался), и в результате появился черновик спецификации.

Формально спецификацию поддерживают Edge, Firefox и Opera (заметьте, что в этом списке отсутствует Chrome). Но на самом деле стандарт во многом совместим и с Chrome, так как фактически написан на основе его расширений. Подробнее о WebExtensions API можно прочитать здесь.

Структура расширения

Единственный файл, который обязательно нужен для расширения — манифест (manifest.json). Он же является “точкой входа” в расширение.

Манифест

По спецификации файл манифеста является валидным JSON файлом. Полное описание ключей манифеста с информацией о том, какие ключи в поддерживается в каком браузере, можно посмотреть здесь.

Ключи, которых нет в спецификации, “могут” быть проигнорированы (и Chrome, и Firefox пишут об ошибках, но расширения продолжают работать).

А я бы хотел обратить внимание на некоторые моменты.

  1. background — объект, который включает в себя следующие поля:
    1. scripts — массив скриптов, которые будут выполнены в background-контексте (поговорим об этом чуть позже);
    2. page — вместо скриптов, которые будут выполнятся в пустой странице, можно задать html с контентом. В этом случае поле script будет проигнорировано, а скрипты нужно будет вставить в страницу с контентом;
    3. persistent — бинарный флаг, eсли не указан, то браузер будет «убивать» background-процесс, когда посчитает, что он ничего не делает, и перезапускать при необходимости. В противном случае страница будет выгружена только при закрытии браузера. Не поддерживается в Firefox.
  2. content_scripts — массив объектов, позволяющий загружать разные скрипты к разным веб страницам. Каждый объект содержит следующие важные поля:
    1. matches — паттерн url, по которому определяется, будет включаться конкретный content script или нет.
    2. js — список скриптов которые будут загружены в данный матч;
    3. exclude_matches — исключает из поля match URL, которые удовлетворяют этому полю.
  3. page_action — фактически является объектом, который отвечает за иконку, которая отображается рядом с адресной строкой в браузере, и взаимодействие с ней. Позволяет так же показывать popup окно, которое задается с помощью своих HTML, CSS и JS.
    1. default_popup — путь до HTML файла с popup-интерфейсом, может содержать CSS и JS.
  4. permissions — массив для управления правами расширения. Существует 3 типа прав, которые подробно описаны тут
  5. web_accessible_resources — ресурсы расширения, которые может запрашивать веб страница, например, изображения, файлы JS, CSS, HTML.
  6. externally_connectable — здесь можно явно указать ID других расширений и домены веб-страниц, с которых можно подключаться. Домен может быть второго уровня и выше. Не работает в Firefox.

Контекст выполнения

У расширения есть три контекста исполнения кода, то есть, приложение состоит из трех частей с разным уровнем доступа к API браузера.

Extension context

Здесь доступна большая часть API. В этом контексте «живут»:

  1. Background page — “backend” часть расширения. Файл указывается в манифесте по ключу “background”.
  2. Popup page — popup страница, которая появляется при нажатии на иконку расширения. В манифесте browser_action -> default_popup.
  3. Custom page — страница расширения, «живущая» в отдельной вкладке вида chrome-extension://<id_расширения>/customPage.html.

Этот контекст существует независимо от окон и вкладок браузера. Background page существует в единственном экземпляре и работает всегда (исключение — event page, когда background-скрипт запускается по событию и «умирает» после его выполнения). Popup page существует, когда открыто окно popup, а Custom page — пока открыта вкладка с ней. Доступа к другим вкладкам и их содержимому из этого контекста нет.

Content script context

Файл контент-скрипта запускается вместе с каждой вкладкой браузера. У него есть доступ к части API расширения и к DOM-дереву веб-страницы. Именно контент-скрипты отвечают за взаимодействие со страницей. Расширения, манипулирующие DOM-деревом, делают это в контент-скриптах – например, блокировщики рекламы или переводчики. Также контент-скрипт может общаться со страницей через стандартный postMessage.

Web page context

Это собственно сама веб-страница. К расширению она не имеет никакого отношения и доступа туда не имеет, кроме случаев, когда в манифесте явно не указан домен этой страницы (об этом — ниже).

Обмен сообщениями

Разные части приложения должны обмениваться сообщениями между собой. Для этого существует API runtime.sendMessage для отправки сообщения background и tabs.sendMessage для отправки сообщения странице (контент-скрипту, popup’у или веб странице при наличии externally_connectable). Ниже приведен пример при обращении к API Chrome.

// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};

// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);

// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))

// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)

// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
    {currentWindow: true, active : true},
    function(tabArray){
      tabArray.forEach(tab => console.log(tab.id))
    }
)

Для полноценного общения можно создавать соединения через runtime.connect. В ответ мы получим runtime.Port, в который, пока он открыт, можно отправлять любое количество сообщений. На стороне клиента, например, contentscript, это выглядит так:

// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
    if (msg.question === "Who's there?")
        port.postMessage({answer: "Madame"});
    else if (msg.question === "Madame who?")
        port.postMessage({answer: "Madame... Bovary"});

Сервер или background:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, popup или страниц расширения
chrome.runtime.onConnect.addListener(function(port) {
    console.assert(port.name === "knockknock");
    port.onMessage.addListener(function(msg) {
        if (msg.joke === "Knock knock")
            port.postMessage({question: "Who's there?"});
        else if (msg.answer === "Madame")
            port.postMessage({question: "Madame who?"});
        else if (msg.answer === "Madame... Bovary")
            port.postMessage({question: "I don't get it."});
    });
});

// Обработчик для подключения внешних вкладок. Других расширений или веб страниц, которым разрешен доступ в манифесте
chrome.runtime.onConnectExternal.addListener(function(port) {
    ...
});

Также есть событие onDisconnect и метод disconnect.

Схема приложения

Давайте сделаем браузерное расширение, которое хранит приватные ключи, предоставляет доступ к публичной информации (адрес, публичный ключ общается со страницей и позволяет сторонним приложениям запросить подпись транзакций.

Разработка приложения

Наше приложение должно как взаимодействовать с пользователем, так и предоставлять странице API для вызова методов (например, для подписи транзакций). Обойтись одним лишь contentscript не получится, так как у него есть доступ только к DOM, но не к JS страницы. Подключаться через runtime.connect мы не можем, потому что API нужен на всех доменах, а в манифесте можно указывать только конкретные. В итоге схема будет выглядеть так:

Будет еще один скрипт — inpage, который мы будем инжектить в страницу. Он будет выполняться в ее контексте и предоставлять API для работы с расширением.

Начало

Весь код браузерного расширения доступен на GitHub. В процессе описания будут ссылки на коммиты.

Начнем с манифеста:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

Создаем пустые background.js, popup.js, inpage.js и contentscript.js. Добавляем popup.html — и наше приложение уже можно загрузить в Google Chrome и убедиться, что оно работает.

Чтобы убедиться в этом, можно взять код отсюда. Кроме того, что мы сделали, по ссылке настроена сборка проекта с помощью webpack. Чтобы добавить приложение в браузер, в chrome://extensions нужно выбрать load unpacked и папку с соответствующим расширением — в нашем случае dist.

Теперь наше расширение установлено и работает. Запуститьинструменты для разработчиков для разных контекстов можно следующим образом:

popup ->

Доступ к консоли контент-скрипта осуществляется через консоль самой страницы, на которой он запущен.

Обмен сообщениями

Итак, нам необходимо установить два канала связи: inpage <-> background и popup <-> background. Можно, конечно, просто отправлять сообщения в порт и изобрести свой протокол, но мне больше нравится подход, который я подсмотрел в проекте с открытым кодом metamask.

Это браузерное расширение для работы с сетью Ethereum. В нем разные части приложения общаются через RPC при помощи библиотеки dnode. Она позволяет достаточно быстро и удобно организовать обмен, если в качестве транспорта ей предоставить nodejs stream (имеется в виду объект, реализующий тот же интерфейс):

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Теперь мы создадим класс приложения. Оно будет создавать объекты API для popup и веб-страницы, а также создавать dnode для них:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(remote)
        })
    }

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(origin);
            console.log(remote)
        })
    }
}

Здесь и далее вместо глобального объекта Chrome мы используем extentionApi, который обращается к Chrome в браузере от Google и к browser в других. Делается это для кроссбраузерности, но в рамках данной статьи можно было бы использовать и просто ‘chrome.runtime.connect’.

Создадим инстанс приложения в background скрипте:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Так как dnode работает со стримами, а мы получаем порт, то необходим класс-адаптер. Он сделан при помощи библиотеки readable-stream, которая реализует nodejs-стримы в браузере:

import {Duplex} from 'readable-stream';

export class PortStream extends Duplex{
    constructor(port){
        super({objectMode: true});
        this._port = port;
        port.onMessage.addListener(this._onMessage.bind(this));
        port.onDisconnect.addListener(this._onDisconnect.bind(this))
    }

    _onMessage(msg) {
        if (Buffer.isBuffer(msg)) {
            delete msg._isBuffer;
            const data = new Buffer(msg);
            this.push(data)
        } else {
            this.push(msg)
        }
    }

    _onDisconnect() {
        this.destroy()
    }

    _read(){}

    _write(msg, encoding, cb) {
        try {
            if (Buffer.isBuffer(msg)) {
                const data = msg.toJSON();
                data._isBuffer = true;
                this._port.postMessage(data)
            } else {
                this._port.postMessage(msg)
            }
        } catch (err) {
            return cb(new Error('PortStream - disconnected'))
        }
        cb()
    }
}

Теперь создаем подключение в UI:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

Затем мы создаем подключение в content script:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import PostMessageStream from 'post-message-stream';

setupConnection();
injectScript();

function setupConnection(){
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Так как API нам нужен не в контент-скрипте, а непосредственно на странице, мы делаем две вещи:

  1. Создаем два стрима. Один — в сторону страницы, поверх postMessage. Для этого мы используем вот этот пакет от создателей metamask. Второй стрим — к background поверх порта, полученного от runtime.connect. Пайпим их. Теперь у страницы будет стрим до бэкграунда.
  2. Инжектим скрипт в DOM. Выкачиваем скрипт (доступ к нему был разрешен в манифесте) и создаем тег script с его содержимым внутри:

import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Теперь создаем объект api в inpage и заводим его global:

import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

У нас готов Remote Procedure Call (RPC) с отдельным API для страницы и UI. При подключении новой страницы к background мы можем это увидеть:

Пустой API и origin. На стороне страницы мы можем вызвать функцию hello вот так:

Работать с callback-функциями в современном JS — моветон, поэтому напишем небольшой хелпер для создания dnode, который позволяет передавать в объект API в utils.

Объекты API теперь будут выглядеть вот так:

export class SignerApp {

    popupApi() {
        return {
            hello: async () => "world"
        }
    }

...

}

Получение объекта от remote следующим образом:

import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // С помощью утилит меняем все callback на promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

А вызов функций возвращает промис:

Версия с асинхронными функциями доступна здесь.

В целом, подход с RPC и стримами кажется достаточно гибким: мы можем использовать steam multiplexing и создавать несколько разных API для разных задач. В принципе, dnode можно использовать где угодно, главное — обернуть транспорт в виде nodejs стрима.

Альтернативой является формат JSON, который реализует протокол JSON RPC 2. Однако он работает с конкретными транспортами (TCP и HTTP(S)), что в нашем случае не применимо.

Внутренний стейт и localStorage

Нам понадобится хранить внутренний стейт приложения — как минимум, ключи для подписи. Мы можем достаточно легко добавить стейт приложению и методы для его изменения в popup API:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

    removeKey(index){
        this.store.keys.splice(index,1)
    }

    popupApi(){
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index)
        }
    }

    ...

} 

В background обернем все в функцию и запишем объект приложения в window, чтобы можно было с ним работать из консоли:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const app = new SignerApp();

    if (DEV_MODE) {
        global.app = app;
    }

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url;
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Добавим из консоли UI несколько ключей и посмотрим, что получилось со стейтом:

Стейт нужно сделать персистентным, чтобы при перезапуске ключи не терялись.

Хранить будем в localStorage, перезаписывая при каждом изменении. Впоследствии доступ к нему также будет необходим для UI, и хочется также подписываться на изменения. Исходя из этого удобно будет сделать наблюдаемое хранилище (observable storage) и подписываться на его изменения.

Использовать будем библиотеку mobx (https://github.com/mobxjs/mobx). Выбор пал на нее, так как работать с ней не приходилось, а очень хотелось ее изучить.

Добавим инициализацию начального стейта и сделаем store observable:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

    @action
    removeKey(index) {
        this.store.keys.splice(index, 1)
    }

    ...

}

«Под капотом» mobx заменил все поля store на proxy и перехватывает все обращения к ним. На эти обращения можно будет подписываться.

Далее я буду часто использовать термин “при изменении”, хотя это не совсем корректно. Mobx отслеживает именно доступ к полям. Используются геттеры и сеттеры прокси-объектов, которые создает библиотека.

Декораторы action служат двум целям:

  1. В строгом режиме с флагом enforceActions mobx запрещает менять стейт напрямую. Хорошим тоном считается работа именно в строгом режиме.
  2. Даже если функция меняет стейт несколько раз – например, мы меняем несколько полей в несколько строк кода, — обсерверы оповещаются только по ее завершении. Это особенно важно для фронтенда, где лишние обновления стейта приводят к ненужному рендеру элементов. В нашем случае ни первое, ни второе особо не актуально, однако мы будем следовать лучшим практикам. Декораторы принято вешать на все функции, которые меняют стейт наблюдаемых полей.

В background добавим инициализацию и сохранение стейта в localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Интересна здесь функция reaction. У нее два аргумента:

  1. Селектор данных.
  2. Обработчик, который будет вызван с этими данными каждый раз, когда они изменяются.

В отличие от redux, где мы явно получаем стейт в качестве аргумента, mobx запоминает к каким именно observable мы обращаемся внутри селектора, и только при их изменении вызывает обработчик.

Важно понимать, как именно mobx решает, на какие observable мы подписываемся. Если бы в коде я написал селектор вот так() => app.store, то reaction не будет вызван никогда, так как сам по себе хранилище не является наблюдаемым, таковыми являются только его поля.

Если бы я написал вот так () => app.store.keys, то опять ничего не произошло бы, так как при добавлении/удалении элементов массива ссылка на него меняться не будет.

Mobx в первый раз выполняет функцию селектора и следит только за теми observable, к которым мы получали доступ. Сделано это через геттеры прокси. Поэтому здесь использована встроенная функция toJS. Она возвращает новый объект, в котором все прокси заменены на оригинальные поля. В процессе выполнения она читает все поля объекта – следовательно, срабатывают геттеры.

В консоли popup снова добавим несколько ключей. На этот раз они попали еще и в localStorage:

При перезагрузке background-страницы информация остается на месте.

Весь код приложения до этого момента можно посмотреть здесь.

Безопасное хранение приватных ключей

Хранить приватные ключи в открытом виде небезопасно: всегда есть вероятность того, что вас взломают, получат доступ к вашему компьютеру и так далее. Поэтому в localStorage мы будем хранить ключи в зашифрованном паролем виде.

Для большей безопасности добавим приложению стейт locked, в котором доступа к ключам не будет совсем. Мы будем автоматически переводить расширение в стейт locked по таймауту.

Mobx позволяет хранить только минимальный набор данных, а остальное автоматически рассчитывать на их основе. Это — так называемые computed properties. Их можно сравнить с view в базах данных:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

    // private
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

Теперь мы храним только шифрованные ключи и пароль. Все остальное вычисляется. Перевод в стейт locked мы делаем с помощью удаления пароля из стейта. В публичном API появился метод для инициализации хранилища.

Для шифрования написаны утилиты с использованием сrypto-js:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

У браузера есть idle API, через который можно подписаться на событие — изменения стейта. Стейт, соответственно, может быть idle, active и locked. Для idle можно настроить таймаут, а locked устанавливается, когда блокируется сама ОС. Также мы поменяем селектор для сохранения в localStorage:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';
const IDLE_INTERVAL = 30;

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Код до этого шага находится здесь.

Транзакции

Итак, мы подошли к самому главному: созданию и подписи транзакций в блокчейне. Мы будем использовать блокчейн WAVES и библиотеку waves-transactions.

Для начала добавим в стейт массив сообщений, которые необходимо подписать, затем — методы добавления нового сообщения, подтверждения подписи и отказа:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

При получении нового сообщения мы добавляем в него метаданные, делаем observable и добавляем в store.messages.

Если не сделать observable вручную, то mobx сделает это сам при добавлении в массив messages. Однако он создаст новый объект, на который у нас не будет ссылки, а она понадобится для следующего шага.

Далее мы возвращаем промис, который резолвится при изменении статуса сообщения. За статусом следит reaction, который сам себя «убьет» при смене статуса.

Код методов approve и reject очень прост: мы просто меняем статус сообщения, предварительно подписав его, если необходимо.

Approve и reject мы выносим в API UI, newMessage — в API страницы:

export class SignerApp {
    ...
    popupApi() {
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index),

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

Теперь попробуем подписать транзакцию расширением:

В целом все готово, осталось добавить простой UI.

UI

Интерфейсу нужен доступ к стейту приложения. На стороне UI мы сделаем observable стейт и добавим в API функцию, которая будет этот стейт менять. Добавим observable в объект API, полученный от background:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

    if (DEV_MODE) {
        global.background = background;
    }

    // Запуск интерфейса
    await initApp(background)
}

В конце мы запускаем рендер интерфейса приложения. Это react-приложение. Background-объект просто передается при помощи props. Правильно, конечно, сделать отдельный сервис для методов и store для стейта, но в рамках данной статьи этого достаточно:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

С помощью mobx очень просто запускать рендер при изменении данных. Мы просто вешаем декоратор observer из пакета mobx-react на компонент, и рендер будет автоматически вызываться при изменении любых observable, на которые ссылается компонент. Не нужно никаких mapStateToProps или connect, как в redux. Все работает сразу «из коробки»:

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

Остальные компоненты можно посмотреть в коде в папке UI.

Теперь в классе приложения необходимо сделать селектор стейта для UI и при его изменении оповещать UI. Для этого добавим метод getState и reaction, вызывающий remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

При получении объекта remote создается reaction на изменение стейта, который вызывает функцию на стороне UI.

Последний штрих — добавим отображение новых сообщений на иконке расширения:

function setupApp() {
...

    // Reaction на выставление текста беджа.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Итак, приложение готово. Веб-страницы могут запрашивать подпись транзакций:

Код доступен по этой ссылке.

Заключение

Если вы дочитали статью до конца, но у вас остались вопросы, вы можете задать их в репозитории с расширением. Там же вы найдете коммиты под каждый обозначенный шаг.

А если вам интересно посмотреть код настоящего расширения, то вы сможете найти это здесь.

Код, репозиторий и описание работы от siemarell

How to Write Your Own Browser Extension [Example Project Included]

In this article we will talk about Browser extensions – what they are, how they work, and how you can build your own.

We will finish by actually writing our own extension (Super Fun!) which allows us to copy any code snippet to our clipboard with a click of a single button.

To continue with this article:

  • You need to have a basic understanding of JavaScript.
  • You need the Firefox browser (or any other browser will also work)

What is a Browser Extension?

A browser extension is something you add to your browser which enhances your browsing experience by extending the capacity of your browser.

As an example, think about an ad blocker which you might have installed on your device. This makes your browsing experience better by blocking ads when you surf the web.

Now let’s start by writing a very basic extension.

To begin, we’ll create a folder inside which we create a file named manifest.json.

What is a manifest file?

A manifest file is a must have file in any browser extension. This file contains basic data about our extension like name, version, and so on.

Now inside the manifest.json file copy the following snippet:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"Test",
}

How to load the extension file

For Firefox users, follow these steps:

In the address bar, search for this:

about:debugging#/runtime/this-firefox

You will see an option to Load Temporary Add-on. Click on that option and choose the manifest.json file from the directory.

For Chrome users:

In the address bar search for this:

chrome://extensions.
  • Enable Developer Mode and switch into it.
  • Click the Load unpacked button and select the extension directory.

Hurray! You’ve installed the extension successfully. But the extension doesn’t do anything currently. Now let’s add some functionality to our extension. To do this, we’ll edit our manifest.json file like this:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"Test",
  "content_scripts":[
    {
     "matches":["<all_urls>"],
     "js":["main.js"]
    }
  ]
}

In the above code, we added a content script to manifest.json. Content scripts can manipulate the Document Object Model of the web page. We can inject JS (and CSS) into a web page using a content script.

"matches" contains the list of domains and subdomains where the content script should be added and js is an array of the JS files to be loaded.

Now inside the same directory create a main.js file and add the following code:

alert("The test extension is up and running")

Now reload the extension and when you visit any URLs you will see an alert message.

Don’t forget to reload the extension anytime you edit the code.

How to Customize Your Browser Extension

Now let’s have some more fun with our extension.

What we are going to do now is create a web extension that changes all the images of a webpage we visit to an image we choose.

For this, just add any image to the current directory and change the main.js file to:

console.log("The extension is up and running");

var images = document.getElementsByTagName('img')

for (elt of images){
   elt.src = `${browser.runtime.getURL("pp.jpg")}`
   elt.alt = 'an alt text'
}

Let’s see whats going on here:

var images = document.getElementsByTagName('img')

This line of code selects all the elements in the web page with the img tag .

Then we loop through the array images using a for loop where we change the src attribute of all the img elements to a URL with the help of the runtime.getURL function.

Here pp.jpg is the name of the image file in the current directory in my device.

We need to inform our content script about the pp.jpg file by editing the manifest.json file to:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"Test",
  "content_scripts":[
   {
    "matches":["<all_urls>"],
    "js":["main.js"]
   }
  ],
  "web_accessible_resources": [
        "pp.jpg"
  ]
}

Then just reload the extension and visit any URL you like. Now you should see all the images being changed to the image which is in your current working directory.

How to add icons to your extension

Add the following code in the manifest.json file:

"icons": {
  "48": "icon-48.png",
  "96": "icon-96.png"
}

How to add a toolbar button to your extension

Now we’ll add a button located in the toolbar of your browser. Users can interact with the extension using this button.

To add a toolbar button, add the following lines to the manifest.json file:

"browser_action":{
   "default_icon":{
     "19":"icon-19.png",
     "38":"icon-38.png"
   }
  }

All the image files should be present in your current directory.

Now, if we reload the extension we should see an icon for our extension in the toolbar of our browser.

How to add listening events for the toolbar button

Maybe we want to do something when a user clicks the button – let’s say we want to open a new tab every time the button is clicked.

For this, we’ll again add the following to the manifest.json file:

"background":{
        "scripts":["background.js"]
  },
  "permissions":[
      "tabs"
  ]

Then we’ll create a new file named background.js in the current working directory and add the following lines in the file:

function openTab(){
    
    var newTab = browser.tabs.create({
        url:'https://twitter.com/abhilekh_gautam',
        active:true
    })
}

browser.browserAction.onClicked.addListener(openTab)

Now reload the extension!

Whenever someone clicks the button, it calls the openTab function which opens a new tab with the URL that links to my twitter profile. Also, the active key, when set to true, makes the newly created tab the current tab.

Note that you can use APIs provided by browsers in the background script. For more about APIs refer to the following article: Javacript APIs.

Now that we’ve learned some of the basics of browser extensions, let’s create an extension that we as developers can use in our daily lives.

Final Project

Alright, now we’re going to write something that will be useful for us in daily life. We’ll create an extension that allows you to copy code snippets from StackOverflow with a single click. So our extension will add a Copy button to the webpage which copies the code to our clipboard.

Demo

demo

First we’ll create a new folder/directory, inside which we’ll add a manifest.json file.

Add the following code to the file:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"copy code",
  "content_scripts":[
    {
     "matches":["*://*.stackoverflow.com/*"],
     "js":["main.js"]
    }
  ]
}

Look at the matches inside the content script – the extension will only work for StackOverflow’s domain and subdomain.

Now create another JavaScript file called main.js in the same directory and add the following lines of code:

var arr =document.getElementsByClassName("s-code-block")

for(let i = 0 ; i < arr.length ; i++){
 var btn = document.createElement("button")
 btn.classList.add("copy_code_button")
 btn.appendChild(document.createTextNode("Copy"))
 arr[i].appendChild(btn)
 //styling the button
 btn.style.position = "relative"
 
 if(arr[i].scrollWidth === arr[i].offsetWidth && arr[i].scrollHeight === arr[i].offsetHeight)
  btn.style.left = `${arr[i].offsetWidth - 70}px`

  else if(arr[i].scrollWidth != arr[i].offsetWidth && arr[i].scrollHeight === arr[i].offsetWidth)
   btn.style.left = `${arr[i].offsetWidth - 200}px`
 else 
   btn.style.left = `${arr[i].offsetWidth - 150}px`
  
 if(arr[i].scrollHeight === arr[i].offsetHeight)
   btn.style.bottom = `${arr[i].offsetHeight - 50}px`
   
 else
   btn.style.bottom = `${arr[i].scrollHeight - 50}px`
 //end of styling the button
   
   console.log("Appended")
}

First of all, I selected all the elements with the class name s-code-block – but why? This is because when I inspected StackOverflow’s site I found that all the code snippets were kept in a class with that name.

And then we loop through all those elements and append a button in those elements. Finally, we just position and style the button properly (the styling’s not perfect yet – this is just a start).

When we load the extension using the process we went through above and visit StackOverflow, we should see a copy button.

How to add functionality to the button

Now, when the button is clicked we want the entire snippet to be copied to our clip board. To do this, add the following line of code to the main.js file:

var button = document.querySelectorAll(".copy_code_button")
 button.forEach((elm)=>{
  elm.addEventListener('click',(e)=>{
    navigator.clipboard.writeText(elm.parentNode.childNodes[0].innerText)
    alert("Copied to Clipboard")
  })
 })

First of all, we select all the buttons we have added to the site using querySelectorAll. Then we listen for the click event whenever the button is clicked.

navigator.clipboard.writeText(elm.parentNode.childNodes[0].innerText)

The above line of code copies the code to our clipboard. Whenever a snippet is copied we alert the user with the message Copied to clipboard and we are done.

Final Words

Web Extensions can be useful in various way and I hope with the help of this article you will be able to write your own extensions.

All the code can be found in this GitHub repository. Don’t forget to give a pull request anytime you come up with some good styling or a new feature like clipboard history and others.

Happy Coding!



Learn to code for free. freeCodeCamp’s open source curriculum has helped more than 40,000 people get jobs as developers. Get started

How to Write Your Own Browser Extension [Example Project Included]

In this article we will talk about Browser extensions – what they are, how they work, and how you can build your own.

We will finish by actually writing our own extension (Super Fun!) which allows us to copy any code snippet to our clipboard with a click of a single button.

To continue with this article:

  • You need to have a basic understanding of JavaScript.
  • You need the Firefox browser (or any other browser will also work)

What is a Browser Extension?

A browser extension is something you add to your browser which enhances your browsing experience by extending the capacity of your browser.

As an example, think about an ad blocker which you might have installed on your device. This makes your browsing experience better by blocking ads when you surf the web.

Now let’s start by writing a very basic extension.

To begin, we’ll create a folder inside which we create a file named manifest.json.

What is a manifest file?

A manifest file is a must have file in any browser extension. This file contains basic data about our extension like name, version, and so on.

Now inside the manifest.json file copy the following snippet:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"Test",
}

How to load the extension file

For Firefox users, follow these steps:

In the address bar, search for this:

about:debugging#/runtime/this-firefox

You will see an option to Load Temporary Add-on. Click on that option and choose the manifest.json file from the directory.

For Chrome users:

In the address bar search for this:

chrome://extensions.
  • Enable Developer Mode and switch into it.
  • Click the Load unpacked button and select the extension directory.

Hurray! You’ve installed the extension successfully. But the extension doesn’t do anything currently. Now let’s add some functionality to our extension. To do this, we’ll edit our manifest.json file like this:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"Test",
  "content_scripts":[
    {
     "matches":["<all_urls>"],
     "js":["main.js"]
    }
  ]
}

In the above code, we added a content script to manifest.json. Content scripts can manipulate the Document Object Model of the web page. We can inject JS (and CSS) into a web page using a content script.

"matches" contains the list of domains and subdomains where the content script should be added and js is an array of the JS files to be loaded.

Now inside the same directory create a main.js file and add the following code:

alert("The test extension is up and running")

Now reload the extension and when you visit any URLs you will see an alert message.

Don’t forget to reload the extension anytime you edit the code.

How to Customize Your Browser Extension

Now let’s have some more fun with our extension.

What we are going to do now is create a web extension that changes all the images of a webpage we visit to an image we choose.

For this, just add any image to the current directory and change the main.js file to:

console.log("The extension is up and running");

var images = document.getElementsByTagName('img')

for (elt of images){
   elt.src = `${browser.runtime.getURL("pp.jpg")}`
   elt.alt = 'an alt text'
}

Let’s see whats going on here:

var images = document.getElementsByTagName('img')

This line of code selects all the elements in the web page with the img tag .

Then we loop through the array images using a for loop where we change the src attribute of all the img elements to a URL with the help of the runtime.getURL function.

Here pp.jpg is the name of the image file in the current directory in my device.

We need to inform our content script about the pp.jpg file by editing the manifest.json file to:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"Test",
  "content_scripts":[
   {
    "matches":["<all_urls>"],
    "js":["main.js"]
   }
  ],
  "web_accessible_resources": [
        "pp.jpg"
  ]
}

Then just reload the extension and visit any URL you like. Now you should see all the images being changed to the image which is in your current working directory.

How to add icons to your extension

Add the following code in the manifest.json file:

"icons": {
  "48": "icon-48.png",
  "96": "icon-96.png"
}

How to add a toolbar button to your extension

Now we’ll add a button located in the toolbar of your browser. Users can interact with the extension using this button.

To add a toolbar button, add the following lines to the manifest.json file:

"browser_action":{
   "default_icon":{
     "19":"icon-19.png",
     "38":"icon-38.png"
   }
  }

All the image files should be present in your current directory.

Now, if we reload the extension we should see an icon for our extension in the toolbar of our browser.

How to add listening events for the toolbar button

Maybe we want to do something when a user clicks the button – let’s say we want to open a new tab every time the button is clicked.

For this, we’ll again add the following to the manifest.json file:

"background":{
        "scripts":["background.js"]
  },
  "permissions":[
      "tabs"
  ]

Then we’ll create a new file named background.js in the current working directory and add the following lines in the file:

function openTab(){
    
    var newTab = browser.tabs.create({
        url:'https://twitter.com/abhilekh_gautam',
        active:true
    })
}

browser.browserAction.onClicked.addListener(openTab)

Now reload the extension!

Whenever someone clicks the button, it calls the openTab function which opens a new tab with the URL that links to my twitter profile. Also, the active key, when set to true, makes the newly created tab the current tab.

Note that you can use APIs provided by browsers in the background script. For more about APIs refer to the following article: Javacript APIs.

Now that we’ve learned some of the basics of browser extensions, let’s create an extension that we as developers can use in our daily lives.

Final Project

Alright, now we’re going to write something that will be useful for us in daily life. We’ll create an extension that allows you to copy code snippets from StackOverflow with a single click. So our extension will add a Copy button to the webpage which copies the code to our clipboard.

Demo

demo

First we’ll create a new folder/directory, inside which we’ll add a manifest.json file.

Add the following code to the file:

{
  "manifest_version":2,
  "version":"1.0",
  "name":"copy code",
  "content_scripts":[
    {
     "matches":["*://*.stackoverflow.com/*"],
     "js":["main.js"]
    }
  ]
}

Look at the matches inside the content script – the extension will only work for StackOverflow’s domain and subdomain.

Now create another JavaScript file called main.js in the same directory and add the following lines of code:

var arr =document.getElementsByClassName("s-code-block")

for(let i = 0 ; i < arr.length ; i++){
 var btn = document.createElement("button")
 btn.classList.add("copy_code_button")
 btn.appendChild(document.createTextNode("Copy"))
 arr[i].appendChild(btn)
 //styling the button
 btn.style.position = "relative"
 
 if(arr[i].scrollWidth === arr[i].offsetWidth && arr[i].scrollHeight === arr[i].offsetHeight)
  btn.style.left = `${arr[i].offsetWidth - 70}px`

  else if(arr[i].scrollWidth != arr[i].offsetWidth && arr[i].scrollHeight === arr[i].offsetWidth)
   btn.style.left = `${arr[i].offsetWidth - 200}px`
 else 
   btn.style.left = `${arr[i].offsetWidth - 150}px`
  
 if(arr[i].scrollHeight === arr[i].offsetHeight)
   btn.style.bottom = `${arr[i].offsetHeight - 50}px`
   
 else
   btn.style.bottom = `${arr[i].scrollHeight - 50}px`
 //end of styling the button
   
   console.log("Appended")
}

First of all, I selected all the elements with the class name s-code-block – but why? This is because when I inspected StackOverflow’s site I found that all the code snippets were kept in a class with that name.

And then we loop through all those elements and append a button in those elements. Finally, we just position and style the button properly (the styling’s not perfect yet – this is just a start).

When we load the extension using the process we went through above and visit StackOverflow, we should see a copy button.

How to add functionality to the button

Now, when the button is clicked we want the entire snippet to be copied to our clip board. To do this, add the following line of code to the main.js file:

var button = document.querySelectorAll(".copy_code_button")
 button.forEach((elm)=>{
  elm.addEventListener('click',(e)=>{
    navigator.clipboard.writeText(elm.parentNode.childNodes[0].innerText)
    alert("Copied to Clipboard")
  })
 })

First of all, we select all the buttons we have added to the site using querySelectorAll. Then we listen for the click event whenever the button is clicked.

navigator.clipboard.writeText(elm.parentNode.childNodes[0].innerText)

The above line of code copies the code to our clipboard. Whenever a snippet is copied we alert the user with the message Copied to clipboard and we are done.

Final Words

Web Extensions can be useful in various way and I hope with the help of this article you will be able to write your own extensions.

All the code can be found in this GitHub repository. Don’t forget to give a pull request anytime you come up with some good styling or a new feature like clipboard history and others.

Happy Coding!



Learn to code for free. freeCodeCamp’s open source curriculum has helped more than 40,000 people get jobs as developers. Get started

Спорим, вы прямо сейчас используете какие-то браузерные расширения. Некоторые из них очень полезны и популярны, например, блокировщики рекламы, менеджеры паролей или средства для просмотра PDF. Функциональность таких расширений (аддонов) не ограничена — вы можете сделать с их помощью что угодно, но сегодня мы будем разбираться, как делать непосредственно их. Другими словами, мы собираемся создать собственное расширение.

Что будем делать?

На сайте Reddit есть аккаунты, которые на добровольной основе занимаются транскрибацией (переводом в текст) изображений, аудио- и видео-контента. Это здорово улучшает доступность для людей с ограниченными возможностями.

Однако эти комментарии находятся в общей ленте и сразу не видны. Мы собираемся решить эту проблему.

Мы создадим расширение Transcribers of Reddit (TOR), которое будет искать и перемещать комментарии с расшифровкой на верх общего списка, а также добавлять aria-атрибуты для скрин-ридеров. Мы даже пойдем чуть дальше и добавим возможность изменить цвет фона и установить рамку для этих комментариев, чтобы улучшить визуальный контраст.

Основная идея этого проекта — показать, как создавать браузерное расширение, а не реализовать полнофункциональный аддон, так что вы всегда можете что-то доработать или переделать на свой вкус.

Мы начнем с расширения для Chromium-браузеров (Google Chrome, Microsoft Edge, Brave и т. д.) В следующих статьях, возможно, мы его портируем на Firefox и Safari, который с недавних пор тоже поддерживает веб-расширения и в MacOS и в iOS.

Репозиторий проекта

Рабочая директория

Прежде чем начинать, нужно создать произвести все необходимые подготовительные действия.

Создадим новую папку для проекта — можно назвать ее transcribers-of-reddit. А внутри нее папку src для исходного кода.

Манифест

Точка входа — это файл, который содержит всю важную информацию о расширении (название, описание и т. д.), а также определяет необходимые разрешения и выполняемые скрипты.

Нашей точкой входа будет файл manifest.json в папке src. Добавим в него для начала три свойства:

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}
  • Поле manifest_version — это версия используемой спецификации, она определяет, какие API доступны приложению. На данный момент самая свежая версия — mv3.
  • Поле name — название расширения, которое будет отображаться, например, в Chrome Web Store.
  • Поле version — это версия самого расширения. Она указывается в виде строки, которая должна содержать только цифры и точки. Можно указывать мажорные, минорные версии, а также патчи (например, 1.3.5).

Больше информации

В файл manifest.json можно добавить очень много различных параметров, описывающих наше приложение. Например, его описание (поле description), которое объясняет, что приложение умеет делать.

Добавим также иконки приложения в разных разрешениях и урл домашней страницы проекта.

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • Поле description не должно быть длиннее 132 символов.
  • Иконки, указанные в поле icons, используются во множестве мест. Предпочтительно загружать файлы png-формата. Иконки для нашего проекта вы можете загрузить прямо из его репозитория.
  • В поле homepage_url можно указать любую страницу, которая содержит информацию о приложении. Она будет отображаться в графе Open extention website на странице управления расширением.

Скриншот страницы управления расширением

Разрешения

Большой плюс расширений — это возможность взаимодействовать напрямую с браузером. Но для этого нужно явно указать, какие API мы собираемся использовать, чтобы получить разрешения. Это нужно сделать в секции permissions файла manifest.json:


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

Прежде всего, мы хотим, чтобы наше расширение сохраняло пользовательские настройки, поэтому мы должны разрешить браузеру сохранять их — разрешение storage. Например, если пользователь хочет видеть у комментариев красную рамку, мы сохраним этот выбор и в следующий раз применим тот же стиль.

Кроме того, мы должны отслеживать пользовательскую навигацию по текущей странице — разрешение webNavigation. Дело в том, что Reddit — это одностраничное приложение (SPA), которое не вызывает событий перезагрузки страниц. Нам нужно поймать взаимодействие и загрузку комментариев на страницу.

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

Управление переводами

Браузерным расширениями доступен встроенный API интернационализации (i18n). Он позволяет управлять переводами для множества языков (полный список). Чтобы воспользоваться этой возможностью, нужно создать сами переводы, а также указать дополнительную информацию в manifest.json.

Определяем, какой язык будет использоваться по умолчанию (английский):

"default_locale": "en"

Если вдруг в браузере будет включен язык, который не поддерживается нашим расширением, оно просто будет использовать базовый язык.

Сами переводы определяются в папке _locales. Для каждого создается отдельная папка, внутри которой находится файл messages.json:

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

Перевод каждой фразы оформляется по специальной схеме:

  1. Определяется ключ перевода (translation key), или идентификатор фразы, по которому мы будем к ней обращаться.
  2. Для каждого ключа указываются свойства: message, description и placeholders.
  • message — это сама переводимая фраза.
  • description — опциональное описание перевода, если необходимо. Предназначено, скорее, для самого переводчика, в расширении не используется.
  • placeholders — динамический контент внутри фразы, например, слова, которые могут изменяться.

Вот пример перевода одной фразы:

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // сама фраза
    "description": "User Greeting", // опциональное описание
    "placeholders": { // динамический контент внутри фразы
      "daytime": { // имя плейсхолдера, используется в самой фразе
        "content": "$1",
        "example": "morning" // необязательный пример значения
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

Схема использования плейсхолдеров может показаться довольно сложной.

Для начала мы должны определить, какие части сообщения (фразы) являются динамическими. Их может и не быть. Если такие фрагменты нашлись, нужно обернуть их символами $.

Каждый такой фрагмент нужно отдельно описать в поле placeholders. Это немного неинтуитивно, но Chrome хочет знать, какое значение следует вставить вместо плейсхолдера. Так как мы хотим использовать динамические (неопределенные) значения, нужно использовать специальную конструкцию $1, которая является ссылкой на вставленный контент.

Свойство example необязательно, оно может использоваться как подсказка для переводчиков, но в самом расширении не применяется.

Давайте добавим переводы в наш проект. Это оригинальный английский файл, а вы можете добавить столько языков, сколько захотите. Например, для русского нужно создать папку ru, а для немецкого de.

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

Возможно, вы хотите знать, почему мы не указали API интернационализации в списке разрешений.

Chrome довольно непоследователен в этом отношении — вам не нужно указывать все необходимые разрешения, например, chrome.i18n. Некоторые другие разрешения требуется указывать, но пользователь, который устанавливает ваше расширение о них ничего не знает. Некоторые разрешения вообще являются «гибридными», вы можете использовать некоторые их функции без объявления, но другие обязательно нужно указать в манифесте. Чтобы разобраться во всем этом, загляните лучше в документацию.

Интернационализация манифеста

Первое, что видит пользователь в Chrome Web Store, — это обзорная страница расширения. Мы должны убедиться, что на ней все переведено на актуальный язык, для этого придется внести несколько изменений в манифест — перевести название и описание:

{
  // Обновленные значения
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

Это специальный синтаксис, который позволяет ссылаться на фразы в messages.json по их идентификатору. Например, строка __MSG_name__ использует фразу с идентификатором name.

Интернационализация HTML-страниц

Чтобы перевести тексты на страницах самого расширения, потребуется добавить немного JavaScript:

chrome.i18n.getMessage('name');

Этот код также использует идентификатор фразы из файла messages.json.

Чтобы передать динамические плейсхолдеры, используйте второй параметр метода:

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

Конечно, перевести таким образом все тексты на странице не самая приятная задача, но мы можем схитрить. Вынесем тексты в data-атрибуты элементов и переведем их все сразу с помощью скрипта.

Создайте новую папку js внутри директории src и добавьте туда файл util.js, в котором будут находиться вспомогательные функции:

src 
 └─ js
     └─ util.js

Вот так выглядит сам код:

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});

При выполнении этот скрипт найдет все элементы с атрибутом data-intl, найдет соответствующую указанному идентификатору переведенную фразу и вставит полученный текст в innerHTML элемента.

<!-- До выполнения JS -->
<html>
  <body>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>
<!-- После выполнения JS -->
<html lang="en">
  <body>
    <button data-intl="popupManageSettings">Manage settings</button>
  </body>
</html>

Добавление выпадающего меню и страницы настроек

Прежде чем перейти уже наконец к настоящей разработке, мы должны создать две страницы:

  1. Страницу настроек, которая будет содержать настройки пользователя.
  2. Выпадающее меню, которое будет открываться при нажатии на иконку расширения в панели браузера. Его можно использовать для различных сценариев, например, отображать быстрые настройки или собранную статистику.

Скриншот страницы с настройками
Выпадающее меню содержит ссылку на страницу настроек

Вот файлы, которые нам понадобятся:

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

Файлы .css содержат самый обычный CSS, не больше, не меньше. Вы все наверняка знаете, как это работает 🙂 При желании вы, разумеется, можете использовать различные препроцессоры, главное, не забудьте скомпилировать код.

Файл paintBucket.css:

:root {
  --primary: #ff4500;
  --primary-dark: #d83a00;
  --secondary: #f2f2f2;
  --secondary-dark: #cdcdcd;
  --text: #000;
  --body: #fff;
  --btn-background: rgba(214, 214, 214,.25);
  --btn-background-hover: #c3c3c3;
  --focus: rgba(24, 116, 195, .3);
  --warning: #9b050c;
}

@media (prefers-color-scheme: dark) {
  :root {
    --secondary: #7e818c;
    --secondary-dark: #91939d;
    --text: #fff;
    --body: #202124;
    --btn-background: #292a2d;
    --btn-background-hover: #47494e;
    --focus: rgba(255, 255, 255, .3);
  }
}
*, *::before, *::after {
  box-sizing: border-box;
}

*:focus {
  box-shadow: 0 0 0 3px rgba(0, 125, 250, 0.45);
  outline: none;
}

html {
  width: 100%;
}

html, body {
  margin: 0;
  padding: 0;
  font-size: 16px;
  color: var(--text);
  background-color: var(--body);
}

body {
  width: 100%;
  height: 100vh;
  max-height: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
}

Обратите внимание, выпадающее меню — это НЕ отдельная вкладка браузера. Его размер зависит от контента. Если вы хотите задать определенные фиксированные размеры, просто установите свойства width и height для элемента html.

Выпадающее меню

Напишем HTML-код выпадающего меню, а также подключим туда CSS- и JS-файлы.

Файл popup.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title data-intl="name"></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>

Заголовок первого уровня будет содержать название расширения и его текущую версию, а кнопка будет открывать страницу настроек. Обратите внимание, для кнопки установлен атрибут data-intl, то есть ее текст будет вставлен автоматически нашим вспомогательным скриптом.

Текст заголовка мы установим отдельно в файле popup.js. Там же будет обработчик клика по кнопке:

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

Чтобы получить доступ к данным манифеста, это скрипт использует runtime API браузера Chrome. Его метод getManifest возвращает JSON-объект (этот метод не требует специальных разрешений).

Для начала устанавливаем текст заголовка. После этого определяем слушатель для события клика по кнопке. При клике мы хотим открывать страницу настроек, для этого есть специальный метод openOptionsPage(), также не требующий разрешений.

Стили меню в файле popup.css:

html {
  width: 360px;
  height: 160px;
  padding: 1.5rem;
}

body {
  max-height: 100%;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

h1 {
  font-size: 1rem;
  font-weight: normal;
  text-align: center;
}

button {
  -moz-appearance: none;
  -webkit-appearance: none;
  appearance: none;
  border: none;
  color: #fff;
  background-color: var(--primary);
  transition: background-color 200ms;
  padding: 0.75rem 1.5rem;
  cursor: pointer;
}
button:hover {
  background-color: var(--primary-dark);
}

Мы полностью закончили меню, но наше расширение пока что ничего о нем не знает. Нужно зарегистировать его в файле manifest.json:

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

Страница настроек

Страница с пользовательскими настройками создается точно так же.

Начинаем с разметки — файл options.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title data-intl="name"></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span data-intl="optionsPageTitle"></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2 data-intl="sectionGeneral"></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

Сейчас здесь нет никаких обещанных настроек, только подготовленный для них блок. Вставлять их мы будем с помощью JavaScript (в файле options.js).

Сначала определим дефолтные значения и получим ссылку на DOM-элемент контейнера:

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

Теперь нужно загрузить сохраненные ранее настройки. Для этого нам понадобится storage API (который мы уже зарегистрировали ранее в манифесте).

Можно сохранять данные локально (chrome.storage.local) или синхронизировать их между разными устройствами пользователя (chrome.storage.sync). Для простоты будем использовать локальное хранилище.

Для получения данных предназначен метод get, который принимает два аргумента: ключ, с которым связано нужное значение, и коллбэк для обработки полученных данных.

У нас ключ — это строка 'settings', но можно также передавать массив строк, если требуется загрузить несколько различных настроек.

Полученные из хранилища данные будут переданы функции-коллбэку:

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // если не было сохраненных настроек, используем дефолтные
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // создаем HTML-опции для каждой настройки
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

Рендер опций вынесен в отдельную функцию createOption():

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

Здесь мы создаем отдельный блок для каждой настройки и помещаем внутрь него чекбокс, который может быть включен или выключен. Добавляем слушатель события change для этого чекбокса и при изменении вызываем метод updateSetting, который должен обновить сохраненное значение в хранилище. Для этого предназначен метод хранилища set, который принимает два аргумента: обновленное значение всего хранилища и коллбэк (опциональный параметр, который мы не будем использовать).

Так как мы храним настройки в виде объекта, следует использовать spread-оператор, чтобы обновлять только нужный параметр, сохраняя актуальные значения прочих.

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

Стили страницы в файле options.css:

header {
  background-color: var(--primary);
  color: #fff;
  padding: 1rem 1.75rem;
}
header > h1 {
  margin: 0 auto;
  max-width: 40rem;
  font-weight: normal;
  display: flex;
  align-items: center;
}
header > h1 > svg {
  width: 2rem;
  height: 2rem;
  margin-right: 0.5rem;
}

main {
  width: 100%;
  padding: 0 1.75rem;
  margin: 1.5rem auto;
  display: flex;
  flex-direction: column;
  flex: 1;
}
main > section {
  width: 100%;
  max-width: 40rem;
  margin: 1rem auto;
}
main > section > h2 {
  max-width: 50rem;
  font-weight: normal;
  margin: 0.25rem 0;
  color: var(--black);
}
main > section > h2.dropdown {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  width: 100%;
  font-size: 1.5rem;
  border: none;
  cursor: pointer;
  padding: 0.25rem 0;
  background-color: var(--white);
  transition: background-color 200ms;
  display: flex;
  align-items: center;
  justify-content: space-between;
  user-select: none;
}
main > section > h2.dropdown > svg {
  width: 1.6rem;
  height: 1.6rem;
  transition: transform 200ms;
}
main > section > h2.dropdown:hover {
  background-color: var(--gray);
}
main > section > h2.dropdown + div {
  display: none !important;
}
main > section > h2.dropdown[aria-expanded=true] > svg {
  transform: rotate(-90deg);
}
main > section > h2.dropdown[aria-expanded=true] + div {
  display: flex !important;
}
main > section > #generalOptionsWrapper, main > section #advancedOptionsWrapper {
  display: flex;
  flex-direction: column;
  margin: 1rem 0;
}
main > section > #generalOptionsWrapper > .setting-item, main > section #advancedOptionsWrapper > .setting-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem 0;
}
main > section > #generalOptionsWrapper > .setting-item:hover > .label-wrapper > button, main > section > #generalOptionsWrapper > .setting-item:focus-within > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item:hover > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item:focus-within > .label-wrapper > button {
  opacity: 1;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper {
  display: flex;
  align-items: center;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper > button, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper > button {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: none;
  border: none;
  cursor: pointer;
  margin-left: 0.25rem;
  opacity: 0;
  transition: opacity 200ms;
}
main > section > #generalOptionsWrapper > .setting-item > .label-wrapper > button > svg, main > section #advancedOptionsWrapper > .setting-item > .label-wrapper > button > svg {
  width: 1.5rem;
  height: 1.5rem;
  stroke: var(--placeholder);
}
main > section > #generalOptionsWrapper > .setting-item > input, main > section #advancedOptionsWrapper > .setting-item > input {
  display: none;
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch {
  background-color: var(--primary);
  border-color: var(--primary);
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch:hover, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch:hover {
  background-color: var(--primary-dark);
  border-color: var(--primary-dark);
}
main > section > #generalOptionsWrapper > .setting-item > input:checked + .is-switch::after, main > section #advancedOptionsWrapper > .setting-item > input:checked + .is-switch::after {
  transform: translateY(-50%) translateX(28px);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch, main > section #advancedOptionsWrapper > .setting-item > .is-switch {
  width: 4rem;
  height: 34px;
  background: var(--secondary);
  cursor: pointer;
  border-radius: 30px;
  overflow: hidden;
  position: relative;
  transition: background-color 200ms, border-color 200ms;
  border: 1px solid var(--secondary);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch:hover, main > section > #generalOptionsWrapper > .setting-item > .is-switch:focus, main > section #advancedOptionsWrapper > .setting-item > .is-switch:hover, main > section #advancedOptionsWrapper > .setting-item > .is-switch:focus {
  background-color: var(--secondary-dark);
  border-color: var(--secondary-dark);
}
main > section > #generalOptionsWrapper > .setting-item > .is-switch::after, main > section #advancedOptionsWrapper > .setting-item > .is-switch::after {
  background: #fff;
  border-radius: 50%;
  box-shadow: 2px 4px 6px rgba(0, 0, 0, 0.2);
  content: "";
  height: 30px;
  width: 30px;
  position: absolute;
  left: 2px;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 200ms;
  will-change: transform;
}

footer {
  display: flex;
  flex-direction: column;
  padding: 1rem 1.75rem;
  text-align: center;
}
footer > p {
  margin: 0.25rem 0;
  font-size: 75%;
}
footer > p > a {
  text-decoration: none;
  color: var(--text);
}

dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  max-width: 40rem;
  border: none;
  margin: 0;
  box-shadow: 5px 5px 8px 0 rgba(0, 0, 0, 0.15);
  border-radius: 0.5rem;
  padding: 2rem;
}
dialog::backdrop {
  background: rgba(0, 0, 0, 0.25);
  backdrop-filter: blur(10px);
}
dialog > #dialogClose {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  padding: 0.25rem;
  background: none;
  border: none;
  cursor: pointer;
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  color: lightgray;
  transition: color 200ms;
}
dialog > #dialogClose:hover {
  color: var(--placeholder);
}
dialog > #dialogClose > svg {
  width: 1.75rem;
  height: 1.75rem;
}
dialog > #optionDescription {
  margin-bottom: 2rem;
  line-height: 1.5;
}
dialog > #dialogActions {
  display: flex;
  justify-content: flex-end;
  margin: 0 -0.5rem;
}
dialog > #dialogActions > #dialogOk {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: var(--blue);
  padding: 0.75rem 1.5rem;
  min-width: 4rem;
  max-width: fit-content;
  border: none;
  color: var(--white);
  cursor: pointer;
}

И конечно же, мы должны зарегистрировать новую страницу в манифесте:

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

При необходимости вы можете оформить страницу опций в виде модального диалога. Для этого нужно установить свойство open_in_tab: false.

Установка расширения для разработки

Мы сделали всю подготовительную работу, и теперь неплохо было бы проверить, работает ли наше расширение.

Перейдите на страницу chrome://extensions и включите режим разработчика (в правом верхнем углу).

Появятся три кнопки, нам нужна первая — Загрузить распакованное расширение. Кликните на нее и в появившемся окне выберите папку src, в которой хранятся все файлы нашего проекта.

После этого наше расширение появится в списке в статусе Установлено. Также его иконка появится в верхней панели браузера рядом с адресной строкой (его можно найти, кликнув на иконку паззла ?) . Если вы нажмете на нее, то появится выпадающее меню с кнопкой:

На вашем девайсе оно может выглядеть немного по-другому, если в предпочтениях не включена темная тема оформления.

Страница настроек:

Попробуйте изменить значение переключателей и перезагрузить страницу — вы увидите, что настройки сохраняются в локальном хранилище браузера.

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

Итак, мы сделали выпадающее меню и настроили взаимодействие со страницей настроек, но само расширение все еще не делает ничего полезного. Давайте исправим это и добавим основной скрипт.

Создайте новый файл comment.js внутри директории js.

Теперь его нужно добавить в манифест в секцию content_scripts:

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

Это массив, каждый элемент которого состоит из двух частей:

  • matches — содержит массив урлов, на которых браузер должен запускать наше расширение. Мы указываем всего один урл домен верхнего уровня — www.reddit.com, а изменяющиеся части обозначаются звездочками.
  • js — содержит массив скриптов, которые нужно запустить на подходящих урлах.

Помните, что скрипты расширения не могут взаимодействовать на скриптами сайта, не могут использовать их переменные или функции.

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

Давайте приступим уже к программированию.

Прежде всего добавим несколько констант в файл comment.js, в которых сохраним селекторы DOM-элементов и регулярные выражения для дальнейшей работы.

Объект CommentUtils содержит набор тестовых функций для определения, содержит ли пост «ToR-комментарии» и существует ли вообще блок комментариев на странице.

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: //r/.*/comments/.*/,
  subredditPage: //r/.*//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});

Затем мы проверяем, находится ли пользователь на странице «поста» с комментариями. Для этого проверяем текущий урл и обновляем значение переменной directPage. Эта проверка сработает в том случае, если пользователь перешел на страницу с внешних ресурсов или собственноручно вбил адрес в адресную строку браузера.

let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

Но в большинстве случаев переходы осуществляются внутри SPA. Чтобы отловить их, мы можем добавить слушатель сообщений, используя runtime API.

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

Теперь нам нужно написать реализацию функций moveComment и waitForComment.

moveComment — находит в общем списке «ToR-комментарии» с транскрипцией медиа-ресурсов и перемещает их в начало списка. Также при наличии соответствующих настроек она изменяет цвет фона и рамки этих комментариев (это проверяется с помощью уже знакомого нам метода get).

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { 
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

Функция applyWaiAria добавляет комментариям aria-атрибуты для улучшения доступности для скринридеров. Мы также используем вспомогательную функцию uuidv4 для генерации уникальных идентификаторов.

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

Функция waitForComment ожидает, пока комментарии загрузятся, и после этого вызывает коллбэк, который был ей передан. Для отслеживания используется API браузера MutationObserver.

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

Добавляем сервис-воркер

Помните, что мы добавили слушатель сообщений в скрипте нашего расширения? Но пока никаких сообщений нам не приходит, кто-то должен их отправлять. Для этого мы должны зарегистрировать сервис-воркер.

Создайте новый файл sw.js внутри директории src (важно, чтобы он находился в корне проекта).

Сразу же зарегистрируем его в манифесте:

"background": {
  "service_worker": "sw.js"
}

Начнем, как обычно, с констант для сообщений и типов страниц:

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: //r/.*/comments/.*/,
  subredditPage: //r/.*//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});

Мы будем следить за историей навигации браузера (onHistoryStateUpdated). Нужное нам событие будет вызываться каждый раз, когда SPA обращается к History API при обновлении контента страницы без перезагрузки.

При наступлении события мы запрашиваем активную вкладку браузера и получаем ее tabId, а затем отправляем сообщение основному скрипту расширения.

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

Готово!

Вот и все!

Перейдите в менеджер расширений (chrome://extensions) и перезапустите наше расширение, чтобы все изменения применились.

Теперь откройте на Reddit какой-нибудь пост, у которого есть комментарии с транскрибацией (например, вот этот). И вы сразу же увидите нужный комментарий, потому что он отображается в самом верху списка, да еще и с рамкой и красным фоном (если вы установили соответствующие настройки).

Расширение Transcribers of Reddit находит комментарии с транскрибацией изображения, поднимает их на верх списка и выделяет красной рамкой и фоном

Заключение

Создавать браузерные расширения совсем несложно, как вы только что убедились. В них нет ничего особенного — те же самые HTML, CSS и JavaScript, которые вы пишете каждый день, плюс совсем немного магии в manifest.json.

Если у вас возникнут какие-то проблемы, скорее всего вы сможете найти ответ в отличной документации Chrome API.

Репозиторий с кодом статьи

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

Расширение или плагин для Chrome – это приложение, которое добавляет новые возможности браузеру. В магазине Chrome порядка 200 тысяч расширений для разработчиков, маркетологов, дизайнеров и других пользователей браузера.

Идея

Больше года назад я заметил, что ссылки «Входящие» и «Написать письмо» на главной странице почты, которой я часто пользуюсь, стали открываться в новом окне. По ответу эксперта стало понятно, что придется привыкать к этому спорному нововведению.

Я привыкнуть не смог: каждый раз возвращаться и закрывать вкладку браузера неудобно. Так появилась идея простого расширения для Chrome, которое вернет прежнее поведение при открытии Входящих и написании нового письма.

SimilarWeb показывает 1,141 млрд посещений главной страницы за август. Если хотя бы 10% из них приходится на десктопный браузер и целевое действие, то это 141 млн открытых вкладок в месяц. На возврат и закрытие вкладки уходит не меньше секунды. Получается, мы собираемся сэкономить 31,5 тысячи человеко-часов в месяц.

Шаг 1. Подготовка расширения

Приготовим минимальный стартовый набор.

1. Создайте папку проекта. Назовите ее именем вашего расширения.

2. Создайте javascript-файл, в котором будет реализована логика приложения. Я назвал его app.js.

3. Добавьте в созданный файл команду для тестирования: вывод строки в консоль или в диалоговое окно браузера.

alert(‘Hello from my extension!’);

4. Создайте manifest.json – служебный файл, который должен быть в каждом расширении.

{
«manifest_version»: 2,
«name»: «Mail link healer»,
«version»: «1.0»,
«description»: «Ссылка «Входящие» и «Написать письмо» снова открываются в той же вкладке!»,
«icons»: {
«128»: «128.png»
},
«content_scripts»: [
{
«matches»: [ «<all_urls>» ],
«js»: [ «app.js» ]
}
]
}

Обязательные параметры, должны быть в каждом manifest.json:

  • manifest_version – целое число, обозначает версию формата манифеста. Для современных версий браузера значение равно 2;

  • name – название расширения, до 45 символов. К названию стоит подойти ответственно – оно отображается в магазине, при установке и на странице управления расширениями (chrome://extensions);

    version – строка с версией расширения. Содержит до четырех чисел, разделенных точками, например 2.19.7.0.

Необязательные параметры:

  • description – простой текст, до 132 символов. Отображается в магазине и на странице управления расширениями;

  • icons – список из иконок расширения;

  • content_scripts – скрипты, которые загружает приложение. Мы прописали запуск app.js на всех веб-страницах. Позже мы изменим этот параметр, чтобы запускать скрипт только на целевой странице. Строго говоря, в нашем случае content_scripts – обязательный параметр. Если его не задать, расширение не сделает ничего полезного.

5. Подготовьте иконку в формате png размером 128х128 пикселей и положите ее в папку проекта. Эта версия отображается при установке и в магазине расширений. Рекомендуют также добавить иконку размером 48х48 пикселей – она отображается на странице управления расширениями. И можно добавить версию 16х16 пикселей, которая будет использоваться как фавикон.

К этому моменту в нашем проекте есть иконка расширения, manifest.json со служебной информацией и app.js, в котором позже появится код приложения.

.
├── 128.png
├── app.js
├── manifest.json

Примечание: расширение может состоять из нескольких JS-файлов, HTML-верстки и CSS-стилей.

Теперь можно проверить, что мы выполнили минимальные требования к расширению.

Шаг 2. Промежуточное тестирование

1. Войдите в свой аккаунт в браузере Chrome.

2. Перейдете на страницу chrome://extensions.

3. В правом верхнем углу включите Режим разработчика.

4. Нажмите Загрузить распакованное расширение и выберите папку проекта. В результате вы увидите похожую картинку:

Расширение после загрузки в браузер.

5. В новой вкладке браузера откройте любую веб-страницу. Отлично, работает!

alert из app.js сработал. Можно двигаться дальше.

Теперь мы готовы добавить полезное действие.

Шаг 3. Код приложения

Проверим код ссылок «Входящие» и «Написать письмо» на странице почты с помощью инструментов разработчика браузера. Нажимаем правую кнопку на ссылке и выбираем Посмотреть код. Видно, что ссылки открываются в новой вкладке из-за атрибута target=”_blank”.

Кусочек кода в этой рамке доставляет столько неудобств. Избавимся от него!

Получается, с помощью javascript нам нужно выбрать две ссылки и удалить этот атрибут.

1. В app.js сохраните ссылки в переменные:

const inboxLink = document.getElementById(‘mailbox:unreadEmails:link’); // ссылка на Входящие
const composeLink = document.getElementById(‘mailbox:write_letter’); // ссылка «Написать письмо»

Здесь мы выбираем элементы по их идентификатору (id).

2. Если такие ссылки найдены, удалите у них атрибут target.

if (inboxLink && composeLink) {
// если ссылки есть на странице
// удаляем у них атрибут ‘target’
inboxLink.removeAttribute(‘target’);
composeLink.removeAttribute(‘target’);
}

Похоже, этого кода достаточно для решения проблемы.

Заодно давайте пропишем в manifest.json правило, по которому расширение будет запускать наш скрипт только на целевой странице. Для этого в параметре content_scripts в ключе matches пропишем конкретный URL:

«content_scripts»: [
{
«matches»: [ «https://mail.ru/*» ],
«js»: [ «app.js» ]
}
]

Звездочку в конце URL я поставил, чтобы скрипт срабатывал на всех страницах, начинающихся с https://mail.ru/.

Кстати, после обновления расширения стоит обновить версию и в manifest.json:

«version»: «1.1»,

После внесения правок важно обновить расширение в браузере. Для этого нажмите на стрелку на странице управления расширениями (chrome://extensions/).

Быстрый способ обновить расширение.

Готово! Проверим расширение в боевых условиях.

Шаг 4. Финальное тестирование

Открываем целевую страницу и переходим по ссылкам «Входящие» и «Написать письмо». Победа! Ссылки открываются в той же вкладке, как в старые добрые времена.

Шаг 5. Публикация расширения

Этот шаг необязательный. Во-первых, возможно, вы не хотите делать доступным для всех свое расширение. А во-вторых, возможно, не захотите платить за это $5. Такой регистрационный сбор должен оплатить разработчик Chrome Web Store.

  1. Создайте zip-архив с файлами проекта.
  2. Зарегистрируйтесь как разработчик Chrome Web Store. Для этого войдите в консоль разработчика под своим аккаунтом Chrome, примите условия соглашения и Политику конфиденциальности.
  3. Оплатите регистрационный сбор $5. Оплатить можно картой, как в обычных интернет-магазинах.
  4. На дашборде разработчика в разделе Аккаунт добавьте адрес электронной почты. Без этого не получится отправить расширение на проверку.
  5. В личном кабинет разработчика нажмите Добавить продукт и загрузите zip-архив проекта.
  6. В разделе Описание продукта заполните описание расширения, выберите категорию и язык. Добавьте хотя бы один скриншот расширения.
  7. В разделе Конфиденциальность коротко опишите единственное назначение расширения, обоснование для использования разрешения и отметьте, что не используете разрешение «удаленный код».
  8. В разделе Цель и распространение задайте, кому будет доступно расширение.
  9. Нажмите Отправить на проверку в правом верхнем углу.

Это всё. Остается ждать результат проверки, которая может занять несколько недель.

Я отправил расширение на проверку параллельно с публикацией статьи. Как только получу ответ, добавлю комментарий со ссылкой на опубликованное расширение.

Надеюсь, эта инструкция была вам полезной. Буду рад обсудить детали в комментариях.

27.06.2014, 08:45. Показов 57583. Ответов 1


1. Что такое расширения к браузерам?
Расширения к браузерам — это программы, которые внедряются в браузер и добавляют в него новые функции или настраивают имеющиеся.
Расширения к браузерам могут:

  • изменять html-код страниц, открываемых в браузере. В том числе внедрять в них скрипт, который на страницах не отображается, но выполняется для каждой странице при её загрузке.
    В хроме такой скрипт называется скриптом содержимого или контентным скриптом, в мазиле и опере — юзерскриптом.
    Примечание: в отличие от обычных видимых скриптов, этот скрипт может делать кроссдоменные запросы XMLHttpRequest.
  • добавлять кнопки на панели инструментов браузера и создавать новые тулбары
  • управлять вкладками браузера: создавать, закрывать, обновлять, посылать по определённому urlу
  • управлять закладками браузера: создавать, изменять, удалять
  • делать редиректы с одних url на другие, причём не только при открытии того или иного url во вкладке, но и при доступе к нему из фреймов, а также через XMLHttpRequest или через script, img, video.
  • просматривать и изменять историю посещенных страниц в браузере
  • и другое, в зависимости от браузера и его версии

Расширения можно публиковать в магазине расширений для соотв. браузера, откуда пользователи смогут их скачивать бесплатно или покупать за деньги.
Ссылки на магазины даны ниже.

Не стоит путать расширения с плагинами.

2. Что представляют собой расширения и как их пишут?
Расширения к браузерам обычно состоят из:

  • файла настроек расширения (ini, json, xml), где указаны, например, название расширения, его версия, автор, поддерживаемые версии браузера, а также файлы скриптов и html страниц, необходимых расширению
  • фонового JS-скрипта (или фоновой страницы с таким скриптом), который выполняется 1 раз при загрузке браузера, не отображается ни на каких страницах, но может менять какие-то настройки в браузере, скажем добавлять кнопку на тулбар
  • юзерскрипта, который внедряется в страницы, не отображается в их коде, но может их изменять так же свободно, как и если бы он был на данном сайте изначально
  • вспомогательных html-страниц, например, страницы настроек для пользователя или диалогового окна, открывающегося при нажатии на кнопку на тулбаре

Всё это либо упаковывают в zip-архив и устанавливают в браузер через страницу Расширения/Дополнения (предварительно сменив расширение архива в зависимости от браузера), либо просто помещают в отдельную папку и опять устанавливают через страницу Расширения/Дополнения, указав путь к этой папке (ручная установка в распакованном виде доступна в хроме).
Возможна программная установка расширений.

Логику расширения пишут на JS, интерфейс пользователя создают с помощью HTML+CSS, а файл настроек пишут на INI/JSON/XML.
Для написания расширений достаточно блокнота.

Исключение — Internet Explorer, расширения для которого пишутся на компилируемом ЯП (C#, C++, Delphi, ..) с помощью COM. Его мы здесь рассматривать не будем.

3. С чего начать?
1. Сначала Chrome.
Во-первых, по нему больше всего инфы в инете.
Во-вторых, он попросту проще.
В-третьих, он не только популярнее сам по себе, но имеет множество клонов, которые, также как и он, основаны на Chromium и обычно совместимы с расширениями Chrome. Это Яндекс.Браузер, Амиго, Интернет от Mail.Ru… Тысячи их.
2. Потом Opera.
Когда вышла Opera 15 (Opera Next) на хромовском движке, оф. документацию по расширениям к старой опере аффффтары просто потёрли, глубоко наплевав на тех, кто привык к старой опере и вообще в гробу видел хром. А таких вот топиков в своё время никто не создал. В результате инфы в инете по опере не Next почти не осталось.
Мы в данном топике будем рассматривать оперу ниже 15, т.е. не Next.
3. Потом Firefox.
Расширения к нему можно создавать двумя способами — как я описал в пункте 2 и каким-то левым способом с помощью специального SDK, причём способы плохо совмещаются друг с другом. Поэтому добрая половина кода в инете, касаемо огнелиса, у вас может просто не заработать. Мы в данном топике будем учиться юзать именно способ без SDK, ибо он проще.
4. Ну и потом IE.
Который по вышеупомянутой причине рассматриваться в топике не будет.

4. Зачем нужен этот топик? Вон статья на хабре, вон оф. документация, вон ещё статья на хабре, что ещё надо?
1) Вам не нужно — вы и не создавайте топиков, и этот не читайте. А некоторым нужно. Мне бы в своё время пригодился.
2) По расширениям к хрому инфа и правда есть, но вот с огнелисом и оперой сложнее. Про оперу — см. выше.
3) Я не прошу отправлять этот топик в важные (хотя буду рад, если он туда попадёт), не буду его апить каждые полчаса. Так что вам он не мешает ничем. Не нужно его трогать.
4) Этот топик нужен мне. Чтобы в свободное время или просто когда лень работать продолжать углублять свои знания и расширять специализацию. И вы бы лучше занялись чем-то таким, нежели ругать и портить чужое.

5. Пишем простое расширение к Chrome.
Расширение будет добавлять всплывающий баннер с текстом «Ура!» на каждую страницу в браузере, в левый-верхний угол.
Итак, приступим.
1. Создаём отдельную папку. Называем её, например, HelloChromeAPI.
2. В папке создаём файл настроек расширения.
Для этого запускаем блокнот, пишем такой код

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
    "name": "Hello Chrome API",
    "version": "1.0.0.0",
 
    "manifest_version": 2,
 
    "content_scripts": [
        {
            "matches": [ "http://*/*" ],
            "js": [ "content_scripts/end.js" ],
            "run_at": "document_end"
        }
    ]
}

сохраняем в нашу папку под именем manifest.json обязательно в кодировке UTF-8.
Такой формат представления данных называется JSON: записи через запятую, имена их в кавычках, каждая запись может включать в себя подзаписи разного типа (тогда заключается в {}) или подзаписи одного и того же типа (тогда называется массивом и заключается в []).
«name» — имя расширения, которое будет видно в браузере
«version» — версия расширения, которую тоже будет видно в браузере
«manifest_version», равное 2, указывает, что расширение будет совместимо с Chrome выше версии 13. Если вместо 2 указать 1, то расширение будет работать в хромах с 5 по

12. Такие хромы сейчас редки, но если уж писать серьёзный проект, то нужно сделать дубликаты расширения для обоих поколений хрома.
«content_scripts» — это массив контентных скриптов (то есть скриптов, которые, как говорилось выше, выполняются на каждой странице в браузере). В данном случае в списке один элемент (он ограничен скобками {}), так как скрипт в нашем расширении будет только 1
«matches» — маска, указывающая, на каких страницах будет выполняться скрипт. * означает любую последовательность символов. Наш скрипт будет выполнять на всех страницах протокола http. Почему только http? Потому что на страницах протокола https браузер проверяет, нет ли левого html-кода, и если есть, то ругается. Может ругаться и антивирус.
«run_at» указывает, на каком этапе загрузки страницы скрипт будет выполняться.
Значение «document_start» = не загружено ничего (скрипт сможет вывести окошко alert или изменить window.location.href, но не сможет работать с document.body, его просто не будет)
Значение «document_end» = загружен код страницы, доступны document и document.body, но могут выполнять какие-то скрипты, которые ещё просто не успели выполниться
Значение «document_idle» = простой, когда никакой другой скрипт не выполняется. точнее ближайший период простоя с начала загрузки. Рекомендуется использовать для сложных ресурсоёмких скриптов, когда в то же время не требуется, чтобы скрипт выполнился прямо сразу после загрузки.
«js» — это массив путей к скриптам, выполняющимся с данными matches. Скрипт один, он будет называться end.js (это будет означать, что скрипт выполняется по document_end) и лежать в подпапке content_scripts в нашей папке расширения.
3. Создадим подпапку content_scripts, а в ней скрипт end.js.
В нём пропишем код

Javascript
1
document.body.innerHTML += '<div style="background-color: #FFFFFF; border-color: black; border-style: solid; color: black; font-family: 'Segoe UI'; font-size: 24px; position: fixed; left: 0; top: 0; width: 300px; height: 110px; padding: 8px; z-index: 1000000000">Ура!</div>';

Этот скрипт добавляет код баннера в код body той страницы, на которой выполняется.
Он тоже обязательно должен быть сохранён в UTF-8.
Примечание: стилей так много затем, чтобы перекрыть стили, применённые на сайте, куда вставляется баннер, и баннер на всех сайтах выглядел одинаково.
4. Установим расширение в браузер.
Откроем меню браузера кнопкой справа-вверху, затем пункт Инструменты, затем выберем пункт Расширения.
Или просто пройдём по ссылке chrome://extensions
Поставим флажок Режим разработчика, нажмем Загрузить распакованное расширение и укажем нашу папку.
5. Теперь зайдём на любую страницу, использующую http (см. выше)
mail.ru подойдёт, наш форум подойдёт, а google.ru нет.
Когда страница загрузится до конца, слева-вверху появится обещанный баннер.
6. На странице расширений мы можем отключить или удалить расширение с помощью соотв. кнопок.
А если мы решим изменить код manifest.json, end.js и т.д., то чтобы изменения применились, мы должны воспользоваться кнопкой Обновить.

6. Добавляем кнопочку на панель инструментов Chrome
Теперь оно будет ещё добавлять кнопочку на панель инструментов хрома и при нажатии на кнопку открывать диалоговое окно.
Нам понадобится Chrome 20 или новее.
1. Откроем наш файл manifest.json
Изменим его таким образом

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    "name": "Hello Chrome API",
    "version": "1.0.0.0",
 
    "manifest_version": 2,
 
    "content_scripts": [
        {
            "matches": [ "http://*/*" ],
            "js": [ "content_scripts/end.js" ],
            "run_at": "document_end"
        }
    ], // <-- здесь добавим запятую
 
    "browser_action": {
          "default_icon": {                       // иконки для кнопки 
            "19": "/images/br-icon19.png",         // иконка 19x19 пкс
            "38": "/images/br-icon38.png"          // иконка 38x38 пкс
          },
          "default_title": "Наша кнопка",         // всплывающая подсказка появл при наведении мыши на кнопку
          "default_popup": "popup/popup.html"     // страница появл во всплывающем окне откр при нажатии на кнопку
    }
}

и сохраним.
2. Создадим подпапки images и popup.
В images создадим две картинки, как указано в манифесте. Картинки могут быть одинаковыми, отображается только одна, в зависимости от расширения монитора (на больших мониторах — 38×38, на маленьких — 19×19).
В popup создадим файл popup.html и запишем в него такой код

HTML5
1
<div style="width: 300px; height: 100px">Ура!</div>

Сохранять тоже лучше в UTF-8.
3. Чтобы изменения вступили в силу, зайдём в Расширения и нажмём кнопку Обновить под нашим расширением.
4. На панели инструментов (слева вверху) появится кнопка с заданной иконкой.
Нажав на кнопку, увидим окошко, в котором будет надпись «Ура!».
Заметим, что размер окошка соответствует размеру, указанному в style корневого элемента popup.html (т.е. 300 x 100 пкс).
Если этот размер не указывать, окошко будет таким, чтобы в него поместилось содержимое этого элемента, т.е. надпись «Ура!».

7. Добавляем иконку в расширение к Chrome.
1. Подправим манифест.

JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
    "manifest_version": 2,
 
    "name": "Hello Chrome API",
    "version": "1.0.0.0",
 
    "manifest_version": 2,
 
    // добавим такие строчки
    "icons": {
        "48": "/images/icon48.png"
    },
 
    "content_scripts": [
        {
            "matches": [ "http://*/*" ],
            "js": [ "content_scripts/end.js" ],
            "run_at": "document_end"
        }
    ],
 
    "browser_action": {
          "default_icon": {
            "19": "/images/br-icon19.png",
            "38": "/images/br-icon38.png"
          },
          "default_title": "Наша кнопка",
          "default_popup": "popup/popup.html"
    }
}

2. Создадим файл icon48.png в папке images.
Это будет иконка расширения, отображаемая на странице расширений вместо дефолтного значка с фрагментом мозаики.
Примечание: помимо иконки размером в 48 пкс, мы можем сделать иконки размером 16, 32, 128 пкс, дописав внутрь ветви «icons:» соотв. строчки, не забывая про запятые. Но это нужно только если мы решим нарисовать действительно разные иконки для расширения. Просто растянув иконку под 16, 32, 128 пкс, мы ничего не достигнем — хром это сделает и сам, где оно понадобится. Я ограничился 48 пкс.
3. Обновим расширение на странице расширений.

8. Пишем простое расширение к Opera.
Расширение будет работать аналогично расширению к хрому.
Опера у меня версии 12. На других не пробовал. На 15 и выше, ещё раз, работать не должно.
Все файлы, как и с хромом, лучше сохранять в UTF-8, иначе кириллица будет отображаться неверно.
Приступим.
1. Создаём отдельную папку. Называем её, например, HelloOperaAPI.
2. В папке создаём файл настроек расширения.
Для этого запускаем блокнот, пишем такой код

XML
1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<widget xmlns="http://www.w3.org/ns/widgets">
    <name>Hello Opera API</name>
    <version>1.0.0.0</version>
</widget>

сохраняем в нашу папку под именем config.xml обязательно в кодировке UTF-8.
Такой формат представления данных называется XML.
<name> — имя расширения, которое будет видно в браузере
<version> — версия расширения, которую тоже будет видно в браузере
А вот никакого упоминания о скриптах в файле не будет. Мы просто создадим в папке с расширением подпапку include и файл .js с любым именем. Этого будет достаточно, чтобы скрипт работал.
3. Сказано — сделано. Создаём подпапку include и файл script_end.js

Javascript
1
2
3
4
5
6
7
// ==UserScript==
// @include [url]http://*/*[/url]
// ==/UserScript==
 
document.addEventListener("DOMContentLoaded", function() {
    document.body.innerHTML += '<div style="background-color: #FFFFFF; border-color: black; border-style: solid; color: black; font-family: 'Segoe UI'; font-size: 24px; position: fixed; left: 0; top: 0; width: 300px; height: 110px; padding: 8px; z-index: 1000000000">Ура!</div>';
}, false);

DOMContentLoaded позволяет создать эффект «document_end» из хрома (а сам юзерскрипт в опере выполняется фактически по «document_start».
«==UserScript==…==/UserScript==» вверху — это не просто комментарий. Там указываются настройки данного скрипта. @include здесь работает по принципу «matches» из Chrome.
Скрипт тоже обязательно должен быть сохранён в UTF-8.
Этот скрипт добавляет код баннера в код body той страницы, на которой выполняется.
4. В папке с расширением создадим index.html. Это фоновая страница. Мы можем оставить её пустой, потому что фоновых скриптов пока нет и она нам не нужна, но в расширении для оперы она должна быть обязательно.
5. Упакуем расширение в zip-архив. Сменим его расширение с zip на oex.
6. Запустим оперу, зайдём в меню, выберим пункт Дополнения, перетащим туда oex.
7. Проверим, появляются ли баннеры при загрузке страниц.

9. Добавляем кнопку на панель инструментов Opera
На панели инструментов справа-вверху появится кнопка. При нажатии на неё откроется всплывающее окошко с заданной страницей. Всё как в хроме.
Нам понадобится Chrome 20 или новее.
1. Изменим фоновую страницу index.html таким образом

HTML5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!doctype html>
<html lang="en">
    <head>
        <script>
            window.addEventListener("load", function(){
                var ToolbarUIItemProperties = {
                    title: "Наша кнопка",
                    icon: "/images/br-icon19.png",
                    popup: {
                        href: "popup/popup.html",
                        width: 300,
                        height: 100
                    }
                }
                opera.contexts.toolbar.addItem(opera.contexts.toolbar.createItem(ToolbarUIItemProperties));
            }, false);
        </script>
    </head>
    <body>
    </body>
</html>

и сохраним.
opera.contexts.toolbar — это класс из Opera API.
У хрома тоже есть API — chrome.*.
2. Создадим подпапки images и popup.
В images создадим картинку, как указано в манифесте.
В popup создадим файл popup.html и запишем в него такой код

Примечание:

в отличие от хрома, размер окошка формируется не через style корневого элемента popup.html, а через width и height, прописанные в ToolbarUIItemProperties в index.html.
3. Чтобы изменения вступили в силу, удалим расширение и установим его обратно.
4. На панели инструментов (слева вверху) появится кнопка с заданной иконкой.
Нажав на кнопку, увидим окошко, в котором будет надпись «Ура!».

10. Добавляем иконку в расширение Opera.
1. Путь к иконке указывается в файле настроек — config.xml.
За него отвечает тэг icon с атрибутом src, в котором указывается относительный путь к иконке (относительно основной папке расширения).
Откроем файл config.xml.
Внутри <widget> добавим строчку

XML
1
<icon src="/images/icon48.png" />

Должно получиться так:

XML
1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<widget xmlns="http://www.w3.org/ns/widgets">
    <name>Hello Opera API</name>
    <version>1.0.0.0</version>
    <icon src="/images/icon48.png" />
</widget>

2. Создадим файл icon48.png (это может любая картинка 48×48 пкс) в папке images.
3. Перепакуем и переустановим расширение.
В списке расширений вместо дефолтного значка (сиреневого фрагмента мозаики) будет отображаться наша иконка.


11. Пишем простое расширение Firefox.
12. Добавляем панель инструментов в Firefox.
13. Добавляем иконку в расширение Firefox.

Продолжение следует…
Пока что читаем здесь и экспериментируем.

Пара советов и ссылок на закуску
Конечно, информации из этого топика ещё недостаточно, чтобы написать любое расширение к браузеру Chrome, Firefox или Opera.
Но общее представление о расширениях и особенностях их разработки вы получили.
Поэтому, воспользовавшись данными ссылками в связке с гуглом, Вы при желании сможете углубиться в расширения и писать крупные, уникальные продукты.
Здесь же обещанные магазины расширений.
CHROME
https://developer.chrome.com/extensions/ — официальная документации.
Вот описание в ней контентных скриптов и кнопки browser-action:
https://developer.chrome.com/e… nt_scripts
https://developer.chrome.com/e… wserAction
https://chrome.google.com/webs… ions?hl=ru — магазин.
OPERA
https://addons.opera.com/ru/extensions/ — магазин.
FIREFOX
https://addons.mozilla.org/ru/firefox/ — магазин.

Добавлено через 2 минуты
В процессе написания топика нашёл на форуме баг.
В кодах JSON, XML и др. текст «https://www.cyberforum.ru/images/картинка.png» заменяется на «https://www.cyberforum.ru/images/картинка.png».
То есть если написать

JSON
1
2
3
{
    "48": "ЗДЕСЬ_НЕТ_НИЧЕГОimages/icon48.png"
}

то получится

JSON
1
2
3
{
    "48": "https://www.cyberforum.ru/images/icon48.png"
}

Правда, написав

JSON
1
2
3
{
    "48": "/images/icon48.png"
}

мы получим то, что ожидали. Но иной код от этого может сделаться также нечитабельным, как и из-за добавления «https://www.cyberforum.ru/».



2



Понравилась статья? Поделить с друзьями:
  • Как написать сатановскому евгению яновичу письмо
  • Как написать саранхэ на корейском
  • Как написать свое ранобэ
  • Как написать сапера на питоне
  • Как написать сапер на javascript