Любой десктопный монолит имеет один фатальный изъян: если UI поток падает - умирает вся бизнес-логика. Я решил это применив бэкенд-подход на десктопе.

Задача была амбициозной: создать единый центр управления рабочим местом. Чтобы одной кнопкой (или по расписанию) переключать ПК и комнату между режимами “кодинг”, “игры”, “стрим”. Это значит: управлять умным домом (Home Assistant), блокировать отвлекающие процессы и сайты, запускать нужный софт, контролировать медиа (Spotify) - и всё это через плагины.


Когда я начал проектировать архитектуру, я столкнулся с классической проблемой десктопного софта: жесткая зависимость бизнес-логики от UI.

В веб-разработке мы привыкли к надежности: если у пользователя зависла вкладка браузера, база данных на сервере не умирает. В десктопе (WPF/Avalonia) мы часто полагаемся на монолитную событийную модель. Если UI-поток зависает (тяжелый рендеринг) или приложение падает (ошибка в XAML, утечка памяти) - умирает вся бизнес-логика.

Для моего приложения это критично. Представьте сценарий:

  1. Софт заблокировал Steam, включил рабочий свет и запустил таймер фокуса.

  2. UI упал (например, драйвер видеокарты моргнул, или я накосячил в верстке).

  3. Процесс умер. Блокировка не снялась, таймеры сбросились, свет остался в “рабочем” режиме навсегда.

Чтобы получить надежность уровня сервера, я решил применить бэкенд-подход на десктопе: Headless Host + Thin Client.

Концепция: Два процесса, одна цель

Я разделил приложение на два независимых процесса, которые общаются через локальную сеть (localhost).

1. Axorith.Host (Backend)

Это .NET Worker Service. Технически это фоновый процесс (собирается как WinExe, чтобы не мелькало черное окно консоли).

  • Ответственность: Хранение стейта, таймеры, загрузка модулей, работа с API (Home Assistant, Spotify), мониторинг процессов (через ETW), управление браузером (через Named Pipes).

  • Жизненный цикл: Работает абсолютно невидимо (headless). Ему все равно, запущен ли UI.

  • Потребление: ~20-30 МБ RAM в простое.

2. Axorith.Client (Frontend)

Это приложение на 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 и Server-Side Streaming

Для общения между процессами я выбрал gRPC. Почему не Named Pipes или REST?

  1. Жесткие контракты (Protobuf): Если я поменял поле в контракте, компилятор заставит меня обновить и клиент, и сервер. Никаких разъехавшихся JSON-моделей.

  2. Стриминг: Это киллер-фича для реактивного 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 интерфейс перерисовывается мгновенно. Для пользователя это выглядит как обычное монолитное приложение - никаких сетевых лагов.

Безопасный IPC вместо TCP-портов

Разделение на два процесса рождает проблему: как они будут общаться? Использовать локальные 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-соединение. Никаких конфликтов портов и полная изоляция."

Управление жизненным циклом (UX)

Пользователь не хочет запускать два файла. Я реализовал схему “Client-First”:

  1. Юзер запускает Axorith.Client.exe.

  2. Клиент читает host-info.json. Если Хост не запущен, Клиент сам поднимает Axorith.Host.exe в фоне.

  3. При закрытии окна крестиком, Клиент не умирает, а сворачивается в системный трей (режим --tray).

  4. Через иконку в трее можно управлять бэкендом (Restart/Stop Хоста) или развернуть UI обратно.

“Неубиваемость” на практике

Сценарий краш-теста:

  1. Запущена сессия “Work”. Играет музыка, заблокирован YouTube.

  2. Я открываю Диспетчер задач и убиваю процесс Axorith.Client (UI и иконка в трее исчезают).

  3. Результат: Музыка продолжает играть. YouTube все еще заблокирован. Хост (бэкенд) продолжает работать невидимо.

Более того, Хост знает, что Клиент умер. Через механизм HTTP/2 Keep-Alive Хост мгновенно детектирует обрыв gRPC-стрима. Понимая, что UI отвалился нештатно, Хост через нативный API ОС (Windows Toast / D-Bus) присылает системное уведомление.

Сложности и выводы

Конечно, такая архитектура не дается бесплатно:

  1. Сложность разработки: Любое действие требует прокидывания через gRPC. Добавить кнопку = обновить .proto, сгенерировать классы, обновить сервис на Хосте, обновить команду на Клиенте.

  2. Версионирование контрактов: Нужно строго следить, чтобы версии Хоста и Клиента совпадали. Я реализовал паттерн “Gatekeeper”: Клиент при каждом gRPC-вызове передает заголовок x-axorith-version. На стороне Хоста висит Interceptor, который сверяет версии. Если юзер обновил только UI, Хост отобьет запрос со статусом FailedPrecondition, а Клиент покажет красивый экран ошибки с кнопкой “Обновить бэкенд” (которая по отдельному каналу скачает нужный бинарник).

  3. Асинхронность везде: Вы больше не можете просто вызвать метод. Всё становится Task, всё может отвалиться по таймауту или из-за сетевой ошибки (даже на localhost).

Но для приложения, которое должно работать 24/7, управлять системой и не падать от чиха в UI-потоке, это один из самых надежных архитектурных паттернов.

Зачем я это вообще сделал

Каждое утро я встаю в 10, иду на кухню за чаем. Пока я там - компьютер уже включается сам: Алиса по расписанию отправляет команду на удаленное включение. Я возвращаюсь к столу, а на экране уже запущен пресет “Утро” - браузер с YouTube, ничего лишнего. Час еды и видео.

Через час Axorith сам закрывает всё это и переключает на пресет “Кодинг”. Открывается Rider, блокируется Youtube + Telegram, меняется свет. Никаких решений, никакой силы воли. Именно ради этого я и городил весь этот огород с двумя процессами и gRPC.

Если вам близка эта идея - проект с открытым исходным кодом (Source-Available, лицензия BSL - бесплатно для личного использования). Буду рад issues, pull requests и просто звезде на GitHub - это лучший способ понять что проект кому-то нужен.

Ссылка на GitHub