Десктопный лончер игр на Flutter: библиотека, загрузка по BitTorrent и — главное — сохранения, которые можно забрать одним файлом и продолжить игру на другом устройстве.
Приложение не содержит каталога контента. Источник каждой игры задаёт
пользователь: magnet-ссылка, .torrent-файл или уже готовая папка на диске.
- Библиотека — сетка вертикальных обложек, как в Steam: полки «Все», «Установленные» и «Не установленные», обложка тянется из Steam по идентификатору игры, статусы, наигранное время, дата последнего запуска.
- Загрузки — magnet и
.torrentвстроенным клиентом на Dart (dtorrent_task_v2): DHT, очередь, пауза/возобновление, SOCKS5-прокси вплоть до peer-соединений. Список загрузок переживает перезапуск. - Перетаскивание — папку с игрой или
.torrentможно бросить прямо в окно библиотеки: папка добавится установленной игрой, раздача встанет в очередь загрузки. - Запуск — поиск исполняемого файла в скачанной папке, запуск
.appна macOS /.exeна Windows / бинарника на Linux, подсчёт времени игры. - Сохранения — снимки в формате
.evsave, автоснимок после выхода из игры, восстановление с резервной копией, экспорт/импорт, папка синхронизации и перенос сохранений всей библиотеки одним действием. - Папки сохранений — находятся сами, по открытой базе известных путей, так что задавать путь руками почти не приходится.
Интерфейс полностью проходится без мыши. Клавиатура и геймпад сводятся к
одному набору действий (NavAction), поэтому ведут себя одинаково.
| Действие | Клавиатура | Геймпад |
|---|---|---|
| Навигация | стрелки, Tab | D-pad, левый стик |
| Выбрать | Enter / Space | A |
| Назад | Escape | B |
| Играть / Скачать | Cmd+Enter, Ctrl+Enter | X |
| Поиск | /, Cmd+F |
Y |
| Разделы | Ctrl+Tab, Cmd+[ / ] | LB / RB |
| Прокрутка | PageUp / PageDown | правый стик |
Элемент под фокусом обведён рамкой, список сам подкручивается к нему, а в нижней строке показаны подсказки — кнопки геймпада, если он подключён, иначе клавиши.
Геймпад читается пакетом gamepads, который приводит контроллеры к
стандартной раскладке Xbox по базе SDL, так что PlayStation, Xbox и
Switch-совместимые контроллеры работают без настройки. Раскладку всё равно
можно переопределить: Настройки → Управление → Назначить ждёт нажатия
кнопки и запоминает её. Там же выключается геймпад целиком и настраивается
зона нечувствительности стиков.
Удержание направления повторяет шаг (400 мс до старта, затем каждые 110 мс), а у стиков гистерезис: порог отпускания ниже порога срабатывания, чтобы на границе не было дребезга.
Настройки → Прокси: тип (SOCKS5 или HTTP), хост, порт, логин, пароль. Применяется кнопкой — смена прокси перезапускает активные задачи, иначе уже открытые соединения продолжили бы идти мимо него.
Разница между типами принципиальная и вынесена прямо в интерфейс:
- SOCKS5 — через прокси идёт и обмен с пирами (
useForPeers: true), то есть торрент-трафик действительно скрыт; - HTTP — покрывает только трекеры и обычные загрузки, обмен с пирами пойдёт напрямую.
Именно из-за этого движком стал dtorrent_task_v2, а не aria2: aria2 не
поддерживает SOCKS вообще, а его HTTP-прокси в торрентах покрывает лишь
обращения к трекерам.
Пароль прокси хранится в файле настроек открытым текстом — об этом сказано и в самом интерфейсе.
Правило простое: SnackBar — для того, что пользователь только что нажал сам; системное уведомление — для того, что закончилось в фоне. Торрент качается десятки минут, окно к этому моменту обычно свёрнуто, и всплывающее сообщение в невидимом окне никому не поможет.
Системным уведомлением сообщаются три вещи:
- загрузка завершена — игра готова к запуску;
- загрузка сорвалась, с причиной;
- автоснимок сохранений после выхода из игры не удался — в интерфейсе он молчалив по замыслу, и без уведомления пользователь узнал бы об этом, только потеряв прогресс.
Об ошибке загрузки уведомление приходит один раз — на переходе в состояние ошибки, а не на каждом опросе движка (он опрашивается раз в секунду).
Отключается в Настройки → Уведомления. Там же кнопка «Проверить» (отправляет тестовое) и, на macOS, «Запросить разрешение»: система спрашивает его один раз, и приложение делает это по кнопке, а не молча при первом запуске. На Linux уведомления идут через D-Bus, на Windows — через toast-механизм системы.
Ключевая идея: в профиле игры хранится не абсолютный путь, а шаблон с
плейсхолдером — {APPSUPPORT}/MyGame/Saves. На другой машине (и на другой ОС)
тот же шаблон разворачивается в правильный локальный путь.
Файл .evsave — это обычный zip:
manifest.json метаданные: игра, устройство, платформа, правила путей
data/<ruleId>/... сами файлы сохранений
При восстановлении правила сопоставляются сначала по идентификатору, затем по метке — поэтому снимок, снятый на Windows, ложится в macOS-путь той же игры, если у обоих правил метка одна и та же (например, «Сохранения»).
Три способа переноса:
- Вручную — «Экспортировать файл», скопировать
.evsaveкуда угодно, на другом устройстве «Импорт» → «Восстановить». - Через папку синхронизации — указать в настройках папку Dropbox / iCloud / Syncthing. Новые снимки попадают туда автоматически; на другом устройстве во вкладке «Сохранения» они появляются в списке с кнопкой «Применить» (импорт + восстановление одним действием).
- Всей библиотекой сразу — «Выгрузить все» складывает по пакету на игру в одну папку, «Загрузить все» разбирает её обратно, сопоставляя пакеты с играми по названию.
Восстановление всегда сначала снимает резервную копию текущих сохранений.
Массовая загрузка не затирает свежий прогресс молча. Она сравнивает время снятия пакета с временем последнего изменения сохранений и пропускает игры, где это устройство ушло вперёд: пакет с другой машины запросто может оказаться старым, а резервная копия — слабое утешение, если о ней не догадаться. Допуск в две минуты не даёт расхождению часов поднимать ложную тревогу, а в диалоге можно осознанно перекрыть проверку.
Задавать путь для каждой игры руками — та самая скука, ради которой всё и затевалось.
Источник — манифест проекта Ludusavi: открытая база, собранная из PCGamingWiki. Пятьдесят три тысячи игр, лицензия MIT. Скачивается по требованию и лежит в кэше; в репозиторий и в сборку не входит ничего.
База пишет пути не готовыми, а с подстановками. Две из них раскрываются уже на месте:
<base>— папка самой игры, самый частый плейсхолдер базы. Лончер её знает точно: он эту игру и поставил. В правило она попадает как{GAME}и на другом устройстве развернётся в тамошнюю папку.- маска «любой профиль» (
*) раскрывается по тому, что действительно лежит на диске, и в правило попадают уже конкретные пути.
Что остаётся неразрешимым — пути через учётную запись магазина
(<storeUserId>) и корень чужого лончера (<root>). Это облако Steam; у игр,
которые ставит Evaporate, его нет.
База знает не всякую игру: торрент-релизов, малоизвестных вещей и всего, что мимо Steam, в ней нет вовсе. Зато игра сама создаёт себе папку под сейвы, а приложение знает точный промежуток, когда она работала, — оно её и запускало.
После выхода из игры Evaporate смотрит, что изменилось за это время в местах, где игры держат сохранения, и в папке самой игры. Найденное показывается в разделе «Папки сохранений» — с предложением, а не молча: рядом с сейвами игры пишут логи, кэш шейдеров и телеметрию, и отличить одно от другого наверняка нельзя.
Отбор идёт по нескольким признакам сразу: имя, похожее на название игры; папка внутри игры; заведомо игровое место вроде «My Games»; расширения файлов. Известные кэши и логи отбрасываются по имени, а папка, куда за сеанс насыпало сотни файлов, теряет в весе — это кэш, а не сохранение. Запуски короче тридцати секунд не осматриваются вовсе: это обычно неудачный старт.
Ветки реестра Windows база тоже знает. Переносить их приложение не умеет, но и не молчит: если сейв игры лежит в реестре, об этом сказано прямо — иначе снимок вышел бы неполным без единого слова.
Раньше рядом лежал сам Ludusavi — бинарник, который разворачивал <base>,
обходя папки Steam и GOG. Лончеру, который сам ставит игры, угадывать нечего,
поэтому бинарник убран: вместе с ним ушли закрепление версий с контрольными
суммами, запуск подпроцесса и то, что релиз Ludusavi под macOS собран только
под arm64.
- Запуск вместе с системой — задание launchd на macOS, запись XDG на Linux, ветка автозапуска текущего пользователя на Windows. Источник правды — сама система: убрали запись её средствами, и переключатель это покажет.
- Окно — размер и положение возвращаются такими, какими их оставили, а «всегда разворачивать» даётся отдельной галочкой. Положение с исчезнувшего монитора отбрасывается: окно за краем экрана выглядит как незапустившееся приложение.
- Проверка обновлений — приложение спрашивает GitHub и сообщает о новой версии. Само оно ничего не скачивает и не ставит: молчаливое самообновление на десктопе — сюрприз, которого никто не просил, а на Linux приложение может лежать в системной папке без прав на запись.
- Flutter 3.47+ (проверено на 3.47.2, Dart 3.13.2).
- Внешних программ не нужно: движок загрузок встроен в приложение.
- Для сборки под Linux нужен
libayatana-appindicator3-dev— без него не соберётся значок в трее.
Сборка под macOS требует полного Xcode (не только Command Line Tools) и CocoaPods:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developerflutter run -d macosТесты:
flutter testПерерисовать иконку приложения:
python3 tool/make_icon.py.github/workflows/ci.yml — пять задач:
| Задача | Раннер | Что делает |
|---|---|---|
| Анализ и тесты | ubuntu | dart format --set-exit-if-changed, flutter analyze, flutter test |
| Сборка macOS | macos | .app, упакованный ditto |
| Сборка Linux | ubuntu | bundle со всеми зависимостями, архивом .tar.gz |
| Сборка Windows | windows | Release-каталог, архивом .zip |
| Приложить к релизу | ubuntu | выкладывает архивы в релиз для тега v* |
На пуш в main идут только анализ и тесты. Сборки трёх платформ запускаются
на теге v* — и тогда же готовые архивы ложатся в релиз. На пуше они выясняли
бы то же самое, что и тесты, только вчетверо дольше: сборка macOS занимает
минуты, а ответ «тесты прошли» нужен сразу.
Проверить сборку, не выпуская версию, можно ручным запуском прогона
(workflow_dispatch) — сборки в нём тоже выполняются. Идут они только после
успешных тестов (needs: analyze).
Большинству тестов не нужны ни Xcode, ни сеть, ни геймпад: движок создаётся с
autoStart: false и очередь проверяется без единого соединения, а события
контроллера подаются напрямую в обход плагина. Единственное исключение —
работа с реестром: она проверяется на сборке под Windows, а на остальных
системах пропускается.
Версия Flutter зафиксирована в env.FLUTTER_VERSION. Чтобы всегда брать
свежую стабильную, уберите flutter-version и оставьте channel: stable.
Собранный .app подписан ad-hoc: он запустится на вашей машине, но на чужой
Gatekeeper потребует «Открыть всё равно». Для нормальной подписи нужен
сертификат Apple Developer в секретах репозитория.
lib/
bloc/ SettingsBloc, LibraryBloc, DownloadsBloc, NavigationBloc
core/ пути приложения, шаблоны путей сохранений, JSON-хранилище
input/ NavAction, раскладка геймпада, сервис ввода, InputScope
models/ Game, AppSettings, SaveProfile, SaveSnapshot, DownloadTask
services/
download/ DownloadEngine (абстракция) + dtorrent: очередь и прокси
saves/ упаковка снимков, база путей, поиск папок
launch/ запуск игр, поиск исполняемых файлов
metadata/ разбор имени раздачи, каталог Steam
system/ автозапуск, геометрия окна, проверка обновлений
ui/ оболочка, библиотека, загрузки, сохранения, настройки
tool/ генерация иконки и вспомогательные скрипты
Четыре блока — SettingsBloc, LibraryBloc, DownloadsBloc,
NavigationBloc — и 36 событий. Каждая фича лежит в своей папке
(bloc/<feature>/) тремя файлами: события, состояние и обработчики,
связанные через part. Состояния иммутабельны (Equatable).
flutter_bloc сам построен на provider, поэтому оба пакета используются
по назначению: блоки раздаёт BlocProvider, а сервисы без состояния
(GamepadService) — обычный Provider.
Событийная модель здесь не церемония: внешние источники подают события
наравне с нажатиями пользователя. Лаунчер сообщает о завершении процесса
через GameExited, движок загрузок — через EngineTasksChanged,
EngineStatusChanged и EngineStatsChanged. Обработчик решает, что делать,
и правит состояние в одном месте.
Асинхронные операции не пробрасывают исключения в виджеты. Блок держит
множество ключей выполняющихся операций (state.isBusy(...)) и одноразовое
сообщение Notice, а единственный BlocListener в оболочке показывает его
как SnackBar. Поэтому в экранах нет ни bool _busy, ни try/catch вокруг
вызовов. У Notice есть счётчик seq: без него два одинаковых сообщения
подряд считались бы одним состоянием, и второе не показалось бы.
Событие ничего не возвращает, и это меняет пару мест. Диалог добавления игры
сам генерирует идентификатор и передаёт его в GameAdded, чтобы сразу знать,
какую игру выделять. А перед стартом загрузки он дожидается, пока игра
действительно появится в состоянии, — иначе два блока могли бы разойтись
в порядке обработки.
Тем две — светлая и тёмная, плюс «как в системе». По умолчанию выбрана последняя. Тёмная осталась той же, с какой приложение начиналось: оно живёт в полноэкранном окне рядом с играми.
Тёмная построена на палитре FFFCF2 / CCC5B9 / 403D39 / 252422 / EB5E28,
светлая — на 264653 / 2A9D8F / E9C46A / F4A261 / E76F51.
Контраст не подбирался на глаз: тест меряет отношение для каждого цвета на
каждой подложке и требует норм WCAG — 4.5 для подписей и акцентов, 7 для
основного текста. Он же заставил отойти от исходных значений там, где иначе
текст было бы не прочесть. В светлой палитре нет светлого фона, а три тёплых
цвета — это цвета для заливок: песочный E9C46A даёт на светлом фоне 1.5 при
нужных 4.5, втрое меньше нормы, поэтому они затемнены с сохранением тона. В
тёмной оранжевый осветлён на четыре процента: им набрана метка статуса, а не
только рамка. Остальные цвета взяты как есть.
Цвета раздаются через расширение темы (context.colors.textSecondary), а не
константами: иначе две схемы не могли бы существовать одновременно. Исключение
одно — затемнение поверх обложки игры: там подложка не фон приложения, а
картинка, и белый текст поверх остаётся белым в любой теме.
Три шрифта, все вшиты в сборку, а не подтягиваются из сети: Nunito Sans на
интерфейс, Nunito на заголовки, JetBrains Mono на пути и размеры. Начертания
вариативные — один файл на семейство. Иконка приложения не лежит двоичным
файлом, а рисуется скриптом tool/make_icon.py: у macOS свой вариант с полями,
которых требует платформа, у Windows — .ico со всеми размерами внутри.
Анимации короткие, и их немного — это осознанный выбор. Приложением управляют и с геймпада, где пользователь держит направление и ждёт мгновенной реакции, а любой переход там читается как подтормаживание. Разделы сменяются затуханием, полосы загрузки едут к новому значению, подсветка выбранного плавно проявляется. Панель игры при выборе намеренно не анимируется: её перебирают быстро.
Отчёты об ошибках, правки перевода и замечания к формулировкам уместны — как это устроено, описано в CONTRIBUTING.md.
Evaporate распространяется под MIT. Вместе с ним поставляется чужая работа со своими условиями — база путей под MIT и три шрифта под OFL; все они перечислены в NOTICE.md.
- macOS: песочница отключена в
*.entitlements— иначе невозможны ни запуск игр, ни доступ к папкам сохранений других приложений. Такая сборка не предназначена для App Store. - Пакеты
.evsaveприходят извне, поэтому при распаковке проверяется выход за пределы целевой папки (zip-slip) — на это есть тест. - Имена файлов чистятся так, чтобы буквы нелатинских алфавитов оставались. В
Dart
\w— это только латиница, и очистка по нему превращала русские названия в одинаковые ряды подчёркиваний: экспорт разных игр затирал сам себя, пока это не поймал тест.