Перейти к содержимому

Архитектура

Двадцать модулей Gradle. Направление зависимостей одностороннее и несущее: движок не видит интерфейс, а дизайн-система не видит предметную область.

Модуль Роль
client-config Брендинг, имена файлов хранилища, протокольные константы и их переопределения в рантайме.
client-core Доменные модели, DTO протокола, интерфейсы служб I*, движок кэша, типы состояния запуска.
client-auth SPI авторизации: AuthProvider, AuthCapabilities, хранилище аккаунтов, менеджер учётных данных.
client-auth-smartycraft Провайдер SmartyCraft.
client-auth-microsoft Провайдер Microsoft (MSA).
client-launcher Движок: сборка DI, клиент зеркала и синхронизация, провизия рантайма, разрешение загрузчиков, запуск, накат обновлений сборок и откат.
client-update Лаунчер обновляет сам себя: проверка релиза, дельта-бандл, бинарный патч и накатчики под каждую платформу.
client-cli Безголовая точка входа поверх того же движка.
client-media Кэш видео и разрешение ссылок через yt-dlp для медийных поверхностей.
client-tray Системный трей за одним интерфейсом, на libtray (привязки Panama).
client-ui Оболочка на Compose Desktop: экраны, виджеты, редактор, консоль.
client-i18n Интерфейс AppStrings, английская, русская и немецкая реализации и композиционная локаль LocalStrings.
client-render3d Программный 3D: растеризатор и граф сцены, а поверх них риг скина Minecraft.
client-easter Первоапрельский движок за SPI, который без зарегистрированного провайдера разрешается в пустышку.
nx-ui Дизайн-система: тема, токены, поверхности, примитивы.
widget-model Граф раскладки, идентичность слотов и виджетов, ключи служб и команд. Без Compose.
widget-api Рантайм виджетов: отрисовщик слота, реестры, композиционные локали.
widget-processor KSP-процессор, собирающий реестр виджетов из @Widget.
authlib-agent Java-агент, перенаправляющий обращения authlib при заходе на сервер SmartyCraft.
profiler-agent Java-агент, наблюдающий за кучей и сборкой мусора внутри JVM игры.

Две границы стоит назвать прямо, потому что механически их не удерживает ничто.

Первая: в client-launcher и всём, что ниже, нет ни одного импорта java.awt, javax.swing или javax.imageio. Именно это держит AWT вне достижимого графа CLI и вне нативной сборки. Единственные упоминания AWT в движке это флаги JVM, которые он передаёт процессу игры.

Вторая: nx-ui зависит от Compose, корутин, сериализации и библиотеки цвета, и ни от одного модуля проекта. Компонент попадает туда, только если не называет доменных типов и не разрешает строки сам. Поэтому бейдж источника переводит PackOrigin в цвет на стороне client-ui, а затем вызывает примитив nx-ui, принимающий готовый цвет.

Конвейер разрезан так, чтобы окно появилось до медленной работы.

  1. LauncherBootstrap.preWindow, только миллисекунды: разрешить каталог логов до первого обращения к логгеру, записать идентификатор сессии в системное свойство, чтобы каждая строка лога прослеживалась до одного процесса, взять блокировку единственного экземпляра. Повторный запуск завершается здесь, не мигнув окном.
  2. Окно открывается и рисует загрузочный порог.
  3. LauncherBootstrap.completeCore, в фоновом потоке: применить отложенный перенос каталога данных, перерезолвить пути после него, восстановить сохранённые обходы SSL до первого запроса, построить репортер крашей.
  4. LauncherBootstrap.finishBoot: обнаружить отложенную миграцию и стартовать Koin.

Прогресс отдаётся четырьмя фазами (Data, Network, Migration, Modules), которые двигают полосу порога.

Край, касающийся тулкита, живёт в hivens.ui.bootstrap.GuiBootstrap: переопределение класса окна для X11, свойство вертикальной синхронизации Skiko, одна диагностическая строка о дисплее и обработчик крашей, показывающий диалог Swing. CLI собирает то же ядро через preBootHeadless, с обработчиком, который пишет отчёт в лог и на диск, и без блокировки единственного экземпляра.

Точка входа Compose работает внутри цикла перезапуска. application вызывается с exitProcessOnExit = false, поэтому падение композиции разматывается и возвращает управление, а не убивает процесс. Падение на потоке отрисовки перехватывается обработчиком исключений окна и попадает туда же. Дальше эскалация: перезапуск со свежей композицией, защёлкивание безопасного режима при цикле падений, терминальный диалог Swing, если упал и он.

Koin и каталоги данных создаются вне цикла, поэтому перезапуск сохраняет данные, сессию и воспроизведение звука. Теряется только состояние композиции.

Отдельный вход в восстановление (переменная окружения, флаг командной строки или одноразовый файл-маркер) рисует поверхность восстановления до того, как Koin вообще появится, чтобы сломанный модуль не утащил её за собой.

Настоящая карта проекта это Koin, и статический анализ её не видит: класс просит у контейнера интерфейс и никогда не называет реализацию.

Движок регистрирует восемь модулей в client-launcher/di/Modules.kt: networkModule, authModule, cacheModule, runtimeModule, mirrorModule, launchPipelineModule, updateModule, appModule. Членство это только группировка, стартуют все вместе.

Интерфейс добавляет девятый, uiModule из hivens.ui.Main, который передаётся в GuiBootstrap.completeBoot дополнительным модулем. Это и есть охрана направления: движок не импортирует ни одного типа интерфейса, а интерфейс всё равно попадает в тот же контейнер.

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

Часть привязок даёт псевдоним одному экземпляру, а не строит второй. Менеджер учётных данных привязан и как хранилище для чтения, и как хранилище аккаунтов. Служба обновления паков привязана как контракт обновлятора, а фоновый автообновлятор как хаб статусов, который читает интерфейс.

Исходящий трафик разделён надвое, и каждая привязка выбирает канал явно.

Канал SmartyCraft несёт всё, что живёт на узле апстрима. Соединение прямое, и это единственный канал, который учитывает обход проверки сертификата: пока у пользователя действует обход для этого узла, запросы идут через клиент, доверяющий любому сертификату. Прокси внутри лаунчера нет, поэтому тому, кто не достаёт до узла напрямую, нужен VPN или системный прокси.

Прямой канал со строгим TLS и без каких-либо обходов обслуживает все сторонние CDN: Mojang, BellSoft, Maven Central, Modrinth, зеркало Hivens, релизы GitHub.

Путь обновления закреплён за прямым каналом намеренно: автообновление обязано работать, когда узел апстрима недоступен, иначе оно не сможет доставить исправление, которое чинит связность. Вход через Microsoft закреплён там же по второй причине: его токены не должны идти через клиент, которому пользователь велел доверять любому сертификату.

HttpClientProvider это провайдер, а не внедрённый клиент, поэтому решение о канале (обход или строгий TLS) перечитывается на каждом вызове, и выданный обход действует без пересборки контейнера.

Разрешается по порядку: переменная окружения NEXIRA_DATA_DIR, ключ data-dir в конфиге начальной загрузки, который намеренно лежит вне каталога данных, затем значение по умолчанию для ОС.

ОС Путь
Windows %LOCALAPPDATA%\Nexira
macOS ~/Library/Application Support/Nexira
Linux $XDG_DATA_HOME/nexira (по умолчанию ~/.local/share/nexira)

Раскладка внутри:

instances/ инстансы паков, единица установки
clients/ старая раскладка SmartyCraft по серверам
libraries/ общие библиотеки в maven-раскладке, переиспользуются между паками
assets/ общие ванильные ассеты (индексы и объекты по содержимому)
db/ Xodus: реестр паков и кэш сканирования содержимого
cache/ пространства TTL-кэша для метаданных паков и Modrinth
loader-cache/ выход безголового установщика загрузчика
snapshots/ снапшоты перед накатом, для отката
presets/ пресеты раскладки
logs/ crash-reports/ skin-cache/ video-cache/ tools/

libraries/ и assets/ лежат вне инстансов намеренно: два пака на одной версии игры делят одну копию вместо того, чтобы качать каждый свою. Каталоги данных прошлых выпусков один раз обходит экран миграции и копирует их, удаления не происходит.

Пак объявляет версию Minecraft и загрузчик, а провизионер превращает это в запускаемый classpath.

Ванильная база приезжает с собственного CDN Mojang в общие корни. Загрузчик добавляет наложение: дополнительные библиотеки и метаданные запуска, то есть главный класс и добавки к аргументам JVM и игры. Слияние отдаёт приоритет загрузчику, а ключ дедупликации это group:artifact:classifier. Классификатор существенен: современный version json перечисляет базовый jar библиотеки и её jar с нативами под одной координатой.

Загрузчики бывают разного рода, и профиль это объявляет.

Аддитивные (Forge, NeoForge, Fabric, Quilt) сливаются с ванильным набором. Загрузчик, меняющий LWJGL, называет ванильную группу на удаление и приносит свои нативы, так что LWJGL3 замещает LWJGL2, а несвязанные ванильные нативы остаются. Самодостаточный загрузчик заменяет ванильный набор целиком, потому что межкоординатных двойников, то есть двух написаний одной библиотеки, слияние по группе и артефакту не разрулит. Современные установщики выдают файлы, которые обязаны лежать на диске, но не попадать в classpath, потому что собственный локатор загрузчика находит их по пути.

Мажорная версия Java выбирается по приоритету: переопределение загрузчика важнее объявления Mojang, а оно важнее эвристики лаунчера. Одна и та же версия игры на разных загрузчиках может требовать разной Java.

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

Обновление это транзакция. До первого записанного байта файлы, которыми владеет пак, снимаются в снапшот (жёсткими ссылками, поэтому дёшево), а в журнал пишется запись о начатом накате. Дальше применяется план, фиксируется новая база и журнал закрывается. Сбой восстанавливает снапшот, жёсткий крах откатывается по журналу на следующем старте.

Каждый файл приземляется атомарно: пишется во временный файл рядом и переносится атомарным перемещением, с разобранными запасными путями для файловых систем, которые его не поддерживают.

Структурные изменения инстанса сериализуются на блокировке по каталогу, поэтому синхронизация, переразметка содержимого и накат обновления не могут переплестись.

Оболочка сама является виджетной поверхностью. AppLayout заканчивается одним вызовом отрисовки слота appshell.root, а левая рельса, центр и правая панель это виджеты в графе раскладки. Навигация поверхностью ещё не стала, поэтому роутер экранов передаётся вниз через контекст оболочки.

Три модуля, три роли. widget-model держит граф (LayoutGraph, SurfaceLayout, SlotContent, WidgetInstance, рекурсивно через children) и типы идентичности; зависимости от Compose у него нет, поэтому раскладку может прочитать и безголовый потребитель. widget-api держит рантайм: отрисовщик слота, реестр виджетов и реестры служб, данных и команд. widget-processor собирает реестр из аннотаций @Widget.

Расширение идёт через композиционные локали с тождественными значениями по умолчанию. Декоратор, оборачивающий каждый виджет, заглушка пустого слота, заглушка неизвестного виджета, модификатор оформления слота и отрисовщик подложки экземпляра по умолчанию не делают ничего, а редактор подменяет их настоящими. Продакшен не платит за редактор, который не смонтирован.

Слот владеет своей раскладкой: колонка, ряд, сетка, свободный холст или адресуемая клеточная сетка. Содержимое виджета обёрнуто в перемещаемое содержимое на экземпляр, поэтому переключение режима редактирования перемещает поддерево, а не пересоздаёт его, и виджет сохраняет загруженное состояние.

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

Две независимые оси, разведённые намеренно, чтобы палитру и форму можно было выбирать порознь.

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

Стиль это форма: радиусы углов, толщина границы, обработка поверхности, множитель движения, свечение, подъём панелей и оболочки переключателя и бейджа. Сегодня их два, Celestia (скруглённый, стеклянный, со свечением и анимациями) и Brut (квадратный, плоский, неподвижный).

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

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

Непрерывная интеграция на пул-реквесте гоняет каждый модуль, у которого есть тесты, двадцать три набора, на Linux, macOS и Windows. Раньше гоняла восемь, и каждый оставленный за списком модуль оказывался тем, чьи регрессии доезжали до релиза: загрузчик виджетов это рантаймовая половина того же валидатора, который KSP-процессор проверяет на сборке, а пример модуля единственное в дереве, что собирается против ABI виджетов снаружи, и потому замечает изменение ABI, которого собственные модули лаунчера увидеть не могут.

Два собственных сканера при этом работают строго на каждом пул-реквесте: один падает на строке, показанной пользователю в обход слоя локализации, второй на процессных метаданных в комментариях. Ни один не держит список модулей, потому что именно из-за списка nx-ui остался несканируемым после выделения: сканер комментариев берёт любой каталог верхнего уровня с исходниками, а сканер строк спрашивает у сборочного файла, применён ли Compose. Новый модуль попадает под проверку в день появления.

Дистрибутив собирают собственные плагины Gradle из buildSrc, а не значения по умолчанию плагина Compose: урезанный через jlink рантайм с измеренным набором модулей, образ jpackage, образ диска для macOS и профиль AppImage, собираемый скриптом с внедрением desktop-entry и метаданных AppStream.

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

hivens.config.Protocol держит значения, которых требует формат обмена SmartyCraft: подставляемую версию лаунчера, хеш по умолчанию и входные данные схемы подписи. Они выведены из лаунчера апстрима, см. Kitty-Hivens/smrt-deco. Это константы совместимости, а не секреты, и документированы они именно так: спрятав их, мы сделали бы протокол только труднее для понимания.

И подставляемая версия, и базовый URL имеют переопределения в рантайме за явными опциями, чтобы отвечать на изменение версии у апстрима быстрее цикла выпусков.