- PVSM.RU - https://www.pvsm.ru -
Любой десктопный монолит имеет один фатальный изъян: если UI поток падает - умирает вся бизнес-логика. Я решил это применив бэкенд-подход на десктопе.
Задача была амбициозной: создать единый центр управления рабочим местом. Чтобы одной кнопкой (или по расписанию) переключать ПК и комнату между режимами “кодинг”, “игры”, “стрим”. Это значит: управлять умным домом (Home Assistant), блокировать отвлекающие процессы и сайты, запускать нужный софт, контролировать медиа (Spotify) - и всё это через плагины.
Когда я начал проектировать архитектуру, я столкнулся с классической проблемой десктопного софта: жесткая зависимость бизнес-логики от UI.
В веб-разработке мы привыкли к надежности: если у пользователя зависла вкладка браузера, база данных на сервере не умирает. В десктопе (WPF/Avalonia) мы часто полагаемся на монолитную событийную модель. Если UI-поток зависает (тяжелый рендеринг) или приложение падает (ошибка в XAML, утечка памяти) - умирает вся бизнес-логика.
Для моего приложения это критично. Представьте сценарий:
Софт заблокировал Steam, включил рабочий свет и запустил таймер фокуса.
UI упал (например, драйвер видеокарты моргнул, или я накосячил в верстке).
Процесс умер. Блокировка не снялась, таймеры сбросились, свет остался в “рабочем” режиме навсегда.
Чтобы получить надежность уровня сервера, я решил применить бэкенд-подход на десктопе: Headless Host + Thin Client.
Я разделил приложение на два независимых процесса, которые общаются через локальную сеть (localhost).
Это .NET Worker Service. Технически это фоновый процесс (собирается как WinExe, чтобы не мелькало черное окно консоли).
Ответственность: Хранение стейта, таймеры, загрузка модулей, работа с API (Home Assistant, Spotify), мониторинг процессов (через ETW), управление браузером (через Named Pipes).
Жизненный цикл: Работает абсолютно невидимо (headless). Ему все равно, запущен ли UI.
Потребление: ~20-30 МБ RAM в простое.
Это приложение на Avalonia UI.
Ответственность: Только визуализация и сбор ввода. Никакой бизнес-логики.
Жизненный цикл: Пользователь может открывать, закрывать, убивать процесс через Диспетчер задач - на работу системы это не влияет.
Глобально архитектура проекта и зависимости выглядят так:
+-------------------------+
| Browser (Firefox) |
| Axorith Extension |
+------------+------------+
^
| native messaging
v
+------+------+
| Axorith.Shim|
| (native host|
| process) |
+-------------+
+---------------------------+ +---------------------------+
| Axorith.Client | gRPC | Axorith.Host |
| (Avalonia UI, Reactive) | <=======> | (ASP.NET Core gRPC) |
+--------------+------------+ +---------------+-----------+
| |
| ProjectReference | ProjectReference
v v
+---------+------------+ +----------+-----------+
| Axorith.Core | <------------> | Axorith.Contracts |
| (session engine, | uses gRPC | (generated gRPC |
| module orchestration)| contracts | contracts, messages)|
+----------+-----------+ +----------+-----------+
| ^
| ProjectReference |
v |
+---------+-------------------------------+ |
| Axorith.Shared | |
| (Platform / Utils / Exceptions / | |
| ApplicationLauncher sub‑projects) | |
+----------------------+------------------+ |
^ |
| |
| ProjectReference |
| |
+-------------------------+----------------+ +-----+-----------------+
| Axorith.Modules (plugins) | | Axorith.Sdk |
| (ApplicationLauncher, JetBrainsIDE, |---->| (IModule/ISetting/ |
| SiteBlocker, SpotifyPlayer, ...) | | IAction contracts, |
| |---->| ValidationResult, |
| depend on: | | Platform, etc.) |
| - Axorith.Sdk | +------------------------+
| - Axorith.Shared.* |
+-----------------------------------------+
Для общения между процессами я выбрал gRPC. Почему не Named Pipes или REST?
Жесткие контракты (Protobuf): Если я поменял поле в контракте, компилятор заставит меня обновить и клиент, и сервер. Никаких разъехавшихся JSON-моделей.
Стриминг: Это киллер-фича для реактивного UI.
Самая сложная часть - сделать так, чтобы UI мгновенно реагировал на изменения в хосте. Обычный polling (опрос сервера раз в секунду) дает неприятную задержку интерфейса и плодит пустые запросы. Я использовал Server-Side Streaming поверх HTTP/2.
Proto-контракт (modules.proto):
syntax = "proto3";
package axorith.contracts;
service ModulesService {
// Клиент подписывается на поток обновлений настроек
rpc StreamSettingUpdates (StreamSettingUpdatesRequest) returns (stream SettingUpdate);
// ... другие методы (InvokeAction, UpdateSetting)
}
message SettingUpdate {
string module_instance_id = 1;
string setting_key = 2;
SettingProperty property = 3; // Тип изменения (Value, Visibility, Label, etc.)
oneof value {
string string_value = 4;
bool bool_value = 5;
int32 int_value = 6;
double number_value = 7;
ChoiceList choice_list = 8;
}
}
На стороне Хоста (C#): У меня есть сервис SettingUpdateBroadcaster. Он использует System.Reactive (Rx.NET). Когда любой модуль меняет свое состояние (например, трек в плеере переключился), это событие попадает в Subject. gRPC-сервис подписывается на этот Subject и пушит данные в открытый стрим клиенту.
На стороне Клиента: Клиент при старте открывает стрим. Как только приходит пакет, он обновляет ViewModel. Благодаря ReactiveUI интерфейс перерисовывается мгновенно. Для пользователя это выглядит как обычное монолитное приложение - никаких сетевых лагов.
Разделение на два процесса рождает проблему: как они будут общаться? Использовать локальные TCP-порты (например, localhost:5901) - плохая идея. Порт может быть занят, а главное - любой локальный процесс (или вирус) сможет подключиться к нашему бэкенду. Плюс, Windows Firewall может выдать пугающее окно при старте.
Я перевел gRPC на нативный IPC (Inter-Process Communication):
На Windows Kestrel поднимает Named Pipes (ListenNamedPipe) с жесткими ACL-правами: доступ имеет только текущий пользователь ОС.
На Linux/macOS используются Unix Domain Sockets (ListenUnixSocket).
При старте Хост создает пайп/сокет и записывает путь к нему в файл host-info.json в директорию %AppData%Axorith.
Клиент при запуске читает этот JSON, берет ipcEndpoint и устанавливает защищенное gRPC-соединение. Никаких конфликтов портов и полная изоляция."
Пользователь не хочет запускать два файла. Я реализовал схему “Client-First”:
Юзер запускает Axorith.Client.exe.
Клиент читает host-info.json. Если Хост не запущен, Клиент сам поднимает Axorith.Host.exe в фоне.
При закрытии окна крестиком, Клиент не умирает, а сворачивается в системный трей (режим --tray).
Через иконку в трее можно управлять бэкендом (Restart/Stop Хоста) или развернуть UI обратно.
Сценарий краш-теста:
Запущена сессия “Work”. Играет музыка, заблокирован YouTube.
Я открываю Диспетчер задач и убиваю процесс Axorith.Client (UI и иконка в трее исчезают).
Результат: Музыка продолжает играть. YouTube все еще заблокирован. Хост (бэкенд) продолжает работать невидимо.
Более того, Хост знает, что Клиент умер. Через механизм HTTP/2 Keep-Alive Хост мгновенно детектирует обрыв gRPC-стрима. Понимая, что UI отвалился нештатно, Хост через нативный API ОС (Windows Toast / D-Bus) присылает системное уведомление.
Конечно, такая архитектура не дается бесплатно:
Сложность разработки: Любое действие требует прокидывания через gRPC. Добавить кнопку = обновить .proto, сгенерировать классы, обновить сервис на Хосте, обновить команду на Клиенте.
Версионирование контрактов: Нужно строго следить, чтобы версии Хоста и Клиента совпадали. Я реализовал паттерн “Gatekeeper”: Клиент при каждом gRPC-вызове передает заголовок x-axorith-version. На стороне Хоста висит Interceptor, который сверяет версии. Если юзер обновил только UI, Хост отобьет запрос со статусом FailedPrecondition, а Клиент покажет красивый экран ошибки с кнопкой “Обновить бэкенд” (которая по отдельному каналу скачает нужный бинарник).
Асинхронность везде: Вы больше не можете просто вызвать метод. Всё становится Task, всё может отвалиться по таймауту или из-за сетевой ошибки (даже на localhost).
Но для приложения, которое должно работать 24/7, управлять системой и не падать от чиха в UI-потоке, это один из самых надежных архитектурных паттернов.
Каждое утро я встаю в 10, иду на кухню за чаем. Пока я там - компьютер уже включается сам: Алиса по расписанию отправляет команду на удаленное включение. Я возвращаюсь к столу, а на экране уже запущен пресет “Утро” - браузер с YouTube, ничего лишнего. Час еды и видео.
Через час Axorith сам закрывает всё это и переключает на пресет “Кодинг”. Открывается Rider, блокируется Youtube + Telegram, меняется свет. Никаких решений, никакой силы воли. Именно ради этого я и городил весь этот огород с двумя процессами и gRPC.
Если вам близка эта идея - проект с открытым исходным кодом (Source-Available, лицензия BSL - бесплатно для личного использования). Буду рад issues, pull requests и просто звезде на GitHub - это лучший способ понять что проект кому-то нужен.
Ссылка на GitHub [1]
Автор: iamfobey
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/avtomatizatsiya/448660
Ссылки в тексте:
[1] Ссылка на GitHub: https://github.com/axorithlabs/axorith
[2] Источник: https://habr.com/ru/articles/1018712/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1018712
Нажмите здесь для печати.