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