Как я научил Steam Deck скачивать игры по ссылке с телефона: Steam через CEF, Mega на stdlib и один файл

Я люблю игры, которых нет в Steam: DRM-free игры с GOG и itch.io, визуальные новеллы, фанатские переводы и патчи к ним. На Steam Deck всё это прекрасно запускается, но каждый раз через одну и ту же рутину: скачать, переключиться в режим рабочего стола, распаковать, добавить в Steam как стороннюю игру, выбрать Proton, поискать обложки, вернуться в игровой режим. А потом ещё раз зайти в режим рабочего стола, потому что ярлык назвался «game.exe». Переключаться туда ради каждой игры было лень, да и просто геморройно.
Отсюда и выросла идея DeckDrop: один раз зайти в режим рабочего стола, поставить его и больше туда не возвращаться. Дальше всё делается с телефона или компьютера: вставил ссылку в браузере, и через пару минут игра уже лежит в библиотеке Steam с нормальным названием. Обложки для новелл он заодно подтягивает с VNDB, чтобы не искать их вручную.
Вторая боль — скриншоты. Перекидывать фотографии с дека на телефон через Steam муторно, а длинное видео так вообще не отправить. Поэтому в DeckDrop появилась вкладка «Медиа»: все скриншоты и записи Steam видны с телефона, их можно просто скачать.
Технически DeckDrop — маленький веб-сервер, который живёт на деке и открывается с любого устройства в домашней сети.

В статье не будет рекламы возможностей, их можно посмотреть в README. Расскажу о четырёх вещах, над которыми пришлось подумать: как управлять Steam, когда он переписывает свои файлы; как скачивать с Mega без единой зависимости; зачем собирать проект в один файл; и как я проверяю, что обновление не сотрёт мне настройки.
Как делался проект
Сразу про инструмент, потому что в истории коммитов это всё равно видно. Код писал Claude Code. Моя часть работы — придумать, что и как должно работать: ограничения, архитектура, поведение в неочевидных случаях, что делать при ошибке. Дальше я формулировал задачу, читал результат, гонял его на деке и возвращал с замечаниями. Большая часть решений ниже появилась именно так: я упирался в проблему на реальном устройстве, и мы искали, как её обойти.
Главных ограничений было три, и они определили всё остальное:
только стандартная библиотека Python;
никакого root и никаких правок системы;
программа должна пережить обновление SteamOS.
SteamOS устроена так, что корневой раздел доступен только для чтения и целиком перезаписывается при обновлении. Всё, что поставлено через pacman, после апдейта исчезает. pip install --user формально работает, но пакеты привязаны к версии Python, а её обновляет система. Поэтому всё живёт в домашней папке и запускается пользовательским сервисом systemd. Внешние программы вроде ffmpeg и 7z используются, если они есть, но без них всё тоже работает.
Steam не даёт править свои файлы
Первая версия добавляла игры очевидным способом: через штатную steamos-add-to-steam, а имя и Proton дописывала в shortcuts.vdf и config.vdf. На ПК с закрытым Steam это работало. На деке Steam запущен всегда, держит эти настройки в памяти и при выходе молча записывает их поверх чужих правок. Файлы на диске были правильные, а Steam их просто не читал и потом затирал.
Решение подсмотрено у Decky Loader. Клиент Steam сделан на Chromium Embedded Framework, и у CEF есть отладочный порт. Steam открывает его, если в папке клиента лежит пустой файл .cef-enable-remote-debugging. Порт принимает подключения только с самого дека, так что снаружи он не виден. Через него можно выполнить JavaScript во внутреннем контексте клиента SharedJSContext, а там доступен объект SteamClient — тот же API, которым пользуется сам интерфейс Steam и плагины Decky:
def add_shortcut(self, name, exe, start_dir):
appid = self.call("SteamClient.Apps.AddShortcut", name, exe, start_dir, "")
return int(appid) & 0xFFFFFFFF
def set_compat(self, appid, tool):
self.call("SteamClient.Apps.SpecifyCompatTool", appid, tool or "")
def set_artwork(self, appid, data, ext, asset_type):
self.call("SteamClient.Apps.SetCustomArtworkForApp",
appid, base64.b64encode(data).decode(), ext, asset_type)Так клиент сам создаёт ярлык, сам запоминает Proton, а обложки появляются в библиотеке сразу, без перезапуска.
Протокол отладки работает поверх WebSocket, а в стандартной библиотеке Python клиента WebSocket нет. Пришлось написать минимальный клиент по RFC 6455: рукопожатие, маскирование кадров, склейка фрагментов, ответ на ping. Вышло около ста строк, и больше не понадобилось.
Было и два неожиданных момента. Отладочный порт Steam — 8080, и первая версия DeckDrop сидела ровно на нём же: пока маркера не было, всё работало, а после перезагрузки Steam и DeckDrop начинали делить порт. Пришлось переехать на 8088 и научить кнопку обновления переводить открытую страницу на новый адрес. Второй момент: маркер начинает действовать только со следующего запуска Steam. Чтобы не перезагружать дек лишний раз, до этого момента игры добавляются старым способом, а переименование и выбор Proton встают в очередь и применяются сами, когда управление появляется.
Mega без зависимостей
Mega шифрует файлы прямо в браузере того, кто их загрузил. На сервере лежат только зашифрованные данные, а ключ передаётся в ссылке, в части после #, которую браузер не отправляет на сервер. Так что скачать с Mega значит расшифровать файл у себя. AES в стандартной библиотеке Python нет.
Зато есть ctypes, а сам Python собран с OpenSSL: модулю ssl нужна libcrypto. Значит, она гарантированно есть на любой системе, где работает HTTPS, включая SteamOS. DeckDrop загружает её через ctypes и вызывает EVP_aes_128_ctr. На случай, если библиотека не найдётся, есть реализация AES на чистом Python, но разница в скорости огромная. На моём ПК вышло так:
libcrypto через ctypes — около 700 МБ/с;
чистый Python — около 0,2 МБ/с.
Для игры на несколько гигабайт второй вариант бесполезен. Поэтому чистый Python нужен только для мелочей: расшифровать ключи и имена файлов, если libcrypto нет. Сам файл без неё Mega не качается.
Остальное — аккуратная реализация формата. 32-байтный ключ из ссылки складывается из двух половин через XOR и даёт 16-байтный ключ AES, а из хвоста берутся nonce для режима CTR и контрольная сумма. Имя файла зашифровано отдельно, AES-CBC с нулевым IV, и после расшифровки выглядит как MEGA{"n":"имя"}. В конце DeckDrop сверяет MAC: Mega считает CBC-MAC по кускам растущего размера, от 128 КБ до 1 МБ. Если файл побился в дороге, он не выдаётся за целый.
CTR хорош тем, что с любого места можно продолжить расшифровку, зная только смещение. Поэтому оборванная загрузка многогигабайтного архива продолжается с того же байта, а не начинается заново. Параллельные соединения Mega не любит и начинает их рвать, так что файлы качаются строго по одному.
Самый показательный баг нашёлся не в криптографии. Я вставил ссылку на папку с Unity-игрой и получил на экране несколько десятков заданий: каждая .dll из BepInEx/plugins стала отдельной загрузкой и отдельной «игрой». Теперь папка с Mega — это одно задание с общим прогрессом: файлы собираются во временном каталоге с сохранением структуры и в конце целиком становятся папкой игры. Если в папке только архив, разбитый на части (.part1.rar, .7z.001), распаковывается главный, а остальные помечаются как его части.
Один файл, но не одна простыня кода
Ставить и обновлять хотелось как можно проще: wget, одна команда, а дальше кнопка «Обновить утилиту» в интерфейсе. Удобнее всего, когда программа — это один файл: его легко скачать, проверить и атомарно заменить. Поначалу так и было. Потом файл дорос до пяти с лишним тысяч строк, в которых лежали и сервер, и HTML со стилями и скриптом строкой, и стало тяжело.
Сейчас исходники — обычный пакет: src/deckdrop/ с модулями по областям, отдельные index.html, app.css, app.js, словари переводов в JSON. А tools/build.py собирает из этого один deckdrop.py. Внутри него исходники всех модулей лежат строками в словаре, а в начале стоит маленький импортёр, который отдаёт их как пакет deckdrop:
class _BundleImporter(importlib.abc.MetaPathFinder, importlib.abc.Loader):
def find_spec(self, name, path=None, target=None):
if name not in _MODULES:
return None
is_pkg, rel, _ = _MODULES[name]
return importlib.util.spec_from_loader(name, self, origin=rel, is_package=is_pkg)
def exec_module(self, module):
_, rel, src = _MODULES[module.__name__]
# tracebacks show the lines of src/deckdrop/<rel>
linecache.cache[rel] = (len(src), None, src.splitlines(True), rel)
exec(compile(src, rel, "exec"), module.__dict__)Мне было важно, чтобы собранный файл вёл себя ровно как код из src/. Код не склеивается в одну простыню, а исполняется как те же самые модули. Благодаря записи в linecache трейсбек с дека указывает на реальный файл и строку в репозитории, а не на строку 4817 в сборке.
Отдельно пришлось думать об обратной совместимости. Уже установленные копии при обновлении проверяют, что скачали именно DeckDrop, а не HTML-страницу с ошибкой, и читают из нового файла версию и порт регулярными выражениями. Сборщик пишет заголовок так, чтобы старые версии его понимали.
Обновление не должно ничего стирать
Больше всего я боялся, что кнопка «Обновить» однажды сотрёт мне все настройки. В DeckDrop хранится немало: PIN администратора, пароль галереи скриншотов (хешем PBKDF2), прокси с логином и паролем, список добавленных игр, отложенные операции со Steam. Юнит-тестами такое не ловится: ломается обычно на стыке старого формата и нового кода.
Поэтому главный тест проекта делает то же, что я сам:
берёт из git несколько прошлых релизов по тегам и запускает каждый в чистой домашней папке;
настраивает старую версию через её же веб-API: меняет PIN, переводит все настройки из значений по умолчанию, задаёт пароль галереи, скрывает одну игру и импортирует другую;
нажимает в старой версии «Обновить», подсунув ей свежую сборку;
проверяет, что обновление не тронуло ни одного файла, кроме самой программы и её резервной копии, а новая версия показывает те же настройки и игры и принимает старый PIN и старый пароль.
В CI это отдельный обязательный шаг, который гоняется на Python 3.8, 3.11 и 3.13. Версия 3.8 в матрице не для красоты. Однажды в веб-API попал dict | dict, который появился только в 3.9, и именно этот прогон его поймал. Теперь в юнит-тестах есть отдельная проверка на такие конструкции.
Релизы тоже собираются автоматически: когда в main меняется version, CI собирает файл, прогоняет всё заново, ставит тег и выкладывает deckdrop.py и install.sh в GitHub Releases. Кнопка обновления на деке берёт файл оттуда же.
Что ещё внутри
Коротко, без подробностей:
Обложки для всех пяти слотов Steam: с VNDB для новелл или из иконки exe. Иконку достаёт свой разбор PE-ресурсов, без внешних библиотек.
Галерея скриншотов и записей Steam: забрать их на телефон без облака и USB. Видео отдаётся с Range-запросами, поэтому нормально перематывается на iOS.
Патчи в папку игры: архив сначала распаковывается в сторону, и DeckDrop показывает, какие файлы добавятся, а какие заменятся. Оригиналы сохраняются как
.bak.Бэкап сейвов в zip и обратно, включая папки внутри префикса Proton.
Интерфейс на русском и английском. Английский — единственное, что я добавил не для себя, а для других. Язык берётся из настроек клиента Steam на деке.

Прокси, куда ж без него
Я живу в России, и все мы знаем, как у нас с доступами. Поначалу VNDB с дека просто не открывался: на ПК у меня стоит VPN, там всё работало, а дек ходил в интернет напрямую, и соединение с VNDB рвалось. Ставить VPN на весь дек ради обложек не хотелось, поэтому в DeckDrop пришлось добавить свой прокси. Работает он только для самого DeckDrop, остальной дек ходит в сеть как обычно.
Самый простой вариант у меня получился такой: у VPN-клиента на ПК есть локальный вход SOCKS5, в нём разрешаешь подключения из локальной сети и указываешь в DeckDrop адрес ПК. SOCKS5-клиента в стандартной библиотеке, конечно, тоже нет, так что его пришлось написать самому. Адрес прокси может быть с логином и паролем, поэтому он закрыт PIN и на страницу без него не уходит.
О безопасности честно
DeckDrop рассчитан на домашнюю сеть. HTTPS нет: для адреса вроде steamdeck.local нельзя получить сертификат, которому браузер поверит без предупреждений, а самоподписанный только приучает нажимать «всё равно открыть». Удаление, обновление и настройки прокси закрыты PIN, галерея — паролем, но положить файл на дек может любой в вашей Wi-Fi-сети. Дома это нормально, в общественной сети запускать его не стоит.
Про работу с Claude Code
Пара наблюдений, раз уж проект сделан так. Лучше всего работало, когда я сначала формулировал ограничения, а не решение: «без зависимостей», «ничего не менять, пока я сам не нажму кнопку», «прокси только для самого приложения». Тогда предложения сразу укладывались в рамки. Хуже всего — когда проверка была только на ПК: половина интересных багов, от Steam, переписывающего конфиги, до десятков заданий из одной папки Mega, всплыла только на живом деке. Тест на сохранность данных тоже появился из этого: заново настраивать всё после каждого обновления мне совсем не хотелось.
Попробовать
В Desktop Mode откройте Konsole и выполните:
wget https://github.com/Aniforka/deckdrop/releases/latest/download/install.sh
sh install.shУстановщик напечатает адрес страницы и PIN администратора. Дальше можно вернуться в Gaming Mode и открыть адрес с телефона.
Код открыт под MIT: github.com/Aniforka/deckdrop. Если что-то не заработает на вашем деке, пишите в issues, туда же идеи. Особенно интересно, какие файлообменники ещё стоит поддержать.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.