Как написать плагин для уведомлений на Steam Deck

«Нужно больше уведомлений»: как написать плагин для Steam Deck

Владимир Туров
Владимир Туров Разработчик
30 ноября 2023

Погружаемся в устройство протокола KDE Connect и рассказываем, как его модифицировать для своей приставки.

Изображение записи

Привет! Меня зовут Вова, я разработчик в Selectel. Недавно в мои руки попала портативная игровая консоль Steam Deck. Замечательное устройство, которое полностью поглощает внимание. В этом я вскоре нашел минус: телефон генерирует уведомления и приходится откладывать приставку, чтобы посмотреть сообщение.

Хочу играть в приставку и не проверять телефон, но при этом не хочу пропустить что-то важное. И я нашел способ, как решить эту проблему. В этой статье мы напишем плагин, который изменит формат уведомлений на Steam Deck.

Почему не подходит стоковый KDE Connect

Опытные пользователи Linux скажут: «Уже давно существует KDE Connect — используй его и не изобретай велосипед!» В этом есть здравый смысл, ведь Steam Deck — это портативный компьютер с Arch Linux и оболочкой KDE Plasma. 

«Плазма», в свою очередь, имеет технологию KDE Connect, которая позволяет связывать устройства между собой, в том числе передавать уведомления с телефона на компьютер. Получается, что все придумано за нас, готовые технологии решают мою проблему. Но всегда есть нюанс.

KDE Connect в классическом режиме.

KDE Connect работает в «классическом» режиме, когда запущена Plasma. В игровом режиме «плазмы» нет, но есть интерфейс Steam, который использует собственные уведомления и не знает о KDE Connect. Поэтому придется решать проблему самостоятельно — для начала выберем технологию.

Велосипед или готовое решение

С помощью KDE Connect можно реализовать различные функции — от пересылки интерактивных уведомлений и передачи файлов до удаленного управления устройством. Протокол взаимодействия устройств мне показался сложным. Потому захотелось изобрести «велосипед» и написать что-нибудь легковесное, что можно использовать исключительно для уведомлений. Но это ошибочный путь.

Портативность. KDE Connect реализован под разные платформы: Linux, Windows, macOS, Android и iOS. В лучшем случае, мое решение появилось бы только для «пришельца».

Приватность. Для доступа к уведомлениям на Android нужно особое разрешение, которое не захочется предоставлять неизвестному приложению из неизвестного источника. Официальная программа KDE Connect в Play Market выглядит более солидно.

Безопасность. Уведомления могут содержать чувствительную информацию, которой не хочется делиться с окружающими. KDE Connect предлагает шифрование, но что насчет своего решения?

Резюмирую: можно резко улучшить пользовательский опыт, если разобраться в протоколе и написать собственную реализацию. К счастью, в интернете доступен проект Valent — реализация KDE Connect для GNOME. У проекта есть страница с описанием протокола, на которой сказано, что это не спецификация протокола. «А где спецификация?» — спросите вы. Все просто: ее нет.

Технологию для синхронизации уведомлений выбрали. Теперь обратимся к моддингу Steam Deck и узнаем, какие технологии предлагает Valve и что придумали моддеры-энтузиасты.

Решение — Decky Loader

По запросу «Steam Deck plugins» поисковики предлагают всего одно решение — Decky Loader. Это фреймворк для создания расширений игрового режима SteamOS. Он не работает в «классическом» режиме, где можно запустить KDE Connect.

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

Установка

Установить Decky Loader можно через классический режим SteamOS. Нужно открыть браузер, перейти на сайт decky.xyz и нажать кнопку Download. Скачается файл с расширением Desktop. Запуск этого ярлыка выполняет следующую команду:


    sh -c 'rm -f /tmp/user_install_script.sh; if curl -S -s -L -O --output-dir /tmp/ --connect-timeout 60 https://github.com/SteamDeckHomebrew/decky-installer/releases/latest/download/user_install_script.sh; then bash /tmp/user_install_script.sh; else echo "Something went wrong, please report this if it is a bug"; read; fi'

Да, это запуск shell-скрипта из интернета. Небезопасно, но быстро. В дальнейшем Decky Loader можно будет обновить из настроек приложения.

Разработка плагинов

Плагины распространяются через встроенный магазин приложений, но есть возможность «ручной» установки. Для разработки своего плагина есть репозиторий-шаблон. На этом моменте мы можем определиться со стеком. Мне повезло только на 50%.

Decky Loader написан на Python с применением asyncio. Поэтому точка входа в плагин — это main.py в корне проекта и класс Plugin, в котором описаны функции _main и _unload. При этом разработчики допускают, что логика плагина может быть выделена в отдельный исполняемый файл, который запускается через main.py. В SteamOS 3.5.7 доступен Python 3.11.3.

А вот фронтенд — это React.js. Признаюсь сразу: я плохо разбираюсь в современном фронтенде, поэтому описание по его части будет не особо содержательным. Для управления зависимостями и сборки используется pnpm версии 8.5.1. Это фундаментальное требование загрузчика плагинов. Актуальная версия на момент написания статьи — 8.11.0.

Особенности процесса

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

1. Начнем с бэкенда. В примере класса Plugin вы можете увидеть следующее:


    class Plugin:
    # Asyncio-compatible long-running code, executed in a task when the plugin is loaded
    async def _main(self):
        decky_plugin.logger.info("Hello World!")

Вам может показаться, что загрузчик создает экземпляр класса и обращается к нему. Но нет: переменная self — это ссылка на класс. Поэтому методы объекта работать не будут.


    class Plugin:
    async foo(self):
        ...

    async def _main(self):
        await self.foo()  
        # TypeError: Plugin.foo() missing 1 required positional argument: 'self'

2. Перезагрузка плагина почему-то вызывает его многократную параллельную загрузку-выгрузку. Для некоторых задач это может быть неприятно. 

3. Логи плагина пишутся в каталог ~/homebrew/logs/<имя плагина>, но каждый запуск — это новый лог-файл. При этом имя файла содержит дату и время запуска, то есть все складируется в одном месте. Поэтому читать их неудобно. 

4. Управление зависимостями для бэкенда на Python не предусмотрено. В теории вы можете использовать pip и установить необходимые пакеты в каталог defaults/py_modules вашего проекта, тогда перечисленные там модули станут доступны плагину. Но в этом случае сторонние библиотеки попадут в репозиторий проекта, что тоже плохо.

Недостатки 

Секцию про фронтенд я пропущу и перейду сразу к недостаткам процесса разработки. Создатели Decky Loader предлагают использовать VSCode или VSCoduim. Использование конкретных IDE — это вкусовщина, но скрипты сборки и переменные окружения адаптированы только под VSCode. Так что, если вы хотите использовать PyCharm, придется «поработать напильником».

Для сборки нужен pnpm, docker и… Linux! Все верно, на Windows собрать плагин не получится. Все потому, что для сборки используется самописная утилита decky, которая не работает на Windows. Однако пользователи могут выкрутиться установкой WSL 2 — она хорошо работает с VSCode и даже умеет автоматически взаимодействовать с Docker Desktop.

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

Игровой режим — это браузер

Код элемента интерфейса SteamOS.

Заголовок говорит сам за себя. Игровой режим SteamOS — это приложение на React. Поэтому для внедрения фронтенд-части Decky Loader использует DevTools-протокол и метод Runtime.evaluate, что эквивалентно выполнению кода на вкладке Console.

Не беспокойтесь: по умолчанию режим отладки доступен только для localhost, но в настройках Decky Loader можно расшарить его до локальной сети. Никакой аутентификации при этом не предусмотрено. Также можно подключаться к React DevTools.

Пример меню в Decky Loader.

С помощью Decky Loader можно также создавать меню с настройками плагина. Для этого достаточно описать специальный React-компонент. Но вернемся к уведомлениям.

Стоковые уведомления Decky Loader и ограничения

Авторы Decky Loader сделали себе уведомления для обновлений, поэтому можно обратиться к глобальному объекту DeckyPluginLoader:


    DeckyPluginLoader.toaster.toast({
  title: "Заголовок!”,
  duration: 15_000, // В миллисекундах
  body: "Тело сообщения"
})

Объект toaster — это хитрая обертка над уведомлениями в игровом режиме. Однако у игровых уведомлений есть целый ряд ограничений.

  • Уведомления, отправленные с помощью toaster, нельзя программно закрыть до истечения их времени. Более того, их не может закрыть никакое другое уведомление, даже системное.
  • Множественные уведомления не поддерживаются. В один момент может прийти только одно уведомление. Нарушение этого правила приведет к глитчу. Звук уведомления сначала воспроизведется при отправке уведомления, а потом второй раз — при демонстрации.
  • Оригинальные уведомления используют protobuf и все действия «захардкожены». То есть нельзя отправить уведомление, которое мимикрирует в «ваш друг играет в Baldur’s Gate 3» без передачи steam_id друга и идентификатора игры.
  • Размер уведомления ограничен. Так что добавить кнопки так просто не получится.

Слишком много ограничений. Поэтому не будем заниматься перфекционизмом — научимся отображать уведомления без обратной связи.

Как обойти ограничения

Есть два способа, как связать фронтенд с бэкендом: встроенный и самописный. Первый предполагает одностороннюю связь вида «клиент запросил — сервер ответил». Во фронтенд-примере эта часть как раз закомментирована, но есть ответная часть в бэкенде. Второй вариант подразумевает работу с веб-сокетами на бэкенде, как, например, в bash-shortcuts.

Я остановился на варианте попроще. Фронтенд-часть раз в секунду запрашивает у бэкенда текущий статус и доступные уведомления. На этом этапе кажется, что основные трудности в SteamOS решены. Можно приступать к изучению протокола KDE Connect.

Погружение в KDE Connect

Схема работы KDE Connect.

Основной источник знаний о KDE Connect — страница проекта Valent, исходный код и эксперименты с мобильным приложением. Протокол использует JSON для коммуникации. Вот структура пакета:


    {
    "id": 0,
    "type": "kdeconnect.share.request",
    "body": {
        "key": "value"
    },
    "payloadSize": 882,
    "payloadTransferInfo": {
        "port": 1739
    }
}

Строковое поле type определяет тип пакета и структуру body. Поля payloadSize и payloadTransferInfo определяют данные для скачивания дополнительных файлов. Например, в уведомлении может быть иконка — ее нужно скачивать отдельным подключением на указанном порту.

Устройство с KDE Connect периодически рассылает широковещательные UDP-пакеты на порт 1716 с информацией о себе:


    
{
   "body":{
      "deviceId":"aad278f7-e147-4ab1-af40-103c8860ee7f",
      "deviceName":"DESKTOP-7S33FAN",
      "deviceType":"desktop",
      "incomingCapabilities":[
         "kdeconnect.notification",
         "kdeconnect.ping"
      ],
      "outgoingCapabilities":[
         
      ],
      "protocolVersion":7,
      "tcpPort":1717
   },
   "type":"kdeconnect.identity",
   "id":1701026875983
}

Этот пакет содержит:

  • имя, идентификатор и тип устройства,
  • обрабатываемые команды (incoming),
  • исходящие команды (outgoing),
  • номер порта для TCP-подключения (если не указан, используется 1716).

Получение такого UDP-пакета должно побудить клиента подключиться по указанному TCP-порту и отправить свой kdeconnect.identity-пакет и инициировать переход в SSL-соединению.

Для SSL-соединения используется самостоятельно подписанный сертификат. При создании сопряжения клиент сохраняет информацию о нем и может использовать для проверок.

Получается, что идентификационный пакет — это единственный пакет в KDE Connect, который доступен в открытом виде. После перехода на SSL устройства могут обмениваться любыми пакетами. Однако удаленное устройство также может «отклонять» запросы, если не считает ваш девайс доверенным.

Для создания сопряжения устройства должны обменяться пакетами типа kdeconnect.pair. При этом между предложением и согласием должно пройти не больше 30 секунд.


    {
   "body":{
      "pair": true,
   },
   "type":"kdeconnect.pair",
   "id":1701026875983
}

Если же удаленное устройство не считает ваше устройство доверенным, то вместо исполнения отправленной команды отправляет pair=false. На словах все просто, давайте посмотрим в реализацию.

Реализация протокола

Я решил реализовать протокол с наименьшим количеством зависимостей. То есть доступные для Decky Loader библиотеки Python и исполняемый файл OpenSSL для генерации сертификатов.

Работа с сертификатами

Начнем с сертификатов. Для идентификации проще всего использовать UUIDv4. Сгенерируем его при первом запуске. Так же через asyncio.create_subprocess_shell вызываем следующую команду для создания сертификатов:


    openssl req -x509 -newkey rsa:4096 -keyout local.key -out local.crt -sha256 -days 3650 -nodes -subj ‘/C=US/O=KDE/OU=KDE Connect/CN=’"${UUID_устройства}"

Для организации TCP и UDP-сервера я использовал asyncio.start_server и asyncio.create_datagram_endpoint соответственно. Так как соединение в начале незащищенное, обработчики едва ли отличаются от документации. Вернемся к сертификатам.

В теории мы говорили, что сопряжение — это просто обмен пакетами kdeconnect.pair. Они отправляются по нажатию кнопки инициации сопряжения или при одобрении предложения. Но на практике нужно убедиться, что мы подключены к нужному устройству и у нас одинаковый взгляд на сертификаты друг друга. Как это можно сделать?

KDE Connect предлагает следующий алгоритм:

  1. Берем бинарную (DER) форму своего и удаленного публичных ключей;
  2. Побайтово сравниваем и склеиваем сначала больший, а затем меньший ключи;
  3. Считаем SHA-256 хэш от получившегося набора байт.

Шестнадцатеричное представление кэша — это ключ, который можно использовать для верификации сопряжения. При этом ключи должны быть идентичны на двух устройствах.

Здесь неожиданно появляется интересная особенность, которую подкладывают нам Python и SSL. Для инициации безопасного подключения сервер обязан отправить свой сертификат. Даже если клиент отключил все проверки. Клиент, в свою очередь, может не отправлять свой сертификат, если об этом не просили.

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


    ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_ctx.load_cert_chain(LOCAL_CERTIFICATE_FILE, keyfile=LOCAL_PRIVATE_KEY_FILE)
ssl_ctx.verify_mode = ssl.VerifyMode.CERT_REQUIRED
await asyncio.open_connection(host, port, ssl=ssl_ctx)

Клиент отправит свой самоподписанный сертификат, который, конечно, не пройдет проверку и вызовет исключение. Если отключить валидацию, клиент не отправит свой сертификат и мы не сможем посчитать ключ.

В режиме клиента такая ситуация не происходит:


    ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_ctx.load_cert_chain(LOCAL_CERTIFICATE_FILE, keyfile=LOCAL_PRIVATE_KEY_FILE)
# Отключаем проверку
ssl_ctx.verify_mode = ssl.VerifyMode.CERT_NONE

# Переходим на безопасный транспорт
loop = asyncio.get_running_loop()
transport = writer.transport
protocol = get_protocol()
tls_transport = await loop.start_tls(transport, protocol, ssl_ctx, server_side=False)

# Хак: подменяем транспорт в потоках
reader._transport = tls_transport
.writer._transport = tls_transport

# Даже если мы отключили проверку сертификатов, сертификат нам все равно пришёл.
# Но получить мы его можем только в бинарном виде - getpeercert(True)
# Но нам нужен именно бинарный вид!
ssl_obj: ssl.SSLObject | ssl.SSLSocket = writer.transport.get_extra_info("ssl_object")
remote_cert_der = ssl_obj.getpeercert(True)

Получается, что наш протокол будет корректно работать с новыми устройствами только в режиме клиента. Можно ли как-то заставить удаленное устройство подключаться к нам?

Работа с подключениями и проблема локализации

Ответ на последний вопрос: можем! Нормальная реакция на UDP-пакет — TCP-подключение. Но есть способ притвориться, что не получали UDP-пакет и отправить «в ответ» свой. Тогда удаленное устройство подключится к нам.

Схема автоматического подключения устройства к Steam Deck.

У этого метода есть очевидная проблема: Steam Deck не сможет подключиться к другому Steam Deck. Но разве это проблема?

Теперь мы можем легко посчитать ключ на основе двух публичных ключей:

a, b = local_public_key, remote_public_key
if a < b:
    a, b = b, a
verification_key = hashlib.sha256(a + b).hexdigest()

Приложение KDE Connect для Android показывает отпечатки (fingerprint) сертификатов, которые, в отличие от ключа сопряжения, можно посмотреть в любой момент. Я пытался реализовать ту же функциональность, но у меня не сходились значения. Тогда я пошел читать исходный код и внезапно нашел «языковой барьер».

Хэш сменили, а локализацию — нет.

Все верно: алгоритм хэш-суммы сменился, а локализацию в порядок не привели. Эта проблема есть и в других приложениях KDE Connect, но из-за разных систем локализации она проявляет себя по-разному. Поэтому приложение для Android вводит в заблуждение, а приложение для ПК — показывает не переведенную строку.

Отлично — с локализацией разобрались. Осталось только скачать дополнительные данные, если есть payloadTransferInfo:


    ssl_context = ssl.SSLContext()
# Включаем проверку сертификата
ssl_context.verify_mode = ssl.VerifyMode.CERT_REQUIRED
# Загружаем локальные сертификаты
ssl_context.load_cert_chain(LOCAL_CERTIFICATE_FILE, LOCAL_PRIVATE_KEY_FILE)
# Загружаем сертификат удаленного устройства, его мы сохранили ранее
ssl_context.load_verify_locations(cadata=context.remote_certificate)

# И сразу инициируем защищенное подключение
payload_reader, payload_writer = await asyncio.open_connection(remote_addr, remote_port, ssl=ssl_context)

payload_writer.write(packet.body.payloadHash.encode())
await payload_writer.drain()

data = await payload_reader.read(packet.payloadSize)

Готово — подключения настроены, теперь все работает. Steam Deck теперь может работать в паре с телефоном и отображает уведомления в правом нижнем углу.

Результат: мобильные уведомления отображаются в интерфейсе приставки.

Заключение

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

Я отправил запрос на включение плагина Decky Notification в магазин Decky Loader, но когда его одобрят — пока неизвестно. Зато его исходный код уже доступен в репозитории на GitHub — делайте форк и предлагайте свои улучшения.