Привет, Хабр. Меня зовут Алексей, я бэкенд-разработчик 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 и всё что мы выводим в коде в консоль.

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