Плагин для браузера как написать

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

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

Существует 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

В конце 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() полный код скрипта из проекта со снежинками и сохранить файл.

Проверка

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

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

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

The Chrome Web Store no longer accepts Manifest V2 extensions. Please use Manifest V3 when building new extensions. You will find a section on upgrading in the navigation tree at the left, including the Manifest V2 support timeline.

Extensions are made of different, but cohesive, components. Components can include background scripts, content scripts, an options page, UI elements and various logic files. Extension components are created with web development technologies: HTML, CSS, and JavaScript. An extension’s components will depend on its functionality and may not require every option.

This tutorial will build an extension that allows the user to change the background color of any page on developer.chrome.com. It will use many core components to give an introductory demonstration of their relationships.

To start, create a new directory to hold the extension’s files.

The completed extension can be found here.

Create the manifest

Extensions start with their manifest. Create a file called manifest.json and include the following code.

{
"name": "Getting Started Example",
"version": "1.0",
"description": "Build an Extension!",
"manifest_version": 2
}

The directory holding the manifest file can be added as an extension in developer mode in its current state.

  1. Open the Extension Management page by navigating to chrome://extensions.
    • The Extension Management page can also be opened by clicking on the Chrome menu, hovering over More Tools then selecting Extensions.
  2. Enable Developer Mode by clicking the toggle switch next to Developer mode.
  3. Click the LOAD UNPACKED button and select the extension directory.

Load Extension

Ta-da! The extension has been successfully installed. Because no icons were included in the manifest, a generic toolbar icon will be created for the extension.

Add instruction

Although the extension has been installed, it has no instruction. Introduce a background script by creating a file titled background.js, and placing it inside the extension directory.

Background scripts, and many other important components, must be registered in the manifest. Registering a background script in the manifest tells the extension which file to reference, and how that file should behave.

{
"name": "Getting Started Example",
"version": "1.0",
"description": "Build an Extension!",
"background": {
"scripts": ["background.js"],
"persistent": false
},
"manifest_version": 2
}

The extension is now aware that it includes a non-persistent background script and will scan the registered file for important events it needs to listen for.

This extension will need information from a persistent variable as soon as it’s installed. Start by including a listening event for runtime.onInstalled in the background script. Inside the onInstalled listener, the extension will set a value using the storage API. This will allow multiple extension components to access that value and update it.

chrome.runtime.onInstalled.addListener(function() {
chrome.storage.sync.set({color: '#3aa757'}, function() {
console.log("The color is green.");
});
});

Most APIs, including the storage API, must be registered under the "permissions" field in the manifest for the extension to use them.

{
"name": "Getting Started Example",
"version": "1.0",
"description": "Build an Extension!",
"permissions": ["storage"],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"manifest_version": 2
}

Navigate back to the extension management page and click the Reload link. A new field, Inspect views, becomes available with a blue link, background page.

Inspect Views

Click the link to view the background script’s console log, «The color is green.«

Introduce a user interface

Extensions can have many forms of a user interface, but this one will use a popup. Create and add a file titled popup.html to the directory. This extension uses a button to change the background color.

<!DOCTYPE html>
<html>
<head>
<style>
button {
height: 30px;
width: 30px;
outline: none;
}
</style>
</head>
<body>
<button id="changeColor"></button>
</body>
</html>

Like the background script, this file needs to be designated as a popup in the manifest under page_action.

{
"name": "Getting Started Example",
"version": "1.0",
"description": "Build an Extension!",
"permissions": ["storage"],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"page_action": {
"default_popup": "popup.html"
},
"manifest_version": 2
}

Designation for toolbar icons is also included under page_action in the default_icons field. Download the images folder here, unzip it, and place it in the extension’s directory. Update the manifest so the extension knows how to use the images.

{
"name": "Getting Started Example",
"version": "1.0",
"description": "Build an Extension!",
"permissions": ["storage"],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"page_action": {
"default_popup": "popup.html",
"default_icon": {
"16": "images/get_started16.png",
"32": "images/get_started32.png",
"48": "images/get_started48.png",
"128": "images/get_started128.png"
}
},
"manifest_version": 2
}

Extensions also display images on the extension management page, the permissions warning, and favicon. These images are designated in the manifest under icons.

{
"name": "Getting Started Example",
"version": "1.0",
"description": "Build an Extension!",
"permissions": ["storage"],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"page_action": {
"default_popup": "popup.html",
"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"
},
"manifest_version": 2
}

If the extension is reloaded at this stage, it will include a grey-scale icon, but will not contain any functionality differences. Because page_action is declared in the manifest, it is up to the extension to tell the browser when the user can interact with popup.html.

Add declared rules to the background script with the declarativeContent API within the runtime.onInstalled listener event.

chrome.runtime.onInstalled.addListener(function() {
chrome.storage.sync.set({color: '#3aa757'}, function() {
console.log('The color is green.');
});
chrome.declarativeContent.onPageChanged.removeRules(undefined, function() {
chrome.declarativeContent.onPageChanged.addRules([{
conditions: [new chrome.declarativeContent.PageStateMatcher({
pageUrl: {hostEquals: 'developer.chrome.com'},
})
],
actions: [new chrome.declarativeContent.ShowPageAction()]
}]);
});
});

The extension will need permission to access the declarativeContent API in its manifest.

{
"name": "Getting Started Example",
...
"permissions": ["declarativeContent", "storage"],
...
}

Popup

The browser will now show a full-color page action icon in the browser toolbar when users navigate to a URL that contains "developer.chrome.com". When the icon is full-color, users can click it to view popup.html.

The last step for the popup UI is adding color to the button. Create and add a file called popup.js with the following code to the extension directory.

let changeColor = document.getElementById('changeColor');

chrome.storage.sync.get('color', function(data) {
changeColor.style.backgroundColor = data.color;
changeColor.setAttribute('value', data.color);
});

This code grabs the button from popup.html and requests the color value from storage. It then applies the color as the background of the button. Include a script tag to popup.js in popup.html.

<!DOCTYPE html>
<html>
...
<body>
<button id="changeColor"></button>
<script src="popup.js"></script>
</body>
</html>

Reload the extension to view the green button.

Layer logic

The extension now knows the popup should be available to users on developer.chrome.com and displays a colored button, but needs logic for further user interaction. Update popup.js to include the following code.

let changeColor = document.getElementById('changeColor');
...
changeColor.onclick = function(element) {
let color = element.target.value;
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
chrome.tabs.executeScript(
tabs[0].id,
{code: 'document.body.style.backgroundColor = "' + color + '";'});
});
};

The updated code adds an onclick event on the button, which triggers a programmatically injected content script. This turns the background color of the page the same color as the button. Using programmatic injection allows for user-invoked content scripts, instead of auto inserting unwanted code into web pages.

The manifest will need the activeTab permission to allow the extension temporary access to the tabs API. This enables the extension to call tabs.executeScript.

{
"name": "Getting Started Example",
...
"permissions": ["activeTab", "declarativeContent", "storage"],
...
}

The extension is now fully functional! Reload the extension, refresh this page, open the popup and click the button to turn it green! However, some users may want to change the background to a different color.

Extensions can not inject content scripts on internal Chrome pages like «chrome://extensions». Be sure to try out the extension on a real webpage like https://google.com.

Give users options

The extension currently only allows users to change the background to green. Including an options page gives users more control over the extension’s functionality, further customizing their browsing experience.

Start by creating a file in the directory called options.html and include the following code.

<!DOCTYPE html>
<html>
<head>
<style>
button {
height: 30px;
width: 30px;
outline: none;
margin: 10px;
}
</style>
</head>
<body>
<div id="buttonDiv">
</div>
<div>
<p>Choose a different background color!</p>
</div>
</body>
<script src="options.js"></script>
</html>

Then register the options page in the manifest,

{
"name": "Getting Started Example",
...
"options_page": "options.html",
...
"manifest_version": 2
}

Reload the extension and click DETAILS.

Inspect Views

Scroll down the details page and select Extension options to view the options page, although it will currently appear blank.

Extension Options

Last step is to add the options logic. Create a file called options.js in the extension directory with the following code.

let page = document.getElementById('buttonDiv');
const kButtonColors = ['#3aa757', '#e8453c', '#f9bb2d', '#4688f1'];
function constructOptions(kButtonColors) {
for (let item of kButtonColors) {
let button = document.createElement('button');
button.style.backgroundColor = item;
button.addEventListener('click', function() {
chrome.storage.sync.set({color: item}, function() {
console.log('color is ' + item);
})
});
page.appendChild(button);
}
}
constructOptions(kButtonColors);

Four color options are provided then generated as buttons on the options page with onclick event listeners. When the user clicks a button, it updates the color value in the extension’s global storage. Since all of the extension’s files pull the color information from global storage no other values need to be updated.

Take the next step

Congratulations! The directory now holds a fully-functional, albeit simplistic, Chrome extension.

What’s next?

  • The Chrome Extension Overview backs up a bit, and fills in a lot of detail about the Extensions architecture in general, and some specific concepts developers will want to be familiar with.
  • Learn about the options available for debugging Extensions in the debugging tutorial.
  • Chrome Extensions have access to powerful APIs above and beyond what’s available on the open web. The chrome.* APIs documentation will walk through each API.
  • The developer’s guide has dozens of additional links to pieces of documentation relevant to advanced extension creation.

В этой инструкции я покажу, как создать расширение для браузера 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. Нажмите Отправить на проверку в правом верхнем углу.

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

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

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

Спорим, вы прямо сейчас используете какие-то браузерные расширения. Некоторые из них очень полезны и популярны, например, блокировщики рекламы, менеджеры паролей или средства для просмотра 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.

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

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

Понравилась статья? Поделить с друзьями:
  • Пищат или пещат как правильно пишется
  • Плагиат или благиат как правильно пишется
  • Пищание как пишется
  • Плавящийся металл как пишется
  • Пищали птенцы как пишется правильно