Skip to content

Repository files navigation

Evaporate

In English

Десктопный лончер игр на 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-путь той же игры, если у обоих правил метка одна и та же (например, «Сохранения»).

Три способа переноса:

  1. Вручную — «Экспортировать файл», скопировать .evsave куда угодно, на другом устройстве «Импорт» → «Восстановить».
  2. Через папку синхронизации — указать в настройках папку Dropbox / iCloud / Syncthing. Новые снимки попадают туда автоматически; на другом устройстве во вкладке «Сохранения» они появляются в списке с кнопкой «Применить» (импорт + восстановление одним действием).
  3. Всей библиотекой сразу — «Выгрузить все» складывает по пакету на игру в одну папку, «Загрузить все» разбирает её обратно, сопоставляя пакеты с играми по названию.

Восстановление всегда сначала снимает резервную копию текущих сохранений.

Массовая загрузка не затирает свежий прогресс молча. Она сравнивает время снятия пакета с временем последнего изменения сохранений и пропускает игры, где это устройство ушло вперёд: пакет с другой машины запросто может оказаться старым, а резервная копия — слабое утешение, если о ней не догадаться. Допуск в две минуты не даёт расхождению часов поднимать ложную тревогу, а в диалоге можно осознанно перекрыть проверку.

Откуда берутся папки сохранений

Задавать путь для каждой игры руками — та самая скука, ради которой всё и затевалось.

Источник — манифест проекта 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/Developer

Запуск

flutter run -d macos

Тесты:

flutter test

Перерисовать иконку приложения:

python3 tool/make_icon.py

Сборка в CI

.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/            генерация иконки и вспомогательные скрипты

Состояние: Bloc с событиями + provider

Четыре блока — 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 — это только латиница, и очистка по нему превращала русские названия в одинаковые ряды подчёркиваний: экспорт разных игр затирал сам себя, пока это не поймал тест.

About

Десктопный лончер игр на Flutter: загрузка по BitTorrent через SOCKS5 и сохранения, которые переносятся между устройствами и операционными системами

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages