Привет, Хабр. Меня зовут Алексей, я бэкенд-разработчик C#. Хочу рассказать о том как я узнал что такое native messaging в браузерах и какие задачи можно с его помощью решать. В одном проекте я разрабатывал десктопную утилиту, которая должна была уметь обмениваться сообщениями с веб-страницами в браузере, чтобы в том числе управлять их содержимым и как угодно взаимодействовать с ними. Расскажу о том, как удалось решить такую задачу и покажу результат работы небольшого приложения с таким взаимодействием.

Постановка задачи

Мне нужно было сделать десктопное C#-приложение, которое позволяло бы кликнуть на какой-нибудь UI-элемент и посмотреть его свойства. Оно должно было работать со всеми популярными браузерами, а также — взаимодействовать с HTML-данными веб-страниц, из которых извлекались бы свойства UI-элементов (всего около 30 свойств), селектор и индекс. Все элементы можно сохранить для дальнейшего использования в сторонних программах (назовём их "роботы"), которые с этими элементами должны взаимодействовать. Также нужна была возможность дорабатывать API для взаимодействий с элементами и браузером.


Итак, сначала было решено взять за основу какую-нибудь популярную библиотеку для автотестирования, например, Selenium. Но вскоре после экспериментов выяснилось, что:

  • Мешает использование веб-драйверов, к которым робот не сможет прикрепиться и от которых «толстеет» дистрибутив (это было неприемлемо в рамках проекта).

  • Всё сводится к использованию ExecuteScript, и API таких библиотек не закрывает все потребности.

  • Мы не сможем работать непосредственно с API браузера. И вообще, Selenium становится совсем ненужной прокладкой, когда существует способ напрямую общаться с браузером и содержимым веб-страницы.

Чтобы решить все эти проблемы, прекрасно подошёл вариант с написанием браузерного расширения, которое предоставляет доступ к широкому функционалу браузерного API, а также к полному управлению веб-страницами. Более того, оказалось что браузеры предоставляют возможность обмена сообщениями между расширением и приложением запущенным на машине, что открывает для нас все необходимые возможности. Эта технология называется native messaging, а для связи между браузером и приложением сам браузер запускает native messaging host - исполняемая программа для обмена сообщениями, которую нам тоже далее предстоит написать.

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

Расширение

Для начала создадим папку с тремя файлами:

manifest.json — обязательный компонент, содержит описание расширения, перечень прав, разрешённых хостов, дополнительных подключаемых скриптов и прочее. Для наших задач подходит следующий манифест (я не буду подробно описывать его возможности, как и браузерный API):

{
  "name": "ExampleExtension",
  "description": "Get properties of any ui element on page",
  "version": "1.0", // Версия расширения
  "manifest_version": 3, // Текущая актуальная версия манифеста
  "content_scripts": [ // Подключение скрипта, работающего со страницей
    {
      "js": [ "content.js" ],
      "matches": [ "http://*/*", "https://*/*", "file://*/*" ] // Разрешённые страницы
    }
  ],
  "permissions": [ "nativeMessaging", "webRequest", "webNavigation", "debugger", "scripting", "activeTab", "tabs" ], // Перечень разрешений, каждое из которых отвечает за работу с определённым API браузера
  "host_permissions": [ "http://*/*", "https://*/*", "file://*/*" ],
  "background": {
    "service_worker": "service_worker.js" // Подключение скрипта, работающего в фоне с событиями браузера
  }
}

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

var isCapturing = false; // Если true, то мы находимся в режиме захвата
var capturedElement = null; // Храним здесь захваченный элемент

document.onkeydown = function (e) {
    if (e.which == 17) { // Клавиша Ctrl
        isCapturing = false;
        e.preventDefault(); // Предовратим реакцию браузера на клавишу
        sendElement(capturedElement);
    }
};

document.onmousemove = function (point) {
    if (!isCapturing)
        return;
    try {
        capturedElement = document.elementFromPoint(point.clientX, point.clientY);
        highlightElement(capturedElement, "green solid 3px");
    }
    catch { }
};

function sendElement(targetElement) {
    let attrs = {}; // Запишем в объект свойства, которые хотим отправить
    attrs['Text'] = targetElement.innerText;
    attrs['Class'] = targetElement.getAttribute('class');
    attrs['X'] = targetElement.getBoundingClientRect()['x'];
    attrs['Y'] = targetElement.getBoundingClientRect()['y'];
    let result = { message: "Element", element: attrs };
    chrome.runtime.sendMessage(result); // Отправка сообщения в service worker
}

function highlightElement(element, outline) {
    if (window.prevElement) { // Запоминаем элемент, чтобы не подсвечивались несколько одновременно
        window.prevElement.style.outline = window.prevElementOutline;
    }
    if (element) {
        const elementOutline = element.style.outline;
        setTimeout(function () {
            element.style.outline = elementOutline;
        }, 5000);

        window.prevElementOutline = element.style.outline;
        window.prevElement = element;
        element.style.outline = outline;
    } else {
        delete window.prevElementOutline;
        delete window.prevElement;
    }
}

Здесь мы обрабатываем два события: движение мыши и нажатие клавиши Ctrl. Если мы находимся в режиме захвата (isCapturing == true, переключать в true будем из service worker), то обработчик первого события будет запоминать UI-элемент, на который мы навелись, и подсвечивать его границы. Обработчик второго события отправит элемент в service worker с помощью вызова API chrome.runtime, который позволяет также получать информацию о манифесте.

service_worker.js — при запуске расширения начинает работать в фоне в единственном экземпляре. В нём прописаны обработчики событий браузера и сообщений из контент-скриптов.

var port = null; // Имя порта, через который свяжем расширение и хост
Initialize();

// Слушаем сообщения из content.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
    if (msg.message == "Element") {
        port.postMessage({ Text: "Element", Data: JSON.stringify(msg.element) }); // API для отправки сообщения хосту
        ExecuteScriptInAllTabs(() => { isCapturing = false; }); // Отключаем режим захвата во вкладках
    }
});

async function Initialize() {
    ConnectPort();
    if (port != null) {
        port.postMessage({ Text: "Connect", Data: "" }); // Если успешно связались с хостом, то сообщим об этом в консоль разработчика
    }
    else {
        await setTimeout(() => { Initialize(); }, 1000); // Повтор подключения к хосту
    }
}

async function ConnectPort() {
    port = chrome.runtime.connectNative('com.hellohabr.msg'); // Пробуем подключиться к хосту
    port.onDisconnect.addListener(async () => { // Ожидаем разрыв соединения
        port = null;
        console.warn("Disconnected");
        if (chrome.runtime.lastError) {
            console.warn(chrome.runtime.lastError.message);
            await setTimeout(() => { Initialize(); }, 1000); // Повтор на случай разрыва
        }
    });
    port.onMessage.addListener(function (message) { // Слушаем сообщения от хоста
        let msg = JSON.parse(JSON.stringify(message));
        console.log("Received " + JSON.stringify(msg));

        if (msg.command == "Capture") { // Пришла команда включить режим захвата
            ExecuteScriptInAllTabs(() => { isCapturing = true; });
        }
    });
}

function ExecuteScriptInAllTabs(script) {
    chrome.tabs.query({}, function (tabs) { // API для взаимодействия со вкладками
        tabs.forEach(function (tab) { // Пусть режим захвата будет активен во всех возможных вкладках
            if (!tab.url.startsWith("http")) { // Работаем только с сайтами
                return;
            }
            try {
                chrome.scripting.executeScript({ // API для проигрывания JS-скрипта в указанной вкладке
                    target: { tabId: tab.id },
                    func: script
                });
            }
            catch (err) {
                console.warn(err);
            }
        });
    });
}

Как только мы активируем наше расширение в браузере, запустится service worker. В нём мы с помощью chrome.runtime.connectNative('com.hellohabr.msg') в первую очередь попытаемся установить соединение с нашим native messaging host (его напишем позже). Для этого в манифесте необходимо разрешение nativeMessaging.

Ещё нам необходимо при запуске приложения создавать в реестре текущего пользователя ветку SOFTWARE\Microsoft\Edge\NativeMessagingHosts\com.hellohabr.msg со значением в виде пути к ещё одному манифесту, который необходим для работы native messaging (создадим его чуть позже). Почему? Дело в том, что при запуске расширения браузер будет пытаться запустить exe-файл нашего хоста, путь к которому и прописан в дополнительном манифесте.

Примечание: лично я сталкивался с проблемой что Chrome может не подключиться к хосту без ключа SOFTWARE\WOW6432Node\Google\Chrome\NativeMessagingHosts\com.saluterpa.msg. Кроме того, если вы упорно видите в консоли нижеприведённую ошибку, то есть вероятность что стоит воспользоваться Process monitor и проследить, куда ходит браузер в реестре. К примеру, у меня он пытался прочитать значение из LOCAL_MACHINE, а некоторые браузеры могут и вовсе заглядывать в ветку для Chrome. Значения ключей в таких ветках должны быть те же самые: путь к манифесту для native messaging.

То, что потратило несколько часов моей жизни
То, что потратило несколько часов моей жизни

К слову, в официальной документации хорошо описаны всевозможные и ошибки и что с ними делать: https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging, раздел debugging.

Вернёмся к скрипту. При успешном подключении обрабатывать сообщения от хоста будет port.onMessage.addListener. При получении команды «захватить» мы будем взаимодействовать с content.js посредством API chrome.scripting, который позволяет нам во время исполнения внедрять в страницу JS-скрипт или CSS (необходимо разрешение scripting в манифесте). Более каноничным способом общения с content.js являются обратные вызовы в методе chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {}}). Но нам это не подходит, так как мы собираемся активировать захват по запросу от хоста.

В результате, получается примерно такая схема общения между компонентами:

Архитектура взаимодействия всех наших компонентов
Архитектура взаимодействия всех наших компонентов

Добавление расширения

В браузере перейдём в панель управления расширения. Это можно сделать одним из двух способов: в адресной строке ввести edge://extensions или в браузере нажать кнопку Расширения → Управление расширениями.

Теперь включим режим разработчика в левой части панели. Для этого нажмём кнопку Загрузить распакованное.

Укажем папку, в которой создали manifest.json. Видим наше расширение. Его ID ещё пригодится нам позже.

Нажав «Служебный сценарий», мы сможем увидеть все ошибки service worker и всё что мы выводим в коде в консоль.

Откуда взялось "Connected to NMHost" узнаем далее
Откуда взялось "Connected to NMHost" узнаем далее

Пишем хост

Как только мы запустим расширение, браузер будет стараться запустить exe-файл, путь к которому мы указали в манифесте для native messaging. Одно расширение держит один такой .exe (хост). Создадим .NET-проект и в нём один класс Program. Его переменные и Main выглядят так:

static NamedPipeServerStream? s_pipe;
static StreamReader? s_pipeReader;
static StreamWriter? s_pipeWriter;

static void Main(string[] args)
{
    s_pipe = null;
    s_pipeReader = null;
    s_pipeWriter = null;
    BrowserMessage message;
    while ((message = Read()) != null) // Ждём сообщение от браузера
    {
        if (message.Text == "Connect")
        {
            StartNamedPipe(); // Откроем pipe для общение с нашим приложением
            Write("Connected to NMHost"); // Ответим браузеру, что всё хорошо
        }
        else if (message.Text == "Element")
        {
            s_pipeWriter.WriteLine(message.Data); // Отправим результат захвата в приложение
            s_pipeWriter.Flush();
        }
    }
}

Для общения с нашим приложением будем использовать NamedPipes. Этого вполне достаточно, потому что позволяет постоянно поддерживать двустороннюю связь. Теперь запустим pipe и ждём сообщений от приложения:

static void StartNamedPipe()
{
  Task.Run(() =>
  {
      while (true)
      {
          if (s_pipe == null || !s_pipe.IsConnected)
          {
              // Ниже инициализируем pipe
              s_pipe = new NamedPipeServerStream($"example_pipe_edge", PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
                  PipeOptions.Asynchronous | PipeOptions.WriteThrough, 0, 0);
              s_pipe.WaitForConnection();
              s_pipeReader = new StreamReader(s_pipe);
              s_pipeWriter = new StreamWriter(s_pipe);
              Write("Client connected");

              string appMsg;
              while ((appMsg = s_pipeReader.ReadLine()) != null) // Ожидаем команд от приложения
              {
                  Write(appMsg); // Передадим сообщение от приложения браузеру
              }

              Write("Lost connection with client");
              s_pipe = null;
          }
      }
  });
}

Для общения с браузером используется стандартный ввод-вывод, который он перехватывает. Выглядит это так:

static BrowserMessage Read()
{
    var stdin = Console.OpenStandardInput();
    var lengthBytes = new byte[4];
    stdin.Read(lengthBytes, 0, 4);
    var length = BitConverter.ToInt32(lengthBytes, 0);
    var buffer = new char[length];

    using (var reader = new StreamReader(stdin))
    {
        var idx = 0;
        while (idx < length && reader.Peek() >= 0)
        {
            idx += reader.Read(buffer, idx, buffer.Length - idx);
        }
    }

    return JsonConvert.DeserializeObject<BrowserMessage>(new string(buffer));
}

static void Write(string command)
{
    var msgdata = "{\"command\": \"" + command + "\"}";
    var dataLength = msgdata.Length;
    var stdout = Console.OpenStandardOutput();
    stdout.WriteByte((byte)((dataLength >> 0) & 0xFF));
    stdout.WriteByte((byte)((dataLength >> 8) & 0xFF));
    stdout.WriteByte((byte)((dataLength >> 16) & 0xFF));
    stdout.WriteByte((byte)((dataLength >> 24) & 0xFF));
    Console.Write(msgdata);
}

Остаётся только структура сообщения, которое мы будем получать от браузера:

class BrowserMessage
{
    public string? Text { get; set; }
    public string? Data { get; set; }
}

Приложение

Создадим WPF-приложение. В проекте необходимо создать тот самый манифест для native messaging. Теоретически, можем создать его где угодно, но придётся прописывать в коде путь к нему. Назовём его edgemanifest.json:

{
  "name": "com.hellohabr.msg",
  "description": "Test messaging host",
  "path": "ExampleNMHost.exe",
  "type": "stdio",
  "allowed_origins": [
    "chrome-extension://<ЗДЕСЬ ID ВАШЕГО РАСШИРЕНИЯ В БРАУЗЕРЕ>/"
  ]
}

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

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

var path = Path.GetFullPath("edgemanifest.json");
var value = Microsoft.Win32.Registry.CurrentUser.OpenSubKey($@"SOFTWARE\Microsoft\Edge\NativeMessagingHosts\com.hellohabr.msg")?.GetValue("").ToString();
if (Microsoft.Win32.Registry.CurrentUser.OpenSubKey($@"SOFTWARE\Microsoft\Edge\NativeMessagingHosts\com.hellohabr.msg") == null || value != path)
{
    Microsoft.Win32.Registry.CurrentUser.CreateSubKey($@"SOFTWARE\Microsoft\Edge\NativeMessagingHosts");
    Microsoft.Win32.Registry.CurrentUser.CreateSubKey($@"SOFTWARE\Microsoft\Edge\NativeMessagingHosts\com.hellohabr.msg");
    var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Edge\NativeMessagingHosts\com.hellohabr.msg", true);
    key.SetValue("", path);
}

if (WaitNamedPipe(@"\\.\pipe\" + $"example_pipe_edge", 0))
{
    connection = new NamedPipeClientStream($"example_pipe_edge");
    try
    {
        connection.Connect();
        connection.ReadMode = PipeTransmissionMode.Byte;
        streamWriter = new StreamWriter(connection);
        streamReader = new StreamReader(connection);
    }
    catch { }
}

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

private void Button_Click(object sender, RoutedEventArgs e)
{
    streamWriter.WriteLine("Capture");
    streamWriter.Flush();
    label.Text= streamReader.ReadLine();
}

Соберём приложение вместе с манифестом и файлом хоста. На выходе должно получиться примерно так:

Да, всё в кучу
Да, всё в кучу

Запускаем

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

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

Для корректной работы необходимо переключиться на вкладку с сайтом и обновить её. На вкладке с менеджером расширений работать не будет. Жмём на нашу печальную кнопку, переходим в браузер, наводим на нужный элемент и жмём Ctrl:

Подсветили элемент
Подсветили элемент
Получили что хотели
Получили что хотели

Заключение

В итоге мы получили приложение, позволяющее при небольшом дополнении функционала свободно взаимодействовать с браузером: запускать js-скрипты, работать с контентом страницы, управлять вкладками и прочие возможности которые даёт нам chrome API и наша фантазия. Данная инструкция подходит для большинства chromium браузеров, для Firefox небольшие отличия заключаются в некоторых полях манифеста и в названии функций вызова api браузера. Также перед загрузкой расширения нужно упаковать его в xpi формат, все эти нюансы описаны в документации Firefox.

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


Комментарии (0)