diff --git a/AGENTS.md b/AGENTS.md index 36421c9..abab995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,9 @@ Интерфейс на русском и английском: язык выбирается на приветствии и в настройках и меняется без перезапуска. Каждый push в `main` собирает обе платформы и публикует общий prerelease; независимые стабильные релизы `macos-vX.Y.Z` и `windows-vX.Y.Z` выходят при повышении версии соответствующей платформы. macOS-клиент автоматически получает обновления из стабильных macOS-релизов; прежние клиенты `nightly` переходят на них вручную. Проект Xcode: `macos/Hako/Hako.xcodeproj`, исходники: `macos/Hako/Hako/`. -Windows-клиент на WinUI 3 (.NET 8) находится в `windows/` и на первом этапе паритета с macOS: приветствие, вход в Microsoft, настройки «Основные» и «Профиль», выход; сборок и запуска игры пока нет. Проекты: `windows/Hako/Hako.csproj`, логика `windows/Hako.Core/`, тесты `windows/Hako.Tests/`. +Windows-клиент на WinUI 3 (.NET 8) находится в `windows/`: приветствие, вход в Microsoft, настройки «Основные», «Игра» и «Профиль», выход, сборки Vanilla/Fabric и запуск Minecraft. Каталог Modrinth устанавливает моды и ресурспаки в сборки, датапаки — в выбранный мир, а готовые модпаки — из `.mrpack`; локальные моды доступны только для Fabric. +Поддерживаются управление мирами и скриншотами, дублирование сборок и архивы `.hakobackup`/`.hakoworld`. Игровое время учитывается с фоновым помощником и переносом гостевой статистики. CurseForge, восстановление архивов и самообновление Windows не реализованы. +Проекты: `windows/Hako/Hako.csproj`, логика `windows/Hako.Core/`, тесты `windows/Hako.Tests/`. ## База знаний — Obsidian diff --git a/Obsidian/HakoVault/Architecture.md b/Obsidian/HakoVault/Architecture.md index 0e25359..1ea16ca 100644 --- a/Obsidian/HakoVault/Architecture.md +++ b/Obsidian/HakoVault/Architecture.md @@ -44,7 +44,7 @@ CocoaPods, Carthage не используются) нет; NuGet-пакеты Wi | `macos/Hako/Hako/AppIcon.icon` | Иконка приложения в формате Icon Composer (`ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon`): слой `Assets/box.png` (коробка с сакурой, 1024×1024, прозрачный фон, Liquid Glass включён) на градиенте от белого к светлой сакуре. Тёмный, прозрачный и тонированный варианты система строит сама. Открывается в Icon Composer из Xcode | | `macos/Hako/Hako/Assets.xcassets` | `AccentColor` (цвет не задан) | | `windows/Hako/` | Windows-клиент: WinUI 3-приложение, конфигурация пакета MSIX, страницы, `MainWindow`, точка входа, ресурсы `.resw`, решение `Hako.slnx` | -| `windows/Hako.Core/` | Логика Windows-клиента без WinUI: вход Microsoft, хранение аккаунта и настроек, язык | +| `windows/Hako.Core/` | Логика Windows-клиента без WinUI: вход Microsoft, хранение аккаунта и настроек, язык, сборки (`Instances/`), установка и запуск Minecraft (`Minecraft/`) | | `windows/Hako.Tests/` | MSTest-тесты `Hako.Core` и ресурсов Windows-клиента | | `Obsidian/HakoVault/` | Эта база знаний | @@ -238,9 +238,14 @@ Windows собирается на `windows-2025` с .NET 8/MSBuild для x64 и ## Windows-клиент -Клиент для Windows в каталоге `windows/` на первом этапе паритета с macOS: приветствие, вход в Microsoft -по коду, главная с разделами «Сборки» и «Настройки», язык, профиль с 3D-превью скина и выход. Сборки, -запуск игры, моды, ресурспаки и самообновление пока не реализованы. +Клиент для Windows в каталоге `windows/` реализует приветствие, вход в Microsoft по коду, главную с рейлом +сборок и «Настройками», русский/английский язык, профиль с 3D-превью скина и выход. Vanilla- и Fabric-сборки +создаются и устанавливаются фоновой очередью (пауза, продолжение, повтор), настраиваются, удаляются и запускаются +онлайн или в offline-mode. Есть локальные моды и ресурспаки, миры и датапаки, каталог Modrinth и импорт готовых +модпаков Modrinth `.mrpack`, скриншоты, дублирование сборок и резервные копии `.hakobackup`/`.hakoworld` +— [[UI/Launcher#Windows-клиент]] и [[Data/Backups]]. Также Windows учитывает игровое время по аккаунту/гостю и сборке. +Фоновый помощник продолжает измерение после закрытия лаунчера, а двухфазный перенос сохраняет границу гостевой сессии. +Сервисы сборок живут всё время работы приложения и создаются в `App.OnLaunched` (`LauncherServices`). `MainWindow` использует Mica под WinUI `TitleBar`; начальная клиентская область 1280×720 эффективных пикселей задаётся с масштабированием по DPI окна. Экраны — [[UI/Screens#Windows-клиент]], вход — [[Auth/MicrosoftAuth#Windows-клиент]], @@ -263,37 +268,60 @@ Windows — 10 1809 (`TargetPlatformMinVersion` 10.0.17763.0); `app.manifest` о ### Структура - `windows/Hako/` — WinUI-приложение: `Program.cs` (точка входа и ключи единственного экземпляра), `App.xaml.cs` - (создаёт `AppCoordinator` и app-lifetime `MinecraftSkinPreviewService`), `MainWindow` (окно с `NavigationView`, + (создаёт `AppCoordinator`, app-lifetime `LauncherServices`, скриншоты и сервисы архивов), `MainWindow` (окно с `NavigationView`, `Frame` и forwarding состояния окна), страницы `LoadingPage`, `WelcomePage`, `LoginPage`, `BuildsPage`, `SettingsPage`, - `MinecraftSkinView`, `MinecraftSkinImageDecoder`, `MinecraftSkinPreviewService`, `MinecraftSkinHeadComposer`, + профиль и его `InstanceScreenshotsView`, `ScreenshotThumbnails`, `MinecraftSkinView`, `MinecraftSkinImageDecoder`, `MinecraftSkinPreviewService`, `MinecraftSkinHeadComposer`, `Graphics/MinecraftSkinRenderer` и `Graphics/MinecraftSkinGeometry`, `HakoStrings`, `AppDataDirectory`, `Strings/ru-RU` и `Strings/en-US` (`Resources.resw`), `Package.appxmanifest`, `app.manifest`, `Properties/launchSettings.json`, решение `Hako.slnx`. -- `windows/Hako.Core/` — логика без WinUI: `AppCoordinator` (фазы входа, экраны, выход), `MicrosoftDeviceAuth`, - `AuthContracts`, `AccountSessionStore`, `JsonUserSettingsStore`, `AppLanguage`, `JsonContexts`. -- `windows/Hako.Tests/` — `AppCoordinatorTests`, `MicrosoftDeviceAuthTests`, `StorageTests`, +- `windows/Hako.Core/` — логика без WinUI: `AppCoordinator` (фазы входа, экраны, выход), `Playtime/` (ledger, сессии, + helper и coordinator), `MicrosoftDeviceAuth`, + `AuthContracts`, `AccountSessionStore`, `UserSettingsService`, `HakoPaths`, `HakoRootLock`, `Instances/` + (`GameInstance`, `InstanceStorage`, `InstanceStore`, `InstanceScreenshots`, `InstanceFileTree`, `InstanceBackup`, + `WorldBackup`, `InstallationCoordinator`, `ModpackCatalog`), `Minecraft/` + (`ModrinthClient`, `ModpackManifest`, `ModpackArchive`, `ModpackInstaller`, Mojang/Fabric install and launch logic), + source-generated COM-корзина `RecycleBin`, `JsonContexts`. +- `windows/Hako.Tests/` — `AppCoordinatorTests`, `PlaytimeTests`, `MicrosoftDeviceAuthTests`, `StorageTests`, `LocalizationResourcesTests`, `RedactionTests`, `MinecraftSkinTests`, `MinecraftSkinImageDecoderTests`, `MinecraftSkinGeometryTests`; `TestSupport.cs` содержит фейки времени, таймеров, хранилищ, сервиса входа и `TestData`. Decoder/head/geometry исходники Windows UI linked-compiled для тестов. + `windows/Hako/app.manifest` включает `longPathAware` для файловых операций Windows. ### Запуск и единственный экземпляр -1. `Program.Main` — синхронный `[STAThread] static void Main`. Он вызывает `ComWrappersSupport.InitializeComWrappers()`, - затем `AppInstance.FindOrRegisterForKey("Hako.Main")`. Изолированный CLI preview использует отдельный ключ - `Hako.ProfilePreview`, поэтому не активирует работающий обычный Hako. Если экземпляр не текущий, `RedirectActivationTo` запускает +1. `Program.Main` — синхронный `[STAThread] static void Main`. Режим `--playtime-helper ` обрабатывается до + WinUI, `AppInstance` и root lock; обычный путь затем разбирает `--hako-root` и `--data-root` + и валидирует абсолютные пути. Для переопределений нужны оба корня; неверные аргументы показывают системное сообщение + и завершают процесс без выбора production-путей. Изолированный запуск использует ключ + `Hako.Isolated.`, а `--profile-preview` — отдельный `Hako.ProfilePreview`. + Для изолированного запуска game-root и data-root не могут содержать друг друга. Обычный ключ остаётся `Hako.Main`. + Если экземпляр не текущий, `RedirectActivationTo` запускает `RedirectActivationToAsync` в `Task.Run`, а STA-поток ждёт его в `CoWaitForMultipleObjects`, продолжая обрабатывать сообщения: блокирующий `Wait()` может зависнуть, пока целевой экземпляр обрабатывает активацию. Затем перенаправляющий процесс вызывает `SetForegroundWindow` для главного окна целевого процесса и выходит. Сбой этого шага (например, целевой процесс уже завершился) игнорируется: второй экземпляр просто выходит. Текущий экземпляр при повторной активации вызывает `App.ActivateMainWindow` через `DispatcherQueue`: разворачивает свёрнутое окно (`OverlappedPresenter.Restore`), затем вызывает `AppWindow.Show()` и `Activate()`. Один экземпляр — решение пользователя. -2. Обычный `App()` создаёт `AppCoordinator` с `JsonUserSettingsStore`, `AccountSessionStore` и - `MicrosoftDeviceAuth(new HttpClient(), TimeProvider.System)`; каталог данных — `AppDataDirectory.GetLocalPath()`. +2. Обычный `App()` удерживает `HakoRootLock` на `.hako-lock` в игровом корне и каталоге данных. При sharing/lock violation + показывается локализованное сообщение и приложение не читает аккаунт, настройки или `instances.json`; прочие ошибки + блокировки показываются как ошибка доступа к хранилищу. Игровой корень может быть junction или symlink, а вложенные + пути сборок остаются защищены от reparse points. `HakoPaths` задаёт + `%USERPROFILE%\.hako`, каталог данных из `AppDataDirectory.GetLocalPath()` и `%LOCALAPPDATA%\Hako\Cache`. + App-lifetime сервисы используют эти пути: `InstanceStore`, `PlaytimeCoordinator` (только `GameRoot`), + `UserSettingsService` с `JsonUserSettingsStore`, `AccountSessionStore` и + `MicrosoftDeviceAuth(new HttpClient(), TimeProvider.System)`. `instances.json` атомарно + сохраняет полный профиль сборки; изменения сериализуются внутри `InstanceStore`. `settings.json` хранит язык и + optional-поля глобальных параметров игры, а `UserSettingsService` пишет единый снимок для обоих видов настроек. Skin preview service использует отдельный `HttpClient` с 15-секундным timeout для публичных Mojang texture API, без токенов; он не меняет `AccountSession` или формат persistence. -3. `OnLaunched` сначала показывает `MainWindow` (экран `Loading`), затем `await InitializeAsync()` выбирает - `Welcome` или `Builds`. В preview-режиме вместо файловых store создаются fake in-memory store и auth-сервис, - который запрещает sign-in; production-путь `AppDataDirectory.GetLocalPath()` не вызывается. Режим +3. `OnLaunched` показывает `MainWindow`, затем `AppCoordinator.InitializeAsync()` загружает настройки и аккаунт. + `PlaytimeCoordinator` сверяет ожидающий перенос с текущим XUID; `GameLaunchCoordinator.RestoreRunningGamesAsync` + восстанавливает существующие session IDs до reconcile, чтобы живая игра не считалась потерянной. После запуска + сервисов приложение сверяет журналы при старте и каждые 10 секунд; закрытие окна отменяет app polling, но не detached helper. + `InitializeAsync()` выбирает `Welcome` или `Builds`; ошибка реестра остаётся в `InstanceStore.LoadError` + для UI. В preview-режиме `HakoPaths` указывает на уникальный временный корень, создаются fake in-memory store + и auth-сервис, который запрещает sign-in; production-каталог данных не читается, временный корень удаляется при выходе. + Режим `--profile-preview guest` после гостевого перехода открывает «Настройки → Профиль»; обычный гостевой вход по-прежнему начинает на странице сборок. @@ -303,11 +331,12 @@ Windows — 10 1809 (`TargetPlatformMinVersion` 10.0.17763.0); `app.manifest` о `Hako (Package)` (`MsixPackage`) и `Hako (Unpackaged)` (`Project`). Тип упаковки (`WindowsPackageType`) в `Hako.csproj` не задан; CI выбирает `MSIX` для Windows-релизной сборки. - `PublishTrimmed` и `PublishReadyToRun` включаются для конфигураций, отличных от `Debug`. - Release-публикация показывает `IL2059` в сгенерированном `XamlTypeInfo.g.cs` для `RunClassConstructor` - у `Nullable` присоединённого свойства `TitleBar.IsDragRegion`, а также `IL2104` для - `SharpGen.Runtime`. `Nullable` не имеет статического конструктора; предупреждение зависимости - относится к upstream assembly. Не подавлять их глобально через `NoWarn`, чтобы не скрыть такие же - предупреждения в коде Hako. + Известные Release-предупреждения trimming: `IL2059` в сгенерированном `XamlTypeInfo.g.cs` для + `RunClassConstructor` у `Nullable` присоединённого свойства `TitleBar.IsDragRegion` и `IL2104` + в upstream-сборках `SharpGen.Runtime` или `System.Text.Json` (набор зависит от SDK и восстановленных + версий зависимостей). `Nullable` не имеет статического конструктора; предупреждения библиотек относятся + к их исходным сборкам. Не подавлять их глобально через `NoWarn`, чтобы не скрыть такие же предупреждения + в коде Hako. - Для isolated UI smoke запустить `Hako.exe --profile-preview [account|no-minecraft|guest|fixture]` (без режима используется `account`). `--preview-width` принимает 640–2560, `--preview-height` — 480–2560; эти аргументы действуют только в preview. Режимы используют fake аккаунт/гостя без реального UUID, сеть @@ -324,10 +353,11 @@ dotnet test windows/Hako.Tests/Hako.Tests.csproj -c Debug ### Ограничения Windows-клиента -- Не реализованы: сборки (вкладка «Сборки» показывает пустое состояние), запуск Minecraft, моды и ресурспаки, - каталоги Modrinth и CurseForge, самообновление. -- Обновление токенов не реализовано: `MicrosoftRefreshToken` сохраняется, refresh-grant в коде нет. -- `MinecraftAccessToken` сохраняется и нигде не читается. Игровой сессии на Windows пока нет. +- Не реализованы: CurseForge, самообновление Windows и восстановление из `.hakobackup`/`.hakoworld`. + Modrinth-паки устанавливаются из каталога; локальный импорт `.mrpack` и обновление всего модпака + не поддерживаются. +- Онлайн-запуск обновляет Minecraft-сессию через `MinecraftSessionCoordinator.ConnectForLaunchAsync` (refresh-grant + с сохранением нового refresh token); без Minecraft-профиля доступен только offline-mode сборки. ### Решения и основания diff --git a/Obsidian/HakoVault/Auth/MicrosoftAuth.md b/Obsidian/HakoVault/Auth/MicrosoftAuth.md index 33c39c7..873b803 100644 --- a/Obsidian/HakoVault/Auth/MicrosoftAuth.md +++ b/Obsidian/HakoVault/Auth/MicrosoftAuth.md @@ -141,8 +141,11 @@ email и, если доступен, профиль Minecraft. Без Minecraft HTTP-статус из этих трёх запросов, пустой `uhs`, пустой или некорректный JSON, сеть, таймаут) даёт пустые поля Minecraft, вход продолжается с профилем Xbox. Причина не сохраняется и не показывается. Исключение выходит наружу только при отмене нашим `CancellationToken`. `MinecraftTokenExpiresAt` = момент получения + `expires_in`. -5. Запись `AccountSession` через `AccountSessionStore.SaveAsync` — аккаунт становится текущим, только если - попытка ещё актуальна ([[Data/Persistence#Windows-клиент]]). +5. Перед `AccountSessionStore.SaveAsync` `AppCoordinator` фиксирует на ledger границу ожидающего переноса гостевого + времени. `session.dat` сохраняется только если попытка ещё актуальна; после успешной записи перенос применяется + одной атомарной операцией, только для сохранённого целевого XUID и зафиксированных на границе сумм/сессий. + При ошибке или отмене запись переноса удаляется; при сбое между фазами решение восстанавливается при запуске + по текущему `session.dat` ([[Data/Persistence#Игровое время Windows]]). Коды `XErr` шагов 1–2 классифицируются: `2148916233` → `XboxProfileMissing`, `2148916235` → `XboxUnavailable`, `2148916236` и `2148916237` → `XboxAgeVerification`, `2148916238` → `XboxChildAccount`, остальные → `XboxRejected`. @@ -167,8 +170,29 @@ email и, если доступен, профиль Minecraft. Без Minecraft - Одна активная попытка: `StartAttempt` через `InvalidateAttemptLocked` увеличивает поколение и отменяет предыдущую попытку. - Отмена: `CancelSignIn` (закрытие `MainWindow`), `BackFromSignIn`, `LogoutAsync`. Ошибки отменённой попытки не показываются. -- Все переходы состояния попытки и финальная запись аккаунта выполняются под `_commitLock`. Запись проходит - только при `IsCurrentAttempt` (то же поколение, попытка активна и не отменена); `File.Move` выполняется - внутри этого условия. Устаревший вход не заменит аккаунт, а завершившийся вход не восстановит аккаунт после выхода. +- Все переходы состояния попытки и финальная запись аккаунта выполняются под `_commitLock`. Подготовка счётчиков + выполняется до финальной фиксации; сама запись проходит только при `IsCurrentAttempt` (то же поколение, попытка + активна и не отменена), а `File.Move` остаётся внутри этого условия. Фаза применения сериализована с выходом, + но файловые операции подготовки не блокируют UI и не удерживают commit lock. Устаревший вход не заменит аккаунт, + а завершившийся вход не восстановит аккаунт после выхода. - Выход: `LogoutAsync` сначала инвалидирует попытку, затем удаляет `session.dat` под тем же условием. Выбранный язык при выходе сохраняется (тест `LogoutKeepsSelectedLanguage`). + +### Обновление сессии для онлайн-запуска + +Строгий путь, отдельный от мягкого шага 4 первого входа (тот не изменён). +`IMicrosoftAuthService.ConnectMinecraftAsync(refreshToken, persistRefreshToken, ct)`: refresh-grant Microsoft → +**сразу** `persistRefreshToken` с ротированным токеном → Xbox → XSTS Minecraft → `login_with_xbox` → профиль. +Сбой после refresh не теряет новый токен. Ошибки различимы: `invalid_grant` → `RefreshTokenRejected`, +403/404 входа или 403 профиля → `MinecraftLicenseMissing`, 404 профиля или пустой профиль → `MinecraftProfileMissing`; +сеть/таймаут → `Network`. Ключи resw: `MinecraftRefreshTokenRejected`, `MinecraftLicenseMissing`, `MinecraftProfileMissing`. +`MinecraftConnection.ToString()` не выводит access token. + +`MinecraftSessionCoordinator` (`AppCoordinator.MinecraftSessions`, `windows/Hako.Core/MinecraftSessionCoordinator.cs`): +- `ConnectForLaunchAsync` возвращает текущий `AccountSession`, если Minecraft-токен действует ещё больше минуты + (время — через `TimeProvider`), иначе запускает refresh. Параллельные вызовы ждут одну операцию. +- Каждая запись (`session.dat` и `AppCoordinator.Account`) идёт через `AppCoordinator.TryCommitAccountUpdate` внутри + `commitIfCurrent` с поколением аккаунта; вход, загрузка и выход увеличивают поколение (`AccountGenerationChanged`) + и отменяют refresh. Поздний результат не восстановит удалённый `session.dat` и не заменит новый аккаунт. +- `Status` (`Idle`/`Connecting`/`Connected`/`Failed`) и `Error` для UI; события приходят с потока refresh — + UI маршалит их в `DispatcherQueue`. diff --git a/Obsidian/HakoVault/Data/Backups.md b/Obsidian/HakoVault/Data/Backups.md index 4e1e95f..516f477 100644 --- a/Obsidian/HakoVault/Data/Backups.md +++ b/Obsidian/HakoVault/Data/Backups.md @@ -105,3 +105,37 @@ SHA-512 позволяет будущему восстановлению рас паков выполняются на `InstanceContent`, копирование/zip — вне главного потока. Восстановление пока не реализовано; будущий импорт обязан проверять версию схемы, все пути и записи архива перед распаковкой, не доверяя `worldPath` и `datapacks[].path` из JSON. + +## Windows-клиент + +Реализация без WinUI находится в `windows/Hako.Core/Instances/InstanceBackup.cs`, +`WorldBackup.cs`, `BackupModels.cs`, `BackupJsonConverters.cs` и общем обходчике +`InstanceFileTree.cs`; source-generated типы зарегистрированы в `HakoJsonContext` +(`windows/Hako.Core/JsonContexts.cs`). UI находится в профиле сборки и `InstanceWorldsView` +(`windows/Hako/InstanceProfilePage.xaml.cs`, `InstanceWorldsView.xaml.cs`). + +Windows использует те же версии схемы и имена полей, что и Swift. CamelCase JSON пишет source generation, +необязательные null-поля пропускаются, PNG-данные кодируются base64. Даты сериализуются ISO 8601 в UTC +с точностью до секунды и суффиксом `Z`, чтобы соответствовать Swift `JSONEncoder.dateEncodingStrategy = .iso8601`. +Дата в имени архива также UTC: `{folder}_{yyyy-MM-dd_HH-mm-ss}`. + +- `.hakobackup` лежит в `%USERPROFILE%\.hako\backups\`; корень ZIP содержит `data.json` и содержимое + папки сборки без родительской папки. В архив входят Java, Minecraft, иконка, служебные реестры происхождения, + Fabric-конфигурация и completed-план модпака. Список `mods` строится из обычных JAR и происхождения Modrinth; + если реестр происхождения нельзя прочитать, моду назначается источник local. transient + `.hako-running.json`, `.hako-launch/`, `.hako-game/` и `minecraft/logs/hako-launch.log` не архивируются. +- `.hakoworld` лежит в `%USERPROFILE%\.hako\worlds\`; ZIP содержит `data.json` манифеста и `world/` со всем + сохранением. Пользовательский `world/data.json` остаётся обычным файлом мира. В манифесте сохраняются источник + сборки, метаданные мира, иконка и включённые/отключённые датапаки с origin; обычные ZIP получают SHA-512, + для папок он отсутствует, доступный `pack.png` ограничен 4 МиБ. +- Архивы создаются потоково, без сборки всего содержимого в память. Перед публикацией файловое дерево повторно + проверяется; reparse points/device entries отклоняются, а зарезервированные папки `backups`/`worlds`, занятые + сборкой или небезопасным объектом, дают типизированный отказ. ZIP сначала пишется в уникальный скрытый + `.partial` рядом с конечным файлом и затем перемещается без перезаписи; отмена или ошибка удаляет только свой + staging-файл. Операция требует готовую сборку и общий content-lease, поэтому игра, установка, дублирование и + другие файловые действия сериализованы. UI показывает прогресс и локализованную причину отказа, затем может + открыть папку архива в Проводнике. + +`windows/Hako.Tests/InstanceBackupsAndScreenshotsTests.cs` проверяет имена/содержимое ZIP, совместимость схемы +и дат, base64-иконки, происхождение модов/датапаков, исключение transient-файлов, хеши, потоковую обработку, +коллизии, отмену и очистку staging. Восстановление или импорт архивов не реализованы. diff --git a/Obsidian/HakoVault/Data/Persistence.md b/Obsidian/HakoVault/Data/Persistence.md index c25bacd..0318008 100644 --- a/Obsidian/HakoVault/Data/Persistence.md +++ b/Obsidian/HakoVault/Data/Persistence.md @@ -245,7 +245,9 @@ project/version ID, страницу, SHA-512 и закреплённый descri ## Windows-клиент Хранение в `windows/Hako.Core/` (без WinUI). Аккаунт — зашифрованный файл `session.dat`, настройки — -`settings.json`. Каталог данных задаёт `windows/Hako/AppDataDirectory.cs`. Экраны и вход — [[UI/Screens#Windows-клиент]], +`settings.json`, профили сборок — `instances.json`. `HakoPaths` задаёт игровой корень, каталог данных и кэш; +производственный игровой корень — `%USERPROFILE%\.hako`, каталог данных по-прежнему задаёт +`windows/Hako/AppDataDirectory.cs`. Экраны и вход — [[UI/Screens#Windows-клиент]], [[Auth/MicrosoftAuth#Windows-клиент]]. ### Источники @@ -254,10 +256,26 @@ project/version ID, страницу, SHA-512 и закреплённый descri |------|-----------------|------| | `windows/Hako.Core/AccountSession.cs` | `AccountSession` | Запись аккаунта | | `windows/Hako.Core/AccountSessionStore.cs` | `AccountSessionStore`, `AccountStoreException` | Шифрование, чтение, атомарная запись и удаление `session.dat` | -| `windows/Hako.Core/JsonUserSettingsStore.cs`, `windows/Hako.Core/UserSettings.cs` | `JsonUserSettingsStore`, `UserSettings` | `settings.json` с языком | -| `windows/Hako.Core/AppCoordinator.cs` | `AppCoordinator.InitializeAsync`, `SetLanguageAsync`, `LogoutAsync` | Загрузка при старте, сериализованное сохранение языка и откат, выход | +| `windows/Hako.Core/JsonUserSettingsStore.cs`, `UserSettings.cs`, `UserSettingsService.cs` | `JsonUserSettingsStore`, `UserSettings`, `UserSettingsService` | `settings.json`: язык и глобальные параметры игры; единый сериализованный снимок | +| `windows/Hako.Core/HakoPaths.cs`, `HakoRootLock.cs` | `HakoPaths`, `HakoRootLock` | Пути корней, изолированные запуски и блокировки game-root/data-root | +| `windows/Hako.Core/Instances/GameInstance.cs` | `GameInstance`, `InstanceDraft`, `InstanceParameters` | Полный JSON-профиль сборки Windows | +| `windows/Hako.Core/Instances/InstanceStorage.cs`, `InstanceStore.cs` | `InstanceStorage`, `InstanceStore`, `InstanceRegistryFile` | Безопасные пути, `instances.json`, транзакционное создание и управление профилями | +| `windows/Hako.Core/Instances/InstanceScreenshots.cs`, `InstanceFileTree.cs` | `InstanceScreenshots`, `InstanceFileTree` | Безопасный список PNG и корзина скриншотов; проверяемое копирование дерева и потоковая запись ZIP | +| `windows/Hako.Core/Instances/InstanceBackup.cs`, `WorldBackup.cs`, `BackupModels.cs`, `BackupJsonConverters.cs` | `InstanceBackup`, `WorldBackup`, `BackupResult` | Форматы Windows `.hakobackup`/`.hakoworld`, типизированные ошибки, Swift-совместимые даты | +| `windows/Hako.Core/Instances/InstanceContent.cs` | `InstanceContent`, `InstanceContentTarget`, `ModRegistry`, `ModOrigin`, `InstanceContentItem` | Реестры модов/ресурспаков/датапаков мира, локальные импорт, включение и замена; установка Fabric API | +| `windows/Hako.Core/Instances/InstanceContentController.cs` | `InstanceContentController`, `InstanceContentState`, `InstanceContentStateKey`, `ContentConfirmationRequest` | Core-оркестрация локального контента, выбранного мира, зависимостей Modrinth и обновлений; подтверждения без WinUI | +| `windows/Hako.Core/Minecraft/FabricModels.cs`, `FabricClient.cs`, `ModrinthClient.cs` | `FabricConfiguration`, `FabricProfile`, `FabricVersionPredicate`, `FabricClient`, `ModrinthClient` | Fabric Meta; поиск и версии Modrinth, распознавание SHA-512, кэш и проверяемая потоковая загрузка | +| `windows/Hako.Core/JsonContexts.cs` | `HakoJsonContext` | Source-generated JSON-контракты Hako, Fabric, контента Modrinth и его кэша | +| `windows/Hako.Core/Playtime/PlaytimeModels.cs`, `PlaytimeStorage.cs`, `PlaytimeCoordinator.cs` | `PlaytimeLedger`, `PlaytimeLedgerStore`, `PlaytimeCoordinator` | Общий журнал игрового времени Windows, проверяемые сессии и восстановление гостевого переноса | +| `windows/Hako.Core/RecycleBin.cs` | `RecycleBin`, `IFileOperationProgressSink` | Перемещение в Корзину и подтверждённое безвозвратное удаление через API | +| `windows/Hako.Core/AppCoordinator.cs` | `AppCoordinator.InitializeAsync`, `SetLanguageAsync`, `LogoutAsync` | Загрузка при старте, изменение языка через общий сервис настроек, выход | +| `windows/Hako/Program.cs`, `App.xaml.cs` | `Program.Main`, `App` | Проверка аргументов до `AppInstance`, выбор путей, блокировка корня и сервисы приложения | | `windows/Hako/AppDataDirectory.cs` | `AppDataDirectory.GetLocalPath` | Каталог данных | | `windows/Hako.Tests/StorageTests.cs` | — | DPAPI без открытого текста токенов, очистка, повреждённый файл, сбой замены | +| `windows/Hako.Tests/InstanceStoreTests.cs`, `UserSettingsServiceTests.cs`, `RecycleBinTests.cs` | — | Изоляция путей, файловые транзакции, настройки, блокировка и корзина | +| `windows/Hako.Tests/ModrinthTests.cs` | — | Фасеты, совместимость версий, распознавание, кэш и проверяемые загрузки Modrinth | +| `windows/Hako.Tests/InstanceContentTests.cs`, `InstanceDatapackContentTests.cs` | — | Реестры и локальные операции модов/паков/датапаков, проверка `pack.mcmeta`, замена, rollback и reparse points | +| `windows/Hako.Tests/InstanceContentControllerTests.cs` | — | Зависимости Modrinth, world-scoped состояния, подтверждения, выбор связанных паков, отмена, обновления и lease | | `windows/Hako.Tests/RedactionTests.cs` | — | `ToString()` записей `MicrosoftToken` и `AccountSession` без токенов | ### Аккаунт: `session.dat` @@ -278,16 +296,150 @@ project/version ID, страницу, SHA-512 и закреплённый descri ### Настройки: `settings.json` -- Файл `settings.json` в том же каталоге. Содержимое — `UserSettings`: `{ "language": "ru" }` или `"en"` - (camelCase, с отступами, `null` не пишется). +- Файл `settings.json` в каталоге данных. `UserSettings` хранит `language` и optional-поля `javaPath`, + `javaArguments`, `minecraftArguments`, `fullscreen`, `windowWidth`, `windowHeight`, `maximumMemoryMiB` + (camelCase, с отступами, `null` не пишется). Старый файл только с `language` читается без миграции. - Отсутствующий `language` или файла нет — язык системы (`AppLanguages.Default`). - `LoadAsync`: файла нет → `null`. Ошибки чтения и `JsonException` пробрасываются; `AppCoordinator.InitializeAsync` ставит `StartupNotice.PreferencesUnavailable` и берёт язык по умолчанию (`AppLanguages.Default`). - `SaveAsync`: запись в `settings.json..tmp`, затем `File.Move(..., true)`; временный файл удаляется в `finally`. -- `SetLanguageAsync` сразу меняет `Language`. Сохранения идут по одному (`_languageSaveGate`) и пишут текущий `Language`, - поэтому побеждает последний выбор. При ошибке записи язык откатывается к `_savedLanguage` (последнему успешно сохранённому - или загруженному при старте), только если за это время не было нового выбора (`_languageSelection`); исключение пробрасывается, - UI показывает `LanguageSaveError`. +- `UserSettingsService` держит единый снимок языка и игровых параметров. Изменения языка и параметров используют общий + `SemaphoreSlim`, и каждая запись сохраняет полный актуальный снимок. Если первоначальная загрузка завершилась ошибкой, + перед следующей записью сервис повторяет чтение и объединяет выбранные изменения с данными на диске; при повторной ошибке + ввода-вывода запись не выполняется, а при `JsonException` повреждённый файл заменяется полным текущим снимком. + `AppCoordinator.SetLanguageAsync` меняет язык сразу; при ошибке он возвращается к языку из + последнего успешно сохранённого снимка, если после этого не было нового выбора. Ошибка пробрасывается, UI показывает + `LanguageSaveError`. + +### Профили сборок Windows + +- `instances.json` находится в игровом корне и имеет формат `{ "formatVersion": 1, "instances": [...] }`. + `HakoJsonContext` пишет camelCase; значения источника аргументов, состояния установки и загрузчика — `global`/`mojang`/`custom`, + `queued`/`installing`/`paused`/`ready`/`failed`, `vanilla`/`fabric`. В записи сохраняются параметры сборки, offline-ник, + конфигурация Fabric, состояние паузы, Java, совместимость текстурпаков и архитектура. +- `FabricConfiguration` хранит `loaderVersion` и optional `api`; в API-дескрипторе JSON-ключи `projectID`, `versionID`, + `version`, `channel`, `filename`, `url`, `size`, `sha1`, `sha512` совпадают со Swift. После установки SHA-1 точного + профиля из Fabric Meta сохраняется в `fabricProfileSHA1`; профиль расположен в `minecraft/.hako-fabric.json`. + Временные и API JSON-типы зарегистрированы в source-generated контекстах, а сохранённые optional-поля `null` опускаются. +- `InstanceStore.LoadAsync` асинхронно загружает реестр при старте. `Instances` и `List()` возвращают снимок в памяти; + успешная фиксация записи обновляет его. Ошибка чтения доступна в `LoadError`; повреждённый или неизвестный формат + остаётся нетронутым, но не блокирует открытие главного окна. Операции создания и изменения профилей блокируются + `InstanceRegistryException` с кодом ошибки и ключом локализации (`InstanceRegistryDamaged` или + `InstanceRegistryUnavailable`). Реестр пишется во временный файл, сбрасывается на диск и публикуется через `File.Move`. +- Имя сборки — 1–60 ASCII-букв, цифр и пробелов после trim; пробелы в имени папки становятся `_`. `backups`, `worlds` + и имена устройств Windows зарезервированы. Коллизии в реестре и файловой системе проверяются без учёта регистра. + `InstanceStorage.ContainedPath` отклоняет traversal, абсолютные пути, symlink и reparse points ниже игрового корня; + сам корень может быть junction или symlink, например когда `.hako` вынесена на другой диск. +- Создание эксклюзивно создаёт каталог профиля и только его удаляет при ошибке. Переименование переносит каталог, + использует временное имя при смене регистра и откатывает перенос при ошибке записи. Удаление сначала переносит каталог + в `.delete-{instanceId:N}-{randomId:N}`, затем сохраняет реестр и очищает staging в фоне. После публикации снимка + `LoadAsync` в фоне удаляет только staging-имена этого формата и прежние `.delete-{GUID}` в форматах N/D; + `.delete-whatever` остаётся нетронутым. При обнаружении нового staging с записью реестра и отсутствующей исходной папкой + запись атомарно удаляется; если папка уже на месте, staging удаляется без изменения записи. Очистка не переходит по + вложенным junction и symlink. Сбой очистки записывается в Debug-вывод и повторяется при следующей загрузке. + При ошибке фиксации реестра каталог восстанавливается. `TryBeginLaunch` и `TryBeginContent` выдают одноразовые + `IDisposable`-leases; занятость снимается только при Dispose. Начало операций и блокировки Delete/Rename/Update + сериализованы общим `_gate`; состояния очереди/установки также блокируют эти операции. +- `InstanceStore.DuplicateAsync` требует готовую и свободную сборку. Он копирует её в эксклюзивный staging рядом + с целевой папкой под content-lease, затем атомарно публикует папку и профиль с новым UUID/датой; сбой записи + реестра откатывает только папку, созданную этой операцией. Имена вида «Имя 2», «Имя 3» подбираются без учёта + регистра. Fabric, параметры, иконка, происхождение и completed-план модпака сохраняются; токены и transient + файлы запуска (`.hako-running.json`, `.hako-launch`, `.hako-game`, журнал `hako-launch.log`) исключены. +- `InstanceScreenshots.ListAsync` читает только обычные видимые PNG в корне `minecraft/screenshots`; + отсутствующая папка остаётся отсутствующей. Перемещение в Корзину перепроверяет владение и тип каждого файла + и возвращает частичные статусы. Отдельный screenshot-lease блокирует установку и файловые операции, но разрешает + удаление скриншотов во время игры, как на macOS. +- `minecraft/.hako-mods.json` повторяет Swift-контракт: `{ "apiProvisioned": bool, "files": { "": ModOrigin } }`. + `source` записывается как `local`, `modrinth` или `curseForge`; ключи Origin — camelCase, включая `projectID`, `pageURL`, + `sha512`, `api`, `versionNumber`, `iconURL`, `fileType`. Необязательные null-поля не записываются. Реестр читается и + пишется через `HakoJsonContext`, публикуется атомарно; повреждённый файл, каталог или reparse point не заменяется. + `InstanceContent.PublishJarAsync` берёт content-lease, а установка использует lease очереди, поэтому пользовательская + публикация и установка сериализованы. `apiProvisioned` не сбрасывается при удалении/отключении API пользователем. +- `InstanceContent.ListAsync`, `ImportAsync`, `SetEnabledAsync`, `ReplaceAsync` и `TrashAsync` — локальные операции этапа 6a. + Каждая публичная мутация берёт `InstanceStore` content-lease; внутренний `WithContentLeaseAsync` позволяет будущему + контроллеру удерживать один lease для нескольких публикаций без повторного захвата. Моды находятся в + `minecraft/mods`; отключённый JAR остаётся там же с суффиксом `.disabled`. Ресурспаки находятся в `resourcepacks` + или в старой папке `texturepacks`; ZIP остаются файлами, папки принимаются только с обычным `pack.mcmeta`. + Отключённые пакеты переносятся в `.hako-disabled-resourcepacks` или `.hako-disabled-texturepacks` соответственно. + Импорт локального файла/папки удаляет его прежнее происхождение, ключи файлов в реестрах всегда lowercase. +- `minecraft/.hako-resourcepacks.json` использует ту же форму `{ "apiProvisioned": bool, "files": { ... } }` и поля + `ModOrigin`, что и реестр модов; флаг `apiProvisioned` сохраняет совместимость, хотя для паков он не используется. + Замена требует ожидаемый SHA-512 текущего файла и сохраняет его enabled/disabled-состояние. При ошибке публикации + новый файл удаляется и старый восстанавливается независимо; если откат не завершён, исходная и ошибки отката + включаются в исключение, а оставшаяся резервная копия не удаляется и указывается в сообщении. Перемещение в Корзину + удаляет запись происхождения только при статусе `Recycled`; + отказ или частичная ошибка оставляет реестр нетронутым. Папки паков локальные, их origin не записывается. +- Датапаки мира хранятся в `minecraft/saves//datapacks`, отключённые — в + `minecraft/saves//.hako-disabled-datapacks`. Реестр `.hako-datapacks.json` лежит в папке мира и содержит + `files` с ключами lowercase ZIP-имён и тем же `ModOrigin` (Swift-ключи `projectID`, `versionID`, `pageURL`, `sha512`, + `versionNumber`, `iconURL`, `fileType`). `apiProvisioned` для этого реестра необязателен при чтении и записывается + Core при сохранении; это сохраняет совместимость с Swift. Реестр повреждённого формата не перезаписывается. ZIP + требует один обычный корневой `pack.mcmeta` размером до 1 МиБ с JSON object `pack`; архив ограничен 100 000 entries, + ZIP копируется как файл, папка обходится без перехода по reparse points. Создание datapacks leaf допускается только + внутри существующего проверенного мира; missing world/level metadata не восстанавливается. +- Реестры повреждённого формата не перезаписываются. Имена проверяются как Windows path segments; импорт и чтение + папок паков отклоняют reparse points и junction внутри дерева. Поиск записей использует сравнение без учёта регистра, + чтобы включение, отключение или замена не создали вторую запись того же логического файла. Для обновления проверяется + ожидаемый старый SHA-512; disabled-состояние и origin сохраняются, публикация и реестр откатываются вместе при ошибке. +- `InstanceContentTarget` предоставляет `.Mods`, `.Packs` и `.WorldDatapacks(worldId)`. Overload-ы `InstanceContent` + и `InstanceContentController` по старому `ModrinthContentKind` остаются для модов и ресурспаков; datapack-kind без + явной папки мира отклоняется. Локальные и Modrinth-операции мира входят в Core этапа 7b; Windows UI подключает + их из профиля по UUID сборки и имени папки мира. Удаление отдельных датапаков не предоставляется. +- `InstanceContentController` — Core-only контроллер этапов 6b/7b; persisted-схема реестров не меняется. `InstalledProjectsAsync` + группирует записи происхождения и распознаёт остальные файлы по SHA-512, включая отключённые. Узнанный вручную файл + получает `ModOrigin` под content-lease только после повторной проверки ожидаемого SHA-512. `RecordOriginUnderLeaseAsync` + обновляет только JSON-реестр и не меняет файл. Обновления связанных ZIP ресурспаков берут совместимую datapack-версию + и сохраняют `fileType` в origin. +- Снимок `InstanceContentState` адресован `InstanceContentStateKey(instanceId, kind, worldId)`; для модов и ресурспаков + `worldId` null, для датапаков это ID папки выбранного мира. Так ошибки, обновления, busy, текущий проект и revision + изолированы между мирами. `StateChanged` не маршалит на UI-поток. + При обновлении с коллизией подтверждённый файл сохраняется в `.hako-backup-` до commit общего registry; + до commit ошибки/отмена восстанавливают старый файл и коллизию независимо. После commit коллизия отправляется + в корзину без caller cancellation. При отказе Shell новая версия и registry остаются опубликованы, backup + сохраняется, `ContentOperationFailed.Arguments["diagnostic"]` сообщает путь для восстановления; JSON-форматы не меняются. + Обработчик WinUI этапа 6c должен публиковать снимок на `DispatcherQueue`. Подтверждения передаются callback-ом через + `ContentConfirmationRequest` с enum-кодами и string-аргументами для ru/en ресурсов; сам Core не содержит UI-локализации. + Для связанных ресурспаков датапака `ContentConfirmationRequest.Files` передаёт имя, `fileType` и required/optional. + Additive `ContentConfirmationSelectionCallback` возвращает имя выбранного ZIP; прежний callback с `Primary` сохраняет + поведение и выбирает первый вариант. `Alternative` продолжает без связанного ресурспака, `Cancel` останавливает операцию + до изменений. `WorldUnavailable` — типизированная ошибка отсутствующего/изменившегося мира для UI 7c. + Callback получает `CancellationToken`, операции не блокируют поток UI и удерживают один content-lease без повторного + захвата через `InstanceContent.*UnderLeaseAsync`. Windows UI подключён через app-lifetime `LauncherServices`: + `InstanceContentView` и `ModrinthCatalogView` применяют актуальный снимок по UUID/kind на `DispatcherQueue`, сверяя + `Revision`; уход со страницы отменяет её чтение и поиск, но не уже начатую установку. Общий `HakoDialogs` сериализует + `ContentDialog` одного окна и завершает ожидающее подтверждение отменой при закрытии окна. `UpdateAllAsync` подтверждения не показывает: такие элементы получают + `ConfirmationRequired`, ошибки собираются по файлам, дальнейшие обновления продолжаются. Публичное подключение контроллера + не меняет JSON-реестры; профиль Windows подключает миры, датапаки и их резервное копирование без изменения + этих сохранённых контрактов. +- Кеш хешей Modrinth находится в `%LOCALAPPDATA%\Hako\Cache\Modrinth\files.json` (или в инжектируемом + `HakoPaths.CacheRoot`); ключ файла содержит нормализованный путь, размер и время изменения. Удалённые пути и + неиспользуемые версии очищаются при сохранении, неизвестные хеши живут только в памяти клиента. Клиенты в одном + процессе с одинаковым путём используют общую блокировку и перечитывают последний снимок до сохранения; разные корни + не блокируют друг друга. Межпроцессная синхронизация не применяется. +- `HakoRootLock` удерживает lock-файлы в игровом и data-root с `FileShare.None` до завершения приложения; lock data-root + защищает `session.dat` и `settings.json` от другого экземпляра. Сам game-root может быть junction или symlink. Только + sharing/lock violation считается уже запущенным экземпляром; прочие ошибки остаются обычными ошибками старта. При конфликте + показывается локализованное сообщение из `Resources.resw`. +- `RecycleBin` применяет `IFileOperation` с `FOF_ALLOWUNDO` и `FOFX_RECYCLEONDELETE`; progress sink возвращает `E_ABORT`, + когда Shell не указал `TSF_DELETE_RECYCLE_IF_POSSIBLE`. Результат пакетного удаления содержит статус каждого элемента: + `Recycled`, `NotRecyclable`, `Failed` с сообщением об ошибке или `Cancelled`; после частичного успеха операция не теряет + уже полученные результаты. Успех Shell подтверждается только при `SUCCEEDED(hr)` и ненулевом `newItem`; иначе элемент + помечается `Failed`, поскольку он мог удалиться, и нужно проверить его наличие. `PermanentDelete` требует абсолютный путь + и обязательный `allowedRoot`, принимает только вложенный путь без прохождения через reparse points и удаляет конечную + ссылку без перехода по ней. Для длинных Win32-путей приложение помечено `longPathAware`; Shell получает обычное абсолютное + parsing name, поскольку пространство `\\?\` предназначено для файловых API и не разбирается Shell как parsing name. + COM-обёртки source generation освобождаются через `ComObject.FinalRelease`: после `Unadvise` освобождаются элементы, + затем операция, и только после этого вызывается `CoUninitialize`. + +### Пути и изоляция Windows + +- `HakoPaths` объединяет игровой корень, каталог данных и кэш. В production это `%USERPROFILE%\.hako`, текущий каталог + `AppDataDirectory.GetLocalPath()` и `%LOCALAPPDATA%\Hako\Cache`. +- `Program.Main` проверяет `--hako-root ` и `--data-root ` до `AppInstance`. Для изоляции требуются оба + абсолютных пути, которые не могут содержать друг друга; ключ процесса — `Hako.Isolated.`. Ошибка разбора + показывает локализованное сообщение и завершает запуск, не выбирая production-каталоги. `--profile-preview` создаёт + уникальный временный корень и in-memory account/settings stores; при выходе удаляется только каталог, эксклюзивно + созданный этим процессом. ### Каталог данных @@ -302,24 +454,61 @@ project/version ID, страницу, SHA-512 и закреплённый descri (см. `AppDataDirectory.GetLocalPath` выше). - Гостевой вход (`AppCoordinator.IsGuest`) хранится только в памяти: после перезапуска приложение возвращается к `Welcome` (тест `GuestDoesNotPersistAndRestartReturnsToWelcome`). -- Сохранённый профиль Minecraft (`MinecraftProfileId`, `MinecraftName`) — данные профиля, а не игровая сессия. - `MinecraftAccessToken` и `MinecraftTokenExpiresAt` нигде не читаются, обновление токенов не реализовано. - Выход удаляет запись целиком. +- `MinecraftProfileId`, `MinecraftName`, `MinecraftAccessToken` и `MinecraftTokenExpiresAt` — состояние Minecraft-сессии + в `AccountSession`. `MinecraftSessionCoordinator.ConnectForLaunchAsync` переиспользует access token, если до истечения + осталось больше минуты; иначе `MicrosoftDeviceAuth.ConnectMinecraftAsync` обновляет сессию через Microsoft refresh token. + Ротированный refresh token сохраняется до Xbox/XSTS/Minecraft-запросов, а профиль, access token и срок действия + записываются через `commitIfCurrent`. Параллельные запросы используют один refresh; смена поколения аккаунта и выход + отменяют его. Подробный контракт — [[Auth/MicrosoftAuth#Обновление сессии для онлайн-запуска]]. Выход удаляет запись целиком. ### Решения и основания - DPAPI и файлы вместо `ApplicationData.Current`: `ApplicationData.Current` падает в unpackaged-запуске (причина из плана пользователя). -## План модпака +### Игровое время Windows + +- Общие данные лежат в `HakoPaths.GameRoot/.hako-playtime/` как в MSIX, так и в unpackaged-запуске; + зашифрованный `session.dat` и настройки остаются в отдельном `DataRoot`. Поэтому игровые счётчики общие между + вариантами запуска, даже когда каталог данных аккаунта различается. +- `playtime.json` хранит счётчики владельцев `account:` и `guest`, независимые суммы по UUID сборок, + уже начисленное время сессий, ожидающий перенос и маркеры применённых переносов. `sessions/.json` + хранит проверяемое состояние сессии; рядом используется эксклюзивный `.lock`. Записи типизированы в + `HakoJsonContext`, ограничены по размеру и публикуются атомарной заменой; межпроцессная запись ledger + дополнительно сериализована file lock. Перед чтением и созданием файлов проверяется вся цепочка родителей, + существующий `GameRoot`, каталог ledger и конечные ledger/lock/temp: reparse points отклоняются; + при отсутствии `GameRoot` хранилище его не создаёт. +- Вход выполняет двухфазный перенос. До атомарной записи `session.dat` ledger фиксирует `pendingTransfer` с целевым + XUID и точными значениями гостевых счётчиков/активных session IDs на границе. После успешной записи аккаунта одна + атомарная ledger-транзакция добавляет только зафиксированные суммы к аккаунту, вычитает их у гостя и меняет владельца + перечисленных активных сессий. Время, начисленное гостю после границы, остаётся гостевым. Повтор защищён ID переноса. +- Помощник держит session lock на время наблюдения, поэтому создание transfer boundary не ждёт его освобождения: + атомарный JSON-снимок читается без записи, а текущий `QueryUnbiasedInterruptTime` задаёт elapsed активных сессий. + Помощник продолжает checkpoints; следующая reconcile начисляет разницу уже по новому владельцу ledger-сессии. + Финальный журнал удерживаемой переносом сессии сохраняется до успешного начисления и удаления сессии из ledger. + Завершение процесса само по себе не завершает сессию: пока журнал не финализирован, фаза A сохраняет session ID, + даже если helper ещё не опубликовал свою identity или последний checkpoint. Для уже завершившейся игры граница + включает только сохранённый elapsed; поздний остаток получает владелец этой сессии после применения переноса + (при отмене — прежний гость). Новые гостевые сессии этим переносом не затрагиваются. + Отмена запуска после прикрепления процесса также проходит через reconcile и начисление, а не удаляет журнал напрямую. + Повтор cleanup после ошибки удаления файла не начисляет время заново. +- При запуске незавершённый перенос применяется только если XUID совпадает с загруженным `session.dat`; иначе он + удаляется без применения. Ошибка после подготовки или сохранения аккаунта оставляет достаточно данных для этой + проверки/повтора. Выход отменяет ожидающий перенос; он не переписывает `session.dat` или DPAPI-формат. +- При удалении сборки её строка профиля и файлы удаляются обычным `InstanceStore`, но игровые суммы остаются в ledger. + Неизвестные/потерянные журналы восстанавливаются по последней сохранённой контрольной точке и не начисляются повторно. -Схема SwiftData не расширяется для модпаков. `ModpackPlan` хранится как `.hako-modpack.json` в корне -папки сборки: версия формата 1, проект/версия Modrinth, страница источника, проверяемый файл архива, -полный манифест и флаг completed. Отсутствие файла означает обычную сборку; повреждённый файл — ошибку, -а не переход к обычной установке. `InstanceStore.create(..., modpack:)` сверяет Minecraft и Loader -с черновиком, пишет план до сохранения модели и удаляет новую папку при ошибке. +## План модпака -У модпака Fabric-конфигурация содержит Loader без api; старые JSON с api остаются читаемыми. -Очередь читает план при повторе/перезапуске. После completed исходный состав больше не применяется. -Переименование, дублирование, удаление и `.hakobackup` переносят план вместе с папкой; -исходный архив находится в проверяемом внешнем кэше и при необходимости скачивается по закреплённой ссылке. +На macOS SwiftData-схема не расширяется. На Windows `ModpackPlan` сериализуется через `HakoJsonContext` и +хранится как `.hako-modpack.json` в корне папки сборки: версия формата 1, `projectID`, `versionID`, +`versionNumber`, `pageURL`, проверяемый файл архива, полный манифест и необязательный `completed` (по умолчанию +`false`). Отсутствие файла означает обычную сборку; повреждённый файл — ошибку, а не переход к обычной установке. +Windows `InstanceStore.Create(..., modpackPlan:)` сверяет Minecraft и Loader с черновиком, пишет план до +сохранения модели и удаляет только новую папку при ошибке. + +У модпака Fabric-конфигурация содержит Loader без API; прежние JSON с API остаются читаемыми. Windows-очередь +читает план при повторе/перезапуске и сверяет закреплённые Minecraft и Loader. После `completed` исходный состав +больше не применяется. Переименование, дублирование, удаление и `.hakobackup` переносят план вместе с папкой; +архив и файлы лежат в проверяемом изолированном кэше `HakoPaths.CacheRoot/Modpacks` и при необходимости +скачиваются заново по закреплённым ссылкам. diff --git a/Obsidian/HakoVault/Index.md b/Obsidian/HakoVault/Index.md index b368420..341f936 100644 --- a/Obsidian/HakoVault/Index.md +++ b/Obsidian/HakoVault/Index.md @@ -15,7 +15,12 @@ Hako — нативный лаунчер Minecraft для macOS на SwiftUI и Скриншоты сборки показываются сеткой, перетаскиваются в Finder копией, открываются в Quick Look и удаляются в корзину. Интерфейс на русском и английском; язык выбирается на приветствии и в настройках и меняется без перезапуска. CI публикует независимые стабильные релизы macOS и Windows при повышении версии соответствующей платформы. macOS-клиент проверяет и устанавливает обновления из стабильных macOS-релизов GitHub; переход прежних клиентов `nightly` выполняется вручную. -Windows-клиент (`windows/`, WinUI 3, .NET 8) на первом этапе паритета: приветствие, вход в Microsoft, настройки с языком и профилем, выход; сборок и запуска игры пока нет. Разделы о нём есть в [[Architecture#Windows-клиент|Architecture]] и в заметках по доменам. +Windows-клиент (`windows/`, WinUI 3, .NET 8) поддерживает Vanilla/Fabric, запуск, локальные моды и ресурспаки, +миры и датапаки, каталог Modrinth, готовые сборки `.mrpack`, скриншоты, дублирование сборок и резервные копии +`.hakobackup`/`.hakoworld`; игровое время сохраняется по аккаунту/гостю и сборке, продолжает учитываться после закрытия +лаунчера, а гостевые счётчики переносятся при входе. CurseForge, самообновление Windows и восстановление из архивов +ещё не реализованы. +Статус описан в [[UI/Launcher#Windows-клиент]] и заметках по доменам. ## Архитектура @@ -29,13 +34,13 @@ Windows-клиент (`windows/`, WinUI 3, .NET 8) на первом этапе | Заметка | Назначение | |---------|------------| -| [[Data/Persistence]] | SwiftData: `Account` и `GameInstance`, совместимость прежнего store; токены, параметры и независимые папки сборок; Windows: зашифрованный `session.dat` и `settings.json` | -| [[Data/Backups]] | Резервные копии `.hakobackup` и `.hakoworld`: состав архива и схема `data.json` для восстановления | +| [[Data/Persistence]] | SwiftData: `Account` и `GameInstance`, совместимость прежнего store; токены, параметры и независимые папки сборок; Windows: зашифрованный `session.dat`, `settings.json` и общий `.hako-playtime` ledger | +| [[Data/Backups]] | macOS и Windows: резервные копии `.hakobackup` и `.hakoworld`, состав архива и схема `data.json`; восстановление не реализовано | ## Minecraft - [[Minecraft/Installation|Установка Minecraft и Java]] — официальные API, совместимость, файлы и очередь -- [[Minecraft/Launching|Запуск Minecraft]] — аргументы, Java, offline-сессия, процессы и восстановление +- [[Minecraft/Launching|Запуск Minecraft]] — аргументы, Java, offline-сессия, процессы и восстановление; Windows: helper и время после закрытия лаунчера - [[Minecraft/Worlds|Локальные миры]] — сведения о сохранениях, копирование, корзина и управление датапаками - [[Minecraft/Modrinth|Каталог Modrinth]] — поиск, выбор версии, зависимости, проверка загрузок и Loader/Java @@ -52,7 +57,7 @@ Windows-клиент (`windows/`, WinUI 3, .NET 8) на первом этапе | [[UI/ContentView]] | Корень главного окна и настройки окна | | [[UI/SystemIntegration]] | Единые сервисы, быстрый запуск сборок из Dock, Spotlight и Siri | | [[UI/Screens]] | Экраны macOS, фон с сакурой, общий стиль; Windows-экраны WinUI | -| [[UI/Launcher]] | Главная страница: рейл, создание и профили сборок, текстурпаки, скриншоты, каталог Modrinth, настройки и аккаунт | +| [[UI/Launcher]] | Главная страница: рейл, профили и управление сборками, миры, скриншоты, резервные копии, каталог Modrinth, настройки и аккаунт | | [[UI/MinecraftSkin]] | 3D-скин: модель, ходьба, вращение, загрузка и резервный Стив | | [[UI/Localization]] | Русский и английский интерфейс: каталог строк, выбор языка, правила для новых строк; Windows: `.resw` и `HakoStrings` | diff --git a/Obsidian/HakoVault/Minecraft/Installation.md b/Obsidian/HakoVault/Minecraft/Installation.md index 42f7623..8e7094a 100644 --- a/Obsidian/HakoVault/Minecraft/Installation.md +++ b/Obsidian/HakoVault/Minecraft/Installation.md @@ -48,6 +48,114 @@ Intel fallback на Apple Silicon, запуск игры через Rosetta, д под Rosetta определяется через `sysctl.proc_translated` как Apple Silicon ([документация Apple](https://developer.apple.com/documentation/apple-silicon/about-the-rosetta-translation-environment)). URL загрузок должен быть HTTPS; пути, архивы и симлинки не могут выходить за папку сборки. +## Windows: Mojang и установка + +Windows-реализация находится в `windows/Hako.Core/Minecraft/`. `MojangClient.CatalogAsync()` читает +полный каталог версий, а `PrepareAsync(version, host)` проверяет SHA-1 описания, наличие клиента, +компонент Java и все требуемые группы Windows natives. Отсутствие файлов возвращается как +`MojangError.Unsupported`; ошибки HTTP, сети и целостности остаются отдельными ошибками подготовки. +JSON внешнего API описан в `MojangModels.cs` и сериализуется через `MojangJsonContext`. +Это отдельный source-generated контекст типов ответа Mojang, не контекст моделей сохранённых данных Hako. + +`WindowsHostArchitectureProvider` определяет архитектуру ОС через `IsWow64Process2`, поэтому x64-процесс +Hako под ARM64 тоже распознаёт ARM64-хост. На ARM64 сначала выбирается полный набор `windows-arm64` +(Java и все natives); если его нет, выбирается целиком x64-набор при `UserEnabled` из +`GetMachineTypeAttributes(IMAGE_FILE_MACHINE_AMD64)`: API возвращает HRESULT, успех — `hr >= 0`; +если API отсутствует, например в Windows 10, x64-fallback недоступен. Это соответствует решению пользователя +разрешить x64 под эмуляцией на ARM64. Если AMD64 недоступен, установка несовместимой версии отклоняется до загрузки. +На x64 используется только x64, на x86 — только x86. Выбранный `GameArchitecture` входит в +`PreparedInstallation`, повторная подготовка принимает закреплённую архитектуру; установщик не +определяет её заново. `InstallationResult.JavaExecutable` и `GameInstance.JavaExecutable` хранят путь +относительно папки `java/`, с разделителем `/`; для Windows это `bin/javaw.exe`. Такой же относительный +контракт используется в общем `data.json` резервной копии. Поле `GameInstance.Architecture` из реестра +этапа хранения сохраняет выбор между подготовкой, установкой и последующими повторами. Каталог Java +использует Mojang-ключи `windows-arm64`, `windows-x64` и `windows-x86`. + +`MojangCompatibility.Libraries` сохраняет порядок библиотек из манифеста, включая legacy natives. +Суффикс `${arch}` в legacy classifier подменяется на `64`, `32` или `arm64`; `natives-windows` без +суффикса также поддерживается для x86. + +`MinecraftInstaller.InstallAsync` пишет только в корень переданной сборки и использует +`InstanceStorage.ContainedPath` этапа хранения для путей и запрета reparse points. Java собирается из +runtime manifest raw-файлов. Link-записи отклоняются на подготовке и установке понятной ошибкой; +символические ссылки и другие reparse points не создаются. До установки сиротские `.hako-*.tmp` ищутся +только в `java/` и `minecraft/{versions,libraries,assets,natives,resources}` с обходом вложенных каталогов, +а также непосредственно в `minecraft/`; `saves`, `mods`, `screenshots` и `resourcepacks` не обходятся. +Очистка best-effort и не останавливает установку при ошибках доступа, ввода-вывода или гонках. Это безопасно, +поскольку `InstanceStore` не допускает одновременные установки одного корня. +Клиент и полный JSON версии размещаются в `minecraft/versions/{id}/`, библиотеки — в +`minecraft/libraries/`, legacy natives после безопасной проверки ZIP — в `minecraft/natives/`, logging — +в `minecraft/assets/log_configs/`. Современные native JAR остаются в libraries. Asset objects хранятся +в `minecraft/assets/objects/`; только virtual и `map_to_resources` проверяют имена и создают проверяемые +копии, коллизии Windows-путей без учёта регистра отклоняются. Создаются `minecraft/mods` и папка +ресурспаков (`resourcepacks`, либо `texturepacks` для legacy версии). Все legacy native archives сначала +распаковываются во временную staging-папку и публикуются заменой каталога `minecraft/natives` после +завершения; если переименование staging не удалось, прежний каталог восстанавливается. Отмена и ошибки +распаковки удаляют staging без частично опубликованных файлов. + +`Instances/InstallationCoordinator.cs` — app-lifetime FIFO-очередь, одна активная установка, порядок по `CreatedAt`. +`StartAsync` загружает реестр при необходимости: прерванный `installing` возвращается в очередь, а сохранённый +`pauseRequested` восстанавливается как `paused`. Публичные `EnqueueAsync`, `PauseAsync`, `ResumeAsync`, `RetryAsync` +и `CreateAndEnqueueAsync` выполняют синхронные операции реестра на фоне. Последний регистрирует проверенный +`PreparedInstallation` до того, как очередь сможет выбрать созданную запись. Если её успели поставить на паузу до +регистрации подготовки, запись остаётся `paused`, а подготовка отбрасывается; `ResumeAsync` подготавливает версию +повторно с уже закреплённой архитектурой. URL/SHA-1 версии и архитектура сохраняются через `InstanceStore`; повторная +установка использует подготовку без лишнего запроса. После перезапуска, паузы и повтора `MojangClient.PrepareAsync` +получает закреплённую в профиле архитектуру. Для прежних записей без архитектуры координатор выбирает её при подготовке +и сохраняет до запуска загрузок установщика. + +Установка занимает content-lease `InstanceStore` на всё время работы установщика. Состояния `queued` и `installing` +блокируют переименование, обновление параметров, удаление, дублирование и резервное копирование. `PauseAsync` +сначала сохраняет `pauseRequested`, затем отменяет активный token вне lock; если установка успела освободить свой +`CancellationTokenSource`, отмена считается уже завершившейся. Активный профиль остаётся `installing`, пока установщик +не дождётся завершения всех отменённых загрузок и очистки временных файлов, после чего становится `paused`. Ошибка +установщика во время этой паузы также завершается состоянием `paused`. Content-lease освобождается до записи +терминального `ready`/`paused`/`failed`, поэтому обработчик события терминального состояния уже может запустить игру +или управлять файлами. Терминальная запись и очистка активной задачи выполняются в одной lock-секции. Поставленная на +паузу сборка больше не удерживает блокировку и может быть удалена. `ResumeAsync` ставит её в очередь; `RetryAsync` +повторяет `failed` и восстанавливает осиротевший `installing` без активной задачи и без `pauseRequested`. Занятая +сборка пропускается при обходе очереди; `BusyStateChanged` повторно запускает pump. Ошибка установки сохраняет код и +сообщение в профиле и не мешает следующим сборкам. Если запись успешного завершения не удалась, координатор пытается +записать `failed` с кодом этой ошибки; при повторном сбое состояние остаётся `installing` и восстанавливается через +`RetryAsync`. При успехе сохраняются относительный `javaExecutable` (`bin/javaw.exe`), `javaMajorVersion`, legacy-флаг +ресурспаков и `ready`. + +При выходе Windows-приложения `InstallationCoordinator.DisposeAsync()` отменяет и ожидает активную попытку. +Если она завершилась неуспешно или отменой при `PauseRequested == false`, сборка остаётся `installing`, +и следующий `StartAsync` возвращает её в очередь. Успешный результат записывается как `ready`, даже если +Dispose уже отменил токен; пауза по запросу пользователя сохраняется как `paused`. +Координатор отключает повторный запуск очереди до завершения процесса. +`InstanceStore.DisposeAsync()` ожидает всю цепочку фоновой очистки `.delete-*`; тесты освобождают coordinator и store +перед удалением временного корня. + +Прогресс по UUID и `PropertyChanged` доступны для UI через координатор. Репорты одной стадии троттлятся при +изменении доли меньше 0,002 и интервале менее 150 мс; смена стадии и завершение проходят всегда. После запроса +паузы поздние репорты загрузок не заменяют `Pausing`. Уведомления приходят в потоке, который изменил состояние, +в том числе из фонового потока установки; обработчик не должен блокироваться или синхронно ждать UI-поток, +а WinUI должен маршалить обновления через +`DispatcherQueue`. Ключи состояния — `InstallationStateQueued`, `InstallationStateInstalling`, +`InstallationStatePaused`, `InstallationStateReady`, `InstallationStateFailed`; ключи стадий — +`InstallationStagePreparingMetadata`, `InstallationStagePreparingFiles`, `InstallationStageDownloading`, +`InstallationStagePreparingLibraries`, `InstallationStagePreparingAssets`, `InstallationStageComplete` и +`InstallationStagePausing` в русских и английских `Resources.resw`. +`GameInstance.ErrorCode` использует коды `mojang.http`, `mojang.unsupported`, `mojang.invalid`, +`mojang.integrity`, `storage.` и `installation.cancelled`/`installation.unexpected`. Их отображение +задаёт `InstallationResourceKeys.ErrorResourceKey`; все ключи имеют русские и английские строки. Ошибки открытия +HTTP-потока установщика классифицируются как `mojang.http`, а несовпадение хеша или размера — как `mojang.integrity`. + +`TryBeginLaunch` и `TryBeginContent` не бросают исключение, если реестр ещё не загружен или последняя загрузка +завершилась ошибкой: они возвращают `false` с `InstanceRegistryNotLoaded` или `InstanceRegistryUnavailable`. +После ошибки загрузки снимок `Instances` очищается, а `LoadError` сохраняет причину, чтобы потребители не использовали +устаревшие записи. + +HTTP-запросы используют `User-Agent: Hako/` и только HTTPS. Бинарные файлы передаются потоково +во временный файл рядом с целью, проверяются по размеру и SHA-1 и публикуются атомарно; тело ответа +считается зависшим после 60 секунд без данных, а поток обрывается при превышении известного `Size`. +Ошибка одной загрузки сразу отменяет остальные; после их завершения возвращается исходная не-отмена ошибка. +Одновременно выполняется не более четырёх загрузок. Проверенные файлы используются повторно, +повреждённые загружаются заново, а отмена удаляет временные файлы. MSTest используют Mojang JSON-фикстуры из +`macos/Hako/HakoTests/Fixtures/` и подменный `HttpMessageHandler`, без сетевых запросов. + ## Каталог Fabric и Fabric API `Minecraft/FabricClient.swift` читает Fabric Meta и только официальный Fabric API (`P7dR8mSH`) из Modrinth. @@ -70,12 +178,30 @@ Fabric profile/json проверяется по `inheritsFrom`, сохраняе `minecraft/.hako-mods.json` хранит происхождение внешних модов и `apiProvisioned`; повторная установка не возвращает API после пользовательского удаления или отключения. Готовность включает все компоненты. +## Windows: Fabric + +`windows/Hako.Core/Minecraft/FabricClient.cs` запрашивает Fabric Meta и использует общий +`windows/Hako.Core/Minecraft/ModrinthClient.cs` для версий официального проекта Modrinth `P7dR8mSH` и проверяемых +загрузок; этап каталога расширяет `ModrinthClient`. Запросы используют User-Agent Hako и только HTTPS. +Кэш API — `HakoPaths.CacheRoot/FabricAPI` (в production `%LOCALAPPDATA%\Hako\Cache\FabricAPI`). Загрузка +публикуется после проверки размера, SHA-1 и SHA-512. `fabric.mod.json` читается из JAR через `ZipArchive` с лимитом +1 МиБ; требования Loader и Java фильтруют список Meta, а первым выбирается стабильный Loader. + +`InstanceCreationPage` до сохранения проверяет Minecraft/Java, API и Fabric-профиль выбранного Loader; выбор +Vanilla/Fabric и Loader является частью ключа проверки, а смена выбора отменяет устаревшие запросы. Очередь +передаёт сохранённый `FabricConfiguration` в `MinecraftInstaller.InstallFabricAsync`: профиль проверяется по +`inheritsFrom` и ID Loader, исходный JSON пишется в `minecraft/.hako-fabric.json`, SHA-1 закрепляется в сборке. +Maven-библиотеки профиля получают SHA-1 из JSON или `.jar.sha1` и загружаются общим проверяющим установщиком. +Fabric API проверяется по требованиям JAR и публикуется в `minecraft/mods`; установка завершится ошибкой, +если не готовы Minecraft, Java, Loader или API. + ## Установка готового модпака -`InstallationCoordinator.modpacks` — общий `ModpackInstaller`. Перед установкой он сверяет сохранённый -план с Minecraft и Fabric-конфигурацией профиля; после `MinecraftInstaller.install` импортирует содержимое -модпака. Состояние ready выставляется только после обоих этапов. Пауза, ошибки и восстановление очереди -используют прежние состояния и блокировки. Сборки без плана проходят прежний путь. +`InstallationCoordinator` вызывает `ModpackInstaller` после Java, Minecraft и Fabric Loader. Перед +повтором/восстановлением он сверяет сохранённый план с Minecraft и Fabric-конфигурацией профиля; состояние +ready выставляется только после импорта содержимого. Пауза, ошибки, повтор и восстановление используют прежнюю +очередь и блокировки. Сборки без плана проходят прежний путь; повреждённый или несовпадающий план не переключает +сборку на Vanilla. `FabricConfiguration.api` необязателен: у модпака он отсутствует, Loader берётся точно из манифеста, а Fabric API ставится только как файл самого пакета. Прежние конфигурации с API продолжают читаться; diff --git a/Obsidian/HakoVault/Minecraft/Launching.md b/Obsidian/HakoVault/Minecraft/Launching.md index da54e87..468fc41 100644 --- a/Obsidian/HakoVault/Minecraft/Launching.md +++ b/Obsidian/HakoVault/Minecraft/Launching.md @@ -136,3 +136,78 @@ Swift Testing проверяет официальный JSON для соврем по group/artifact/classifier. Клиентский JAR, assets, Java и natives принадлежат базовому Mojang manifest. `version_name` использует ID дочернего профиля; аргументы JSON не разбиваются по пробелам. Предпросмотр настроек использует тот же профиль и механизм аргументов. + +## Windows-клиент + +Код — `windows/Hako.Core/Minecraft/` и `windows/Hako.Core/Instances/GameLaunchCoordinator.cs`; поведение повторяет macOS, +отличия ниже. Сессия онлайн-запуска — [[Auth/MicrosoftAuth#Обновление сессии для онлайн-запуска]]. + +| Файл | Символ | Роль | +|------|--------|------| +| `Minecraft/MinecraftLaunchPlan.cs` | `MinecraftLaunchPlan.Build`, `ArgumentTemplates`, `MinecraftLaunchIdentity`, `MinecraftLaunchProfile` | Аргументы, classpath, offline-идентичность; `MinecraftLaunchProfile` — точка расширения для Fabric-профиля | +| `Minecraft/LaunchArguments.cs` | `LaunchArguments`, `JavaMemoryPolicy` | Разбор аргументов без shell, единый `-Xmx`, политика памяти | +| `Minecraft/JavaLaunchValidation.cs` | `JavaLaunchValidation` | Проба `-XshowSettings:properties -version` | +| `Minecraft/Win32Process.cs`, `GameProcessRunner.cs`, `JavaAnsiEncoding.cs` | `GameProcessRunner`, `GameProcessRecord`, `JavaAnsiEncoding` | Запуск, `@argfile`, `.hako-running.json`, восстановление | +| `Instances/GameLaunchCoordinator.cs` | `GameLaunchCoordinator` | Порядок запуска, `GameRunState`, события сессий | +| `Playtime/PlaytimeCoordinator.cs`, `PlaytimeClock.cs`, `PlaytimeHelperHost` | `PlaytimeCoordinator`, `WindowsPlaytimeClock`, `PlaytimeHelperHost` | Сессии и их восстановление; процессный помощник после закрытия приложения | + +Для Fabric `GameLaunchCoordinator` читает исходный профиль из `minecraft/.hako-fabric.json`, проверяет SHA-1 из +`GameInstance.FabricProfileSHA1`, родительскую версию и `fabric-loader-{loader}-{minecraft}` ID. Отсутствующий, +неизвестный или повреждённый профиль и отсутствующая Maven-библиотека завершают запуск ошибкой без перехода +на Vanilla. `MinecraftLaunchPlan` использует Fabric `mainClass`, дочерний ID для `version_name` и обязательные +аргументы профиля во всех режимах; Maven-конфликты group/artifact/classifier разрешаются в пользу Fabric. +Профиль и библиотеки читаются локально, сетевые запросы при запуске не выполняются. + +- **Аргументы.** Правила OS/arch/features вычисляются для закреплённой архитектуры сборки (`${arch}` в natives тоже). + Пользовательские game-флаги, которыми управляет лаунчер (`--username`, `--gameDir`, `--width` и др.), отклоняются + (`LaunchReservedGameArgument`). Все `-Xmx`/`-XX:MaxHeapSize` заменяются одним лимитом; начальный heap больше лимита уменьшается. +- **Память.** `JavaMemoryPolicy`: от 512 МиБ до физической памяти, шаг 256 МиБ; для x86-Java предел 1536 МиБ; + `Warns` — больше 70 % физической памяти (`LaunchMemoryWarning`). +- **Java.** Глобальный `JavaPath` используется только при источнике аргументов `Global`; иначе `java/` + сборки (`bin/javaw.exe`). Отсутствующий файл → `LaunchJavaMissing`, скрытой подмены нет. Проба: таймаут 10 с, + major и `os.arch` против закреплённой архитектуры, вывод во временный файл (чтение ≤ 64 КиБ), kill только своей пробы, + среда без `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS` (их нет и в среде игры). +- **Проверки перед запуском.** SHA-1 metadata (`MetadataSHA1`), asset index, размер и SHA-1 клиента; Minecraft-сессия + проверяется до подготовки и ещё раз перед стартом (`LaunchSessionChanged`). +- **Процесс.** `CreateProcessW` без shell и pipe, `CREATE_NO_WINDOW`, рабочий каталог `minecraft/`; stdout/stderr — + наследуемый handle `minecraft/logs/hako-launch.log`, наследование ограничено им через `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` + (иначе игра, переживающая Hako, удержала бы чужие handle, например pipe параллельной пробы Java). Игра переживает Hako. + Известное ограничение: пока handle лога помечен наследуемым, параллельный `Process.Start` без списка handle + (например, проба Java) может унаследовать его на время жизни своего процесса. +- **Длина командной строки.** Java ≥ 9: аргументы в `minecraft/.hako-launch/.args` по синтаксису Java argfiles + (значения с пробелами, `"`, `\`, `'` или `#` берутся в двойные кавычки); файл удаляется после выхода Java, сироты — + при восстановлении. Java 8 — без argfile. Строка `CreateProcessW` ограничена 32767 (`LaunchCommandLineTooLong`). +- **Кодировка аргументов** (`JavaAnsiEncoding`). `java.exe` — ANSI-программа: командную строку читает через + `GetCommandLineA`, argfile — сырыми байтами в `sun.jnu.encoding`, который на Windows остаётся ANSI и после JEP 400. + Поэтому для любой Java argfile кодируется в `GetACP()` строгим `WideCharToMultiByte(WC_NO_BEST_FIT_CHARS)` + (при ACP 65001 — UTF-8 без BOM), а для Java 8 той же проверке подлежит командная строка. Абсолютный путь + (весь аргумент, значение `-D…=`, `@file`, элементы списка через `;`), непредставимый в ACP, заменяется 8.3-именем + `GetShortPathNameW`; если его нет (8.3 отключены на томе) или остался иной непредставимый символ — + `LaunchArgumentFileEncoding`. Best-fit-замены (`?`, похожие буквы) не допускаются. +- **`.hako-running.json`.** Ключи как у Swift `GameProcessRecord`: `instanceID`, `pid`, `startSeconds`/`startMicroseconds` + (Unix-время создания из `GetProcessTimes`), необязательный `sessionID`. Восстановление сверяет PID и время создания; + запись удаляет только наблюдатель того же запуска. Ожидание выхода — `RegisterWaitForSingleObject`, без занятого потока. +- **`GameLaunchCoordinator`.** Требует `Ready` и lease `InstanceStore.TryBeginLaunch`; `GameRunState` + `Preparing`/`Running`/`Failed` (ненулевой код выхода → `LaunchGameExitCode` с путём лога). `RestoreRunningGamesAsync` + берёт lease до восстановления; повреждённая запись одной сборки даёт `Failed` (`LaunchProcessRecordInvalid`) и не + мешает остальным. `GameSessionStarted`/`GameSessionEnded` (instance, record с `sessionID`, код выхода) связывают запуск + и завершение с учётом времени и срабатывают для восстановленных игр. Сессия и владелец фиксируются до запуска игры; + при восстановлении используется сохранённый `sessionID`, а legacy-запись получает стабильный ID. Ошибки трекера + показываются отдельно и не прерывают уже запущенную игру. +- **Журнал времени.** В `/.hako-playtime/` ledger и журнал сессии разделены от `.hako-running.json`. + `GameProcessRecord.SessionID` связывает процесс игры и сессию. Процессная идентичность — PID плюс точное время + создания; совпадение только PID не засчитывается. Длительность вычисляется `QueryUnbiasedInterruptTime`, поэтому + прыжки системных часов и сон Windows не добавляют игровое время. +- **Помощник времени.** `Hako.exe --playtime-helper ` обрабатывается `Program.Main` до `AppInstance`, + WinUI и блокировки корня. Модульный инициализатор Windows App SDK также пропускается для этого режима; обычный + framework-dependent запуск вызывает `DeploymentManager.Initialize` в том же раннем инициализаторе, self-contained + приложение не запускает deployment initialization. Путь принимается только для UUID-журнала + внутри `.hako-playtime/sessions`; помощник + удерживает `.lock`, проверяет PID/время создания игры, сохраняет контрольную точку примерно каждые 10 секунд и + финальный снимок. Если при старте помощника исходный процесс уже не подтверждается, сохраняется последнее + записанное elapsed без начисления до текущего uptime; финальный интервал измеряется только после наблюдения + подтверждённого процесса. Запуск detached/hidden использует текущий packaged или unpackaged `Hako.exe`; помощник не берёт + root lock и продолжает после закрытия лаунчера. Приложение при запуске и каждые 10 секунд сверяет журнал и ledger, + начисляя только положительную разницу `elapsedSeconds - creditedSeconds`; повторное чтение не дублирует время. + Пока helper держит lock, transfer читает атомарный snapshot и вычисляет границу по unbiased uptime без изменения журнала. + Ошибка запуска помощника показывает локализованную диагностику, а app-lifetime reconcile продолжает работу. diff --git a/Obsidian/HakoVault/Minecraft/Modrinth.md b/Obsidian/HakoVault/Minecraft/Modrinth.md index 5bb37c7..41e79ca 100644 --- a/Obsidian/HakoVault/Minecraft/Modrinth.md +++ b/Obsidian/HakoVault/Minecraft/Modrinth.md @@ -139,6 +139,114 @@ ZIP остаётся архивом. Перед публикацией мир п - Несовместимость проверяется только по объявлениям новых версий; объявления уже установленных модов не читаются. - Несовместимое обновление Fabric API по-прежнему не предлагается (`checkUpdates`). +## Windows-клиент: Core этапов 6b/7b и UI этапов 6c/7c + +`windows/Hako.Core/Minecraft/ModrinthClient.cs` расширяет общий клиент, используемый Fabric: поиск с фасетами, +пагинацией и сортировкой, проекты и версии по ID, проверка совместимых версий по фактическим `game_versions` +и `loaders`, сопоставление SHA-512 и проверяемые потоковые загрузки. Кеш `Modrinth/files.json` размещается +под инжектируемым `HakoPaths.CacheRoot`; хеши файлов переиспользуются по пути, размеру и времени изменения. +Клиенты в одном процессе с одинаковым путём кеша используют общую блокировку и перечитывают последний снимок перед +сохранением; разные корни не блокируют друг друга, межпроцессная синхронизация не применяется. Метаданные ограничены +по размеру, URL загрузки должны быть HTTPS. + +`windows/Hako.Core/Instances/InstanceContent.cs` предоставляет локальные операции для JAR-модов, ресурспаков и +датапаков. `InstanceContentTarget` различает `.Mods`, `.Packs` и `.WorldDatapacks(worldId)`; старые overload-ы по +`ModrinthContentKind` сохраняют контракты модов/ресурспаков и отвергают датапак без папки мира. Список включает +отключённые файлы; отключённые JAR сохраняют имя с `.jar.disabled`, ресурспаки и датапаки переносятся в соседние +`.hako-disabled-resourcepacks`/`.hako-disabled-texturepacks` и `.hako-disabled-datapacks`. +Папки обходятся без перехода по reparse points; ZIP остаются архивами. Для датапака `pack.mcmeta` должен быть +единственным обычным корневым ZIP entry или корневым файлом папки, не больше 1 МиБ, корректным JSON-объектом с +объектом `pack`; число ZIP entries ограничено 100 000, чтение метаданных можно отменить. В мире создаётся только leaf +`datapacks`, если выбранная папка мира и её level metadata всё ещё существуют. +Импорт очищает происхождение, замена сверяет ожидаемый SHA-512 и сохраняет disabled-состояние; при ошибке новый файл +удаляется, а старый и коллизия восстанавливаются независимо. Реестр мира — `.hako-datapacks.json`; отсутствие +`apiProvisioned` совместимо с данными Swift. Повреждённый реестр не перезаписывается. Публичные мутации сериализованы +content-lease сборки; контроллер повторно проверяет целевой мир под тем же lease перед публикацией. + +`windows/Hako.Core/Instances/InstanceContentController.cs` координирует локальные операции, каталог и обновления +без WinUI. Публичные методы чтения, установки и обновления имеют overload-ы с `InstanceContentTarget`; target-методы +доступны для локального импорта, переключения, проверки и обновления датапаков выбранного мира. Удаление отдельных +датапаков не предоставляется. `InstanceContentState` адресуется по `InstanceContentStateKey(instanceId, kind, worldId)`; +для модов и ресурспаков `worldId` равен null. `GetState` возвращает снимок, а `StateChanged` передаёт новый снимок +на потоке, где завершилась операция. WinUI переносит обновление на `DispatcherQueue`. +Состояние содержит локальные файлы, обновления, ошибки с кодом и аргументами, busy/checking, текущий проект и сообщения. +Ошибки чтения публикуются только в ревизию, в которой началось чтение; они не меняют ревизию активной мутации. +Проверка busy и начало чтения (revision + loading/checking) атомарны с началом мутации под общей блокировкой состояния. +Мутация, включая зависимости другого kind, фиксирует revision и busy до первого события; обработчики вызываются вне +блокировки. Чтение занятого раздела не меняет revision, поздние результаты прежнего чтения отбрасываются. +Busy снимается владельцем per-instance operation gate независимо от ревизий чтения. + +При обновлении с подтверждённой коллизией `ReplaceUnderLeaseAsync` получает `confirmedCollision`: старый файл, +коллизия и registry публикуются одной обратимой операцией под существующим lease. До commit registry коллизия +хранится в backup; отмена или ошибка восстанавливает оба исходных файла независимо. После commit коллизия +отправляется в корзину без отмены caller-а; отказ/отмена Shell сохраняет backup, новая версия уже установлена, +но результат операции содержит `ContentOperationFailed`. Аргумент `diagnostic` сохраняет сведения об ошибке и пути +оставшихся backup для диагностики; UI локализует код ошибки, а не использует diagnostic как текст интерфейса. +Состояние после такой ошибки перечитывается и отражает фактические файлы. + +`ContentConfirmationRequest` передаёт коды, аргументы, проекты, иконки и коды пояснений. Для связанных файлов версии +датапака `Files` содержит filename, file type и required/optional; additive `ContentConfirmationSelectionCallback` +возвращает выбор имени файла и не меняет прежний `ContentConfirmationCallback`. Старый callback с ответом `Primary` +выбирает первый файл, `Alternative` продолжает без ресурспака, `Cancel` отменяет установку до загрузки и публикации. +Новый callback тоже может вернуть `Primary` без имени — используется первый файл. UI локализует коды; callback принимает +`CancellationToken`, а отмена снимает ожидание подтверждения. Вызов без callback отменяет операции, которым нужно +подтверждение. WinUI диалог показывает файлы с пометками required/optional, выбирает первый файл по умолчанию и +передаёт выбранный filename только при основном действии; альтернативное действие продолжает без связанного +ресурспака, закрытие окна отменяет ожидание. Каталог поиска остаётся в UI и использует `ModrinthClient.SearchAsync`. + +Связанный ресурспак с уже установленным SHA-512 используется без изменения происхождения: существующий origin +сохраняется целиком, локальный файл остаётся без origin. Отключённый файл включается после подтверждений и успешной +установки датапака. Новый загруженный ресурспак получает origin выбранной версии и её `file_type`. + +Контроллер строит обязательные зависимости breadth-first (до 30 проектов), разрешает зависимость только по `version_id`, +проверяет установленные по происхождению или SHA-512 файлы, предлагает включить отключённые и показывает отсутствующие, +неподдерживаемые и несовместимые элементы. Для цели мира сначала ищется совместимая datapack-версия зависимости, +включая проект Modrinth с `project_type:mod`; такой файл ставится в тот же мир. Ресурспак-зависимости идут в `.Packs`, +а моды — в `.Mods` только у Fabric. Если доступна только версия мода, неподдерживаемая цель или нет совместимой версии, +подтверждение показывает соответствующий note code. Root может выбирать release/beta/alpha; зависимости берут последнюю +совместимую версию независимо от канала root. Все выбранные файлы сначала загружаются в отдельные подпапки +`%TEMP%/Hako-Modrinth-*`, сверяются клиентом по размеру, SHA-1 и SHA-512, а каждый staged ZIP датапака дополнительно +проверяется по `pack.mcmeta`. Перед публикацией проверяются совместимость Fabric Loader/Java для модов, несовместимости +только в сборке и выбранном мире, и коллизии имён. Операция удерживает один `InstanceStore` content-lease; внутренние +методы `InstanceContent.*UnderLeaseAsync` повторный lease не захватывают. После отмены или ошибки снимки затронутых +target-ов перечитываются. + +`CheckUpdatesAsync` отдельно проверяет Fabric API по закреплённому Loader и сохраняет его `FabricApiDescriptor`. +Обновление API использует общий план коллизий: одиночное обновление запрашивает подтверждение замены чужого файла, +отказ и отмена оставляют файлы без изменений; bulk возвращает `ConfirmationRequired` без диалога. Подтверждённая +коллизия передаётся в ту же обратимую замену, с сохранением отключённого состояния и API descriptor. +Другие файлы распознаются по SHA-512; распознанным файлам без происхождения запись добавляется под lease только при +совпадении ожидаемого хеша. Обновление требует отличающийся SHA-512 и дату строго новее установленной; при более старом +release поиск продолжается для beta/alpha. Связанные `required-resource-pack` и `optional-resource-pack` версии датапака +обновляются как ресурспаки с loader `datapack`, по прежнему `file_type` и точному имени либо единственному подходящему файлу. +`UpdateAllAsync` обрабатывает Fabric API первым, затем файлы последовательно, без диалогов; элементам, которым требуется +подтверждение, записывается `ConfirmationRequired`, остальные продолжают обновляться. + +`windows/Hako/App.xaml.cs` создаёт один app-lifetime `ModrinthClient`, `InstanceContent` и +`InstanceContentController`; экземпляр Modrinth общий с `FabricClient`. В UI профиль сборки держит разделы «Моды» +(только Fabric), «Ресурспаки», «Миры» и «Настройки». `InstanceContentView` и `ModrinthCatalogView` используют +`InstanceContentTarget`; kind-overload оставлен совместимым для модов и ресурспаков. Локальные списки и каталог +размещены внутри `InstanceContentView`, поэтому возврат из каталога не пересоздаёт профильный черновик. Picker +принимает JAR для модов, ZIP/папки для ресурспаков и датапаков; drag-and-drop фильтруется теми же правилами, +импорт идёт последовательно через контроллер. Карточка мира настраивает target с конкретным worldId, а UI скрывает +удаление отдельных датапаков. + +`InstanceContentView` подписывается на `StateChanged` только пока раздел виден. Событие может прийти с потока Core; +UI ставит coalesced refresh в `DispatcherQueue`, затем читает последний снимок через `GetState(instanceId, target)` и +не применяет ревизию ниже уже отображённой. При уходе со страницы отменяются только её чтение и поиск, начатая +установка остаётся app-lifetime операцией. Проверка обновлений запускается при открытии раздела и сохраняет список +файлов при сетевой ошибке. + +`ModrinthCatalogView` запрашивает точную версию Minecraft и тип контента, сортирует по четырём режимам и загружает +страницы по 20 проектов. Изменение запроса отменяет прежний поиск и повышает его generation до debounce; поздний +результат не заменяет новый. При ошибке первой страницы показывается повтор, при ошибке пагинации прежняя лента +сохраняется. Установленные проекты сопоставляются контроллером по origin или SHA-512, включая отключённые файлы. +Кнопка добавления выбирает release; совместимые beta/alpha доступны в меню проекта. Подтверждения и ошибки Core +сопоставляются с ru/en строками через `ContentResourceKeys` и сериализуются общим `HakoDialogs` на `XamlRoot`. + +Форматы реестров, source generation и cache изоляция остаются контрактами Core выше. Windows UI этапа 7c выбирает +мир через профиль сборки, показывает его target-контент и возвращает из каталога к той же раскрытой карточке. + ## Решения и основания Решения пользователя: предлагать установку недостающих зависимостей, ставить последнюю совместимую версию, @@ -181,3 +289,21 @@ ZIP остаётся архивом. Перед публикацией мир п при его отсутствии архив и файлы загружаются заново по закреплённым адресам. Распознавание отдельных файлов по хешам использует прежний `versions(of:)`. Импорт локального `.mrpack` и обновление модпака целиком не реализованы. + +### Windows-клиент + +`windows/Hako.Core/Minecraft/ModrinthClient.cs` использует общий HTTP-клиент Fabric и каталога. `SearchModpacksAsync` +строит facets `project_type:modpack`, OR для Fabric/Minecraft, исключение dedicated-server-only, сортировку, +точный Minecraft-фильтр и offset. `GetModpackVersionsAsync` повторно сверяет `project_id`, `game_versions`, +loaders, канал и наличие installable `.mrpack`; каталог/pagination — `Instances/ModpackCatalog.cs`. + +Windows `Minecraft/ModpackArchive.cs` читает central directory напрямую, а файлы распаковывает потоково через +`ZipArchive`. По умолчанию ограничены архив (2 GiB), число записей (50 000), индекс (16 MiB), один файл (2 GiB), +общая распаковка (20 GiB) и central directory (8 MiB). ZIP64, шифрование, ссылки, reparse/device атрибуты, +опасные Windows-пути, коллизии регистра и файлов/каталогов отклоняются до публикации. Разрешённые корни игры, +служебные `.hako*` и пути компонентов запрещены. Common overrides применяются первыми, client overrides заменяют +общие, server overrides игнорируются после проверки путей. Required/optional client-файлы включаются, unsupported +пропускаются; файлы и архив проверяются по размеру, SHA-1 и SHA-512, до четырёх файлов загружаются параллельно. +Кэш SHA-512 находится в `HakoPaths.CacheRoot/Modpacks`. План сохраняется до queue; ready выставляется только после +успешного импорта. `completed` не позволяет повторно применить удалённые пользователем файлы при переустановке. +Локальный импорт `.mrpack` и обновление модпака целиком пока не реализованы. diff --git a/Obsidian/HakoVault/Minecraft/Worlds.md b/Obsidian/HakoVault/Minecraft/Worlds.md index 1ea3234..cb291cb 100644 --- a/Obsidian/HakoVault/Minecraft/Worlds.md +++ b/Obsidian/HakoVault/Minecraft/Worlds.md @@ -16,6 +16,12 @@ Parent: [[Index]] | `macos/Hako/Hako/Views/Launcher/InstanceWorldsView.swift` | `InstanceWorldsView` | Список миров и переход к датапакам | | `macos/Hako/Hako/Instances/InstanceContent.swift` | `importDatapack` | Копирование ZIP в существующий мир без восстановления исчезнувших родителей | | `macos/Hako/HakoTests/InstanceWorldsTests.swift` | `InstanceWorldsTests` | Современные/старые/повреждённые сохранения, размер, Unicode, ссылки и ограничения NBT | +| `windows/Hako.Core/Instances/InstanceWorlds.cs` | `InstanceWorlds`, `WorldFolder`, `DatapacksFolder` | Список, проверка назначения, копия мира и корзина через `InstanceStore` | +| `windows/Hako.Core/Instances/WorldBackup.cs` | `WorldBackup.CreateAsync`, `WorldBackupManifest` | Потоковый архив `.hakoworld` под общей файловой блокировкой — [[Data/Backups]] | +| `windows/Hako.Core/Minecraft/WorldMetadata.cs` | `WorldMetadata.Read`, `WorldMetadata.Renamed` | Ограниченное gzip/NBT чтение и переименование сохранения при копировании | +| `windows/Hako/InstanceWorldsView.xaml(.cs)` | `InstanceWorldsView`, `InstanceWorldRow` | Профильный список миров, локальные датапаки, действия мира и доступность при busy | +| `windows/Hako/WorldResourceKeys.cs` | `WorldResourceKeys` | ru/en ключи причин отказа, ошибок metadata, типов игры и форматированных данных | +| `windows/Hako.Tests/InstanceWorldsTests.cs`, `WorldMetadataTests.cs` | MSTest | Изолированные проверки миров и gzip/NBT для Windows | ## Контракты @@ -64,14 +70,70 @@ Parent: [[Index]] и повторно проверяют существование мира перед публикацией; `level.dat` не меняется. - `.hakoworld` и схема манифеста описаны в [[Data/Backups]]. +### Windows Core + +- `InstanceWorlds.ListAsync(instanceId)` получает папку сборки через `InstanceStore` и выполняет чтение файлов вне UI-потока. + Отсутствующая `minecraft/saves` даёт пустой список без создания папок. При обеих повреждённых версиях `level.dat` + мир остаётся в списке с типизированным `WorldMetadataError`; имя берётся из папки. +- `WorldFolder(worldId, instanceRoot)` проверяет отдельный сегмент Windows-пути, существование папки и обычный + `level.dat` или `level.dat_old`. `DatapacksFolder` дополнительно проверяет путь `datapacks`, но не создаёт его; + исчезнувший мир не восстанавливается. Действия Core перепроверяют путь под content-lease перед публикацией. +- `DuplicateAsync` и `TrashAsync` работают под общим content lease и разрешены только готовой сборке. Копирование + дубликата выполняется вне UI-потока. Копия собирается + во временной папке внутри `saves`, затем публикуется перемещением без перезаписи существующего имени. Суффиксы + папки выбираются с 2 по 999 без учёта регистра. `level.dat` и `level.dat_old` переименовываются только в копии, + если соответствующий gzip/NBT читается; нечитаемые файлы копируются как есть. Исходная папка не изменяется. + Отмена удаляет только staging-папку, созданную этой операцией. Удаление использует общий `RecycleBin`; ошибка и + отказ корзины оставляют мир на месте. Результат копии содержит новый `WorldId` либо типизированную причину отказа. +- Обход размера считает обычные файлы, включая скрытые файлы, измерения и `datapacks`; reparse entries пропускаются, + а ошибка обхода даёт неизвестный размер. `icon.png` читается только до 4 МиБ. Папки `saves`, мира и назначение + datapacks не проходят через ссылки/junction; копирование отклоняет reparse point внутри сохранения и перепроверяет + stage перед публикацией. +- Windows `WorldMetadata` использует .NET `GZipStream` и небольшой big-endian NBT reader без хранения дерева тегов. + Сжатый и распакованный данные ограничены 16 МиБ, глубина — 64 уровня. Reader проверяет CRC/размер gzip, длины, + массивы, списки и типы тегов, а строковые поля понимает как UTF-8 или Java modified UTF-8, включая NUL и суррогатные пары. +- Core этапа 7b добавляет локальные и Modrinth-операции датапаков через `InstanceContentTarget.WorldDatapacks(worldId)`. + Импорт ZIP проверяет ровно один корневой `pack.mcmeta` размером не более 1 МиБ и объект JSON `pack`; ограничены + число записей ZIP и чтение метаданных. ZIP сохраняется как архив. Папки обходятся без перехода по reparse points. + При создании операций добавляется только лист `datapacks` в существующий проверенный мир; `saves` и папка мира + не создаются повторно. Отключённые наборы лежат в `.hako-disabled-datapacks`, происхождение Modrinth — в + `.hako-datapacks.json` мира. Папка импорта копируется в staging внутри `datapacks` на том же томе; каждый каталог + staging создаётся нерекурсивно в существующем родителе, поэтому удаление мира или его предков во время копирования + не восстанавливает их. Отмена и ошибка очищают staging, не меняя исходную папку. + Проверки, замены, зависимости и обновления выполняются под тем же lease, что запуск, + установка, копирование, удаление и архивирование мира. `WorldBackup` создаёт `.hakoworld` под тем же content-lease; + архив и схема описаны в [[Data/Backups]]. + +## Windows-клиент + +`windows/Hako/InstanceWorldsView` входит в профиль сборки для Vanilla и Fabric. Карточка показывает иконку, имя, +версию Minecraft, тип игры, последнюю игру и размер. Ошибка metadata показывается отдельно и не исключает мир из списка; +отсутствующие сведения имеют локализованные подписи. Список поддерживает loading, ошибку с повтором и empty state. +Кнопка `minecraft/saves` открывает только уже существующую проверенную папку. + +Раскрытие карточки настраивает общий `InstanceContentView` на `InstanceContentTarget.WorldDatapacks(worldId)`; +контент и каталог адресуются одновременно UUID сборки и worldId. В UI доступны локальный импорт ZIP/папок, +включение/выключение, источник/версия, ссылка на проект, проверка, обновление одного и обновление всех. Отдельное +удаление датапаков скрыто. Каталог показывает имя мира и Minecraft; возврат сохраняет раскрытую карточку. Активна +только одна карточка, проверки обновлений идут при открытии мира или явном обновлении, а busy-уведомления не +запускают сетевые проверки. + +Дублирование и отправка в Корзину подтверждаются через общую очередь `HakoDialogs`, после чего вызываются методы +Core `InstanceWorlds`; резервная копия вызывает `WorldBackup` и после успеха предлагает показать архив в Проводнике. +Файловые операции и lease остаются в Core. UI блокирует изменения во время установки, запуска, операций файлов и +собственных world actions и показывает локализованную причину. Список перечитывается при входе, возврате окна, +завершении игры и после операции; generation/cancellation отбрасывают устаревшие ответы. Восстановление из +`.hakoworld` не реализовано. + ## Чтение NBT -`WorldMetadata` использует системный `zlib` для gzip и небольшой reader big-endian NBT. +macOS `WorldMetadata` использует системный `zlib`; Windows использует .NET `GZipStream`. Обе реализации имеют +небольшой reader big-endian NBT. Сохраняются только четыре поля; остальные теги, списки и массивы пропускаются с проверкой границ и длины. Читаются UTF-8 и Java modified UTF-8 (суррогатные пары). Вход и распакованный поток ограничены 16 МиБ, вложенность — 64 уровня; отмена проверяется при распаковке и обходе. ## Ограничения -Импорт, восстановление и произвольное переименование миров, удаление отдельных датапаков не реализованы. -Каталог распознаёт установленные ZIP по SHA-512; папки датапаков по хешу не распознаются. +Восстановление и произвольное переименование миров не реализованы. Отдельное удаление датапаков не добавлено; +папки датапаков по хешу не распознаются. Восстановление из резервной копии мира не реализовано. diff --git a/Obsidian/HakoVault/UI/Launcher.md b/Obsidian/HakoVault/UI/Launcher.md index ca3245a..b249eac 100644 --- a/Obsidian/HakoVault/UI/Launcher.md +++ b/Obsidian/HakoVault/UI/Launcher.md @@ -361,3 +361,177 @@ Java проверяется только при подготовке запус заменяет имя JAR и происхождение без оставления старой версии; ошибки откатывают файловую публикацию. Локальные файлы, распознанные по хешу как проекты Modrinth, тоже получают обновления; пометка «Обновляется вручную» скрыта, пока обновление доступно. + +## Windows-клиент + +Windows-клиент создаёт, устанавливает и запускает Vanilla- и Fabric-сборки; локальные моды доступны только для Fabric, +ресурспаки и их каталог Modrinth — для обеих платформ. Профиль также показывает миры и поддерживает локальные и +Modrinth-датапаки выбранного мира. Готовые сборки устанавливаются из каталога Modrinth по закреплённой версии `.mrpack`. +В профиле доступны скриншоты, дублирование готовой сборки и резервные копии сборки и мира. Время игры отображается +для текущего аккаунта (или гостя) и выбранной сборки; гостевое время переносится при входе, включая правильную границу +активных игровых сессий. CurseForge и самообновление ещё не реализованы; архивы пока нельзя восстановить или импортировать. +Навигация и диалоги — [[UI/Screens#Windows-клиент]]. + +### Сервисы и потоки + +`App.OnLaunched` (`windows/Hako/App.xaml.cs`) создаёт app-lifetime `InstanceStore`, `PlaytimeCoordinator`, `InstanceWorlds`, `MojangClient`, +`FabricClient` (с общим `ModrinthClient`), `ModpackInstaller`, `ModpackCatalog`, `InstanceContent`, `InstanceContentController`, +`InstallationCoordinator` и `GameLaunchCoordinator` (с `AppCoordinator.MinecraftSessions` и `GameProcessRunner`), +затем передаёт их страницам через `LauncherServices`. Подтверждения контроллера локализуются в WinUI через +общий `HakoDialogs.ConfirmContentAsync`; диалоги сериализованы на одном `XamlRoot`, а закрытие окна отменяет +ожидающее подтверждение. После `AppCoordinator.InitializeAsync` запускаются +`InstallationCoordinator.StartAsync` и `GameLaunchCoordinator.RestoreRunningGamesAsync`; при ошибке реестра +(`InstanceStore.LoadError`) очередь не стартует, «Сборки» показывают ошибку, «+» и сохранение формы отключены, +вход и настройки работают. + +События Core (`InstancesChanged`, `BusyStateChanged`, `PropertyChanged` очереди и сессий, `RunStateChanged`) +приходят с рабочих потоков и иногда под блокировками Core. Обработчики страниц только ставят одно +обновление в `DispatcherQueue` (`CoalescedRefresh`) и не вызывают Core синхронно. Изменения хранилища +(`UpdateSettings`, `Rename`, `Delete`) и запуск (`InstancePlayLogic.Launch`) выполняются через `Task.Run`. +Доступность локального контента и каталога сравнивается с сохранённой причиной блокировки: launch/content lease +может измениться без нового `GameInstance`. Подготовка, работа и завершение игры обновляют действия и пояснения, +не повторяя чтение файлов или сетевой поиск. При блокировке закрывается открытое меню beta/alpha; скрытый раздел +получает актуальную доступность при открытии. + +### Сборки и рейл + +`BuildsPage` — `ItemsRepeater` с `UniformGridLayout` (минимум 320 DIP). `BuildCardModel` обновляет +только изменившиеся свойства: иконка, имя, «Minecraft · Loader», у неготовой сборки — состояние или стадия +с процентом и полосой прогресса, у ошибки — текст по `ErrorCode`; у `ready` — кнопка «Играть», состояние запуска +и время этой сборки. `PlaytimeCoordinator` предоставляет отдельные итоги аккаунта/гостя и сборки, а карточки +обновляют только изменившиеся свойства через `DispatcherQueue`. Профиль показывает то же время выбранной сборки; +в «Настройки → Профиль» выводятся общий счётчик аккаунта и гостя с подсказкой о переносе. + +Иконки: в `iconSymbol` хранятся имена SF Symbols, как на macOS; `InstanceIconGlyphs` сопоставляет шесть +встроенных имён с глифами Segoe Fluent Icons, пустое и неизвестное имя — коробка. Своя картинка PNG/JPEG +нормализуется `InstanceIconImage` (WinRT `BitmapDecoder`/`BitmapEncoder`, центральный квадрат 256×256 PNG) +и пишется ядром в `icon.png`. Картинка читается в память вне UI-потока, чтобы файл не держался открытым. + +### Создание + +`InstanceCreationChoiceDialog`: «Своя сборка» открывает `InstanceCreationPage`; «Готовая с Modrinth» +открывает каталог модпаков. Форма повторяет macOS: иконка, имя с проверкой `InstanceStore.ValidateName` +(ошибки — локализованные ключи по `InstanceStorageError`), каталог Mojang с поиском и фильтрами +«Релизы / Снапшоты / История / Все» (сначала последний релиз), `MojangClient.PrepareAsync` для выбранной +версии (несовместимость без повтора, сетевая ошибка с кнопкой повтора), «Загрузчик модов» (Vanilla или Fabric) +и раскрываемые параметры `InstanceParametersEditor`. Начальные окно и полноэкранный режим берутся из глобальных +настроек. Сохранение — `InstallationCoordinator.CreateAndEnqueueAsync(draft, prepared)` и переход к «Сборкам». +Отмена, Escape и уход по рейлу подтверждаются диалогом. + +`InstanceParametersEditor`: две взаимоисключающие галочки «глобальные / Mojang» (обе сняты — свои аргументы), +аргументы Java и Minecraft (для глобальных и Mojang показываются итоговые шаблоны +`MinecraftLaunchPlan.ArgumentTemplates`, когда известно описание версии), `JavaMemorySlider` +(шаг 256 МиБ, предупреждение выше 70% памяти, предел 1,5 ГиБ для x86), окно (черновики ширины и высоты, +ошибка не меняет сохранённый размер), offline-mode с проверкой ника `OfflineUsername.IsValid`. + +### Готовые модпаки Modrinth + +«Готовая с Modrinth» открывает `ModpackCatalogPage`: поиск с debounce 300 мс, сортировка, фильтр точной версии +Minecraft из каталога Mojang, список с иконками, страница проекта и дозагрузка страниц. Ошибку поиска и загрузки +версий можно повторить. Выбор проекта загружает совместимые версии; экран создания закрепляет выбранную версию и +показывает Minecraft/Loader из проверенного манифеста только для чтения. Имя, иконка, глобальные параметры и +параметры запуска настраиваются до сохранения; действие отключено, пока асинхронная подготовка `.mrpack` не +проверила архив и точные версии Minecraft/Loader. `ModpackCreationPage` сохраняет план через очередь установки. +Карточка сборки показывает обычные стадии/ошибки установки и кнопку игры после готовности. Каталог и форма +используют generation guards и отмену сетевой подготовки при уходе со страницы. + +### Профиль сборки + +`InstanceProfilePage`: заголовок (иконка, имя, «Minecraft · Loader · Java»), у `ready` — «Играть» и ник offline-mode. +Причины недоступности (`InstancePlayLogic.Evaluate`, порядок как на macOS): операции с файлами, установка +не готова, запуск подготавливается, игра уже работает, неверный ник, нет Minecraft-профиля. Срок токена +не блокирует кнопку: `MinecraftSessionCoordinator` обновляет сессию при запуске; если обновление не удалось, +показывается ошибка сессии и «Повторить подключение». Ошибка запуска показывает ключ +`GameRunState.ErrorResourceKey` (`LaunchGameExitCode` с кодом) и ссылку на `minecraft/logs/hako-launch.log`. + +Неготовая сборка показывает панель установки: «Остановить» (очередь или установка), «Продолжить» (пауза), +«Повторить» (ошибка), прогресс и ошибку. «⋯» (`MenuFlyout`) содержит «Дублировать сборку», «Создать резервную копию» и +«Удалить сборку…». Дублирование и резервная копия доступны только для готовой сборки, все три действия +отключаются по `InstanceStore.ManagementBlockedError`; удаление подтверждается и затем вызывает `InstanceStore.Delete`. +Перед снимком дублирование и архив сохраняют черновик настроек профиля. Для файловой операции показывается +индикатор; копия сборки становится выбранной, а созданный архив можно показать в Проводнике ([[Data/Backups]]). + +Раздел «Настройки»: черновик имени с «Сохранить имя» и подтверждением переименования папки +(`InstanceStore.Rename`); иконка и параметры сохраняются автоматически (`UpdateSettings` с задержкой 500 мс, +по одному сохранению, побеждает последний черновик). Во время установки и операций с файлами настройки +заблокированы; при работающей игре они меняются и применяются при следующем запуске +(`InstanceStore.UpdateSettings` не блокируется `BuildRunning`, переименование и удаление — блокируются). +Несохранённое имя при уходе со страницы или закрытии окна вызывает выбор «Переименовать / Продолжить +без переименования / Отмена». Изменённые, но невалидные параметры (размер окна, ник offline-mode) не сохраняются: +при уходе `HakoDialogs.ConfirmLeaveInvalidDraftAsync` предлагает «Остаться / Уйти»; так же ведёт себя +раздел «Игра» настроек Windows при неверном размере окна. Имя проверяется `InstanceStore.ValidateName` +в фоне через 300 мс после ввода; признаки занятости (`IsLaunchBusy`, `IsContentBusy`, +`ManagementBlockedError`) читаются из снимка без ожидания операций хранилища. Путь папки строится от +`InstanceStore.GameRoot` и сокращается до `%USERPROFILE%\…` только внутри профиля пользователя. + +Профиль сохраняет заголовок, управление запуском и защиту черновиков настроек. Переключатель показывает «Моды» +только у Fabric, «Ресурспаки», «Миры», «Скриншоты» и «Настройки» у любой сборки. `InstanceWorldsView` читает список +через app-lifetime `InstanceWorlds`: карточка показывает иконку, имя, версию Minecraft, тип игры, дату и размер; +ошибка `level.dat` оставляет мир видимым. Список имеет состояния загрузки, ошибки с повтором и пустого результата, +а папка `minecraft/saves` открывается только если уже существует. + +Карточка мира раскрывает общий `InstanceContentView`, настроенный на `InstanceContentTarget.WorldDatapacks(worldId)`. +Локальные ZIP и папки импортируются выбором или drag-and-drop; доступны включение/выключение, источник и версия, +ссылка на Modrinth, проверка и обновление одного или всех датапаков. Отдельное удаление датапака скрыто. Каталог +показывает имя мира, сборку и версию Minecraft; возврат сохраняет раскрытый мир. Одновременно активна одна раскрытая +карточка, поэтому её списки/сетевые проверки не запускаются при рендере всех строк. + +«Дублировать» и «В корзину» используют подтверждения общей очереди `HakoDialogs` и Core-операции `InstanceWorlds`. +Профиль обновляет список при входе, возврате окна, завершении игры и после операции. Отмена и generation guard +отбрасывают устаревшие ответы. Причина блокировки установки, игры или content-lease отображается рядом с действиями; +операции не копируют файлы в UI и не захватывают вложенный lease. Действие резервной копии держит тот же lease, +показывает неопределённый прогресс и после успеха предлагает открыть расположение `.hakoworld` в Проводнике. + +### Скриншоты сборки Windows + +`InstanceScreenshotsView` перечисляет только обычные видимые PNG непосредственно в `minecraft/screenshots`. +Пустая или отсутствующая папка даёт пустой список и не создаётся до «Открыть папку». Миниатюры декодируются +WinRT с ограничением размера 512 px; параллельная декодировка ограничена, кэш ограничен, а уход из раздела +отменяет чтение и загрузки. Состояние каждой миниатюры проверяет владельца строки, чтобы поздний ответ не +попал к другой карточке; повреждённый PNG сохраняет карточку с заглушкой. + +Можно выбрать несколько снимков, открыть файл системным приложением, перетащить его в Проводник как копию +или переместить выбранное в Корзину после подтверждения. Ошибка одного файла не скрывает статусы остальных. +Список обновляется при входе в раздел, после завершения игры, по кнопке и по изменениям профиля. Удаление +скриншотов разрешено во время игры и блокируется установкой или другой файловой операцией. + +### Моды, ресурспаки и каталог Modrinth + +`InstanceContentView` читает списки через `InstanceContentController` и показывает имя файла/папки, иконку, +состояние, источник, версию и ссылку на страницу проекта. Поддержаны включение и выключение, подтверждённое +удаление в Корзину, импорт JAR-модов и ZIP/папок ресурспаков через picker или drag-and-drop, открытие папки, +проверка обновлений, обновление одного элемента и массовое обновление с результатом по файлам. Список загружается +и проверяется при открытии раздела; ошибка Modrinth оставляет текущие локальные файлы и отдельно показывает ошибку. +Во время установки сборки и файловых операций заблокированные действия имеют пояснение. + +`InstanceContentView` и `ModrinthCatalogView` принимают `InstanceContentTarget`; совместимые overload-ы по kind +остаются для модов и ресурспаков. Состояния и уведомления фильтруются по UUID сборки, kind и `worldId`, а ревизия +не может откатить более свежий снимок. Проверка обновлений запускается при открытии раздела/мира и по явному действию, +а не при каждом событии занятости. + +`ModrinthCatalogView` ищет только для точной версии Minecraft и типа выбранного содержимого; для модов используется +Fabric-фасет. Поиск задерживается на 300 мс, результат старого запроса не заменяет новый; поддержаны релевантность, +загрузки, дата публикации/обновления и страницы по 20 проектов с повтором после ошибки. Установленные проекты +определяются по origin и SHA-512, включая отключённые файлы. «Добавить» выбирает release; beta и alpha доступны +явно в меню версий. Наблюдаемый busy-индикатор привязан к текущему проекту и выбранной сборке/типу. + +Коды подтверждений, пояснений и ошибок Core сопоставлены с ключами в `ContentResourceKeys`; все сообщения и кнопки +есть в ru/en. Для связанных ресурсов `HakoDialogs.ConfirmContentAsync` показывает файлы из `ContentConfirmationRequest.Files` +с пометками «обязательный»/«необязательный», выбирает первый по умолчанию и возвращает выбранный filename при основном +действии; альтернативное действие продолжает без связанного ресурспака, закрытие отменяет операцию. Если +`UpdateAllAsync` возвращает `ConfirmationRequired`, UI объясняет, что такой элемент нужно обновить отдельно. + +### Безвозвратное удаление + +`HakoDialogs.ConfirmPermanentDeleteAsync` — общий шаг для будущих операций корзины: элементы, которые +`RecycleBin` вернул как `NotRecyclable`, удаляются `RecycleBin.PermanentDelete` только после подтверждения. + +### Предпросмотр + +`--profile-preview builds --preview-page builds|create|profile|content|resourcepacks|catalog|worlds|datapacks|datapack-catalog|modpacks|modpack-create|settings-game` +создаёт во временном корне сборки во всех состояниях (`PreviewBuildsFixture`), два мира с `level.dat`, локальные +включённые/отключённые датапаки с origin и папочный датапак, а также fixture-каталог с зависимостью и выбором +связанного ресурспака. Страницы `worlds`, `datapacks` и `datapack-catalog` открывают профиль нужной сборки. +Существующие previews модов/ресурспаков/каталога также остаются доступны. `modpacks` открывает fixture-каталог, +а `modpack-create` автоматически выбирает fixture-модпак и форму создания. HTTP-ответы подменены, +кэш и содержимое остаются во временном корне; внешняя сеть и реальный `%USERPROFILE%\.hako` не используются. diff --git a/Obsidian/HakoVault/UI/Localization.md b/Obsidian/HakoVault/UI/Localization.md index 3a4cb8d..4d1f1dd 100644 --- a/Obsidian/HakoVault/UI/Localization.md +++ b/Obsidian/HakoVault/UI/Localization.md @@ -94,7 +94,14 @@ Parent: [[Index]] | `windows/Hako/HakoStrings.cs` | `HakoStrings.Get`, `SetLanguage`, `LanguageChanged` | Чтение строк и смена языка | | `windows/Hako.Core/AuthContracts.cs` | `AuthErrorKindExtensions.ResourceKey` | Ключи ошибок входа | | `windows/Hako.Core/AppCoordinator.cs` | `StartupNoticeExtensions.ResourceKey`, `SetLanguageAsync` | Ключи уведомлений запуска и смена языка | -| `windows/Hako.Tests/LocalizationResourcesTests.cs` | — | Совпадение ключей ru/en и покрытие ключей ошибок и уведомлений | +| `windows/Hako.Core/Instances/InstallationCoordinator.cs` | `InstallationResourceKeys` | Ключи состояний и стадий установки, кодов ошибок установки, `ModpackException`, `InstanceStorageError.ResourceKey()`, `MojangError.ResourceKey()` и `ErrorResourceKey(Exception)` для ошибок UI | +| `windows/Hako/ContentResourceKeys.cs` | `ContentResourceKeys` | Ключи подтверждений, пояснений и ошибок `InstanceContentController` | +| `windows/Hako/WorldResourceKeys.cs` | `WorldResourceKeys` | Ключи ошибок миров и метаданных, типов игры, неизвестных значений и аргументированных строк | +| `windows/Hako/BackupResourceKeys.cs` | `BackupResourceKeys` | Ключи типизированных причин ошибки архивирования и форматирование диагностики/пути | +| `windows/Hako.Core/Playtime/PlaytimeFormatter.cs` | `PlaytimeFormatter` | Локализованное форматирование дней, часов и минут для русского и английского языков | +| `windows/Hako/BuildsPage.xaml.cs`, `InstanceProfilePage.xaml.cs`, `SettingsPage.xaml.cs` | `RefreshStrings` | Строки учёта времени и локализованная диагностика helper/storage | +| `windows/Hako/InstanceScreenshotsView.xaml.cs` | `InstanceScreenshotsView.RefreshStrings` | Подписи галереи скриншотов и обновление открытого раздела при смене языка | +| `windows/Hako.Tests/LocalizationResourcesTests.cs` | — | Совпадение ключей ru/en и покрытие ошибок, уведомлений, контента, миров, скриншотов, архивов и строк учёта времени | ### Публичные контракты @@ -113,8 +120,12 @@ Parent: [[Index]] 1. `AppCoordinator.SetLanguageAsync` меняет `Language` и сохраняет `settings.json`; сохранения сериализованы и пишут текущий язык, поэтому побеждает последний выбор. 2. `MainWindow.Coordinator_PropertyChanged` при изменении `Language` вызывает `HakoStrings.SetLanguage`. 3. `HakoStrings` поднимает `LanguageChanged`. Страницы, которые сейчас открыты (`WelcomePage`, `LoginPage`, - `SettingsPage`, `BuildsPage`), подписываются на событие в `OnNavigatedTo` и перечитывают строки; - в `OnNavigatedFrom` подписка снимается. + `SettingsPage`, `BuildsPage`, `InstanceCreationPage`, `ModpackCatalogPage`, `ModpackCreationPage`, `InstanceProfilePage`) подписываются на событие в `OnNavigatedTo` и перечитывают строки; + в `OnNavigatedFrom` подписка снимается. `InstanceContentView` и `ModrinthCatalogView` подписываются при `Loaded` + и снимаются при `Unloaded`, поэтому видимые списки, действия и ожидающие диалоги обновляют текст без перезапуска. + `InstanceWorldsView` обновляет подписи карточек и состояния мира; `WorldResourceKeys` локализует каждое значение + `InstanceWorldFailureReason`, `WorldMetadataError` и известный тип игры. `InstanceScreenshotsView` также обновляет + свои действия, карточки и состояния при смене языка. Язык при старте: `AppCoordinator.InitializeAsync` берёт `AppLanguages.Parse(settings?.Language)`. `MainWindow` вызывает `HakoStrings.SetLanguage(..., force: true)` в конструкторе. @@ -126,6 +137,18 @@ Parent: [[Index]] - Ключи ошибок и уведомлений берутся через `ResourceKey()`. `EveryMappedErrorAndStartupNoticeHasRussianAndEnglishText` проверяет, что они есть в обоих файлах. Ключи `CopyCodeFailed` и `OpenVerificationFailed` задаются в `LoginPage` литералами; проверки их наличия в `.resw` нет, `RussianAndEnglishResourcesHaveTheSameNonemptyKeys` сверяет только наборы ru/en. +- Ошибки сборок в UI не показывают `Exception.Message` (тексты Core английские): страницы берут ключ через + `InstallationResourceKeys.ErrorResourceKey(exception)` (хранилище, реестр, Mojang, сеть; иное — `InstallationErrorUnexpected`), + ошибки запуска — через `GameRunState.ErrorResourceKey`. `UiExceptionsMapToSpecificLocalizedErrors` проверяет покрытие. - Тексты страниц задаются в коде через `HakoStrings.Get` в методе `RefreshStrings()`; `x:Uid` не используется. +- `ContentResourceKeys` исчерпывающе сопоставляет enum-коды подтверждений, пояснений и ошибок контроллера; + `EveryContentConfirmationNoteAndFailureCodeMapsToRussianAndEnglishResources` проверяет обе локали. + `FormatFailure` подставляет аргументы несовместимости Fabric API и числа ошибок; `FormatFailureDetails` сохраняет diagnostic, включая путь оставшейся резервной копии. Эти функции используются и для ответов отдельных действий; форматирование проверяется на обоих ресурсных файлах. +- `WorldResourceKeys` исчерпывающе сопоставляет ошибки мира и метаданных; форматирование версии, даты и размера, + включая неизвестные значения, проверяется `EveryWorldFailureMetadataAndGameTypeMappingHasRussianAndEnglishText` + и `WorldMessagesAndUnknownDisplayValuesFormatArguments` в обеих локалях. +- `BackupResourceKeys` сопоставляет каждый `BackupFailureReason` с ru/en строкой, а форматирование сохраняет + diagnostic и путь оставшейся копии. Ресурсы действий профиля, миров и галереи проверяются тем же тестом локализации. - Названия языков («Русский», «English») и заголовок окна `Title="Hako"` заданы литералами в XAML. Подписи пунктов - навигации присваиваются из `BuildsTitle` и `SettingsTitle` в `MainWindow.UpdateNavigationLabels`. + навигации присваиваются из `BuildsTitle`, `SettingsTitle` и `CreateBuildButton` в `MainWindow.UpdateNavigationLabels`; + пункты сборок показывают имя сборки без перевода. diff --git a/Obsidian/HakoVault/UI/Screens.md b/Obsidian/HakoVault/UI/Screens.md index de93071..5141407 100644 --- a/Obsidian/HakoVault/UI/Screens.md +++ b/Obsidian/HakoVault/UI/Screens.md @@ -88,8 +88,19 @@ Microsoft. При успехе токены сохраняются в `TokenKeyc | `windows/Hako/WelcomePage.xaml`, `windows/Hako/WelcomePage.xaml.cs` | `WelcomePage` | Приветствие, выбор языка, вход и гостевой вход | | `windows/Hako/LoginPage.xaml`, `windows/Hako/LoginPage.xaml.cs` | `LoginPage` | Фазы входа в Microsoft | | `windows/Hako/LoadingPage.xaml`, `windows/Hako/LoadingPage.xaml.cs` | `LoadingPage` | Индикатор до завершения `InitializeAsync` | -| `windows/Hako/BuildsPage.xaml`, `windows/Hako/BuildsPage.xaml.cs` | `BuildsPage` | Раздел «Сборки» (пустое состояние) | -| `windows/Hako/SettingsPage.xaml`, `windows/Hako/SettingsPage.xaml.cs` | `SettingsPage` | Разделы «Основные» и «Профиль», выход | +| `windows/Hako/BuildsPage.xaml`, `windows/Hako/BuildsPage.xaml.cs` | `BuildsPage`, `BuildCardModel` | Раздел «Сборки»: пустое состояние или карточки сборок — [[UI/Launcher#Windows-клиент]] | +| `windows/Hako/InstanceCreationPage.xaml(.cs)`, `InstanceCreationChoiceDialog.cs` | `InstanceCreationPage`, `InstanceCreationChoiceDialog` | Создание своей сборки: выбор Minecraft, Vanilla/Fabric и параметров запуска | +| `windows/Hako/InstanceProfilePage.xaml(.cs)` | `InstanceProfilePage` | Профиль сборки: запуск, установка, разделы контента, скриншоты, настройки, дублирование и резервная копия | +| `windows/Hako/InstanceScreenshotsView.xaml(.cs)`, `ScreenshotThumbnails.cs` | `InstanceScreenshotsView`, `ScreenshotThumbnails` | Сетка скриншотов с ограниченными миниатюрами, выбором, drag-copy и перемещением в Корзину | +| `windows/Hako/InstanceWorldsView.xaml(.cs)` | `InstanceWorldsView`, `InstanceWorldRow` | Миры, метаданные, открытие saves, дублирование, резервная копия, корзина и датапаки | +| `windows/Hako/InstanceContentView.xaml(.cs)` | `InstanceContentView`, `InstanceContentRow` | Локальный контент по `InstanceContentTarget`: моды, ресурспаки или датапаки выбранного мира | +| `windows/Hako/ModrinthCatalogView.xaml(.cs)` | `ModrinthCatalogView`, `ModrinthCatalogRow` | Поиск, сортировка, пагинация и установка контента Modrinth в сборку или выбранный мир | +| `windows/Hako/ContentResourceKeys.cs` | `ContentResourceKeys` | Сопоставление UI-кодов подтверждений и ошибок Core с локализованными ресурсами | +| `windows/Hako/WorldResourceKeys.cs` | `WorldResourceKeys` | Маппинг ошибок мира, типов игры, неизвестных значений и форматирования размера/даты | +| `windows/Hako/BackupResourceKeys.cs` | `BackupResourceKeys` | Локализация типизированных ошибок архивов и диагностических путей | +| `windows/Hako/LauncherServices.cs` | `LauncherServices`, `INavigationGuard`, `CoalescedRefresh`, `HakoDialogs` | App-lifetime сервисы, защита ухода со страницы, маршалинг событий Core, очередь диалогов и отмена подтверждения при закрытии окна | +| `windows/Hako.Core/Playtime/PlaytimeCoordinator.cs` | `PlaytimeCoordinator` | Живые итоги аккаунта/гостя и сборки; UI получает обновления через `DispatcherQueue` | +| `windows/Hako/SettingsPage.xaml`, `windows/Hako/SettingsPage.xaml.cs` | `SettingsPage` | Разделы «Основные», «Игра» и «Профиль», выход | | `windows/Hako/MinecraftSkinView.xaml`, `windows/Hako/MinecraftSkinView.xaml.cs` | `MinecraftSkinView` | Скин, камера, кадры ходьбы, отмена и владение renderer по видимости профиля | | `windows/Hako/MinecraftSkinPreviewService.cs`, `MinecraftSkinImageDecoder.cs` | `MinecraftSkinPreviewService`, `MinecraftSkinImageDecoder` | Mojang skin PNG, проверка контейнера/размера, straight RGBA, нормализованный cache по UUID | | `windows/Hako/Graphics/MinecraftSkinRenderer.cs`, `MinecraftSkinGeometry.cs` | `MinecraftSkinRenderer`, `MinecraftSkinGeometry` | D3D11 Composition surface и независимое построение UV/pose geometry | @@ -101,15 +112,43 @@ Microsoft. При успехе токены сохраняются в `TokenKeyc | `Loading` | `LoadingPage` | Кольцо прогресса на время чтения настроек и аккаунта | | `Welcome` | `WelcomePage` | Заголовок и подзаголовок, `LanguageBox` (Русский / English), кнопка «Начать» (`StartButton`), «Продолжить без аккаунта» (`GuestButton`), `InfoBar` со `StartupNotice`, дисклеймер | | `Login` | `LoginPage` | Панели по `SignInPhase`: получение кода, код `UserCodeText` с кнопками «Открыть страницу входа» (`Launcher.LaunchUriAsync`) и «Копировать код» (`Clipboard`), ожидание, подключение Xbox, ошибка с «Попробовать снова» (`RetryButton`). `LoginNotice` (`InfoBar`) показывает `OpenVerificationFailed` или `CopyCodeFailed`, если открыть страницу или скопировать код не удалось; при сбое копирования кнопка снова «Скопировать код»; копирование перехватывает любое исключение. Уведомление сбрасывается при следующем нажатии этих кнопок и при «Попробовать снова»; закрытое крестиком (`LoginNotice_Closed`) не открывается повторно при обновлении состояния | -| `Builds` | `BuildsPage` | Заголовок и текст пустого состояния; сборок на этом этапе нет | -| `Settings` | `SettingsPage` | Слева `ListView` из двух разделов, справа панель выбранного | +| `Builds` | `BuildsPage` | Пустое состояние с кнопкой «Создать сборку» или сетка карточек; ошибка реестра `instances.json` — `InfoBar` | +| `Instance` | `InstanceProfilePage` | Профиль сборки `AppCoordinator.SelectedInstanceId`: время игры для выбранной сборки, моды только для Fabric, ресурспаки, миры, скриншоты и настройки; меню действий дублирует сборку и создаёт резервную копию | +| `InstanceCreation` | `InstanceCreationPage` | Форма «Своя сборка» | +| `Settings` | `SettingsPage` | Слева `ListView` из трёх разделов (`SettingsSection.General`, `Game`, `Profile`), справа панель выбранного; профиль показывает время аккаунта или гостя и перенос при входе | +| `ModpackCatalog` | `ModpackCatalogPage` | Поиск готовых сборок Modrinth, фильтр Minecraft, версии и ссылка на проект | +| `ModpackCreation` | `ModpackCreationPage` | Форма выбранной закреплённой версии `.mrpack`, асинхронная проверка и постановка в очередь | ### Навигация -- Для `Builds` и `Settings` `MainWindow` показывает `RootNavigation` (`NavigationView`, `PaneDisplayMode=LeftCompact`) - с пунктами «Сборки» (`Tag="Builds"`) и «Настройки» (`Tag="Settings"`); содержимое — `ShellFrame`. - Для остальных экранов видим только `FlowFrame`. При скрытии корневой навигации страница Settings может +- Для `Builds`, `Instance`, `InstanceCreation` и `Settings` `MainWindow` показывает `RootNavigation` + (`NavigationView`, `PaneDisplayMode=LeftCompact`) — аналог рейла macOS: «Сборки», разделитель, пункты сборок + (иконка, имя, подсказка с версией) и «+» (`SelectsOnInvoked=False`, открывает `InstanceCreationChoiceDialog`); + в `FooterMenuItems` — «Настройки» и аккаунт (ник, gamertag или «Гость»), который открывает раздел «Профиль». + Пункты сборок обновляются на месте по `InstanceStore.InstancesChanged`; если выбранная сборка исчезла, + `AppCoordinator.CloseInstance` возвращает к «Сборкам». Содержимое — `ShellFrame`; для остальных экранов видим только `FlowFrame`. + При скрытии корневой навигации страница Settings может остаться текущей страницей `ShellFrame`, поэтому `MainWindow` отдельно передаёт ей активность экрана и окна. +- Кнопка «+» и пустое состояние сборок открывают `InstanceCreationChoiceDialog`: «Своя сборка» ведёт в + `InstanceCreationPage`, «Готовая с Modrinth» — в `ModpackCatalogPage`. Выбор версии открывает `ModpackCreationPage`; + после подготовки она сохраняет сборку через очередь и возвращает к списку карточек. Обе страницы используют + тот же `RootNavigation`, локализацию ru/en и `INavigationGuard` для отмены формы. +- Переход по рейлу и закрытие окна сначала спрашивают текущую страницу через `INavigationGuard.ConfirmLeaveAsync`: + форма создания подтверждает отмену, профиль сборки дописывает настройки и спрашивает о несохранённом имени. + Закрытие перехватывается в `AppWindow.Closing`, затем `App.ShutdownServicesAsync` освобождает + `InstallationCoordinator` (выход не ставит установку на паузу), `InstanceStore` и `MojangClient`. +- Все `ContentDialog`, включая выход и ошибки Settings/Welcome, показываются через очередь `HakoDialogs`: WinUI допускает один открытый диалог на `XamlRoot`. + Ожидание асинхронное; закрытие окна отменяет показанные и ожидающие диалоги. Settings перечитывает строки при показе из очереди и смене языка, сохраняя исходную кнопку по умолчанию. +- `InstanceContentController.StateChanged` приходит на потоке операции. `InstanceContentView` ставит обновление + в `DispatcherQueue`, заново читает актуальный снимок по UUID/kind/worldId и отбрасывает ревизии старше уже применённой. + При уходе со страницы отменяются её чтение и поиск; установка контента продолжается в app-lifetime контроллере. +- `InstanceWorldsView` при входе и возврате окна читает список через Core; generation + cancellation отбрасывают + результаты прежней сборки или загрузки. `GameSessionEnded`, изменения списка сборок и busy-состояния обновляют + список/доступность. Раскрытая карточка одна; её `InstanceContentView` адресует датапаки UUID сборки и worldId. + `DuplicateAsync` и `TrashAsync` вызываются через Core после подтверждения в общей очереди `HakoDialogs`. +- Подтверждения замены, удаления, зависимостей и несовместимости локализует `HakoDialogs.ConfirmContentAsync`. + Для связанного ресурспака показывает варианты из `request.Files` с required/optional notes и возвращает выбранный + filename; закрытие окна отменяет ожидающее подтверждение и завершает callback выбором «Отмена». - Закрытие окна вызывает `CancelSignIn()` и завершает preview service; текущая страница сначала отменяет загрузку и останавливает frame callback, затем освобождает renderer. Деактивация или сворачивание окна, переход на другой корневой экран и выбор «Основные» выключают skin control без сброса камеры. @@ -117,6 +156,10 @@ Microsoft. При успехе токены сохраняются в `TokenKeyc ### Настройки - «Основные»: `LanguageBox` меняет язык сразу. Ошибка сохранения — диалог `LanguageSaveError`. +- «Игра»: путь к Java (`TextBox` и `Microsoft.Windows.Storage.Pickers.FileOpenPicker` для `.exe`), аргументы + Java и Minecraft, `JavaMemorySlider`, полноэкранный режим, ширина и высота. Изменения сохраняются через + `UserSettingsService.SetGameLaunchDefaultsAsync` с задержкой 500 мс и при уходе со страницы; неверный размер + окна остаётся черновиком с ошибкой и не затирает сохранённое значение. Подробности — [[UI/Launcher#Windows-клиент]]. - «Профиль» для Microsoft-аккаунта показывает слева две оформленные карточки: Xbox (аватар `AccountAvatar`, при ошибке загрузки — `FontIcon`, gamertag и email) и Minecraft (пиксельная голова с лицом/шапкой, ник и бейдж `Java Edition`). Если Minecraft не подключён, карточка объясняет это diff --git a/windows/Hako.Core/AppCoordinator.cs b/windows/Hako.Core/AppCoordinator.cs index ab50c7b..d62b604 100644 --- a/windows/Hako.Core/AppCoordinator.cs +++ b/windows/Hako.Core/AppCoordinator.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json; +using Hako.Core.Playtime; namespace Hako.Core; @@ -11,13 +12,18 @@ public enum LauncherScreen Welcome, Login, Builds, + Instance, + InstanceCreation, + ModpackCatalog, + ModpackCreation, Settings } public enum SettingsSection { General, - Profile + Profile, + Game } public enum StartupNotice @@ -38,16 +44,18 @@ public static class StartupNoticeExtensions public sealed class AppCoordinator : INotifyPropertyChanged { - private readonly IUserSettingsStore _settingsStore; + private readonly UserSettingsService _settingsService; private readonly IAccountStore _accountStore; private readonly IMicrosoftAuthService _authService; private readonly object _commitLock = new(); - private readonly SemaphoreSlim _languageSaveGate = new(1, 1); + private readonly SemaphoreSlim _accountTransitionGate = new(1, 1); + private readonly IAccountPlaytimeTransfer? _playtimeTransfer; private LauncherScreen _screen = LauncherScreen.Loading; private AppLanguage _language = AppLanguages.Default; private AppLanguage _savedLanguage = AppLanguages.Default; private bool _isGuest; private SettingsSection _settingsSection = SettingsSection.General; + private Guid? _selectedInstanceId; private LauncherScreen _loginReturnScreen = LauncherScreen.Welcome; private SettingsSection _loginReturnSection = SettingsSection.General; private DeviceCode? _currentDeviceCode; @@ -57,17 +65,40 @@ public sealed class AppCoordinator : INotifyPropertyChanged private AccountSession? _account; private CancellationTokenSource? _activeAttempt; private long _attemptGeneration; + private long _accountGeneration; private long _languageSelection; - public AppCoordinator(IUserSettingsStore settingsStore, IAccountStore accountStore, IMicrosoftAuthService authService) + public AppCoordinator( + IUserSettingsStore settingsStore, + IAccountStore accountStore, + IMicrosoftAuthService authService, + TimeProvider? timeProvider = null, + IAccountPlaytimeTransfer? playtimeTransfer = null) + : this(new UserSettingsService(settingsStore), accountStore, authService, timeProvider, playtimeTransfer) { - _settingsStore = settingsStore; + } + + public AppCoordinator( + UserSettingsService settingsService, + IAccountStore accountStore, + IMicrosoftAuthService authService, + TimeProvider? timeProvider = null, + IAccountPlaytimeTransfer? playtimeTransfer = null) + { + _settingsService = settingsService; _accountStore = accountStore; _authService = authService; + _playtimeTransfer = playtimeTransfer; + MinecraftSessions = new MinecraftSessionCoordinator(this, _accountStore, _authService, timeProvider); } public event PropertyChangedEventHandler? PropertyChanged; + /// Raised on the calling thread when the signed-in account is replaced or invalidated. + public event EventHandler? AccountGenerationChanged; + + public MinecraftSessionCoordinator MinecraftSessions { get; } + public LauncherScreen Screen { get => _screen; @@ -119,7 +150,6 @@ public StartupNotice? CurrentStartupNotice public AccountSession? Account { get => _account; - private set => SetProperty(ref _account, value); } public async Task InitializeAsync(CancellationToken cancellationToken = default) @@ -127,7 +157,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) UserSettings? settings = null; try { - settings = await _settingsStore.LoadAsync(cancellationToken); + settings = await _settingsService.LoadAsync(cancellationToken); } catch (OperationCanceledException) { @@ -143,7 +173,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) try { - Account = await _accountStore.LoadAsync(cancellationToken); + SetAccount(await _accountStore.LoadAsync(cancellationToken), invalidateSessions: true); IsGuest = false; Screen = Account is null ? LauncherScreen.Welcome : LauncherScreen.Builds; } @@ -153,7 +183,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) } catch { - Account = null; + SetAccount(null, invalidateSessions: true); IsGuest = false; CurrentStartupNotice = StartupNotice.AccountUnavailable; Screen = LauncherScreen.Welcome; @@ -166,10 +196,12 @@ public async Task SetLanguageAsync(AppLanguage language, CancellationToken cance Language = language; try { - await SaveLanguageAsync(cancellationToken); + await _settingsService.SetLanguageAsync(language, cancellationToken); + _savedLanguage = AppLanguages.Parse(_settingsService.SavedLanguage); } catch { + _savedLanguage = AppLanguages.Parse(_settingsService.SavedLanguage); if (selection == _languageSelection) { Language = _savedLanguage; @@ -179,22 +211,6 @@ public async Task SetLanguageAsync(AppLanguage language, CancellationToken cance } } - private async Task SaveLanguageAsync(CancellationToken cancellationToken) - { - await _languageSaveGate.WaitAsync(cancellationToken); - try - { - // Saves the current language rather than the caller's: the last save to run always carries the latest choice. - var language = Language; - await _settingsStore.SaveAsync(new UserSettings(language.ToTag()), cancellationToken); - _savedLanguage = language; - } - finally - { - _languageSaveGate.Release(); - } - } - public Task BeginSignIn() { lock (_commitLock) @@ -244,6 +260,49 @@ public void OpenBuilds() Screen = LauncherScreen.Builds; } + /// The build shown by . + public Guid? SelectedInstanceId + { + get => _selectedInstanceId; + private set => SetProperty(ref _selectedInstanceId, value); + } + + public void OpenInstance(Guid id) + { + SelectedInstanceId = id; + Screen = LauncherScreen.Instance; + } + + public void OpenInstanceCreation() + { + Screen = LauncherScreen.InstanceCreation; + } + + public void OpenModpackCatalog() + { + Screen = LauncherScreen.ModpackCatalog; + } + + public void OpenModpackCreation() + { + Screen = LauncherScreen.ModpackCreation; + } + + /// Leaves the profile of a build that no longer exists. + public void CloseInstance(Guid id) + { + if (SelectedInstanceId != id) + { + return; + } + + SelectedInstanceId = null; + if (Screen == LauncherScreen.Instance) + { + Screen = LauncherScreen.Builds; + } + } + public void OpenSettings(SettingsSection section = SettingsSection.General) { SelectedSettingsSection = section; @@ -261,32 +320,41 @@ public async Task LogoutAsync(CancellationToken cancellationToken = default) lock (_commitLock) { InvalidateAttemptLocked(); + InvalidateAccountOperationsLocked(); generation = _attemptGeneration; } - var cleared = await _accountStore.ClearAsync(action => + await _accountTransitionGate.WaitAsync(cancellationToken); + try { - lock (_commitLock) + var cleared = await _accountStore.ClearAsync(action => { - if (generation != _attemptGeneration) + lock (_commitLock) { - return false; + if (generation != _attemptGeneration) + { + return false; + } + + action(); + SetAccount(null, invalidateSessions: false); + IsGuest = false; + CurrentDeviceCode = null; + LoginError = null; + CurrentStartupNotice = null; + Screen = LauncherScreen.Welcome; + return true; } + }, cancellationToken); - action(); - Account = null; - IsGuest = false; - CurrentDeviceCode = null; - LoginError = null; - CurrentStartupNotice = null; - Screen = LauncherScreen.Welcome; - return true; + if (!cleared) + { + throw new OperationCanceledException(cancellationToken); } - }, cancellationToken); - - if (!cleared) + } + finally { - throw new OperationCanceledException(cancellationToken); + _accountTransitionGate.Release(); } } @@ -296,6 +364,7 @@ private Task StartAttempt() long generation; lock (_commitLock) { + InvalidateAccountOperationsLocked(); InvalidateAttemptLocked(); generation = _attemptGeneration; attempt = new CancellationTokenSource(); @@ -330,24 +399,62 @@ private async Task RunAttemptAsync(long generation, CancellationTokenSource atte } var account = await _authService.SignInAsync(token, cancellationToken); - await _accountStore.SaveAsync(account, replaceFile => + Guid? transferID = null; + var accountSaved = false; + try { - lock (_commitLock) + if (_playtimeTransfer is not null && CaptureAccountState().Account is null) { - if (!IsCurrentAttempt(generation, attempt)) + try { transferID = await _playtimeTransfer.PrepareGuestTransferAsync(account.Xuid, cancellationToken); } + catch (Exception) { } + } + + accountSaved = await _accountStore.SaveAsync(account, replaceFile => + { + lock (_commitLock) { - return false; + if (!IsCurrentAttempt(generation, attempt)) + { + return false; + } + + replaceFile(); + SetAccount(account, invalidateSessions: true); + IsGuest = false; + CurrentDeviceCode = null; + LoginError = null; + Screen = LauncherScreen.Builds; + return true; } + }, cancellationToken); - replaceFile(); - Account = account; - IsGuest = false; - CurrentDeviceCode = null; - LoginError = null; - Screen = LauncherScreen.Builds; - return true; + if (transferID is { } committedTransfer && accountSaved) + { + await _accountTransitionGate.WaitAsync(CancellationToken.None); + try + { + if (CaptureAccountState().Account?.Xuid == account.Xuid) + { + try { await _playtimeTransfer!.CompleteGuestTransferAsync(committedTransfer, CancellationToken.None); } + catch (Exception) { } + } + else + { + try { await _playtimeTransfer!.CancelGuestTransferAsync(committedTransfer, CancellationToken.None); } + catch (Exception) { } + } + } + finally { _accountTransitionGate.Release(); } } - }, cancellationToken); + } + finally + { + if (transferID is { } pending && !accountSaved) + { + try { await _playtimeTransfer!.CancelGuestTransferAsync(pending, CancellationToken.None); } + catch (Exception) { } + } + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -419,6 +526,69 @@ private bool IsCurrentAttempt(long generation, CancellationTokenSource attempt) && ReferenceEquals(_activeAttempt, attempt) && !attempt.IsCancellationRequested; + internal (AccountSession? Account, long Generation) CaptureAccountState() + { + lock (_commitLock) + { + return (_account, _accountGeneration); + } + } + + internal bool TryCommitAccountUpdate( + AccountSession expectedAccount, + long generation, + AccountSession updatedAccount, + Action persist) + { + lock (_commitLock) + { + if (generation != _accountGeneration || !ReferenceEquals(_account, expectedAccount)) + { + return false; + } + + persist(); + SetAccount(updatedAccount, invalidateSessions: false); + return true; + } + } + + private void SetAccount(AccountSession? account, bool invalidateSessions) + { + if (ReferenceEquals(_account, account)) + { + return; + } + + _account = account; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Account))); + if (invalidateSessions) + { + InvalidateAccountOperationsLocked(); + } + } + + private void InvalidateAccountOperationsLocked() + { + _accountGeneration++; + var handlers = AccountGenerationChanged; + if (handlers is null) + { + return; + } + + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, EventArgs.Empty); + } + catch (Exception) + { + } + } + } + private void InvalidateAttemptLocked() { _attemptGeneration++; diff --git a/windows/Hako.Core/AuthContracts.cs b/windows/Hako.Core/AuthContracts.cs index 3085ab0..56ce87b 100644 --- a/windows/Hako.Core/AuthContracts.cs +++ b/windows/Hako.Core/AuthContracts.cs @@ -22,6 +22,9 @@ public enum AuthErrorKind XboxAgeVerification, XboxChildAccount, XboxRejected, + RefreshTokenRejected, + MinecraftLicenseMissing, + MinecraftProfileMissing, Persistence } @@ -36,6 +39,23 @@ public interface IMicrosoftAuthService Task RequestDeviceCodeAsync(CancellationToken cancellationToken); Task WaitForTokenAsync(DeviceCode deviceCode, CancellationToken cancellationToken); Task SignInAsync(MicrosoftToken token, CancellationToken cancellationToken); + + Task ConnectMinecraftAsync( + string refreshToken, + Func persistRefreshToken, + CancellationToken cancellationToken); +} + +public sealed record MinecraftConnection(string ProfileId, string ProfileName, string AccessToken, DateTimeOffset ExpiresAt) +{ + // Never allow an access token into debugger or diagnostic output. + private bool PrintMembers(StringBuilder builder) + { + builder.Append("ProfileId = ").Append(ProfileId) + .Append(", ProfileName = ").Append(ProfileName) + .Append(", ExpiresAt = ").Append(ExpiresAt); + return true; + } } public sealed record MicrosoftToken( @@ -65,6 +85,9 @@ public static class AuthErrorKindExtensions AuthErrorKind.XboxAgeVerification => "XboxAgeVerification", AuthErrorKind.XboxChildAccount => "XboxChildAccount", AuthErrorKind.XboxRejected => "XboxRejected", + AuthErrorKind.RefreshTokenRejected => "MinecraftRefreshTokenRejected", + AuthErrorKind.MinecraftLicenseMissing => "MinecraftLicenseMissing", + AuthErrorKind.MinecraftProfileMissing => "MinecraftProfileMissing", AuthErrorKind.Persistence => "AccountSaveFailed", _ => throw new ArgumentOutOfRangeException(nameof(error), error, null) }; diff --git a/windows/Hako.Core/GameLaunchDefaults.cs b/windows/Hako.Core/GameLaunchDefaults.cs new file mode 100644 index 0000000..c1c2c54 --- /dev/null +++ b/windows/Hako.Core/GameLaunchDefaults.cs @@ -0,0 +1,90 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Hako.Core; + +public sealed record GameLaunchDefaults( + string JavaPath, + string JavaArguments, + string MinecraftArguments, + bool Fullscreen, + int WindowWidth, + int WindowHeight, + int MaximumMemoryMiB) +{ + private static readonly Lazy InitialMemory = new(GetInitialMemoryMiB); + + public static GameLaunchDefaults Standard => new("", "", "", false, 1280, 720, InitialMemory.Value); + + public static GameLaunchDefaults From(UserSettings? settings) + { + var standard = Standard; + return new GameLaunchDefaults( + settings?.JavaPath ?? standard.JavaPath, + settings?.JavaArguments ?? standard.JavaArguments, + settings?.MinecraftArguments ?? standard.MinecraftArguments, + settings?.Fullscreen ?? standard.Fullscreen, + settings?.WindowWidth is > 0 ? settings.WindowWidth.Value : standard.WindowWidth, + settings?.WindowHeight is > 0 ? settings.WindowHeight.Value : standard.WindowHeight, + NormalizeMemory(settings?.MaximumMemoryMiB ?? standard.MaximumMemoryMiB)); + } + + /// A window size typed by the user: a positive integer, otherwise null. + public static int? WindowDimension(string? text) => + int.TryParse(text, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value) && value > 0 + ? value + : null; + + public static int NormalizeMemory(int value) + { + if (value <= 0) + { + return GetInitialMemoryMiB(); + } + + var maximum = GetMaximumMemoryMiB(); + return Math.Min(maximum, Math.Max(512, value / 256 * 256)); + } + + private static int GetInitialMemoryMiB() => NormalizeMemory(Math.Min(4096, GetMaximumMemoryMiB() / 4)); + + private static int GetMaximumMemoryMiB() + { + var physicalBytes = GetPhysicalMemoryBytes(); + return Math.Max(512, checked((int)(physicalBytes / (1024 * 1024)) / 256 * 256)); + } + + private static ulong GetPhysicalMemoryBytes() + { + if (!OperatingSystem.IsWindows()) + { + return 16UL * 1024 * 1024 * 1024; + } + + var status = new MemoryStatusEx { Length = (uint)Marshal.SizeOf() }; + if (!GlobalMemoryStatusEx(ref status)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return status.TotalPhysical; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct MemoryStatusEx + { + public uint Length; + public uint MemoryLoad; + public ulong TotalPhysical; + public ulong AvailablePhysical; + public ulong TotalPageFile; + public ulong AvailablePageFile; + public ulong TotalVirtual; + public ulong AvailableVirtual; + public ulong AvailableExtendedVirtual; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx buffer); +} diff --git a/windows/Hako.Core/Hako.Core.csproj b/windows/Hako.Core/Hako.Core.csproj index 4f32ace..8a4b78d 100644 --- a/windows/Hako.Core/Hako.Core.csproj +++ b/windows/Hako.Core/Hako.Core.csproj @@ -3,6 +3,7 @@ net8.0 enable enable + true diff --git a/windows/Hako.Core/HakoPaths.cs b/windows/Hako.Core/HakoPaths.cs new file mode 100644 index 0000000..02fb6da --- /dev/null +++ b/windows/Hako.Core/HakoPaths.cs @@ -0,0 +1,148 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Hako.Core; + +public sealed class HakoPaths +{ + public HakoPaths(string gameRoot, string dataRoot, string cacheRoot, bool isolated = false) + { + GameRoot = Normalize(gameRoot, nameof(gameRoot)); + DataRoot = Normalize(dataRoot, nameof(dataRoot)); + CacheRoot = Normalize(cacheRoot, nameof(cacheRoot)); + if (ContainsPath(GameRoot, DataRoot) || ContainsPath(DataRoot, GameRoot)) + { + throw new ArgumentException("The game and data roots cannot contain one another."); + } + + Isolated = isolated; + } + + public string GameRoot { get; } + public string DataRoot { get; } + public string CacheRoot { get; } + public bool Isolated { get; } + + public static HakoPaths CreateProduction(string dataRoot) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return new HakoPaths( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".hako"), + dataRoot, + Path.Combine(localAppData, "Hako", "Cache")); + } + + public static HakoPaths CreateIsolated(string gameRoot, string dataRoot) => + new(gameRoot, dataRoot, Path.Combine(Normalize(dataRoot, nameof(dataRoot)), "Cache"), isolated: true); + + public static HakoPaths CreatePreview(string temporaryRoot) + { + var root = Normalize(temporaryRoot, nameof(temporaryRoot)); + var gameRoot = Path.Combine(root, "Game"); + var dataRoot = Path.Combine(root, "Data"); + return new HakoPaths(gameRoot, dataRoot, Path.Combine(dataRoot, "Cache"), isolated: true); + } + + public string GetInstanceKey() + { + if (!Isolated) + { + return "Hako.Main"; + } + + var identity = GameRoot.ToUpperInvariant() + "\0" + DataRoot.ToUpperInvariant(); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); + return "Hako.Isolated." + hash; + } + + public static HakoPathOverrides ParseOverrides(string[] args) + { + string? gameRoot = null; + string? dataRoot = null; + + for (var index = 0; index < args.Length; index++) + { + var argument = args[index]; + if (argument is not ("--hako-root" or "--data-root")) + { + if (argument.StartsWith("--hako-root", StringComparison.Ordinal) + || argument.StartsWith("--data-root", StringComparison.Ordinal)) + { + throw new ArgumentException($"Use {argument.Split('=')[0]} followed by an absolute path."); + } + + continue; + } + + if (index + 1 >= args.Length || string.IsNullOrWhiteSpace(args[index + 1])) + { + throw new ArgumentException($"{argument} requires an absolute path."); + } + + var path = Normalize(args[++index], argument); + if (argument == "--hako-root") + { + if (gameRoot is not null) + { + throw new ArgumentException("--hako-root may be specified only once."); + } + + gameRoot = path; + } + else + { + if (dataRoot is not null) + { + throw new ArgumentException("--data-root may be specified only once."); + } + + dataRoot = path; + } + } + + if ((gameRoot is null) != (dataRoot is null)) + { + throw new ArgumentException("Pass both --hako-root and --data-root to use isolated storage."); + } + + if (gameRoot is not null && (ContainsPath(gameRoot, dataRoot!) || ContainsPath(dataRoot!, gameRoot))) + { + throw new ArgumentException("--hako-root and --data-root cannot contain one another."); + } + + return new HakoPathOverrides(gameRoot, dataRoot); + } + + private static string Normalize(string path, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path, parameterName); + if (!Path.IsPathFullyQualified(path)) + { + throw new ArgumentException("An absolute path is required.", parameterName); + } + + var fullPath = Path.GetFullPath(path); + if (File.Exists(fullPath)) + { + throw new ArgumentException("The path points to a file, not a directory.", parameterName); + } + + return Path.TrimEndingDirectorySeparator(fullPath); + } + + private static bool ContainsPath(string parent, string child) + { + if (string.Equals(parent, child, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var prefix = Path.EndsInDirectorySeparator(parent) ? parent : parent + Path.DirectorySeparatorChar; + return child.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } +} + +public sealed record HakoPathOverrides(string? GameRoot, string? DataRoot) +{ + public bool Isolated => GameRoot is not null; +} diff --git a/windows/Hako.Core/HakoRootLock.cs b/windows/Hako.Core/HakoRootLock.cs new file mode 100644 index 0000000..9a2aaa7 --- /dev/null +++ b/windows/Hako.Core/HakoRootLock.cs @@ -0,0 +1,100 @@ +namespace Hako.Core; + +public enum HakoRootLockError +{ + AlreadyInUse +} + +public sealed class HakoRootInUseException : IOException +{ + public HakoRootInUseException(string path, Exception? innerException = null) + : base("Hako is already using this data folder.", innerException) + { + LockPath = path; + Error = HakoRootLockError.AlreadyInUse; + } + + public string LockPath { get; } + public HakoRootLockError Error { get; } +} + +public sealed class HakoRootLock : IDisposable +{ + private readonly IReadOnlyList _streams; + + private HakoRootLock(IReadOnlyList streams) => _streams = streams; + + public static HakoRootLock Acquire(HakoPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + var streams = new List(2); + try + { + foreach (var root in new[] { paths.GameRoot, paths.DataRoot }) + { + Directory.CreateDirectory(root); + var lockPath = Path.Combine(root, ".hako-lock"); + if (TryGetAttributes(lockPath, out var attributes)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new IOException("The Hako lock cannot be a reparse point or symbolic link."); + } + } + + try + { + streams.Add(new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)); + } + catch (IOException exception) when (IsLockViolation(exception)) + { + throw new HakoRootInUseException(lockPath, exception); + } + } + + return new HakoRootLock(streams); + } + catch + { + foreach (var stream in streams) + { + stream.Dispose(); + } + + throw; + } + } + + internal static bool IsLockViolation(IOException exception) + { + var error = exception.HResult & 0xFFFF; + return error is 32 or 33; + } + + private static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) + { + attributes = default; + return false; + } + catch (DirectoryNotFoundException) + { + attributes = default; + return false; + } + } + + public void Dispose() + { + foreach (var stream in _streams) + { + stream.Dispose(); + } + } +} diff --git a/windows/Hako.Core/Instances/BackupJsonConverters.cs b/windows/Hako.Core/Instances/BackupJsonConverters.cs new file mode 100644 index 0000000..20111a9 --- /dev/null +++ b/windows/Hako.Core/Instances/BackupJsonConverters.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hako.Core.Instances; + +/// Matches Swift JSONEncoder.DateEncodingStrategy.iso8601 at whole-second UTC precision. +internal sealed class UtcWholeSecondDateTimeOffsetConverter : JsonConverter +{ + private const string Format = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var text = reader.GetString() ?? throw new JsonException("A backup date cannot be null."); + if (!DateTimeOffset.TryParseExact(text, Format, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var value)) + throw new JsonException("A backup date must be an ISO 8601 UTC timestamp with whole-second precision."); + return value; + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => + writer.WriteStringValue(value.UtcDateTime.ToString(Format, CultureInfo.InvariantCulture)); +} + +internal sealed class UtcWholeSecondNullableDateTimeOffsetConverter : JsonConverter +{ + private readonly UtcWholeSecondDateTimeOffsetConverter _converter = new(); + + public override bool HandleNull => true; + + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null ? null : _converter.Read(ref reader, typeof(DateTimeOffset), options); + + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value is null) writer.WriteNullValue(); + else _converter.Write(writer, value.Value, options); + } +} diff --git a/windows/Hako.Core/Instances/BackupModels.cs b/windows/Hako.Core/Instances/BackupModels.cs new file mode 100644 index 0000000..af8ad6a --- /dev/null +++ b/windows/Hako.Core/Instances/BackupModels.cs @@ -0,0 +1,54 @@ +namespace Hako.Core.Instances; + +public enum BackupFailureReason +{ + BuildUnavailable, + BuildNotReady, + BuildInstalling, + BuildRunning, + BuildFilesBusy, + WorldUnavailable, + ReservedFolderOccupied, + ReparsePointNotAllowed, + DestinationConflict, + SourceChanged, + Cancelled, + OperationFailed +} + +public sealed record BackupFailure(BackupFailureReason Reason, string Diagnostic, string? DiagnosticPath = null); + +public sealed record BackupResult(string? Path, BackupFailure? Failure) +{ + public bool Succeeded => Path is not null && Failure is null; + + internal static BackupResult Success(string path) => new(path, null); + internal static BackupResult Failed(BackupFailureReason reason, string diagnostic, string? diagnosticPath = null) => + new(null, new BackupFailure(reason, diagnostic, diagnosticPath)); +} + +internal static class BackupFailureMapping +{ + internal static BackupFailureReason From(Exception exception) => exception switch + { + OperationCanceledException => BackupFailureReason.Cancelled, + BackupFolderOccupiedException => BackupFailureReason.ReservedFolderOccupied, + InstanceFileTreeReparseException => BackupFailureReason.ReparsePointNotAllowed, + InstanceFileTreeChangedException => BackupFailureReason.SourceChanged, + InstanceStorageException { Error: InstanceStorageError.BuildInstalling } => BackupFailureReason.BuildInstalling, + InstanceStorageException { Error: InstanceStorageError.BuildRunning } => BackupFailureReason.BuildRunning, + InstanceStorageException { Error: InstanceStorageError.BuildFilesBusy } => BackupFailureReason.BuildFilesBusy, + InstanceStorageException { Error: InstanceStorageError.InstanceRegistryUnavailable or InstanceStorageError.InstanceRegistryNotLoaded } => BackupFailureReason.BuildUnavailable, + InstanceStorageException { Error: InstanceStorageError.ReparsePointNotAllowed } => BackupFailureReason.ReparsePointNotAllowed, + InstanceStorageException { Error: InstanceStorageError.DuplicateBuildName or InstanceStorageError.BuildFolderExists } => BackupFailureReason.DestinationConflict, + InstanceWorldException { Error: InstanceWorldFailureReason.ReparsePointNotAllowed } => BackupFailureReason.ReparsePointNotAllowed, + InstanceWorldException => BackupFailureReason.WorldUnavailable, + _ => BackupFailureReason.OperationFailed + }; +} + +internal sealed class BackupFolderOccupiedException(string folder) + : IOException($"The build folder '{folder}' is reserved for backups."); + +internal sealed class InstanceFileTreeChangedException(string message) : IOException(message); +internal sealed class InstanceFileTreeReparseException(string message) : IOException(message); diff --git a/windows/Hako.Core/Instances/GameInstance.cs b/windows/Hako.Core/Instances/GameInstance.cs new file mode 100644 index 0000000..fc974a3 --- /dev/null +++ b/windows/Hako.Core/Instances/GameInstance.cs @@ -0,0 +1,130 @@ +using System.Text.Json.Serialization; + +namespace Hako.Core.Instances; + +[JsonConverter(typeof(LaunchArgumentSourceJsonConverter))] +public enum LaunchArgumentSource +{ + Global, + Mojang, + Custom +} + +[JsonConverter(typeof(InstanceInstallationStateJsonConverter))] +public enum InstanceInstallationState +{ + Queued, + Installing, + Paused, + Ready, + Failed +} + +[JsonConverter(typeof(InstanceModLoaderJsonConverter))] +public enum InstanceModLoader +{ + Vanilla, + Fabric +} + +public sealed record InstanceParameters +{ + [JsonRequired] + public string JavaArguments { get; init; } = ""; + [JsonRequired] + public string MinecraftArguments { get; init; } = ""; + [JsonRequired] + public bool Fullscreen { get; init; } + [JsonRequired] + public int WindowWidth { get; init; } = 1280; + [JsonRequired] + public int WindowHeight { get; init; } = 720; + [JsonRequired] + public int MaximumMemoryMiB { get; init; } = GameLaunchDefaults.Standard.MaximumMemoryMiB; +} + +public sealed record FabricConfiguration([property: JsonRequired] string LoaderVersion, FabricApiDescriptor? Api = null); + +public sealed record FabricApiDescriptor( + [property: JsonRequired] string ProjectID, + [property: JsonRequired] string VersionID, + [property: JsonRequired] string Version, + [property: JsonRequired] string Channel, + [property: JsonRequired] string Filename, + [property: JsonRequired] string Url, + [property: JsonRequired] long Size, + [property: JsonRequired] string Sha1, + [property: JsonRequired] string Sha512); + +public sealed record GameInstance +{ + [JsonRequired] + public Guid Id { get; init; } = Guid.NewGuid(); + [JsonRequired] + public string Name { get; init; } = ""; + [JsonRequired] + public string FolderName { get; init; } = ""; + [JsonRequired] + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + [JsonRequired] + public string VersionID { get; init; } = ""; + [JsonRequired] + public string MetadataURL { get; init; } = ""; + [JsonRequired] + public string MetadataSHA1 { get; init; } = ""; + [JsonRequired] + public string IconSymbol { get; init; } = "shippingbox.fill"; + public Guid IconRevision { get; init; } = Guid.NewGuid(); + [JsonRequired] + public LaunchArgumentSource ArgumentSource { get; init; } = LaunchArgumentSource.Mojang; + [JsonRequired] + public bool OfflineMode { get; init; } + [JsonRequired] + public string OfflineUsername { get; init; } = "Player"; + [JsonRequired] + public InstanceParameters Parameters { get; init; } = new(); + [JsonRequired] + public InstanceInstallationState State { get; init; } = InstanceInstallationState.Queued; + [JsonRequired] + public bool PauseRequested { get; init; } + public string? ErrorMessage { get; init; } + public string? ErrorCode { get; init; } + [JsonRequired] + public string JavaExecutable { get; init; } = "bin/javaw.exe"; + [JsonRequired] + public int JavaMajorVersion { get; init; } + [JsonRequired] + public bool LegacyTexturepacks { get; init; } + [JsonRequired] + public InstanceModLoader ModLoader { get; init; } = InstanceModLoader.Vanilla; + public FabricConfiguration? FabricConfiguration { get; init; } + public string? FabricProfileSHA1 { get; init; } + public string? ModpackVersionID { get; init; } + public string? Architecture { get; init; } +} + +public sealed record InstanceDraft +{ + public string Name { get; init; } = ""; + public string IconSymbol { get; init; } = "shippingbox.fill"; + [JsonIgnore] + public byte[]? IconData { get; init; } + public LaunchArgumentSource ArgumentSource { get; init; } = LaunchArgumentSource.Mojang; + public bool OfflineMode { get; init; } + public string OfflineUsername { get; init; } = "Player"; + public InstanceParameters Parameters { get; init; } = new(); + public InstanceModLoader ModLoader { get; init; } = InstanceModLoader.Vanilla; + public FabricConfiguration? FabricConfiguration { get; init; } + public int JavaMajorVersion { get; init; } + public bool LegacyTexturepacks { get; init; } + public string? Architecture { get; init; } +} + +public static class OfflineUsername +{ + public static bool IsValid(string? value) => value is { Length: >= 3 and <= 16 } + && value.All(character => character is >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or >= '0' and <= '9' + or '_'); +} diff --git a/windows/Hako.Core/Instances/GameLaunchCoordinator.cs b/windows/Hako.Core/Instances/GameLaunchCoordinator.cs new file mode 100644 index 0000000..fd38695 --- /dev/null +++ b/windows/Hako.Core/Instances/GameLaunchCoordinator.cs @@ -0,0 +1,544 @@ +using System.ComponentModel; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text.Json; +using Hako.Core.Minecraft; +using Hako.Core.Playtime; + +namespace Hako.Core.Instances; + +public enum GameRunPhase +{ + Preparing, + Running, + Failed +} + +public sealed record GameRunState( + GameRunPhase Phase, + int? ExitCode = null, + string? ErrorResourceKey = null, + string? LogPath = null); + +public sealed class GameRunStateChangedEventArgs(Guid instanceId) : EventArgs +{ + public Guid InstanceId { get; } = instanceId; +} + +/// Extension point for playtime tracking: a game session started or ended (including restored games). +public sealed class GameSessionEventArgs(Guid instanceId, GameProcessRecord record, int? exitCode = null) : EventArgs +{ + public Guid InstanceId { get; } = instanceId; + public GameProcessRecord Record { get; } = record; + public int? ExitCode { get; } = exitCode; +} + +public sealed record MinecraftLaunchRequest( + GameInstance Instance, + string InstanceRoot, + string Executable, + MinecraftLaunchIdentity Identity, + LaunchArgumentSource ArgumentSource, + InstanceParameters Parameters, + GameArchitecture Architecture); + +/// +/// Coordinates one build launch at a time. State notifications are raised from worker threads as +/// well as the caller thread; UI subscribers must marshal them to their DispatcherQueue. +/// +public sealed class GameLaunchCoordinator : INotifyPropertyChanged +{ + private readonly InstanceStore _store; + private readonly UserSettingsService _settings; + private readonly MinecraftSessionCoordinator _sessions; + private readonly GameProcessRunner _runner; + private readonly JavaLaunchValidation _javaValidation; + private readonly Func> _prepare; + private readonly Func> _start; + private readonly TimeProvider _timeProvider; + private readonly PlaytimeCoordinator? _playtime; + private readonly object _sync = new(); + private readonly Dictionary _states = []; + private readonly Dictionary _launchLeases = []; + + public GameLaunchCoordinator( + InstanceStore store, + UserSettingsService settings, + MinecraftSessionCoordinator sessions, + GameProcessRunner runner, + JavaLaunchValidation? javaValidation = null, + Func>? prepare = null, + TimeProvider? timeProvider = null, + PlaytimeCoordinator? playtime = null) + : this(store, settings, sessions, runner, javaValidation, prepare, null, timeProvider, playtime) + { + } + + internal GameLaunchCoordinator( + InstanceStore store, + UserSettingsService settings, + MinecraftSessionCoordinator sessions, + GameProcessRunner runner, + JavaLaunchValidation? javaValidation, + Func>? prepare, + Func>? start, + TimeProvider? timeProvider, + PlaytimeCoordinator? playtime = null) + { + _store = store; + _settings = settings; + _sessions = sessions; + _runner = runner; + _javaValidation = javaValidation ?? new JavaLaunchValidation(); + _prepare = prepare ?? PrepareAsync; + _start = start ?? ((plan, id, root, sessionId, token) => runner.StartAsync(plan, id, root, sessionId, token)); + _timeProvider = timeProvider ?? TimeProvider.System; + _playtime = playtime; + } + + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? RunStateChanged; + public event EventHandler? GameSessionStarted; + public event EventHandler? GameSessionEnded; + + public IReadOnlyDictionary States + { + get + { + lock (_sync) + { + return new Dictionary(_states); + } + } + } + + public GameRunState? GetState(Guid instanceId) + { + lock (_sync) + { + return _states.GetValueOrDefault(instanceId); + } + } + + public async Task LaunchAsync(Guid instanceId, CancellationToken cancellationToken = default) + { + var instance = _store.List().FirstOrDefault(candidate => candidate.Id == instanceId) + ?? throw new KeyNotFoundException("The build does not exist."); + if (instance.State != InstanceInstallationState.Ready) + { + throw new MinecraftLaunchException(MinecraftLaunchError.BuildNotReady); + } + + if (!_store.TryBeginLaunch(instanceId, out var lease, out _ ) || lease is null) + { + throw new MinecraftLaunchException(MinecraftLaunchError.BuildAlreadyRunning); + } + + var leaseTransferred = false; + PlaytimeTrackingRequest? playtimeTracking = null; + try + { + var root = _store.GetInstanceDirectory(instanceId); + var settingsSnapshot = _settings.Current; + var globalDefaults = GameLaunchDefaults.From(settingsSnapshot); + var parameters = instance.ArgumentSource == LaunchArgumentSource.Global + ? new InstanceParameters + { + JavaArguments = globalDefaults.JavaArguments, + MinecraftArguments = globalDefaults.MinecraftArguments, + Fullscreen = globalDefaults.Fullscreen, + WindowWidth = globalDefaults.WindowWidth, + WindowHeight = globalDefaults.WindowHeight, + MaximumMemoryMiB = globalDefaults.MaximumMemoryMiB + } + : instance.Parameters; + var architecture = ParsePinnedArchitecture(instance.Architecture); + var memoryPolicy = JavaMemoryPolicy.Current; + parameters = parameters with { MaximumMemoryMiB = memoryPolicy.Normalize(parameters.MaximumMemoryMiB, architecture) }; + + var executable = ResolveJavaExecutable(instance, root, settingsSnapshot); + if (!File.Exists(executable)) + { + // The selected Java is never silently replaced by another runtime. + throw new JavaLaunchValidationException(JavaValidationError.MissingExecutable); + } + + SetState(instanceId, new GameRunState(GameRunPhase.Preparing)); + + MinecraftLaunchIdentity identity; + AccountSession? session = null; + string? ownerXuid = _sessions.CurrentAccountXuid; + if (instance.OfflineMode) + { + identity = MinecraftLaunchIdentity.Offline(instance.OfflineUsername); + } + else + { + session = await _sessions.ConnectForLaunchAsync(cancellationToken).ConfigureAwait(false); + identity = OnlineIdentity(session); + ownerXuid = session.Xuid; + } + + var request = new MinecraftLaunchRequest(instance, root, executable, identity, instance.ArgumentSource, parameters, architecture); + var plan = await _prepare(request, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (!instance.OfflineMode) + { + var currentSession = await _sessions.ConnectForLaunchAsync(cancellationToken).ConfigureAwait(false); + if (session is null || !SameMinecraftSession(session, currentSession)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.SessionChanged); + } + } + + playtimeTracking = _playtime is null + ? null + : await _playtime.BeginSessionAsync(instanceId, ownerXuid, cancellationToken).ConfigureAwait(false); + var sessionID = playtimeTracking?.SessionID ?? Guid.NewGuid(); + var process = await _start(plan, instanceId, root, sessionID, cancellationToken).ConfigureAwait(false); + if (playtimeTracking is not null) + await _playtime!.AttachProcessAsync(playtimeTracking, process.Record, CancellationToken.None).ConfigureAwait(false); + lock (_sync) + { + _launchLeases[instanceId] = lease; + leaseTransferred = true; + } + + SetState(instanceId, new GameRunState(GameRunPhase.Running, LogPath: process.LogPath)); + _ = ObserveCompletionAsync(instanceId, process, lease); + } + catch (OperationCanceledException) + { + if (playtimeTracking is not null) await _playtime!.CancelSessionAsync(playtimeTracking.SessionID, CancellationToken.None).ConfigureAwait(false); + ClearState(instanceId); + throw; + } + catch (Exception exception) + { + if (playtimeTracking is not null) await _playtime!.CancelSessionAsync(playtimeTracking.SessionID, CancellationToken.None).ConfigureAwait(false); + var logPath = TryGetLogPath(instanceId); + SetState(instanceId, new GameRunState(GameRunPhase.Failed, ErrorResourceKey: ResourceKey(exception), LogPath: logPath)); + throw; + } + finally + { + if (!leaseTransferred) + { + lease.Dispose(); + } + } + } + + public async Task RestoreRunningGamesAsync(CancellationToken cancellationToken = default) + { + foreach (var instance in _store.List()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_store.TryBeginLaunch(instance.Id, out var lease, out _) || lease is null) + { + continue; + } + + GameProcessLaunch? restored; + string root; + try + { + root = _store.GetInstanceDirectory(instance.Id); + restored = await _runner.RestoreAsync(instance.Id, root, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + lease.Dispose(); + throw; + } + catch (Exception) + { + // One damaged record must not prevent the remaining builds from being restored. + lease.Dispose(); + SetState(instance.Id, new GameRunState(GameRunPhase.Failed, + ErrorResourceKey: "LaunchProcessRecordInvalid", LogPath: TryGetLogPath(instance.Id))); + continue; + } + + if (restored is null) + { + lease.Dispose(); + continue; + } + + if (_playtime is not null) + { + var record = restored.Record; + if (record.SessionID is null) + { + var legacySessionID = PlaytimeCoordinator.LegacySessionID(record); + if (_runner.UpdateSessionID(root, record, legacySessionID, out var updatedRecord)) + { + record = updatedRecord; + restored = restored with { Record = updatedRecord }; + } + } + + if (record.SessionID is not null) + { + record = await _playtime.ResumeSessionAsync(record, _sessions.CurrentAccountXuid, cancellationToken).ConfigureAwait(false); + restored = restored with { Record = record }; + } + } + + lock (_sync) + { + _launchLeases[instance.Id] = lease; + } + + SetState(instance.Id, new GameRunState(GameRunPhase.Running, LogPath: restored.LogPath)); + _ = ObserveCompletionAsync(instance.Id, restored, lease); + } + } + + public string GetLaunchLogPath(Guid instanceId) + { + var root = _store.GetInstanceDirectory(instanceId); + return InstanceStorage.ContainedPath("minecraft/logs/hako-launch.log", root, allowBaseReparsePoint: true); + } + + private async Task ObserveCompletionAsync(Guid instanceId, GameProcessLaunch process, IDisposable lease) + { + Raise(GameSessionStarted, new GameSessionEventArgs(instanceId, process.Record)); + int? exitCode = null; + try + { + exitCode = await process.Completion.ConfigureAwait(false); + if (exitCode is { } code && code != 0) + { + SetState(instanceId, new GameRunState(GameRunPhase.Failed, code, "LaunchGameExitCode", process.LogPath)); + } + else + { + ClearState(instanceId); + } + } + catch + { + SetState(instanceId, new GameRunState(GameRunPhase.Failed, + ErrorResourceKey: "LaunchProcessRecordInvalid", LogPath: process.LogPath)); + } + finally + { + lock (_sync) + { + _launchLeases.Remove(instanceId); + } + + lease.Dispose(); + if (_playtime is not null) await _playtime.ReconcileAsync(CancellationToken.None).ConfigureAwait(false); + Raise(GameSessionEnded, new GameSessionEventArgs(instanceId, process.Record, exitCode)); + } + } + + private void Raise(EventHandler? handlers, GameSessionEventArgs args) + { + if (handlers is null) + { + return; + } + + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try { handler(this, args); } + catch (Exception) { } + } + } + + private async Task PrepareAsync(MinecraftLaunchRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var manifestPath = InstanceStorage.ContainedPath( + $"minecraft/versions/{request.Instance.VersionID}/{request.Instance.VersionID}.json", + request.InstanceRoot, + allowBaseReparsePoint: true); + var manifestData = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false); + if (!string.Equals(Convert.ToHexString(SHA1.HashData(manifestData)), request.Instance.MetadataSHA1, StringComparison.OrdinalIgnoreCase)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.MetadataInvalid); + } + + MojangVersionMetadata manifest; + try + { + manifest = JsonSerializer.Deserialize(manifestData, MojangJsonContext.Default.MojangVersionMetadata) + ?? throw new JsonException(); + } + catch (JsonException exception) + { + throw new MinecraftLaunchException(MinecraftLaunchError.MetadataInvalid, innerException: exception); + } + + if (!string.Equals(manifest.Id, request.Instance.VersionID, StringComparison.Ordinal)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.MetadataInvalid); + } + + MojangAssetIndex? assetIndex = null; + if (manifest.AssetIndex is { } assetReference) + { + var assetIndexPath = InstanceStorage.ContainedPath( + $"minecraft/assets/indexes/{assetReference.Id}.json", request.InstanceRoot, allowBaseReparsePoint: true); + var assetIndexData = await File.ReadAllBytesAsync(assetIndexPath, cancellationToken).ConfigureAwait(false); + try + { + MojangClient.Verify(assetIndexData, assetReference.Download); + assetIndex = JsonSerializer.Deserialize(assetIndexData, MojangJsonContext.Default.MojangAssetIndex) + ?? throw new JsonException(); + } + catch (Exception exception) when (exception is JsonException or MojangException) + { + throw new MinecraftLaunchException(MinecraftLaunchError.AssetIndexInvalid, innerException: exception); + } + } + + if (!manifest.Downloads.TryGetValue("client", out var clientDownload) || clientDownload.Sha1.Length != 40) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ClientMissing); + } + + var clientPath = InstanceStorage.ContainedPath( + $"minecraft/versions/{manifest.Id}/{manifest.Id}.jar", request.InstanceRoot, allowBaseReparsePoint: true); + if (!File.Exists(clientPath)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ClientMissing); + } + + await using (var stream = new FileStream(clientPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + if (clientDownload.Size is { } expectedSize && stream.Length != expectedSize) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ClientMissing); + } + + var hash = await SHA1.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + if (!string.Equals(Convert.ToHexString(hash), clientDownload.Sha1, StringComparison.OrdinalIgnoreCase)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ClientMissing); + } + } + + var major = await _javaValidation.ValidateAsync(request.Executable, manifest.Java.MajorVersion, + request.Architecture, cancellationToken: cancellationToken).ConfigureAwait(false); + MinecraftLaunchProfile? profile = null; + if (request.Instance.ModLoader == InstanceModLoader.Fabric) + { + profile = await LoadFabricProfileAsync(request, cancellationToken).ConfigureAwait(false); + } + + return MinecraftLaunchPlan.Build(manifest, request.InstanceRoot, request.Executable, request.Identity, + request.ArgumentSource, request.Parameters, request.Architecture, assetIndex, + javaMajorVersion: major, profile: profile); + } + + private static Task LoadFabricProfileAsync(MinecraftLaunchRequest request, CancellationToken cancellationToken) => + request.Instance.FabricConfiguration is { } configuration + ? MinecraftLaunchPlan.LoadInstalledFabricProfileAsync(request.InstanceRoot, request.Instance.VersionID, + configuration.LoaderVersion, request.Instance.FabricProfileSHA1, cancellationToken) + : throw new MinecraftLaunchException(MinecraftLaunchError.FabricProfileMissing); + + private static string ResolveJavaExecutable( + GameInstance instance, + string root, + UserSettings settings) + { + if (instance.ArgumentSource == LaunchArgumentSource.Global && !string.IsNullOrWhiteSpace(settings.JavaPath)) + { + var globalPath = Environment.ExpandEnvironmentVariables(settings.JavaPath.Trim()); + return Path.GetFullPath(globalPath); + } + + return InstanceStorage.ContainedPath("java/" + instance.JavaExecutable, root, allowBaseReparsePoint: true); + } + + private static GameArchitecture ParsePinnedArchitecture(string? architecture) + { + if (architecture is not null && Enum.TryParse(architecture, ignoreCase: false, out var parsed) + && Enum.IsDefined(parsed)) + { + return parsed; + } + + throw new MinecraftLaunchException(MinecraftLaunchError.InvalidManifest, "The build has no valid pinned architecture."); + } + + private MinecraftLaunchIdentity OnlineIdentity(AccountSession account) + { + if (string.IsNullOrWhiteSpace(account.MinecraftName) || string.IsNullOrWhiteSpace(account.MinecraftProfileId) + || string.IsNullOrWhiteSpace(account.MinecraftAccessToken) + || account.MinecraftTokenExpiresAt is not { } expiresAt || expiresAt <= _timeProvider.GetUtcNow()) + { + throw new AuthServiceException(AuthErrorKind.MinecraftProfileMissing); + } + + return new MinecraftLaunchIdentity(account.MinecraftName, account.MinecraftProfileId.Replace("-", "", StringComparison.Ordinal), + account.MinecraftAccessToken, account.Xuid, "msa"); + } + + private bool SameMinecraftSession(AccountSession first, AccountSession second) => + first.Xuid == second.Xuid + && first.MinecraftProfileId == second.MinecraftProfileId + && first.MinecraftAccessToken == second.MinecraftAccessToken + && first.MinecraftTokenExpiresAt is { } expiresAt + && expiresAt > _timeProvider.GetUtcNow(); + + private string? TryGetLogPath(Guid instanceId) + { + try { return GetLaunchLogPath(instanceId); } + catch (Exception) { return null; } + } + + private static string ResourceKey(Exception exception) => exception switch + { + AuthServiceException auth => auth.Kind.ResourceKey(), + MinecraftLaunchException launch => launch.ResourceKey, + JavaLaunchValidationException java => java.ResourceKey, + _ => "LaunchProcessStartFailed" + }; + + private void SetState(Guid instanceId, GameRunState state) + { + lock (_sync) + { + _states[instanceId] = state; + } + + NotifyState(instanceId); + } + + private void ClearState(Guid instanceId) + { + bool removed; + lock (_sync) + { + removed = _states.Remove(instanceId); + } + + if (removed) + { + NotifyState(instanceId); + } + } + + private void NotifyState(Guid instanceId) + { + var handlers = RunStateChanged; + if (handlers is not null) + { + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try { handler(this, new GameRunStateChangedEventArgs(instanceId)); } + catch (Exception) { } + } + } + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(States))); + } +} diff --git a/windows/Hako.Core/Instances/InstallationCoordinator.cs b/windows/Hako.Core/Instances/InstallationCoordinator.cs new file mode 100644 index 0000000..71bf099 --- /dev/null +++ b/windows/Hako.Core/Instances/InstallationCoordinator.cs @@ -0,0 +1,817 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using Hako.Core.Minecraft; + +namespace Hako.Core.Instances; + +/// +/// App-lifetime FIFO installer. PropertyChanged is raised on the calling thread, including worker +/// threads; UI subscribers must marshal updates to their DispatcherQueue. Handlers can run while an +/// internal lock is held and must not block or synchronously wait for the UI thread. +/// +public sealed class InstallationCoordinator : INotifyPropertyChanged, IAsyncDisposable +{ + private readonly InstanceStore _store; + private readonly Func> _prepare; + private readonly Func?, CancellationToken, Task> _installFabric; + private readonly Func? _validateModpack; + private readonly Func?, CancellationToken, Task>? _installModpack; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _startGate = new(1, 1); + private readonly object _sync = new(); + private readonly object _disposeSync = new(); + private readonly Dictionary _prepared = []; + private readonly Dictionary _progress = []; + private Guid? _activeInstanceId; + private CancellationTokenSource? _activeCancellation; + private Task? _activeTask; + private string? _queueError; + private long _lastProgressTimestamp; + private bool _hasProgressTimestamp; + private int _pendingCreates; + private bool _started; + private bool _pumping; + private bool _disposing; + private bool _disposed; + private Task? _disposeTask; + + public InstallationCoordinator(InstanceStore store, MojangClient client, MinecraftInstaller installer, + ModpackInstaller? modpackInstaller = null) + : this( + store, + (version, pinned, cancellationToken) => pinned is { } architecture + ? client.PrepareAsync(version, architecture, cancellationToken) + : client.PrepareAsync(version, cancellationToken), + (prepared, root, progress, cancellationToken) => installer.InstallAsync(prepared, root, progress, cancellationToken), + installFabric: (prepared, root, fabric, progress, cancellationToken) => + installer.InstallFabricAsync(prepared, root, fabric, progress, cancellationToken), + timeProvider: null, + validateModpack: modpackInstaller is null ? null : modpackInstaller.Validate, + installModpack: modpackInstaller is null ? null : modpackInstaller.InstallAsync) + { + } + + /// Uses custom preparation and installation steps (tests and the offline UI preview). + public InstallationCoordinator( + InstanceStore store, + Func> prepare, + Func?, CancellationToken, Task> install, + TimeProvider? timeProvider = null, + Func?, CancellationToken, Task>? installFabric = null, + ModpackInstaller? modpackInstaller = null) + : this(store, prepare, install, timeProvider, installFabric, + modpackInstaller is null ? null : modpackInstaller.Validate, + modpackInstaller is null ? null : modpackInstaller.InstallAsync) + { + } + + internal InstallationCoordinator( + InstanceStore store, + Func> prepare, + Func?, CancellationToken, Task> install, + TimeProvider? timeProvider, + Func?, CancellationToken, Task>? installFabric, + Func? validateModpack, + Func?, CancellationToken, Task>? installModpack) + { + _store = store; + _prepare = prepare; + _installFabric = installFabric ?? ((prepared, root, _, progress, cancellationToken) => + install(prepared, root, progress, cancellationToken)); + _validateModpack = validateModpack; + _installModpack = installModpack; + _timeProvider = timeProvider ?? TimeProvider.System; + _store.BusyStateChanged += OnBusyStateChanged; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public ValueTask DisposeAsync() + { + lock (_disposeSync) + { + _disposeTask ??= DisposeCoreAsync(); + return new ValueTask(_disposeTask); + } + } + + private async Task DisposeCoreAsync() + { + await _startGate.WaitAsync().ConfigureAwait(false); + Task? activeTask; + CancellationTokenSource? cancellation; + try + { + lock (_sync) + { + _disposing = true; + _disposed = true; + activeTask = _activeTask; + cancellation = _activeCancellation; + } + } + finally + { + _startGate.Release(); + } + + _store.BusyStateChanged -= OnBusyStateChanged; + try + { + cancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // The active installation may have completed after its task was captured. + } + finally + { + if (activeTask is not null) + { + await activeTask.ConfigureAwait(false); + } + } + } + + public IReadOnlyDictionary Progress + { + get + { + lock (_sync) + { + return new ReadOnlyDictionary(new Dictionary(_progress)); + } + } + } + + public Guid? ActiveInstanceId + { + get { lock (_sync) return _activeInstanceId; } + } + + public string? QueueError + { + get { lock (_sync) return _queueError; } + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + lock (_sync) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(InstallationCoordinator)); + } + + if (_started) + { + return; + } + } + + if (!_store.IsLoaded) + { + await _store.LoadAsync(cancellationToken).ConfigureAwait(false); + } + + if (_store.LoadError is { } loadError) + { + SetQueueError(loadError.Message); + return; + } + + await Task.Run(() => + { + lock (_sync) + { + if (_started) + { + return; + } + + foreach (var instance in _store.Instances) + { + if (instance.PauseRequested && instance.State != InstanceInstallationState.Paused) + { + _store.SetInstallationStatus(instance.Id, InstanceInstallationState.Paused, pauseRequested: true); + } + else if (!instance.PauseRequested && instance.State == InstanceInstallationState.Installing) + { + _store.SetInstallationStatus(instance.Id, InstanceInstallationState.Queued, pauseRequested: false); + } + } + + _started = true; + _queueError = null; + NotifyPropertyChanged(nameof(QueueError)); + PumpLocked(); + } + }).ConfigureAwait(false); + } + finally + { + _startGate.Release(); + } + } + + /// Queues a build created in the queued state. Pass its verified preparation on first install. + public Task EnqueueAsync(Guid id, PreparedInstallation? prepared = null) => Task.Run(() => + { + lock (_sync) + { + EnqueueLocked(id, prepared); + } + }); + + /// Creates and queues a build without allowing the pump to start it before its verified preparation is registered. + public Task CreateAndEnqueueAsync(InstanceDraft draft, PreparedInstallation prepared, + bool legacyTexturepacks = false) => CreateAndEnqueueCoreAsync(draft, prepared, legacyTexturepacks, null); + + public Task CreateAndEnqueueModpackAsync(InstanceDraft draft, PreparedModpack prepared) => + CreateAndEnqueueCoreAsync(draft, prepared.Minecraft, prepared.Minecraft.Manifest.LegacyTexturepacks, prepared.Plan); + + private Task CreateAndEnqueueCoreAsync(InstanceDraft draft, PreparedInstallation prepared, + bool legacyTexturepacks, ModpackPlan? modpackPlan) => Task.Run(() => + { + lock (_sync) + { + EnsureStarted(); + _pendingCreates++; + } + + var pending = true; + try + { + var instance = _store.Create(draft, prepared, legacyTexturepacks, modpackPlan); + lock (_sync) + { + _pendingCreates--; + pending = false; + var current = FindInstance(instance.Id); + if (current.State == InstanceInstallationState.Paused && current.PauseRequested) + { + // Resume re-prepares this build using the architecture already pinned at creation. + _prepared.Remove(instance.Id); + PumpLocked(); + } + else + { + EnqueueLocked(instance.Id, prepared); + } + + return FindInstance(instance.Id); + } + } + catch + { + lock (_sync) + { + if (pending) + { + _pendingCreates--; + } + PumpLocked(); + } + + throw; + } + }); + + public Task PauseAsync(Guid id) => Task.Run(() => + { + CancellationTokenSource? cancellation = null; + lock (_sync) + { + EnsureStarted(); + var instance = FindInstance(id); + if (instance.State is not (InstanceInstallationState.Queued or InstanceInstallationState.Installing)) + { + throw new InvalidOperationException("Only a queued or installing build can be paused."); + } + + var active = _activeTask is not null + && _activeInstanceId == id + && instance.State == InstanceInstallationState.Installing; + _store.SetInstallationStatus( + id, + active ? InstanceInstallationState.Installing : InstanceInstallationState.Paused, + pauseRequested: true); + + if (active) + { + SetProgress(id, new InstallationProgress(InstallationProgressStage.Pausing)); + cancellation = _activeCancellation; + } + else + { + _prepared.Remove(id); + PumpLocked(); + } + } + + try + { + cancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // The installation may have completed after pauseRequested was persisted. + } + }); + + public Task ResumeAsync(Guid id) => Task.Run(() => + { + lock (_sync) + { + EnsureStarted(); + var instance = FindInstance(id); + if (instance.State != InstanceInstallationState.Paused) + { + throw new InvalidOperationException("Only a paused build can be resumed."); + } + + _store.SetInstallationStatus(id, InstanceInstallationState.Queued, pauseRequested: false); + SetQueueError(null); + PumpLocked(); + } + }); + + public Task RetryAsync(Guid id) => Task.Run(() => + { + lock (_sync) + { + EnsureStarted(); + var instance = FindInstance(id); + var orphanedInstallation = instance.State == InstanceInstallationState.Installing + && instance.Id != _activeInstanceId + && !instance.PauseRequested; + if (instance.State != InstanceInstallationState.Failed && !orphanedInstallation) + { + throw new InvalidOperationException("Only a failed or abandoned installation can be retried."); + } + + _store.SetInstallationStatus(id, InstanceInstallationState.Queued, pauseRequested: false); + SetQueueError(null); + PumpLocked(); + } + }); + + private void EnqueueLocked(Guid id, PreparedInstallation? prepared) + { + EnsureStarted(); + var instance = FindInstance(id); + if (_activeInstanceId == id) + { + return; + } + + if (instance.State != InstanceInstallationState.Queued) + { + throw new InvalidOperationException("Only a queued build can be enqueued."); + } + + if (prepared is not null) + { + ValidatePreparedForInstance(instance, prepared); + _store.PinArchitecture(id, prepared.Architecture); + _prepared[id] = prepared; + } + + _store.SetInstallationStatus(id, InstanceInstallationState.Queued, pauseRequested: false); + SetQueueError(null); + PumpLocked(); + } + + private void PumpLocked() + { + if (_disposed || !_started || _activeTask is not null || _pumping || _pendingCreates > 0) + { + return; + } + + _pumping = true; + try + { + foreach (var instance in _store.Instances + .Where(item => item.State == InstanceInstallationState.Queued) + .OrderBy(item => item.CreatedAt) + .ThenBy(item => item.Id)) + { + if (instance.PauseRequested) + { + _store.SetInstallationStatus(instance.Id, InstanceInstallationState.Paused, pauseRequested: true); + continue; + } + + if (!_store.TryBeginInstallation(instance.Id, out var lease, out var reason)) + { + if (reason is InstanceStorageError.InstanceRegistryNotLoaded or InstanceStorageError.InstanceRegistryUnavailable) + { + SetQueueError(reason.Value.ToString()); + return; + } + + continue; + } + + var cancellation = new CancellationTokenSource(); + try + { + _store.SetInstallationStatus(instance.Id, InstanceInstallationState.Installing, pauseRequested: false); + _activeInstanceId = instance.Id; + _activeCancellation = cancellation; + _prepared.Remove(instance.Id, out var prepared); + _activeTask = Task.Run(() => RunInstallationAsync(instance.Id, prepared, lease!, cancellation)); + SetQueueError(null); + NotifyPropertyChanged(nameof(ActiveInstanceId)); + return; + } + catch + { + lease?.Dispose(); + cancellation.Dispose(); + throw; + } + } + } + finally + { + _pumping = false; + } + } + + private async Task RunInstallationAsync( + Guid id, + PreparedInstallation? suppliedPreparation, + IDisposable? lease, + CancellationTokenSource cancellation) + { + InstallationResult? result = null; + Exception? installationFailure = null; + try + { + var instance = FindInstance(id); + var instanceRoot = _store.GetInstanceDirectory(id); + ModpackPlan? modpackPlan; + if (_validateModpack is not null) + { + modpackPlan = _validateModpack(instanceRoot, instance); + } + else + { + var planPath = Path.Combine(instanceRoot, ModpackPlan.Filename); + if (instance.ModpackVersionID is not null || File.Exists(planPath) || Directory.Exists(planPath)) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + modpackPlan = null; + } + + var version = new MojangVersionEntry + { + Id = instance.VersionID, + Type = "release", + Url = instance.MetadataURL, + Sha1 = instance.MetadataSHA1 + }; + var prepared = suppliedPreparation; + if (prepared is null) + { + SetProgress(id, new InstallationProgress(InstallationProgressStage.PreparingMetadata)); + var pinned = ParsePinnedArchitecture(instance.Architecture); + prepared = await _prepare(version, pinned, cancellation.Token).ConfigureAwait(false); + } + + cancellation.Token.ThrowIfCancellationRequested(); + ValidatePreparedForInstance(instance, prepared); + _store.PinArchitecture(id, prepared.Architecture); + + var progress = new InlineProgress(value => SetProgress(id, value)); + result = await _installFabric(prepared, instanceRoot, instance.FabricConfiguration, progress, + cancellation.Token).ConfigureAwait(false); + if (modpackPlan is not null) + { + if (_installModpack is null) throw new ModpackException(ModpackError.InvalidManifest); + await _installModpack(instanceRoot, progress, cancellation.Token).ConfigureAwait(false); + } + cancellation.Token.ThrowIfCancellationRequested(); + } + catch (Exception exception) + { + installationFailure = exception; + } + + lease?.Dispose(); + lease = null; + try + { + lock (_sync) + { + try + { + var instance = _store.Instances.FirstOrDefault(item => item.Id == id); + var userPauseRequested = instance?.PauseRequested == true; + if (_disposing && !userPauseRequested && installationFailure is not null) + { + // Keep Installing so the next coordinator can restore the build to the queue. + } + else if (userPauseRequested || (!_disposing && cancellation.IsCancellationRequested)) + { + _store.SetInstallationStatus(id, InstanceInstallationState.Paused, pauseRequested: true); + } + else if (installationFailure is not null) + { + var failure = DescribeFailure(installationFailure); + _store.SetInstallationStatus(id, InstanceInstallationState.Failed, pauseRequested: false, + failure.Code, failure.Message); + } + else + { + _store.CompleteInstallation(id, result!); + SetQueueError(null); + } + } + catch (Exception persistenceError) + { + SetQueueError(persistenceError.Message); + try + { + var failure = DescribeFailure(persistenceError); + _store.SetInstallationStatus(id, InstanceInstallationState.Failed, pauseRequested: false, + failure.Code, failure.Message); + } + catch (Exception fallbackError) + { + SetQueueError(fallbackError.Message); + } + } + finally + { + RemoveProgress(id); + _activeInstanceId = null; + _activeCancellation = null; + _activeTask = null; + cancellation.Dispose(); + NotifyPropertyChanged(nameof(ActiveInstanceId)); + try + { + PumpLocked(); + } + catch (Exception exception) + { + SetQueueError(exception.Message); + } + } + } + } + finally + { + lease?.Dispose(); + } + } + + private void OnBusyStateChanged(object? sender, EventArgs eventArgs) + { + lock (_sync) + { + try + { + PumpLocked(); + } + catch (Exception exception) + { + SetQueueError(exception.Message); + } + } + } + + private GameInstance FindInstance(Guid id) => + _store.Instances.FirstOrDefault(instance => instance.Id == id) + ?? throw new KeyNotFoundException("The build does not exist."); + + private void EnsureStarted() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(InstallationCoordinator)); + } + + if (!_started) + { + throw new InvalidOperationException("StartAsync must complete before using the installation queue."); + } + } + + private static GameArchitecture? ParsePinnedArchitecture(string? value) + { + if (value is null) + { + return null; + } + + if (Enum.TryParse(value, ignoreCase: false, out var architecture) + && Enum.IsDefined(architecture)) + { + return architecture; + } + + throw new MojangException(MojangError.Invalid, "The build's pinned architecture is invalid."); + } + + private static void ValidatePreparedForInstance(GameInstance instance, PreparedInstallation prepared) + { + if (!Enum.IsDefined(prepared.Architecture) + || !string.Equals(instance.VersionID, prepared.Version.Id, StringComparison.Ordinal) + || !string.Equals(prepared.Version.Id, prepared.Manifest.Id, StringComparison.Ordinal)) + { + throw new MojangException(MojangError.Invalid, "The prepared Minecraft version does not match this build."); + } + + if (instance.Architecture is { } pinned + && !string.Equals(pinned, prepared.Architecture.ToString(), StringComparison.Ordinal)) + { + throw new MojangException(MojangError.Invalid, "The prepared architecture does not match this build's pinned architecture."); + } + } + + private static (string Code, string Message) DescribeFailure(Exception exception) => exception switch + { + MojangException mojang => ($"mojang.{mojang.Error.ToString().ToLowerInvariant()}", mojang.Message), + ModpackException modpack => ($"modpack.{modpack.Error.ToString().ToLowerInvariant()}", modpack.Message), + InstanceStorageException storage => ($"storage.{storage.Error.ToString().ToLowerInvariant()}", storage.Message), + OperationCanceledException => ("installation.cancelled", exception.Message), + _ => ("installation.unexpected", exception.Message) + }; + + private void SetProgress(Guid id, InstallationProgress value) + { + var changed = false; + lock (_sync) + { + if (value.Stage != InstallationProgressStage.Pausing + && (_activeCancellation?.IsCancellationRequested == true + || _store.Instances.FirstOrDefault(instance => instance.Id == id)?.PauseRequested == true)) + { + return; + } + + if (_progress.TryGetValue(id, out var previous)) + { + if (previous.Stage == value.Stage + && value.Stage != InstallationProgressStage.Complete + && Math.Abs(ProgressFraction(value) - ProgressFraction(previous)) < 0.002 + && _hasProgressTimestamp + && _timeProvider.GetElapsedTime(_lastProgressTimestamp) < TimeSpan.FromMilliseconds(150)) + { + return; + } + + changed = previous != value; + } + else + { + changed = true; + } + + _progress[id] = value; + _lastProgressTimestamp = _timeProvider.GetTimestamp(); + _hasProgressTimestamp = true; + } + + if (changed) + { + NotifyPropertyChanged(nameof(Progress)); + } + } + + private static double ProgressFraction(InstallationProgress value) => value.TotalBytes > 0 + ? (double)value.CompletedBytes / value.TotalBytes + : value.TotalFiles > 0 ? (double)value.CompletedFiles / value.TotalFiles : 0; + + private void RemoveProgress(Guid id) + { + if (_progress.Remove(id)) + { + NotifyPropertyChanged(nameof(Progress)); + } + } + + private void SetQueueError(string? value) + { + lock (_sync) + { + if (_queueError == value) + { + return; + } + + _queueError = value; + NotifyPropertyChanged(nameof(QueueError)); + } + } + + private void NotifyPropertyChanged(string propertyName) + { + var handlers = PropertyChanged; + if (handlers is null) + { + return; + } + + foreach (PropertyChangedEventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, new PropertyChangedEventArgs(propertyName)); + } + catch (Exception) + { + } + } + } + + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(T value) => report(value); + } +} + +public static class InstallationResourceKeys +{ + public static string ErrorResourceKey(string errorCode) => errorCode switch + { + "mojang.http" => "InstallationErrorMojangHttp", + "mojang.unsupported" => "InstallationErrorMojangUnsupported", + "mojang.integrity" => "InstallationErrorMojangIntegrity", + "mojang.invalid" => "InstallationErrorMojangInvalid", + "modpack.invalidmanifest" => "ModpackErrorInvalid", + "modpack.unsafepath" => "ModpackErrorUnsafe", + "modpack.archive" => "ModpackErrorArchive", + "modpack.damagedfile" => "ModpackErrorDamaged", + "storage.operationfailed" => "InstallationErrorStorageOperationFailed", + "storage.invalidpath" => "InstallationErrorStorageInvalidPath", + "storage.pathtraversal" => "InstallationErrorStoragePathTraversal", + "storage.reparsepointnotallowed" => "InstallationErrorStorageReparsePoint", + "storage.invalidbuildname" => "InstallationErrorStorageInvalidBuildName", + "storage.reservedbuildname" => "InstallationErrorStorageReservedBuildName", + "storage.duplicatebuildname" => "InstallationErrorStorageDuplicateBuildName", + "storage.buildfolderexists" => "InstallationErrorStorageBuildFolderExists", + "storage.buildinstalling" => "InstallationErrorStorageBuildInstalling", + "storage.buildrunning" => "InstallationErrorStorageBuildRunning", + "storage.buildfilesbusy" => "InstallationErrorStorageBuildFilesBusy", + "storage.instanceregistrynotloaded" => "InstallationErrorStorageRegistryNotLoaded", + "storage.instanceregistryunavailable" => "InstallationErrorStorageRegistryUnavailable", + "storage.invalidofflineusername" => "InstallationErrorStorageInvalidOfflineUsername", + "storage.missingfabricloaderversion" => "InstallationErrorStorageMissingFabricLoaderVersion", + "installation.cancelled" => "InstallationErrorCancelled", + "installation.unexpected" => "InstallationErrorUnexpected", + _ => "InstallationErrorUnexpected" + }; + + public static string ResourceKey(this InstanceStorageError error) => + ErrorResourceKey($"storage.{error.ToString().ToLowerInvariant()}"); + + public static string ResourceKey(this MojangError error) => + ErrorResourceKey($"mojang.{error.ToString().ToLowerInvariant()}"); + + /// Localized text key for an exception from build creation, settings or the Mojang catalog. + public static string ErrorResourceKey(Exception exception) => exception switch + { + InstanceStorageException storage => storage.Error.ResourceKey(), + InstanceRegistryException registry => registry.ResourceKey, + ModpackException modpack => ErrorResourceKey($"modpack.{modpack.Error.ToString().ToLowerInvariant()}"), + MojangException mojang => mojang.Error.ResourceKey(), + HttpRequestException or TimeoutException or TaskCanceledException => "InstallationErrorMojangHttp", + IOException or UnauthorizedAccessException => "InstallationErrorStorageOperationFailed", + _ => "InstallationErrorUnexpected" + }; + + public static string ResourceKey(this InstanceInstallationState state) => state switch + { + InstanceInstallationState.Queued => "InstallationStateQueued", + InstanceInstallationState.Installing => "InstallationStateInstalling", + InstanceInstallationState.Paused => "InstallationStatePaused", + InstanceInstallationState.Ready => "InstallationStateReady", + InstanceInstallationState.Failed => "InstallationStateFailed", + _ => throw new ArgumentOutOfRangeException(nameof(state), state, null) + }; + + public static string ResourceKey(this InstallationProgressStage stage) => stage switch + { + InstallationProgressStage.PreparingMetadata => "InstallationStagePreparingMetadata", + InstallationProgressStage.PreparingFiles => "InstallationStagePreparingFiles", + InstallationProgressStage.Downloading => "InstallationStageDownloading", + InstallationProgressStage.PreparingLibraries => "InstallationStagePreparingLibraries", + InstallationProgressStage.PreparingAssets => "InstallationStagePreparingAssets", + InstallationProgressStage.PreparingModpack => "InstallationStagePreparingModpack", + InstallationProgressStage.DownloadingModpack => "InstallationStageDownloadingModpack", + InstallationProgressStage.InstallingModpackContents => "InstallationStageInstallingModpackContents", + InstallationProgressStage.Complete => "InstallationStageComplete", + InstallationProgressStage.Pausing => "InstallationStagePausing", + _ => throw new ArgumentOutOfRangeException(nameof(stage), stage, null) + }; +} diff --git a/windows/Hako.Core/Instances/InstanceBackup.cs b/windows/Hako.Core/Instances/InstanceBackup.cs new file mode 100644 index 0000000..42aac21 --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceBackup.cs @@ -0,0 +1,272 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hako.Core.Instances; + +public sealed record InstanceBackupManifest +{ + public const int CurrentFormat = 1; + + public sealed record Mod(string File, bool Enabled, string Source, string? ProjectID, string? VersionID); + + public sealed record ProfileData( + string MetadataURL, + string MetadataSHA1, + LaunchArgumentSource ArgumentSource, + bool OfflineMode, + string OfflineUsername, + InstanceParameters Parameters, + string JavaExecutable, + bool LegacyTexturepacks, + FabricConfiguration? FabricConfiguration, + string? FabricProfileSHA1); + + public int FormatVersion { get; init; } = CurrentFormat; + + [JsonConverter(typeof(UtcWholeSecondDateTimeOffsetConverter))] + public DateTimeOffset BackupCreatedAt { get; init; } + public string Name { get; init; } = ""; + public string FolderName { get; init; } = ""; + + [JsonConverter(typeof(UtcWholeSecondDateTimeOffsetConverter))] + public DateTimeOffset InstanceCreatedAt { get; init; } + public string MinecraftVersion { get; init; } = ""; + public string ModLoader { get; init; } = "vanilla"; + public string? LoaderVersion { get; init; } + public int JavaMajorVersion { get; init; } + public string IconSymbol { get; init; } = ""; + public byte[]? IconPNG { get; init; } + public int ModCount { get; init; } + public IReadOnlyList Mods { get; init; } = []; + public ProfileData Profile { get; init; } = new("", "", LaunchArgumentSource.Mojang, false, "Player", + new InstanceParameters(), "bin/javaw.exe", false, null, null); +} + +/// Creates Swift-compatible .hakobackup files. Restore/import is intentionally not implemented. +public sealed class InstanceBackup +{ + private readonly InstanceStore _store; + private readonly InstanceContent _content; + private readonly Func _utcNow; + private readonly Action? _beforeArchiveEntry; + + public InstanceBackup(InstanceStore store, InstanceContent content) + : this(store, content, null, null) + { + } + + internal InstanceBackup(InstanceStore store, InstanceContent content, Action? beforeArchiveEntry) + : this(store, content, null, beforeArchiveEntry) + { + } + + internal InstanceBackup(InstanceStore store, InstanceContent content, Func? utcNow, + Action? beforeArchiveEntry) + { + _store = store; + _content = content; + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + _beforeArchiveEntry = beforeArchiveEntry; + } + + public async Task CreateAsync(Guid instanceId, CancellationToken cancellationToken = default) + { + string? partialPath = null; + var ownsPartial = false; + BackupResult result; + Exception? cleanupFailure = null; + try + { + result = await _content.WithContentLeaseAsync(instanceId, async (root, instance, token) => + { + if (instance.State != InstanceInstallationState.Ready) + return BackupResult.Failed(BackupFailureReason.BuildNotReady, + "Wait for the build installation to finish before creating a backup."); + EnsureReservedFolderAvailable(InstanceStorage.BackupsFolder); + var outputDirectory = EnsureOutputDirectory(InstanceStorage.BackupsFolder); + var backupTime = _utcNow(); + var target = Path.Combine(outputDirectory, + $"{instance.FolderName}_{backupTime.UtcDateTime.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture)}.hakobackup"); + if (EntryExists(target)) + return BackupResult.Failed(BackupFailureReason.DestinationConflict, + "A backup with this name already exists.", target); + + var modItems = await ListModsAsync(root, instance, token).ConfigureAwait(false); + var mods = modItems.Select(item => new InstanceBackupManifest.Mod( + item.LogicalName, + item.Enabled, + item.Origin?.Source switch + { + ModSource.Modrinth => "modrinth", + ModSource.CurseForge => "curseForge", + _ => "local" + }, + item.Origin?.ProjectID, + item.Origin?.VersionID)).ToArray(); + var icon = ReadBuildIcon(root, instance); + var loader = instance.ModLoader == InstanceModLoader.Fabric ? "fabric" : "vanilla"; + var manifest = new InstanceBackupManifest + { + BackupCreatedAt = backupTime, + Name = instance.Name, + FolderName = instance.FolderName, + InstanceCreatedAt = instance.CreatedAt, + MinecraftVersion = instance.VersionID, + ModLoader = loader, + LoaderVersion = instance.FabricConfiguration?.LoaderVersion, + JavaMajorVersion = instance.JavaMajorVersion, + IconSymbol = instance.IconSymbol, + IconPNG = icon, + ModCount = mods.Length, + Mods = mods, + Profile = new InstanceBackupManifest.ProfileData( + instance.MetadataURL, + instance.MetadataSHA1, + instance.ArgumentSource, + instance.OfflineMode, + instance.OfflineUsername, + instance.Parameters, + instance.JavaExecutable, + instance.LegacyTexturepacks, + instance.FabricConfiguration, + instance.FabricProfileSHA1) + }; + + var excluded = IsExcludedPath; + var entries = await Task.Run(() => InstanceFileTree.Snapshot(root, excluded, token), token) + .ConfigureAwait(false); + partialPath = Path.Combine(outputDirectory, "." + Guid.NewGuid().ToString("N") + ".partial"); + await WriteArchiveAsync(partialPath, manifest, entries, token, () => ownsPartial = true).ConfigureAwait(false); + InstanceFileTree.EnsureUnchanged(_store.GetInstanceDirectory(instanceId), entries, excluded, token); + EnsureOutputDirectory(InstanceStorage.BackupsFolder); + if (EntryExists(target)) + return BackupResult.Failed(BackupFailureReason.DestinationConflict, + "A backup with this name already exists.", target); + try { File.Move(partialPath, target); } + catch (IOException) when (EntryExists(target)) + { + return BackupResult.Failed(BackupFailureReason.DestinationConflict, + "A backup with this name already exists.", target); + } + partialPath = null; + ownsPartial = false; + return BackupResult.Success(target); + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + result = BackupResult.Failed(BackupFailureMapping.From(exception), exception.Message); + } + finally + { + if (ownsPartial && partialPath is not null) + { + try { File.Delete(partialPath); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + cleanupFailure = exception; + } + } + } + + if (cleanupFailure is not null) + { + var failure = result.Failure; + var diagnostic = failure?.Diagnostic ?? "Backup creation failed."; + return BackupResult.Failed(failure?.Reason ?? BackupFailureReason.OperationFailed, + $"{diagnostic} Cleanup failed: {cleanupFailure.Message}", partialPath); + } + + return result; + } + + private async Task> ListModsAsync(string root, GameInstance instance, + CancellationToken cancellationToken) + { + try + { + return await _content.ListUnderLeaseAsync(root, instance, InstanceContentTarget.Mods, true, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch + { + return await _content.ListUnderLeaseAsync(root, instance, InstanceContentTarget.Mods, true, + cancellationToken, readOrigins: false).ConfigureAwait(false); + } + } + + private static byte[]? ReadBuildIcon(string root, GameInstance instance) + { + if (!string.IsNullOrEmpty(instance.IconSymbol)) return null; + var path = InstanceStorage.ContainedPath("icon.png", root, allowBaseReparsePoint: true); + if (!EntryExists(path)) return null; + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + return null; + if (new FileInfo(path).Length > 4 * 1024 * 1024) return null; + return File.ReadAllBytes(path); + } + + private void EnsureReservedFolderAvailable(string name) + { + if (_store.List().Any(instance => string.Equals(instance.FolderName, name, StringComparison.OrdinalIgnoreCase))) + throw new BackupFolderOccupiedException(name); + } + + private string EnsureOutputDirectory(string name) + { + var path = InstanceStorage.ContainedPath(name, _store.GameRoot, allowBaseReparsePoint: true); + if (!Directory.Exists(path) && !EntryExists(path)) + { + try { InstanceStorage.CreateDirectoryExclusiveAt(_store.GameRoot, name); } + catch (IOException) when (EntryExists(path)) { } + path = InstanceStorage.ContainedPath(name, _store.GameRoot, allowBaseReparsePoint: true); + } + + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != FileAttributes.Directory) + throw new BackupFolderOccupiedException(name); + return path; + } + + private async Task WriteArchiveAsync(string partialPath, InstanceBackupManifest manifest, + IReadOnlyList entries, CancellationToken cancellationToken, Action onCreated) + { + await using var output = new FileStream(partialPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + onCreated(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + var data = archive.CreateEntry("data.json", CompressionLevel.Optimal); + await using (var stream = data.Open()) + await JsonSerializer.SerializeAsync(stream, manifest, HakoJsonContext.Default.InstanceBackupManifest, + cancellationToken).ConfigureAwait(false); + await InstanceFileTree.AddToArchiveAsync(archive, entries, "", cancellationToken, + _beforeArchiveEntry).ConfigureAwait(false); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + internal static bool IsExcludedPath(string path) => + path.Equals("minecraft/.hako-running.json", StringComparison.OrdinalIgnoreCase) + || path.Equals("minecraft/.hako-launch", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("minecraft/.hako-launch/", StringComparison.OrdinalIgnoreCase) + || path.Equals("minecraft/logs/hako-launch.log", StringComparison.OrdinalIgnoreCase) + || path.Equals(".hako-game", StringComparison.OrdinalIgnoreCase) + || path.StartsWith(".hako-game/", StringComparison.OrdinalIgnoreCase); + + internal static bool EntryExists(string path) + { + try { _ = File.GetAttributes(path); return true; } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + +} diff --git a/windows/Hako.Core/Instances/InstanceContent.cs b/windows/Hako.Core/Instances/InstanceContent.cs new file mode 100644 index 0000000..85f6f5b --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceContent.cs @@ -0,0 +1,1431 @@ +using System.Security.Cryptography; +using System.Runtime.Versioning; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using Hako.Core; +using Hako.Core.Minecraft; + +namespace Hako.Core.Instances; + +[JsonConverter(typeof(ModSourceJsonConverter))] +public enum ModSource +{ + Local, + Modrinth, + CurseForge +} + +public sealed record ModOrigin +{ + [JsonRequired] + public ModSource Source { get; init; } + [JsonRequired] + public string ProjectID { get; init; } = ""; + [JsonRequired] + public string VersionID { get; init; } = ""; + [JsonRequired] + public Uri PageURL { get; init; } = new("https://modrinth.com/"); + [JsonRequired] + public string Sha512 { get; init; } = ""; + public FabricApiDescriptor? Api { get; init; } + public string? VersionNumber { get; init; } + public Uri? IconURL { get; init; } + public string? FileType { get; init; } + + public static ModOrigin FromApi(FabricApiDescriptor api) => new() + { + Source = ModSource.Modrinth, + ProjectID = api.ProjectID, + VersionID = api.VersionID, + PageURL = new Uri($"https://modrinth.com/mod/fabric-api/version/{Uri.EscapeDataString(api.VersionID)}"), + Sha512 = api.Sha512, + Api = api, + VersionNumber = api.Version + }; +} + +public sealed record ModRegistry +{ + public bool ApiProvisioned { get; init; } + [JsonRequired] + public Dictionary Files { get; init; } = new(StringComparer.Ordinal); +} + +/// Identifies one content location. World datapacks always carry the selected world folder name. +public sealed record InstanceContentTarget +{ + private InstanceContentTarget(ModrinthContentKind kind, string? worldId) + { + Kind = kind; + WorldId = worldId; + } + + public ModrinthContentKind Kind { get; } + public string? WorldId { get; } + + public static InstanceContentTarget Mods { get; } = new(ModrinthContentKind.Mod, null); + public static InstanceContentTarget Packs { get; } = new(ModrinthContentKind.ResourcePack, null); + + public static InstanceContentTarget WorldDatapacks(string worldId) + { + if (!InstanceStorage.IsSinglePathSegment(worldId)) + throw new ArgumentException("The world folder name is invalid.", nameof(worldId)); + return new InstanceContentTarget(ModrinthContentKind.Datapack, worldId); + } + + internal static InstanceContentTarget ForKind(ModrinthContentKind kind) => kind switch + { + ModrinthContentKind.Mod => Mods, + ModrinthContentKind.ResourcePack => Packs, + _ => throw new NotSupportedException("Datapacks require an explicit world target.") + }; +} + +public sealed record InstanceContentItem(string Path, bool IsDirectory, ModOrigin? Origin, DateTimeOffset? ModifiedAt, + bool DisabledPack = false) +{ + public string Name => System.IO.Path.GetFileName(Path); + public string LogicalName => Name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase) ? Name[..^9] : Name; + public bool Enabled => !DisabledPack && !Name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase); + public ModSource Source => Origin?.Source ?? ModSource.Local; +} + +internal sealed class ModSourceJsonConverter : JsonConverter +{ + public override ModSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetString() switch + { + "local" => ModSource.Local, + "modrinth" => ModSource.Modrinth, + "curseForge" => ModSource.CurseForge, + _ => throw new JsonException("Unknown content source.") + }; + + public override void Write(Utf8JsonWriter writer, ModSource value, JsonSerializerOptions options) => + writer.WriteStringValue(value switch + { + ModSource.Local => "local", + ModSource.Modrinth => "modrinth", + ModSource.CurseForge => "curseForge", + _ => throw new JsonException("Unknown content source.") + }); +} + +/// Minimal Fabric content operations. InstanceStore leases serialize them with installation and launch. +public sealed class InstanceContent +{ + private const string RegistryName = ".hako-mods.json"; + private const int MaximumDatapackMetadataBytes = 1024 * 1024; + private const int MaximumDatapackArchiveEntries = 100_000; + private readonly InstanceStore store; + private readonly RecycleBin recycleBin; + private readonly Action? beforeRegistryCommit; + private readonly Action? beforeReplacementPublish; + private readonly Action? beforeCopyDirectory; + + [SupportedOSPlatform("windows")] + public InstanceContent(InstanceStore store, RecycleBin? recycleBin = null) + : this(store, recycleBin, null) + { + } + + [SupportedOSPlatform("windows")] + internal InstanceContent(InstanceStore store, RecycleBin? recycleBin, Action? beforeRegistryCommit, + Action? beforeReplacementPublish = null, Action? beforeCopyDirectory = null) + { + this.store = store; + this.recycleBin = recycleBin ?? new RecycleBin(); + this.beforeRegistryCommit = beforeRegistryCommit; + this.beforeReplacementPublish = beforeReplacementPublish; + this.beforeCopyDirectory = beforeCopyDirectory; + } + + /// Lists regular JARs, ZIPs, and resourcepack folders without following reparse points. + public async Task> ListAsync(Guid instanceId, ModrinthContentKind kind, + bool includeDisabled = true, CancellationToken cancellationToken = default) + { + return await ListAsync(instanceId, InstanceContentTarget.ForKind(kind), includeDisabled, cancellationToken).ConfigureAwait(false); + } + + internal async Task> ListAsync(Guid instanceId, ModrinthContentKind kind, + bool includeDisabled, bool readOrigins, CancellationToken cancellationToken = default) + { + return await ListAsync(instanceId, InstanceContentTarget.ForKind(kind), includeDisabled, readOrigins, + cancellationToken).ConfigureAwait(false); + } + + public async Task> ListAsync(Guid instanceId, InstanceContentTarget target, + bool includeDisabled = true, CancellationToken cancellationToken = default) => + await ListAsync(instanceId, target, includeDisabled, readOrigins: true, cancellationToken).ConfigureAwait(false); + + internal async Task> ListAsync(Guid instanceId, InstanceContentTarget target, + bool includeDisabled, bool readOrigins, CancellationToken cancellationToken = default) + { + ValidateTarget(target); + var instance = store.List().FirstOrDefault(candidate => candidate.Id == instanceId) + ?? throw new InstanceStorageException(InstanceStorageError.InstanceRegistryUnavailable, "The build no longer exists."); + var root = await Task.Run(() => store.GetInstanceDirectory(instanceId), cancellationToken).ConfigureAwait(false); + return await ListUnderLeaseAsync(root, instance, target, includeDisabled, cancellationToken, readOrigins).ConfigureAwait(false); + } + + /// Imports a local JAR, ZIP, or resourcepack folder. Local imports clear stored Modrinth origin. + public Task ImportAsync(Guid instanceId, ModrinthContentKind kind, string sourcePath, bool replace = false, + CancellationToken cancellationToken = default) => + ImportAsync(instanceId, InstanceContentTarget.ForKind(kind), sourcePath, replace, cancellationToken); + + public Task ImportAsync(Guid instanceId, InstanceContentTarget target, string sourcePath, bool replace = false, + CancellationToken cancellationToken = default) => + WithContentLeaseAsync(instanceId, (root, instance, token) => ImportUnderLeaseAsync(root, instance, target, + sourcePath, replace, origin: null, token), cancellationToken); + + /// Enables or disables one previously listed item while preserving its logical file and origin. + public Task SetEnabledAsync(Guid instanceId, InstanceContentItem item, ModrinthContentKind kind, bool enabled, + CancellationToken cancellationToken = default) => + SetEnabledAsync(instanceId, item, InstanceContentTarget.ForKind(kind), enabled, cancellationToken); + + public Task SetEnabledAsync(Guid instanceId, InstanceContentItem item, InstanceContentTarget target, bool enabled, + CancellationToken cancellationToken = default) => + WithContentLeaseAsync(instanceId, (root, instance, token) => Task.Run( + () => SetEnabledUnderLease(root, instance, item, target, enabled), token), cancellationToken); + + /// Moves an item to the Windows Recycle Bin; a non-recyclable or failed delete leaves its registry entry. + [SupportedOSPlatform("windows")] + public Task TrashAsync(Guid instanceId, InstanceContentItem item, ModrinthContentKind kind, + CancellationToken cancellationToken = default) => + WithContentLeaseAsync(instanceId, (root, instance, token) => TrashUnderLeaseAsync(root, instance, item, kind, token), + cancellationToken); + + /// Replaces a regular file only if its currently installed SHA-512 still matches the caller's snapshot. + public Task ReplaceAsync(Guid instanceId, InstanceContentItem item, ModrinthContentKind kind, string sourcePath, + string filename, string expectedSha512, ModOrigin? origin = null, CancellationToken cancellationToken = default) => + ReplaceAsync(instanceId, item, InstanceContentTarget.ForKind(kind), sourcePath, filename, expectedSha512, origin, + cancellationToken); + + public Task ReplaceAsync(Guid instanceId, InstanceContentItem item, InstanceContentTarget target, string sourcePath, + string filename, string expectedSha512, ModOrigin? origin = null, CancellationToken cancellationToken = default) => + WithContentLeaseAsync(instanceId, (root, instance, token) => ReplaceUnderLeaseAsync(root, instance, item, target, + sourcePath, filename, expectedSha512, origin, token), cancellationToken); + + /// + /// Internal transaction boundary for the upcoming catalog controller. It can hold one content lease across + /// downloads and several nested publications without trying to acquire another lease. + /// + internal async Task WithContentLeaseAsync(Guid instanceId, + Func> operation, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + using var lease = await BeginContentAsync(instanceId, cancellationToken).ConfigureAwait(false); + var instance = store.List().FirstOrDefault(candidate => candidate.Id == instanceId) + ?? throw new InstanceStorageException(InstanceStorageError.InstanceRegistryUnavailable, "The build no longer exists."); + var root = await Task.Run(() => store.GetInstanceDirectory(instanceId), cancellationToken).ConfigureAwait(false); + return await Task.Run(() => operation(root, instance, cancellationToken), cancellationToken).ConfigureAwait(false); + } + + internal async Task WithContentLeaseAsync(Guid instanceId, + Func operation, CancellationToken cancellationToken = default) + { + await WithContentLeaseAsync(instanceId, async (root, instance, token) => + { + await operation(root, instance, token).ConfigureAwait(false); + return true; + }, cancellationToken).ConfigureAwait(false); + } + + internal async Task ImportUnderLeaseAsync(string instanceRoot, GameInstance instance, ModrinthContentKind kind, + string sourcePath, bool replace, ModOrigin? origin, CancellationToken cancellationToken) + { + await ImportUnderLeaseAsync(instanceRoot, instance, InstanceContentTarget.ForKind(kind), sourcePath, replace, + origin, cancellationToken).ConfigureAwait(false); + } + + internal async Task ImportUnderLeaseAsync(string instanceRoot, GameInstance instance, InstanceContentTarget target, + string sourcePath, bool replace, ModOrigin? origin, CancellationToken cancellationToken) + { + ValidateTarget(target); + var folder = ContentFolder(instanceRoot, instance, target); + await ImportAtAsync(instanceRoot, instance, folder, target, sourcePath, replace, origin, cancellationToken) + .ConfigureAwait(false); + } + + internal Task> ListUnderLeaseAsync(string instanceRoot, GameInstance instance, + ModrinthContentKind kind, bool includeDisabled, CancellationToken cancellationToken, bool readOrigins = true) => + ListUnderLeaseAsync(instanceRoot, instance, InstanceContentTarget.ForKind(kind), includeDisabled, + cancellationToken, readOrigins); + + internal Task> ListUnderLeaseAsync(string instanceRoot, GameInstance instance, + InstanceContentTarget target, bool includeDisabled, CancellationToken cancellationToken, bool readOrigins = true) => + Task.Run(() => ListAtAsync(instanceRoot, instance, target, includeDisabled, cancellationToken, readOrigins), cancellationToken); + + internal async Task RecordOriginUnderLeaseAsync(string instanceRoot, GameInstance instance, InstanceContentItem item, + ModrinthContentKind kind, string expectedSha512, ModOrigin origin, CancellationToken cancellationToken) + { + await RecordOriginUnderLeaseAsync(instanceRoot, instance, item, InstanceContentTarget.ForKind(kind), + expectedSha512, origin, cancellationToken).ConfigureAwait(false); + } + + internal async Task RecordOriginUnderLeaseAsync(string instanceRoot, GameInstance instance, InstanceContentItem item, + InstanceContentTarget target, string expectedSha512, ModOrigin origin, CancellationToken cancellationToken) + { + if (item.IsDirectory || !IsHash(expectedSha512, 128) || !IsHash(origin.Sha512, 128) + || !string.Equals(expectedSha512, origin.Sha512, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The content origin does not match a regular file checksum."); + ValidateTarget(target); + var folder = ContentFolder(instanceRoot, instance, target); + var path = CheckedItemPath(instanceRoot, folder, target, item); + if (!string.Equals(await HashFileAsync(path, cancellationToken).ConfigureAwait(false), expectedSha512, + StringComparison.OrdinalIgnoreCase)) + throw new IOException("The installed content changed before origin recording."); + var registry = ReadRegistry(instanceRoot, instance, target); + var files = new Dictionary(registry.Files, StringComparer.Ordinal) + { + [LogicalContentName(target.Kind, item.Name).ToLowerInvariant()] = origin + }; + cancellationToken.ThrowIfCancellationRequested(); + CommitRegistry(instanceRoot, instance, target, registry with { Files = files }); + } + + internal async Task ValidateExpectedHashUnderLeaseAsync(string instanceRoot, GameInstance instance, + InstanceContentItem item, ModrinthContentKind kind, string expectedSha512, CancellationToken cancellationToken) + { + await ValidateExpectedHashUnderLeaseAsync(instanceRoot, instance, item, InstanceContentTarget.ForKind(kind), + expectedSha512, cancellationToken).ConfigureAwait(false); + } + + internal async Task ValidateExpectedHashUnderLeaseAsync(string instanceRoot, GameInstance instance, + InstanceContentItem item, InstanceContentTarget target, string expectedSha512, CancellationToken cancellationToken) + { + if (item.IsDirectory || !IsHash(expectedSha512, 128)) + throw new IOException("The installed content snapshot is invalid."); + ValidateTarget(target); + var folder = ContentFolder(instanceRoot, instance, target); + var path = CheckedItemPath(instanceRoot, folder, target, item); + if (!string.Equals(await HashFileAsync(path, cancellationToken).ConfigureAwait(false), expectedSha512, + StringComparison.OrdinalIgnoreCase)) + throw new IOException("The installed content changed before replacement."); + } + + internal async Task MatchesSha512UnderLeaseAsync(string instanceRoot, GameInstance instance, + InstanceContentItem item, InstanceContentTarget target, string expectedSha512, CancellationToken cancellationToken) + { + if (item.IsDirectory || !IsHash(expectedSha512, 128)) return false; + var folder = ContentFolder(instanceRoot, instance, target); + var path = CheckedItemPath(instanceRoot, folder, target, item); + return string.Equals(await HashFileAsync(path, cancellationToken).ConfigureAwait(false), expectedSha512, + StringComparison.OrdinalIgnoreCase); + } + + internal async Task ReplaceUnderLeaseAsync(string instanceRoot, GameInstance instance, InstanceContentItem item, + ModrinthContentKind kind, string sourcePath, string filename, string expectedSha512, ModOrigin? origin, + CancellationToken cancellationToken, InstanceContentItem? confirmedCollision = null) + { + await ReplaceUnderLeaseAsync(instanceRoot, instance, item, InstanceContentTarget.ForKind(kind), sourcePath, + filename, expectedSha512, origin, cancellationToken, confirmedCollision).ConfigureAwait(false); + } + + internal async Task ReplaceUnderLeaseAsync(string instanceRoot, GameInstance instance, InstanceContentItem item, + InstanceContentTarget target, string sourcePath, string filename, string expectedSha512, ModOrigin? origin, + CancellationToken cancellationToken, InstanceContentItem? confirmedCollision = null) + { + ValidateTarget(target); + var folder = ContentFolder(instanceRoot, instance, target); + await ReplaceAtAsync(instanceRoot, instance, folder, target, item, sourcePath, filename, expectedSha512, origin, + cancellationToken, confirmedCollision).ConfigureAwait(false); + } + + internal void SetEnabledUnderLease(string instanceRoot, GameInstance instance, InstanceContentItem item, + ModrinthContentKind kind, bool enabled) + { + SetEnabledUnderLease(instanceRoot, instance, item, InstanceContentTarget.ForKind(kind), enabled); + } + + internal void SetEnabledUnderLease(string instanceRoot, GameInstance instance, InstanceContentItem item, + InstanceContentTarget target, bool enabled) + { + ValidateTarget(target); + var folder = ContentFolder(instanceRoot, instance, target); + SetEnabledAt(instanceRoot, instance, folder, target, item, enabled); + } + + [SupportedOSPlatform("windows")] + internal async Task TrashUnderLeaseAsync(string instanceRoot, GameInstance instance, + InstanceContentItem item, ModrinthContentKind kind, CancellationToken cancellationToken) + { + return await TrashUnderLeaseAsync(instanceRoot, instance, item, InstanceContentTarget.ForKind(kind), cancellationToken) + .ConfigureAwait(false); + } + + [SupportedOSPlatform("windows")] + internal async Task TrashUnderLeaseAsync(string instanceRoot, GameInstance instance, + InstanceContentItem item, InstanceContentTarget target, CancellationToken cancellationToken) + { + ValidateTarget(target); + var folder = ContentFolder(instanceRoot, instance, target); + return await TrashAtAsync(instanceRoot, instance, folder, target, item, cancellationToken).ConfigureAwait(false); + } + + private async Task> ListAtAsync(string instanceRoot, GameInstance instance, + InstanceContentTarget target, bool includeDisabled, CancellationToken cancellationToken, bool readOrigins = true) + { + ValidateTarget(target); + var kind = target.Kind; + var folder = ContentFolder(instanceRoot, instance, target); + var registry = readOrigins ? ReadRegistry(instanceRoot, instance, target) : new ModRegistry(); + var disabledFolder = IsPack(kind) ? DisabledPacksFolder(instanceRoot, folder) : null; + var folders = new List<(string Path, bool Disabled)> { (folder, false) }; + if (includeDisabled && disabledFolder is not null) folders.Add((disabledFolder, true)); + var items = new List(); + foreach (var (directory, disabled) in folders) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.Exists(directory)) continue; + EnsureDirectoryIsSafe(directory); + foreach (var path in Directory.EnumerateFileSystemEntries(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) continue; + var isDirectory = (attributes & FileAttributes.Directory) != 0; + var name = Path.GetFileName(path); + var logicalName = kind == ModrinthContentKind.Mod ? LogicalJarName(name) : name; + var accepted = kind switch + { + ModrinthContentKind.Mod => !isDirectory && (name.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) + || includeDisabled && name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase)), + ModrinthContentKind.ResourcePack or ModrinthContentKind.Datapack => isDirectory ? HasPackMetadata(instanceRoot, path) + : name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase), + _ => false + }; + if (!accepted) continue; + var origin = !isDirectory && registry.Files.TryGetValue(logicalName.ToLowerInvariant(), out var found) ? found : null; + if (origin is not null && !string.Equals(await HashFileAsync(path, cancellationToken).ConfigureAwait(false), + origin.Sha512, StringComparison.OrdinalIgnoreCase)) + origin = null; + var modified = new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero); + items.Add(new InstanceContentItem(path, isDirectory, origin, modified, + kind == ModrinthContentKind.Mod ? name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase) : disabled)); + } + } + + return items.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase).ToArray(); + } + + private static string ContentFolder(string instanceRoot, GameInstance instance, ModrinthContentKind kind) => + ContentFolder(instanceRoot, instance, InstanceContentTarget.ForKind(kind)); + + private static string ContentFolder(string instanceRoot, GameInstance instance, InstanceContentTarget target) + { + ValidateTarget(target); + if (target.Kind == ModrinthContentKind.Datapack) + return CheckedDatapacksFolder(target.WorldId!, instanceRoot); + var relative = target.Kind switch + { + ModrinthContentKind.Mod => "minecraft/mods", + ModrinthContentKind.ResourcePack => instance.LegacyTexturepacks ? "minecraft/texturepacks" : "minecraft/resourcepacks", + _ => throw new ArgumentOutOfRangeException(nameof(target)) + }; + return InstanceStorage.ContainedPath(relative, instanceRoot, allowBaseReparsePoint: true); + } + + private static void ValidateTarget(InstanceContentTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (target.Kind == ModrinthContentKind.Datapack) + { + if (target.WorldId is null || !InstanceStorage.IsSinglePathSegment(target.WorldId)) + throw new ArgumentException("Datapack content requires a valid world folder.", nameof(target)); + } + else if (target.Kind is not (ModrinthContentKind.Mod or ModrinthContentKind.ResourcePack) || target.WorldId is not null) + { + throw new ArgumentException("The content target is invalid.", nameof(target)); + } + } + + private static void ValidateTargetWorld(string instanceRoot, InstanceContentTarget target) + { + if (target.Kind == ModrinthContentKind.Datapack) + _ = CheckedDatapacksFolder(target.WorldId!, instanceRoot); + } + + private static void EnsureTargetContentFolder(string instanceRoot, GameInstance instance, InstanceContentTarget target, + string folder) + { + if (target.Kind != ModrinthContentKind.Datapack) + { + Directory.CreateDirectory(folder); + return; + } + + EnsureTargetLeafDirectory(instanceRoot, instance, target, folder); + } + + private static void EnsureTargetLeafDirectory(string instanceRoot, GameInstance instance, InstanceContentTarget target, + string folder) + { + if (Directory.Exists(folder)) + { + EnsureDirectoryIsSafe(folder); + ValidateTargetWorld(instanceRoot, target); + return; + } + + if (target.Kind != ModrinthContentKind.Datapack) + { + Directory.CreateDirectory(folder); + EnsureDirectoryIsSafe(folder); + return; + } + + var world = CheckedWorldFolder(target.WorldId!, instanceRoot); + if (!PathEquals(Path.GetDirectoryName(folder)!, world)) + throw new IOException("The datapacks folder is outside the selected world."); + InstanceStorage.CreateDirectoryExclusiveAt(world, Path.GetFileName(folder)); + ValidateTargetWorld(instanceRoot, target); + } + + private static string? DisabledPacksFolder(string instanceRoot, string folder) + { + var name = ".hako-disabled-" + Path.GetFileName(folder); + var parent = Path.GetDirectoryName(folder)!; + var relativeParent = Path.GetRelativePath(instanceRoot, parent).Replace(Path.DirectorySeparatorChar, '/'); + return InstanceStorage.ContainedPath(relativeParent + "/" + name, instanceRoot, allowBaseReparsePoint: true); + } + + private static bool IsPack(ModrinthContentKind kind) => kind is ModrinthContentKind.ResourcePack or ModrinthContentKind.Datapack; + + private static string RegistryNameFor(ModrinthContentKind kind) => kind switch + { + ModrinthContentKind.Mod => RegistryName, + ModrinthContentKind.ResourcePack => ".hako-resourcepacks.json", + ModrinthContentKind.Datapack => ".hako-datapacks.json", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string RegistryPath(string instanceRoot, GameInstance instance, InstanceContentTarget target) + { + ValidateTarget(target); + if (target.Kind == ModrinthContentKind.Datapack) + { + var world = CheckedWorldFolder(target.WorldId!, instanceRoot); + return InstanceStorage.ContainedPath(RegistryNameFor(target.Kind), world); + } + + return InstanceStorage.ContainedPath($"minecraft/{RegistryNameFor(target.Kind)}", instanceRoot, + allowBaseReparsePoint: true); + } + + private static string CheckedWorldFolder(string worldId, string instanceRoot) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException(); + return InstanceWorlds.WorldFolder(worldId, instanceRoot); + } + + private static string CheckedDatapacksFolder(string worldId, string instanceRoot) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException(); + return InstanceWorlds.DatapacksFolder(worldId, instanceRoot); + } + + private async Task ImportAtAsync(string instanceRoot, GameInstance instance, string folder, InstanceContentTarget target, + string sourcePath, bool replace, ModOrigin? origin, CancellationToken cancellationToken) + { + var kind = target.Kind; + var source = ValidateImportSource(sourcePath, target, cancellationToken); + if (Directory.Exists(source) && IsPathInside(source, folder)) + throw new IOException("A pack folder cannot be imported into itself or one of its children."); + if (origin is not null && IsPack(kind) && Directory.Exists(source)) + throw new ArgumentException("A directory pack cannot have a file origin.", nameof(origin)); + if (origin is not null && !IsHash(origin.Sha512, 128)) + throw new InvalidDataException("The content origin has an invalid SHA-512."); + var registry = ReadRegistry(instanceRoot, instance, target); + EnsureTargetContentFolder(instanceRoot, instance, target, folder); + EnsureDirectoryIsSafe(folder); + var name = Path.GetFileName(source); + ValidateContentName(name); + var existing = FindExisting(instanceRoot, folder, target, name); + if (existing is not null && !replace) throw new IOException($"Content named '{name}' already exists."); + var disabled = kind == ModrinthContentKind.Mod && existing is not null + && existing.Value.Name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase); + var destinationFolder = existing?.Disabled == true && IsPack(kind) + ? DisabledPacksFolder(instanceRoot, folder)! + : folder; + var finalName = kind == ModrinthContentKind.Mod && disabled && !name.EndsWith(".disabled", StringComparison.OrdinalIgnoreCase) + ? name + ".disabled" : name; + var destination = ChildPath(instanceRoot, destinationFolder, finalName); + var staged = ChildPath(instanceRoot, folder, ".hako-import-" + Guid.NewGuid().ToString("N") + ".tmp"); + var backup = existing is null ? null : existing.Value.Path + ".hako-backup-" + Guid.NewGuid().ToString("N"); + var backupCreated = false; + try + { + await CopyToStageAsync(source, staged, cancellationToken, kind == ModrinthContentKind.Datapack).ConfigureAwait(false); + if (kind == ModrinthContentKind.Datapack) + ValidateDatapackPackage(staged, cancellationToken); + if (origin is not null && !string.Equals(await HashFileAsync(staged, cancellationToken).ConfigureAwait(false), + origin.Sha512, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The imported file does not match its Modrinth origin."); + cancellationToken.ThrowIfCancellationRequested(); + ValidateTargetWorld(instanceRoot, target); + if (existing is not null) + { + MoveEntry(existing.Value.Path, backup!); + backupCreated = true; + } + try + { + ValidateTargetWorld(instanceRoot, target); + MoveEntry(staged, destination); + var files = new Dictionary(registry.Files, StringComparer.Ordinal); + if (existing is not null) files.Remove(LogicalContentName(kind, existing.Value.Name).ToLowerInvariant()); + var key = LogicalContentName(kind, finalName).ToLowerInvariant(); + files.Remove(key); + if (origin is not null) files[key] = origin; + ValidateTargetWorld(instanceRoot, target); + CommitRegistry(instanceRoot, instance, target, registry with { Files = files }); + } + catch (Exception original) + { + RollBackPublishedEntry(original, destination, backup, existing?.Path, backupCreated); + throw; + } + + if (backup is not null) TryDeleteEntry(backup); + } + finally + { + TryDeleteEntry(staged); + } + } + + private async Task ReplaceAtAsync(string instanceRoot, GameInstance instance, string folder, InstanceContentTarget target, + InstanceContentItem item, string sourcePath, string filename, string expectedSha512, ModOrigin? origin, + CancellationToken cancellationToken, InstanceContentItem? confirmedCollision) + { + var kind = target.Kind; + ValidateContentFilename(filename, target); + if (!IsHash(expectedSha512, 128)) throw new ArgumentException("An expected SHA-512 is required.", nameof(expectedSha512)); + if (item.IsDirectory) throw new IOException("Pack folders cannot be replaced by a file update."); + var old = CheckedItemPath(instanceRoot, folder, target, item); + if (!string.Equals(await HashFileAsync(old, cancellationToken).ConfigureAwait(false), expectedSha512, + StringComparison.OrdinalIgnoreCase)) + throw new IOException("The installed content changed before replacement."); + var source = ValidateImportSource(sourcePath, target, cancellationToken); + if (string.Equals(Path.GetFullPath(source), old, StringComparison.OrdinalIgnoreCase)) + throw new IOException("The replacement source must be a separate staged file."); + if (Directory.Exists(source)) throw new IOException("A content update must be a regular file."); + if (origin is not null && !IsHash(origin.Sha512, 128)) throw new InvalidDataException("The content origin has an invalid SHA-512."); + var registry = ReadRegistry(instanceRoot, instance, target); + var match = FindExisting(instanceRoot, folder, target, filename); + var collisionPath = match is not null && !PathEquals(match.Value.Path, old) ? match.Value.Path : null; + if (collisionPath is not null && (confirmedCollision is null + || !PathEquals(CheckedItemPath(instanceRoot, folder, target, confirmedCollision), collisionPath))) + throw new IOException($"Content named '{filename}' already exists."); + if (confirmedCollision is not null && collisionPath is null) + throw new IOException("The confirmed collision changed before replacement."); + var disabled = kind == ModrinthContentKind.Mod ? !item.Enabled : item.DisabledPack; + var destinationFolder = disabled && IsPack(kind) + ? DisabledPacksFolder(instanceRoot, folder)! + : folder; + var finalName = kind == ModrinthContentKind.Mod && disabled ? filename + ".disabled" : filename; + var destination = ChildPath(instanceRoot, destinationFolder, finalName); + var staged = ChildPath(instanceRoot, folder, ".hako-update-" + Guid.NewGuid().ToString("N") + ".tmp"); + var backup = old + ".hako-backup-" + Guid.NewGuid().ToString("N"); + var backupCreated = false; + var collisionBackup = collisionPath is null ? null : collisionPath + ".hako-backup-" + Guid.NewGuid().ToString("N"); + var collisionBackupCreated = false; + var published = false; + try + { + await CopyFileAsync(source, staged, cancellationToken).ConfigureAwait(false); + if (origin is not null && !string.Equals(await HashFileAsync(staged, cancellationToken).ConfigureAwait(false), + origin.Sha512, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The replacement file does not match its Modrinth origin."); + cancellationToken.ThrowIfCancellationRequested(); + try + { + ValidateTargetWorld(instanceRoot, target); + if (collisionPath is not null) + { + MoveEntry(collisionPath, collisionBackup!); + collisionBackupCreated = true; + } + beforeReplacementPublish?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + MoveEntry(old, backup); + backupCreated = true; + MoveEntry(staged, destination); + published = true; + var files = new Dictionary(registry.Files, StringComparer.Ordinal); + files.Remove(item.LogicalName.ToLowerInvariant()); + files.Remove(filename.ToLowerInvariant()); + if (origin is not null) files[filename.ToLowerInvariant()] = origin; + ValidateTargetWorld(instanceRoot, target); + CommitRegistry(instanceRoot, instance, target, registry with { Files = files }); + } + catch (Exception original) + { + RollBackPublishedEntry(original, destination, backup, old, backupCreated, + collisionBackup, collisionPath, collisionBackupCreated, published); + throw; + } + + TryDeleteEntry(backup); + if (collisionBackupCreated) + { + // Registry commit is the boundary: finish recycling even if the caller cancels now. + if (!OperatingSystem.IsWindows()) + throw new IOException($"Content replacement committed. Recycling requires Windows; backup remains at '{collisionBackup}'."); + var result = await recycleBin.DeleteAsync(collisionBackup!, CancellationToken.None).ConfigureAwait(false); + if (result.Items.Count != 1 || result.Items[0].Status != RecycleBinItemStatus.Recycled) + throw new IOException("Content replacement committed, but recycling the displaced file failed. " + + $"Backup remains at '{collisionBackup}'. " + + string.Join("; ", result.Items.Select(entry => $"{entry.Status}: {entry.Error}"))); + } + } + finally + { + TryDeleteEntry(staged); + } + } + + private static void SetEnabledAt(string instanceRoot, GameInstance instance, string folder, InstanceContentTarget target, + InstanceContentItem item, bool enabled) + { + var kind = target.Kind; + var source = CheckedItemPath(instanceRoot, folder, target, item); + if (item.Enabled == enabled) return; + var destinationFolder = kind == ModrinthContentKind.Mod || enabled + ? folder + : DisabledPacksFolder(instanceRoot, folder)!; + var name = kind == ModrinthContentKind.Mod + ? item.LogicalName + (enabled ? "" : ".disabled") + : item.Name; + var collision = FindExisting(instanceRoot, folder, target, name, source); + if (collision is not null) throw new IOException($"Content named '{name}' already exists."); + EnsureTargetLeafDirectory(instanceRoot, instance, target, destinationFolder); + EnsureDirectoryIsSafe(destinationFolder); + ValidateTargetWorld(instanceRoot, target); + MoveEntry(source, ChildPath(instanceRoot, destinationFolder, name)); + } + + [SupportedOSPlatform("windows")] + private async Task TrashAtAsync(string instanceRoot, GameInstance instance, string folder, + InstanceContentTarget target, + InstanceContentItem item, CancellationToken cancellationToken) + { + var path = CheckedItemPath(instanceRoot, folder, target, item); + var registry = ReadRegistry(instanceRoot, instance, target); + var result = await recycleBin.DeleteAsync(path, cancellationToken).ConfigureAwait(false); + if (result.Items.Count != 1) throw new IOException("The Recycle Bin returned an invalid result."); + var status = result.Items[0].Status; + if (status != RecycleBinItemStatus.Recycled) return status; + var key = item.LogicalName.ToLowerInvariant(); + if (!registry.Files.ContainsKey(key)) return status; + var files = new Dictionary(registry.Files, StringComparer.Ordinal); + files.Remove(key); + await Task.Run(() => CommitRegistry(instanceRoot, instance, target, registry with { Files = files }), CancellationToken.None) + .ConfigureAwait(false); + return status; + } + + private void CommitRegistry(string instanceRoot, GameInstance instance, InstanceContentTarget target, ModRegistry registry) + { + beforeRegistryCommit?.Invoke(); + WriteRegistry(instanceRoot, instance, target, registry); + } + + private static (string Path, string Name, bool Disabled)? FindExisting(string instanceRoot, string folder, + InstanceContentTarget target, string name, string? exceptPath = null) + { + var kind = target.Kind; + ValidateContentName(name); + var logical = LogicalContentName(kind, name); + var candidates = new List<(string Path, string Name, bool Disabled)>(); + var folders = new List<(string Path, bool Disabled)> { (folder, false) }; + if (IsPack(kind) && DisabledPacksFolder(instanceRoot, folder) is { } disabled) folders.Add((disabled, true)); + foreach (var (directory, isDisabled) in folders) + { + if (!Directory.Exists(directory)) continue; + EnsureDirectoryIsSafe(directory); + foreach (var path in Directory.EnumerateFileSystemEntries(directory)) + { + var existingName = Path.GetFileName(path); + if (!string.Equals(LogicalContentName(kind, existingName), logical, StringComparison.OrdinalIgnoreCase)) continue; + if (exceptPath is not null && PathEquals(path, exceptPath)) continue; + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("A colliding content entry is a reparse point."); + candidates.Add((path, existingName, kind == ModrinthContentKind.Mod + ? existingName.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase) : isDisabled)); + } + } + + if (candidates.Count > 1) throw new IOException($"More than one content entry matches '{name}'."); + return candidates.Count == 0 ? null : candidates[0]; + } + + private static string ValidateImportSource(string sourcePath, InstanceContentTarget target, + CancellationToken cancellationToken) + { + var kind = target.Kind; + ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath); + var source = Path.GetFullPath(sourcePath); + var attributes = File.GetAttributes(source); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Content sources cannot be reparse points."); + var directory = (attributes & FileAttributes.Directory) != 0; + if (kind == ModrinthContentKind.Mod) + { + if (directory || !source.EndsWith(".jar", StringComparison.OrdinalIgnoreCase)) + throw new IOException("Choose a regular JAR file for a mod."); + } + else if (IsPack(kind)) + { + if (directory) + { + _ = InstanceStorage.ContainedPath("pack.mcmeta", source); + var metadata = Path.Combine(source, "pack.mcmeta"); + if (kind == ModrinthContentKind.Datapack) + ValidateDatapackPackage(source, cancellationToken); + else + { + var metadataAttributes = File.GetAttributes(metadata); + if ((metadataAttributes & (FileAttributes.ReparsePoint | FileAttributes.Directory)) != 0) + throw new IOException("A pack folder must contain a regular pack.mcmeta file."); + ValidateDirectoryTree(source, cancellationToken); + } + } + else if (!source.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("Choose a ZIP file or a folder with pack.mcmeta for a pack."); + } + else if (kind == ModrinthContentKind.Datapack) + ValidateDatapackPackage(source, cancellationToken); + } + else + { + throw new ArgumentOutOfRangeException(nameof(target)); + } + + ValidateContentName(Path.GetFileName(source)); + return source; + } + + private static void ValidateDirectoryTree(string root, CancellationToken cancellationToken) + { + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out var directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateContentName(Path.GetFileName(entry)); + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Resourcepack folders cannot contain reparse points."); + if ((attributes & FileAttributes.Directory) != 0) pending.Push(entry); + } + } + } + + internal static void ValidateDatapackPackage(string path, CancellationToken cancellationToken) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException("A datapack package cannot be a reparse point."); + if ((attributes & FileAttributes.Directory) != 0) + ValidateDatapackFolder(path, cancellationToken); + else + ValidateDatapackArchive(path, cancellationToken); + } + catch (OperationCanceledException) { throw; } + catch (InvalidDatapackPackageException) { throw; } + catch (Exception exception) when (exception is IOException or InvalidDataException or JsonException or UnauthorizedAccessException) + { + throw new InvalidDatapackPackageException("The datapack package is missing a valid pack.mcmeta.", exception); + } + } + + private static void ValidateDatapackFolder(string root, CancellationToken cancellationToken) + { + ValidateDirectoryTree(root, cancellationToken); + var metadata = InstanceStorage.ContainedPath("pack.mcmeta", root); + ValidatePackMetadataFile(metadata, cancellationToken); + } + + private static void ValidateDatapackArchive(string path, CancellationToken cancellationToken) + { + using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.SequentialScan); + using var archive = new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: false); + if (archive.Entries.Count > MaximumDatapackArchiveEntries) + throw new InvalidDataException("The datapack archive has too many entries."); + var metadata = archive.Entries.Where(entry => string.Equals(entry.FullName, "pack.mcmeta", StringComparison.Ordinal)) + .ToArray(); + if (metadata.Length != 1 || IsZipSymbolicLink(metadata[0])) + throw new InvalidDataException("The datapack archive must contain one regular root pack.mcmeta file."); + if (metadata[0].Length > MaximumDatapackMetadataBytes) + throw new InvalidDataException("The datapack pack.mcmeta file is too large."); + using var stream = metadata[0].Open(); + ValidatePackMetadata(ReadBounded(stream, MaximumDatapackMetadataBytes, cancellationToken)); + } + + private static void ValidatePackMetadataFile(string path, CancellationToken cancellationToken) + { + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 + || new FileInfo(path).Length > MaximumDatapackMetadataBytes) + throw new InvalidDataException("The datapack folder must contain a bounded regular pack.mcmeta file."); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.SequentialScan); + ValidatePackMetadata(ReadBounded(stream, MaximumDatapackMetadataBytes, cancellationToken)); + } + + private static byte[] ReadBounded(Stream input, int maximumBytes, CancellationToken cancellationToken) + { + using var output = new MemoryStream(Math.Min(maximumBytes, 4096)); + var buffer = new byte[8192]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = input.Read(buffer, 0, buffer.Length); + if (read == 0) break; + if (output.Length + read > maximumBytes) + throw new InvalidDataException("The datapack pack.mcmeta file is too large."); + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + private static void ValidatePackMetadata(ReadOnlyMemory bytes) + { + using var document = JsonDocument.Parse(bytes); + if (document.RootElement.ValueKind != JsonValueKind.Object + || !document.RootElement.TryGetProperty("pack", out var pack) + || pack.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("The datapack pack.mcmeta file is not a pack JSON object."); + } + + internal sealed class InvalidDatapackPackageException(string message, Exception? innerException = null) + : IOException(message, innerException); + + private static bool IsZipSymbolicLink(ZipArchiveEntry entry) + { + var unixType = (unchecked((uint)entry.ExternalAttributes) >> 16) & 0xF000; + return unixType == 0xA000; + } + + private async Task CopyToStageAsync(string source, string staged, CancellationToken cancellationToken, + bool existingParentsOnly) + { + if (Directory.Exists(source)) await CopyDirectoryAsync(source, staged, cancellationToken, existingParentsOnly).ConfigureAwait(false); + else await CopyFileAsync(source, staged, cancellationToken).ConfigureAwait(false); + } + + private async Task CopyDirectoryAsync(string source, string destination, CancellationToken cancellationToken, + bool existingParentsOnly) + { + beforeCopyDirectory?.Invoke(source); + cancellationToken.ThrowIfCancellationRequested(); + if (existingParentsOnly) + { + // Keep staging on the destination volume without ever recreating a deleted world or ancestor. + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException(); + InstanceStorage.CreateDirectoryExclusiveAt(Path.GetDirectoryName(destination)!, Path.GetFileName(destination)); + } + else + { + Directory.CreateDirectory(destination); + } + foreach (var entry in Directory.EnumerateFileSystemEntries(source)) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = Path.GetFileName(entry); + ValidateContentName(name); + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Resourcepack folders cannot contain reparse points."); + var target = Path.Combine(destination, name); + if ((attributes & FileAttributes.Directory) != 0) + await CopyDirectoryAsync(entry, target, cancellationToken, existingParentsOnly).ConfigureAwait(false); + else + await CopyFileAsync(entry, target, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task CopyFileAsync(string source, string destination, CancellationToken cancellationToken) + { + var attributes = File.GetAttributes(source); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new IOException("Content files must be regular files."); + await using var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await input.CopyToAsync(output, 81920, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + private static async Task HashFileAsync(string path, CancellationToken cancellationToken) + { + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new IOException("Only regular files can be hashed."); + await using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + hash.AppendData(buffer, 0, read); + } + + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static bool HasPackMetadata(string instanceRoot, string path) + { + var metadata = InstanceStorage.ContainedPath(Path.GetRelativePath(instanceRoot, Path.Combine(path, "pack.mcmeta")), + instanceRoot, allowBaseReparsePoint: true); + if (!File.Exists(metadata)) return false; + return (File.GetAttributes(metadata) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == 0; + } + + private static string CheckedItemPath(string instanceRoot, string folder, InstanceContentTarget target, InstanceContentItem item) + { + var kind = target.Kind; + ValidateContentName(item.Name); + var enabledFolder = folder; + var disabledFolder = IsPack(kind) ? DisabledPacksFolder(instanceRoot, folder) : null; + var expectedFolder = item.DisabledPack && disabledFolder is not null ? disabledFolder : enabledFolder; + var expected = ChildPath(instanceRoot, expectedFolder, item.Name); + if (!PathEquals(expected, item.Path)) throw new IOException("The item does not belong to this build content folder."); + var attributes = File.GetAttributes(expected); + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException("Content entries cannot be reparse points."); + var isDirectory = (attributes & FileAttributes.Directory) != 0; + if (kind == ModrinthContentKind.Mod && isDirectory) throw new IOException("A mod must be a regular JAR file."); + if (kind == ModrinthContentKind.Mod && !item.Name.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) + && !item.Name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase)) + throw new IOException("A mod must be a JAR file."); + if (IsPack(kind) && !isDirectory + && !item.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + throw new IOException("A pack must be a ZIP file or a folder with pack.mcmeta."); + if (IsPack(kind) && isDirectory && !HasPackMetadata(instanceRoot, expected)) + throw new IOException("A pack folder must contain pack.mcmeta."); + return expected; + } + + private static string ChildPath(string instanceRoot, string folder, string name) + { + ValidateContentName(name); + var relativeFolder = Path.GetRelativePath(instanceRoot, folder).Replace(Path.DirectorySeparatorChar, '/'); + return InstanceStorage.ContainedPath(relativeFolder + "/" + name, instanceRoot, allowBaseReparsePoint: true); + } + + private static void EnsureDirectoryIsSafe(string folder) + { + if (!Directory.Exists(folder)) return; + if ((File.GetAttributes(folder) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != FileAttributes.Directory) + throw new IOException("Content folders cannot be reparse points."); + } + + private static string LogicalContentName(ModrinthContentKind kind, string name) => + kind == ModrinthContentKind.Mod ? LogicalJarName(name) : name; + + private static void ValidateContentFilename(string filename, InstanceContentTarget target) + { + var kind = target.Kind; + ValidateContentName(filename); + var suffix = kind switch + { + ModrinthContentKind.Mod => ".jar", + ModrinthContentKind.ResourcePack or ModrinthContentKind.Datapack => ".zip", + _ => throw new ArgumentOutOfRangeException(nameof(target)) + }; + if (!filename.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"A content filename must end in {suffix}.", nameof(filename)); + } + + private static void ValidateContentName(string name) + { + if (!InstanceStorage.IsSinglePathSegment(name)) + throw new ArgumentException("The content name is not a valid Windows path segment.", nameof(name)); + } + + private static bool IsHash(string value, int length) => value.Length == length + && value.All(Uri.IsHexDigit); + + private static bool PathEquals(string left, string right) => + string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); + + private static bool IsPathInside(string parent, string child) + { + var fullParent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent)); + var fullChild = Path.TrimEndingDirectorySeparator(Path.GetFullPath(child)); + if (PathEquals(fullParent, fullChild)) return true; + return fullChild.StartsWith(fullParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static bool EntryExists(string path) + { + try { _ = File.GetAttributes(path); return true; } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + + private static void MoveEntry(string from, string to) + { + if ((File.GetAttributes(from) & FileAttributes.Directory) != 0) Directory.Move(from, to); + else File.Move(from, to); + } + + private static void RollBackPublishedEntry(Exception original, string publishedPath, string? backupPath, + string? restorePath, bool backupCreated, string? collisionBackup = null, string? collisionPath = null, + bool collisionBackupCreated = false, bool removePublished = true) + { + var failures = new List<(string Description, Exception Error)>(); + try { if (removePublished) TryDeleteEntry(publishedPath); } + catch (Exception error) { failures.Add(($"Could not remove published entry '{publishedPath}'", error)); } + + var retainedBackups = new List(); + if (backupCreated) + { + try { MoveEntry(backupPath!, restorePath!); } + catch (Exception error) + { + failures.Add(($"Could not restore backup '{backupPath}' to '{restorePath}'", error)); + retainedBackups.Add(backupPath!); + } + } + + if (collisionBackupCreated) + { + try { MoveEntry(collisionBackup!, collisionPath!); } + catch (Exception error) + { + failures.Add(($"Could not restore backup '{collisionBackup}' to '{collisionPath}'", error)); + retainedBackups.Add(collisionBackup!); + } + } + + if (failures.Count == 0) return; + var rollbackDetails = string.Join("; ", failures.Select(failure => + $"{failure.Description}: {failure.Error.Message}")); + var backupDetails = string.Concat(retainedBackups.Select(path => $" Backup remains at '{path}'.")); + throw new IOException($"Content operation failed: {original.Message} Rollback failed: {rollbackDetails}.{backupDetails}", + new AggregateException("The original operation and rollback both failed.", + new[] { original }.Concat(failures.Select(failure => failure.Error)))); + } + + private static void TryDeleteEntry(string path) + { + try { InstanceStorage.DeleteEntryWithoutFollowingReparsePoints(path); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + } + + + public async Task PublishJarAsync(Guid instanceId, string sourcePath, string filename, ModOrigin? origin = null, + CancellationToken cancellationToken = default) + { + using var lease = await BeginContentAsync(instanceId, cancellationToken).ConfigureAwait(false); + var root = await Task.Run(() => store.GetInstanceDirectory(instanceId), cancellationToken).ConfigureAwait(false); + await Task.Run(() => PublishJarAsync(sourcePath, filename, root, origin, cancellationToken), cancellationToken) + .ConfigureAwait(false); + } + + internal static async Task PublishJarAsync(string sourcePath, string filename, string instanceRoot, ModOrigin? origin, + CancellationToken cancellationToken) + { + ValidateJarName(filename); + var source = Path.GetFullPath(sourcePath); + if (!File.Exists(source) || (File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0) + throw new IOException("The JAR source is missing or is a reparse point."); + + var folder = InstanceStorage.ContainedPath("minecraft/mods", instanceRoot, allowBaseReparsePoint: true); + Directory.CreateDirectory(folder); + var registry = ReadRegistry(instanceRoot); + EnsureNoCollision(folder, filename); + var destination = InstanceStorage.ContainedPath($"minecraft/mods/{filename}", instanceRoot, allowBaseReparsePoint: true); + var staged = InstanceStorage.ContainedPath($"minecraft/mods/.hako-{Guid.NewGuid():N}.tmp", instanceRoot, allowBaseReparsePoint: true); + try + { + await using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + await using (var output = new FileStream(staged, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(staged, destination); + try + { + if (origin is not null) + { + var files = new Dictionary(registry.Files, StringComparer.Ordinal) + { + [filename.ToLowerInvariant()] = origin + }; + WriteRegistry(instanceRoot, registry with { Files = files }); + } + } + catch + { + File.Delete(destination); + throw; + } + } + finally + { + try { File.Delete(staged); } + catch (FileNotFoundException) { } + } + } + + internal static async Task ProvisionAPIAsync(FabricApiDescriptor api, string cachedPath, string instanceRoot, + CancellationToken cancellationToken) + { + var registry = ReadRegistry(instanceRoot); + if (registry.ApiProvisioned) return; + await FabricClient.ValidateApiFileAsync(cachedPath, api, cancellationToken).ConfigureAwait(false); + ValidateJarName(api.Filename); + var folder = InstanceStorage.ContainedPath("minecraft/mods", instanceRoot, allowBaseReparsePoint: true); + Directory.CreateDirectory(folder); + var existing = FindLogicalFile(folder, api.Filename); + if (existing is not null) + { + await FabricClient.ValidateApiFileAsync(existing, api, cancellationToken).ConfigureAwait(false); + WriteRegistry(instanceRoot, WithApi(registry, api)); + return; + } + + await PublishJarAsync(cachedPath, api.Filename, instanceRoot, ModOrigin.FromApi(api), cancellationToken).ConfigureAwait(false); + WriteRegistry(instanceRoot, WithApi(ReadRegistry(instanceRoot), api)); + } + + internal static bool ApiWasProvisioned(string instanceRoot) => ReadRegistry(instanceRoot).ApiProvisioned; + + internal static ModRegistry ReadRegistry(string instanceRoot) => ReadRegistry(instanceRoot, RegistryName); + + internal static ModRegistry ReadRegistry(string instanceRoot, string registryName) + { + if (!InstanceStorage.IsSinglePathSegment(registryName)) + throw new ArgumentException("The registry name must be one path segment.", nameof(registryName)); + var path = InstanceStorage.ContainedPath($"minecraft/{registryName}", instanceRoot, allowBaseReparsePoint: true); + return ReadRegistryAt(path, requireApiProvisioned: true); + } + + private static ModRegistry ReadRegistry(string instanceRoot, GameInstance instance, InstanceContentTarget target) + { + var path = RegistryPath(instanceRoot, instance, target); + return ReadRegistryAt(path, requireApiProvisioned: target.Kind != ModrinthContentKind.Datapack); + } + + private static ModRegistry ReadRegistryAt(string path, bool requireApiProvisioned) + { + FileAttributes attributes; + try { attributes = File.GetAttributes(path); } + catch (FileNotFoundException) { return new ModRegistry(); } + catch (DirectoryNotFoundException) { return new ModRegistry(); } + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new InvalidDataException("The mod registry must be a regular file."); + if (new FileInfo(path).Length > 16 * 1024 * 1024) + throw new InvalidDataException("The mod registry is too large."); + var bytes = File.ReadAllBytes(path); + ValidateRegistry(bytes, requireApiProvisioned); + return JsonSerializer.Deserialize(bytes, HakoJsonContext.Default.ModRegistry) + ?? throw new InvalidDataException("The mod registry is empty."); + } + + private static void ValidateRegistry(ReadOnlyMemory bytes, bool requireApiProvisioned = true) + { + using var document = JsonDocument.Parse(bytes); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("files", out var files) + || files.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The mod registry is missing required fields or has an invalid structure."); + } + + if (root.TryGetProperty("apiProvisioned", out var apiProvisioned)) + { + if (apiProvisioned.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new JsonException("The mod registry apiProvisioned field has an invalid structure."); + } + else if (requireApiProvisioned) + { + throw new JsonException("The mod registry is missing the required apiProvisioned field."); + } + + foreach (var file in files.EnumerateObject()) + { + try { ValidateContentName(file.Name); } + catch (ArgumentException exception) { throw new JsonException("A content registry key is not a valid filename.", exception); } + if (!string.Equals(file.Name, file.Name.ToLowerInvariant(), StringComparison.Ordinal)) + throw new JsonException("Content registry keys must be lowercase logical filenames."); + ValidateOrigin(file.Value); + } + } + + private static void ValidateOrigin(JsonElement origin) + { + if (origin.ValueKind != JsonValueKind.Object) + throw new JsonException("A mod origin must be an object."); + RequireString(origin, "source"); + RequireString(origin, "projectID"); + RequireString(origin, "versionID"); + RequireString(origin, "pageURL"); + RequireString(origin, "sha512"); + RequireOptionalString(origin, "versionNumber"); + RequireOptionalString(origin, "iconURL"); + RequireOptionalString(origin, "fileType"); + if (origin.TryGetProperty("api", out var api) && api.ValueKind != JsonValueKind.Null) + { + ValidateApiDescriptor(api); + } + } + + private static void ValidateApiDescriptor(JsonElement api) + { + if (api.ValueKind != JsonValueKind.Object) + throw new JsonException("A Fabric API descriptor must be an object."); + RequireString(api, "projectID"); + RequireString(api, "versionID"); + RequireString(api, "version"); + RequireString(api, "channel"); + RequireString(api, "filename"); + RequireString(api, "url"); + RequireString(api, "sha1"); + RequireString(api, "sha512"); + if (!api.TryGetProperty("size", out var size) || size.ValueKind != JsonValueKind.Number) + throw new JsonException("A Fabric API descriptor is missing its required size."); + } + + private static void RequireString(JsonElement value, string name) + { + if (!value.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.String) + throw new JsonException($"A required mod registry field is missing or null: {name}."); + } + + private static void RequireOptionalString(JsonElement value, string name) + { + if (value.TryGetProperty(name, out var property) && property.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) + throw new JsonException($"An optional mod registry field has an invalid structure: {name}."); + } + + internal static void WriteRegistry(string instanceRoot, ModRegistry registry) => WriteRegistry(instanceRoot, RegistryName, registry); + + internal static void WriteRegistry(string instanceRoot, string registryName, ModRegistry registry) + { + if (!InstanceStorage.IsSinglePathSegment(registryName)) + throw new ArgumentException("The registry name must be one path segment.", nameof(registryName)); + var path = InstanceStorage.ContainedPath($"minecraft/{registryName}", instanceRoot, allowBaseReparsePoint: true); + WriteRegistryAt(path, registry, createParent: true, validateWorld: null); + } + + private static void WriteRegistry(string instanceRoot, GameInstance instance, InstanceContentTarget target, + ModRegistry registry) + { + var path = RegistryPath(instanceRoot, instance, target); + Action? validateWorld = target.WorldId is null ? null : () => ValidateTargetWorld(instanceRoot, target); + WriteRegistryAt(path, registry, createParent: target.WorldId is null, validateWorld); + } + + private static void WriteRegistryAt(string path, ModRegistry registry, bool createParent, Action? validateWorld) + { + foreach (var key in registry.Files.Keys) + { + ValidateContentName(key); + if (!string.Equals(key, key.ToLowerInvariant(), StringComparison.Ordinal)) + throw new InvalidDataException("Content registry keys must be lowercase logical filenames."); + } + if (createParent) Directory.CreateDirectory(Path.GetDirectoryName(path)!); + validateWorld?.Invoke(); + try + { + if ((File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new InvalidDataException("The mod registry must be a regular file."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + var bytes = JsonSerializer.SerializeToUtf8Bytes(registry, HakoJsonContext.Default.ModRegistry); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + validateWorld?.Invoke(); + File.Move(temporary, path, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (FileNotFoundException) { } + } + } + + private async Task BeginContentAsync(Guid instanceId, CancellationToken cancellationToken) + { + var result = await Task.Run(() => store.TryBeginContent(instanceId, out var lease, out var reason) + ? (lease, reason) + : (null, reason), cancellationToken).ConfigureAwait(false); + return result.lease ?? throw new InstanceStorageException(result.reason ?? InstanceStorageError.BuildFilesBusy, + "The build files are busy."); + } + + private static ModRegistry WithApi(ModRegistry registry, FabricApiDescriptor api) + { + var files = new Dictionary(registry.Files, StringComparer.Ordinal) + { + [api.Filename.ToLowerInvariant()] = ModOrigin.FromApi(api) + }; + return registry with { ApiProvisioned = true, Files = files }; + } + + private static void EnsureNoCollision(string folder, string filename) + { + if (FindLogicalFile(folder, filename) is not null) + throw new IOException($"A mod named '{filename}' is already installed."); + } + + private static string? FindLogicalFile(string folder, string filename) + { + var logical = LogicalJarName(filename); + foreach (var path in Directory.EnumerateFiles(folder)) + { + var name = Path.GetFileName(path); + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0 + && string.Equals(LogicalJarName(name), logical, StringComparison.OrdinalIgnoreCase)) + return path; + } + + return null; + } + + private static string LogicalJarName(string filename) => filename.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase) + ? filename[..^9] + : filename; + + private static void ValidateJarName(string filename) + { + if (!InstanceStorage.IsSinglePathSegment(filename) || !filename.EndsWith(".jar", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("A mod filename must be a single JAR filename.", nameof(filename)); + } +} diff --git a/windows/Hako.Core/Instances/InstanceContentController.cs b/windows/Hako.Core/Instances/InstanceContentController.cs new file mode 100644 index 0000000..43ffae6 --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceContentController.cs @@ -0,0 +1,1757 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Runtime.Versioning; +using System.Text.Json; +using Hako.Core.Minecraft; + +namespace Hako.Core.Instances; + +public enum InstanceContentOperation +{ + Reload, + Import, + Enable, + Delete, + Install, + CheckUpdates, + Update, + UpdateAll +} + +public enum ContentConfirmationCode +{ + Dependencies, + IncompatibleContent, + ReplaceExisting, + DeleteContent, + DatapackResourcePacks +} + +public enum ContentConfirmationChoice +{ + Cancel, + Primary, + Alternative +} + +public enum ContentConfirmationNoteCode +{ + DisabledWillBeEnabled, + Unsupported, + RequiresFabric, + NoCompatibleVersion, + ProjectLimitReached, + ProjectUnavailable, + FabricLoaderRequirement, + JavaRequirement, + IncompatibleWith +} + +public enum ContentFailureCode +{ + BuildUnavailable, + BuildBusy, + BuildInstalling, + ModRequiresFabric, + InvalidChannel, + NoCompatibleVersion, + RegistryCorrupt, + RegistryUnavailable, + StaleContent, + ConfirmationRequired, + ConfirmationDeclined, + FabricApiIncompatible, + ModrinthRequestFailed, + ContentOperationFailed, + BulkUpdateFailed, + Cancelled, + WorldUnavailable +} + +public readonly record struct InstanceContentStateKey(Guid InstanceId, ModrinthContentKind Kind, string? WorldId = null); + +public sealed record ContentFailure(ContentFailureCode Code, IReadOnlyDictionary Arguments); + +public sealed record ContentConfirmationProject( + string Id, + string Title, + Uri? IconUrl, + ContentConfirmationNoteCode? NoteCode = null, + IReadOnlyDictionary? NoteArguments = null); + +public sealed record ContentConfirmationFile(string Filename, string? FileType, bool Required); + +/// UI-neutral confirmation data. The UI maps codes and arguments to localized text. +public sealed record ContentConfirmationRequest( + ContentConfirmationCode Code, + IReadOnlyDictionary Arguments, + bool PrimaryActionAvailable, + bool AlternativeActionAvailable, + bool Destructive, + IReadOnlyList Projects, + IReadOnlyList? Files = null, + InstanceContentTarget? Target = null); + +public delegate Task ContentConfirmationCallback( + ContentConfirmationRequest request, + CancellationToken cancellationToken); + +public sealed record ContentConfirmationResult(ContentConfirmationChoice Choice, string? SelectedFilename = null); + +public delegate Task ContentConfirmationSelectionCallback( + ContentConfirmationRequest request, + CancellationToken cancellationToken); + +public sealed record InstanceContentUpdate( + InstanceContentItem Item, + ModrinthProject Project, + ModrinthVersion? Version, + ModrinthFile? File, + string CurrentSha512, + ModrinthContentKind? VersionKind = null, + FabricApiDescriptor? Api = null); + +public sealed record InstanceContentState +{ + public IReadOnlyList Items { get; init; } = []; + public IReadOnlyDictionary Updates { get; init; } + = new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase)); + public bool IsLoading { get; init; } + public bool IsBusy { get; init; } + public bool IsCheckingUpdates { get; init; } + public InstanceContentOperation? BusyOperation { get; init; } + public string? CurrentProjectId { get; init; } + public ContentFailure? Error { get; init; } + public ContentFailure? UpdateMessage { get; init; } + public long Revision { get; init; } + public bool HasUpdates => Updates.Count != 0; +} + +public sealed class InstanceContentStateChangedEventArgs(InstanceContentStateKey key, InstanceContentState state) : EventArgs +{ + public InstanceContentStateKey Key { get; } = key; + public InstanceContentState State { get; } = state; +} + +public sealed record ContentActionResult(bool Succeeded, ContentFailure? Failure = null); + +public sealed record ContentUpdateItemResult(string Filename, bool Succeeded, ContentFailure? Failure = null); + +public sealed record ContentBulkUpdateResult(IReadOnlyList Items) +{ + public bool HasFailures => Items.Any(item => !item.Succeeded); +} + +/// +/// Core-only coordinator for local content, Modrinth installs and updates. Events are raised on the +/// thread that changed the snapshot; UI consumers marshal them to their dispatcher. +/// +[SupportedOSPlatform("windows")] +public sealed class InstanceContentController +{ + private const int MaximumDependencyProjects = 30; + private const int MaximumFabricMetadataBytes = 1024 * 1024; + private readonly InstanceStore _store; + private readonly InstanceContent _content; + private readonly ModrinthClient _modrinth; + private readonly FabricClient _fabric; + private readonly ContentConfirmationCallback? _confirm; + private readonly ContentConfirmationSelectionCallback? _confirmSelection; + private readonly ConcurrentDictionary _operationGates = new(); + private readonly SemaphoreSlim _confirmationGate = new(1, 1); + private readonly object _sync = new(); + private readonly Dictionary _states = []; + private readonly Dictionary _revisions = []; + + public InstanceContentController(InstanceStore store, InstanceContent content, ModrinthClient modrinth, + FabricClient fabric, ContentConfirmationCallback? confirm = null, + ContentConfirmationSelectionCallback? confirmSelection = null) + { + _store = store; + _content = content; + _modrinth = modrinth; + _fabric = fabric; + _confirm = confirm; + _confirmSelection = confirmSelection; + } + + public event EventHandler? StateChanged; + + public InstanceContentState GetState(Guid instanceId, ModrinthContentKind kind) + { + ValidateKind(kind); + return GetState(instanceId, InstanceContentTarget.ForKind(kind)); + } + + public InstanceContentState GetState(Guid instanceId, InstanceContentTarget target) + { + ValidateTarget(target); + var key = Key(instanceId, target); + lock (_sync) return Snapshot(_states.GetValueOrDefault(key) ?? new InstanceContentState()); + } + + public Task ReloadAsync(Guid instanceId, ModrinthContentKind kind, CancellationToken cancellationToken = default) => + ReloadAsync(instanceId, InstanceContentTarget.ForKind(kind), cancellationToken); + + public async Task ReloadAsync(Guid instanceId, InstanceContentTarget target, CancellationToken cancellationToken = default) + { + ValidateTarget(target); + var key = Key(instanceId, target); + var initial = TryBeginRead(key, checkingUpdates: false); + if (initial is null) return; + var revision = initial.Revision; + RaiseStateChanged(key, initial); + if (!IsRevisionCurrent(key, revision)) return; + try + { + var instance = FindInstance(instanceId); + var root = _store.GetInstanceDirectory(instanceId); + ValidateTargetSnapshot(instance, root, target); + var (items, registryFailure) = await ListWithRegistryFallbackAsync(instance, target, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (!IsCurrent(key, revision, instance, root, target)) return; + ChangeState(key, revision, state => state with + { + Items = items, + Updates = RetainUpdates(state.Updates, items), + Error = registryFailure + }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + ChangeState(key, revision, state => state with { Error = MapFailure(exception) }); + } + finally + { + ChangeState(key, revision, state => state with { IsLoading = false }); + } + } + + public async Task>> InstalledProjectsAsync( + Guid instanceId, ModrinthContentKind kind, CancellationToken cancellationToken = default) + { + ValidateKind(kind); + return await InstalledProjectsAsync(instanceId, InstanceContentTarget.ForKind(kind), cancellationToken).ConfigureAwait(false); + } + + public async Task>> InstalledProjectsAsync( + Guid instanceId, InstanceContentTarget target, CancellationToken cancellationToken = default) + { + ValidateTarget(target); + var readRevision = GetState(instanceId, target).Revision; + var instance = FindInstance(instanceId); + var root = _store.GetInstanceDirectory(instanceId); + ValidateTargetSnapshot(instance, root, target); + var (items, registryFailure) = await ListWithRegistryFallbackAsync(instance, target, cancellationToken).ConfigureAwait(false); + if (registryFailure is not null) + SetReadError(instanceId, target, readRevision, registryFailure); + return await GroupInstalledProjectsAsync(items, cancellationToken).ConfigureAwait(false); + } + + public Task ImportLocalAsync(Guid instanceId, ModrinthContentKind kind, string sourcePath, + CancellationToken cancellationToken = default) => + ImportLocalAsync(instanceId, InstanceContentTarget.ForKind(kind), sourcePath, cancellationToken); + + public Task ImportLocalAsync(Guid instanceId, InstanceContentTarget target, string sourcePath, + CancellationToken cancellationToken = default) => RunMutationAsync(instanceId, target, InstanceContentOperation.Import, + async (operation, root, instance, token) => + { + var kind = target.Kind; + var items = await _content.ListUnderLeaseAsync(root, instance, target, includeDisabled: true, token).ConfigureAwait(false); + var incoming = Path.GetFileName(Path.GetFullPath(sourcePath)); + var collision = items.FirstOrDefault(item => LogicalName(kind, item).Equals(incoming, StringComparison.OrdinalIgnoreCase)); + var replace = false; + if (collision is not null) + { + var choice = await ConfirmAsync(new ContentConfirmationRequest(ContentConfirmationCode.ReplaceExisting, + Args(("filename", incoming), ("instanceName", instance.Name)), true, false, true, + [new ContentConfirmationProject(incoming, incoming, null)], Target: target), token).ConfigureAwait(false); + if (choice != ContentConfirmationChoice.Primary) + return new ContentActionResult(false, choice == ContentConfirmationChoice.Cancel ? null : Failure(ContentFailureCode.ConfirmationDeclined)); + replace = true; + } + + ValidateTargetSnapshot(instance, root, target); + operation.Touch(target); + await _content.ImportUnderLeaseAsync(root, instance, target, sourcePath, replace, origin: null, token).ConfigureAwait(false); + return new ContentActionResult(true); + }, cancellationToken); + + public Task SetEnabledAsync(Guid instanceId, ModrinthContentKind kind, + InstanceContentItem item, bool enabled, CancellationToken cancellationToken = default) => + SetEnabledAsync(instanceId, InstanceContentTarget.ForKind(kind), item, enabled, cancellationToken); + + public Task SetEnabledAsync(Guid instanceId, InstanceContentTarget target, + InstanceContentItem item, bool enabled, CancellationToken cancellationToken = default) => + RunMutationAsync(instanceId, target, InstanceContentOperation.Enable, async (operation, root, instance, token) => + { + var items = await _content.ListUnderLeaseAsync(root, instance, target, includeDisabled: true, token).ConfigureAwait(false); + var current = items.FirstOrDefault(candidate => PathEquals(candidate.Path, item.Path)); + if (current is null) throw ControllerException.With(ContentFailureCode.StaleContent); + ValidateTargetSnapshot(instance, root, target); + operation.Touch(target); + await Task.Run(() => _content.SetEnabledUnderLease(root, instance, current, target, enabled), token).ConfigureAwait(false); + return new ContentActionResult(true); + }, cancellationToken); + + public Task DeleteAsync(Guid instanceId, ModrinthContentKind kind, + InstanceContentItem item, CancellationToken cancellationToken = default) => + RunMutationAsync(instanceId, InstanceContentTarget.ForKind(kind), InstanceContentOperation.Delete, async (operation, root, instance, token) => + { + var choice = await ConfirmAsync(new ContentConfirmationRequest(ContentConfirmationCode.DeleteContent, + Args(("filename", item.Name), ("instanceName", instance.Name)), true, false, true, + [new ContentConfirmationProject(item.Name, item.Name, item.Origin?.IconURL)], + Target: InstanceContentTarget.ForKind(kind)), token).ConfigureAwait(false); + if (choice != ContentConfirmationChoice.Primary) return new ContentActionResult(false); + var items = await _content.ListUnderLeaseAsync(root, instance, kind, includeDisabled: true, token).ConfigureAwait(false); + var current = items.FirstOrDefault(candidate => PathEquals(candidate.Path, item.Path)); + if (current is null) throw ControllerException.With(ContentFailureCode.StaleContent); + ValidatePathSnapshot(instance, root); + operation.Touch(kind); + var status = await _content.TrashUnderLeaseAsync(root, instance, current, kind, token).ConfigureAwait(false); + return status == RecycleBinItemStatus.Recycled + ? new ContentActionResult(true) + : new ContentActionResult(false, Failure(ContentFailureCode.ContentOperationFailed)); + }, cancellationToken); + + public Task InstallAsync(Guid instanceId, ModrinthContentKind kind, + ModrinthProject project, string channel = "release", CancellationToken cancellationToken = default) => + InstallAsync(instanceId, InstanceContentTarget.ForKind(kind), project, channel, cancellationToken); + + public Task InstallAsync(Guid instanceId, InstanceContentTarget target, + ModrinthProject project, string channel = "release", CancellationToken cancellationToken = default) => + RunMutationAsync(instanceId, target, InstanceContentOperation.Install, + (operation, root, instance, token) => InstallUnderLeaseAsync(operation, root, instance, target, project, + channel, requestedVersion: null, requestedFile: null, replacing: null, interactive: true, token), + cancellationToken, project.Id); + + public Task CheckUpdatesAsync(Guid instanceId, ModrinthContentKind kind, CancellationToken cancellationToken = default) => + CheckUpdatesAsync(instanceId, InstanceContentTarget.ForKind(kind), cancellationToken); + + public Task CheckUpdatesAsync(Guid instanceId, InstanceContentTarget target, CancellationToken cancellationToken = default) => + CheckUpdatesCoreAsync(instanceId, target, cancellationToken); + + public Task UpdateAsync(Guid instanceId, ModrinthContentKind kind, + InstanceContentItem item, CancellationToken cancellationToken = default) + { + ValidateKind(kind); + return UpdateAsync(instanceId, InstanceContentTarget.ForKind(kind), item, cancellationToken); + } + + public Task UpdateAsync(Guid instanceId, InstanceContentTarget target, + InstanceContentItem item, CancellationToken cancellationToken = default) + { + ValidateTarget(target); + var update = GetState(instanceId, target).Updates.GetValueOrDefault(item.LogicalName); + if (update is null || !PathEquals(update.Item.Path, item.Path)) + return Task.FromResult(new ContentActionResult(false, Failure(ContentFailureCode.StaleContent))); + return RunMutationAsync(instanceId, target, InstanceContentOperation.Update, async (operation, root, instance, token) => + { + operation.Touch(target); + operation.SetCurrentProject(update.Project.Id); + if (update.Api is not null) + { + return await ApplyApiUpdateUnderLeaseAsync(root, instance, update, interactive: true, token).ConfigureAwait(false); + } + + return await InstallUnderLeaseAsync(operation, root, instance, target, update.Project, channel: null, + update.Version, update.File, (update.Item, update.CurrentSha512), interactive: true, token, + requestedVersionKind: update.VersionKind).ConfigureAwait(false); + }, cancellationToken, update.Project.Id); + } + + public Task UpdateAllAsync(Guid instanceId, ModrinthContentKind kind, + CancellationToken cancellationToken = default) + { + ValidateKind(kind); + return UpdateAllAsync(instanceId, InstanceContentTarget.ForKind(kind), cancellationToken); + } + + public Task UpdateAllAsync(Guid instanceId, InstanceContentTarget target, + CancellationToken cancellationToken = default) + { + ValidateTarget(target); + var updates = GetState(instanceId, target).Updates.Values + .OrderBy(update => update.Api is null ? 1 : 0) + .ThenBy(update => update.Item.LogicalName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (updates.Length == 0) return Task.FromResult(new ContentBulkUpdateResult([])); + + return RunBulkUpdateAsync(instanceId, target, updates, cancellationToken); + } + + private async Task RunBulkUpdateAsync(Guid instanceId, InstanceContentTarget target, + IReadOnlyList updates, CancellationToken cancellationToken) + { + var results = new List(updates.Count); + var outcome = await RunMutationAsync(instanceId, target, InstanceContentOperation.UpdateAll, + async (operation, root, instance, token) => + { + operation.Touch(target); + foreach (var update in updates.OrderBy(item => item.Api is null ? 1 : 0) + .ThenBy(item => item.Item.LogicalName, StringComparer.OrdinalIgnoreCase)) + { + token.ThrowIfCancellationRequested(); + operation.SetCurrentProject(update.Project.Id); + try + { + if (update.Api is not null) + await ApplyApiUpdateUnderLeaseAsync(root, instance, update, interactive: false, token).ConfigureAwait(false); + else + { + var result = await InstallUnderLeaseAsync(operation, root, instance, target, update.Project, + channel: null, update.Version, update.File, (update.Item, update.CurrentSha512), + interactive: false, token, requestedVersionKind: update.VersionKind).ConfigureAwait(false); + if (!result.Succeeded) + throw ControllerException.With(ContentFailureCode.ConfirmationRequired); + } + + results.Add(new ContentUpdateItemResult(update.Item.LogicalName, true)); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + results.Add(new ContentUpdateItemResult(update.Item.LogicalName, false, MapFailure(exception))); + } + } + + var failure = results.Any(result => !result.Succeeded) + ? Failure(ContentFailureCode.BulkUpdateFailed, ("failureCount", results.Count(result => !result.Succeeded).ToString())) + : null; + return new ContentActionResult(failure is null, failure); + }, cancellationToken, updates[0].Project.Id).ConfigureAwait(false); + + if (!outcome.Succeeded && results.Count == 0 && outcome.Failure is not null) + results.Add(new ContentUpdateItemResult("", false, outcome.Failure)); + return new ContentBulkUpdateResult(results); + } + + private async Task RunMutationAsync(Guid instanceId, InstanceContentTarget target, + InstanceContentOperation operationKind, + Func> action, + CancellationToken cancellationToken, string? initialProjectId = null) + { + ValidateTarget(target); + var gate = _operationGates.GetOrAdd(instanceId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + var operation = new OperationContext(this, instanceId, operationKind, cancellationToken); + var key = Key(instanceId, target); + try + { + operation.Touch(target); + var instance = FindInstance(instanceId); + if (target.Kind == ModrinthContentKind.Mod && instance.ModLoader != InstanceModLoader.Fabric) + throw ControllerException.With(ContentFailureCode.ModRequiresFabric); + if (target.Kind == ModrinthContentKind.Mod + && instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + throw ControllerException.With(ContentFailureCode.BuildInstalling); + var root = _store.GetInstanceDirectory(instanceId); + ValidateTargetSnapshot(instance, root, target); + operation.SetSnapshot(instance, root); + operation.SetCurrentProject(initialProjectId); + try + { + var result = await _content.WithContentLeaseAsync(instanceId, async (leasedRoot, leasedInstance, token) => + { + operation.CallbackEntered = true; + ValidateTargetSnapshot(leasedInstance, leasedRoot, target); + operation.SetSnapshot(leasedInstance, leasedRoot); + try + { + return await action(operation, leasedRoot, leasedInstance, token).ConfigureAwait(false); + } + finally + { + await RefreshTouchedUnderLeaseAsync(operation).ConfigureAwait(false); + } + }, cancellationToken).ConfigureAwait(false); + + if (result.Failure is not null) + foreach (var stateKey in operation.Keys) ChangeState(stateKey, operation.Revision(stateKey), state => state with { Error = result.Failure }); + else + foreach (var stateKey in operation.Keys) ChangeState(stateKey, operation.Revision(stateKey), state => state with { Error = null }); + return result; + } + catch (OperationCanceledException) + { + if (!operation.CallbackEntered) + await RefreshTouchedOutsideLeaseAsync(operation).ConfigureAwait(false); + throw; + } + catch (Exception exception) + { + var failure = MapFailure(exception); + if (!operation.CallbackEntered) + await RefreshTouchedOutsideLeaseAsync(operation).ConfigureAwait(false); + foreach (var stateKey in operation.Keys) ChangeState(stateKey, operation.Revision(stateKey), state => state with { Error = failure }); + return new ContentActionResult(false, failure); + } + } + catch (OperationCanceledException) + { + foreach (var stateKey in operation.Keys) ChangeState(stateKey, operation.Revision(stateKey), state => state with { Error = Failure(ContentFailureCode.Cancelled) }); + throw; + } + catch (Exception exception) + { + var failure = MapFailure(exception); + ChangeState(key, operation.Revision(key), state => state with { Error = failure }); + return new ContentActionResult(false, failure); + } + finally + { + foreach (var stateKey in operation.Keys) + ClearBusyUnderOperationGate(stateKey); + gate.Release(); + } + } + + private async Task InstallUnderLeaseAsync(OperationContext operation, string root, + GameInstance instance, InstanceContentTarget target, ModrinthProject project, string? channel, + ModrinthVersion? requestedVersion, ModrinthFile? requestedFile, + (InstanceContentItem Item, string Sha512)? replacing, bool interactive, CancellationToken cancellationToken, + ModrinthContentKind? requestedVersionKind = null) + { + var targetKind = target.Kind; + if (targetKind == ModrinthContentKind.Mod && instance.ModLoader != InstanceModLoader.Fabric) + throw ControllerException.With(ContentFailureCode.ModRequiresFabric); + if (channel is not null && channel is not ("release" or "beta" or "alpha")) + throw ControllerException.With(ContentFailureCode.InvalidChannel); + + operation.SetCurrentProject(project.Id); + var version = requestedVersion ?? await _modrinth.GetLatestVersionAsync(project.Id, targetKind, + instance.VersionID, channel, cancellationToken).ConfigureAwait(false); + var file = requestedFile ?? version?.InstallableFile(targetKind); + var versionKind = requestedVersionKind ?? targetKind; + if (version is null || file is null || !file.IsInstallable(targetKind) + || !version.GameVersions.Contains(instance.VersionID, StringComparer.Ordinal) + || !version.Loaders.Contains(Loader(versionKind), StringComparer.Ordinal)) + throw ControllerException.With(ContentFailureCode.NoCompatibleVersion, + ("projectId", project.Id), ("channel", channel ?? "latest")); + + var plan = await BuildDependencyPlanAsync(root, instance, target, project, version, cancellationToken).ConfigureAwait(false); + foreach (var dependency in plan.Downloads) operation.Touch(dependency.Target); + foreach (var enable in plan.Enable) operation.Touch(enable.Target); + if (plan.Listed.Count > 0) + { + if (!interactive) throw ControllerException.With(ContentFailureCode.ConfirmationRequired, ("projectId", project.Id)); + var choice = await ConfirmAsync(new ContentConfirmationRequest(ContentConfirmationCode.Dependencies, + Args(("projectId", project.Id), ("projectTitle", project.Title), ("instanceName", instance.Name)), + plan.Downloads.Count > 0 || plan.Enable.Count > 0, true, false, plan.Listed, Target: target), cancellationToken).ConfigureAwait(false); + if (choice == ContentConfirmationChoice.Cancel) return new ContentActionResult(false); + if (choice == ContentConfirmationChoice.Alternative) + { + plan.Downloads.Clear(); + plan.Enable.Clear(); + } + else if (!plan.Downloads.Any() && !plan.Enable.Any()) + { + return new ContentActionResult(false); + } + } + + plan.Downloads.Add(new PlannedDownload(project, version, file, target, versionKind, IsRoot: true)); + foreach (var datapack in plan.Downloads.Where(download => download.Target.Kind == ModrinthContentKind.Datapack).ToArray()) + { + var options = datapack.Version.ResourcePacks; + if (options.Count == 0) continue; + if (!interactive) + throw ControllerException.With(ContentFailureCode.ConfirmationRequired, ("projectId", datapack.Project.Id)); + var selection = await ConfirmSelectionAsync(new ContentConfirmationRequest( + ContentConfirmationCode.DatapackResourcePacks, + Args(("projectId", datapack.Project.Id), ("projectTitle", datapack.Project.Title), + ("instanceName", instance.Name)), + true, true, false, + [new ContentConfirmationProject(datapack.Project.Id, datapack.Project.Title, datapack.Project.IconUrl)], + options.Select(option => new ContentConfirmationFile(option.Filename, option.FileType, + option.FileType == "required-resource-pack")).ToArray(), datapack.Target), cancellationToken).ConfigureAwait(false); + if (selection.Choice == ContentConfirmationChoice.Cancel) return new ContentActionResult(false); + if (selection.Choice == ContentConfirmationChoice.Alternative) continue; + var selectedFilename = selection.SelectedFilename ?? options[0].Filename; + var selected = options.FirstOrDefault(option => string.Equals(option.Filename, selectedFilename, + StringComparison.Ordinal)); + if (selected is null) + throw ControllerException.With(ContentFailureCode.ConfirmationDeclined, ("filename", selectedFilename)); + var linkedPack = new PlannedDownload(datapack.Project, datapack.Version, selected, + InstanceContentTarget.Packs, ModrinthContentKind.Datapack, IsRoot: false); + var installedPacks = await _content.ListUnderLeaseAsync(root, instance, InstanceContentTarget.Packs, + includeDisabled: true, cancellationToken).ConfigureAwait(false); + InstanceContentItem? matchingPack = null; + foreach (var installedPack in installedPacks) + { + if (!await _content.MatchesSha512UnderLeaseAsync(root, instance, installedPack, + InstanceContentTarget.Packs, selected.Sha512, cancellationToken).ConfigureAwait(false)) continue; + matchingPack = installedPack; + break; + } + + if (matchingPack is null) + { + plan.Downloads.Add(linkedPack); + } + else + { + if (!matchingPack.Enabled) plan.Enable.Add(new PlannedEnable(matchingPack, InstanceContentTarget.Packs)); + } + } + + var plannedTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var download in plan.Downloads) + if (!plannedTargets.Add($"{download.Target.Kind}:{download.Target.WorldId}:{download.File.Filename}")) + throw ControllerException.With(ContentFailureCode.ContentOperationFailed, + ("filename", download.File.Filename)); + foreach (var download in plan.Downloads) operation.Touch(download.Target); + foreach (var enable in plan.Enable) operation.Touch(enable.Target); + + var staging = Path.Combine(Path.GetTempPath(), "Hako-Modrinth-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(staging); + try + { + var staged = new List<(PlannedDownload Download, string Path)>(); + var warnings = new Dictionary(StringComparer.Ordinal); + var configuration = instance.FabricConfiguration; + foreach (var download in plan.Downloads) + { + cancellationToken.ThrowIfCancellationRequested(); + operation.SetCurrentProject(download.Project.Id); + var path = Path.Combine(staging, Guid.NewGuid().ToString("N"), download.File.Filename); + await _modrinth.DownloadFileAsync(download.File, path, cancellationToken).ConfigureAwait(false); + if (download.Target.Kind == ModrinthContentKind.Datapack) + InstanceContent.ValidateDatapackPackage(path, cancellationToken); + if (download.Target.Kind == ModrinthContentKind.Mod && configuration is not null) + { + foreach (var note in await CompatibilityNotesAsync(path, configuration.LoaderVersion, + instance.JavaMajorVersion, cancellationToken).ConfigureAwait(false)) + warnings.TryAdd(download.Project.Id + ":" + note.NoteCode, + new ContentConfirmationProject(download.Project.Id, download.Project.Title, + download.Project.IconUrl, note.NoteCode, note.NoteArguments)); + } + + staged.Add((download, path)); + } + + foreach (var download in plan.Downloads) + { + foreach (var conflictId in await IncompatibleProjectIdsAsync(download.Version, cancellationToken).ConfigureAwait(false)) + { + foreach (var conflictTarget in ConflictTargets(target, instance)) + { + var installed = await InstalledProjectsUnderLeaseAsync(root, instance, conflictTarget, cancellationToken).ConfigureAwait(false); + if (!installed.TryGetValue(conflictId, out var files) || !files.Any(item => item.Enabled)) continue; + var conflict = (await _modrinth.GetProjectsAsync([conflictId], cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(); + warnings.TryAdd("conflict:" + conflictId + ":" + download.Project.Id, + new ContentConfirmationProject(conflictId, conflict?.Title ?? conflictId, + conflict?.IconUrl, ContentConfirmationNoteCode.IncompatibleWith, + Args(("projectTitle", download.Project.Title)))); + } + } + } + + if (warnings.Count > 0) + { + if (!interactive) throw ControllerException.With(ContentFailureCode.ConfirmationRequired, ("projectId", project.Id)); + var choice = await ConfirmAsync(new ContentConfirmationRequest(ContentConfirmationCode.IncompatibleContent, + Args(("projectId", project.Id), ("projectTitle", project.Title), ("instanceName", instance.Name)), + true, false, true, warnings.Values.ToArray(), Target: target), cancellationToken).ConfigureAwait(false); + if (choice != ContentConfirmationChoice.Primary) return new ContentActionResult(false); + } + + if (replacing is { } expected) + await _content.ValidateExpectedHashUnderLeaseAsync(root, instance, expected.Item, target, + expected.Sha512, cancellationToken).ConfigureAwait(false); + + var collisionPlan = new List<(PlannedDownload Download, string Path, InstanceContentItem? Collision)>(); + foreach (var entry in staged) + { + var collision = await PlanCollisionUnderLeaseAsync(root, instance, entry.Download.Target, + entry.Download.File.Filename, entry.Download.Project, + entry.Download.IsRoot ? replacing?.Item : null, interactive, cancellationToken).ConfigureAwait(false); + if (!collision.Proceed) return new ContentActionResult(false, collision.Failure); + + collisionPlan.Add((entry.Download, entry.Path, collision.Item)); + } + + ValidateTargetSnapshot(instance, root, target); + foreach (var (download, path, collision) in collisionPlan) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateStagedFile(staging, path); + ValidateTargetSnapshot(instance, root, download.Target); + operation.Touch(download.Target); + var origin = OriginFor(download); + if (download.IsRoot && replacing is { } replacement) + { + await _content.ReplaceUnderLeaseAsync(root, instance, replacement.Item, download.Target, path, + download.File.Filename, replacement.Sha512, origin, cancellationToken, collision).ConfigureAwait(false); + } + else + { + await _content.ImportUnderLeaseAsync(root, instance, download.Target, path, + replace: collision is not null, origin, cancellationToken).ConfigureAwait(false); + } + } + + foreach (var enable in plan.Enable) + { + var currentItems = await _content.ListUnderLeaseAsync(root, instance, enable.Target, true, cancellationToken).ConfigureAwait(false); + var current = currentItems.FirstOrDefault(item => PathEquals(item.Path, enable.Item.Path)); + if (current is null) throw ControllerException.With(ContentFailureCode.StaleContent); + ValidateTargetSnapshot(instance, root, enable.Target); + operation.Touch(enable.Target); + await Task.Run(() => _content.SetEnabledUnderLease(root, instance, current, enable.Target, true), cancellationToken) + .ConfigureAwait(false); + } + + return new ContentActionResult(true); + } + finally + { + TryDeleteDirectory(staging); + } + } + + private async Task BuildDependencyPlanAsync(string root, GameInstance instance, + InstanceContentTarget rootTarget, ModrinthProject rootProject, ModrinthVersion rootVersion, + CancellationToken cancellationToken) + { + var plan = new DependencyPlan(); + var installed = new Dictionary>>(); + var pending = new Queue<(ModrinthProject Project, ModrinthVersion Version)>(); + var seen = new HashSet(StringComparer.Ordinal) { rootProject.Id }; + pending.Enqueue((rootProject, rootVersion)); + + while (pending.TryDequeue(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + var required = (current.Version.Dependencies ?? []).Where(dependency => dependency.Type == "required").ToArray(); + var resolved = await ResolveDependencyProjectsAsync(required, cancellationToken).ConfigureAwait(false); + foreach (var entry in resolved) + { + if (entry.ProjectId is null) + { + plan.Listed.Add(new ContentConfirmationProject(entry.Dependency.VersionId ?? "missing", + entry.Dependency.VersionId ?? "", null, ContentConfirmationNoteCode.ProjectUnavailable)); + continue; + } + if (!seen.Add(entry.ProjectId)) continue; + if (seen.Count > MaximumDependencyProjects) + { + seen.Remove(entry.ProjectId); + plan.Listed.Add(new ContentConfirmationProject(entry.ProjectId, entry.ProjectId, null, + ContentConfirmationNoteCode.ProjectLimitReached, + Args(("limit", MaximumDependencyProjects.ToString())))); + continue; + } + + var dependency = entry.Project; + if (dependency is null) + { + dependency = (await _modrinth.GetProjectsAsync([entry.ProjectId], cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(); + } + if (dependency is null) + { + plan.Listed.Add(new ContentConfirmationProject(entry.ProjectId, entry.ProjectId, null, + ContentConfirmationNoteCode.ProjectUnavailable)); + continue; + } + + var dependencyResolution = await ResolveDependencyTargetAsync(dependency, rootTarget, instance, + cancellationToken).ConfigureAwait(false); + if (dependencyResolution.Target is null) + { + plan.Listed.Add(ProjectNote(dependency, dependencyResolution.NoteCode ?? ContentConfirmationNoteCode.Unsupported)); + continue; + } + + var dependencyTarget = dependencyResolution.Target; + if (dependencyTarget.Kind == ModrinthContentKind.Mod + && (instance.ModLoader != InstanceModLoader.Fabric + || instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing)) + { + plan.Listed.Add(ProjectNote(dependency, ContentConfirmationNoteCode.RequiresFabric)); + continue; + } + + if (!installed.TryGetValue(dependencyTarget, out var installedProjects)) + { + installedProjects = await InstalledProjectsUnderLeaseAsync(root, instance, dependencyTarget, + cancellationToken).ConfigureAwait(false); + installed[dependencyTarget] = installedProjects; + } + if (installedProjects.TryGetValue(dependency.Id, out var existing)) + { + if (existing.Any(item => item.Enabled)) continue; + var disabled = existing.FirstOrDefault(); + if (disabled is not null) + { + plan.Enable.Add(new PlannedEnable(disabled, dependencyTarget)); + plan.Listed.Add(ProjectNote(dependency, ContentConfirmationNoteCode.DisabledWillBeEnabled)); + continue; + } + } + + var next = dependencyResolution.Version; + var file = dependencyResolution.File; + if (next is null || file is null) + { + plan.Listed.Add(ProjectNote(dependency, ContentConfirmationNoteCode.NoCompatibleVersion)); + continue; + } + + plan.Downloads.Add(new PlannedDownload(dependency, next, file, dependencyTarget, + dependencyTarget.Kind, IsRoot: false)); + plan.Listed.Add(ProjectNote(dependency, null)); + pending.Enqueue((dependency, next)); + } + } + + return plan; + } + + private async Task ResolveDependencyTargetAsync(ModrinthProject project, + InstanceContentTarget rootTarget, GameInstance instance, CancellationToken cancellationToken) + { + var hasDatapackType = project.ProjectType == "datapack" + || project.AllProjectTypes.Contains("datapack", StringComparer.Ordinal); + if (rootTarget.WorldId is not null && (hasDatapackType || project.ProjectType == "mod")) + { + var datapackVersion = await _modrinth.GetLatestVersionAsync(project.Id, ModrinthContentKind.Datapack, + instance.VersionID, cancellationToken: cancellationToken).ConfigureAwait(false); + var datapackFile = datapackVersion?.InstallableFile(ModrinthContentKind.Datapack); + if (datapackVersion is not null && datapackFile is not null) + return new DependencyTargetResolution(InstanceContentTarget.WorldDatapacks(rootTarget.WorldId), + datapackVersion, datapackFile, null); + } + + var kind = project.ProjectType switch + { + "datapack" => (ModrinthContentKind?)ModrinthContentKind.Datapack, + "mod" => ModrinthContentKind.Mod, + "resourcepack" => ModrinthContentKind.ResourcePack, + _ => null + }; + if (kind is null) return new DependencyTargetResolution(null, null, null, ContentConfirmationNoteCode.Unsupported); + if (kind == ModrinthContentKind.Datapack && rootTarget.WorldId is null) + return new DependencyTargetResolution(null, null, null, ContentConfirmationNoteCode.Unsupported); + if (kind == ModrinthContentKind.Mod + && (instance.ModLoader != InstanceModLoader.Fabric + || instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing)) + return new DependencyTargetResolution(null, null, null, ContentConfirmationNoteCode.RequiresFabric); + + var target = kind switch + { + ModrinthContentKind.Mod => InstanceContentTarget.Mods, + ModrinthContentKind.ResourcePack => InstanceContentTarget.Packs, + ModrinthContentKind.Datapack => InstanceContentTarget.WorldDatapacks(rootTarget.WorldId!), + _ => null + }; + if (target is null) return new DependencyTargetResolution(null, null, null, ContentConfirmationNoteCode.Unsupported); + var version = await _modrinth.GetLatestVersionAsync(project.Id, kind.Value, instance.VersionID, + cancellationToken: cancellationToken).ConfigureAwait(false); + var file = version?.InstallableFile(kind.Value); + return version is null || file is null + ? new DependencyTargetResolution(target, null, null, ContentConfirmationNoteCode.NoCompatibleVersion) + : new DependencyTargetResolution(target, version, file, null); + } + + private async Task> ResolveDependencyProjectsAsync( + IReadOnlyList dependencies, CancellationToken cancellationToken) + { + var byProject = new Dictionary(StringComparer.Ordinal); + foreach (var projectId in dependencies.Where(dependency => dependency.ProjectId is not null) + .Select(dependency => dependency.ProjectId!).Distinct(StringComparer.Ordinal)) + byProject[projectId] = null; + + var versionIds = dependencies.Where(dependency => dependency.ProjectId is null && dependency.VersionId is not null) + .Select(dependency => dependency.VersionId!).Distinct(StringComparer.Ordinal).ToArray(); + var versions = versionIds.Length == 0 + ? Array.Empty() + : await _modrinth.GetVersionsAsync(versionIds, cancellationToken).ConfigureAwait(false); + var projectByVersion = versions.ToDictionary(version => version.Id, version => version.ProjectId, StringComparer.Ordinal); + foreach (var projectId in projectByVersion.Values.Distinct(StringComparer.Ordinal)) byProject[projectId] = null; + + var projects = byProject.Count == 0 + ? Array.Empty() + : (await _modrinth.GetProjectsAsync(byProject.Keys.ToArray(), cancellationToken).ConfigureAwait(false)).ToArray(); + foreach (var project in projects) byProject[project.Id] = project; + + return dependencies.Select(dependency => + { + var id = dependency.ProjectId; + if (id is null && dependency.VersionId is not null) projectByVersion.TryGetValue(dependency.VersionId, out id); + return new ResolvedDependency(dependency, id, id is null ? null : byProject.GetValueOrDefault(id)); + }).ToArray(); + } + + private async Task>> InstalledProjectsUnderLeaseAsync( + string root, GameInstance instance, InstanceContentTarget target, CancellationToken cancellationToken) + { + var items = await _content.ListUnderLeaseAsync(root, instance, target, includeDisabled: true, cancellationToken) + .ConfigureAwait(false); + return await GroupInstalledProjectsAsync(items, cancellationToken).ConfigureAwait(false); + } + + private async Task>> GroupInstalledProjectsAsync( + IReadOnlyList items, CancellationToken cancellationToken) + { + var grouped = new Dictionary>(StringComparer.Ordinal); + var unknown = new List(); + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + if (item.Origin is { Source: ModSource.Modrinth } origin) + Add(origin.ProjectID, item); + else if (!item.IsDirectory) + unknown.Add(item); + } + + var matches = await _modrinth.VersionsOfAsync(unknown.Select(item => item.Path).ToArray(), cancellationToken) + .ConfigureAwait(false); + foreach (var item in unknown) + if (matches.TryGetValue(item.Path, out var match)) Add(match.ProjectId, item); + + return new ReadOnlyDictionary>( + grouped.ToDictionary(pair => pair.Key, pair => (IReadOnlyList)pair.Value.ToArray(), StringComparer.Ordinal)); + + void Add(string projectId, InstanceContentItem item) + { + if (!grouped.TryGetValue(projectId, out var files)) grouped[projectId] = files = []; + files.Add(item); + } + } + + private async Task CheckUpdatesCoreAsync(Guid instanceId, InstanceContentTarget target, CancellationToken cancellationToken) + { + ValidateTarget(target); + var kind = target.Kind; + var key = Key(instanceId, target); + var initial = TryBeginRead(key, checkingUpdates: true); + if (initial is null) return; + var revision = initial.Revision; + RaiseStateChanged(key, initial); + if (!IsRevisionCurrent(key, revision)) return; + GameInstance instance; + string root; + try + { + instance = FindInstance(instanceId); + root = _store.GetInstanceDirectory(instanceId); + ValidateTargetSnapshot(instance, root, target); + } + catch (Exception exception) + { + ChangeState(key, revision, state => state with { IsCheckingUpdates = false, Error = MapFailure(exception) }); + return; + } + + if (kind == ModrinthContentKind.Mod && instance.ModLoader != InstanceModLoader.Fabric) + { + ChangeState(key, revision, state => state with + { + IsCheckingUpdates = false, + Updates = EmptyUpdates(), + UpdateMessage = Failure(ContentFailureCode.ModRequiresFabric) + }); + return; + } + + if (kind == ModrinthContentKind.Mod + && instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + { + ChangeState(key, revision, state => state with + { + IsCheckingUpdates = false, + Updates = EmptyUpdates(), + UpdateMessage = Failure(ContentFailureCode.BuildInstalling) + }); + return; + } + + try + { + var (items, registryFailure) = await ListWithRegistryFallbackAsync(instance, target, cancellationToken).ConfigureAwait(false); + var available = new Dictionary(StringComparer.OrdinalIgnoreCase); + ContentFailure? updateMessage = registryFailure; + + if (kind == ModrinthContentKind.Mod && instance.ModLoader == InstanceModLoader.Fabric) + { + foreach (var item in items.Where(item => item.Origin?.Api is not null)) + { + cancellationToken.ThrowIfCancellationRequested(); + ChangeState(key, revision, state => state with { CurrentProjectId = FabricClient.FabricApiProjectId }); + try + { + var next = await _fabric.GetLatestApiAsync(instance.VersionID, cancellationToken).ConfigureAwait(false); + var current = item.Origin!.Api!; + if (next.VersionID == current.VersionID || next.Sha512.Equals(current.Sha512, StringComparison.OrdinalIgnoreCase)) continue; + var cached = await _fabric.DownloadApiAsync(next, cancellationToken).ConfigureAwait(false); + var metadata = await FabricClient.ReadModMetadataAsync(cached, cancellationToken).ConfigureAwait(false); + var configuration = instance.FabricConfiguration; + if (configuration is null || !metadata.Supports(configuration.LoaderVersion, instance.JavaMajorVersion)) + { + updateMessage = Failure(ContentFailureCode.FabricApiIncompatible, + ("version", next.Version), ("loader", configuration?.LoaderVersion ?? ""), + ("java", instance.JavaMajorVersion.ToString())); + continue; + } + + var project = ApiProject(); + available[item.LogicalName] = new InstanceContentUpdate(item, project, null, null, + item.Origin.Sha512, Api: next); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + updateMessage ??= MapFailure(exception); + } + } + } + + var ordinary = items.Where(item => !item.IsDirectory && item.Origin?.Api is null).ToArray(); + if (ordinary.Length > 0) + { + try + { + var matches = await _modrinth.VersionsOfAsync(ordinary.Select(item => item.Path).ToArray(), cancellationToken) + .ConfigureAwait(false); + var matched = ordinary.Where(item => matches.ContainsKey(item.Path)).ToArray(); + var matchValues = matched.Select(item => matches[item.Path]).ToArray(); + var versions = await TryGetVersionsAsync(matchValues.Select(match => match.VersionId), cancellationToken).ConfigureAwait(false); + var projects = await TryGetProjectsAsync(matchValues.Select(match => match.ProjectId), cancellationToken).ConfigureAwait(false); + var versionsById = versions.ToDictionary(version => version.Id, StringComparer.Ordinal); + var projectsById = projects.ToDictionary(project => project.Id, StringComparer.Ordinal); + var fileTypes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in matched) + { + var match = matches[item.Path]; + var oldVersion = versionsById.GetValueOrDefault(match.VersionId); + var oldFileType = item.Origin?.FileType + ?? oldVersion?.Files.FirstOrDefault(file => file.Sha512.Equals(match.Sha512, StringComparison.OrdinalIgnoreCase))?.FileType; + fileTypes[match.Sha512] = oldFileType; + } + + var attached = kind == ModrinthContentKind.ResourcePack + ? matchValues.Where(match => fileTypes.GetValueOrDefault(match.Sha512) is "required-resource-pack" or "optional-resource-pack") + .Select(match => match.Sha512).ToHashSet(StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + var latest = new Dictionary(StringComparer.OrdinalIgnoreCase); + var publishedAfter = matched.GroupBy(item => matches[item.Path].Sha512, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Max(item => matches[item.Path].DatePublished), + StringComparer.OrdinalIgnoreCase); + var regularHashes = matchValues.Select(match => match.Sha512).Where(hash => !attached.Contains(hash)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (regularHashes.Length > 0) + foreach (var pair in await _modrinth.LatestVersionsAfterAsync(regularHashes, kind, instance.VersionID, + publishedAfter, cancellationToken) + .ConfigureAwait(false)) latest[pair.Key] = pair.Value; + if (attached.Count > 0) + foreach (var pair in await _modrinth.LatestVersionsAfterAsync(attached.ToArray(), ModrinthContentKind.Datapack, + instance.VersionID, publishedAfter, cancellationToken).ConfigureAwait(false)) latest[pair.Key] = pair.Value; + + var origins = new List<(InstanceContentItem Item, string Hash, ModOrigin Origin)>(); + foreach (var item in matched) + { + cancellationToken.ThrowIfCancellationRequested(); + var match = matches[item.Path]; + ChangeState(key, revision, state => state with { CurrentProjectId = match.ProjectId }); + var oldVersion = versionsById.GetValueOrDefault(match.VersionId); + var project = projectsById.GetValueOrDefault(match.ProjectId); + var oldFileType = item.Origin?.FileType + ?? oldVersion?.Files.FirstOrDefault(file => file.Sha512.Equals(match.Sha512, StringComparison.OrdinalIgnoreCase))?.FileType; + if (oldVersion is not null && project is not null) + { + var origin = new ModOrigin + { + Source = ModSource.Modrinth, + ProjectID = match.ProjectId, + VersionID = oldVersion.Id, + PageURL = project.PageUrl, + Sha512 = match.Sha512, + VersionNumber = oldVersion.VersionNumber, + IconURL = project.IconUrl, + FileType = oldFileType + }; + origins.Add((item, match.Sha512, origin)); + } + + if (!latest.TryGetValue(match.Sha512, out var next) + || next.ProjectId != match.ProjectId || next.DatePublished <= match.DatePublished) continue; + var updateKind = attached.Contains(match.Sha512) ? ModrinthContentKind.ResourcePack : kind; + var file = next.UpdateFile(updateKind, oldFileType, item.LogicalName); + if (file is null || file.Sha512.Equals(match.Sha512, StringComparison.OrdinalIgnoreCase)) continue; + var updateProject = project ?? ProjectFallback(match.ProjectId, updateKind); + available[item.LogicalName] = new InstanceContentUpdate(item, updateProject, next, file, + match.Sha512, attached.Contains(match.Sha512) ? ModrinthContentKind.Datapack : kind); + } + + if (origins.Count > 0) + { + try + { + await _content.WithContentLeaseAsync(instanceId, async (leasedRoot, leasedInstance, token) => + { + ValidateTargetSnapshot(leasedInstance, leasedRoot, target); + foreach (var origin in origins) + { + token.ThrowIfCancellationRequested(); + await _content.RecordOriginUnderLeaseAsync(leasedRoot, leasedInstance, origin.Item, + target, origin.Hash, origin.Origin, token).ConfigureAwait(false); + } + + return true; + }, cancellationToken).ConfigureAwait(false); + items = await _content.ListAsync(instanceId, target, cancellationToken: cancellationToken).ConfigureAwait(false); + foreach (var name in available.Keys.ToArray()) + { + var updatedItem = items.FirstOrDefault(item => item.LogicalName.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (updatedItem is not null) available[name] = available[name] with { Item = updatedItem }; + } + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + updateMessage ??= MapFailure(exception); + } + } + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + updateMessage ??= MapFailure(exception); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!IsCurrent(key, revision, instance, root, target)) return; + ChangeState(key, revision, state => state with + { + Items = items, + Updates = new ReadOnlyDictionary(available), + UpdateMessage = updateMessage, + Error = registryFailure + }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + ChangeState(key, revision, state => state with { UpdateMessage = MapFailure(exception) }); + } + finally + { + ChangeState(key, revision, state => state with { IsCheckingUpdates = false, CurrentProjectId = null }); + } + } + + private async Task<(bool Proceed, InstanceContentItem? Item, ContentFailure? Failure)> PlanCollisionUnderLeaseAsync( + string root, GameInstance instance, InstanceContentTarget target, string filename, ModrinthProject project, + InstanceContentItem? replacing, bool interactive, CancellationToken cancellationToken) + { + var kind = target.Kind; + var items = await _content.ListUnderLeaseAsync(root, instance, target, true, cancellationToken).ConfigureAwait(false); + var collision = items.FirstOrDefault(item => LogicalName(kind, item).Equals(filename, StringComparison.OrdinalIgnoreCase)); + if (collision is null || (replacing is not null && PathEquals(collision.Path, replacing.Path))) + return (true, null, null); + if (!interactive) throw ControllerException.With(ContentFailureCode.ConfirmationRequired, ("filename", filename)); + var choice = await ConfirmAsync(new ContentConfirmationRequest(ContentConfirmationCode.ReplaceExisting, + Args(("filename", filename), ("instanceName", instance.Name)), true, false, true, + [new ContentConfirmationProject(project.Id, project.Title, project.IconUrl)], Target: target), cancellationToken).ConfigureAwait(false); + return choice == ContentConfirmationChoice.Primary + ? (true, collision, null) + : (false, null, choice == ContentConfirmationChoice.Cancel ? null + : Failure(ContentFailureCode.ConfirmationDeclined, ("filename", filename))); + } + + private async Task ApplyApiUpdateUnderLeaseAsync(string root, GameInstance instance, + InstanceContentUpdate update, bool interactive, CancellationToken cancellationToken) + { + var api = update.Api ?? throw ControllerException.With(ContentFailureCode.StaleContent); + var configuration = instance.FabricConfiguration; + if (configuration is null) throw ControllerException.With(ContentFailureCode.FabricApiIncompatible); + var cached = await _fabric.DownloadApiAsync(api, cancellationToken).ConfigureAwait(false); + var metadata = await FabricClient.ReadModMetadataAsync(cached, cancellationToken).ConfigureAwait(false); + if (!metadata.Supports(configuration.LoaderVersion, instance.JavaMajorVersion)) + throw ControllerException.With(ContentFailureCode.FabricApiIncompatible, + ("version", api.Version), ("loader", configuration.LoaderVersion), ("java", instance.JavaMajorVersion.ToString())); + var staging = Path.Combine(Path.GetTempPath(), "Hako-Modrinth-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(staging); + try + { + var staged = Path.Combine(staging, api.Filename); + await using (var input = new FileStream(cached, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + await using (var output = new FileStream(staged, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + await _content.ValidateExpectedHashUnderLeaseAsync(root, instance, update.Item, ModrinthContentKind.Mod, + update.CurrentSha512, cancellationToken).ConfigureAwait(false); + var collision = await PlanCollisionUnderLeaseAsync(root, instance, InstanceContentTarget.Mods, + api.Filename, update.Project, update.Item, interactive, cancellationToken).ConfigureAwait(false); + if (!collision.Proceed) return new ContentActionResult(false, collision.Failure); + ValidatePathSnapshot(instance, root); + await _content.ReplaceUnderLeaseAsync(root, instance, update.Item, ModrinthContentKind.Mod, staged, + api.Filename, update.CurrentSha512, ModOrigin.FromApi(api), cancellationToken, collision.Item).ConfigureAwait(false); + return new ContentActionResult(true); + } + finally + { + TryDeleteDirectory(staging); + } + } + + private async Task<(IReadOnlyList Items, ContentFailure? Failure)> ListWithRegistryFallbackAsync( + GameInstance instance, InstanceContentTarget target, CancellationToken cancellationToken) + { + try + { + return (await _content.ListAsync(instance.Id, target, cancellationToken: cancellationToken).ConfigureAwait(false), null); + } + catch (Exception exception) when (IsRegistryCorruption(exception)) + { + var items = await _content.ListAsync(instance.Id, target, includeDisabled: true, readOrigins: false, cancellationToken) + .ConfigureAwait(false); + return (items, Failure(ContentFailureCode.RegistryCorrupt)); + } + } + + private async Task RefreshTouchedUnderLeaseAsync(OperationContext operation) + { + foreach (var target in operation.TouchedTargets) + { + var key = Key(operation.InstanceId, target); + try + { + ValidateTargetSnapshot(operation.Instance!, operation.Root!, target); + var (items, failure) = await ListWithRegistryFallbackAsync(operation.Root!, operation.Instance!, target, + CancellationToken.None, underLease: true).ConfigureAwait(false); + ChangeState(key, operation.Revision(key), state => state with + { + Items = items, + Updates = RetainUpdates(state.Updates, items), + Error = failure + }); + } + catch (Exception exception) + { + ChangeState(key, operation.Revision(key), state => state with { Error = MapFailure(exception) }); + } + } + } + + private async Task RefreshTouchedOutsideLeaseAsync(OperationContext operation) + { + if (operation.Instance is null) return; + foreach (var target in operation.TouchedTargets) + { + var key = Key(operation.InstanceId, target); + try + { + var (items, failure) = await ListWithRegistryFallbackAsync(operation.Instance, target, + CancellationToken.None).ConfigureAwait(false); + if (!IsCurrent(key, operation.Revision(key), operation.Instance, operation.Root!, target)) continue; + ChangeState(key, operation.Revision(key), state => state with + { + Items = items, + Updates = RetainUpdates(state.Updates, items), + Error = failure + }); + } + catch (Exception exception) + { + ChangeState(key, operation.Revision(key), state => state with { Error = MapFailure(exception) }); + } + } + } + + private async Task<(IReadOnlyList Items, ContentFailure? Failure)> ListWithRegistryFallbackAsync( + string root, GameInstance instance, InstanceContentTarget target, CancellationToken cancellationToken, + bool underLease = false) + { + try + { + var items = underLease + ? await _content.ListUnderLeaseAsync(root, instance, target, true, cancellationToken).ConfigureAwait(false) + : await _content.ListAsync(instance.Id, target, cancellationToken: cancellationToken).ConfigureAwait(false); + return (items, null); + } + catch (Exception exception) when (IsRegistryCorruption(exception)) + { + var items = underLease + ? await _content.ListUnderLeaseAsync(root, instance, target, true, cancellationToken, readOrigins: false).ConfigureAwait(false) + : await _content.ListAsync(instance.Id, target, true, readOrigins: false, cancellationToken).ConfigureAwait(false); + return (items, Failure(ContentFailureCode.RegistryCorrupt)); + } + } + + private async Task> TryGetVersionsAsync(IEnumerable ids, + CancellationToken cancellationToken) + { + var values = ids.Distinct(StringComparer.Ordinal).ToArray(); + if (values.Length == 0) return []; + try { return await _modrinth.GetVersionsAsync(values, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { throw; } + catch { return []; } + } + + private async Task> TryGetProjectsAsync(IEnumerable ids, + CancellationToken cancellationToken) + { + var values = ids.Distinct(StringComparer.Ordinal).ToArray(); + if (values.Length == 0) return []; + try { return await _modrinth.GetProjectsAsync(values, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { throw; } + catch { return []; } + } + + private async Task> CompatibilityNotesAsync(string jarPath, + string loader, int java, CancellationToken cancellationToken) + { + FabricModMetadata metadata; + try + { + var fileInfo = new FileInfo(jarPath); + if (!fileInfo.Exists || fileInfo.Length == 0) return []; + metadata = await FabricClient.ReadModMetadataAsync(jarPath, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch { return []; } + + var notes = new List(); + if (metadata.Depends?.TryGetValue("fabricloader", out var loaderPredicate) == true + && TryMatches(loaderPredicate, loader, out var loaderMatches) && !loaderMatches) + { + notes.Add(new ContentConfirmationProject(metadata.Id, metadata.Id, null, + ContentConfirmationNoteCode.FabricLoaderRequirement, + Args(("required", string.Join(" | ", loaderPredicate.Alternatives)), ("current", loader)))); + } + + if (java > 0 && metadata.Depends?.TryGetValue("java", out var javaPredicate) == true + && TryMatches(javaPredicate, java.ToString(System.Globalization.CultureInfo.InvariantCulture), out var javaMatches) + && !javaMatches) + { + notes.Add(new ContentConfirmationProject(metadata.Id, metadata.Id, null, + ContentConfirmationNoteCode.JavaRequirement, + Args(("required", string.Join(" | ", javaPredicate.Alternatives)), ("current", java.ToString())))); + } + + return notes; + } + + private static bool TryMatches(FabricVersionPredicate predicate, string version, out bool matches) + { + try { matches = predicate.Matches(version); return true; } + catch { matches = false; return false; } + } + + private async Task> IncompatibleProjectIdsAsync(ModrinthVersion version, + CancellationToken cancellationToken) + { + var incompatible = (version.Dependencies ?? []).Where(dependency => dependency.Type == "incompatible").ToArray(); + var resolved = await ResolveDependencyProjectsAsync(incompatible, cancellationToken).ConfigureAwait(false); + return resolved.Where(item => item.ProjectId is not null).Select(item => item.ProjectId!).Distinct(StringComparer.Ordinal).ToArray(); + } + + private async Task ConfirmAsync(ContentConfirmationRequest request, + CancellationToken cancellationToken) => + (await ConfirmSelectionAsync(request, cancellationToken).ConfigureAwait(false)).Choice; + + private async Task ConfirmSelectionAsync(ContentConfirmationRequest request, + CancellationToken cancellationToken) + { + if (_confirmSelection is null && _confirm is null) + return new ContentConfirmationResult(ContentConfirmationChoice.Cancel); + await _confirmationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_confirmSelection is not null) + return await _confirmSelection(request, cancellationToken).WaitAsync(cancellationToken).ConfigureAwait(false); + return new ContentConfirmationResult( + await _confirm!(request, cancellationToken).WaitAsync(cancellationToken).ConfigureAwait(false)); + } + finally + { + _confirmationGate.Release(); + } + } + + private InstanceContentState? TryBeginRead(InstanceContentStateKey key, bool checkingUpdates) + { + lock (_sync) + { + var previous = _states.GetValueOrDefault(key) ?? new InstanceContentState(); + if (previous.IsBusy || (checkingUpdates && previous.IsCheckingUpdates)) return null; + var revision = _revisions.GetValueOrDefault(key) + 1; + _revisions[key] = revision; + var initial = checkingUpdates + ? previous with { Revision = revision, IsCheckingUpdates = true, IsLoading = false, CurrentProjectId = null, UpdateMessage = null } + : previous with { Revision = revision, IsLoading = true, IsCheckingUpdates = false, CurrentProjectId = null, Error = null }; + _states[key] = initial; + return Snapshot(initial); + } + } + + private void ChangeState(InstanceContentStateKey key, long revision, Func change) + { + InstanceContentState? snapshot = null; + lock (_sync) + { + if (_revisions.GetValueOrDefault(key) != revision) return; + var previous = _states.GetValueOrDefault(key) ?? new InstanceContentState(); + _states[key] = change(previous) with { Revision = revision }; + snapshot = Snapshot(_states[key]); + } + + RaiseStateChanged(key, snapshot); + } + + private void ClearBusyUnderOperationGate(InstanceContentStateKey key) + { + InstanceContentState snapshot; + lock (_sync) + { + // The per-instance operation gate is still held: no other mutation can own busy. + // Read revisions govern publication of read results, never mutation cleanup. + var previous = _states[key]; + _states[key] = previous with { IsBusy = false, BusyOperation = null, CurrentProjectId = null }; + snapshot = Snapshot(_states[key]); + } + + RaiseStateChanged(key, snapshot); + } + + private void SetReadError(Guid instanceId, InstanceContentTarget target, long revision, ContentFailure failure) + { + // A read result belongs to its starting revision and cannot supersede a mutation. + var key = Key(instanceId, target); + ChangeState(key, revision, state => state with { Error = failure }); + } + + private void RaiseStateChanged(InstanceContentStateKey key, InstanceContentState snapshot) + { + var handlers = StateChanged; + if (handlers is null) return; + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try { handler(this, new InstanceContentStateChangedEventArgs(key, snapshot)); } + catch { } + } + } + + private bool IsCurrent(InstanceContentStateKey key, long revision, GameInstance instance, string root, + InstanceContentTarget target) + { + lock (_sync) + if (_revisions.GetValueOrDefault(key) != revision) return false; + try + { + var current = _store.List().FirstOrDefault(candidate => candidate.Id == instance.Id); + if (current is null || current.FolderName != instance.FolderName + || !PathEquals(_store.GetInstanceDirectory(instance.Id), root)) return false; + ValidateTargetSnapshot(current, root, target); + return true; + } + catch (ControllerException exception) when (exception.Failure.Code == ContentFailureCode.WorldUnavailable) + { + ChangeState(key, revision, state => state with + { + Items = [], + Updates = EmptyUpdates(), + Error = exception.Failure, + UpdateMessage = exception.Failure + }); + return false; + } + catch { return false; } + } + + private bool IsRevisionCurrent(InstanceContentStateKey key, long revision) + { + lock (_sync) return _revisions.GetValueOrDefault(key) == revision; + } + + private GameInstance FindInstance(Guid instanceId) => _store.List().FirstOrDefault(instance => instance.Id == instanceId) + ?? throw ControllerException.With(ContentFailureCode.BuildUnavailable); + + private void ValidatePathSnapshot(GameInstance instance, string root) + { + var current = _store.List().FirstOrDefault(candidate => candidate.Id == instance.Id); + if (current is null || !string.Equals(current.FolderName, instance.FolderName, StringComparison.Ordinal) + || !PathEquals(_store.GetInstanceDirectory(instance.Id), root)) + throw ControllerException.With(ContentFailureCode.StaleContent); + _ = InstanceStorage.ContainedPath("minecraft/mods", root, allowBaseReparsePoint: true); + _ = InstanceStorage.ContainedPath(instance.LegacyTexturepacks ? "minecraft/texturepacks" : "minecraft/resourcepacks", + root, allowBaseReparsePoint: true); + } + + private void ValidateTargetSnapshot(GameInstance instance, string root, InstanceContentTarget target) + { + ValidatePathSnapshot(instance, root); + if (target.WorldId is null) return; + try { _ = InstanceWorlds.WorldFolder(target.WorldId, root); } + catch (InstanceWorldException exception) + { + throw ControllerException.With(ContentFailureCode.WorldUnavailable, ("worldId", target.WorldId), + ("diagnostic", exception.Message)); + } + } + + private static IReadOnlyDictionary RetainUpdates( + IReadOnlyDictionary updates, IReadOnlyList items) + { + var retained = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, update) in updates) + { + var current = items.FirstOrDefault(item => !item.IsDirectory && item.LogicalName.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (current is null || !PathEquals(current.Path, update.Item.Path)) continue; + retained[name] = update with { Item = current }; + } + + return new ReadOnlyDictionary(retained); + } + + private static InstanceContentState Snapshot(InstanceContentState state) => state with + { + Items = state.Items.ToArray(), + Updates = new ReadOnlyDictionary( + state.Updates.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase)) + }; + + private static ContentFailure MapFailure(Exception exception) + { + if (exception is ControllerException controller) return controller.Failure; + if (IsRegistryCorruption(exception)) return Failure(ContentFailureCode.RegistryCorrupt); + if (exception is InstanceRegistryException registry) + return Failure(registry.Error == InstanceRegistryError.ReadFailure + ? ContentFailureCode.RegistryUnavailable : ContentFailureCode.RegistryCorrupt); + if (exception is InstanceWorldException world) + return Failure(ContentFailureCode.WorldUnavailable, ("diagnostic", world.Message)); + if (exception is InstanceStorageException storage) + return Failure(storage.Error switch + { + InstanceStorageError.BuildRunning or InstanceStorageError.BuildFilesBusy => ContentFailureCode.BuildBusy, + InstanceStorageError.BuildInstalling or InstanceStorageError.InstanceRegistryNotLoaded => ContentFailureCode.BuildInstalling, + InstanceStorageError.InstanceRegistryUnavailable => ContentFailureCode.RegistryUnavailable, + _ => ContentFailureCode.ContentOperationFailed + }); + if (exception is MojangException) return Failure(ContentFailureCode.ModrinthRequestFailed); + if (exception is HttpRequestException or JsonException) return Failure(ContentFailureCode.ModrinthRequestFailed); + if (exception is IOException) return Failure(ContentFailureCode.ContentOperationFailed, ("diagnostic", exception.Message)); + return Failure(ContentFailureCode.ContentOperationFailed); + } + + private static bool IsRegistryCorruption(Exception exception) => exception is InvalidDataException or JsonException; + + private static void ValidateKind(ModrinthContentKind kind) + { + if (kind is not (ModrinthContentKind.Mod or ModrinthContentKind.ResourcePack)) + throw new NotSupportedException("Datapacks require an explicit world target."); + } + + private static void ValidateTarget(InstanceContentTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (target.Kind == ModrinthContentKind.Datapack) + { + if (target.WorldId is null || !InstanceStorage.IsSinglePathSegment(target.WorldId)) + throw new ArgumentException("Datapacks require a valid world target.", nameof(target)); + } + else if (target.Kind is not (ModrinthContentKind.Mod or ModrinthContentKind.ResourcePack) || target.WorldId is not null) + { + throw new ArgumentException("The content target is invalid.", nameof(target)); + } + } + + private static InstanceContentStateKey Key(Guid instanceId, InstanceContentTarget target) => + new(instanceId, target.Kind, target.WorldId); + + private static IReadOnlyList ConflictTargets(InstanceContentTarget rootTarget, + GameInstance instance) + { + var targets = new List(); + if (instance.ModLoader == InstanceModLoader.Fabric) targets.Add(InstanceContentTarget.Mods); + targets.Add(InstanceContentTarget.Packs); + if (rootTarget.WorldId is not null) + targets.Add(InstanceContentTarget.WorldDatapacks(rootTarget.WorldId)); + return targets; + } + + private static string Loader(ModrinthContentKind kind) => kind switch + { + ModrinthContentKind.Mod => "fabric", + ModrinthContentKind.ResourcePack => "minecraft", + ModrinthContentKind.Datapack => "datapack", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string LogicalName(ModrinthContentKind kind, InstanceContentItem item) => kind == ModrinthContentKind.Mod + ? item.LogicalName + : item.Name; + + private static string LogicalName(ModrinthContentKind kind, string name) => + kind == ModrinthContentKind.Mod && name.EndsWith(".jar.disabled", StringComparison.OrdinalIgnoreCase) + ? name[..^9] : name; + + private static bool PathEquals(string left, string right) => string.Equals(Path.GetFullPath(left), + Path.GetFullPath(right), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static ContentConfirmationProject ProjectNote(ModrinthProject project, ContentConfirmationNoteCode? code) => + new(project.Id, project.Title, project.IconUrl, code); + + private static ModOrigin OriginFor(PlannedDownload download) + { + if (download.Project.Id == FabricClient.FabricApiProjectId) + { + var api = new FabricApiDescriptor(download.Project.Id, download.Version.Id, download.Version.VersionNumber, + download.Version.VersionType, download.File.Filename, download.File.Url.AbsoluteUri, + download.File.Size, download.File.Sha1, download.File.Sha512); + return ModOrigin.FromApi(api); + } + + return new ModOrigin + { + Source = ModSource.Modrinth, + ProjectID = download.Project.Id, + VersionID = download.Version.Id, + PageURL = download.Project.PageUrl, + Sha512 = download.File.Sha512, + VersionNumber = download.Version.VersionNumber, + IconURL = download.Project.IconUrl, + FileType = download.File.FileType + }; + } + + private static ModrinthProject ApiProject() => new() + { + Id = FabricClient.FabricApiProjectId, + Title = "Fabric API", + ProjectType = "mod" + }; + + private static ModrinthProject ProjectFallback(string projectId, ModrinthContentKind kind) => new() + { + Id = projectId, + Title = projectId, + ProjectType = kind == ModrinthContentKind.Mod ? "mod" : "resourcepack" + }; + + private static void ValidateStagedFile(string staging, string path) + { + var full = Path.GetFullPath(path); + var relative = Path.GetRelativePath(staging, full); + if (relative == "." || relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + throw ControllerException.With(ContentFailureCode.StaleContent); + var attributes = File.GetAttributes(full); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw ControllerException.With(ContentFailureCode.StaleContent); + } + + private static void TryDeleteDirectory(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } + catch { } + } + + private static ContentFailure Failure(ContentFailureCode code, params (string Key, string Value)[] values) => + new(code, Args(values.Select(value => (value.Key, (string?)value.Value)).ToArray())); + + private static IReadOnlyDictionary EmptyUpdates() => + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase)); + + private static IReadOnlyDictionary Args(params (string Key, string? Value)[] values) => + new ReadOnlyDictionary(values.Where(value => value.Value is not null) + .ToDictionary(value => value.Key, value => value.Value!, StringComparer.Ordinal)); + + private sealed class OperationContext(InstanceContentController owner, Guid instanceId, + InstanceContentOperation operation, CancellationToken cancellationToken) + { + private readonly Dictionary _revisions = []; + private readonly HashSet _touched = []; + public Guid InstanceId { get; } = instanceId; + public InstanceContentOperation Operation { get; } = operation; + public CancellationToken CancellationToken { get; } = cancellationToken; + public GameInstance? Instance { get; private set; } + public string? Root { get; private set; } + public bool CallbackEntered { get; set; } + public IEnumerable TouchedTargets => _touched; + public IEnumerable Keys => _revisions.Keys.ToArray(); + + public void SetSnapshot(GameInstance instance, string root) + { + Instance = instance; + Root = root; + } + + public void Touch(ModrinthContentKind kind) => Touch(InstanceContentTarget.ForKind(kind)); + + public void Touch(InstanceContentTarget target) + { + if (!_touched.Add(target)) return; + var key = Key(InstanceId, target); + InstanceContentState snapshot; + lock (owner._sync) + { + var revision = owner._revisions.GetValueOrDefault(key) + 1; + owner._revisions[key] = revision; + _revisions[key] = revision; + var state = owner._states.GetValueOrDefault(key) ?? new InstanceContentState(); + owner._states[key] = state with + { + Revision = revision, + IsBusy = true, + BusyOperation = Operation, + IsCheckingUpdates = false, + IsLoading = false, + Updates = EmptyUpdates(), + Error = null, + UpdateMessage = null + }; + snapshot = Snapshot(owner._states[key]); + } + + owner.RaiseStateChanged(key, snapshot); + } + + public long Revision(InstanceContentStateKey key) => _revisions.GetValueOrDefault(key); + + public void SetCurrentProject(string? projectId) + { + foreach (var key in _revisions.Keys.ToArray()) + owner.ChangeState(key, Revision(key), state => state with { CurrentProjectId = projectId }); + } + } + + private sealed class DependencyPlan + { + public List Downloads { get; } = []; + public List Enable { get; } = []; + public List Listed { get; } = []; + } + + private sealed record PlannedDownload(ModrinthProject Project, ModrinthVersion Version, + ModrinthFile File, InstanceContentTarget Target, ModrinthContentKind VersionKind, bool IsRoot); + private sealed record PlannedEnable(InstanceContentItem Item, InstanceContentTarget Target); + private sealed record ResolvedDependency(ModrinthDependency Dependency, string? ProjectId, ModrinthProject? Project); + private sealed record DependencyTargetResolution(InstanceContentTarget? Target, ModrinthVersion? Version, + ModrinthFile? File, ContentConfirmationNoteCode? NoteCode); + + private sealed class ControllerException(ContentFailure failure) : Exception + { + public ContentFailure Failure { get; } = failure; + public static ControllerException With(ContentFailureCode code, params (string Key, string Value)[] arguments) => + new(InstanceContentController.Failure(code, arguments)); + } +} diff --git a/windows/Hako.Core/Instances/InstanceFileTree.cs b/windows/Hako.Core/Instances/InstanceFileTree.cs new file mode 100644 index 0000000..ffc19aa --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceFileTree.cs @@ -0,0 +1,206 @@ +using System.IO.Compression; +using System.Security.Cryptography; + +namespace Hako.Core.Instances; + +internal sealed record InstanceFileTreeEntry( + string RelativePath, + string FullPath, + bool IsDirectory, + FileAttributes Attributes, + DateTime LastWriteUtc, + long Length); + +/// Safe, streaming traversal for copies and ZIPs of build-owned files. +internal static class InstanceFileTree +{ + private const int BufferSize = 81920; + + internal static IReadOnlyList Snapshot(string root, Func? exclude, + CancellationToken cancellationToken) + { + var fullRoot = Path.GetFullPath(root); + var rootAttributes = File.GetAttributes(fullRoot); + if ((rootAttributes & (FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + throw new InstanceFileTreeReparseException("The source folder cannot be a reparse point or device."); + if ((rootAttributes & FileAttributes.Directory) == 0) + throw new IOException("The source folder is not a directory."); + + var entries = new List(); + Walk(fullRoot, fullRoot, "", exclude, entries, cancellationToken); + return entries.OrderBy(entry => entry.RelativePath, StringComparer.Ordinal).ToArray(); + } + + private static void Walk(string root, string directory, string relativeDirectory, + Func? exclude, List entries, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var attributes = File.GetAttributes(directory); + if ((attributes & (FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + throw new InstanceFileTreeReparseException("The source tree contains a reparse point or device."); + if ((attributes & FileAttributes.Directory) == 0) + throw new IOException("The source tree contains a non-directory parent."); + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in Directory.EnumerateFileSystemEntries(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = Path.GetFileName(path); + if (!InstanceStorage.IsSinglePathSegment(name) || !names.Add(name)) + throw new IOException("The source tree contains a path that cannot be represented safely on Windows."); + + var relative = relativeDirectory.Length == 0 ? name : relativeDirectory + "/" + name; + var safePath = InstanceStorage.ContainedPath(relative.Replace('/', Path.DirectorySeparatorChar), root); + var childAttributes = File.GetAttributes(safePath); + if ((childAttributes & (FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + throw new InstanceFileTreeReparseException("The source tree contains a reparse point or device entry."); + if (exclude?.Invoke(relative) == true) continue; + + var isDirectory = (childAttributes & FileAttributes.Directory) != 0; + var info = isDirectory ? null : new FileInfo(safePath); + entries.Add(new InstanceFileTreeEntry(relative, safePath, isDirectory, childAttributes, + isDirectory ? Directory.GetLastWriteTimeUtc(safePath) : info!.LastWriteTimeUtc, + isDirectory ? 0 : info!.Length)); + if (isDirectory) Walk(root, safePath, relative, exclude, entries, cancellationToken); + } + } + + internal static async Task> CopyAsync(string sourceRoot, string destinationRoot, Func? exclude, + CancellationToken cancellationToken) + { + var entries = Snapshot(sourceRoot, exclude, cancellationToken); + var destination = Path.GetFullPath(destinationRoot); + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var target = Path.Combine(destination, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + if (entry.IsDirectory) + { + InstanceStorage.CreateDirectoryExclusiveAt(Path.GetDirectoryName(target)!, Path.GetFileName(target)); + continue; + } + + using (var input = new FileStream(entry.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) + await using (var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await input.CopyToAsync(output, BufferSize, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + var sourceInfo = new FileInfo(entry.FullPath); + if (sourceInfo.Length != entry.Length || sourceInfo.LastWriteTimeUtc != entry.LastWriteUtc) + throw new InstanceFileTreeChangedException($"The source file changed while it was being copied: {entry.RelativePath}"); + File.SetLastWriteTimeUtc(target, entry.LastWriteUtc); + File.SetAttributes(target, entry.Attributes & ~FileAttributes.ReparsePoint); + } + + foreach (var entry in entries.Where(item => item.IsDirectory).OrderByDescending(item => item.RelativePath.Length)) + { + var target = Path.Combine(destination, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.SetLastWriteTimeUtc(target, entry.LastWriteUtc); + File.SetAttributes(target, entry.Attributes & ~FileAttributes.ReparsePoint); + } + + cancellationToken.ThrowIfCancellationRequested(); + EnsureUnchanged(sourceRoot, entries, exclude, cancellationToken); + return entries; + } + + internal static void EnsureUnchanged(string root, IReadOnlyList before, + Func? exclude, CancellationToken cancellationToken) + { + var after = Snapshot(root, exclude, cancellationToken); + if (before.Count != after.Count) + throw new InstanceFileTreeChangedException("The source tree changed during the operation."); + for (var index = 0; index < before.Count; index++) + { + var left = before[index]; + var right = after[index]; + if (!string.Equals(left.RelativePath, right.RelativePath, StringComparison.Ordinal) + || left.IsDirectory != right.IsDirectory || left.Length != right.Length + || left.LastWriteUtc != right.LastWriteUtc) + throw new InstanceFileTreeChangedException("The source tree changed during the operation."); + } + } + + internal static async Task AddToArchiveAsync(ZipArchive archive, IReadOnlyList entries, + string pathPrefix, CancellationToken cancellationToken, Action? beforeEntry = null) + { + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + beforeEntry?.Invoke(); + var name = pathPrefix + entry.RelativePath; + if (entry.IsDirectory) + { + archive.CreateEntry(name.TrimEnd('/') + "/"); + continue; + } + + var zipped = archive.CreateEntry(name, CompressionLevel.Optimal); + using var input = new FileStream(entry.FullPath, FileMode.Open, FileAccess.Read, FileShare.Read, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = zipped.Open(); + await input.CopyToAsync(output, BufferSize, cancellationToken).ConfigureAwait(false); + var info = new FileInfo(entry.FullPath); + if (info.Length != entry.Length || info.LastWriteTimeUtc != entry.LastWriteUtc) + throw new InstanceFileTreeChangedException($"The source file changed while it was being archived: {entry.RelativePath}"); + } + } + + internal static async Task Sha512Async(string path, CancellationToken cancellationToken) + { + await using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[BufferSize]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + hash.AppendData(buffer, 0, read); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + internal static void DeleteOwnedDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + FileAttributes rootAttributes; + try { rootAttributes = File.GetAttributes(fullPath); } + catch (FileNotFoundException) { return; } + catch (DirectoryNotFoundException) { return; } + if ((rootAttributes & FileAttributes.ReparsePoint) != 0) + { + if ((rootAttributes & FileAttributes.Directory) != 0) Directory.Delete(fullPath); + else File.Delete(fullPath); + return; + } + if ((rootAttributes & FileAttributes.Directory) == 0) + throw new IOException("Owned staging path is not a directory."); + + foreach (var entry in Directory.EnumerateFileSystemEntries(fullPath)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if ((attributes & FileAttributes.Directory) != 0) Directory.Delete(entry); + else File.Delete(entry); + } + else if ((attributes & FileAttributes.Directory) != 0) + { + DeleteOwnedDirectory(entry); + } + else + { + File.SetAttributes(entry, FileAttributes.Normal); + File.Delete(entry); + } + } + File.SetAttributes(fullPath, FileAttributes.Directory); + Directory.Delete(fullPath); + } +} diff --git a/windows/Hako.Core/Instances/InstanceJsonConverters.cs b/windows/Hako.Core/Instances/InstanceJsonConverters.cs new file mode 100644 index 0000000..4a35dd0 --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceJsonConverters.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hako.Core.Instances; + +internal sealed class LaunchArgumentSourceJsonConverter : JsonStringEnumConverter +{ + public LaunchArgumentSourceJsonConverter() : base(JsonNamingPolicy.CamelCase) { } +} + +internal sealed class InstanceInstallationStateJsonConverter : JsonStringEnumConverter +{ + public InstanceInstallationStateJsonConverter() : base(JsonNamingPolicy.CamelCase) { } +} + +internal sealed class InstanceModLoaderJsonConverter : JsonStringEnumConverter +{ + public InstanceModLoaderJsonConverter() : base(JsonNamingPolicy.CamelCase) { } +} diff --git a/windows/Hako.Core/Instances/InstanceScreenshots.cs b/windows/Hako.Core/Instances/InstanceScreenshots.cs new file mode 100644 index 0000000..09b4d41 --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceScreenshots.cs @@ -0,0 +1,170 @@ +using System.Runtime.Versioning; +using Hako.Core; + +namespace Hako.Core.Instances; + +public sealed record InstanceScreenshot(string Path, DateTimeOffset? CreatedAt) +{ + public string Name => System.IO.Path.GetFileName(Path); +} + +public sealed record ScreenshotTrashResult(RecycleBinResult Results) +{ + public bool Succeeded => Results.Items.All(item => item.Status == RecycleBinItemStatus.Recycled); + public bool HasFailures => Results.Items.Any(item => item.Status != RecycleBinItemStatus.Recycled); +} + +/// Enumerates and trashes PNG screenshots owned by one registered build. +[SupportedOSPlatform("windows")] +public sealed class InstanceScreenshots +{ + private readonly InstanceStore _store; + private readonly RecycleBin _recycleBin; + + public InstanceScreenshots(InstanceStore store, RecycleBin? recycleBin = null) + { + _store = store; + _recycleBin = recycleBin ?? new RecycleBin(); + } + + public async Task> ListAsync(Guid instanceId, + CancellationToken cancellationToken = default) + { + var root = await Task.Run(() => _store.GetInstanceDirectory(instanceId), cancellationToken) + .ConfigureAwait(false); + return await Task.Run(() => ListAt(root, cancellationToken), cancellationToken).ConfigureAwait(false); + } + + /// Creates the screenshots folder only after an explicit Open Folder request. + public async Task GetOrCreateFolderAsync(Guid instanceId, CancellationToken cancellationToken = default) + { + using var lease = await BeginMutationAsync(instanceId, cancellationToken).ConfigureAwait(false); + return await Task.Run(() => + { + var root = _store.GetInstanceDirectory(instanceId); + var folder = ScreenshotFolder(root, create: true); + return folder; + }, cancellationToken).ConfigureAwait(false); + } + + public async Task TrashAsync(Guid instanceId, IEnumerable screenshots, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(screenshots); + using var lease = await BeginMutationAsync(instanceId, cancellationToken).ConfigureAwait(false); + var root = await Task.Run(() => _store.GetInstanceDirectory(instanceId), cancellationToken) + .ConfigureAwait(false); + var folder = await Task.Run(() => ScreenshotFolder(root, create: false), cancellationToken) + .ConfigureAwait(false); + if (!Directory.Exists(folder)) + return new ScreenshotTrashResult(new RecycleBinResult([])); + + var items = screenshots.ToArray(); + var failures = new Dictionary(); + var paths = new List<(int Index, string Path)>(); + var unique = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < items.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = items[index]; + try + { + var name = System.IO.Path.GetFileName(item.Path); + if (!InstanceStorage.IsSinglePathSegment(name) + || !name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + throw new IOException("The selected file is not a PNG screenshot."); + + var ownedPath = InstanceStorage.ContainedPath(Path.Combine("minecraft", "screenshots", name), root, + allowBaseReparsePoint: true); + if (!PathEquals(item.Path, ownedPath)) + throw new IOException("The selected file does not belong to this build's screenshots folder."); + var attributes = File.GetAttributes(ownedPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device | FileAttributes.Hidden)) != 0) + throw new IOException("The selected screenshot is not a visible regular file."); + if (!unique.Add(ownedPath)) continue; + paths.Add((index, ownedPath)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + failures[index] = new RecycleBinItemResult(item.Path, RecycleBinItemStatus.Failed, exception.Message); + } + } + + var recycled = paths.Count == 0 + ? new RecycleBinResult([]) + : await _recycleBin.DeleteAsync(paths.Select(item => item.Path), cancellationToken).ConfigureAwait(false); + var recycleResults = recycled.Items.ToDictionary(item => item.Path, StringComparer.OrdinalIgnoreCase); + var results = new List(items.Length); + for (var index = 0; index < items.Length; index++) + { + if (failures.TryGetValue(index, out var failure)) + { + results.Add(failure); + continue; + } + + var name = System.IO.Path.GetFileName(items[index].Path); + var path = Path.Combine(folder, name); + if (recycleResults.TryGetValue(Path.GetFullPath(path), out var result)) results.Add(result); + } + return new ScreenshotTrashResult(new RecycleBinResult(results)); + } + + private async Task BeginMutationAsync(Guid instanceId, CancellationToken cancellationToken) + { + var result = await Task.Run(() => _store.TryBeginScreenshotMutation(instanceId, out var lease, out var reason) + ? (lease, reason) + : (null, reason), cancellationToken) + .ConfigureAwait(false); + return result.lease ?? throw new InstanceStorageException(result.reason ?? InstanceStorageError.BuildFilesBusy, + "Screenshots cannot be changed while the build is installing or another file operation is active."); + } + + private static IReadOnlyList ListAt(string root, CancellationToken cancellationToken) + { + var folder = ScreenshotFolder(root, create: false); + if (!Directory.Exists(folder)) return Array.Empty(); + var results = new List(); + foreach (var path in Directory.EnumerateFileSystemEntries(folder)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!System.IO.Path.GetExtension(path).Equals(".png", StringComparison.OrdinalIgnoreCase)) continue; + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device | FileAttributes.Hidden)) != 0) + continue; + + var created = File.GetCreationTimeUtc(path); + var modified = File.GetLastWriteTimeUtc(path); + var date = created.Year <= 1601 ? modified : created; + results.Add(new InstanceScreenshot(Path.GetFullPath(path), new DateTimeOffset(date, TimeSpan.Zero))); + } + + return results.OrderByDescending(item => item.CreatedAt) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.Ordinal) + .ToArray(); + } + + private static string ScreenshotFolder(string instanceRoot, bool create) + { + var minecraft = InstanceStorage.ContainedPath("minecraft", instanceRoot, allowBaseReparsePoint: true); + if (File.Exists(minecraft)) throw new IOException("The minecraft path is not a directory."); + if (!Directory.Exists(minecraft)) return InstanceStorage.ContainedPath("minecraft/screenshots", instanceRoot, allowBaseReparsePoint: true); + + var folder = InstanceStorage.ContainedPath("minecraft/screenshots", instanceRoot, allowBaseReparsePoint: true); + if (File.Exists(folder)) throw new IOException("The screenshots path is not a directory."); + if (Directory.Exists(folder)) + { + var attributes = File.GetAttributes(folder); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != FileAttributes.Directory) + throw new IOException("The screenshots folder is not a regular directory."); + return folder; + } + + if (create) InstanceStorage.CreateDirectoryExclusiveAt(minecraft, "screenshots"); + return folder; + } + + private static bool PathEquals(string left, string right) => + string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); +} diff --git a/windows/Hako.Core/Instances/InstanceStorage.cs b/windows/Hako.Core/Instances/InstanceStorage.cs new file mode 100644 index 0000000..212b91f --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceStorage.cs @@ -0,0 +1,278 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace Hako.Core.Instances; + +public enum InstanceStorageError +{ + OperationFailed, + InvalidPath, + PathTraversal, + ReparsePointNotAllowed, + InvalidBuildName, + ReservedBuildName, + DuplicateBuildName, + BuildFolderExists, + BuildInstalling, + BuildRunning, + BuildFilesBusy, + InstanceRegistryNotLoaded, + InstanceRegistryUnavailable, + InvalidOfflineUsername, + MissingFabricLoaderVersion +} + +public sealed class InstanceStorageException : IOException +{ + public InstanceStorageException(string message) : this(InstanceStorageError.OperationFailed, message) { } + + public InstanceStorageException(InstanceStorageError error, string message, Exception? innerException = null) + : base(message, innerException) => Error = error; + + public InstanceStorageError Error { get; } +} + +public sealed class InstanceStorage(HakoPaths paths) +{ + public const string BackupsFolder = "backups"; + public const string WorldsFolder = "worlds"; + + public string Root { get; } = Path.GetFullPath(paths.GameRoot); + + public string Directory(string folderName) + { + if (!IsSinglePathSegment(folderName)) + { + throw new InstanceStorageException(InstanceStorageError.InvalidPath, "Invalid build folder name."); + } + + return ContainedPath(folderName, Root, allowBaseReparsePoint: true); + } + + public static string ContainedPath(string relativePath, string baseDirectory, bool allowBaseReparsePoint = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + var root = Path.GetFullPath(baseDirectory); + if (Path.IsPathRooted(relativePath) || relativePath.StartsWith('\\') || relativePath.StartsWith('/') + || relativePath.Contains(':') || relativePath.Contains('\0')) + { + throw new InstanceStorageException(InstanceStorageError.InvalidPath, "Absolute or invalid paths are not allowed."); + } + + var parts = relativePath.Split(['\\', '/'], StringSplitOptions.None); + if (parts.Any(part => part.Length == 0 || part is "." or "..")) + { + throw new InstanceStorageException(InstanceStorageError.PathTraversal, "Path traversal is not allowed."); + } + + var target = root; + foreach (var part in parts) + { + target = Path.Combine(target, part); + } + + target = Path.GetFullPath(target); + var relative = Path.GetRelativePath(root, target); + if (relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + throw new InstanceStorageException(InstanceStorageError.PathTraversal, "The path escapes its parent folder."); + } + + EnsureNoReparsePoints(root, parts, checkRoot: !allowBaseReparsePoint); + return target; + } + + public void EnsureAvailable(string folderName, IReadOnlyCollection instances, Guid? excluding = null) + { + if (instances.Any(instance => instance.Id != excluding + && string.Equals(instance.FolderName, folderName, StringComparison.OrdinalIgnoreCase))) + { + throw new InstanceStorageException(InstanceStorageError.DuplicateBuildName, "A build with this name already exists."); + } + + if (!System.IO.Directory.Exists(Root)) + { + return; + } + + var excludedFolder = instances.FirstOrDefault(instance => instance.Id == excluding)?.FolderName; + foreach (var existing in System.IO.Directory.EnumerateFileSystemEntries(Root)) + { + var name = Path.GetFileName(existing); + if (string.Equals(name, folderName, StringComparison.OrdinalIgnoreCase) + && !string.Equals(name, excludedFolder, StringComparison.OrdinalIgnoreCase)) + { + throw new InstanceStorageException(InstanceStorageError.BuildFolderExists, "A build folder with this name already exists."); + } + } + } + + internal void CreateDirectoryExclusive(string path) + { + EnsureNoReparsePoints(Root, [Path.GetFileName(path)], checkRoot: false); + if (!CreateDirectoryW(path, IntPtr.Zero)) + { + var error = Marshal.GetLastWin32Error(); + if (error == 183) + { + throw new IOException("The build folder already exists.", new Win32Exception(error)); + } + + throw new Win32Exception(error); + } + } + + internal static void CreateDirectoryExclusiveAt(string parentDirectory, string name) + { + if (!IsSinglePathSegment(name)) + { + throw new InstanceStorageException(InstanceStorageError.InvalidPath, "Invalid directory name."); + } + + var path = ContainedPath(name, parentDirectory); + if (!CreateDirectoryW(path, IntPtr.Zero)) + { + var error = Marshal.GetLastWin32Error(); + if (error == 183) + { + throw new IOException("The directory already exists.", new Win32Exception(error)); + } + + throw new Win32Exception(error); + } + } + + internal static void MoveDirectory(string from, string to) => System.IO.Directory.Move(from, to); + + internal static bool IsSinglePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value) || value is "." or ".." + || value.Contains('\\') || value.Contains('/') || value.Contains(':') || value.Contains('\0') + || value.EndsWith(' ') || value.EndsWith('.') || Path.IsPathRooted(value) + || value.Any(character => character < 32 || "<>\"|?*".Contains(character))) + { + return false; + } + + var stem = value.Split('.')[0]; + return !stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + && !stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + && !stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + && !stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + && !(stem.Length == 4 && (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + || stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) && stem[3] is >= '1' and <= '9'); + } + + internal static void DeleteCreatedDirectory(string path) + { + DeleteEntryWithoutFollowingReparsePoints(path); + } + + internal static void DeleteEntryWithoutFollowingReparsePoints(string path) + { + var root = Path.GetFullPath(path); + DeleteEntry(root, root); + } + + private static void DeleteEntry(string path, string root) + { + var fullPath = Path.GetFullPath(path); + var relative = Path.GetRelativePath(root, fullPath); + if (relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + throw new InstanceStorageException(InstanceStorageError.PathTraversal, "The path escapes its deletion root."); + } + + if (!TryGetAttributes(fullPath, out var attributes)) + { + return; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if ((attributes & FileAttributes.Directory) != 0) + { + System.IO.Directory.Delete(fullPath); + } + else + { + File.Delete(fullPath); + } + + return; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + foreach (var child in System.IO.Directory.EnumerateFileSystemEntries(fullPath)) + { + DeleteEntry(child, root); + } + + System.IO.Directory.Delete(fullPath); + } + else + { + File.Delete(fullPath); + } + } + + private static void EnsureNoReparsePoints(string root, IReadOnlyList children, bool checkRoot) + { + var fullRoot = Path.GetFullPath(root); + if (checkRoot) + { + CheckReparsePoint(fullRoot); + } + + var current = fullRoot; + foreach (var child in children) + { + current = Path.Combine(current, child); + if (!TryGetAttributes(current, out var attributes)) + { + break; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceStorageException(InstanceStorageError.ReparsePointNotAllowed, + "Reparse points and symbolic links are not allowed in build paths."); + } + } + } + + private static void CheckReparsePoint(string path) + { + if (TryGetAttributes(path, out var attributes) && (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceStorageException(InstanceStorageError.ReparsePointNotAllowed, + "Reparse points and symbolic links are not allowed in build paths."); + } + } + + private static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) + { + attributes = default; + return false; + } + catch (DirectoryNotFoundException) + { + attributes = default; + return false; + } + } + + [DllImport("kernel32.dll", EntryPoint = "CreateDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); +} diff --git a/windows/Hako.Core/Instances/InstanceStore.cs b/windows/Hako.Core/Instances/InstanceStore.cs new file mode 100644 index 0000000..5247ae4 --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceStore.cs @@ -0,0 +1,1571 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Minecraft; + +namespace Hako.Core.Instances; + +public enum InstanceRegistryError +{ + Corrupt, + UnsupportedVersion, + ReadFailure +} + +public sealed class InstanceRegistryException : IOException +{ + public InstanceRegistryException(string message) : this(InstanceRegistryError.Corrupt, message) { } + + public InstanceRegistryException(string message, Exception innerException) + : this(InstanceRegistryError.Corrupt, message, innerException) { } + + public InstanceRegistryException(InstanceRegistryError error, string message, Exception? innerException = null) + : base(message, innerException) => Error = error; + + public InstanceRegistryError Error { get; } + public string ResourceKey => Error switch + { + InstanceRegistryError.Corrupt or InstanceRegistryError.UnsupportedVersion => "InstanceRegistryDamaged", + InstanceRegistryError.ReadFailure => "InstanceRegistryUnavailable", + _ => throw new ArgumentOutOfRangeException(nameof(Error), Error, null) + }; +} + +public sealed record InstanceRegistryFile(int FormatVersion, List Instances); + +public sealed class InstanceStore : INotifyPropertyChanged, IAsyncDisposable +{ + private static readonly HashSet ReservedNames = CreateReservedNames(); + private readonly InstanceStorage _storage; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Action? _beforeRegistryCommit; + private readonly Action? _beforeDeleteStagingCleanup; + private readonly object _deleteStagingSync = new(); + private readonly object _deleteStagingTasksSync = new(); + private readonly object _snapshotSync = new(); + private IReadOnlyList _instances = Array.AsReadOnly(Array.Empty()); + private InstanceRegistryException? _loadError; + private Task? _deleteStagingCleanupTask; + private Task? _disposeTask; + private bool _isLoaded; + // Written under _gate, read lock-free so UI-thread checks never wait for a long operation holding _gate. + private ImmutableHashSet _launchBusy = ImmutableHashSet.Empty; + private ImmutableHashSet _contentBusy = ImmutableHashSet.Empty; + + public InstanceStore(HakoPaths paths, Action? beforeRegistryCommit = null) + : this(paths, beforeRegistryCommit, null) + { + } + + internal InstanceStore(HakoPaths paths, Action? beforeRegistryCommit, Action? beforeDeleteStagingCleanup) + { + _storage = new InstanceStorage(paths); + _beforeRegistryCommit = beforeRegistryCommit; + _beforeDeleteStagingCleanup = beforeDeleteStagingCleanup; + } + + internal Task? DeleteStagingCleanupTask => Volatile.Read(ref _deleteStagingCleanupTask); + + public ValueTask DisposeAsync() + { + lock (_deleteStagingTasksSync) + { + _disposeTask ??= _deleteStagingCleanupTask ?? Task.CompletedTask; + return new ValueTask(_disposeTask); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? InstancesChanged; + public event EventHandler? BusyStateChanged; + + public IReadOnlyList Instances + { + get + { + lock (_snapshotSync) + { + return _instances; + } + } + } + + public InstanceRegistryException? LoadError + { + get + { + lock (_snapshotSync) + { + return _loadError; + } + } + } + + public bool IsLoaded + { + get + { + lock (_snapshotSync) + { + return _isLoaded; + } + } + } + + public IReadOnlyList List() => Instances; + + /// Absolute folder that contains the build folders. + public string GameRoot => _storage.Root; + + public string GetInstanceDirectory(Guid id) + { + _gate.Wait(); + try + { + return _storage.Directory(Find(GetRegistrySnapshot(), id).FolderName); + } + finally + { + _gate.Release(); + } + } + + public bool IsLaunchBusy(Guid id) => Volatile.Read(ref _launchBusy).Contains(id); + + public bool IsContentBusy(Guid id) => Volatile.Read(ref _contentBusy).Contains(id); + + public bool TryBeginLaunch(Guid id, out IDisposable? lease, out InstanceStorageError? reason) + { + bool changed; + _gate.Wait(); + try + { + if (!TryGetRegistrySnapshot(out var registry, out reason)) + { + lease = null; + return false; + } + + var instance = Find(registry, id); + reason = GetManagementBlock(instance); + if (reason is not null) + { + lease = null; + return false; + } + + changed = AddBusy(ref _launchBusy, id); + lease = new BusyLease(() => EndBusy(id, launch: true)); + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + + reason = null; + return true; + } + + public bool TryBeginContent(Guid id, out IDisposable? lease, out InstanceStorageError? reason) + { + bool changed; + _gate.Wait(); + try + { + if (!TryGetRegistrySnapshot(out var registry, out reason)) + { + lease = null; + return false; + } + + var instance = Find(registry, id); + reason = GetManagementBlock(instance); + if (reason is not null) + { + lease = null; + return false; + } + + changed = AddBusy(ref _contentBusy, id); + lease = new BusyLease(() => EndBusy(id, launch: false)); + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + + reason = null; + return true; + } + + /// Locks screenshots against other file operations while preserving macOS behavior during gameplay. + internal bool TryBeginScreenshotMutation(Guid id, out IDisposable? lease, out InstanceStorageError? reason) + { + bool changed; + _gate.Wait(); + try + { + if (!TryGetRegistrySnapshot(out var registry, out reason)) + { + lease = null; + return false; + } + + var instance = Find(registry, id); + if (instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + { + lease = null; + reason = InstanceStorageError.BuildInstalling; + return false; + } + + if (_contentBusy.Contains(id)) + { + lease = null; + reason = InstanceStorageError.BuildFilesBusy; + return false; + } + + changed = AddBusy(ref _contentBusy, id); + lease = new BusyLease(() => EndBusy(id, launch: false)); + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + + reason = null; + return true; + } + + internal bool TryBeginInstallation(Guid id, out IDisposable? lease, out InstanceStorageError? reason) + { + bool changed; + _gate.Wait(); + try + { + if (!TryGetRegistrySnapshot(out var registry, out reason)) + { + lease = null; + return false; + } + + var instance = Find(registry, id); + if (instance.State != InstanceInstallationState.Queued) + { + lease = null; + reason = InstanceStorageError.BuildInstalling; + return false; + } + + if (_launchBusy.Contains(id)) + { + lease = null; + reason = InstanceStorageError.BuildRunning; + return false; + } + + if (_contentBusy.Contains(id)) + { + lease = null; + reason = InstanceStorageError.BuildFilesBusy; + return false; + } + + changed = AddBusy(ref _contentBusy, id); + lease = new BusyLease(() => EndBusy(id, launch: false)); + reason = null; + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + + return true; + } + + private static bool AddBusy(ref ImmutableHashSet busy, Guid id) + { + var updated = busy.Add(id); + var changed = !ReferenceEquals(updated, busy); + Volatile.Write(ref busy, updated); + return changed; + } + + private void EndBusy(Guid id, bool launch) + { + _gate.Wait(); + bool changed; + try + { + ref var busy = ref launch ? ref _launchBusy : ref _contentBusy; + var updated = busy.Remove(id); + changed = !ReferenceEquals(updated, busy); + Volatile.Write(ref busy, updated); + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + var cleanupStaging = false; + await _gate.WaitAsync(cancellationToken); + try + { + try + { + var registry = await ReadRegistryAsync(cancellationToken); + lock (_snapshotSync) + { + _instances = Array.AsReadOnly(registry.Instances.ToArray()); + _loadError = null; + _isLoaded = true; + } + cleanupStaging = true; + } + catch (OperationCanceledException) + { + throw; + } + catch (InstanceRegistryException exception) + { + lock (_snapshotSync) + { + _instances = Array.AsReadOnly(Array.Empty()); + _loadError = exception; + _isLoaded = true; + } + } + + NotifyInstancesChanged(); + } + finally + { + _gate.Release(); + } + + if (cleanupStaging) + { + ScheduleDeleteStagingCleanup(() => + { + _beforeDeleteStagingCleanup?.Invoke(); + CleanupDeleteStaging(); + }); + } + } + + private void ScheduleDeleteStagingCleanup(Action cleanup) + { + lock (_deleteStagingTasksSync) + { + if (_disposeTask is not null) + { + return; + } + + var previous = _deleteStagingCleanupTask ?? Task.CompletedTask; + var cleanupTask = Task.Run(async () => + { + try + { + await previous.ConfigureAwait(false); + cleanup(); + } + catch (Exception exception) + { + Debug.WriteLine($"Could not clean instance delete staging; it will be retried on the next load: {exception}"); + } + }); + Volatile.Write(ref _deleteStagingCleanupTask, cleanupTask); + } + } + + public string ValidateName(string input, Guid? excluding = null) + { + _gate.Wait(); + try + { + var (name, folderName) = ParseName(input); + ValidateReservedName(folderName); + var instances = GetRegistrySnapshot().Instances; + _storage.EnsureAvailable(folderName, instances, excluding); + return folderName; + } + finally + { + _gate.Release(); + } + } + + public GameInstance Create( + InstanceDraft draft, + string versionID, + string metadataURL = "", + string metadataSHA1 = "", + bool legacyTexturepacks = false, + int javaMajorVersion = 0, + ModpackPlan? modpackPlan = null) + { + ArgumentNullException.ThrowIfNull(draft); + if (modpackPlan is not null) + { + modpackPlan.Validate(); + var loader = modpackPlan.Manifest.LoaderVersion; + var matchesLoader = loader is null + ? draft.ModLoader == InstanceModLoader.Vanilla && draft.FabricConfiguration is null + : draft.ModLoader == InstanceModLoader.Fabric + && draft.FabricConfiguration is { Api: null } fabric + && string.Equals(fabric.LoaderVersion, loader, StringComparison.Ordinal); + if (!string.Equals(versionID, modpackPlan.Manifest.Minecraft, StringComparison.Ordinal) || !matchesLoader) + { + throw new InstanceStorageException(InstanceStorageError.OperationFailed, + "The modpack plan does not match the selected Minecraft version and loader."); + } + } + + _gate.Wait(); + try + { + ValidateOffline(draft); + var (name, folderName) = ParseName(draft.Name); + ValidateReservedName(folderName); + var registry = GetRegistrySnapshot(); + _storage.EnsureAvailable(folderName, registry.Instances); + + System.IO.Directory.CreateDirectory(_storage.Root); + _ = InstanceStorage.ContainedPath(".hako-lock", _storage.Root, allowBaseReparsePoint: true); + var directory = _storage.Directory(folderName); + _storage.CreateDirectoryExclusive(directory); + + GameInstance instance; + try + { + System.IO.Directory.CreateDirectory(Path.Combine(directory, "java")); + System.IO.Directory.CreateDirectory(Path.Combine(directory, "minecraft")); + WriteIconIfProvided(directory, draft.IconData); + + instance = new GameInstance + { + Name = name, + FolderName = folderName, + VersionID = versionID, + MetadataURL = metadataURL, + MetadataSHA1 = metadataSHA1, + IconSymbol = draft.IconSymbol, + ArgumentSource = draft.ArgumentSource, + OfflineMode = draft.OfflineMode, + OfflineUsername = draft.OfflineUsername, + Parameters = NormalizeParameters(draft.Parameters), + JavaMajorVersion = javaMajorVersion > 0 ? javaMajorVersion : draft.JavaMajorVersion, + LegacyTexturepacks = legacyTexturepacks || draft.LegacyTexturepacks, + ModLoader = draft.ModLoader, + FabricConfiguration = draft.ModLoader == InstanceModLoader.Fabric ? draft.FabricConfiguration : null, + ModpackVersionID = modpackPlan?.VersionId, + Architecture = draft.Architecture + }; + if (instance.ModLoader == InstanceModLoader.Fabric && string.IsNullOrWhiteSpace(instance.FabricConfiguration?.LoaderVersion)) + { + throw new InstanceStorageException(InstanceStorageError.MissingFabricLoaderVersion, + "Choose a Fabric Loader version before creating the build."); + } + + modpackPlan?.Write(directory); + var updated = registry.Instances.Append(instance).ToList(); + WriteRegistry(new InstanceRegistryFile(1, updated)); + } + catch (Exception operationFailure) + { + RollbackAndThrow(operationFailure, () => InstanceStorage.DeleteCreatedDirectory(directory)); + throw; + } + NotifyInstancesChanged(); + return instance; + } + finally + { + _gate.Release(); + } + } + + /// Creates a queued build from a version that was already verified by MojangClient.PrepareAsync. + public GameInstance Create(InstanceDraft draft, PreparedInstallation prepared, bool legacyTexturepacks = false, + ModpackPlan? modpackPlan = null) + { + ArgumentNullException.ThrowIfNull(prepared); + if (!string.Equals(prepared.Version.Id, prepared.Manifest.Id, StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(prepared.Version.Url) + || string.IsNullOrWhiteSpace(prepared.Version.Sha1) + || !Enum.IsDefined(prepared.Architecture)) + { + throw new InstanceStorageException(InstanceStorageError.OperationFailed, + "The prepared Minecraft version is incomplete."); + } + + return Create( + draft with { Architecture = prepared.Architecture.ToString() }, + prepared.Version.Id, + prepared.Version.Url, + prepared.Version.Sha1, + legacyTexturepacks, + prepared.Manifest.Java.MajorVersion, + modpackPlan); + } + + public void SetInstallationStatus( + Guid id, + InstanceInstallationState state, + bool pauseRequested, + string? errorCode = null, + string? errorMessage = null) + { + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + var updated = current with + { + State = state, + PauseRequested = pauseRequested, + ErrorCode = errorCode, + ErrorMessage = errorMessage + }; + WriteRegistry(registry with + { + Instances = registry.Instances.Select(instance => instance.Id == id ? updated : instance).ToList() + }); + NotifyInstancesChanged(); + } + finally + { + _gate.Release(); + } + } + + public void PinArchitecture(Guid id, GameArchitecture architecture) + { + if (!Enum.IsDefined(architecture)) + { + throw new ArgumentOutOfRangeException(nameof(architecture)); + } + + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + var architectureName = architecture.ToString(); + if (current.Architecture is { } pinned + && !string.Equals(pinned, architectureName, StringComparison.Ordinal)) + { + throw new InstanceStorageException(InstanceStorageError.OperationFailed, + "The build architecture has already been pinned."); + } + + if (current.Architecture is not null) + { + return; + } + + var updated = current with { Architecture = architectureName }; + WriteRegistry(registry with + { + Instances = registry.Instances.Select(instance => instance.Id == id ? updated : instance).ToList() + }); + NotifyInstancesChanged(); + } + finally + { + _gate.Release(); + } + } + + public void CompleteInstallation(Guid id, InstallationResult result) + { + ArgumentNullException.ThrowIfNull(result); + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + if (current.PauseRequested) + { + throw new OperationCanceledException("The installation was paused."); + } + + var expectedArchitecture = current.Architecture; + if (!string.Equals(expectedArchitecture, result.Architecture.ToString(), StringComparison.Ordinal)) + { + throw new InstanceStorageException(InstanceStorageError.OperationFailed, + "The installer returned a different architecture than the build's pinned architecture."); + } + + var updated = current with + { + State = InstanceInstallationState.Ready, + PauseRequested = false, + ErrorCode = null, + ErrorMessage = null, + JavaExecutable = result.JavaExecutable, + JavaMajorVersion = result.JavaMajorVersion, + LegacyTexturepacks = result.LegacyTexturepacks, + FabricProfileSHA1 = result.FabricProfileSHA1 + }; + WriteRegistry(registry with + { + Instances = registry.Instances.Select(instance => instance.Id == id ? updated : instance).ToList() + }); + NotifyInstancesChanged(); + } + finally + { + _gate.Release(); + } + } + + public void Rename(Guid id, string input) + { + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + ThrowIfManagementBlocked(current); + + var (name, folderName) = ParseName(input); + ValidateReservedName(folderName); + _storage.EnsureAvailable(folderName, registry.Instances, id); + var updated = current with { Name = name, FolderName = folderName }; + UpdateCore(registry, current, updated, null, preserveIcon: true); + } + finally + { + _gate.Release(); + } + } + + public void UpdateSettings(Guid id, InstanceDraft draft) + { + ArgumentNullException.ThrowIfNull(draft); + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + // Like macOS, a running game doesn't block profile settings: they apply on the next launch. + if (GetManagementBlock(current) is { } error and not InstanceStorageError.BuildRunning) + { + throw new InstanceStorageException(error, ManagementMessage(error)); + } + + ValidateOffline(draft); + var updated = ApplyDraft(current, draft, includeName: false); + UpdateCore(registry, current, updated, draft.IconData); + } + finally + { + _gate.Release(); + } + } + + public void Update(Guid id, InstanceDraft draft) + { + ArgumentNullException.ThrowIfNull(draft); + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + ThrowIfManagementBlocked(current); + ValidateOffline(draft); + var (name, folderName) = ParseName(draft.Name); + ValidateReservedName(folderName); + _storage.EnsureAvailable(folderName, registry.Instances, id); + var updated = ApplyDraft(current, draft, includeName: false) with + { + Name = name, + FolderName = folderName, + IconRevision = Guid.NewGuid() + }; + UpdateCore(registry, current, updated, draft.IconData); + } + finally + { + _gate.Release(); + } + } + + public string? ManagementBlockedReason(GameInstance instance) => + GetManagementBlock(instance) is { } error ? ManagementMessage(error) : null; + + /// The reason rename and delete are blocked, for localized UI text; does not wait for running store operations. + public InstanceStorageError? ManagementBlockedError(GameInstance instance) => GetManagementBlock(instance); + + /// Copies a ready build into an independently registered folder while holding its content lease. + public async Task DuplicateAsync(Guid id, CancellationToken cancellationToken = default) + { + var leaseResult = await Task.Run(() => TryBeginContent(id, out var lease, out var reason) + ? (lease, reason) + : (null, reason), cancellationToken) + .ConfigureAwait(false); + using var lease = leaseResult.lease + ?? throw new InstanceStorageException(leaseResult.reason ?? InstanceStorageError.BuildFilesBusy, + "The build cannot be duplicated while it is installing, running, or busy."); + + var sourceInstance = Instances.FirstOrDefault(instance => instance.Id == id) + ?? throw new InstanceStorageException(InstanceStorageError.InstanceRegistryUnavailable, + "The build no longer exists."); + if (sourceInstance.State != InstanceInstallationState.Ready) + throw new InstanceStorageException(InstanceStorageError.BuildInstalling, + "Wait for the build installation to finish before duplicating it."); + + var source = await Task.Run(() => GetInstanceDirectory(id), cancellationToken).ConfigureAwait(false); + var stageName = ".hako-duplicate-" + Guid.NewGuid().ToString("N"); + var stage = InstanceStorage.ContainedPath(stageName, _storage.Root, allowBaseReparsePoint: true); + var ownsStage = false; + var published = false; + try + { + InstanceStorage.CreateDirectoryExclusiveAt(_storage.Root, stageName); + ownsStage = true; + File.SetAttributes(stage, FileAttributes.Directory | FileAttributes.Hidden); + var sourceEntries = await Task.Run(async () => await InstanceFileTree.CopyAsync(source, stage, + IsTransientCopyPath, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + if (current != sourceInstance || current.State != InstanceInstallationState.Ready) + throw new InstanceStorageException(InstanceStorageError.BuildFilesBusy, + "The build profile changed while it was being duplicated."); + + InstanceFileTree.EnsureUnchanged(_storage.Directory(current.FolderName), sourceEntries, + IsTransientCopyPath, cancellationToken); + for (var number = 2; number <= 999; number++) + { + cancellationToken.ThrowIfCancellationRequested(); + var suffix = " " + number; + var name = current.Name[..Math.Min(current.Name.Length, 60 - suffix.Length)].TrimEnd() + suffix; + var (validatedName, folderName) = ParseName(name); + try + { + ValidateReservedName(folderName); + _storage.EnsureAvailable(folderName, registry.Instances); + } + catch (InstanceStorageException exception) when (exception.Error is + InstanceStorageError.DuplicateBuildName or InstanceStorageError.BuildFolderExists) + { + continue; + } + + var destination = _storage.Directory(folderName); + try + { + Directory.Move(stage, destination); + } + catch (IOException) when (EntryExists(destination)) + { + continue; + } + + try + { + File.SetAttributes(destination, FileAttributes.Directory); + } + catch (Exception operationFailure) + { + RollbackAndThrow(operationFailure, () => InstanceFileTree.DeleteOwnedDirectory(destination)); + throw; + } + + published = true; + var duplicate = current with + { + Id = Guid.NewGuid(), + Name = validatedName, + FolderName = folderName, + CreatedAt = DateTimeOffset.UtcNow, + IconRevision = Guid.NewGuid(), + State = InstanceInstallationState.Ready, + PauseRequested = false, + ErrorCode = null, + ErrorMessage = null + }; + try + { + WriteRegistry(new InstanceRegistryFile(1, registry.Instances.Append(duplicate).ToList())); + } + catch (Exception operationFailure) + { + RollbackAndThrow(operationFailure, () => InstanceFileTree.DeleteOwnedDirectory(destination)); + throw; + } + + NotifyInstancesChanged(); + return duplicate; + } + + throw new InstanceStorageException(InstanceStorageError.DuplicateBuildName, + "No available name could be chosen for the duplicate build."); + } + finally + { + _gate.Release(); + } + } + catch (InstanceFileTreeReparseException exception) + { + throw new InstanceStorageException(InstanceStorageError.ReparsePointNotAllowed, + exception.Message, exception); + } + finally + { + if (ownsStage && !published && Directory.Exists(stage)) + { + try { InstanceFileTree.DeleteOwnedDirectory(stage); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Debug.WriteLine($"Could not remove duplicate staging folder '{stage}': {exception.Message}"); + } + } + } + } + + private static bool IsTransientCopyPath(string path) => + path.Equals(".hako-game", StringComparison.OrdinalIgnoreCase) + || path.StartsWith(".hako-game/", StringComparison.OrdinalIgnoreCase) + || path.Equals("minecraft/.hako-running.json", StringComparison.OrdinalIgnoreCase) + || path.Equals("minecraft/.hako-launch", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("minecraft/.hako-launch/", StringComparison.OrdinalIgnoreCase) + || path.Equals("minecraft/logs/hako-launch.log", StringComparison.OrdinalIgnoreCase); + + private static bool EntryExists(string path) + { + try { _ = File.GetAttributes(path); return true; } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + + public void Delete(Guid id) + { + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var current = Find(registry, id); + ThrowIfManagementBlocked(current); + + var directory = _storage.Directory(current.FolderName); + if (File.Exists(directory)) + { + throw new InstanceStorageException(InstanceStorageError.BuildFolderExists, "The build folder is not a directory."); + } + + var removed = InstanceStorage.ContainedPath($".delete-{id:N}-{Guid.NewGuid():N}", _storage.Root, allowBaseReparsePoint: true); + var exists = System.IO.Directory.Exists(directory); + if (exists) + { + if (System.IO.Directory.Exists(removed) || File.Exists(removed)) + { + throw new InstanceStorageException(InstanceStorageError.BuildFolderExists, + "The temporary delete folder already exists."); + } + + InstanceStorage.MoveDirectory(directory, removed); + } + + try + { + WriteRegistry(new InstanceRegistryFile(1, registry.Instances.Where(item => item.Id != id).ToList())); + } + catch (Exception operationFailure) + { + RollbackAndThrow(operationFailure, () => + { + if (exists && System.IO.Directory.Exists(removed)) + { + InstanceStorage.MoveDirectory(removed, directory); + } + }); + } + + if (exists) + { + ScheduleDeleteStagingCleanup(() => TryDeleteStaging(removed)); + } + + NotifyInstancesChanged(); + } + finally + { + _gate.Release(); + } + } + + private void UpdateCore(InstanceRegistryFile registry, GameInstance current, GameInstance updated, byte[]? iconData, bool preserveIcon = false) + { + if (current.FolderName != updated.FolderName && GetManagementBlock(current) is { } error) + { + throw new InstanceStorageException(error, ManagementMessage(error)); + } + + var oldDirectory = _storage.Directory(current.FolderName); + var newDirectory = _storage.Directory(updated.FolderName); + var oldIcon = preserveIcon ? null : ReadIcon(oldDirectory); + var moved = false; + try + { + if (current.FolderName != updated.FolderName) + { + MoveDirectoryForRename(oldDirectory, newDirectory); + moved = true; + } + + if (!preserveIcon && iconData is not null) + { + WriteIcon(newDirectory, iconData); + } + else if (!preserveIcon && !string.IsNullOrEmpty(updated.IconSymbol)) + { + File.Delete(InstanceStorage.ContainedPath("icon.png", newDirectory)); + } + + var instances = registry.Instances.Select(item => item.Id == current.Id ? updated : item).ToList(); + WriteRegistry(new InstanceRegistryFile(1, instances)); + } + catch (Exception operationFailure) + { + var rollbackActions = new List(); + if (moved) + { + rollbackActions.Add(() => + { + if (System.IO.Directory.Exists(newDirectory)) + { + MoveDirectoryForRename(newDirectory, oldDirectory); + } + }); + } + + if (!preserveIcon) + { + rollbackActions.Add(() => RestoreIcon(oldDirectory, oldIcon)); + } + + RollbackAndThrow(operationFailure, rollbackActions.ToArray()); + } + + NotifyInstancesChanged(); + } + + private InstanceRegistryFile GetRegistrySnapshot() + { + lock (_snapshotSync) + { + if (!_isLoaded) + { + throw new InvalidOperationException("LoadAsync must complete before using the instance registry."); + } + + if (_loadError is not null) + { + throw _loadError; + } + + return new InstanceRegistryFile(1, _instances.ToList()); + } + } + + private bool TryGetRegistrySnapshot(out InstanceRegistryFile registry, out InstanceStorageError? reason) + { + lock (_snapshotSync) + { + if (!_isLoaded) + { + registry = new InstanceRegistryFile(1, []); + reason = InstanceStorageError.InstanceRegistryNotLoaded; + return false; + } + + if (_loadError is not null) + { + registry = new InstanceRegistryFile(1, []); + reason = InstanceStorageError.InstanceRegistryUnavailable; + return false; + } + + registry = new InstanceRegistryFile(1, _instances.ToList()); + reason = null; + return true; + } + } + + private async Task ReadRegistryAsync(CancellationToken cancellationToken) + { + string registryPath; + try + { + registryPath = InstanceStorage.ContainedPath("instances.json", _storage.Root, allowBaseReparsePoint: true); + } + catch (InstanceStorageException exception) + { + throw new InstanceRegistryException(InstanceRegistryError.ReadFailure, + "The instances registry path is not safe to read.", exception); + } + + FileAttributes attributes; + try + { + attributes = File.GetAttributes(registryPath); + } + catch (FileNotFoundException) + { + return new InstanceRegistryFile(1, []); + } + catch (DirectoryNotFoundException) + { + return new InstanceRegistryFile(1, []); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new InstanceRegistryException(InstanceRegistryError.ReadFailure, + "The instances registry could not be read. It was left unchanged.", exception); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + throw new InstanceRegistryException(InstanceRegistryError.Corrupt, "The instances registry path is a directory."); + } + + try + { + var bytes = await File.ReadAllBytesAsync(registryPath, cancellationToken); + var registry = JsonSerializer.Deserialize(bytes, HakoJsonContext.Default.InstanceRegistryFile); + if (registry is null) + { + throw new JsonException("The registry is empty."); + } + + if (registry.FormatVersion != 1) + { + throw new InstanceRegistryException(InstanceRegistryError.UnsupportedVersion, + $"Unsupported instances registry format version: {registry.FormatVersion}."); + } + + if (registry.Instances is null) + { + throw new JsonException("The registry contains an invalid instance."); + } + + var identifiers = new HashSet(); + var folders = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var instance in registry.Instances) + { + if (instance is null || instance.Id == Guid.Empty || !identifiers.Add(instance.Id) + || string.IsNullOrWhiteSpace(instance.Name) || string.IsNullOrWhiteSpace(instance.FolderName) + || instance.VersionID is null || instance.MetadataURL is null || instance.MetadataSHA1 is null + || instance.IconSymbol is null || instance.OfflineUsername is null || instance.JavaExecutable is null + || !InstanceStorage.IsSinglePathSegment(instance.FolderName) + || !folders.Add(instance.FolderName) || instance.Parameters is null + || instance.Parameters.JavaArguments is null || instance.Parameters.MinecraftArguments is null + || instance.OfflineMode && !OfflineUsername.IsValid(instance.OfflineUsername)) + { + throw new JsonException("The registry contains an invalid instance."); + } + + if (instance.ModLoader == InstanceModLoader.Fabric + && string.IsNullOrWhiteSpace(instance.FabricConfiguration?.LoaderVersion)) + { + throw new JsonException("The Fabric configuration is missing."); + } + + try + { + var (name, folderName) = ParseName(instance.Name); + if (!string.Equals(name, instance.Name, StringComparison.Ordinal) + || !string.Equals(folderName, instance.FolderName, StringComparison.Ordinal)) + { + throw new JsonException("The registry contains an invalid instance name."); + } + + ValidateReservedName(folderName); + } + catch (InstanceStorageException exception) + { + throw new JsonException("The registry contains an invalid instance name.", exception); + } + } + + return registry; + } + catch (InstanceRegistryException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (JsonException exception) + { + throw new InstanceRegistryException(InstanceRegistryError.Corrupt, + "The instances registry is damaged. It was left unchanged.", exception); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new InstanceRegistryException(InstanceRegistryError.ReadFailure, + "The instances registry could not be read. It was left unchanged.", exception); + } + } + + private void WriteRegistry(InstanceRegistryFile registry) + { + System.IO.Directory.CreateDirectory(_storage.Root); + var registryPath = InstanceStorage.ContainedPath("instances.json", _storage.Root, allowBaseReparsePoint: true); + var bytes = JsonSerializer.SerializeToUtf8Bytes(registry, HakoJsonContext.Default.InstanceRegistryFile); + var temporaryPath = registryPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + _beforeRegistryCommit?.Invoke(); + File.Move(temporaryPath, registryPath, overwrite: true); + lock (_snapshotSync) + { + _instances = Array.AsReadOnly(registry.Instances.ToArray()); + } + } + finally + { + try + { + File.Delete(temporaryPath); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + private static GameInstance Find(InstanceRegistryFile registry, Guid id) => registry.Instances.FirstOrDefault(instance => instance.Id == id) + ?? throw new KeyNotFoundException("The build does not exist."); + + private static (string Name, string FolderName) ParseName(string input) + { + var name = input.Trim(); + if (name.Length is < 1 or > 60 || name.Any(character => character is not (>= 'A' and <= 'Z') + and not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not ' ')) + { + throw new InstanceStorageException(InstanceStorageError.InvalidBuildName, + "Enter 1 to 60 Latin letters, digits, or spaces."); + } + + return (name, name.Replace(' ', '_')); + } + + private static void ValidateReservedName(string folderName) + { + if (ReservedNames.Contains(folderName)) + { + throw new InstanceStorageException(InstanceStorageError.ReservedBuildName, + $"The build name '{folderName}' is reserved."); + } + } + + private static HashSet CreateReservedNames() + { + var values = new HashSet(StringComparer.OrdinalIgnoreCase) { InstanceStorage.BackupsFolder, InstanceStorage.WorldsFolder, "CON", "PRN", "AUX", "NUL" }; + for (var index = 1; index <= 9; index++) + { + values.Add("COM" + index); + values.Add("LPT" + index); + } + + return values; + } + + private static void ValidateOffline(InstanceDraft draft) + { + if (draft.OfflineMode && !OfflineUsername.IsValid(draft.OfflineUsername)) + { + throw new InstanceStorageException(InstanceStorageError.InvalidOfflineUsername, + "Offline username must contain 3 to 16 ASCII letters, digits, or underscores."); + } + } + + private void CleanupDeleteStaging() + { + if (!System.IO.Directory.Exists(_storage.Root)) + { + return; + } + + try + { + foreach (var entry in System.IO.Directory.EnumerateFileSystemEntries(_storage.Root)) + { + if (!TryParseDeleteStagingName(Path.GetFileName(entry), out var instanceId)) + { + continue; + } + + if (instanceId is { } id && !ReconcileInterruptedDelete(id)) + { + continue; + } + + TryDeleteStaging(entry); + } + } + catch (Exception exception) + { + Debug.WriteLine($"Could not inspect instance delete staging; it will be retried on the next load: {exception}"); + } + } + + private bool ReconcileInterruptedDelete(Guid instanceId) + { + _gate.Wait(); + try + { + var registry = GetRegistrySnapshot(); + var instance = registry.Instances.FirstOrDefault(item => item.Id == instanceId); + if (instance is null) + { + return true; + } + + var buildDirectory = _storage.Directory(instance.FolderName); + if (System.IO.Directory.Exists(buildDirectory) || File.Exists(buildDirectory)) + { + return true; + } + + var remaining = registry.Instances.Where(item => item.Id != instanceId).ToList(); + WriteRegistry(new InstanceRegistryFile(1, remaining)); + NotifyInstancesChanged(); + return true; + } + catch (Exception exception) + { + Debug.WriteLine($"Could not reconcile instance delete staging; it will be retried on the next load: {exception}"); + return false; + } + finally + { + _gate.Release(); + } + } + + private static bool TryParseDeleteStagingName(string name, out Guid? instanceId) + { + instanceId = null; + const string prefix = ".delete-"; + if (!name.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + var identifier = name[prefix.Length..]; + if (Guid.TryParseExact(identifier, "N", out _) || Guid.TryParseExact(identifier, "D", out _)) + { + return true; + } + + if (identifier.Length != 65 || identifier[32] != '-' + || !Guid.TryParseExact(identifier[..32], "N", out var parsedInstanceId) + || !Guid.TryParseExact(identifier[33..], "N", out _)) + { + return false; + } + + instanceId = parsedInstanceId; + return true; + } + + private void TryDeleteStaging(string path) + { + lock (_deleteStagingSync) + { + try + { + InstanceStorage.DeleteCreatedDirectory(path); + } + catch (DirectoryNotFoundException) + { + // Another cleanup pass or the delete operation already removed this staging path. + } + catch (Exception exception) + { + Debug.WriteLine($"Could not remove instance delete staging; it will be retried on the next load: {exception}"); + } + } + } + + private static InstanceParameters NormalizeParameters(InstanceParameters parameters) => parameters with + { + WindowWidth = parameters.WindowWidth > 0 ? parameters.WindowWidth : 1280, + WindowHeight = parameters.WindowHeight > 0 ? parameters.WindowHeight : 720, + MaximumMemoryMiB = GameLaunchDefaults.NormalizeMemory(parameters.MaximumMemoryMiB) + }; + + private static GameInstance ApplyDraft(GameInstance current, InstanceDraft draft, bool includeName) => current with + { + Name = includeName ? ParseName(draft.Name).Name : current.Name, + IconSymbol = draft.IconSymbol, + IconRevision = draft.IconData is not null || !string.Equals(draft.IconSymbol, current.IconSymbol, StringComparison.Ordinal) + ? Guid.NewGuid() + : current.IconRevision, + ArgumentSource = draft.ArgumentSource, + OfflineMode = draft.OfflineMode, + OfflineUsername = draft.OfflineUsername, + Parameters = NormalizeParameters(draft.Parameters) + }; + + private static void WriteIconIfProvided(string directory, byte[]? data) + { + if (data is not null) + { + WriteIcon(directory, data); + } + } + + private static void WriteIcon(string directory, byte[] data) + { + var iconPath = InstanceStorage.ContainedPath("icon.png", directory); + var temporaryPath = iconPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(data); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, iconPath, overwrite: true); + } + finally + { + try { File.Delete(temporaryPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private static byte[]? ReadIcon(string directory) + { + var iconPath = InstanceStorage.ContainedPath("icon.png", directory); + return File.Exists(iconPath) ? File.ReadAllBytes(iconPath) : null; + } + + private static void RestoreIcon(string directory, byte[]? oldIcon) + { + var iconPath = InstanceStorage.ContainedPath("icon.png", directory); + if (oldIcon is null) + { + File.Delete(iconPath); + } + else + { + WriteIcon(directory, oldIcon); + } + } + + private static void MoveDirectoryForRename(string from, string to) + { + if (!string.Equals(Path.GetFileName(from), Path.GetFileName(to), StringComparison.OrdinalIgnoreCase)) + { + InstanceStorage.MoveDirectory(from, to); + return; + } + + var root = Path.GetDirectoryName(from)!; + var temporary = Path.Combine(root, ".rename-" + Guid.NewGuid().ToString("N")); + InstanceStorage.MoveDirectory(from, temporary); + try + { + InstanceStorage.MoveDirectory(temporary, to); + } + catch (Exception operationFailure) + { + RollbackAndThrow(operationFailure, () => + { + if (System.IO.Directory.Exists(temporary)) + { + InstanceStorage.MoveDirectory(temporary, from); + } + }); + } + } + + private InstanceStorageError? GetManagementBlock(GameInstance instance) + { + if (instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + { + return InstanceStorageError.BuildInstalling; + } + + if (Volatile.Read(ref _launchBusy).Contains(instance.Id)) + { + return InstanceStorageError.BuildRunning; + } + + if (Volatile.Read(ref _contentBusy).Contains(instance.Id)) + { + return InstanceStorageError.BuildFilesBusy; + } + + return null; + } + + private void ThrowIfManagementBlocked(GameInstance instance) + { + if (GetManagementBlock(instance) is { } error) + { + throw new InstanceStorageException(error, ManagementMessage(error)); + } + } + + private static string ManagementMessage(InstanceStorageError error) => error switch + { + InstanceStorageError.BuildInstalling => "The build is installing.", + InstanceStorageError.BuildRunning => "Minecraft is running for this build.", + InstanceStorageError.BuildFilesBusy => "Build files are busy.", + _ => throw new ArgumentOutOfRangeException(nameof(error), error, null) + }; + + [DoesNotReturn] + private static void RollbackAndThrow(Exception operationFailure, params Action[] rollbackActions) + { + List? failures = null; + foreach (var rollback in rollbackActions) + { + try + { + rollback(); + } + catch (Exception rollbackFailure) + { + failures ??= [operationFailure]; + failures.Add(rollbackFailure); + } + } + + if (failures is not null) + { + throw new AggregateException("The operation and its rollback both failed.", failures); + } + + ExceptionDispatchInfo.Capture(operationFailure).Throw(); + } + + private void NotifyInstancesChanged() + { + _gate.Release(); + try + { + InvokePropertyChanged(nameof(Instances)); + InvokePropertyChanged(nameof(LoadError)); + InvokePropertyChanged(nameof(IsLoaded)); + InvokeHandlers(InstancesChanged, this, EventArgs.Empty); + } + finally + { + _gate.Wait(); + } + } + + private void NotifyBusyStateChanged() + { + InvokePropertyChanged("BusyState"); + InvokeHandlers(BusyStateChanged, this, EventArgs.Empty); + } + + private void InvokePropertyChanged(string propertyName) + { + var handlers = PropertyChanged; + if (handlers is null) + { + return; + } + + foreach (PropertyChangedEventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, new PropertyChangedEventArgs(propertyName)); + } + catch (Exception) + { + } + } + } + + private static void InvokeHandlers(EventHandler? handlers, object sender, EventArgs args) + { + if (handlers is null) + { + return; + } + + foreach (EventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(sender, args); + } + catch (Exception) + { + } + } + } + + private sealed class BusyLease(Action end) : IDisposable + { + private Action? _end = end; + + public void Dispose() => Interlocked.Exchange(ref _end, null)?.Invoke(); + } +} diff --git a/windows/Hako.Core/Instances/InstanceWorlds.cs b/windows/Hako.Core/Instances/InstanceWorlds.cs new file mode 100644 index 0000000..2bf0115 --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceWorlds.cs @@ -0,0 +1,629 @@ +using System.Runtime.Versioning; +using Hako.Core.Minecraft; + +namespace Hako.Core.Instances; + +public sealed record InstanceWorld( + string Id, + string Name, + string? Version, + int? GameType, + DateTimeOffset? LastPlayed, + long? Size, + byte[]? IconData, + WorldMetadataError? MetadataError); + +public enum InstanceWorldFailureReason +{ + BuildUnavailable, + BuildNotReady, + BuildInstalling, + BuildRunning, + BuildFilesBusy, + InvalidWorldId, + WorldMissing, + ReparsePointNotAllowed, + NoAvailableName, + MetadataRenameFailed, + CopyFailed, + RecycleBinFailed, + NotRecyclable +} + +public sealed record WorldDuplicateResult(string? WorldId, InstanceWorldFailureReason? FailureReason) +{ + public bool Succeeded => WorldId is not null && FailureReason is null; +} + +public sealed record WorldTrashResult(RecycleBinItemStatus Status, InstanceWorldFailureReason? FailureReason) +{ + public bool Succeeded => Status == RecycleBinItemStatus.Recycled && FailureReason is null; +} + +public sealed class InstanceWorldException(InstanceWorldFailureReason error, string message, Exception? innerException = null) + : IOException(message, innerException) +{ + public InstanceWorldFailureReason Error { get; } = error; +} + +/// Lists and manages the local saves folder for each registered build. +[SupportedOSPlatform("windows")] +public sealed class InstanceWorlds +{ + private static readonly string[] MetadataFiles = ["level.dat", "level.dat_old"]; + private const int MaximumIconBytes = 4 * 1024 * 1024; + private const string StagePrefix = ".hako-world-copy-"; + + private readonly InstanceStore _store; + private readonly InstanceContent _content; + private readonly RecycleBin _recycleBin; + private readonly Action? _afterStageCreated; + + public InstanceWorlds(InstanceStore store, RecycleBin? recycleBin = null) + : this(store, recycleBin, null) + { + } + + internal InstanceWorlds(InstanceStore store, RecycleBin? recycleBin, Action? afterStageCreated) + { + _store = store; + _recycleBin = recycleBin ?? new RecycleBin(); + _afterStageCreated = afterStageCreated; + _content = new InstanceContent(store, _recycleBin); + } + + /// Lists the registered build's local worlds without creating the saves folder. + public async Task> ListAsync(Guid instanceId, + CancellationToken cancellationToken = default) + { + var root = await Task.Run(() => _store.GetInstanceDirectory(instanceId), cancellationToken) + .ConfigureAwait(false); + return await Task.Run(() => ListAt(root, cancellationToken), cancellationToken).ConfigureAwait(false); + } + + /// Returns an existing world folder only after checking its path and regular metadata files. + public static string WorldFolder(string worldId, string instanceRoot) + { + if (!InstanceStorage.IsSinglePathSegment(worldId)) + { + throw new InstanceWorldException(InstanceWorldFailureReason.InvalidWorldId, + "The world folder name is invalid."); + } + + var folder = ContainedPath(Path.Combine("minecraft", "saves", worldId), instanceRoot); + if (!Directory.Exists(folder)) + { + throw new InstanceWorldException(InstanceWorldFailureReason.WorldMissing, + "The world no longer exists."); + } + + var attributes = File.GetAttributes(folder); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.ReparsePointNotAllowed, + "World folders cannot be reparse points."); + } + + if ((attributes & FileAttributes.Directory) == 0 || !MetadataFiles.Any(file => IsOrdinaryFile(folder, file))) + { + throw new InstanceWorldException(InstanceWorldFailureReason.WorldMissing, + "The world no longer has regular level metadata."); + } + + return folder; + } + + /// Returns the contained datapacks path without creating it; the world itself must still exist. + public static string DatapacksFolder(string worldId, string instanceRoot) + { + var worldFolder = WorldFolder(worldId, instanceRoot); + var folder = ContainedPath("datapacks", worldFolder); + if (TryGetAttributes(folder, out var attributes) + && ((attributes & FileAttributes.ReparsePoint) != 0 || (attributes & FileAttributes.Directory) == 0)) + { + throw new InstanceWorldException((attributes & FileAttributes.ReparsePoint) != 0 + ? InstanceWorldFailureReason.ReparsePointNotAllowed + : InstanceWorldFailureReason.WorldMissing, + "The datapacks path is not a regular folder."); + } + + return folder; + } + + /// Creates an independent copy of a ready build's world under the same content lease as other file operations. + public async Task DuplicateAsync(Guid instanceId, string worldId, + CancellationToken cancellationToken = default) + { + try + { + return await _content.WithContentLeaseAsync(instanceId, async (root, instance, token) => + { + if (instance.State != InstanceInstallationState.Ready) + { + return new WorldDuplicateResult(null, InstanceWorldFailureReason.BuildNotReady); + } + + return await Task.Run(() => DuplicateUnderLease(root, worldId, token, _afterStageCreated), token) + .ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + return new WorldDuplicateResult(null, MapFailure(exception, InstanceWorldFailureReason.CopyFailed)); + } + } + + /// Moves a revalidated world folder to the Windows Recycle Bin under the build content lease. + public async Task TrashAsync(Guid instanceId, string worldId, + CancellationToken cancellationToken = default) + { + try + { + return await _content.WithContentLeaseAsync(instanceId, async (root, instance, token) => + { + if (instance.State != InstanceInstallationState.Ready) + { + return new WorldTrashResult(RecycleBinItemStatus.Failed, + InstanceWorldFailureReason.BuildNotReady); + } + + token.ThrowIfCancellationRequested(); + var folder = WorldFolder(worldId, root); + var result = await _recycleBin.DeleteAsync(folder, token).ConfigureAwait(false); + if (result.Items.Count != 1) + { + return new WorldTrashResult(RecycleBinItemStatus.Failed, + InstanceWorldFailureReason.RecycleBinFailed); + } + + var item = result.Items[0]; + return item.Status switch + { + RecycleBinItemStatus.Recycled => new WorldTrashResult(item.Status, null), + RecycleBinItemStatus.NotRecyclable => new WorldTrashResult(item.Status, + InstanceWorldFailureReason.NotRecyclable), + RecycleBinItemStatus.Cancelled => new WorldTrashResult(item.Status, + InstanceWorldFailureReason.RecycleBinFailed), + _ => new WorldTrashResult(item.Status, InstanceWorldFailureReason.RecycleBinFailed) + }; + }, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + return new WorldTrashResult(RecycleBinItemStatus.Failed, + MapFailure(exception, InstanceWorldFailureReason.RecycleBinFailed)); + } + } + + private static IReadOnlyList ListAt(string instanceRoot, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var saves = ContainedPath(Path.Combine("minecraft", "saves"), instanceRoot); + if (!Directory.Exists(saves)) return Array.Empty(); + + var savesAttributes = File.GetAttributes(saves); + if ((savesAttributes & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.ReparsePointNotAllowed, + "The saves folder cannot be a reparse point."); + } + if ((savesAttributes & FileAttributes.Directory) == 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.WorldMissing, + "The saves path is not a folder."); + } + + var worlds = new List(); + foreach (var folder in Directory.EnumerateDirectories(saves)) + { + cancellationToken.ThrowIfCancellationRequested(); + var id = Path.GetFileName(folder); + if (!InstanceStorage.IsSinglePathSegment(id)) continue; + + var attributes = File.GetAttributes(folder); + if ((attributes & FileAttributes.ReparsePoint) != 0 + || (attributes & FileAttributes.Directory) == 0 + || !MetadataFiles.Any(file => IsOrdinaryFile(folder, file))) continue; + + var metadata = ReadMetadata(folder, cancellationToken); + worlds.Add(new InstanceWorld(id, metadata.Value?.Name ?? id, metadata.Value?.Version, + metadata.Value?.GameType, metadata.Value?.LastPlayed, TryGetSize(folder, cancellationToken), + TryReadIcon(folder, cancellationToken), metadata.Error)); + } + + return worlds + .OrderByDescending(world => world.LastPlayed.HasValue) + .ThenByDescending(world => world.LastPlayed) + .ThenBy(world => world.Name, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(world => world.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static (WorldMetadata? Value, WorldMetadataError? Error) ReadMetadata(string folder, + CancellationToken cancellationToken) + { + WorldMetadataError? firstError = null; + foreach (var file in MetadataFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsOrdinaryFile(folder, file)) continue; + try + { + return (WorldMetadata.ReadFile(ContainedPath(file, folder), cancellationToken), null); + } + catch (OperationCanceledException) + { + throw; + } + catch (WorldMetadataException exception) + { + firstError ??= exception.Error; + } + } + + return (null, firstError ?? WorldMetadataError.ReadFailure); + } + + private static WorldDuplicateResult DuplicateUnderLease(string instanceRoot, string worldId, + CancellationToken cancellationToken, Action? afterStageCreated) + { + cancellationToken.ThrowIfCancellationRequested(); + var source = WorldFolder(worldId, instanceRoot); + CheckNoReparsePoints(source, cancellationToken); + var sourceMetadata = ReadMetadata(source, cancellationToken); + var metadataWasReadable = sourceMetadata.Value is not null; + var sourceName = sourceMetadata.Value?.Name ?? worldId; + var saves = ContainedPath(Path.Combine("minecraft", "saves"), instanceRoot); + var stageId = StagePrefix + Guid.NewGuid().ToString("N"); + var stage = ContainedPath(stageId, saves); + InstanceStorage.CreateDirectoryExclusiveAt(saves, stageId); + var published = false; + try + { + afterStageCreated?.Invoke(stage); + var attributes = new List<(string Path, FileAttributes Value, DateTime LastWriteUtc)>(); + attributes.Add((stage, File.GetAttributes(source), Directory.GetLastWriteTimeUtc(source))); + CopyDirectory(source, source, stage, attributes, cancellationToken); + CheckNoReparsePoints(stage, cancellationToken); + + CheckNoReparsePoints(source, cancellationToken); + _ = WorldFolder(worldId, instanceRoot); + + for (var suffix = 2; suffix <= 999; suffix++) + { + cancellationToken.ThrowIfCancellationRequested(); + var newWorldId = $"{worldId} {suffix}"; + if (!InstanceStorage.IsSinglePathSegment(newWorldId)) continue; + if (WorldNameExists(saves, newWorldId)) continue; + var destination = ContainedPath(newWorldId, saves); + + var renamedCount = RenameReadableMetadata(stage, $"{sourceName} {suffix}", cancellationToken); + if (metadataWasReadable && renamedCount == 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.MetadataRenameFailed, + "The copied world metadata could not be renamed."); + } + + ApplyAttributes(attributes); + cancellationToken.ThrowIfCancellationRequested(); + try + { + Directory.Move(stage, destination); + published = true; + return new WorldDuplicateResult(newWorldId, null); + } + catch (IOException) when (WorldNameExists(saves, newWorldId)) + { + // An external writer claimed the candidate after it was checked; try the next suffix. + } + } + + return new WorldDuplicateResult(null, InstanceWorldFailureReason.NoAvailableName); + } + finally + { + if (!published && Directory.Exists(stage)) InstanceStorage.DeleteCreatedDirectory(stage); + } + } + + private static int RenameReadableMetadata(string stage, string name, CancellationToken cancellationToken) + { + var renamed = 0; + foreach (var file in MetadataFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsOrdinaryFile(stage, file)) continue; + var path = ContainedPath(file, stage); + byte[] compressed; + try + { + compressed = ReadBounded(path, WorldMetadata.MaximumBytes, cancellationToken); + _ = WorldMetadata.Read(compressed, cancellationToken); + } + catch (IOException) + { + // Unreadable metadata is copied byte-for-byte; a valid alternate file can still be renamed. + continue; + } + + var renamedBytes = WorldMetadata.Renamed(compressed, name, cancellationToken); + File.WriteAllBytes(path, renamedBytes); + renamed++; + } + + return renamed; + } + + private static void CopyDirectory(string sourceRoot, string currentSource, string destination, + List<(string Path, FileAttributes Value, DateTime LastWriteUtc)> attributes, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var relativeDirectory = Path.GetRelativePath(sourceRoot, currentSource); + var checkedDirectory = relativeDirectory == "." ? sourceRoot : ContainedPath(relativeDirectory, sourceRoot); + CheckNoReparsePoint(checkedDirectory); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var sourcePath in Directory.EnumerateFileSystemEntries(checkedDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = Path.GetFileName(sourcePath); + if (!InstanceStorage.IsSinglePathSegment(name) || !names.Add(name)) + { + throw new InstanceWorldException(InstanceWorldFailureReason.CopyFailed, + "The world contains a name that cannot be copied safely on Windows."); + } + + var relativePath = Path.Combine(relativeDirectory == "." ? string.Empty : relativeDirectory, name); + var safeSource = ContainedPath(relativePath, sourceRoot); + var sourceAttributes = File.GetAttributes(safeSource); + if ((sourceAttributes & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.ReparsePointNotAllowed, + "Worlds with reparse points cannot be copied."); + } + + var destinationPath = Path.Combine(destination, name); + if ((sourceAttributes & FileAttributes.Directory) != 0) + { + InstanceStorage.CreateDirectoryExclusiveAt(destination, name); + attributes.Add((destinationPath, sourceAttributes, Directory.GetLastWriteTimeUtc(safeSource))); + CopyDirectory(sourceRoot, safeSource, destinationPath, attributes, cancellationToken); + } + else + { + if ((sourceAttributes & FileAttributes.Device) != 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.CopyFailed, + "Device entries cannot be copied as world files."); + } + + using var input = new FileStream(safeSource, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 81920, FileOptions.SequentialScan); + using var output = new FileStream(destinationPath, FileMode.CreateNew, FileAccess.Write, + FileShare.None, 81920, FileOptions.SequentialScan); + var buffer = new byte[81920]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = input.Read(buffer, 0, buffer.Length); + if (read == 0) break; + output.Write(buffer, 0, read); + } + + attributes.Add((destinationPath, sourceAttributes, File.GetLastWriteTimeUtc(safeSource))); + } + } + } + + private static void ApplyAttributes(List<(string Path, FileAttributes Value, DateTime LastWriteUtc)> attributes) + { + const FileAttributes preserved = FileAttributes.Hidden | FileAttributes.System + | FileAttributes.Archive | FileAttributes.NotContentIndexed | FileAttributes.Temporary; + for (var index = attributes.Count - 1; index >= 0; index--) + { + var entry = attributes[index]; + if ((entry.Value & FileAttributes.Directory) != 0) + { + Directory.SetLastWriteTimeUtc(entry.Path, entry.LastWriteUtc); + File.SetAttributes(entry.Path, FileAttributes.Directory | (entry.Value & preserved)); + } + else + { + File.SetLastWriteTimeUtc(entry.Path, entry.LastWriteUtc); + var fileAttributes = entry.Value & preserved; + File.SetAttributes(entry.Path, fileAttributes == 0 ? FileAttributes.Normal : fileAttributes); + } + } + } + + private static bool WorldNameExists(string savesFolder, string worldId) => + Directory.EnumerateFileSystemEntries(savesFolder).Any(path => + string.Equals(Path.GetFileName(path), worldId, StringComparison.OrdinalIgnoreCase)); + + private static void CheckNoReparsePoints(string folder, CancellationToken cancellationToken) + { + CheckNoReparsePoint(folder); + var pending = new Stack(); + pending.Push(folder); + while (pending.TryPop(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + cancellationToken.ThrowIfCancellationRequested(); + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.ReparsePointNotAllowed, + "Worlds with reparse points cannot be copied."); + } + if ((attributes & FileAttributes.Directory) != 0) pending.Push(entry); + } + } + } + + private static void CheckNoReparsePoint(string path) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new InstanceWorldException(InstanceWorldFailureReason.ReparsePointNotAllowed, + "World paths cannot be reparse points."); + } + } + + private static long? TryGetSize(string folder, CancellationToken cancellationToken) + { + try + { + long size = 0; + var pending = new Stack(); + pending.Push(folder); + while (pending.TryPop(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var entry in Directory.EnumerateFileSystemEntries(current)) + { + cancellationToken.ThrowIfCancellationRequested(); + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) continue; + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(entry); + } + else if ((attributes & FileAttributes.Device) == 0) + { + size = checked(size + new FileInfo(entry).Length); + } + } + } + + return size; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or OverflowException) + { + return null; + } + } + + private static byte[]? TryReadIcon(string folder, CancellationToken cancellationToken) + { + try + { + if (!IsOrdinaryFile(folder, "icon.png")) return null; + return ReadBounded(ContainedPath("icon.png", folder), MaximumIconBytes, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static byte[] ReadBounded(string path, int maximumBytes, CancellationToken cancellationToken) + { + using var input = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 81920, FileOptions.SequentialScan); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = input.Read(buffer, 0, buffer.Length); + if (read == 0) break; + if (output.Length + read > maximumBytes) + { + throw new IOException("The file exceeds the configured size limit."); + } + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + + private static bool IsOrdinaryFile(string directory, string name) + { + try + { + var path = ContainedPath(name, directory); + if (!TryGetAttributes(path, out var attributes)) return false; + return (attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) == 0; + } + catch (InstanceWorldException exception) + when (exception.Error == InstanceWorldFailureReason.ReparsePointNotAllowed) + { + return false; + } + } + + private static string ContainedPath(string relativePath, string baseDirectory) + { + try + { + return InstanceStorage.ContainedPath(relativePath, baseDirectory); + } + catch (InstanceStorageException exception) when (exception.Error == InstanceStorageError.ReparsePointNotAllowed) + { + throw new InstanceWorldException(InstanceWorldFailureReason.ReparsePointNotAllowed, + "World paths cannot contain reparse points.", exception); + } + catch (InstanceStorageException exception) when (exception.Error is InstanceStorageError.InvalidPath + or InstanceStorageError.PathTraversal) + { + throw new InstanceWorldException(InstanceWorldFailureReason.InvalidWorldId, + "The world path is invalid.", exception); + } + } + + private static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) + { + attributes = default; + return false; + } + catch (DirectoryNotFoundException) + { + attributes = default; + return false; + } + } + + private static InstanceWorldFailureReason MapFailure(Exception exception, + InstanceWorldFailureReason fallback) + { + if (exception is InstanceWorldException worldException) return worldException.Error; + if (exception is WorldMetadataException) return InstanceWorldFailureReason.MetadataRenameFailed; + if (exception is InstanceStorageException storageException) + { + return storageException.Error switch + { + InstanceStorageError.BuildInstalling => InstanceWorldFailureReason.BuildInstalling, + InstanceStorageError.BuildRunning => InstanceWorldFailureReason.BuildRunning, + InstanceStorageError.BuildFilesBusy => InstanceWorldFailureReason.BuildFilesBusy, + InstanceStorageError.InvalidPath or InstanceStorageError.PathTraversal => InstanceWorldFailureReason.InvalidWorldId, + InstanceStorageError.ReparsePointNotAllowed => InstanceWorldFailureReason.ReparsePointNotAllowed, + _ => InstanceWorldFailureReason.BuildUnavailable + }; + } + return fallback; + } +} diff --git a/windows/Hako.Core/Instances/ModpackCatalog.cs b/windows/Hako.Core/Instances/ModpackCatalog.cs new file mode 100644 index 0000000..8eecd6b --- /dev/null +++ b/windows/Hako.Core/Instances/ModpackCatalog.cs @@ -0,0 +1,187 @@ +using System.ComponentModel; +using Hako.Core.Minecraft; + +namespace Hako.Core.Instances; + +/// Search and pagination state for the ready-made Modrinth modpack catalog. +public sealed class ModpackCatalog : INotifyPropertyChanged, IDisposable +{ + private readonly ModrinthClient _client; + private readonly object _sync = new(); + private CancellationTokenSource? _searchCancellation; + private IReadOnlyList _projects = []; + private Exception? _error; + private string _query = ""; + private string? _minecraft; + private ModrinthSort _sort = ModrinthSort.Relevance; + private int _nextOffset; + private int _total; + private long _generation; + private bool _isLoading; + private bool _isLoadingMore; + private bool _disposed; + + public ModpackCatalog(ModrinthClient client) => _client = client; + + public event PropertyChangedEventHandler? PropertyChanged; + + public IReadOnlyList Projects { get { lock (_sync) return _projects; } } + public Exception? Error { get { lock (_sync) return _error; } } + public string Query { get { lock (_sync) return _query; } } + public string? Minecraft { get { lock (_sync) return _minecraft; } } + public ModrinthSort Sort { get { lock (_sync) return _sort; } } + public int NextOffset { get { lock (_sync) return _nextOffset; } } + public int Total { get { lock (_sync) return _total; } } + public bool IsLoading { get { lock (_sync) return _isLoading; } } + public bool IsLoadingMore { get { lock (_sync) return _isLoadingMore; } } + public bool HasMore { get { lock (_sync) return _nextOffset < _total; } } + + public async Task SearchAsync(string query, string? minecraft, ModrinthSort sort, + CancellationToken cancellationToken = default) + { + CancellationTokenSource search; + long generation; + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _searchCancellation?.Cancel(); + _searchCancellation?.Dispose(); + search = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _searchCancellation = search; + generation = ++_generation; + _query = query; + _minecraft = minecraft; + _sort = sort; + _projects = []; + _error = null; + _nextOffset = 0; + _total = 0; + _isLoading = true; + _isLoadingMore = false; + } + NotifyAll(); + + try + { + var page = await _client.SearchModpacksAsync(query, minecraft, sort, cancellationToken: search.Token) + .ConfigureAwait(false); + lock (_sync) + { + if (generation != _generation) return; + _projects = page.Hits.Where(project => project.ProjectType == "modpack") + .DistinctBy(project => project.Id, StringComparer.Ordinal).ToArray(); + _nextOffset = page.Hits.Count; + _total = page.TotalHits; + } + } + catch (OperationCanceledException) when (search.IsCancellationRequested) + { + } + catch (Exception exception) + { + lock (_sync) + { + if (generation == _generation) _error = exception; + } + } + finally + { + lock (_sync) + { + if (generation == _generation) _isLoading = false; + } + NotifyAll(); + } + } + + public async Task LoadMoreAsync(CancellationToken cancellationToken = default) + { + CancellationTokenSource linked; + long generation; + int offset; + string query; + string? minecraft; + ModrinthSort sort; + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_isLoading || _isLoadingMore || _nextOffset >= _total || _searchCancellation is null) return; + generation = _generation; + offset = _nextOffset; + query = _query; + minecraft = _minecraft; + sort = _sort; + linked = CancellationTokenSource.CreateLinkedTokenSource(_searchCancellation.Token, cancellationToken); + _isLoadingMore = true; + _error = null; + } + NotifyAll(); + + try + { + var page = await _client.SearchModpacksAsync(query, minecraft, sort, offset, cancellationToken: linked.Token) + .ConfigureAwait(false); + lock (_sync) + { + if (generation != _generation) return; + var projects = _projects.ToDictionary(project => project.Id, StringComparer.Ordinal); + foreach (var project in page.Hits.Where(project => project.ProjectType == "modpack")) + { + projects.TryAdd(project.Id, project); + } + _projects = projects.Values.ToArray(); + _nextOffset += page.Hits.Count; + _total = page.TotalHits; + } + } + catch (OperationCanceledException) when (linked.IsCancellationRequested) + { + } + catch (Exception exception) + { + lock (_sync) + { + if (generation == _generation) _error = exception; + } + } + finally + { + linked.Dispose(); + lock (_sync) + { + if (generation == _generation) _isLoadingMore = false; + } + NotifyAll(); + } + } + + public Task> VersionsAsync( + ModrinthProject project, + string? minecraft, + CancellationToken cancellationToken = default) => + _client.GetModpackVersionsAsync(project.Id, minecraft, cancellationToken); + + public void Dispose() + { + lock (_sync) + { + if (_disposed) return; + _disposed = true; + _generation++; + _searchCancellation?.Cancel(); + _searchCancellation?.Dispose(); + _searchCancellation = null; + } + } + + private void NotifyAll() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Projects))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NextOffset))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Total))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoading))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsLoadingMore))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasMore))); + } +} diff --git a/windows/Hako.Core/Instances/WorldBackup.cs b/windows/Hako.Core/Instances/WorldBackup.cs new file mode 100644 index 0000000..bc9a111 --- /dev/null +++ b/windows/Hako.Core/Instances/WorldBackup.cs @@ -0,0 +1,278 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Runtime.Versioning; + +namespace Hako.Core.Instances; + +public sealed record WorldBackupManifest +{ + public const int CurrentFormat = 1; + + public sealed record SourceInstanceData(Guid Id, string Name, string FolderName, string MinecraftVersion, + string ModLoader, string? LoaderVersion); + + public sealed record Datapack(string File, string Path, bool Enabled, bool IsDirectory, string? Sha512, + byte[]? IconPNG); + + public int FormatVersion { get; init; } = CurrentFormat; + public string WorldPath { get; init; } = "world"; + + [JsonConverter(typeof(UtcWholeSecondDateTimeOffsetConverter))] + public DateTimeOffset BackupCreatedAt { get; init; } + public string Name { get; init; } = ""; + public string FolderName { get; init; } = ""; + public string? SaveVersion { get; init; } + public int? GameType { get; init; } + + [JsonConverter(typeof(UtcWholeSecondNullableDateTimeOffsetConverter))] + public DateTimeOffset? LastPlayed { get; init; } + public long? Size { get; init; } + public byte[]? IconPNG { get; init; } + public SourceInstanceData SourceInstance { get; init; } = new(Guid.Empty, "", "", "", "vanilla", null); + public IReadOnlyList Datapacks { get; init; } = []; +} + +/// Creates streaming .hakoworld archives without modifying the source save. +[SupportedOSPlatform("windows")] +public sealed class WorldBackup +{ + private const int MaximumPackIconBytes = 4 * 1024 * 1024; + private readonly InstanceStore _store; + private readonly InstanceContent _content; + private readonly InstanceWorlds _worlds; + private readonly Func _utcNow; + private readonly Action? _beforeArchiveEntry; + + public WorldBackup(InstanceStore store, InstanceContent content, InstanceWorlds worlds) + : this(store, content, worlds, null, null) + { + } + + internal WorldBackup(InstanceStore store, InstanceContent content, InstanceWorlds worlds, + Action? beforeArchiveEntry) + : this(store, content, worlds, null, beforeArchiveEntry) + { + } + + internal WorldBackup(InstanceStore store, InstanceContent content, InstanceWorlds worlds, + Func? utcNow, Action? beforeArchiveEntry) + { + _store = store; + _content = content; + _worlds = worlds; + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + _beforeArchiveEntry = beforeArchiveEntry; + } + + public async Task CreateAsync(Guid instanceId, string worldId, + CancellationToken cancellationToken = default) + { + string? partialPath = null; + var ownsPartial = false; + BackupResult result; + Exception? cleanupFailure = null; + try + { + result = await _content.WithContentLeaseAsync(instanceId, async (root, instance, token) => + { + if (instance.State != InstanceInstallationState.Ready) + return BackupResult.Failed(BackupFailureReason.BuildNotReady, + "Wait for the build installation to finish before creating a world backup."); + EnsureReservedFolderAvailable(InstanceStorage.WorldsFolder); + var outputDirectory = EnsureOutputDirectory(InstanceStorage.WorldsFolder); + var source = InstanceWorlds.WorldFolder(worldId, root); + var backupTime = _utcNow(); + var entries = await Task.Run(() => InstanceFileTree.Snapshot(source, null, token), token) + .ConfigureAwait(false); + var listedWorld = (await _worlds.ListAsync(instanceId, token).ConfigureAwait(false)) + .FirstOrDefault(world => string.Equals(world.Id, worldId, StringComparison.OrdinalIgnoreCase)); + if (listedWorld is null) + return BackupResult.Failed(BackupFailureReason.WorldUnavailable, + "The world no longer exists.", source); + + var target = Path.Combine(outputDirectory, + $"{instance.FolderName}_{worldId}_{backupTime.UtcDateTime.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture)}.hakoworld"); + if (EntryExists(target)) + return BackupResult.Failed(BackupFailureReason.DestinationConflict, + "A world backup with this name already exists.", target); + + var packs = await BuildDatapacksAsync(root, instance, worldId, token).ConfigureAwait(false); + var manifest = new WorldBackupManifest + { + BackupCreatedAt = backupTime, + Name = listedWorld.Name, + FolderName = worldId, + SaveVersion = listedWorld.Version, + GameType = listedWorld.GameType, + LastPlayed = listedWorld.LastPlayed, + Size = listedWorld.Size, + IconPNG = listedWorld.IconData, + SourceInstance = new WorldBackupManifest.SourceInstanceData( + instance.Id, + instance.Name, + instance.FolderName, + instance.VersionID, + instance.ModLoader == InstanceModLoader.Fabric ? "fabric" : "vanilla", + instance.FabricConfiguration?.LoaderVersion), + Datapacks = packs + }; + + partialPath = Path.Combine(outputDirectory, "." + Guid.NewGuid().ToString("N") + ".partial"); + await WriteArchiveAsync(partialPath, manifest, entries, token, () => ownsPartial = true).ConfigureAwait(false); + var latestSource = InstanceWorlds.WorldFolder(worldId, _store.GetInstanceDirectory(instanceId)); + InstanceFileTree.EnsureUnchanged(latestSource, entries, null, token); + EnsureOutputDirectory(InstanceStorage.WorldsFolder); + if (EntryExists(target)) + return BackupResult.Failed(BackupFailureReason.DestinationConflict, + "A world backup with this name already exists.", target); + try { File.Move(partialPath, target); } + catch (IOException) when (EntryExists(target)) + { + return BackupResult.Failed(BackupFailureReason.DestinationConflict, + "A world backup with this name already exists.", target); + } + partialPath = null; + ownsPartial = false; + return BackupResult.Success(target); + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + result = BackupResult.Failed(BackupFailureMapping.From(exception), exception.Message); + } + finally + { + if (ownsPartial && partialPath is not null) + { + try { File.Delete(partialPath); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + cleanupFailure = exception; + } + } + } + + if (cleanupFailure is not null) + { + var failure = result.Failure; + var diagnostic = failure?.Diagnostic ?? "Backup creation failed."; + return BackupResult.Failed(failure?.Reason ?? BackupFailureReason.OperationFailed, + $"{diagnostic} Cleanup failed: {cleanupFailure.Message}", partialPath); + } + + return result; + } + + private async Task> BuildDatapacksAsync(string root, + GameInstance instance, string worldId, CancellationToken cancellationToken) + { + var target = InstanceContentTarget.WorldDatapacks(worldId); + var items = await _content.ListUnderLeaseAsync(root, instance, target, true, cancellationToken) + .ConfigureAwait(false); + var packs = new List(items.Count); + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = item.Name; + var folder = item.DisabledPack ? ".hako-disabled-datapacks" : "datapacks"; + var hash = item.IsDirectory ? null : await InstanceFileTree.Sha512Async(item.Path, cancellationToken) + .ConfigureAwait(false); + var icon = await TryReadPackIconAsync(item, cancellationToken).ConfigureAwait(false); + packs.Add(new WorldBackupManifest.Datapack(name, $"{folder}/{name}", item.Enabled, + item.IsDirectory, hash, icon)); + } + return packs; + } + + private static async Task TryReadPackIconAsync(InstanceContentItem item, + CancellationToken cancellationToken) + { + try + { + if (item.IsDirectory) + { + var icon = InstanceStorage.ContainedPath("pack.png", item.Path); + if (!File.Exists(icon)) return null; + var attributes = File.GetAttributes(icon); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0 + || new FileInfo(icon).Length > MaximumPackIconBytes) return null; + return await File.ReadAllBytesAsync(icon, cancellationToken).ConfigureAwait(false); + } + + await using var input = new FileStream(item.Path, FileMode.Open, FileAccess.Read, FileShare.Read, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var archive = new ZipArchive(input, ZipArchiveMode.Read, leaveOpen: false); + var entry = archive.GetEntry("pack.png"); + if (entry is null || entry.Length > MaximumPackIconBytes) return null; + await using var stream = entry.Open(); + using var output = new MemoryStream((int)entry.Length); + var buffer = new byte[81920]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > MaximumPackIconBytes) return null; + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return null; + } + } + + private void EnsureReservedFolderAvailable(string name) + { + if (_store.List().Any(instance => string.Equals(instance.FolderName, name, StringComparison.OrdinalIgnoreCase))) + throw new BackupFolderOccupiedException(name); + } + + private string EnsureOutputDirectory(string name) + { + var path = InstanceStorage.ContainedPath(name, _store.GameRoot, allowBaseReparsePoint: true); + if (!Directory.Exists(path) && !EntryExists(path)) + { + try { InstanceStorage.CreateDirectoryExclusiveAt(_store.GameRoot, name); } + catch (IOException) when (EntryExists(path)) { } + path = InstanceStorage.ContainedPath(name, _store.GameRoot, allowBaseReparsePoint: true); + } + + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != FileAttributes.Directory) + throw new BackupFolderOccupiedException(name); + return path; + } + + private async Task WriteArchiveAsync(string partialPath, WorldBackupManifest manifest, + IReadOnlyList entries, CancellationToken cancellationToken, Action onCreated) + { + await using var output = new FileStream(partialPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + onCreated(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + var data = archive.CreateEntry("data.json", CompressionLevel.Optimal); + await using (var stream = data.Open()) + await JsonSerializer.SerializeAsync(stream, manifest, HakoJsonContext.Default.WorldBackupManifest, + cancellationToken).ConfigureAwait(false); + await InstanceFileTree.AddToArchiveAsync(archive, entries, "world/", cancellationToken, + _beforeArchiveEntry).ConfigureAwait(false); + } + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + internal static bool EntryExists(string path) + { + try { _ = File.GetAttributes(path); return true; } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } +} diff --git a/windows/Hako.Core/JsonContexts.cs b/windows/Hako.Core/JsonContexts.cs index f1b156c..ae43777 100644 --- a/windows/Hako.Core/JsonContexts.cs +++ b/windows/Hako.Core/JsonContexts.cs @@ -1,4 +1,7 @@ using System.Text.Json.Serialization; +using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Hako.Core.Playtime; namespace Hako.Core; @@ -7,7 +10,36 @@ namespace Hako.Core; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(UserSettings))] +[JsonSerializable(typeof(InstanceRegistryFile))] +[JsonSerializable(typeof(InstanceBackupManifest))] +[JsonSerializable(typeof(WorldBackupManifest))] +[JsonSerializable(typeof(ModRegistry))] +[JsonSerializable(typeof(ModOrigin))] +[JsonSerializable(typeof(FabricApiDescriptor))] +[JsonSerializable(typeof(FabricConfiguration))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(FabricModMetadata))] +[JsonSerializable(typeof(FabricProfile))] +[JsonSerializable(typeof(FabricLoaderEntry[]))] +[JsonSerializable(typeof(ModrinthVersion[]))] +[JsonSerializable(typeof(ModrinthSearchPage))] +[JsonSerializable(typeof(ModrinthProject[]))] +[JsonSerializable(typeof(string[][]))] +[JsonSerializable(typeof(ModrinthFileCache))] +[JsonSerializable(typeof(ModrinthHashesRequest))] +[JsonSerializable(typeof(ModrinthVersionUpdateRequest))] +[JsonSerializable(typeof(ModpackManifest))] +[JsonSerializable(typeof(ModpackPlan))] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ModrinthVersionHashMatchDictionary")] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ModrinthVersionDictionary")] [JsonSerializable(typeof(AccountSession))] +[JsonSerializable(typeof(GameProcessRecord))] +[JsonSerializable(typeof(PlaytimeLedger))] +[JsonSerializable(typeof(PlaytimeOwnerRecord))] +[JsonSerializable(typeof(PlaytimeSessionRecord))] +[JsonSerializable(typeof(PlaytimePendingTransfer))] +[JsonSerializable(typeof(PlaytimeSessionJournal))] +[JsonSerializable(typeof(PlaytimeProcessIdentity))] [JsonSerializable(typeof(DeviceCodeResponse))] [JsonSerializable(typeof(MicrosoftTokenResponse))] [JsonSerializable(typeof(OAuthErrorResponse))] diff --git a/windows/Hako.Core/MicrosoftDeviceAuth.cs b/windows/Hako.Core/MicrosoftDeviceAuth.cs index 48d60f3..d8fcf77 100644 --- a/windows/Hako.Core/MicrosoftDeviceAuth.cs +++ b/windows/Hako.Core/MicrosoftDeviceAuth.cs @@ -135,6 +135,103 @@ public async Task SignInAsync(MicrosoftToken token, Cancellation minecraft?.ExpiresAt); } + public async Task ConnectMinecraftAsync( + string refreshToken, + Func persistRefreshToken, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken); + ArgumentNullException.ThrowIfNull(persistRefreshToken); + + using var refreshResponse = await httpClient.PostAsync( + new Uri(OAuthBase, "token"), + new FormUrlEncodedContent( + [ + new("grant_type", "refresh_token"), + new("client_id", ClientId), + new("refresh_token", refreshToken) + ]), + cancellationToken); + var refreshBytes = await refreshResponse.Content.ReadAsByteArrayAsync(cancellationToken); + if (!refreshResponse.IsSuccessStatusCode) + { + var error = TryDeserialize(refreshBytes, HakoJsonContext.Default.OAuthErrorResponse)?.Error; + throw error == "invalid_grant" + ? new AuthServiceException(AuthErrorKind.RefreshTokenRejected, (int)refreshResponse.StatusCode) + : OAuthFailure(refreshBytes, refreshResponse.StatusCode); + } + + var token = JsonSerializer.Deserialize(refreshBytes, HakoJsonContext.Default.MicrosoftTokenResponse); + if (token?.AccessToken is not { Length: > 0 } accessToken + || token.RefreshToken is not { Length: > 0 } rotatedRefreshToken) + { + throw new AuthServiceException(AuthErrorKind.InvalidResponse); + } + + // Persist the rotation before making any Xbox or Minecraft request. If those later fail, + // the next attempt must use the newest Microsoft refresh token. + await persistRefreshToken(rotatedRefreshToken, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + var userToken = await XboxRequestAsync(XboxUserAuth, new XboxAuthRequest( + new XboxAuthProperties("RPS", "user.auth.xboxlive.com", "d=" + accessToken, null, null)), + "http://auth.xboxlive.com", cancellationToken); + var xboxUserToken = userToken.Token ?? throw new AuthServiceException(AuthErrorKind.InvalidResponse); + var xsts = await AuthorizeXboxAsync(xboxUserToken, "rp://api.minecraftservices.com/", cancellationToken); + var claims = xsts.DisplayClaims?.Xui?.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(claims?.UserHash) || string.IsNullOrWhiteSpace(xsts.Token)) + { + throw new AuthServiceException(AuthErrorKind.InvalidResponse); + } + + var loginRequest = new MinecraftLoginRequest($"XBL3.0 x={claims.UserHash};{xsts.Token}"); + using var loginMessage = CreateJsonRequest(MinecraftLogin, loginRequest, HakoJsonContext.Default.MinecraftLoginRequest); + using var loginResponse = await httpClient.SendAsync(loginMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (loginResponse.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.NotFound) + { + throw new AuthServiceException(AuthErrorKind.MinecraftLicenseMissing, (int)loginResponse.StatusCode); + } + + if (!loginResponse.IsSuccessStatusCode) + { + throw new AuthServiceException(AuthErrorKind.InvalidResponse, (int)loginResponse.StatusCode); + } + + var loginBytes = await loginResponse.Content.ReadAsByteArrayAsync(cancellationToken); + var login = JsonSerializer.Deserialize(loginBytes, HakoJsonContext.Default.MinecraftLoginResponse); + if (login?.AccessToken is not { Length: > 0 } minecraftAccessToken || login.ExpiresIn <= 0) + { + throw new AuthServiceException(AuthErrorKind.InvalidResponse); + } + + using var profileRequest = new HttpRequestMessage(HttpMethod.Get, MinecraftProfile); + profileRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", minecraftAccessToken); + using var profileResponse = await httpClient.SendAsync(profileRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (profileResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new AuthServiceException(AuthErrorKind.MinecraftProfileMissing, (int)profileResponse.StatusCode); + } + + if (profileResponse.StatusCode == HttpStatusCode.Forbidden) + { + throw new AuthServiceException(AuthErrorKind.MinecraftLicenseMissing, (int)profileResponse.StatusCode); + } + + if (!profileResponse.IsSuccessStatusCode) + { + throw new AuthServiceException(AuthErrorKind.InvalidResponse, (int)profileResponse.StatusCode); + } + + var profileBytes = await profileResponse.Content.ReadAsByteArrayAsync(cancellationToken); + var profile = JsonSerializer.Deserialize(profileBytes, HakoJsonContext.Default.MinecraftProfileResponse); + if (string.IsNullOrWhiteSpace(profile?.Id) || string.IsNullOrWhiteSpace(profile.Name)) + { + throw new AuthServiceException(AuthErrorKind.MinecraftProfileMissing); + } + + return new MinecraftConnection(profile.Id!, profile.Name!, minecraftAccessToken, timeProvider.GetUtcNow().AddSeconds(login.ExpiresIn)); + } + private async Task AuthorizeXboxAsync(string userToken, string relyingParty, CancellationToken cancellationToken) { return await XboxRequestAsync(XboxXstsAuth, new XboxAuthRequest( diff --git a/windows/Hako.Core/Minecraft/FabricClient.cs b/windows/Hako.Core/Minecraft/FabricClient.cs new file mode 100644 index 0000000..4c39320 --- /dev/null +++ b/windows/Hako.Core/Minecraft/FabricClient.cs @@ -0,0 +1,289 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +/// Minimal official Fabric Meta and Modrinth client used by installation and creation preflight. +public sealed class FabricClient : IDisposable +{ + public const string FabricApiProjectId = "P7dR8mSH"; + public const string UserAgent = ModrinthClient.UserAgent; + private const long MaximumApiSize = 64L * 1024 * 1024; + private const int MaximumMetadataSize = 1024 * 1024; + private const int MaximumResponseSize = 32 * 1024 * 1024; + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly ModrinthClient _modrinth; + private readonly string _cacheRoot; + private readonly Uri _metaBase; + + public ModrinthClient Modrinth => _modrinth; + + public FabricClient(HttpClient? httpClient = null, HakoPaths? paths = null, string? cacheRoot = null, + Uri? metaBase = null, Uri? modrinthBase = null) + { + _ownsHttpClient = httpClient is null; + _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + _cacheRoot = Path.GetFullPath(cacheRoot ?? Path.Combine(paths?.CacheRoot + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Hako", "Cache"), "FabricAPI")); + _metaBase = EnsureTrailingSlash(metaBase ?? new Uri("https://meta.fabricmc.net/v2/")); + _modrinth = new ModrinthClient(_httpClient, modrinthBase, paths); + } + + public async Task PrepareAsync(string minecraft, int java, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(minecraft) || java < 1) + throw new ArgumentException("Minecraft version and Java major version are required."); + + var loadersTask = GetLoaderVersionsAsync(minecraft, cancellationToken); + var api = await GetLatestApiAsync(minecraft, cancellationToken).ConfigureAwait(false); + var cached = await DownloadApiAsync(api, cancellationToken).ConfigureAwait(false); + var metadata = await ReadModMetadataAsync(cached, cancellationToken).ConfigureAwait(false); + var loaders = await loadersTask.ConfigureAwait(false); + var compatible = loaders.Where(loader => metadata.Supports(loader.Version, java)).ToArray(); + if (compatible.Length == 0) + throw new MojangException(MojangError.Unsupported, "Fabric API is incompatible with this Java and Minecraft version."); + return new FabricPreparation(api, metadata, compatible); + } + + public async Task> GetLoaderVersionsAsync(string minecraft, CancellationToken cancellationToken = default) + { + var uri = new Uri(_metaBase, $"versions/loader/{Uri.EscapeDataString(minecraft)}"); + var bytes = await GetBytesAsync(uri, MaximumResponseSize, allowEmptyBadRequest: true, cancellationToken).ConfigureAwait(false); + var entries = JsonSerializer.Deserialize(bytes, FabricJsonContext.Default.FabricLoaderEntryArray) + ?? throw new MojangException(MojangError.Invalid, "Fabric returned an empty loader list."); + return entries.Select(entry => entry.Loader).ToArray(); + } + + public async Task GetLatestApiAsync(string minecraft, CancellationToken cancellationToken = default) + { + var versions = await _modrinth.GetProjectVersionsAsync(FabricApiProjectId, [minecraft], ["fabric"], cancellationToken) + .ConfigureAwait(false); + var selected = SelectLatestApiVersion(versions, minecraft); + var file = selected.Files.FirstOrDefault(candidate => IsMainJar(candidate) && candidate.Primary) + ?? selected.Files.FirstOrDefault(IsMainJar); + if (file is null || file.Size is <= 0 or > MaximumApiSize + || !InstanceStorage.IsSinglePathSegment(file.Filename) + || !file.Filename.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) + || !file.Hashes.TryGetValue("sha1", out var sha1) || !IsHash(sha1, 40) + || !file.Hashes.TryGetValue("sha512", out var sha512) || !IsHash(sha512, 128)) + throw new MojangException(MojangError.Invalid, "Modrinth returned a Fabric API file without valid checksums."); + RequireHttps(file.Url); + return new FabricApiDescriptor(selected.ProjectId, selected.Id, selected.VersionNumber, + selected.VersionType, file.Filename, file.Url.AbsoluteUri, file.Size, sha1.ToLowerInvariant(), sha512.ToLowerInvariant()); + } + + internal static ModrinthVersion SelectLatestApiVersion(IEnumerable versions, string minecraft) + { + var priority = new Dictionary(StringComparer.Ordinal) { ["release"] = 0, ["beta"] = 1, ["alpha"] = 2 }; + return versions.Where(version => version.ProjectId == FabricApiProjectId + && version.GameVersions.Contains(minecraft, StringComparer.Ordinal) + && version.Loaders.Contains("fabric", StringComparer.Ordinal) + && priority.ContainsKey(version.VersionType)) + .OrderBy(version => priority[version.VersionType]) + .ThenByDescending(version => version.DatePublished) + .FirstOrDefault() + ?? throw new MojangException(MojangError.Unsupported, $"No compatible Fabric API exists for Minecraft {minecraft}."); + } + + public async Task<(FabricProfile Profile, byte[] Data)> GetProfileAsync(string minecraft, string loader, CancellationToken cancellationToken = default) + { + var uri = new Uri(_metaBase, + $"versions/loader/{Uri.EscapeDataString(minecraft)}/{Uri.EscapeDataString(loader)}/profile/json"); + var bytes = await GetBytesAsync(uri, MaximumResponseSize, false, cancellationToken).ConfigureAwait(false); + var profile = JsonSerializer.Deserialize(bytes, FabricJsonContext.Default.FabricProfile) + ?? throw new MojangException(MojangError.Invalid, "Fabric returned an empty profile."); + profile.Validate(minecraft, loader); + return (profile, bytes); + } + + public static async Task ReadInstalledProfileAsync(string instanceRoot, string minecraft, string loader, + string? expectedSha1, CancellationToken cancellationToken = default) + { + if (!IsHash(expectedSha1, 40)) + throw new MojangException(MojangError.Invalid, "Fabric profile checksum is missing."); + var path = InstanceStorage.ContainedPath("minecraft/.hako-fabric.json", instanceRoot, allowBaseReparsePoint: true); + if (!File.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new MojangException(MojangError.Invalid, "Fabric profile is missing."); + if (new FileInfo(path).Length > MaximumResponseSize) + throw new MojangException(MojangError.Invalid, "Fabric profile is too large."); + var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); + if (!Convert.ToHexString(SHA1.HashData(bytes)).Equals(expectedSha1, StringComparison.OrdinalIgnoreCase)) + throw new MojangException(MojangError.Integrity, "Fabric profile checksum does not match this build."); + var profile = JsonSerializer.Deserialize(bytes, FabricJsonContext.Default.FabricProfile) + ?? throw new MojangException(MojangError.Invalid, "Fabric profile is empty."); + profile.Validate(minecraft, loader); + return profile; + } + + public async Task> ResolveLibrariesAsync(FabricProfile profile, CancellationToken cancellationToken = default) + { + var result = new List(profile.Libraries.Count); + foreach (var library in profile.Libraries) + { + cancellationToken.ThrowIfCancellationRequested(); + var maven = FabricMavenLibrary.Parse(library.Name); + RequireHttps(library.Url); + if (library.Size is < 0) throw new MojangException(MojangError.Invalid, "Fabric returned an invalid Maven library size."); + var url = new Uri(library.Url, maven.Path); + RequireHttps(url); + var sha1 = library.Sha1; + if (sha1 is null) + { + var checksum = await GetBytesAsync(new Uri(url.AbsoluteUri + ".sha1"), 256, false, cancellationToken).ConfigureAwait(false); + sha1 = System.Text.Encoding.UTF8.GetString(checksum).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + } + + if (!IsHash(sha1, 40)) throw new MojangException(MojangError.Invalid, "Fabric omitted a valid Maven library SHA-1 checksum."); + result.Add(new LibraryInstallation(new MojangDownload + { + Url = url.AbsoluteUri, + Sha1 = sha1!.ToLowerInvariant(), + Size = library.Size, + Path = maven.Path + }, maven.Path)); + } + + return result; + } + + public async Task DownloadApiAsync(FabricApiDescriptor api, CancellationToken cancellationToken = default) + { + ValidateDescriptor(api); + var destination = InstanceStorage.ContainedPath(api.Sha512.ToLowerInvariant() + ".jar", _cacheRoot, allowBaseReparsePoint: true); + try + { + await ValidateApiFileAsync(destination, api, cancellationToken).ConfigureAwait(false); + return destination; + } + catch (Exception exception) when (exception is IOException or MojangException) + { + if (File.Exists(destination)) File.Delete(destination); + } + + await _modrinth.DownloadFileAsync(new Uri(api.Url), destination, api.Size, api.Sha1, api.Sha512, cancellationToken) + .ConfigureAwait(false); + return destination; + } + + public static async Task ValidateApiFileAsync(string path, FabricApiDescriptor api, CancellationToken cancellationToken = default) + { + ValidateDescriptor(api); + if (!File.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new MojangException(MojangError.Integrity, "Fabric API file is missing or unsafe."); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + if (stream.Length != api.Size) throw new MojangException(MojangError.Integrity, "Fabric API file size does not match Modrinth."); + using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + using var sha512 = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[81920]; + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + sha1.AppendData(buffer, 0, read); + sha512.AppendData(buffer, 0, read); + } + + if (!HashEquals(sha1.GetHashAndReset(), api.Sha1) || !HashEquals(sha512.GetHashAndReset(), api.Sha512)) + throw new MojangException(MojangError.Integrity, "Fabric API file failed its SHA-1 or SHA-512 check."); + } + + public static async Task ReadModMetadataAsync(string archivePath, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await using var file = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var archive = new System.IO.Compression.ZipArchive(file, System.IO.Compression.ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.GetEntry("fabric.mod.json") + ?? throw new MojangException(MojangError.Invalid, "Fabric API JAR has no fabric.mod.json."); + if (entry.Length > MaximumMetadataSize) throw new MojangException(MojangError.Invalid, "fabric.mod.json exceeds the allowed size."); + await using var input = entry.Open(); + using var output = new MemoryStream((int)entry.Length); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > MaximumMetadataSize) + throw new MojangException(MojangError.Invalid, "fabric.mod.json exceeds the allowed size."); + output.Write(buffer, 0, read); + } + + return JsonSerializer.Deserialize(output.ToArray(), FabricJsonContext.Default.FabricModMetadata) + ?? throw new MojangException(MojangError.Invalid, "fabric.mod.json is empty."); + } + + public void Dispose() + { + _modrinth.Dispose(); + if (_ownsHttpClient) _httpClient.Dispose(); + } + + private async Task GetBytesAsync(Uri uri, int maximumBytes, bool allowEmptyBadRequest, CancellationToken cancellationToken) + { + using var response = await SendAsync(uri, cancellationToken, allowEmptyBadRequest).ConfigureAwait(false); + if (allowEmptyBadRequest && response.StatusCode == System.Net.HttpStatusCode.BadRequest) return "[]"u8.ToArray(); + if (response.Content.Headers.ContentLength is { } length && length > maximumBytes) + throw new MojangException(MojangError.Invalid, "Fabric or Modrinth response is too large."); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > maximumBytes) throw new MojangException(MojangError.Invalid, "Fabric or Modrinth response is too large."); + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + private async Task SendAsync(Uri uri, CancellationToken cancellationToken, bool allowEmptyBadRequest = false) + { + RequireHttps(uri); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.UserAgent.ParseAdd(UserAgent); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (allowEmptyBadRequest && response.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (bytes.AsSpan().SequenceEqual("[]"u8)) return response; + response.Dispose(); + throw new MojangException(MojangError.Http, "Fabric returned an invalid loader response.", (int)System.Net.HttpStatusCode.BadRequest); + } + + if (!response.IsSuccessStatusCode) + { + var status = (int)response.StatusCode; + response.Dispose(); + throw new MojangException(MojangError.Http, $"Fabric or Modrinth returned HTTP {status}.", status); + } + + return response; + } + + private static bool IsMainJar(ModrinthFile file) => + file.Filename.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) + && file.FileType is not ("sources-jar" or "dev-jar" or "javadoc-jar"); + + private static void ValidateDescriptor(FabricApiDescriptor api) + { + if (api.ProjectID != FabricApiProjectId || !InstanceStorage.IsSinglePathSegment(api.Filename) + || !api.Filename.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) + || api.Size is <= 0 or > MaximumApiSize || !IsHash(api.Sha1, 40) || !IsHash(api.Sha512, 128) + || !Uri.TryCreate(api.Url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + throw new MojangException(MojangError.Invalid, "Invalid Fabric API descriptor."); + } + + private static bool IsHash(string? value, int length) => value is { Length: var size } && size == length && value.All(Uri.IsHexDigit); + private static bool HashEquals(byte[] hash, string expected) => Convert.ToHexString(hash).Equals(expected, StringComparison.OrdinalIgnoreCase); + private static void RequireHttps(Uri uri) + { + if (!uri.IsAbsoluteUri || uri.Scheme != Uri.UriSchemeHttps) + throw new MojangException(MojangError.Invalid, "Fabric and Modrinth URLs must use HTTPS."); + } + private static Uri EnsureTrailingSlash(Uri uri) => new(uri.AbsoluteUri.TrimEnd('/') + "/"); +} diff --git a/windows/Hako.Core/Minecraft/FabricModels.cs b/windows/Hako.Core/Minecraft/FabricModels.cs new file mode 100644 index 0000000..bde694a --- /dev/null +++ b/windows/Hako.Core/Minecraft/FabricModels.cs @@ -0,0 +1,381 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public sealed record FabricLoaderVersion(string Version, bool Stable); + +public sealed record FabricLoaderEntry(FabricLoaderVersion Loader); + +public sealed record FabricProfileLibrary +{ + public string Name { get; init; } = ""; + public Uri Url { get; init; } = new("https://maven.fabricmc.net/"); + public string? Sha1 { get; init; } + public long? Size { get; init; } + + public string MavenPath => FabricMavenLibrary.Parse(Name).Path; +} + +public sealed record FabricProfile +{ + public string Id { get; init; } = ""; + public string InheritsFrom { get; init; } = ""; + public string MainClass { get; init; } = ""; + public Dictionary>? Arguments { get; init; } + public List Libraries { get; init; } = []; + + public void Validate(string minecraft, string loader) + { + if (!string.Equals(InheritsFrom, minecraft, StringComparison.Ordinal) + || !string.Equals(Id, $"fabric-loader-{loader}-{minecraft}", StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(MainClass)) + { + throw new MojangException(MojangError.Invalid, "Fabric returned a profile that does not match this build."); + } + + _ = InstanceStorage.ContainedPath(Id, Path.GetTempPath()); + } +} + +public sealed record FabricMavenLibrary(string Group, string Artifact, string Version, string? Classifier) +{ + private static readonly Regex Segment = new("^[A-Za-z0-9_.+\\-]+$", RegexOptions.CultureInvariant); + + public string Path + { + get + { + var groupPath = string.Join('/', Group.Split('.')); + var suffix = Classifier is null ? "" : "-" + Classifier; + return $"{groupPath}/{Artifact}/{Version}/{Artifact}-{Version}{suffix}.jar"; + } + } + + public string Key => $"{Group}:{Artifact}" + (Classifier is null ? "" : $":{Classifier}"); + + public static FabricMavenLibrary Parse(string value) + { + var parts = value.Split(':'); + if (parts.Length is < 3 or > 4 || parts.Any(part => part.Length == 0 || !Segment.IsMatch(part))) + { + throw new MojangException(MojangError.Invalid, "Fabric returned an invalid Maven library name."); + } + + return new FabricMavenLibrary(parts[0], parts[1], parts[2], parts.Length == 4 ? parts[3] : null); + } + + public static string? KeyFromPath(string path) + { + var parts = path.Replace('\\', '/').Split('/'); + if (parts.Length < 4 || !parts[^1].EndsWith(".jar", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var artifact = parts[^3]; + var filename = parts[^1][..^4]; + var prefix = artifact + "-" + parts[^2]; + if (!filename.StartsWith(prefix, StringComparison.Ordinal)) + { + return null; + } + + var classifier = filename.Length == prefix.Length ? null : filename[prefix.Length..].TrimStart('-'); + if (classifier is { Length: 0 }) + { + return null; + } + + var group = string.Join('.', parts[..^3]); + return group.Length == 0 ? null : $"{group}:{artifact}" + (classifier is null ? "" : $":{classifier}"); + } +} + +[JsonConverter(typeof(FabricVersionPredicateJsonConverter))] +public sealed record FabricVersionPredicate(IReadOnlyList Alternatives) +{ + public FabricVersionPredicate(string value) : this([value]) { } + + public bool Matches(string version) + { + foreach (var alternative in Alternatives) + { + var terms = alternative.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (terms.Length == 0) + { + throw Invalid("A Fabric version requirement is empty."); + } + + if (terms.All(term => MatchesTerm(term, version))) + { + return true; + } + } + + return false; + } + + private static bool MatchesTerm(string term, string value) + { + if (term is "*" or "x" or "X") + { + return true; + } + + var operation = new[] { ">=", "<=", ">", "<", "=", "~", "^" } + .FirstOrDefault(candidate => term.StartsWith(candidate, StringComparison.Ordinal)) ?? "="; + var text = operation == "=" && !term.StartsWith('=') ? term : term[operation.Length..]; + if (text.Length == 0) + { + throw Invalid($"Invalid Fabric version requirement: {term}."); + } + + var pieces = text.Split('.'); + var wildcardIndex = Array.FindIndex(pieces, piece => piece is "*" or "x" or "X"); + if (wildcardIndex >= 0 && (wildcardIndex == 0 || pieces.Skip(wildcardIndex).Any(piece => piece is not ("*" or "x" or "X")))) + { + throw Invalid($"Invalid Fabric version requirement: {term}."); + } + + var reference = FabricSemanticVersion.Parse(wildcardIndex < 0 ? text : string.Join('.', pieces.Take(wildcardIndex))); + var current = FabricSemanticVersion.Parse(value); + var comparison = current.CompareTo(reference); + if (wildcardIndex >= 0) + { + var upper = reference.Increment(wildcardIndex - 1); + return operation switch + { + ">" => current.CompareTo(upper) >= 0, + ">=" => comparison >= 0, + "<=" => current.CompareTo(upper) < 0, + "<" => comparison < 0, + _ => comparison >= 0 && current.CompareTo(upper) < 0 + }; + } + + if (operation is "~" or "^") + { + return comparison >= 0 && current.CompareTo(reference.Increment(operation == "~" && reference.Numbers.Count > 1 ? 1 : 0)) < 0; + } + + return operation switch + { + ">=" => comparison >= 0, + ">" => comparison > 0, + "<=" => comparison <= 0, + "<" => comparison < 0, + _ => comparison == 0 + }; + } + + private static MojangException Invalid(string message) => new(MojangError.Invalid, message); +} + +public sealed class FabricVersionPredicateJsonConverter : JsonConverter +{ + public override FabricVersionPredicate Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new FabricVersionPredicate(reader.GetString() ?? ""); + } + + if (reader.TokenType == JsonTokenType.StartArray) + { + var alternatives = JsonSerializer.Deserialize(ref reader, HakoJsonContext.Default.StringArray) + ?? throw new JsonException("Fabric version alternatives are missing."); + return new FabricVersionPredicate(alternatives); + } + + throw new JsonException("Fabric version requirement must be a string or array."); + } + + public override void Write(Utf8JsonWriter writer, FabricVersionPredicate value, JsonSerializerOptions options) + { + if (value.Alternatives.Count == 1) + { + writer.WriteStringValue(value.Alternatives[0]); + return; + } + + JsonSerializer.Serialize(writer, value.Alternatives.ToArray(), HakoJsonContext.Default.StringArray); + } +} + +public sealed record FabricModMetadata +{ + public string Id { get; init; } = ""; + public Dictionary? Depends { get; init; } + + public bool Supports(string loader, int java) => + (Depends?.GetValueOrDefault("fabricloader") ?? new FabricVersionPredicate("*")).Matches(loader) + && (Depends?.GetValueOrDefault("java") ?? new FabricVersionPredicate("*")).Matches(java.ToString(CultureInfo.InvariantCulture)); +} + +public sealed record FabricPreparation( + FabricApiDescriptor Api, + FabricModMetadata Metadata, + IReadOnlyList Loaders); + +public sealed record ModrinthVersion +{ + public string Id { get; init; } = ""; + [JsonPropertyName("project_id")] + public string ProjectId { get; init; } = ""; + [JsonPropertyName("version_number")] + public string VersionNumber { get; init; } = ""; + [JsonPropertyName("version_type")] + public string VersionType { get; init; } = ""; + [JsonPropertyName("date_published")] + public DateTimeOffset DatePublished { get; init; } + [JsonPropertyName("game_versions")] + public List GameVersions { get; init; } = []; + public List Loaders { get; init; } = []; + public List Files { get; init; } = []; + public List Dependencies { get; init; } = []; + + public ModrinthFile? InstallableFile(ModrinthContentKind kind) + { + var candidates = Files.Where(file => file.IsInstallable(kind) + && (kind != ModrinthContentKind.Datapack || !file.IsResourcePack)).ToArray(); + return candidates.FirstOrDefault(file => file.Primary) ?? candidates.FirstOrDefault(); + } + + [JsonIgnore] + public IReadOnlyList ResourcePacks => Files + .Where(file => file.IsResourcePack && file.IsInstallable(ModrinthContentKind.ResourcePack)) + .OrderBy(file => file.FileType == "required-resource-pack" ? 0 : 1) + .ThenBy(file => file.Filename, StringComparer.Ordinal) + .ToArray(); + + public ModrinthFile? UpdateFile(ModrinthContentKind kind, string? fileType, string filename) + { + if (kind == ModrinthContentKind.Mod) return InstallableFile(kind); + var candidates = Files.Where(file => file.IsInstallable(kind) + && (kind != ModrinthContentKind.Datapack || !file.IsResourcePack) + && string.Equals(file.FileType, fileType, StringComparison.Ordinal)).ToArray(); + return candidates.FirstOrDefault(file => string.Equals(file.Filename, filename, StringComparison.OrdinalIgnoreCase)) + ?? (candidates.Length == 1 ? candidates[0] : null); + } +} + +public sealed record ModrinthDependency +{ + [JsonPropertyName("version_id")] + public string? VersionId { get; init; } + [JsonPropertyName("project_id")] + public string? ProjectId { get; init; } + [JsonPropertyName("dependency_type")] + public string Type { get; init; } = ""; +} + +public sealed record ModrinthFile +{ + public string Filename { get; init; } = ""; + public Uri Url { get; init; } = new("https://cdn.modrinth.com/"); + public long Size { get; init; } + public Dictionary Hashes { get; init; } = new(StringComparer.OrdinalIgnoreCase); + public bool Primary { get; init; } + [JsonPropertyName("file_type")] + public string? FileType { get; init; } + + public string Sha1 => Hashes.GetValueOrDefault("sha1", "").ToLowerInvariant(); + public string Sha512 => Hashes.GetValueOrDefault("sha512", "").ToLowerInvariant(); + public bool IsResourcePack => FileType is "required-resource-pack" or "optional-resource-pack"; + + public bool IsInstallable(ModrinthContentKind kind) => IsInstallable(kind switch + { + ModrinthContentKind.Mod => ".jar", + ModrinthContentKind.ResourcePack or ModrinthContentKind.Datapack => ".zip", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }); + + public bool IsInstallable(string fileExtension) + { + if (!InstanceStorage.IsSinglePathSegment(Filename) || Filename.StartsWith('.') + || !Filename.EndsWith(fileExtension, StringComparison.OrdinalIgnoreCase) + || !Uri.TryCreate(Url.ToString(), UriKind.Absolute, out var url) || url.Scheme != Uri.UriSchemeHttps + || Size <= 0 || !IsHash(Sha1, 40) || !IsHash(Sha512, 128) + || FileType is "sources-jar" or "dev-jar" or "javadoc-jar") + { + return false; + } + + var suffix = Path.GetFileNameWithoutExtension(Filename).Split('-', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + return suffix is not ("sources" or "source" or "dev" or "javadoc"); + } + + private static bool IsHash(string value, int length) => value.Length == length && value.All(Uri.IsHexDigit); +} + +internal readonly record struct FabricSemanticVersion(IReadOnlyList Numbers, IReadOnlyList Prerelease) : IComparable +{ + public static FabricSemanticVersion Parse(string value) + { + var withoutBuild = value.Split('+', 2)[0]; + var parts = withoutBuild.Split('-', 2); + var numberText = parts[0].Split('.'); + if (numberText.Length == 0 || numberText.Any(part => !int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var number) || number < 0)) + { + throw new MojangException(MojangError.Invalid, $"Could not parse Fabric version: {value}."); + } + + var numbers = numberText.Select(part => int.Parse(part, CultureInfo.InvariantCulture)).ToArray(); + var prerelease = parts.Length == 1 ? [] : parts[1].Split('.').ToArray(); + if (prerelease.Any(part => part.Length == 0)) + { + throw new MojangException(MojangError.Invalid, $"Could not parse Fabric version: {value}."); + } + + return new FabricSemanticVersion(numbers, prerelease); + } + + public FabricSemanticVersion Increment(int index) + { + var numbers = Numbers.ToList(); + while (numbers.Count <= index) numbers.Add(0); + numbers[index]++; + for (var next = index + 1; next < numbers.Count; next++) numbers[next] = 0; + return new FabricSemanticVersion(numbers, []); + } + + public int CompareTo(FabricSemanticVersion other) + { + for (var index = 0; index < Math.Max(Numbers.Count, other.Numbers.Count); index++) + { + var left = index < Numbers.Count ? Numbers[index] : 0; + var right = index < other.Numbers.Count ? other.Numbers[index] : 0; + if (left != right) return left.CompareTo(right); + } + + if (Prerelease.Count == 0 || other.Prerelease.Count == 0) + { + return Prerelease.Count == other.Prerelease.Count ? 0 : Prerelease.Count == 0 ? 1 : -1; + } + + for (var index = 0; index < Math.Min(Prerelease.Count, other.Prerelease.Count); index++) + { + var left = Prerelease[index]; + var right = other.Prerelease[index]; + if (left == right) continue; + var leftNumeric = int.TryParse(left, NumberStyles.None, CultureInfo.InvariantCulture, out var leftNumber); + var rightNumeric = int.TryParse(right, NumberStyles.None, CultureInfo.InvariantCulture, out var rightNumber); + if (leftNumeric && rightNumeric) return leftNumber.CompareTo(rightNumber); + if (leftNumeric != rightNumeric) return leftNumeric ? -1 : 1; + return string.CompareOrdinal(left, right); + } + + return Prerelease.Count.CompareTo(other.Prerelease.Count); + } +} + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(FabricLoaderEntry[]))] +[JsonSerializable(typeof(FabricProfile))] +[JsonSerializable(typeof(FabricModMetadata))] +[JsonSerializable(typeof(ModrinthVersion[]))] +internal partial class FabricJsonContext : JsonSerializerContext; diff --git a/windows/Hako.Core/Minecraft/FabricPreparationCommit.cs b/windows/Hako.Core/Minecraft/FabricPreparationCommit.cs new file mode 100644 index 0000000..c87a714 --- /dev/null +++ b/windows/Hako.Core/Minecraft/FabricPreparationCommit.cs @@ -0,0 +1,16 @@ +namespace Hako.Core.Minecraft; + +internal static class FabricPreparationCommit +{ + public static async Task PrepareAndCommitIfCurrentAsync( + Func> prepareAsync, + Func isCurrent, + Action commit) + { + var preparation = await prepareAsync(); + if (!isCurrent()) return false; + + commit(preparation); + return true; + } +} diff --git a/windows/Hako.Core/Minecraft/GameProcessRunner.cs b/windows/Hako.Core/Minecraft/GameProcessRunner.cs new file mode 100644 index 0000000..5e9fa69 --- /dev/null +++ b/windows/Hako.Core/Minecraft/GameProcessRunner.cs @@ -0,0 +1,475 @@ +using System.ComponentModel; +using System.Text.Json; +using Hako.Core.Instances; +using Microsoft.Win32.SafeHandles; + +namespace Hako.Core.Minecraft; + +public sealed record GameProcessRecord( + Guid InstanceID, + int Pid, + ulong StartSeconds, + ulong StartMicroseconds, + Guid? SessionID = null); + +public sealed record GameProcessLaunch(GameProcessRecord Record, string LogPath, Task Completion); + +public sealed class GameProcessRunner +{ + private readonly Func _creationTimeProvider; + private readonly uint? _codePage; + + public GameProcessRunner() : this(null) { } + + internal GameProcessRunner( + Func? creationTimeProvider, + uint? codePage = null) + { + _creationTimeProvider = creationTimeProvider ?? ReadCreationTime; + _codePage = codePage; + } + + /// Starts Minecraft without a shell and leaves it alive if Hako exits. + public Task StartAsync( + MinecraftLaunchPlan plan, + Guid instanceId, + string instanceRoot, + Guid? sessionId = null, + CancellationToken cancellationToken = default, + Action? onExit = null) + { + ArgumentNullException.ThrowIfNull(plan); + cancellationToken.ThrowIfCancellationRequested(); + var codePage = _codePage ?? JavaAnsiEncoding.CurrentCodePage; + return Task.Run(() => Start(plan, instanceId, instanceRoot, sessionId, codePage, cancellationToken, onExit), cancellationToken); + } + + public Task RestoreAsync( + Guid instanceId, + string instanceRoot, + CancellationToken cancellationToken = default, + Action? onExit = null) => Task.Run(() => Restore(instanceId, instanceRoot, _creationTimeProvider, cancellationToken, onExit), cancellationToken); + + internal bool UpdateSessionID(string instanceRoot, GameProcessRecord expected, Guid sessionID, out GameProcessRecord updated) + { + updated = expected; + var current = ReadRecord(instanceRoot); + if (current != expected || sessionID == Guid.Empty) return false; + updated = current with { SessionID = sessionID }; + WriteRecord(instanceRoot, updated); + return true; + } + + internal static bool MatchesIdentity(GameProcessRecord record, int pid, ulong startSeconds, ulong startMicroseconds) => + record.Pid == pid && record.StartSeconds == startSeconds && record.StartMicroseconds == startMicroseconds; + + /// + /// Waits for the recorded process only while its PID and creation time still match the record, + /// so a reused PID of an unrelated process is never awaited. Returns false when it already exited or was replaced. + /// + public static bool TryWaitForRecordedProcess(GameProcessRecord record, out int? exitCode) + { + ArgumentNullException.ThrowIfNull(record); + exitCode = null; + using var process = Win32Process.Open((uint)record.Pid); + if (process.IsInvalid + || !Win32Process.TryReadCreationTime(process, out var seconds, out var microseconds) + || !MatchesIdentity(record, record.Pid, seconds, microseconds) + || Win32Process.Wait(process, uint.MaxValue) != 0) + { + return false; + } + + exitCode = unchecked((int)Win32Process.GetExitCode(process)); + return true; + } + + private static (bool Success, ulong StartSeconds, ulong StartMicroseconds) ReadCreationTime(SafeProcessHandle process) + { + var success = Win32Process.TryReadCreationTime(process, out var seconds, out var microseconds); + return (success, seconds, microseconds); + } + + internal static string FormatArgumentFileValue(string value) + { + if (value.Contains('\0') || value.Contains('\r') || value.Contains('\n')) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ArgumentFileEncoding); + } + + // The JDK argument file parser treats ' as a quote and # at the start of a token as a comment. + if (value.Length > 0 && !value.Any(character => char.IsWhiteSpace(character) || character is '"' or '\\' or '\'' or '#')) + { + return value; + } + + return "\"" + value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + /// + /// Makes the executable and arguments representable in the ANSI code page java.exe reads them with, + /// replacing unrepresentable paths with 8.3 names. Java 8 receives them on the command line, so the whole + /// command line is checked as well. + /// + internal static (string Executable, IReadOnlyList Arguments) PrepareArguments( + MinecraftLaunchPlan plan, + uint codePage, + Func? shortPathProvider = null) + { + var executable = JavaAnsiEncoding.AdaptPaths(plan.Executable, codePage, shortPathProvider); + var arguments = plan.Arguments.Select(argument => JavaAnsiEncoding.AdaptPaths(argument, codePage, shortPathProvider)).ToArray(); + if (plan.JavaMajorVersion < 9) + { + _ = JavaAnsiEncoding.Encode(Win32Process.BuildCommandLine(executable, arguments), codePage); + } + + return (executable, arguments); + } + + /// + /// Creates the folders substituted into launch arguments. 8.3 names exist only for existing paths, so a + /// missing folder under a non-ANSI root would otherwise fail . + /// + internal static void EnsureLaunchDirectories(string instanceRoot) + { + foreach (var relative in new[] { "minecraft/natives", "minecraft/libraries", "minecraft/assets", "minecraft/logs" }) + { + System.IO.Directory.CreateDirectory(InstanceStorage.ContainedPath(relative, instanceRoot, allowBaseReparsePoint: true)); + } + } + + internal static byte[] EncodeArgumentFile(IReadOnlyList arguments, uint codePage) => + JavaAnsiEncoding.Encode(string.Join("\r\n", arguments.Select(FormatArgumentFileValue)) + "\r\n", codePage); + + private static GameProcessLaunch Start( + MinecraftLaunchPlan plan, + Guid instanceId, + string instanceRoot, + Guid? sessionId, + uint codePage, + CancellationToken cancellationToken, + Action? onExit) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureLaunchDirectories(instanceRoot); + var (executable, javaArguments) = PrepareArguments(plan, codePage); + string? argumentFile = null; + if (plan.JavaMajorVersion >= 9) + { + argumentFile = WriteArgumentFile(instanceRoot, javaArguments, codePage); + try + { + javaArguments = [JavaAnsiEncoding.AdaptPaths("@" + argumentFile, codePage)]; + } + catch + { + TryDelete(argumentFile); + throw; + } + } + + var logPath = InstanceStorage.ContainedPath("minecraft/logs/hako-launch.log", instanceRoot, allowBaseReparsePoint: true); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(logPath)!); + logPath = InstanceStorage.ContainedPath("minecraft/logs/hako-launch.log", instanceRoot, allowBaseReparsePoint: true); + + FileStream? log = null; + SafeProcessHandle? process = null; + try + { + log = new FileStream(logPath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, + FileOptions.SequentialScan); + WindowsFileSecurity.RestrictToCurrentUser(logPath); + cancellationToken.ThrowIfCancellationRequested(); + + var processInfo = Win32Process.Start(executable, javaArguments, plan.WorkingDirectory, log.SafeFileHandle); + process = processInfo.Process; + log.Dispose(); + log = null; + + if (!Win32Process.TryReadCreationTime(process, out var startSeconds, out var startMicroseconds)) + { + Win32Process.Terminate(process, 1); + throw new MinecraftLaunchException(MinecraftLaunchError.ProcessStartFailed); + } + + var record = new GameProcessRecord(instanceId, checked((int)processInfo.ProcessId), startSeconds, startMicroseconds, sessionId); + try + { + WriteRecord(instanceRoot, record); + } + catch + { + Win32Process.Terminate(process, 1); + throw; + } + + if (cancellationToken.IsCancellationRequested) + { + Win32Process.Terminate(process, 1); + RemoveRecordIfCurrent(instanceRoot, record); + throw new OperationCanceledException(cancellationToken); + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = ObserveAsync(record, instanceRoot, process, argumentFile, completion, onExit); + process = null; + return new GameProcessLaunch(record, logPath, completion.Task); + } + catch + { + process?.Dispose(); + log?.Dispose(); + if (argumentFile is not null) + { + TryDelete(argumentFile); + } + + throw; + } + } + + private static GameProcessLaunch? Restore( + Guid instanceId, + string instanceRoot, + Func creationTimeProvider, + CancellationToken cancellationToken, + Action? onExit) + { + cancellationToken.ThrowIfCancellationRequested(); + GameProcessRecord? record; + try + { + record = ReadRecord(instanceRoot); + } + catch (Exception exception) when (exception is IOException or JsonException or UnauthorizedAccessException) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ProcessRecordInvalid, innerException: exception); + } + + if (record is null) + { + DeleteArgumentFiles(instanceRoot); + return null; + } + + if (record.InstanceID != instanceId || record.Pid <= 0) + { + RemoveRecordIfCurrent(instanceRoot, record); + DeleteArgumentFiles(instanceRoot); + return null; + } + + var process = Win32Process.Open((uint)record.Pid); + var (timeRead, seconds, microseconds) = process.IsInvalid + ? (false, 0UL, 0UL) + : creationTimeProvider(process); + if (!timeRead || !MatchesIdentity(record, record.Pid, seconds, microseconds)) + { + process.Dispose(); + RemoveRecordIfCurrent(instanceRoot, record); + DeleteArgumentFiles(instanceRoot); + return null; + } + + var waitResult = Win32Process.Wait(process, 0); + if (waitResult == 0) + { + process.Dispose(); + RemoveRecordIfCurrent(instanceRoot, record); + DeleteArgumentFiles(instanceRoot); + return null; + } + + if (waitResult != 0x00000102) + { + process.Dispose(); + throw new Win32Exception("WaitForSingleObject could not inspect the saved Minecraft process."); + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = ObserveAsync(record, instanceRoot, process, null, completion, onExit); + var logPath = InstanceStorage.ContainedPath("minecraft/logs/hako-launch.log", instanceRoot, allowBaseReparsePoint: true); + return new GameProcessLaunch(record, logPath, completion.Task); + } + + // Registers the process handle with the thread pool wait thread instead of blocking a worker + // for the whole game session. + private static async Task WaitForExitAsync(SafeProcessHandle process) + { + using var waitHandle = new ProcessWaitHandle(process); + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = ThreadPool.RegisterWaitForSingleObject( + waitHandle, static (state, _) => ((TaskCompletionSource)state!).TrySetResult(), exited, + Timeout.Infinite, executeOnlyOnce: true); + try + { + await exited.Task.ConfigureAwait(false); + } + finally + { + registration.Unregister(null); + } + } + + private sealed class ProcessWaitHandle : WaitHandle + { + public ProcessWaitHandle(SafeProcessHandle process) => + SafeWaitHandle = new SafeWaitHandle(process.DangerousGetHandle(), ownsHandle: false); + } + + private static async Task ObserveAsync( + GameProcessRecord record, + string instanceRoot, + SafeProcessHandle process, + string? argumentFile, + TaskCompletionSource completion, + Action? onExit) + { + try + { + await WaitForExitAsync(process).ConfigureAwait(false); + if (Win32Process.Wait(process, 0) != 0) + { + throw new Win32Exception("WaitForSingleObject failed while observing Minecraft."); + } + + var exitCode = unchecked((int)Win32Process.GetExitCode(process)); + if (argumentFile is not null) + { + TryDelete(argumentFile); + } + else + { + DeleteArgumentFiles(instanceRoot); + } + + RemoveRecordIfCurrent(instanceRoot, record); + completion.TrySetResult(exitCode); + try { onExit?.Invoke(exitCode); } + catch (Exception) { } + } + catch (Exception exception) + { + completion.TrySetException(exception); + try { onExit?.Invoke(null); } + catch (Exception) { } + } + finally + { + process.Dispose(); + } + } + + private static string WriteArgumentFile(string instanceRoot, IReadOnlyList arguments, uint codePage) + { + var bytes = EncodeArgumentFile(arguments, codePage); + var launchDirectory = InstanceStorage.ContainedPath("minecraft/.hako-launch", instanceRoot, allowBaseReparsePoint: true); + System.IO.Directory.CreateDirectory(launchDirectory); + _ = InstanceStorage.ContainedPath("minecraft/.hako-launch", instanceRoot, allowBaseReparsePoint: true); + var path = InstanceStorage.ContainedPath($"minecraft/.hako-launch/{Guid.NewGuid():N}.args", instanceRoot, allowBaseReparsePoint: true); + try + { + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read, 4096, FileOptions.WriteThrough); + WindowsFileSecurity.RestrictToCurrentUser(path); + stream.Write(bytes); + stream.Flush(flushToDisk: true); + return path; + } + catch + { + TryDelete(path); + throw; + } + } + + private static void WriteRecord(string instanceRoot, GameProcessRecord record) + { + var path = InstanceStorage.ContainedPath("minecraft/.hako-running.json", instanceRoot, allowBaseReparsePoint: true); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var data = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(record, HakoJsonContext.Default.GameProcessRecord); + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + stream.Write(data); + stream.Flush(flushToDisk: true); + } + + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + TryDelete(temporaryPath); + } + } + + private static GameProcessRecord? ReadRecord(string instanceRoot) + { + var path = InstanceStorage.ContainedPath("minecraft/.hako-running.json", instanceRoot, allowBaseReparsePoint: true); + if (!File.Exists(path)) + { + return null; + } + + if (new FileInfo(path).Length > 64 * 1024) + { + throw new JsonException("The running game record is too large."); + } + + var record = System.Text.Json.JsonSerializer.Deserialize(File.ReadAllBytes(path), HakoJsonContext.Default.GameProcessRecord); + if (record is null || record.InstanceID == Guid.Empty || record.Pid <= 0 + || record.StartSeconds == 0 || record.StartMicroseconds >= 1_000_000) + { + throw new JsonException("The running game record is invalid."); + } + + return record; + } + + private static void RemoveRecordIfCurrent(string instanceRoot, GameProcessRecord record) + { + GameProcessRecord? current; + try + { + current = ReadRecord(instanceRoot); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return; + } + + if (current is not null && current.InstanceID == record.InstanceID + && MatchesIdentity(current, record.Pid, record.StartSeconds, record.StartMicroseconds) + && current.SessionID == record.SessionID) + { + var path = InstanceStorage.ContainedPath("minecraft/.hako-running.json", instanceRoot, allowBaseReparsePoint: true); + TryDelete(path); + } + } + + private static void DeleteArgumentFiles(string instanceRoot) + { + var directory = InstanceStorage.ContainedPath("minecraft/.hako-launch", instanceRoot, allowBaseReparsePoint: true); + if (!System.IO.Directory.Exists(directory)) + { + return; + } + + foreach (var path in System.IO.Directory.EnumerateFiles(directory, "*.args", SearchOption.TopDirectoryOnly)) + { + var safe = InstanceStorage.ContainedPath("minecraft/.hako-launch/" + Path.GetFileName(path), instanceRoot, allowBaseReparsePoint: true); + TryDelete(safe); + } + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/windows/Hako.Core/Minecraft/HostArchitecture.cs b/windows/Hako.Core/Minecraft/HostArchitecture.cs new file mode 100644 index 0000000..f9c3958 --- /dev/null +++ b/windows/Hako.Core/Minecraft/HostArchitecture.cs @@ -0,0 +1,124 @@ +using System.Runtime.InteropServices; + +namespace Hako.Core.Minecraft; + +public enum HostMachineArchitecture +{ + X86, + X64, + Arm64, + Unknown +} + +public sealed record HostArchitecture(HostMachineArchitecture Machine, bool Amd64UserEnabled); + +public interface IHostArchitectureProvider +{ + HostArchitecture GetHostArchitecture(); +} + +public sealed class WindowsHostArchitectureProvider : IHostArchitectureProvider +{ + private const ushort ImageFileMachineUnknown = 0x0000; + private const ushort ImageFileMachineI386 = 0x014c; + private const ushort ImageFileMachineAmd64 = 0x8664; + private const ushort ImageFileMachineArm64 = 0xaa64; + private const uint MachineTypeAttributeUserEnabled = 0x00000001; + + public HostArchitecture GetHostArchitecture() + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("Minecraft Windows installations require Windows."); + } + + if (!IsWow64Process2(GetCurrentProcess(), out _, out var nativeMachine)) + { + throw new InvalidOperationException($"Could not determine the Windows machine architecture (Win32 error {Marshal.GetLastWin32Error()})."); + } + + var machine = nativeMachine switch + { + ImageFileMachineI386 => HostMachineArchitecture.X86, + ImageFileMachineAmd64 => HostMachineArchitecture.X64, + ImageFileMachineArm64 => HostMachineArchitecture.Arm64, + ImageFileMachineUnknown => HostMachineArchitecture.Unknown, + _ => HostMachineArchitecture.Unknown + }; + + var amd64UserEnabled = machine == HostMachineArchitecture.Arm64 && IsAmd64UserEnabled(); + return new HostArchitecture(machine, amd64UserEnabled); + } + + internal delegate int MachineTypeAttributesQuery(ushort machine, out uint attributes); + + private static bool IsAmd64UserEnabled() => IsAmd64UserEnabled(GetMachineTypeAttributes); + + internal static bool IsAmd64UserEnabled(MachineTypeAttributesQuery query) + { + try + { + var hResult = query(ImageFileMachineAmd64, out var attributes); + return hResult >= 0 && (attributes & MachineTypeAttributeUserEnabled) != 0; + } + catch (EntryPointNotFoundException) + { + return false; + } + } + + internal static int QueryMachineTypeAttributes(ushort machine, out uint attributes) => + GetMachineTypeAttributes(machine, out attributes); + + [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWow64Process2(nint process, out ushort processMachine, out ushort nativeMachine); + + [DllImport("kernel32.dll", ExactSpelling = true)] + private static extern nint GetCurrentProcess(); + + [DllImport("kernel32.dll", ExactSpelling = true)] + private static extern int GetMachineTypeAttributes(ushort machine, out uint machineTypeAttributes); +} + +public enum GameArchitecture +{ + X64, + Arm64, + X86 +} + +public static class GameArchitectureNames +{ + public static string MojangRuntimeKey(this GameArchitecture architecture) => architecture switch + { + GameArchitecture.X64 => "windows-x64", + GameArchitecture.Arm64 => "windows-arm64", + GameArchitecture.X86 => "windows-x86", + _ => throw new ArgumentOutOfRangeException(nameof(architecture)) + }; + + public static string MojangNativeClassifier(this GameArchitecture architecture) => architecture switch + { + GameArchitecture.X64 => "natives-windows", + GameArchitecture.Arm64 => "natives-windows-arm64", + GameArchitecture.X86 => "natives-windows-x86", + _ => throw new ArgumentOutOfRangeException(nameof(architecture)) + }; + + public static string RuleArchitecture(this GameArchitecture architecture) => architecture switch + { + GameArchitecture.X64 => "x86_64", + GameArchitecture.Arm64 => "aarch64", + GameArchitecture.X86 => "x86", + _ => throw new ArgumentOutOfRangeException(nameof(architecture)) + }; + + internal static string NativeArchSubstitution(this GameArchitecture architecture) => architecture switch + { + GameArchitecture.X64 => "64", + GameArchitecture.Arm64 => "arm64", + GameArchitecture.X86 => "32", + _ => throw new ArgumentOutOfRangeException(nameof(architecture)) + }; +} diff --git a/windows/Hako.Core/Minecraft/JavaAnsiEncoding.cs b/windows/Hako.Core/Minecraft/JavaAnsiEncoding.cs new file mode 100644 index 0000000..4236281 --- /dev/null +++ b/windows/Hako.Core/Minecraft/JavaAnsiEncoding.cs @@ -0,0 +1,146 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace Hako.Core.Minecraft; + +/// +/// java.exe on Windows is an ANSI program: it reads its command line through GetCommandLineA and +/// decodes argument files as raw bytes in sun.jnu.encoding, which stays the ANSI code page after JEP 400. +/// Launch arguments therefore must be representable in GetACP() without best-fit substitutions. +/// +internal static unsafe partial class JavaAnsiEncoding +{ + internal const uint Utf8CodePage = 65001; + private const uint NoBestFitCharacters = 0x00000400; + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + internal static uint CurrentCodePage => GetACP(); + + internal static bool TryEncode(string value, uint codePage, out byte[] bytes) + { + bytes = []; + if (value.Length == 0) + { + return true; + } + + if (codePage == Utf8CodePage) + { + try + { + bytes = StrictUtf8.GetBytes(value); + return true; + } + catch (EncoderFallbackException) + { + return false; + } + } + + fixed (char* source = value) + { + int usedDefault; + var length = WideCharToMultiByte(codePage, NoBestFitCharacters, source, value.Length, null, 0, 0, &usedDefault); + if (length <= 0 || usedDefault != 0) + { + return false; + } + + var result = new byte[length]; + fixed (byte* destination = result) + { + usedDefault = 0; + if (WideCharToMultiByte(codePage, NoBestFitCharacters, source, value.Length, destination, length, 0, &usedDefault) != length + || usedDefault != 0) + { + return false; + } + } + + bytes = result; + return true; + } + } + + internal static bool CanRepresent(string value, uint codePage) => TryEncode(value, codePage, out _); + + internal static byte[] Encode(string value, uint codePage) => TryEncode(value, codePage, out var bytes) + ? bytes + : throw new MinecraftLaunchException(MinecraftLaunchError.ArgumentFileEncoding); + + /// + /// Replaces absolute paths that the code page can't represent with their 8.3 names. Paths may be the whole + /// argument, the value of an -Dname= or @file argument, or entries of a ;-separated list. + /// + internal static string AdaptPaths(string argument, uint codePage, Func? shortPathProvider = null) + { + if (CanRepresent(argument, codePage)) + { + return argument; + } + + shortPathProvider ??= GetShortPath; + var prefixLength = argument.StartsWith('@') ? 1 + : argument.StartsWith('-') && argument.IndexOf('=') is var equals and >= 0 ? equals + 1 + : 0; + var entries = argument[prefixLength..].Split(';'); + for (var index = 0; index < entries.Length; index++) + { + var entry = entries[index]; + if (CanRepresent(entry, codePage)) + { + continue; + } + + var shortPath = Path.IsPathFullyQualified(entry) ? shortPathProvider(entry) : null; + if (shortPath is null || !CanRepresent(shortPath, codePage)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ArgumentFileEncoding); + } + + entries[index] = shortPath; + } + + var adapted = argument[..prefixLength] + string.Join(';', entries); + return CanRepresent(adapted, codePage) + ? adapted + : throw new MinecraftLaunchException(MinecraftLaunchError.ArgumentFileEncoding); + } + + // Returns null when the path doesn't exist or the volume has no 8.3 names; the caller then rejects the launch. + internal static string? GetShortPath(string path) + { + var buffer = new char[path.Length + 1]; + while (true) + { + uint length; + fixed (char* destination = buffer) + { + length = GetShortPathNameW(path, destination, (uint)buffer.Length); + } + + if (length == 0) + { + return null; + } + + if (length < buffer.Length) + { + return new string(buffer, 0, (int)length); + } + + buffer = new char[length]; + } + } + + [LibraryImport("kernel32.dll", EntryPoint = "GetACP")] + private static partial uint GetACP(); + + [LibraryImport("kernel32.dll", EntryPoint = "WideCharToMultiByte", SetLastError = true)] + private static partial int WideCharToMultiByte( + uint codePage, uint flags, char* wideCharString, int wideCharCount, byte* multiByteString, int multiByteCount, + nint defaultChar, int* usedDefaultChar); + + [LibraryImport("kernel32.dll", EntryPoint = "GetShortPathNameW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + private static partial uint GetShortPathNameW(string longPath, char* shortPath, uint bufferLength); +} diff --git a/windows/Hako.Core/Minecraft/JavaLaunchValidation.cs b/windows/Hako.Core/Minecraft/JavaLaunchValidation.cs new file mode 100644 index 0000000..3628a1c --- /dev/null +++ b/windows/Hako.Core/Minecraft/JavaLaunchValidation.cs @@ -0,0 +1,315 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace Hako.Core.Minecraft; + +public enum JavaValidationError +{ + MissingExecutable, + ProbeFailed, + ProbeTimeout, + UnsupportedVersion, + ArchitectureMismatch +} + +public sealed class JavaLaunchValidationException(JavaValidationError error, Exception? innerException = null) + : Exception("The selected Java runtime cannot launch this build.", innerException) +{ + public JavaValidationError Error { get; } = error; + public string ResourceKey => Error switch + { + JavaValidationError.MissingExecutable => "LaunchJavaMissing", + JavaValidationError.ProbeFailed => "LaunchJavaProbeFailed", + JavaValidationError.ProbeTimeout => "LaunchJavaProbeTimeout", + JavaValidationError.UnsupportedVersion => "LaunchJavaVersionMismatch", + JavaValidationError.ArchitectureMismatch => "LaunchJavaArchitectureMismatch", + _ => throw new ArgumentOutOfRangeException(nameof(Error), Error, null) + }; +} + +public sealed class JavaLaunchValidation +{ + private const int MaximumOutputBytes = 64 * 1024; + private static readonly string[] RemovedEnvironmentVariables = ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"]; + private readonly IJavaValidationProbe _probe; + + public JavaLaunchValidation() : this(new ProcessJavaValidationProbe()) { } + + internal JavaLaunchValidation(IJavaValidationProbe probe) => _probe = probe; + + public async Task ValidateAsync( + string executable, + int minimumMajor, + GameArchitecture architecture, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(executable) || !File.Exists(executable)) + { + throw new JavaLaunchValidationException(JavaValidationError.MissingExecutable); + } + + JavaProbeResult result; + try + { + result = await _probe.RunAsync(executable, ["-XshowSettings:properties", "-version"], + EnvironmentForValidation(), timeout ?? TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException exception) + { + throw new JavaLaunchValidationException(JavaValidationError.ProbeTimeout, exception); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is Win32Exception or IOException or InvalidOperationException) + { + throw new JavaLaunchValidationException(JavaValidationError.ProbeFailed, exception); + } + + if (result.ExitCode != 0) + { + throw new JavaLaunchValidationException(JavaValidationError.ProbeFailed); + } + + var properties = Encoding.UTF8.GetString(result.Output).Split('\n') + .Select(line => line.Split('=', 2).Select(value => value.Trim()).ToArray()) + .Where(pair => pair.Length == 2) + .ToDictionary(pair => pair[0], pair => pair[1], StringComparer.Ordinal); + var version = properties.GetValueOrDefault("java.version") ?? ""; + var majorText = version.StartsWith("1.", StringComparison.Ordinal) + ? version[2..].Split('.', '_', '+')[0] + : new string(version.TakeWhile(char.IsDigit).ToArray()); + if (!int.TryParse(majorText, out var major) || major < minimumMajor) + { + throw new JavaLaunchValidationException(JavaValidationError.UnsupportedVersion); + } + + var osArchitecture = properties.GetValueOrDefault("os.arch") ?? ""; + var compatible = architecture switch + { + GameArchitecture.Arm64 => osArchitecture is "aarch64" or "arm64", + GameArchitecture.X64 => osArchitecture is "amd64" or "x86_64", + GameArchitecture.X86 => osArchitecture is "x86" or "i386" or "i486" or "i586" or "i686", + _ => false + }; + if (!compatible) + { + throw new JavaLaunchValidationException(JavaValidationError.ArchitectureMismatch); + } + + return major; + } + + internal static IReadOnlyDictionary EnvironmentForValidation() + { + var environment = Environment.GetEnvironmentVariables() + .Cast() + .Where(entry => entry.Key is string) + .ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase); + foreach (var name in RemovedEnvironmentVariables) + { + environment.Remove(name); + } + + return environment; + } +} + +internal sealed record JavaProbeResult(int ExitCode, byte[] Output); + +internal interface IJavaValidationProbe +{ + Task RunAsync( + string executable, + IReadOnlyList arguments, + IReadOnlyDictionary environment, + TimeSpan timeout, + CancellationToken cancellationToken); +} + +internal sealed class ProcessJavaValidationProbe : IJavaValidationProbe +{ + private const int MaximumOutputBytes = 64 * 1024; + + public async Task RunAsync( + string executable, + IReadOnlyList arguments, + IReadOnlyDictionary environment, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var temporaryPath = Path.Combine(Path.GetTempPath(), "hako-java-probe-" + Guid.NewGuid().ToString("N") + ".log"); + try + { + await using var output = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, + 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); + WindowsFileSecurity.RestrictToCurrentUser(temporaryPath); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = Path.GetDirectoryName(executable) ?? Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.StartInfo.Environment.Clear(); + foreach (var (name, value) in environment) + { + if (value is not null) + { + process.StartInfo.Environment[name] = value; + } + } + + if (!process.Start()) + { + throw new Win32Exception("The Java probe process did not start."); + } + + var writeGate = new SemaphoreSlim(1, 1); + long written = 0; + var standardOutput = PumpAsync(process.StandardOutput.BaseStream, output, writeGate, () => written, value => written = value, cancellationToken); + var standardError = PumpAsync(process.StandardError.BaseStream, output, writeGate, () => written, value => written = value, cancellationToken); + using var timeoutCancellation = new CancellationTokenSource(timeout); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCancellation.Token); + try + { + await process.WaitForExitAsync(linkedCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(); + await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false); + } + + await DrainAsync(standardOutput, standardError).ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + { + throw; + } + + throw new TimeoutException(); + } + + await DrainAsync(standardOutput, standardError).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + await using var input = new FileStream(temporaryPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, + 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); + var captured = new byte[Math.Min((int)input.Length, MaximumOutputBytes)]; + var read = await input.ReadAsync(captured, cancellationToken).ConfigureAwait(false); + return new JavaProbeResult(process.ExitCode, captured.AsSpan(0, read).ToArray()); + } + finally + { + try { File.Delete(temporaryPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + // A descendant of the probe may still hold the pipe after Java exits; never wait for it indefinitely. + private static async Task DrainAsync(Task standardOutput, Task standardError) + { + try + { + await Task.WhenAll(standardOutput, standardError).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + + private static async Task PumpAsync( + Stream input, + FileStream output, + SemaphoreSlim writeGate, + Func getWritten, + Action setWritten, + CancellationToken cancellationToken) + { + var buffer = new byte[4096]; + while (true) + { + var count = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (count == 0) + { + return; + } + + await writeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var written = getWritten(); + var remaining = MaximumOutputBytes - written; + if (remaining > 0) + { + var writeLength = (int)Math.Min(count, remaining); + await output.WriteAsync(buffer.AsMemory(0, writeLength), cancellationToken).ConfigureAwait(false); + setWritten(written + writeLength); + } + } + finally + { + writeGate.Release(); + } + } + } +} + +internal static partial class WindowsFileSecurity +{ + internal static void RestrictToCurrentUser(string path) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW("D:P(A;;FA;;;OW)", 1, out var descriptor, out _)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + if (!SetFileSecurityW(path, 0x00000004, descriptor)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + _ = LocalFree(descriptor); + } + } + + [LibraryImport("advapi32.dll", EntryPoint = "ConvertStringSecurityDescriptorToSecurityDescriptorW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static partial bool ConvertStringSecurityDescriptorToSecurityDescriptorW( + string stringSecurityDescriptor, + uint stringSDRevision, + out nint securityDescriptor, + out uint securityDescriptorSize); + + [LibraryImport("advapi32.dll", EntryPoint = "SetFileSecurityW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static partial bool SetFileSecurityW(string fileName, uint securityInformation, nint securityDescriptor); + + [LibraryImport("kernel32.dll", EntryPoint = "LocalFree")] + private static partial nint LocalFree(nint memory); +} diff --git a/windows/Hako.Core/Minecraft/LaunchArguments.cs b/windows/Hako.Core/Minecraft/LaunchArguments.cs new file mode 100644 index 0000000..2f4f894 --- /dev/null +++ b/windows/Hako.Core/Minecraft/LaunchArguments.cs @@ -0,0 +1,244 @@ +using System.Runtime.InteropServices; + +namespace Hako.Core.Minecraft; + +public static class LaunchArguments +{ + public static IReadOnlyList Parse(string text) + { + ArgumentNullException.ThrowIfNull(text); + var result = new List(); + var value = new System.Text.StringBuilder(); + char quote = '\0'; + var escaped = false; + var started = false; + + foreach (var character in text) + { + if (escaped) + { + value.Append(character); + escaped = false; + started = true; + } + else if (character == '\\' && quote != '\'') + { + escaped = true; + started = true; + } + else if (quote != '\0') + { + if (character == quote) + { + quote = '\0'; + } + else + { + value.Append(character); + } + } + else if (character is '"' or '\'') + { + quote = character; + started = true; + } + else if (char.IsWhiteSpace(character)) + { + if (started) + { + result.Add(value.ToString()); + value.Clear(); + started = false; + } + } + else + { + value.Append(character); + started = true; + } + } + + if (quote != '\0' || escaped) + { + throw new LaunchArgumentException("LaunchArgumentsInvalid"); + } + + if (started) + { + result.Add(value.ToString()); + } + + return result; + } + + public static string Format(IEnumerable arguments) => string.Join(' ', arguments.Select(argument => + { + if (argument.Length > 0 && !argument.Any(character => char.IsWhiteSpace(character) || character is '"' or '\'' or '\\')) + { + return argument; + } + + return "\"" + argument.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + })); + + public static IReadOnlyList ApplyMemory( + IEnumerable arguments, + int maximumMiB, + GameArchitecture architecture, + JavaMemoryPolicy? policy = null) + { + var memory = policy ?? JavaMemoryPolicy.Current; + var maximum = memory.Normalize(maximumMiB, architecture); + var maximumBytes = (long)maximum * 1024 * 1024; + var source = arguments.ToArray(); + var result = new List(source.Length + 1); + + for (var index = 0; index < source.Length; index++) + { + var argument = source[index]; + if (argument == "-Xmx") + { + if (index + 1 < source.Length && (source[index + 1].Length == 0 || source[index + 1][0] != '-')) + { + index++; + } + + continue; + } + + if (argument.StartsWith("-Xmx", StringComparison.Ordinal) + || argument.StartsWith("-XX:MaxHeapSize=", StringComparison.Ordinal)) + { + continue; + } + + if (argument == "-Xms" && index + 1 < source.Length) + { + var initial = source[++index]; + result.Add(HeapBytes(initial) is { } bytes && bytes > maximumBytes + ? $"-Xms{maximum}M" + : "-Xms" + initial); + continue; + } + + if (argument.StartsWith("-Xms", StringComparison.Ordinal) + && HeapBytes(argument[4..]) is { } xmsBytes && xmsBytes > maximumBytes) + { + result.Add($"-Xms{maximum}M"); + continue; + } + + if (argument.StartsWith("-XX:InitialHeapSize=", StringComparison.Ordinal) + && HeapBytes(argument[20..]) is { } initialBytes && initialBytes > maximumBytes) + { + result.Add($"-XX:InitialHeapSize={maximum}M"); + continue; + } + + result.Add(argument); + } + + result.Add($"-Xmx{maximum}M"); + return result; + } + + public static long? HeapBytes(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var suffix = char.ToLowerInvariant(value[^1]); + var multiplier = suffix switch + { + 'k' => 1024L, + 'm' => 1024L * 1024, + 'g' => 1024L * 1024 * 1024, + _ => 1L + }; + var numberText = multiplier == 1 ? value : value[..^1]; + if (!long.TryParse(numberText, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var number) || number <= 0 + || number > long.MaxValue / multiplier) + { + return null; + } + + return number * multiplier; + } +} + +public sealed class LaunchArgumentException(string resourceKey) : Exception("The launch arguments are invalid.") +{ + public string ResourceKey { get; } = resourceKey; +} + +public sealed partial class JavaMemoryPolicy +{ + private const long MiB = 1024L * 1024; + private readonly ulong _physicalBytes; + + public JavaMemoryPolicy(ulong physicalBytes) => _physicalBytes = physicalBytes; + + public static JavaMemoryPolicy Current => new(ReadPhysicalMemoryBytes()); + + public ulong PhysicalBytes => _physicalBytes; + + public int MaximumMiB(GameArchitecture architecture) + { + var physicalMiB = _physicalBytes / (ulong)MiB; + var maximum = (int)Math.Min(int.MaxValue, physicalMiB / 256 * 256); + maximum = Math.Max(512, maximum); + return architecture == GameArchitecture.X86 ? Math.Min(maximum, 1536) : maximum; + } + + public int InitialMiB(GameArchitecture architecture) => Normalize(Math.Min(4096, MaximumMiB(architecture) / 4), architecture); + + public int Normalize(int value, GameArchitecture architecture) + { + var maximum = MaximumMiB(architecture); + if (value <= 0) + { + value = InitialMiB(architecture); + } + + return Math.Min(maximum, Math.Max(512, value / 256 * 256)); + } + + public bool Warns(int value) => (double)value * MiB > (double)_physicalBytes * 0.7; + + private static ulong ReadPhysicalMemoryBytes() + { + if (!OperatingSystem.IsWindows()) + { + return 16UL * 1024 * 1024 * 1024; + } + + var status = new MemoryStatusEx { Length = (uint)Marshal.SizeOf() }; + if (GlobalMemoryStatusEx(ref status) == 0) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + + return status.TotalPhysical; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MemoryStatusEx + { + public uint Length; + public uint MemoryLoad; + public ulong TotalPhysical; + public ulong AvailablePhysical; + public ulong TotalPageFile; + public ulong AvailablePageFile; + public ulong TotalVirtual; + public ulong AvailableVirtual; + public ulong AvailableExtendedVirtual; + } + + [LibraryImport("kernel32.dll", EntryPoint = "GlobalMemoryStatusEx", SetLastError = true)] + private static partial int GlobalMemoryStatusEx(ref MemoryStatusEx buffer); +} diff --git a/windows/Hako.Core/Minecraft/MinecraftInstaller.cs b/windows/Hako.Core/Minecraft/MinecraftInstaller.cs new file mode 100644 index 0000000..052bad5 --- /dev/null +++ b/windows/Hako.Core/Minecraft/MinecraftInstaller.cs @@ -0,0 +1,1014 @@ +using System.IO.Compression; +using System.Runtime.ExceptionServices; +using System.Security.Cryptography; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public enum InstallationProgressStage +{ + PreparingMetadata, + PreparingFiles, + Downloading, + PreparingLibraries, + PreparingAssets, + PreparingModpack, + DownloadingModpack, + InstallingModpackContents, + Complete, + Pausing +} + +public sealed record InstallationProgress( + InstallationProgressStage Stage, + long CompletedBytes = 0, + long TotalBytes = 0, + int CompletedFiles = 0, + int TotalFiles = 0); + +public sealed record InstallationResult( + GameArchitecture Architecture, + string JavaExecutable, + int JavaMajorVersion, + bool LegacyTexturepacks, + string? FabricProfileSHA1 = null); + +public sealed class MinecraftInstaller(MojangClient client, FabricClient? fabricClient = null) +{ + private const int MaximumConcurrentDownloads = 4; + private const int MaximumNativeArchiveEntries = 100_000; + private const long MaximumNativeArchiveSize = 1024L * 1024 * 1024; + private const int BufferSize = 81920; + private static readonly HashSet ReservedDeviceNames = BuildReservedDeviceNames(); + private readonly FabricClient _fabricClient = fabricClient ?? new FabricClient(); + + private sealed record DownloadJob(MojangDownload Download, string RelativePath); + private sealed record AssetCopy(string Name, MojangDownload Download); + private sealed record NativeArchive(string RelativePath, IReadOnlyList Excludes); + + public Task InstallAsync( + PreparedInstallation prepared, + string instanceRoot, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + Task.Run(() => InstallCoreAsync(prepared, instanceRoot, [], null, progress, cancellationToken), cancellationToken); + + public Task InstallAsync( + PreparedInstallation prepared, + string instanceRoot, + IReadOnlyCollection additionalLibraries, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + Task.Run(() => InstallCoreAsync(prepared, instanceRoot, additionalLibraries, null, progress, cancellationToken), cancellationToken); + + public Task InstallFabricAsync( + PreparedInstallation prepared, + string instanceRoot, + FabricConfiguration? fabric, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + Task.Run(() => InstallCoreAsync(prepared, instanceRoot, [], fabric, progress, cancellationToken), cancellationToken); + + private async Task InstallCoreAsync( + PreparedInstallation prepared, + string instanceRoot, + IReadOnlyCollection additionalLibraries, + FabricConfiguration? fabric, + IProgress? progress, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.Equals(prepared.Version.Id, prepared.Manifest.Id, StringComparison.Ordinal)) + { + throw new MojangException(MojangError.Invalid, "The prepared version ID does not match its version description."); + } + + MojangClient.Verify(prepared.ManifestData, new MojangDownload + { + Url = prepared.Version.Url, + Sha1 = prepared.Version.Sha1 + }); + MojangClient.Verify(prepared.RuntimeManifestData, prepared.Runtime.Manifest); + progress?.Report(new InstallationProgress(InstallationProgressStage.PreparingFiles)); + var root = Path.GetFullPath(instanceRoot); + Directory.CreateDirectory(root); + _ = InstanceStorage.ContainedPath("installation-check", root); + CleanStaleTemporaryFiles(root, cancellationToken); + + var manifest = prepared.Manifest; + FabricProfile? fabricProfile = null; + byte[]? fabricProfileData = null; + string? fabricProfileSha1 = null; + IReadOnlyList fabricLibraries = []; + if (fabric is not null) + { + if (string.IsNullOrWhiteSpace(fabric.LoaderVersion)) + { + throw new MojangException(MojangError.Unsupported, "A Fabric Loader version is required."); + } + + (fabricProfile, fabricProfileData) = await _fabricClient.GetProfileAsync(manifest.Id, fabric.LoaderVersion, cancellationToken) + .ConfigureAwait(false); + fabricLibraries = await _fabricClient.ResolveLibrariesAsync(fabricProfile, cancellationToken).ConfigureAwait(false); + fabricProfileSha1 = Convert.ToHexString(SHA1.HashData(fabricProfileData)).ToLowerInvariant(); + } + + var versionDirectory = $"minecraft/versions/{manifest.Id}"; + var jobsByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + var nativeArchives = new List(); + var assetCopies = new List(); + var assetCopyPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var totalBytes = 0L; + + void AddJob(MojangDownload download, string relativePath) + { + relativePath = NormalizeRelativePath(relativePath); + MojangClient.ValidateDownload(download); + ValidateRelativeWindowsPath(relativePath); + _ = InstanceStorage.ContainedPath(relativePath, root); + if (jobsByPath.TryGetValue(relativePath, out var existing)) + { + if (!string.Equals(existing.Download.Sha1, download.Sha1, StringComparison.OrdinalIgnoreCase) + || existing.Download.Size is { } oldSize && download.Size is { } newSize && oldSize != newSize) + { + throw new MojangException(MojangError.Invalid, "The version description maps different files to the same path."); + } + + if (existing.Download.Size is null && download.Size is { } knownSize) + { + jobsByPath[relativePath] = existing with { Download = existing.Download with { Size = knownSize } }; + totalBytes += knownSize; + } + + return; + } + + jobsByPath.Add(relativePath, new DownloadJob(download, relativePath)); + totalBytes += download.Size ?? 0; + } + + void AddAssetCopy(string relativePath, string hash, long size) + { + relativePath = NormalizeRelativePath(relativePath); + ValidateRelativeWindowsPath(relativePath); + _ = InstanceStorage.ContainedPath(relativePath, root); + if (!assetCopyPaths.Add(relativePath)) + { + throw new MojangException(MojangError.Invalid, "The asset index maps multiple assets to the same Windows path."); + } + + assetCopies.Add(new AssetCopy(relativePath, new MojangDownload + { + Url = MojangClient.AssetBaseUrl, + Sha1 = hash, + Size = size + })); + } + + foreach (var (relativePath, entry) in prepared.RuntimeManifest.Files) + { + ValidateRelativeWindowsPath(relativePath); + _ = InstanceStorage.ContainedPath(Path.Combine("java", relativePath), root); + switch (entry.Type) + { + case "directory": + directories.Add(Path.Combine("java", relativePath)); + break; + case "file": + if (entry.Downloads?.GetValueOrDefault("raw") is not { } raw) + { + throw new MojangException(MojangError.Invalid, $"Mojang omitted the raw Java file for {relativePath}."); + } + + AddJob(raw, Path.Combine("java", relativePath)); + break; + case "link": + ValidateRuntimeLinkTarget(relativePath, entry.Target, root); + throw new MojangException(MojangError.Invalid, + $"The Java runtime contains a link at '{relativePath}'. Hako does not create Windows reparse points."); + default: + throw new MojangException(MojangError.Invalid, $"Mojang returned an unknown Java runtime entry type for {relativePath}."); + } + } + + if (!prepared.RuntimeManifest.Files.ContainsKey("bin/javaw.exe")) + { + throw new MojangException(MojangError.Invalid, "The selected Java runtime does not contain bin/javaw.exe."); + } + + if (!manifest.Downloads.TryGetValue("client", out var clientDownload)) + { + throw new MojangException(MojangError.Unsupported, "Mojang does not provide a client for this version."); + } + + AddJob(clientDownload, $"{versionDirectory}/{manifest.Id}.jar"); + var allLibraries = prepared.Libraries.Concat(additionalLibraries).ToArray(); + if (fabricProfile is not null) + { + var fabricKeys = fabricProfile.Libraries.Select(library => FabricMavenLibrary.Parse(library.Name).Key) + .ToHashSet(StringComparer.Ordinal); + allLibraries = fabricLibraries.Concat(allLibraries.Where(library => FabricMavenLibrary.KeyFromPath(library.Path) is not { } key + || !fabricKeys.Contains(key))).ToArray(); + } + + foreach (var library in allLibraries) + { + var libraryPath = NormalizeRelativePath(Path.Combine("minecraft/libraries", library.Path)); + AddJob(library.Download, libraryPath); + if (library.ExtractNatives) + { + nativeArchives.Add(new NativeArchive( + libraryPath, + library.ExtractionExcludes ?? [])); + } + } + + foreach (var logging in manifest.Logging?.Values ?? Enumerable.Empty()) + { + var fileName = logging.File.Id ?? Path.GetFileName(new Uri(logging.File.Url).AbsolutePath); + ValidateRelativeWindowsPath(fileName); + AddJob(logging.File, Path.Combine("minecraft/assets/log_configs", fileName)); + } + + byte[]? assetIndexData = null; + MojangAssetIndex? assetIndex = null; + if (manifest.AssetIndex is { } assetReference) + { + ValidateRelativeWindowsPath(assetReference.Id); + assetIndexData = await client.GetVerifiedBytesAsync(assetReference.Download, cancellationToken).ConfigureAwait(false); + assetIndex = System.Text.Json.JsonSerializer.Deserialize(assetIndexData, MojangJsonContext.Default.MojangAssetIndex) + ?? throw new MojangException(MojangError.Invalid, "Mojang returned an empty asset index."); + + foreach (var (name, asset) in assetIndex.Objects) + { + if (asset.Hash.Length != 40 || !asset.Hash.All(Uri.IsHexDigit) || asset.Size < 0) + { + throw new MojangException(MojangError.Invalid, "Mojang returned an invalid asset entry."); + } + + var assetHash = asset.Hash.ToLowerInvariant(); + var hashPath = $"{assetHash[..2]}/{assetHash}"; + AddJob(new MojangDownload + { + Url = MojangClient.AssetBaseUrl + hashPath, + Sha1 = assetHash, + Size = asset.Size + }, $"minecraft/assets/objects/{hashPath}"); + if (assetIndex.Virtual) + { + AddAssetCopy($"minecraft/assets/virtual/{assetReference.Id}/{name}", assetHash, asset.Size); + } + + if (assetIndex.MapToResources) + { + AddAssetCopy($"minecraft/resources/{name}", assetHash, asset.Size); + } + } + + await WriteAtomicallyAsync($"minecraft/assets/indexes/{assetReference.Id}.json", assetIndexData, root, cancellationToken) + .ConfigureAwait(false); + } + + await WriteAtomicallyAsync($"{versionDirectory}/{manifest.Id}.json", prepared.ManifestData, root, cancellationToken) + .ConfigureAwait(false); + if (fabricProfileData is not null) + { + await WriteAtomicallyAsync("minecraft/.hako-fabric.json", fabricProfileData, root, cancellationToken).ConfigureAwait(false); + } + await WriteAtomicallyAsync("java/runtime-manifest.json", prepared.RuntimeManifestData, root, cancellationToken) + .ConfigureAwait(false); + + foreach (var directory in directories) + { + CreateDirectory(directory, root); + } + + var jobs = jobsByPath.Values.OrderBy(job => job.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray(); + progress?.Report(new InstallationProgress(InstallationProgressStage.Downloading, 0, totalBytes, 0, jobs.Length)); + var progressState = new ProgressState(progress, totalBytes, jobs.Length); + using (var semaphore = new SemaphoreSlim(MaximumConcurrentDownloads)) + using (var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + Exception? firstNonCancellationFailure = null; + async Task DownloadAndCancelOnErrorAsync(DownloadJob job) + { + try + { + await DownloadAsync(job, root, semaphore, progressState, linkedCancellation.Token).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Interlocked.CompareExchange(ref firstNonCancellationFailure, exception, null); + linkedCancellation.Cancel(); + throw; + } + } + + var downloads = jobs.Select(DownloadAndCancelOnErrorAsync).ToArray(); + try + { + await Task.WhenAll(downloads).ConfigureAwait(false); + } + catch + { + if (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + if (firstNonCancellationFailure is not null) + { + ExceptionDispatchInfo.Capture(firstNonCancellationFailure).Throw(); + } + + throw; + } + } + + cancellationToken.ThrowIfCancellationRequested(); + progress?.Report(new InstallationProgress(InstallationProgressStage.PreparingLibraries, totalBytes, totalBytes, jobs.Length, jobs.Length)); + if (nativeArchives.Count > 0) + { + await ExtractNativesAsync(nativeArchives.DistinctBy(value => value.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray(), root, cancellationToken) + .ConfigureAwait(false); + } + + if (assetIndex is not null) + { + progress?.Report(new InstallationProgress(InstallationProgressStage.PreparingAssets, totalBytes, totalBytes, jobs.Length, jobs.Length)); + foreach (var assetCopy in assetCopies) + { + cancellationToken.ThrowIfCancellationRequested(); + var assetHash = assetCopy.Download.Sha1.ToLowerInvariant(); + var source = $"minecraft/assets/objects/{assetHash[..2]}/{assetHash}"; + await CopyAtomicallyAsync(source, assetCopy.Name, assetCopy.Download, root, cancellationToken).ConfigureAwait(false); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + CreateDirectory("minecraft/mods", root); + CreateDirectory(manifest.LegacyTexturepacks ? "minecraft/texturepacks" : "minecraft/resourcepacks", root); + if (fabricProfile is not null && fabric is not null && fabric.Api is { } api) + { + if (!InstanceContent.ApiWasProvisioned(root)) + { + var cachedApi = await _fabricClient.DownloadApiAsync(api, cancellationToken).ConfigureAwait(false); + var metadata = await FabricClient.ReadModMetadataAsync(cachedApi, cancellationToken).ConfigureAwait(false); + if (!metadata.Supports(fabric.LoaderVersion, manifest.Java.MajorVersion)) + { + throw new MojangException(MojangError.Unsupported, "Fabric API is incompatible with the selected Loader or Java version."); + } + + await InstanceContent.ProvisionAPIAsync(api, cachedApi, root, cancellationToken).ConfigureAwait(false); + } + } + + progress?.Report(new InstallationProgress(InstallationProgressStage.Complete, totalBytes, totalBytes, jobs.Length, jobs.Length)); + return new InstallationResult( + prepared.Architecture, + "bin/javaw.exe", + manifest.Java.MajorVersion, + manifest.LegacyTexturepacks, + fabricProfileSha1); + } + + private async Task DownloadAsync( + DownloadJob job, + string root, + SemaphoreSlim semaphore, + ProgressState progress, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var destination = InstanceStorage.ContainedPath(job.RelativePath, root); + if (await IsValidFileAsync(destination, job.Download, cancellationToken).ConfigureAwait(false)) + { + progress.FileCompleted(job.Download.Size ?? 0); + return; + } + + CreateDirectory(Path.GetDirectoryName(job.RelativePath)!, root); + var temporary = TemporaryPathFor(destination); + try + { + using var response = await client.SendAsync(job.Download.Url, cancellationToken).ConfigureAwait(false); + if (job.Download.Size is { } expectedContentLength && response.Content.Headers.ContentLength is { } contentLength + && contentLength > expectedContentLength) + { + throw new MojangException(MojangError.Integrity, $"The downloaded file is larger than declared: {job.RelativePath}."); + } + + Stream source; + try + { + source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new MojangException(MojangError.Http, "The Mojang response stream timed out.", innerException: exception); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new MojangException(MojangError.Http, "The Mojang response stream could not be opened.", innerException: exception); + } + + await using var responseStream = source; + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + var buffer = new byte[BufferSize]; + long size = 0; + await using (var target = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + while (true) + { + var readLength = BufferSize; + if (job.Download.Size is { } declaredSizeLimit) + { + var remaining = declaredSizeLimit - size; + if (remaining < BufferSize) + { + readLength = (int)remaining + 1; + } + } + + var read = await client.ReadWithIdleTimeoutAsync(source, buffer.AsMemory(0, readLength), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (job.Download.Size is { } maximumSize && read > maximumSize - size) + { + throw new MojangException(MojangError.Integrity, $"The downloaded file is larger than declared: {job.RelativePath}."); + } + + size += read; + hash.AppendData(buffer, 0, read); + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + progress.BytesDownloaded(read); + } + + await target.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + var actualHash = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + if (!string.Equals(actualHash, job.Download.Sha1, StringComparison.OrdinalIgnoreCase) + || job.Download.Size is { } expectedSize && size != expectedSize) + { + throw new MojangException(MojangError.Integrity, $"The downloaded file failed its SHA-1 or size check: {job.RelativePath}."); + } + + File.Move(temporary, destination, overwrite: true); + progress.FileCompleted(0); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + finally + { + semaphore.Release(); + } + } + + private static async Task IsValidFileAsync(string path, MojangDownload download, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return false; + } + + var info = new FileInfo(path); + if (download.Size is { } expectedSize && info.Length != expectedSize) + { + return false; + } + + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + var buffer = new byte[BufferSize]; + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + hash.AppendData(buffer, 0, read); + } + + return string.Equals(Convert.ToHexString(hash.GetHashAndReset()), download.Sha1, StringComparison.OrdinalIgnoreCase); + } + + private static async Task WriteAtomicallyAsync(string relativePath, byte[] data, string root, CancellationToken cancellationToken) + { + ValidateRelativeWindowsPath(relativePath); + var destination = InstanceStorage.ContainedPath(relativePath, root); + CreateDirectory(Path.GetDirectoryName(relativePath)!, root); + var temporary = TemporaryPathFor(destination); + try + { + await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await stream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporary, destination, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + + private static async Task CopyAtomicallyAsync( + string sourceRelativePath, + string destinationRelativePath, + MojangDownload download, + string root, + CancellationToken cancellationToken) + { + ValidateRelativeWindowsPath(destinationRelativePath); + var source = InstanceStorage.ContainedPath(sourceRelativePath, root); + var destination = InstanceStorage.ContainedPath(destinationRelativePath, root); + CreateDirectory(Path.GetDirectoryName(destinationRelativePath)!, root); + if (!await IsValidFileAsync(source, download, cancellationToken).ConfigureAwait(false)) + { + throw new MojangException(MojangError.Integrity, "An asset object changed before it could be copied."); + } + + if (await IsValidFileAsync(destination, download, cancellationToken).ConfigureAwait(false)) + { + return; + } + + var temporary = TemporaryPathFor(destination); + try + { + await using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await input.CopyToAsync(output, BufferSize, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!await IsValidFileAsync(temporary, download, cancellationToken).ConfigureAwait(false)) + { + throw new MojangException(MojangError.Integrity, "An asset copy failed its SHA-1 or size check."); + } + + File.Move(temporary, destination, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + + private static async Task ExtractNativesAsync( + IReadOnlyList archives, + string root, + CancellationToken cancellationToken) + { + var stagingRelative = $"minecraft/.hako-natives-{Guid.NewGuid():N}.tmp"; + var stagingPath = InstanceStorage.ContainedPath(stagingRelative, root); + CreateDirectory(stagingRelative, root); + try + { + foreach (var archive in archives) + { + cancellationToken.ThrowIfCancellationRequested(); + await ExtractNativeArchiveAsync(archive, stagingRelative, root, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + PublishNativeStaging(stagingPath, root, Directory.Move); + } + finally + { + try + { + if (Directory.Exists(stagingPath)) + { + InstanceStorage.DeleteEntryWithoutFollowingReparsePoints(stagingPath); + } + } + catch (Exception exception) when (IsBestEffortFileSystemFailure(exception)) + { + } + } + } + + private static async Task ExtractNativeArchiveAsync( + NativeArchive nativeArchive, + string stagingRelative, + string root, + CancellationToken cancellationToken) + { + var archivePath = InstanceStorage.ContainedPath(nativeArchive.RelativePath, root); + using var archive = ZipFile.OpenRead(archivePath); + if (archive.Entries.Count > MaximumNativeArchiveEntries) + { + throw new MojangException(MojangError.Invalid, "A native library archive contains too many entries."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + var entries = new List<(ZipArchiveEntry Entry, string RelativePath, bool IsDirectory, bool Excluded)>(); + long expandedSize = 0; + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var isDirectory = entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\'); + var path = entry.FullName.Replace('\\', '/').TrimEnd('/'); + if (path.Length == 0) + { + continue; + } + + ValidateRelativeWindowsPath(path); + if (!paths.Add(path)) + { + throw new MojangException(MojangError.Invalid, "A native library archive contains duplicate paths."); + } + + var destinationRelative = $"{stagingRelative}/{path}"; + _ = InstanceStorage.ContainedPath(destinationRelative, root); + expandedSize = checked(expandedSize + (isDirectory ? 0 : entry.Length)); + if (expandedSize > MaximumNativeArchiveSize) + { + throw new MojangException(MojangError.Invalid, "A native library archive expands beyond the supported size limit."); + } + + var normalized = path + (isDirectory ? "/" : ""); + var excluded = nativeArchive.Excludes.Any(exclude => IsExcluded(normalized, exclude)); + entries.Add((entry, destinationRelative, isDirectory, excluded)); + } + + foreach (var (entry, destinationRelative, isDirectory, excluded) in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (excluded) + { + continue; + } + + if (isDirectory) + { + CreateDirectory(destinationRelative, root); + continue; + } + + var destination = InstanceStorage.ContainedPath(destinationRelative, root); + CreateDirectory(Path.GetDirectoryName(destinationRelative)!, root); + await using var input = entry.Open(); + await ExtractNativeEntryAsync(input, destination, entry.Length, cancellationToken).ConfigureAwait(false); + } + } + + internal static async Task ExtractNativeEntryAsync( + Stream input, + string destination, + long expectedLength, + CancellationToken cancellationToken) + { + if (expectedLength < 0) + { + throw new MojangException(MojangError.Invalid, "A native library entry has an invalid declared size."); + } + + var temporary = TemporaryPathFor(destination); + try + { + long size = 0; + var buffer = new byte[BufferSize]; + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + while (true) + { + var remaining = expectedLength - size; + var readLength = remaining < BufferSize ? (int)remaining + 1 : BufferSize; + var read = await input.ReadAsync(buffer.AsMemory(0, readLength), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (read > remaining) + { + throw new MojangException(MojangError.Invalid, "A native library entry exceeds its declared size."); + } + + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + size += read; + } + + if (size != expectedLength) + { + throw new MojangException(MojangError.Invalid, "A native library entry was truncated while extracting."); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporary, destination, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + + internal static void PublishNativeStaging(string stagingPath, string root, Action moveDirectory) + { + var pending = new Stack(); + pending.Push(stagingPath); + while (pending.TryPop(out var directory)) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new MojangException(MojangError.Invalid, "A reparse point appeared in the native library staging folder."); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(entry); + } + } + } + + var destination = InstanceStorage.ContainedPath("minecraft/natives", root); + var backup = InstanceStorage.ContainedPath($"minecraft/.hako-natives-backup-{Guid.NewGuid():N}.tmp", root); + var destinationMoved = false; + try + { + if (Directory.Exists(destination)) + { + moveDirectory(destination, backup); + destinationMoved = true; + } + + moveDirectory(stagingPath, destination); + } + catch (Exception publicationFailure) + { + try + { + if (destinationMoved) + { + if (Directory.Exists(destination) && !Directory.Exists(stagingPath)) + { + moveDirectory(destination, stagingPath); + } + + moveDirectory(backup, destination); + } + } + catch (Exception rollbackFailure) + { + throw new AggregateException("Native library publication failed and the previous natives could not be restored.", + publicationFailure, rollbackFailure); + } + + throw; + } + + if (destinationMoved) + { + try + { + InstanceStorage.DeleteEntryWithoutFollowingReparsePoints(backup); + } + catch (Exception exception) when (IsBestEffortFileSystemFailure(exception)) + { + } + } + } + + private static bool IsExcluded(string path, string exclude) + { + var normalized = exclude.Replace('\\', '/'); + if (!normalized.EndsWith('/')) + { + return string.Equals(path.TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase); + } + + return path.StartsWith(normalized, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeRelativePath(string path) => path.Replace('\\', '/'); + + private static string TemporaryPathFor(string destination) => + Path.Combine(Path.GetDirectoryName(destination)!, $".hako-{Guid.NewGuid():N}.tmp"); + + internal static void CleanStaleTemporaryFiles(string root, CancellationToken cancellationToken) + { + // InstanceStore prevents concurrent installs for one root; only installer-owned folders are cleaned here. + foreach (var relativeDirectory in new[] + { + "java", + "minecraft/versions", + "minecraft/libraries", + "minecraft/assets", + "minecraft/natives", + "minecraft/resources" + }) + { + cancellationToken.ThrowIfCancellationRequested(); + CleanStaleTemporaryEntries(root, Path.Combine(root, relativeDirectory), recurse: true, cancellationToken); + } + + cancellationToken.ThrowIfCancellationRequested(); + CleanStaleTemporaryEntries(root, Path.Combine(root, "minecraft"), recurse: false, cancellationToken); + } + + private static void CleanStaleTemporaryEntries( + string root, + string directory, + bool recurse, + CancellationToken cancellationToken) + { + var pending = new Stack(); + pending.Push(directory); + while (pending.TryPop(out var currentDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var safeDirectory = InstanceStorage.ContainedPath(Path.GetRelativePath(root, currentDirectory), root); + if (!Directory.Exists(safeDirectory)) + { + continue; + } + + foreach (var entry in Directory.EnumerateFileSystemEntries(safeDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var safeEntry = InstanceStorage.ContainedPath(Path.GetRelativePath(root, entry), root); + var attributes = File.GetAttributes(safeEntry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + var name = Path.GetFileName(safeEntry); + if (name.StartsWith(".hako-", StringComparison.OrdinalIgnoreCase) + && name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + { + try + { + InstanceStorage.DeleteEntryWithoutFollowingReparsePoints(safeEntry); + } + catch (Exception exception) when (IsBestEffortFileSystemFailure(exception)) + { + } + } + else if (recurse && (attributes & FileAttributes.Directory) != 0) + { + pending.Push(safeEntry); + } + } + catch (Exception exception) when (IsBestEffortFileSystemFailure(exception)) + { + } + } + } + catch (Exception exception) when (IsBestEffortFileSystemFailure(exception)) + { + } + } + } + + private static bool IsBestEffortFileSystemFailure(Exception exception) => + exception is IOException or UnauthorizedAccessException or System.Security.SecurityException or InstanceStorageException; + + private static void ValidateRuntimeLinkTarget(string relativePath, string? target, string root) + { + if (string.IsNullOrWhiteSpace(target) || Path.IsPathRooted(target) || target.Contains(':') || target.Contains('\0')) + { + throw new MojangException(MojangError.Invalid, $"The Java runtime link at '{relativePath}' has an invalid target."); + } + + var javaRoot = Path.GetFullPath(Path.Combine(root, "java")); + var linkPath = Path.GetFullPath(Path.Combine(javaRoot, relativePath)); + var resolved = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(linkPath)!, target)); + var prefix = Path.EndsInDirectorySeparator(javaRoot) ? javaRoot : javaRoot + Path.DirectorySeparatorChar; + if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + throw new MojangException(MojangError.Invalid, $"The Java runtime link at '{relativePath}' escapes the java directory."); + } + } + + private static void CreateDirectory(string relativePath, string root) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return; + } + + ValidateRelativeWindowsPath(relativePath); + var path = InstanceStorage.ContainedPath(relativePath, root); + Directory.CreateDirectory(path); + _ = InstanceStorage.ContainedPath(relativePath, root); + } + + private static void ValidateRelativeWindowsPath(string path) + { + if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path) || path.StartsWith('/') || path.StartsWith('\\') + || path.Contains(':') || path.Contains('\0')) + { + throw new MojangException(MojangError.Invalid, "An installation path is absolute or invalid."); + } + + var parts = path.Split(['/', '\\'], StringSplitOptions.None); + foreach (var part in parts) + { + if (part.Length == 0 || part is "." or ".." || part.EndsWith('.') || part.EndsWith(' ') + || part.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new MojangException(MojangError.Invalid, "An installation path contains an invalid Windows name."); + } + + var baseName = part.Split('.')[0].TrimEnd(' ', '.'); + if (ReservedDeviceNames.Contains(baseName)) + { + throw new MojangException(MojangError.Invalid, "An installation path contains a reserved Windows device name."); + } + } + } + + private static HashSet BuildReservedDeviceNames() + { + var names = new HashSet(["CON", "PRN", "AUX", "NUL"], StringComparer.OrdinalIgnoreCase); + for (var index = 1; index <= 9; index++) + { + names.Add($"COM{index}"); + names.Add($"LPT{index}"); + } + + return names; + } + + private sealed class ProgressState(IProgress? progress, long totalBytes, int totalFiles) + { + private readonly object _gate = new(); + private long _completedBytes; + private int _completedFiles; + + public void BytesDownloaded(int count) + { + lock (_gate) + { + _completedBytes += count; + progress?.Report(new InstallationProgress(InstallationProgressStage.Downloading, _completedBytes, totalBytes, + _completedFiles, totalFiles)); + } + } + + public void FileCompleted(long reusedBytes) + { + lock (_gate) + { + _completedBytes += reusedBytes; + _completedFiles++; + progress?.Report(new InstallationProgress(InstallationProgressStage.Downloading, _completedBytes, totalBytes, + _completedFiles, totalFiles)); + } + } + } +} diff --git a/windows/Hako.Core/Minecraft/MinecraftLaunchPlan.cs b/windows/Hako.Core/Minecraft/MinecraftLaunchPlan.cs new file mode 100644 index 0000000..7850a13 --- /dev/null +++ b/windows/Hako.Core/Minecraft/MinecraftLaunchPlan.cs @@ -0,0 +1,359 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public sealed record MinecraftLaunchIdentity( + string Name, + string Uuid, + string AccessToken, + string Xuid = "", + string UserType = "msa") +{ + public static MinecraftLaunchIdentity Offline(string name) + { + if (!OfflineUsername.IsValid(name)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.InvalidOfflineName); + } + + var bytes = MD5.HashData(Encoding.UTF8.GetBytes("OfflinePlayer:" + name)); + bytes[6] = (byte)((bytes[6] & 0x0f) | 0x30); + bytes[8] = (byte)((bytes[8] & 0x3f) | 0x80); + return new MinecraftLaunchIdentity(name, Convert.ToHexString(bytes).ToLowerInvariant(), "0", UserType: "legacy"); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append("Name = ").Append(Name) + .Append(", Uuid = ").Append(Uuid) + .Append(", Xuid = ").Append(Xuid) + .Append(", UserType = ").Append(UserType); + return true; + } +} + +public sealed record MinecraftLaunchProfile( + string? MainClass = null, + string? InheritsFrom = null, + string? Id = null, + IReadOnlyList? Libraries = null, + IReadOnlyList? JvmArguments = null, + IReadOnlyList? GameArguments = null); + +public sealed class MinecraftLaunchPlan +{ + private static readonly HashSet ReservedGameArguments = new(StringComparer.Ordinal) + { + "--username", "--uuid", "--accessToken", "--session", "--clientId", "--xuid", "--userType", + "--userProperties", "--profileProperties", "--version", "--versionType", "--gameDir", "--assetsDir", + "--assetIndex", "--width", "--height", "--fullscreen" + }; + + internal MinecraftLaunchPlan(string executable, IReadOnlyList arguments, string workingDirectory, int javaMajorVersion) + { + Executable = executable; + Arguments = arguments; + WorkingDirectory = workingDirectory; + JavaMajorVersion = javaMajorVersion; + } + + public string Executable { get; } + public IReadOnlyList Arguments { get; } + public string WorkingDirectory { get; } + public int JavaMajorVersion { get; } + + internal static async Task LoadInstalledFabricProfileAsync( + string instanceRoot, + string minecraft, + string loader, + string? profileSha1, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(loader) || string.IsNullOrWhiteSpace(profileSha1)) + throw new MinecraftLaunchException(MinecraftLaunchError.FabricProfileMissing); + try + { + var profile = await FabricClient.ReadInstalledProfileAsync(instanceRoot, minecraft, loader, profileSha1, cancellationToken) + .ConfigureAwait(false); + var libraries = new List(profile.Libraries.Count); + foreach (var library in profile.Libraries) + { + cancellationToken.ThrowIfCancellationRequested(); + var maven = FabricMavenLibrary.Parse(library.Name); + var path = InstanceStorage.ContainedPath("minecraft/libraries/" + maven.Path, instanceRoot, allowBaseReparsePoint: true); + if (!File.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new MinecraftLaunchException(MinecraftLaunchError.FabricProfileMissing, + "A Fabric library is missing. Reinstall this build."); + libraries.Add(new LibraryInstallation(new MojangDownload + { + Url = new Uri(library.Url, maven.Path).AbsoluteUri, + Sha1 = library.Sha1 ?? "", + Size = library.Size, + Path = maven.Path + }, maven.Path)); + } + + return new MinecraftLaunchProfile(profile.MainClass, profile.InheritsFrom, profile.Id, libraries, + profile.Arguments?.GetValueOrDefault("jvm"), profile.Arguments?.GetValueOrDefault("game")); + } + catch (OperationCanceledException) + { + throw; + } + catch (MinecraftLaunchException) + { + throw; + } + catch (Exception exception) when (exception is MojangException or IOException or JsonException or ArgumentException) + { + throw new MinecraftLaunchException(MinecraftLaunchError.FabricProfileMissing, innerException: exception); + } + } + + public static MinecraftLaunchPlan Build( + MojangVersionMetadata manifest, + string instanceRoot, + string executable, + MinecraftLaunchIdentity identity, + LaunchArgumentSource source, + InstanceParameters parameters, + GameArchitecture architecture, + MojangAssetIndex? assetIndex = null, + IReadOnlyList? libraries = null, + int javaMajorVersion = 0, + string clientId = MicrosoftDeviceAuth.ClientId, + string launcherVersion = "1.0", + MinecraftLaunchProfile? profile = null, + JavaMemoryPolicy? memoryPolicy = null) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(parameters); + ArgumentException.ThrowIfNullOrWhiteSpace(instanceRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(executable); + + var mainClass = profile?.MainClass ?? manifest.MainClass; + if (string.IsNullOrWhiteSpace(mainClass)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.InvalidManifest); + } + + var gameDirectory = InstanceStorage.ContainedPath("minecraft", instanceRoot, allowBaseReparsePoint: true); + var assetsDirectory = InstanceStorage.ContainedPath("minecraft/assets", instanceRoot, allowBaseReparsePoint: true); + var indexId = manifest.AssetIndex?.Id ?? manifest.Assets ?? "legacy"; + var gameAssets = assetIndex?.Virtual == true + ? InstanceStorage.ContainedPath($"minecraft/assets/virtual/{indexId}", instanceRoot, allowBaseReparsePoint: true) + : assetIndex?.MapToResources == true + ? InstanceStorage.ContainedPath("minecraft/resources", instanceRoot, allowBaseReparsePoint: true) + : assetsDirectory; + + var features = new Dictionary(StringComparer.Ordinal) + { + ["has_custom_resolution"] = !parameters.Fullscreen, + ["is_demo_user"] = false + }; + var resolvedLibraries = libraries ?? MojangCompatibility.Libraries(manifest, architecture, features); + if (profile is not null && profile.InheritsFrom is { } parent + && !string.Equals(parent, manifest.Id, StringComparison.Ordinal)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.FabricProfileMissing, + "Fabric profile inherits from a different Minecraft version."); + } + + if (profile?.Libraries is { } profileLibraries) + { + var fabricKeys = profileLibraries.Select(library => FabricMavenLibrary.KeyFromPath(library.Path)) + .Where(key => key is not null).ToHashSet(StringComparer.Ordinal); + resolvedLibraries = profileLibraries.Concat(resolvedLibraries.Where(library => + FabricMavenLibrary.KeyFromPath(library.Path) is not { } key || !fabricKeys.Contains(key))).ToArray(); + } + var classpath = new List(resolvedLibraries.Count + 1); + foreach (var library in resolvedLibraries) + { + if (library.IsNative || library.ExtractNatives) + { + continue; + } + + classpath.Add(InstanceStorage.ContainedPath("minecraft/libraries/" + library.Path, instanceRoot, allowBaseReparsePoint: true)); + } + + classpath.Add(InstanceStorage.ContainedPath( + $"minecraft/versions/{manifest.Id}/{manifest.Id}.jar", instanceRoot, allowBaseReparsePoint: true)); + var substitutions = new Dictionary(StringComparer.Ordinal) + { + ["auth_player_name"] = identity.Name, + ["auth_uuid"] = identity.Uuid, + ["auth_access_token"] = identity.AccessToken, + ["auth_session"] = identity.AccessToken == "0" ? "0" : $"token:{identity.AccessToken}:{identity.Uuid}", + ["auth_xuid"] = identity.Xuid, + ["user_type"] = identity.UserType, + ["user_properties"] = "{}", + ["profile_properties"] = "{}", + ["clientid"] = clientId, + ["version_name"] = profile?.Id ?? manifest.Id, + ["version_type"] = manifest.Type ?? "release", + ["game_directory"] = gameDirectory, + ["assets_root"] = assetsDirectory, + ["assets_index_name"] = indexId, + ["game_assets"] = gameAssets, + ["natives_directory"] = InstanceStorage.ContainedPath("minecraft/natives", instanceRoot, allowBaseReparsePoint: true), + ["library_directory"] = InstanceStorage.ContainedPath("minecraft/libraries", instanceRoot, allowBaseReparsePoint: true), + ["classpath"] = string.Join(';', classpath), + ["classpath_separator"] = ";", + ["launcher_name"] = "Hako", + ["launcher_version"] = launcherVersion, + ["resolution_width"] = parameters.WindowWidth.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["resolution_height"] = parameters.WindowHeight.ToString(System.Globalization.CultureInfo.InvariantCulture) + }; + + if (manifest.Logging?.GetValueOrDefault("client") is { Argument: not null } logging) + { + var fileName = logging.File.Id ?? Path.GetFileName(new Uri(logging.File.Url).AbsolutePath); + substitutions["logging_configuration"] = InstanceStorage.ContainedPath( + "minecraft/assets/log_configs/" + fileName, instanceRoot, allowBaseReparsePoint: true); + } + + var (jvm, game) = ArgumentTemplates(manifest, source, parameters, architecture, memoryPolicy, profile); + var allArguments = jvm.Concat([mainClass]).Concat(game) + .Select(argument => Substitute(argument, substitutions)).ToArray(); + return new MinecraftLaunchPlan(executable, allArguments, gameDirectory, + javaMajorVersion > 0 ? javaMajorVersion : manifest.Java.MajorVersion); + } + + public static (IReadOnlyList Java, IReadOnlyList Minecraft) ArgumentTemplates( + MojangVersionMetadata manifest, + LaunchArgumentSource source, + InstanceParameters parameters, + GameArchitecture architecture, + JavaMemoryPolicy? memoryPolicy = null, + MinecraftLaunchProfile? profile = null) + { + var features = new Dictionary(StringComparer.Ordinal) + { + ["has_custom_resolution"] = !parameters.Fullscreen, + ["is_demo_user"] = false + }; + IReadOnlyList Group(string name) => (manifest.Arguments?.GetValueOrDefault(name) ?? []) + .Where(argument => argument.IsAllowed(architecture, features)) + .SelectMany(argument => argument.Values) + .ToArray(); + + var java = source == LaunchArgumentSource.Mojang + ? Group("default-user-jvm").ToList() + : LaunchArguments.Parse(parameters.JavaArguments).ToList(); + if (manifest.Arguments is not null) + { + java.AddRange(Group("jvm")); + } + else + { + java.Add("-Djava.library.path=${natives_directory}"); + java.Add("-cp"); + java.Add("${classpath}"); + } + + java.AddRange((profile?.JvmArguments ?? []).Where(argument => argument.IsAllowed(architecture, features)) + .SelectMany(argument => argument.Values)); + if (manifest.Logging?.GetValueOrDefault("client")?.Argument is { } loggingArgument) + { + java.Add(loggingArgument.Replace("${path}", "${logging_configuration}", StringComparison.Ordinal)); + } + + java = LaunchArguments.ApplyMemory(java, parameters.MaximumMemoryMiB, architecture, memoryPolicy).ToList(); + + var game = manifest.Arguments is not null + ? Group("game").ToList() + : LaunchArguments.Parse(manifest.MinecraftArguments ?? "").ToList(); + game.AddRange((profile?.GameArguments ?? []).Where(argument => argument.IsAllowed(architecture, features)) + .SelectMany(argument => argument.Values)); + if (parameters.Fullscreen) + { + game.Add("--fullscreen"); + } + else if (!game.Contains("--width", StringComparer.Ordinal)) + { + game.Add("--width"); + game.Add(parameters.WindowWidth.ToString(System.Globalization.CultureInfo.InvariantCulture)); + game.Add("--height"); + game.Add(parameters.WindowHeight.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + var extra = source == LaunchArgumentSource.Mojang + ? Array.Empty() + : LaunchArguments.Parse(parameters.MinecraftArguments).ToArray(); + foreach (var argument in extra) + { + var option = argument.Split('=', 2)[0]; + if (ReservedGameArguments.Contains(option)) + { + throw new MinecraftLaunchException(MinecraftLaunchError.ReservedGameArgument, option); + } + } + + game.AddRange(extra); + for (var index = 0; index < game.Count; index++) + { + game[index] = game[index] + .Replace("${resolution_width}", parameters.WindowWidth.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal) + .Replace("${resolution_height}", parameters.WindowHeight.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + return (java, game); + } + + private static string Substitute(string text, IReadOnlyDictionary values) => Regex.Replace( + text, + "\\$\\{([^}]+)\\}", + match => values.TryGetValue(match.Groups[1].Value, out var value) + ? value + : throw new MinecraftLaunchException(MinecraftLaunchError.InvalidManifest, match.Groups[1].Value)); +} + +public enum MinecraftLaunchError +{ + InvalidManifest, + InvalidOfflineName, + ReservedGameArgument, + BuildNotReady, + BuildAlreadyRunning, + JavaMissing, + MetadataInvalid, + AssetIndexInvalid, + ClientMissing, + SessionChanged, + FabricProfileMissing, + ProcessStartFailed, + CommandLineTooLong, + ArgumentFileEncoding, + ProcessRecordInvalid +} + +public sealed class MinecraftLaunchException(MinecraftLaunchError error, string? detail = null, Exception? innerException = null) + : Exception(detail ?? error.ToString(), innerException) +{ + public MinecraftLaunchError Error { get; } = error; + public string ResourceKey => Error switch + { + MinecraftLaunchError.InvalidManifest => "LaunchInvalidManifest", + MinecraftLaunchError.InvalidOfflineName => "LaunchInvalidOfflineName", + MinecraftLaunchError.ReservedGameArgument => "LaunchReservedGameArgument", + MinecraftLaunchError.BuildNotReady => "LaunchBuildNotReady", + MinecraftLaunchError.BuildAlreadyRunning => "LaunchBuildAlreadyRunning", + MinecraftLaunchError.JavaMissing => "LaunchJavaMissing", + MinecraftLaunchError.MetadataInvalid => "LaunchMetadataInvalid", + MinecraftLaunchError.AssetIndexInvalid => "LaunchAssetIndexInvalid", + MinecraftLaunchError.ClientMissing => "LaunchClientMissing", + MinecraftLaunchError.SessionChanged => "LaunchSessionChanged", + MinecraftLaunchError.FabricProfileMissing => "LaunchFabricProfileMissing", + MinecraftLaunchError.ProcessStartFailed => "LaunchProcessStartFailed", + MinecraftLaunchError.CommandLineTooLong => "LaunchCommandLineTooLong", + MinecraftLaunchError.ArgumentFileEncoding => "LaunchArgumentFileEncoding", + MinecraftLaunchError.ProcessRecordInvalid => "LaunchProcessRecordInvalid", + _ => throw new ArgumentOutOfRangeException(nameof(Error), Error, null) + }; +} diff --git a/windows/Hako.Core/Minecraft/ModpackArchive.cs b/windows/Hako.Core/Minecraft/ModpackArchive.cs new file mode 100644 index 0000000..bd878dc --- /dev/null +++ b/windows/Hako.Core/Minecraft/ModpackArchive.cs @@ -0,0 +1,329 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text.Json; + +namespace Hako.Core.Minecraft; + +public sealed record ModpackArchiveOverride(string Path, string EntryPath, long Size); + +public sealed record ModpackArchiveContents(ModpackManifest Manifest, IReadOnlyList Overrides); + +public static class ModpackArchive +{ + private const uint EndSignature = 0x06054b50; + private const uint EntrySignature = 0x02014b50; + private const int EndRecordSize = 22; + private const int EntryRecordSize = 46; + private const int MaximumZipCommentLength = ushort.MaxValue; + + public static ModpackArchiveContents Inspect(string archivePath) => + Inspect(archivePath, ModpackArchiveLimits.Default); + + internal static ModpackArchiveContents Inspect(string archivePath, ModpackArchiveLimits limits) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + var archiveLength = new FileInfo(archivePath).Length; + if (archiveLength <= 0 || archiveLength > limits.MaximumArchiveSize) + { + throw new ModpackException(ModpackError.Archive); + } + + var centralDirectory = ScanCentralDirectory(archivePath, limits); + using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + if (archive.Entries.Count != centralDirectory.EntryCount) + { + throw new ModpackException(ModpackError.Archive); + } + + var archivePaths = new ModpackPathRegistry(); + var indexEntries = new List(); + long expandedSize = 0; + foreach (var entry in archive.Entries) + { + var isDirectory = entry.FullName.EndsWith('/'); + var path = isDirectory ? entry.FullName[..^1] : entry.FullName; + ModpackPaths.Validate(path); + ValidateEntryAttributes(entry, isDirectory); + if (entry.Length < 0 || entry.Length > limits.MaximumFileSize) + { + throw new ModpackException(ModpackError.Archive); + } + + expandedSize = checked(expandedSize + entry.Length); + if (expandedSize > limits.MaximumExpandedSize) + { + throw new ModpackException(ModpackError.Archive); + } + + if (isDirectory) + { + if (entry.Length != 0) throw new ModpackException(ModpackError.Archive); + archivePaths.AddDirectory(path, explicitEntry: true); + } + else + { + archivePaths.AddFile(path); + if (string.Equals(path, "modrinth.index.json", StringComparison.Ordinal)) + { + if (entry.Length > limits.MaximumIndexSize) throw new ModpackException(ModpackError.Archive); + indexEntries.Add(entry); + } + } + } + + if (indexEntries.Count != 1) + { + throw new ModpackException(ModpackError.Archive); + } + + var manifest = ReadManifest(indexEntries[0], limits); + var destinationPaths = new ModpackPathRegistry(); + foreach (var file in manifest.Files) + { + destinationPaths.AddFile(file.Path); + } + + var overrides = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var layer in new[] { "overrides/", "client-overrides/", "server-overrides/" }) + { + foreach (var entry in archive.Entries) + { + if (!entry.FullName.StartsWith(layer, StringComparison.Ordinal)) + { + continue; + } + + var isDirectory = entry.FullName.EndsWith('/'); + var entryPath = isDirectory ? entry.FullName[..^1] : entry.FullName; + var destination = ModpackPaths.OverrideDestination(entryPath, isDirectory); + if (destination is null || layer == "server-overrides/") + { + continue; + } + + if (isDirectory) + { + destinationPaths.AddDirectory(destination); + continue; + } + + destinationPaths.AddFile(destination, replacing: true); + if (overrides.TryGetValue(destination, out var previous) + && !string.Equals(previous.Path, destination, StringComparison.Ordinal)) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + overrides[destination] = new ModpackArchiveOverride(destination, entry.FullName, entry.Length); + } + } + + return new ModpackArchiveContents(manifest, overrides.Values.OrderBy(item => item.Path, StringComparer.Ordinal).ToArray()); + } + + public static async Task ExtractAsync( + string archivePath, + ModpackArchiveOverride entry, + string destination, + long limit, + CancellationToken cancellationToken) + { + ModpackPaths.Validate(entry.Path); + ModpackPaths.Validate(entry.EntryPath); + if (limit < 0 || entry.Size > limit) + { + throw new ModpackException(ModpackError.Archive); + } + + try + { + await using var archiveStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: false); + var matches = archive.Entries.Where(item => string.Equals(item.FullName, entry.EntryPath, StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1 || matches[0].FullName.EndsWith('/') + || matches[0].Length != entry.Size) + { + throw new ModpackException(ModpackError.Archive); + } + + ValidateEntryAttributes(matches[0], directory: false); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await using var source = matches[0].Open(); + await using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + var buffer = new byte[81920]; + long written = 0; + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + written = checked(written + read); + if (written > limit) throw new ModpackException(ModpackError.Archive); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + } + + if (written != entry.Size) + { + throw new ModpackException(ModpackError.Archive); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + try { File.Delete(destination); } + catch (FileNotFoundException) { } + throw; + } + } + + private static ModpackManifest ReadManifest(ZipArchiveEntry entry, ModpackArchiveLimits limits) + { + try + { + using var input = entry.Open(); + using var output = new MemoryStream((int)entry.Length); + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = input.Read(buffer, 0, buffer.Length); + if (read == 0) break; + total = checked(total + read); + if (total > limits.MaximumIndexSize) throw new ModpackException(ModpackError.Archive); + output.Write(buffer, 0, read); + } + + if (total != entry.Length) throw new ModpackException(ModpackError.Archive); + var manifest = JsonSerializer.Deserialize(output.GetBuffer().AsSpan(0, (int)output.Length), + HakoJsonContext.Default.ModpackManifest) + ?? throw new ModpackException(ModpackError.InvalidManifest); + manifest.Validate(limits); + return manifest; + } + catch (JsonException exception) + { + throw new ModpackException(ModpackError.InvalidManifest, exception); + } + } + + private static (int EntryCount, long CentralOffset, long CentralSize) ScanCentralDirectory( + string archivePath, + ModpackArchiveLimits limits) + { + using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); + var tailLength = (int)Math.Min(stream.Length, EndRecordSize + MaximumZipCommentLength); + var tail = new byte[tailLength]; + stream.Position = stream.Length - tailLength; + stream.ReadExactly(tail); + + var endOffset = -1; + for (var index = tail.Length - EndRecordSize; index >= 0; index--) + { + if (BinaryPrimitives.ReadUInt32LittleEndian(tail.AsSpan(index, sizeof(uint))) != EndSignature) + { + continue; + } + + var commentLength = BinaryPrimitives.ReadUInt16LittleEndian(tail.AsSpan(index + 20, sizeof(ushort))); + if (index + EndRecordSize + commentLength == tail.Length) + { + endOffset = index; + break; + } + } + + if (endOffset < 0) throw new ModpackException(ModpackError.Archive); + var end = tail.AsSpan(endOffset); + if (BinaryPrimitives.ReadUInt16LittleEndian(end[4..]) != 0 + || BinaryPrimitives.ReadUInt16LittleEndian(end[6..]) != 0 + || BinaryPrimitives.ReadUInt16LittleEndian(end[8..]) != BinaryPrimitives.ReadUInt16LittleEndian(end[10..])) + { + throw new ModpackException(ModpackError.Archive); + } + + var entries = BinaryPrimitives.ReadUInt16LittleEndian(end[10..]); + var centralSize = BinaryPrimitives.ReadUInt32LittleEndian(end[12..]); + var centralOffset = BinaryPrimitives.ReadUInt32LittleEndian(end[16..]); + if (entries == ushort.MaxValue || centralSize == uint.MaxValue || centralOffset == uint.MaxValue + || entries > limits.MaximumEntries || centralSize > limits.MaximumCentralDirectorySize + || (long)centralOffset + centralSize > stream.Length) + { + throw new ModpackException(ModpackError.Archive); + } + + stream.Position = centralOffset; + var fixedEntry = new byte[EntryRecordSize]; + long expandedSize = 0; + for (var index = 0; index < entries; index++) + { + stream.ReadExactly(fixedEntry); + var header = fixedEntry.AsSpan(); + if (BinaryPrimitives.ReadUInt32LittleEndian(header) != EntrySignature) + { + throw new ModpackException(ModpackError.Archive); + } + + var flags = BinaryPrimitives.ReadUInt16LittleEndian(header[8..]); + var method = BinaryPrimitives.ReadUInt16LittleEndian(header[10..]); + var compressedSize = BinaryPrimitives.ReadUInt32LittleEndian(header[20..]); + var uncompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(header[24..]); + var nameLength = BinaryPrimitives.ReadUInt16LittleEndian(header[28..]); + var extraLength = BinaryPrimitives.ReadUInt16LittleEndian(header[30..]); + var commentLength = BinaryPrimitives.ReadUInt16LittleEndian(header[32..]); + var disk = BinaryPrimitives.ReadUInt16LittleEndian(header[34..]); + if ((flags & (0x0001 | 0x0040 | 0x2000)) != 0 + || method is not (0 or 8) + || disk != 0 + || compressedSize == uint.MaxValue + || uncompressedSize == uint.MaxValue) + { + throw new ModpackException(ModpackError.Archive); + } + + if (uncompressedSize > limits.MaximumFileSize) + { + throw new ModpackException(ModpackError.Archive); + } + + expandedSize = checked(expandedSize + uncompressedSize); + if (expandedSize > limits.MaximumExpandedSize) + { + throw new ModpackException(ModpackError.Archive); + } + + var metadataLength = (long)nameLength + extraLength + commentLength; + if (stream.Position + metadataLength > (long)centralOffset + centralSize) + { + throw new ModpackException(ModpackError.Archive); + } + + stream.Position += metadataLength; + } + + if (stream.Position != (long)centralOffset + centralSize) + { + throw new ModpackException(ModpackError.Archive); + } + + return (entries, centralOffset, centralSize); + } + + private static void ValidateEntryAttributes(ZipArchiveEntry entry, bool directory) + { + var attributes = unchecked((uint)entry.ExternalAttributes); + var dosAttributes = attributes & 0xFFFF; + var unixType = (attributes >> 16) & 0xF000; + var isSymlink = unixType == 0xA000; + var isSupportedUnixType = unixType is 0 or 0x4000 or 0x8000; + var dosReparse = (dosAttributes & (uint)(FileAttributes.ReparsePoint | FileAttributes.Device)) != 0; + if (isSymlink || !isSupportedUnixType || dosReparse + || (unixType == 0x4000 && !directory) + || (unixType == 0x8000 && directory)) + { + throw new ModpackException(ModpackError.UnsafePath); + } + } +} diff --git a/windows/Hako.Core/Minecraft/ModpackInstaller.cs b/windows/Hako.Core/Minecraft/ModpackInstaller.cs new file mode 100644 index 0000000..3efd378 --- /dev/null +++ b/windows/Hako.Core/Minecraft/ModpackInstaller.cs @@ -0,0 +1,395 @@ +using System.Collections.Concurrent; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public sealed class ModpackInstaller +{ + private static readonly ConcurrentDictionary CacheGates = new(StringComparer.OrdinalIgnoreCase); + private static readonly HashSet AllowedManifestHosts = new(StringComparer.OrdinalIgnoreCase) + { + "cdn.modrinth.com", + "github.com", + "raw.githubusercontent.com", + "gitlab.com" + }; + private static readonly HashSet AllowedArchiveHosts = new(StringComparer.OrdinalIgnoreCase) + { + "cdn.modrinth.com" + }; + private readonly ModrinthClient _modrinth; + private readonly MojangClient _minecraft; + private readonly FabricClient _fabric; + private readonly string _cacheRoot; + + public ModpackInstaller(ModrinthClient modrinth, MojangClient minecraft, FabricClient fabric, HakoPaths paths) + { + _modrinth = modrinth; + _minecraft = minecraft; + _fabric = fabric; + _cacheRoot = Path.Combine(Path.GetFullPath(paths.CacheRoot), "Modpacks"); + } + + public Task DownloadIconAsync(ModrinthProject project, CancellationToken cancellationToken = default) => + _modrinth.DownloadImageAsync(project.IconUrl, cancellationToken); + + public async Task PrepareAsync( + ModrinthProject project, + ModrinthVersion version, + string? minecraftFilter = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(version); + var archive = SelectArchive(version); + var pinnedArchive = ModpackFile.From(archive); + if (project.ProjectType != "modpack" + || version.ProjectId != project.Id + || version.Loaders.Count == 0 + || version.Loaders.Any(loader => loader is not ("fabric" or "minecraft"))) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + var archivePath = await GetCachedArchiveAsync(pinnedArchive, cancellationToken).ConfigureAwait(false); + var contents = await Task.Run(() => ModpackArchive.Inspect(archivePath), cancellationToken).ConfigureAwait(false); + var manifest = contents.Manifest; + var expectedLoader = manifest.LoaderVersion is null ? "minecraft" : "fabric"; + if (!version.GameVersions.Contains(manifest.Minecraft, StringComparer.Ordinal) + || !version.Loaders.Contains(expectedLoader, StringComparer.Ordinal) + || minecraftFilter is not null && manifest.Minecraft != minecraftFilter) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + var catalog = await _minecraft.CatalogAsync(cancellationToken).ConfigureAwait(false); + var gameVersion = catalog.Versions.FirstOrDefault(item => + string.Equals(item.Id, manifest.Minecraft, StringComparison.Ordinal)) + ?? throw new ModpackException(ModpackError.InvalidManifest); + var preparedMinecraft = await _minecraft.PrepareAsync(gameVersion, cancellationToken).ConfigureAwait(false); + FabricProfile? fabricProfile = null; + if (manifest.LoaderVersion is { } loader) + { + var loaders = await _fabric.GetLoaderVersionsAsync(manifest.Minecraft, cancellationToken).ConfigureAwait(false); + if (!loaders.Any(item => string.Equals(item.Version, loader, StringComparison.Ordinal))) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + fabricProfile = (await _fabric.GetProfileAsync(manifest.Minecraft, loader, cancellationToken).ConfigureAwait(false)).Profile; + } + + cancellationToken.ThrowIfCancellationRequested(); + var plan = new ModpackPlan + { + ProjectId = project.Id, + VersionId = version.Id, + VersionNumber = version.VersionNumber, + PageUrl = project.PageUrl, + Archive = pinnedArchive, + Manifest = manifest + }; + plan.Validate(); + return new PreparedModpack(plan, preparedMinecraft, fabricProfile); + } + + public ModpackPlan? Validate(string instanceRoot, GameInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + var plan = ModpackPlan.Read(instanceRoot); + if (instance.ModpackVersionID is null) + { + if (plan is not null) throw new ModpackException(ModpackError.InvalidManifest); + return null; + } + + if (plan is null + || !string.Equals(plan.VersionId, instance.ModpackVersionID, StringComparison.Ordinal) + || !string.Equals(plan.Manifest.Minecraft, instance.VersionID, StringComparison.Ordinal) + || plan.Manifest.LoaderVersion is null + && (instance.ModLoader != InstanceModLoader.Vanilla || instance.FabricConfiguration is not null) + || plan.Manifest.LoaderVersion is { } expectedLoader + && (instance.ModLoader != InstanceModLoader.Fabric + || instance.FabricConfiguration is not { Api: null } configuration + || !string.Equals(configuration.LoaderVersion, expectedLoader, StringComparison.Ordinal))) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + return plan; + } + + public async Task InstallAsync(string instanceRoot, IProgress? progress, + CancellationToken cancellationToken) + { + var plan = ModpackPlan.Read(instanceRoot) + ?? throw new ModpackException(ModpackError.InvalidManifest); + if (plan.Completed) return; + + plan.Validate(); + progress?.Report(new InstallationProgress(InstallationProgressStage.PreparingModpack)); + var archivePath = await GetCachedArchiveAsync(plan.Archive, cancellationToken).ConfigureAwait(false); + var contents = await Task.Run(() => ModpackArchive.Inspect(archivePath), cancellationToken).ConfigureAwait(false); + if (!contents.Manifest.EquivalentTo(plan.Manifest)) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + var files = plan.Manifest.Files.Where(file => file.InstallOnClient).ToArray(); + var totalFileBytes = files.Aggregate(0L, (total, file) => checked(total + file.FileSize)); + var totalOverrideBytes = contents.Overrides.Aggregate(0L, (total, file) => checked(total + file.Size)); + if (totalFileBytes > ModpackArchiveLimits.Default.MaximumExpandedSize - totalOverrideBytes) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + var minecraftRoot = InstanceStorage.ContainedPath("minecraft", instanceRoot); + if (!Directory.Exists(minecraftRoot) || IsReparsePoint(minecraftRoot)) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + var staging = InstanceStorage.ContainedPath(".hako-modpack-" + Guid.NewGuid().ToString("N"), instanceRoot); + if (File.Exists(staging) || Directory.Exists(staging)) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + Directory.CreateDirectory(staging); + try + { + var cacheFiles = Path.Combine(_cacheRoot, "files"); + Directory.CreateDirectory(cacheFiles); + var downloadedBytes = 0L; + var completedFiles = 0; + progress?.Report(new InstallationProgress(InstallationProgressStage.DownloadingModpack, + TotalBytes: totalFileBytes, TotalFiles: files.Length)); + using (var limiter = new SemaphoreSlim(4)) + using (var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + var tasks = files.Select(async file => + { + await limiter.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + var cached = await GetCachedFileAsync(file, cacheFiles, linked.Token).ConfigureAwait(false); + var destination = InstanceStorage.ContainedPath(file.Path, staging); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await CopyFileAsync(cached, destination, linked.Token).ConfigureAwait(false); + var bytes = Interlocked.Add(ref downloadedBytes, file.FileSize); + var count = Interlocked.Increment(ref completedFiles); + progress?.Report(new InstallationProgress(InstallationProgressStage.DownloadingModpack, + bytes, totalFileBytes, count, files.Length)); + } + catch + { + linked.Cancel(); + throw; + } + finally + { + limiter.Release(); + } + }).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + var extractedBytes = 0L; + foreach (var item in contents.Overrides) + { + cancellationToken.ThrowIfCancellationRequested(); + var destination = InstanceStorage.ContainedPath(item.Path, staging); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + if (File.Exists(destination)) File.Delete(destination); + var remaining = ModpackArchiveLimits.Default.MaximumExpandedSize - totalFileBytes - extractedBytes; + await ModpackArchive.ExtractAsync(archivePath, item, destination, + Math.Min(ModpackArchiveLimits.Default.MaximumFileSize, remaining), cancellationToken).ConfigureAwait(false); + extractedBytes = checked(extractedBytes + item.Size); + } + + var stagedPaths = files.Select(file => file.Path) + .Concat(contents.Overrides.Select(item => item.Path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + foreach (var path in stagedPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + _ = DestinationPath(path, minecraftRoot); + } + + progress?.Report(new InstallationProgress(InstallationProgressStage.InstallingModpackContents, + totalFileBytes, totalFileBytes, files.Length, files.Length)); + foreach (var path in stagedPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var source = InstanceStorage.ContainedPath(path, staging); + var destination = DestinationPath(path, minecraftRoot); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + _ = DestinationPath(path, minecraftRoot); + File.Move(source, destination, overwrite: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + await (plan with { Completed = true }).WriteAsync(instanceRoot, cancellationToken).ConfigureAwait(false); + } + finally + { + DeleteOwnedStaging(staging); + } + } + + private async Task GetCachedArchiveAsync(ModpackFile file, CancellationToken cancellationToken) + { + if (!file.IsInstallable(ModpackArchiveLimits.Default.MaximumFileSize)) + throw new ModpackException(ModpackError.InvalidManifest); + var directory = Path.Combine(_cacheRoot, "archives"); + Directory.CreateDirectory(directory); + var destination = Path.Combine(directory, file.Sha512.ToLowerInvariant() + ".mrpack"); + return await GetCachedAsync(destination, file.Size, file.Sha1, file.Sha512, + [file.Url], AllowedArchiveHosts, cancellationToken).ConfigureAwait(false); + } + + private Task GetCachedFileAsync(ModpackManifestFile file, string cacheDirectory, CancellationToken cancellationToken) + { + var sha1 = file.Hashes["sha1"]; + var sha512 = file.Hashes["sha512"]; + var destination = Path.Combine(cacheDirectory, sha512.ToLowerInvariant()); + return GetCachedAsync(destination, file.FileSize, sha1, sha512, file.Downloads, + AllowedManifestHosts, cancellationToken, allowEmpty: true); + } + + private async Task GetCachedAsync(string destination, long size, string sha1, string sha512, + IReadOnlyList urls, IReadOnlySet? allowedHosts, CancellationToken cancellationToken, + bool allowEmpty = false) + { + var gate = CacheGates.GetOrAdd(destination, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + EnsureRegularCacheFileOrMissing(destination); + if (File.Exists(destination) + && await ModrinthClient.IsVerifiedFileAsync(destination, size, sha1, sha512, cancellationToken) + .ConfigureAwait(false)) + { + return destination; + } + + if (File.Exists(destination)) File.Delete(destination); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var temporary = Path.Combine(Path.GetDirectoryName(destination)!, ".hako-modpack-" + Guid.NewGuid().ToString("N")); + try + { + try + { + await _modrinth.DownloadVerifiedFileAsync(urls, temporary, size, sha1, sha512, allowedHosts, + cancellationToken, allowEmpty).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + throw new ModpackException(ModpackError.DamagedFile, exception); + } + + EnsureRegularCacheFileOrMissing(destination); + File.Move(temporary, destination, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (FileNotFoundException) { } + } + + return destination; + } + finally + { + gate.Release(); + } + } + + private static async Task CopyFileAsync(string source, string destination, CancellationToken cancellationToken) + { + await using var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + await input.CopyToAsync(output, 81920, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static string DestinationPath(string relativePath, string minecraftRoot) + { + ModpackPaths.Validate(relativePath); + var destination = InstanceStorage.ContainedPath(relativePath, minecraftRoot); + var current = minecraftRoot; + if (IsReparsePoint(current)) throw new ModpackException(ModpackError.UnsafePath); + var segments = relativePath.Split('/'); + for (var index = 0; index < segments.Length; index++) + { + current = Path.Combine(current, segments[index]); + if (!File.Exists(current) && !Directory.Exists(current)) + { + try + { + _ = File.GetAttributes(current); + } + catch (FileNotFoundException) + { + continue; + } + catch (DirectoryNotFoundException) + { + continue; + } + } + + var attributes = File.GetAttributes(current); + if ((attributes & FileAttributes.ReparsePoint) != 0 + || index < segments.Length - 1 && (attributes & FileAttributes.Directory) == 0 + || index == segments.Length - 1 && (attributes & FileAttributes.Directory) != 0) + { + throw new ModpackException(ModpackError.UnsafePath); + } + } + + return destination; + } + + private static bool IsReparsePoint(string path) + { + try { return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + + private static void EnsureRegularCacheFileOrMissing(string path) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & (FileAttributes.ReparsePoint | FileAttributes.Directory | FileAttributes.Device)) != 0) + throw new ModpackException(ModpackError.UnsafePath); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + } + + private static void DeleteOwnedStaging(string path) + { + if (!Directory.Exists(path) || IsReparsePoint(path)) return; + try { Directory.Delete(path, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static ModrinthFile SelectArchive(ModrinthVersion version) + { + var candidates = version.Files.Where(file => file.IsInstallable(".mrpack")).ToArray(); + return candidates.FirstOrDefault(file => file.Primary) ?? candidates.FirstOrDefault() + ?? throw new ModpackException(ModpackError.InvalidManifest); + } +} diff --git a/windows/Hako.Core/Minecraft/ModpackManifest.cs b/windows/Hako.Core/Minecraft/ModpackManifest.cs new file mode 100644 index 0000000..8b15ad2 --- /dev/null +++ b/windows/Hako.Core/Minecraft/ModpackManifest.cs @@ -0,0 +1,339 @@ +using System.Text.Json.Serialization; +using Hako.Core; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public sealed record ModpackManifest +{ + [JsonRequired] + [JsonPropertyName("formatVersion")] + public int FormatVersion { get; init; } + + [JsonRequired] + public string Game { get; init; } = ""; + + [JsonRequired] + [JsonPropertyName("versionId")] + public string VersionId { get; init; } = ""; + + [JsonRequired] + public string Name { get; init; } = ""; + + [JsonRequired] + public List Files { get; init; } = []; + + [JsonRequired] + public Dictionary Dependencies { get; init; } = new(StringComparer.Ordinal); + + [JsonIgnore] + public string Minecraft => Dependencies.GetValueOrDefault("minecraft", ""); + + [JsonIgnore] + public string? LoaderVersion => Dependencies.GetValueOrDefault("fabric-loader"); + + public void Validate(ModpackArchiveLimits? limits = null) + { + limits ??= ModpackArchiveLimits.Default; + if (FormatVersion != 1 || Game != "minecraft" || string.IsNullOrWhiteSpace(Minecraft) + || Dependencies.Count is < 1 or > 2 + || Dependencies.Keys.Any(key => key is not ("minecraft" or "fabric-loader")) + || Dependencies.Values.Any(string.IsNullOrWhiteSpace) + || Files.Count > limits.MaximumEntries) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + long total = 0; + var paths = new ModpackPathRegistry(); + foreach (var file in Files) + { + ModpackPaths.Validate(file.Path); + paths.AddFile(file.Path); + if (file.FileSize < 0 || file.FileSize > limits.MaximumFileSize + || file.Hashes.GetValueOrDefault("sha1") is not { Length: 40 } sha1 + || file.Hashes.GetValueOrDefault("sha512") is not { Length: 128 } sha512 + || !(sha1 + sha512).All(Uri.IsHexDigit) + || file.Downloads.Count == 0 + || file.Downloads.Any(uri => !ModpackPaths.IsAllowedDownload(uri)) + || file.Environment is { } environment + && (environment.Client is not ("required" or "optional" or "unsupported") + || environment.Server is not ("required" or "optional" or "unsupported"))) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + total = checked(total + file.FileSize); + if (total > limits.MaximumExpandedSize) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + } + } + + public bool EquivalentTo(ModpackManifest other) => + FormatVersion == other.FormatVersion + && Game == other.Game + && VersionId == other.VersionId + && Name == other.Name + && Dependencies.Count == other.Dependencies.Count + && Dependencies.All(item => other.Dependencies.TryGetValue(item.Key, out var value) && value == item.Value) + && Files.Count == other.Files.Count + && Files.Zip(other.Files).All(pair => + pair.First.Path == pair.Second.Path + && pair.First.FileSize == pair.Second.FileSize + && pair.First.Downloads.SequenceEqual(pair.Second.Downloads) + && pair.First.Hashes.Count == pair.Second.Hashes.Count + && pair.First.Hashes.All(item => pair.Second.Hashes.TryGetValue(item.Key, out var value) && value == item.Value) + && (pair.First.Environment is null && pair.Second.Environment is null + || pair.First.Environment == pair.Second.Environment)); +} + +public sealed record ModpackManifestFile +{ + [JsonRequired] + public string Path { get; init; } = ""; + + [JsonRequired] + public Dictionary Hashes { get; init; } = new(StringComparer.Ordinal); + + [JsonPropertyName("env")] + public ModpackEnvironment? Environment { get; init; } + + [JsonRequired] + public List Downloads { get; init; } = []; + + [JsonRequired] + [JsonPropertyName("fileSize")] + public long FileSize { get; init; } + + [JsonIgnore] + public bool InstallOnClient => Environment?.Client != "unsupported"; +} + +public sealed record ModpackEnvironment +{ + [JsonRequired] + public string Client { get; init; } = ""; + + [JsonRequired] + public string Server { get; init; } = ""; +} + +/// The pinned Modrinth file shape is shared with ModrinthVersion.File in Swift. +public sealed record ModpackFile +{ + [JsonRequired] + public string Filename { get; init; } = ""; + + [JsonRequired] + public Uri Url { get; init; } = new("https://cdn.modrinth.com/"); + + [JsonRequired] + public long Size { get; init; } + + [JsonRequired] + public Dictionary Hashes { get; init; } = new(StringComparer.Ordinal); + + [JsonRequired] + public bool Primary { get; init; } + + [JsonPropertyName("file_type")] + public string? FileType { get; init; } + + [JsonIgnore] + public string Sha1 => Hashes.GetValueOrDefault("sha1", "").ToLowerInvariant(); + + [JsonIgnore] + public string Sha512 => Hashes.GetValueOrDefault("sha512", "").ToLowerInvariant(); + + public static ModpackFile From(ModrinthFile file) => new() + { + Filename = file.Filename, + Url = file.Url, + Size = file.Size, + Hashes = new Dictionary(file.Hashes, StringComparer.Ordinal), + Primary = file.Primary, + FileType = file.FileType + }; + + public bool IsInstallable(long maximumSize) => + Filename.EndsWith(".mrpack", StringComparison.OrdinalIgnoreCase) + && InstanceStorage.IsSinglePathSegment(Filename) + && !Filename.StartsWith('.') + && Url.IsAbsoluteUri + && Url.Scheme == Uri.UriSchemeHttps + && Url.IdnHost == "cdn.modrinth.com" + && Url.Port == 443 + && string.IsNullOrEmpty(Url.UserInfo) + && Size > 0 + && Size <= maximumSize + && Sha1.Length == 40 && Sha512.Length == 128 + && (Sha1 + Sha512).All(Uri.IsHexDigit); +} + +public sealed record ModpackPlan +{ + public const string Filename = ".hako-modpack.json"; + + [JsonRequired] + [JsonPropertyName("formatVersion")] + public int FormatVersion { get; init; } = 1; + + [JsonRequired] + [JsonPropertyName("projectID")] + public string ProjectId { get; init; } = ""; + + [JsonRequired] + [JsonPropertyName("versionID")] + public string VersionId { get; init; } = ""; + + [JsonRequired] + [JsonPropertyName("versionNumber")] + public string VersionNumber { get; init; } = ""; + + [JsonRequired] + [JsonPropertyName("pageURL")] + public Uri PageUrl { get; init; } = new("https://modrinth.com/"); + + [JsonRequired] + public ModpackFile Archive { get; init; } = new(); + + [JsonRequired] + public ModpackManifest Manifest { get; init; } = new(); + + public bool Completed { get; init; } + + public void Validate(ModpackArchiveLimits? limits = null) + { + limits ??= ModpackArchiveLimits.Default; + if (FormatVersion != 1 + || !IsId(ProjectId) + || !IsId(VersionId) + || string.IsNullOrWhiteSpace(VersionNumber) + || !PageUrl.IsAbsoluteUri || PageUrl.Scheme != Uri.UriSchemeHttps + || PageUrl.IdnHost != "modrinth.com" + || !Archive.IsInstallable(limits.MaximumFileSize)) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + Manifest.Validate(limits); + } + + public async Task WriteAsync(string instanceRoot, CancellationToken cancellationToken = default) + { + Validate(); + var destination = InstanceStorage.ContainedPath(Filename, instanceRoot); + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 81920, FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await System.Text.Json.JsonSerializer.SerializeAsync(output, this, HakoJsonContext.Default.ModpackPlan, + cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporary, destination, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (FileNotFoundException) { } + } + } + + public void Write(string instanceRoot) + { + Validate(); + var destination = InstanceStorage.ContainedPath(Filename, instanceRoot); + var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 81920, FileOptions.WriteThrough)) + { + System.Text.Json.JsonSerializer.Serialize(output, this, HakoJsonContext.Default.ModpackPlan); + output.Flush(flushToDisk: true); + } + + File.Move(temporary, destination, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (FileNotFoundException) { } + } + } + + public static ModpackPlan? Read(string instanceRoot) + { + var path = InstanceStorage.ContainedPath(Filename, instanceRoot); + if (!File.Exists(path)) + { + if (Directory.Exists(path)) throw new ModpackException(ModpackError.InvalidManifest); + return null; + } + + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0 + || new FileInfo(path).Length > 16 * 1024 * 1024) + { + throw new ModpackException(ModpackError.InvalidManifest); + } + + try + { + using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var plan = System.Text.Json.JsonSerializer.Deserialize(input, HakoJsonContext.Default.ModpackPlan) + ?? throw new ModpackException(ModpackError.InvalidManifest); + plan.Validate(); + return plan; + } + catch (System.Text.Json.JsonException exception) + { + throw new ModpackException(ModpackError.InvalidManifest, exception); + } + } + + private static bool IsId(string value) => + value.Length is > 0 and <= 128 && value.All(char.IsAsciiLetterOrDigit); +} + +public sealed record PreparedModpack(ModpackPlan Plan, PreparedInstallation Minecraft, FabricProfile? FabricProfile = null); + +public enum ModpackError +{ + InvalidManifest, + UnsafePath, + Archive, + DamagedFile +} + +public sealed class ModpackException : IOException +{ + public ModpackException(ModpackError error, Exception? innerException = null) + : base("The Modrinth modpack could not be prepared safely.", innerException) => Error = error; + + public ModpackError Error { get; } +} + +public sealed record ModpackArchiveLimits( + int MaximumEntries, + long MaximumArchiveSize, + long MaximumIndexSize, + long MaximumFileSize, + long MaximumExpandedSize, + long MaximumCentralDirectorySize) +{ + public static ModpackArchiveLimits Default { get; } = new( + MaximumEntries: 50_000, + MaximumArchiveSize: 2_147_483_648, + MaximumIndexSize: 16_777_216, + MaximumFileSize: 2_147_483_648, + MaximumExpandedSize: 21_474_836_480, + MaximumCentralDirectorySize: 8_388_608); +} diff --git a/windows/Hako.Core/Minecraft/ModpackPaths.cs b/windows/Hako.Core/Minecraft/ModpackPaths.cs new file mode 100644 index 0000000..0f9fcf6 --- /dev/null +++ b/windows/Hako.Core/Minecraft/ModpackPaths.cs @@ -0,0 +1,172 @@ +using System.Text; + +namespace Hako.Core.Minecraft; + +public static class ModpackPaths +{ + private static readonly HashSet ReservedDevices = CreateReservedDevices(); + private static readonly HashSet ReservedGameRoots = new(StringComparer.OrdinalIgnoreCase) + { + "assets", "java", "libraries", "minecraft", "natives", "resources", "versions" + }; + private static readonly HashSet AllowedDownloadHosts = new(StringComparer.OrdinalIgnoreCase) + { + "cdn.modrinth.com", + "github.com", + "raw.githubusercontent.com", + "gitlab.com" + }; + + public static void Validate(string path) + { + if (string.IsNullOrEmpty(path) || path.Length > 32_760 + || path.StartsWith('/') || path.StartsWith('\\') + || Path.IsPathRooted(path) + || path.Contains('\\') || path.Contains(':') + || path.Any(character => char.IsControl(character) || "<>\"|?*[]".Contains(character))) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + var segments = path.Split('/', StringSplitOptions.None); + if (segments.Length == 0 || segments.Any(segment => + segment.Length == 0 + || segment is "." or ".." + || segment.Length > 255 + || segment.EndsWith('.') || segment.EndsWith(' ') + || segment.StartsWith(".hako", StringComparison.OrdinalIgnoreCase) + || ReservedDevices.Contains(DeviceBaseName(segment)))) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + if (ReservedGameRoots.Contains(segments[0])) + { + throw new ModpackException(ModpackError.UnsafePath); + } + } + + public static bool IsAllowedDownload(Uri uri) => + uri.IsAbsoluteUri + && uri.Scheme == Uri.UriSchemeHttps + && string.IsNullOrEmpty(uri.UserInfo) + && uri.Port == 443 + && AllowedDownloadHosts.Contains(uri.IdnHost); + + internal static string Key(string path) => path.Normalize(NormalizationForm.FormC); + + internal static string? OverrideDestination(string entryPath, bool directory) + { + var layers = new[] { "overrides/", "client-overrides/", "server-overrides/" }; + var layer = layers.FirstOrDefault(entryPath.StartsWith); + if (layer is null) + { + return null; + } + + var destination = entryPath[layer.Length..]; + if (destination.Length == 0 && directory) + { + return null; + } + + Validate(destination); + return destination; + } + + private static string DeviceBaseName(string segment) + { + var dot = segment.IndexOf('.'); + return (dot < 0 ? segment : segment[..dot]).ToUpperInvariant(); + } + + private static HashSet CreateReservedDevices() + { + var values = new HashSet(StringComparer.OrdinalIgnoreCase) { "CON", "PRN", "AUX", "NUL" }; + foreach (var number in Enumerable.Range(1, 9)) values.Add($"COM{number}"); + foreach (var number in Enumerable.Range(1, 9)) values.Add($"LPT{number}"); + foreach (var number in new[] { "¹", "²", "³" }) + { + values.Add("COM" + number); + values.Add("LPT" + number); + } + return values; + } +} + +internal enum ModpackPathKind +{ + File, + Directory +} + +internal sealed class ModpackPathRegistry +{ + private sealed record Entry(string Path, ModpackPathKind Kind, bool ExplicitDirectory = false); + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + + public void AddFile(string path, bool replacing = false) + { + ModpackPaths.Validate(path); + var parts = path.Split('/'); + AddParents(parts); + var key = ModpackPaths.Key(path); + if (_entries.TryGetValue(key, out var previous)) + { + if (previous.Kind == ModpackPathKind.Directory + || !replacing + || !string.Equals(previous.Path, path, StringComparison.Ordinal)) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + _entries[key] = previous; + return; + } + + _entries.Add(key, new Entry(path, ModpackPathKind.File)); + } + + public void AddDirectory(string path, bool explicitEntry = false) + { + ModpackPaths.Validate(path); + var parts = path.Split('/'); + AddParents(parts); + var key = ModpackPaths.Key(path); + if (_entries.TryGetValue(key, out var previous)) + { + if (previous.Kind != ModpackPathKind.Directory + || !string.Equals(previous.Path, path, StringComparison.Ordinal) + || explicitEntry && previous.ExplicitDirectory) + { + throw new ModpackException(ModpackError.UnsafePath); + } + + if (explicitEntry) _entries[key] = previous with { ExplicitDirectory = true }; + return; + } + + _entries.Add(key, new Entry(path, ModpackPathKind.Directory, explicitEntry)); + } + + private void AddParents(string[] parts) + { + for (var index = 1; index < parts.Length; index++) + { + var parent = string.Join('/', parts.Take(index)); + var key = ModpackPaths.Key(parent); + if (_entries.TryGetValue(key, out var existing)) + { + if (existing.Kind != ModpackPathKind.Directory + || !string.Equals(existing.Path, parent, StringComparison.Ordinal)) + { + throw new ModpackException(ModpackError.UnsafePath); + } + } + else + { + _entries.Add(key, new Entry(parent, ModpackPathKind.Directory)); + } + } + } +} diff --git a/windows/Hako.Core/Minecraft/ModrinthClient.cs b/windows/Hako.Core/Minecraft/ModrinthClient.cs new file mode 100644 index 0000000..f08a942 --- /dev/null +++ b/windows/Hako.Core/Minecraft/ModrinthClient.cs @@ -0,0 +1,827 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public enum ModrinthContentKind +{ + Mod, + ResourcePack, + Datapack +} + +public enum ModrinthSort +{ + Relevance, + Downloads, + Newest, + Updated +} + +[JsonConverter(typeof(ModrinthProjectJsonConverter))] +public sealed record ModrinthProject +{ + public string Id { get; init; } = ""; + public string? Slug { get; init; } + public string Title { get; init; } = ""; + public string Description { get; init; } = ""; + public Uri? IconUrl { get; init; } + public string ProjectType { get; init; } = ""; + public IReadOnlyList AllProjectTypes { get; init; } = []; + + public Uri PageUrl => new($"https://modrinth.com/{Uri.EscapeDataString(ProjectType)}/{Uri.EscapeDataString(Slug ?? Id)}"); +} + +internal sealed class ModrinthProjectJsonConverter : JsonConverter +{ + public override ModrinthProject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + var id = ReadString(root, "project_id") ?? ReadString(root, "id") + ?? throw new JsonException("Modrinth returned a project without an ID."); + var projectType = ReadString(root, "project_type") ?? ""; + var allTypes = root.TryGetProperty("all_project_types", out var types) && types.ValueKind == JsonValueKind.Array + ? types.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String).Select(item => item.GetString()!).ToArray() + : [projectType]; + var iconText = ReadString(root, "icon_url"); + return new ModrinthProject + { + Id = id, + Slug = ReadString(root, "slug"), + Title = ReadString(root, "title") ?? "", + Description = ReadString(root, "description") ?? "", + IconUrl = Uri.TryCreate(iconText, UriKind.Absolute, out var icon) ? icon : null, + ProjectType = projectType, + AllProjectTypes = allTypes + }; + } + + public override void Write(Utf8JsonWriter writer, ModrinthProject value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("id", value.Id); + if (value.Slug is not null) writer.WriteString("slug", value.Slug); + writer.WriteString("title", value.Title); + writer.WriteString("description", value.Description); + if (value.IconUrl is not null) writer.WriteString("icon_url", value.IconUrl.AbsoluteUri); + writer.WriteString("project_type", value.ProjectType); + writer.WritePropertyName("all_project_types"); + writer.WriteStartArray(); + foreach (var projectType in value.AllProjectTypes) writer.WriteStringValue(projectType); + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + private static string? ReadString(JsonElement root, string name) => + root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; +} + +public sealed record ModrinthSearchPage +{ + public List Hits { get; init; } = []; + [JsonPropertyName("total_hits")] + public int TotalHits { get; init; } +} + +public sealed record ModrinthFileMatch(string ProjectId, string VersionId, DateTimeOffset DatePublished, string Sha512); + +internal sealed record ModrinthVersionHashMatch +{ + public string Id { get; init; } = ""; + [JsonPropertyName("project_id")] + public string ProjectId { get; init; } = ""; + [JsonPropertyName("date_published")] + public DateTimeOffset DatePublished { get; init; } +} + +internal sealed record ModrinthFileCache +{ + public Dictionary Files { get; init; } = new(StringComparer.Ordinal); + public Dictionary Versions { get; init; } = new(StringComparer.OrdinalIgnoreCase); +} + +internal sealed record ModrinthFileCacheEntry(long Size, long ModifiedUtcTicks, string Sha512); +internal sealed record ModrinthHashesRequest(IReadOnlyList Hashes, string Algorithm); +internal sealed record ModrinthVersionUpdateRequest( + IReadOnlyList Hashes, + string Algorithm, + IReadOnlyList Loaders, + [property: JsonPropertyName("game_versions")] IReadOnlyList GameVersions, + [property: JsonPropertyName("version_types")] IReadOnlyList VersionTypes); + +/// The shared HTTPS boundary for Modrinth search, metadata, hash lookup, cache, and verified downloads. +public sealed class ModrinthClient : IDisposable +{ + public const string UserAgent = "Hako/1.0 (https://github.com/Liis17/Hako)"; + private const int MaximumResponseSize = 32 * 1024 * 1024; + private const int SearchPageSize = 20; + private static readonly string[] Channels = ["release", "beta", "alpha"]; + private static readonly ConcurrentDictionary CacheGates = new(StringComparer.OrdinalIgnoreCase); + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly Uri _base; + private readonly string _cacheFile; + private readonly SemaphoreSlim _cacheGate; + private bool _cacheWasInvalid; + /// Unknown hashes are retried only after a new client is created. + private readonly HashSet _unknownHashes = new(StringComparer.OrdinalIgnoreCase); + + public ModrinthClient(HttpClient? httpClient = null, Uri? baseUri = null, HakoPaths? paths = null, string? cacheRoot = null) + { + _ownsHttpClient = httpClient is null; + _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + _base = EnsureTrailingSlash(baseUri ?? new Uri("https://api.modrinth.com/v2/")); + var root = cacheRoot ?? paths?.CacheRoot + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Hako", "Cache"); + _cacheFile = Path.Combine(Path.GetFullPath(root), "Modrinth", "files.json"); + _cacheGate = CacheGates.GetOrAdd(_cacheFile, static _ => new SemaphoreSlim(1, 1)); + } + + public async Task SearchAsync(string query, ModrinthContentKind kind, string minecraft, + ModrinthSort sort = ModrinthSort.Relevance, int offset = 0, int limit = SearchPageSize, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(minecraft); + if (offset < 0 || limit is < 1 or > 100) + throw new ArgumentOutOfRangeException(offset < 0 ? nameof(offset) : nameof(limit)); + + string[][] facets = kind switch + { + ModrinthContentKind.Mod => [["project_type:mod"], ["categories:fabric"], [$"versions:{minecraft}"], ["environment!=dedicated_server_only"]], + ModrinthContentKind.ResourcePack => [["project_type:resourcepack"], [$"versions:{minecraft}"]], + ModrinthContentKind.Datapack => [["all_project_types:datapack"], ["categories:datapack"], [$"versions:{minecraft}"]], + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["facets"] = JsonSerializer.Serialize(facets, HakoJsonContext.Default.StringArrayArray), + ["index"] = SortName(sort), + ["offset"] = offset.ToString(CultureInfo.InvariantCulture), + ["limit"] = limit.ToString(CultureInfo.InvariantCulture) + }; + var trimmed = query.Trim(); + if (trimmed.Length > 0) parameters["query"] = trimmed; + return JsonSerializer.Deserialize(await GetBytesAsync(BuildUri("search", parameters), cancellationToken).ConfigureAwait(false), + HakoJsonContext.Default.ModrinthSearchPage) + ?? throw InvalidResponse("Modrinth returned an empty search page."); + } + + public async Task SearchModpacksAsync(string query, string? minecraft, + ModrinthSort sort = ModrinthSort.Relevance, int offset = 0, int limit = SearchPageSize, + CancellationToken cancellationToken = default) + { + if (offset < 0 || limit is < 1 or > 100) + throw new ArgumentOutOfRangeException(offset < 0 ? nameof(offset) : nameof(limit)); + if (minecraft is { Length: 0 }) + throw new ArgumentException("Minecraft filter must be null or an exact version.", nameof(minecraft)); + + string[][] facets = + [ + ["project_type:modpack"], + ["categories:fabric", "categories:minecraft"], + ["environment!=dedicated_server_only"] + ]; + if (minecraft is not null) facets = [.. facets, [$"versions:{minecraft}"]]; + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["facets"] = JsonSerializer.Serialize(facets, HakoJsonContext.Default.StringArrayArray), + ["index"] = SortName(sort), + ["offset"] = offset.ToString(CultureInfo.InvariantCulture), + ["limit"] = limit.ToString(CultureInfo.InvariantCulture) + }; + var trimmed = query.Trim(); + if (trimmed.Length > 0) parameters["query"] = trimmed; + var page = JsonSerializer.Deserialize(await GetBytesAsync(BuildUri("search", parameters), cancellationToken) + .ConfigureAwait(false), HakoJsonContext.Default.ModrinthSearchPage) + ?? throw InvalidResponse("Modrinth returned an empty modpack search page."); + return page; + } + + public Task SearchAsync(string query, bool mods, string minecraft, + ModrinthSort sort = ModrinthSort.Relevance, int offset = 0, CancellationToken cancellationToken = default) => + SearchAsync(query, mods ? ModrinthContentKind.Mod : ModrinthContentKind.ResourcePack, minecraft, sort, offset, + SearchPageSize, cancellationToken); + + public async Task> GetProjectVersionsAsync( + string projectId, + IReadOnlyList gameVersions, + IReadOnlyList loaders, + CancellationToken cancellationToken = default) + { + var id = CheckIds([projectId])[0]; + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["game_versions"] = JsonSerializer.Serialize(gameVersions.ToArray(), HakoJsonContext.Default.StringArray), + ["loaders"] = JsonSerializer.Serialize(loaders.ToArray(), HakoJsonContext.Default.StringArray), + ["include_changelog"] = "false" + }; + var bytes = await GetBytesAsync(BuildUri($"project/{id}/version", parameters), cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(bytes, FabricJsonContext.Default.ModrinthVersionArray) + ?? throw InvalidResponse("Modrinth returned an empty project version list."); + } + + public async Task> GetCompatibleVersionsAsync(string projectId, + ModrinthContentKind kind, string minecraft, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(minecraft); + var loader = Loader(kind); + var versions = await GetProjectVersionsAsync(projectId, [minecraft], [loader], cancellationToken).ConfigureAwait(false); + return versions + .Where(version => version.ProjectId == projectId + && version.GameVersions.Contains(minecraft, StringComparer.Ordinal) + && version.Loaders.Contains(loader, StringComparer.Ordinal) + && ChannelPriority(version.VersionType) >= 0 + && version.InstallableFile(kind) is not null) + .OrderBy(version => ChannelPriority(version.VersionType)) + .ThenByDescending(version => version.DatePublished) + .ThenBy(version => version.Id, StringComparer.Ordinal) + .ToArray(); + } + + public async Task> GetModpackVersionsAsync(string projectId, string? minecraft, + CancellationToken cancellationToken = default) + { + var id = CheckIds([projectId])[0]; + if (minecraft is { Length: 0 }) + throw new ArgumentException("Minecraft filter must be null or an exact version.", nameof(minecraft)); + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["loaders"] = JsonSerializer.Serialize(new[] { "fabric", "minecraft" }, HakoJsonContext.Default.StringArray), + ["include_changelog"] = "false" + }; + if (minecraft is not null) + { + parameters["game_versions"] = JsonSerializer.Serialize(new[] { minecraft }, HakoJsonContext.Default.StringArray); + } + + var versions = JsonSerializer.Deserialize(await GetBytesAsync(BuildUri($"project/{id}/version", parameters), cancellationToken) + .ConfigureAwait(false), FabricJsonContext.Default.ModrinthVersionArray) + ?? throw InvalidResponse("Modrinth returned an empty modpack version list."); + return versions + .Where(version => version.ProjectId == projectId + && version.Files.Any(file => file.IsInstallable(".mrpack")) + && version.Loaders.Count > 0 + && version.Loaders.All(loader => loader is "fabric" or "minecraft") + && (minecraft is null || version.GameVersions.Contains(minecraft, StringComparer.Ordinal)) + && ChannelPriority(version.VersionType) >= 0) + .OrderBy(version => ChannelPriority(version.VersionType)) + .ThenByDescending(version => version.DatePublished) + .ThenBy(version => version.Id, StringComparer.Ordinal) + .ToArray(); + } + + public async Task GetLatestVersionAsync(string projectId, ModrinthContentKind kind, + string minecraft, string? channel = null, CancellationToken cancellationToken = default) + { + if (channel is not null && ChannelPriority(channel) < 0) + throw new ArgumentException("Unknown Modrinth release channel.", nameof(channel)); + var versions = await GetCompatibleVersionsAsync(projectId, kind, minecraft, cancellationToken).ConfigureAwait(false); + return versions.FirstOrDefault(version => channel is null || version.VersionType == channel); + } + + public Task GetLatestVersionAsync(string projectId, bool mods, string minecraft, + string? channel = null, CancellationToken cancellationToken = default) => + GetLatestVersionAsync(projectId, mods ? ModrinthContentKind.Mod : ModrinthContentKind.ResourcePack, + minecraft, channel, cancellationToken); + + public async Task> GetProjectsAsync(IReadOnlyList ids, + CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + var checkedIds = CheckIds(ids); + var query = JsonSerializer.Serialize(checkedIds, HakoJsonContext.Default.StringArray); + var bytes = await GetBytesAsync(BuildUri("projects", new Dictionary { ["ids"] = query }), cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.Deserialize(bytes, HakoJsonContext.Default.ModrinthProjectArray) + ?? throw InvalidResponse("Modrinth returned an empty project list."); + } + + public async Task> GetVersionsAsync(IReadOnlyList ids, + CancellationToken cancellationToken = default) + { + if (ids.Count == 0) return []; + var checkedIds = CheckIds(ids); + var query = JsonSerializer.Serialize(checkedIds, HakoJsonContext.Default.StringArray); + var bytes = await GetBytesAsync(BuildUri("versions", new Dictionary { ["ids"] = query }), cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.Deserialize(bytes, FabricJsonContext.Default.ModrinthVersionArray) + ?? throw InvalidResponse("Modrinth returned an empty version list."); + } + + /// Maps local files to known Modrinth versions. File hashes are reused by normalized path, size, and mtime. + public async Task> VersionsOfAsync( + IReadOnlyList filePaths, CancellationToken cancellationToken = default) + { + await _cacheGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var cache = await LoadFileCacheAsync(cancellationToken).ConfigureAwait(false); + var hashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var changed = _cacheWasInvalid; + foreach (var path in filePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var fullPath = Path.GetFullPath(path); + var key = fullPath.ToUpperInvariant(); + var attributes = File.GetAttributes(fullPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + throw new IOException("Modrinth file recognition requires a regular, non-reparse file."); + + var info = new FileInfo(fullPath); + var size = info.Length; + var modified = info.LastWriteTimeUtc.Ticks; + if (cache.Files.TryGetValue(key, out var entry) && entry.Size == size + && entry.ModifiedUtcTicks == modified && IsHash(entry.Sha512, 128)) + { + hashes[fullPath] = entry.Sha512.ToLowerInvariant(); + continue; + } + + var hash = await HashFileAsync(fullPath, cancellationToken).ConfigureAwait(false); + hashes[fullPath] = hash; + cache.Files[key] = new ModrinthFileCacheEntry(size, modified, hash); + changed = true; + } + + var unknown = hashes.Values.Distinct(StringComparer.OrdinalIgnoreCase) + .Where(hash => !cache.Versions.ContainsKey(hash) && !_unknownHashes.Contains(hash)).ToArray(); + if (unknown.Length > 0) + { + var body = new ModrinthHashesRequest(unknown, "sha512"); + var bytes = await PostJsonAsync("version_files", body, HakoJsonContext.Default.ModrinthHashesRequest, cancellationToken) + .ConfigureAwait(false); + var matches = JsonSerializer.Deserialize(bytes, HakoJsonContext.Default.ModrinthVersionHashMatchDictionary) + ?? throw InvalidResponse("Modrinth returned an empty hash lookup response."); + foreach (var hash in unknown) + { + var match = matches.FirstOrDefault(pair => string.Equals(pair.Key, hash, StringComparison.OrdinalIgnoreCase)).Value; + if (match is not null && IsProjectId(match.ProjectId) && IsProjectId(match.Id)) + { + cache.Versions[hash] = new ModrinthFileMatch(match.ProjectId, match.Id, match.DatePublished, hash); + changed = true; + } + else + { + _unknownHashes.Add(hash); + } + } + } + + foreach (var key in cache.Files.Keys.ToArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(key)) continue; + cache.Files.Remove(key); + changed = true; + } + + var usedHashes = cache.Files.Values.Select(entry => entry.Sha512).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var hash in cache.Versions.Keys.Where(hash => !usedHashes.Contains(hash)).ToArray()) + { + cache.Versions.Remove(hash); + changed = true; + } + + if (changed) + { + await SaveFileCacheAsync(cache, cancellationToken).ConfigureAwait(false); + _cacheWasInvalid = false; + } + + return hashes.Where(pair => cache.Versions.ContainsKey(pair.Value)) + .ToDictionary(pair => pair.Key, pair => cache.Versions[pair.Value], StringComparer.OrdinalIgnoreCase); + } + finally + { + _cacheGate.Release(); + } + } + + /// Looks up the latest compatible release, beta, then alpha version for each SHA-512. + public async Task> LatestVersionsAsync( + IReadOnlyList hashes, ModrinthContentKind kind, string minecraft, + CancellationToken cancellationToken = default) => + await LatestVersionsCoreAsync(hashes, kind, minecraft, publishedAfter: null, cancellationToken).ConfigureAwait(false); + + internal Task> LatestVersionsAfterAsync( + IReadOnlyList hashes, ModrinthContentKind kind, string minecraft, + IReadOnlyDictionary publishedAfter, CancellationToken cancellationToken = default) => + LatestVersionsCoreAsync(hashes, kind, minecraft, publishedAfter, cancellationToken); + + private async Task> LatestVersionsCoreAsync( + IReadOnlyList hashes, ModrinthContentKind kind, string minecraft, + IReadOnlyDictionary? publishedAfter, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(minecraft); + var loader = Loader(kind); + var remaining = hashes.Where(hash => IsHash(hash, 128)).Select(hash => hash.ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase).ToHashSet(StringComparer.OrdinalIgnoreCase); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var channel in Channels) + { + if (remaining.Count == 0) break; + var body = new ModrinthVersionUpdateRequest(remaining.ToArray(), "sha512", [loader], [minecraft], [channel]); + var bytes = await PostJsonAsync("version_files/update", body, HakoJsonContext.Default.ModrinthVersionUpdateRequest, + cancellationToken).ConfigureAwait(false); + var found = JsonSerializer.Deserialize(bytes, HakoJsonContext.Default.ModrinthVersionDictionary) + ?? throw InvalidResponse("Modrinth returned an empty version update response."); + foreach (var pair in found) + { + var version = pair.Value; + if (!remaining.Contains(pair.Key) || version.VersionType != channel + || !version.GameVersions.Contains(minecraft, StringComparer.Ordinal) + || !version.Loaders.Contains(loader, StringComparer.Ordinal) + || version.InstallableFile(kind) is null + || (publishedAfter?.TryGetValue(pair.Key, out var oldPublished) == true + && version.DatePublished <= oldPublished)) + continue; + result[pair.Key.ToLowerInvariant()] = version; + remaining.Remove(pair.Key); + } + } + + return result; + } + + public async Task DownloadFileAsync(ModrinthFile file, string destination, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(file); + if (!InstanceStorage.IsSinglePathSegment(file.Filename) + || !(file.Filename.EndsWith(".jar", StringComparison.OrdinalIgnoreCase) + || file.Filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + || !file.IsInstallable(Path.GetExtension(file.Filename))) + throw InvalidResponse("Modrinth returned invalid file metadata."); + await DownloadFileAsync(file.Url, destination, file.Size, file.Sha1, file.Sha512, cancellationToken).ConfigureAwait(false); + } + + internal async Task DownloadImageAsync(Uri? url, CancellationToken cancellationToken) + { + if (url is null || url.Scheme != Uri.UriSchemeHttps) + return null; + using var response = await SendAsync(new HttpRequestMessage(HttpMethod.Get, url), cancellationToken) + .ConfigureAwait(false); + if (response.RequestMessage?.RequestUri is not { } responseUri + || responseUri.Scheme != Uri.UriSchemeHttps + || response.Content.Headers.ContentType?.MediaType is not { } mediaType + || !mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (response.Content.Headers.ContentLength is { } length && length > 8 * 1024 * 1024) + return null; + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > 8 * 1024 * 1024) return null; + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + public async Task DownloadFileAsync(Uri url, string destination, long size, string sha1, string sha512, + CancellationToken cancellationToken = default) + { + await DownloadFileAsync(url, destination, size, sha1, sha512, allowedHosts: null, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + internal async Task DownloadVerifiedFileAsync(IReadOnlyList urls, string destination, long size, + string sha1, string sha512, IReadOnlySet? allowedHosts, CancellationToken cancellationToken, + bool allowEmpty = false) + { + if (urls.Count == 0) + throw InvalidResponse("Modrinth file metadata did not include a download URL."); + Exception? lastFailure = null; + foreach (var url in urls) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await DownloadFileAsync(url, destination, size, sha1, sha512, allowedHosts, cancellationToken, allowEmpty) + .ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + lastFailure = exception; + } + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(lastFailure!).Throw(); + } + + private async Task DownloadFileAsync(Uri url, string destination, long size, string sha1, string sha512, + IReadOnlySet? allowedHosts, CancellationToken cancellationToken, bool allowEmpty = false) + { + RequireHttps(url); + if (allowedHosts is not null + && (!allowedHosts.Contains(url.IdnHost) || url.Port != 443 || !string.IsNullOrEmpty(url.UserInfo))) + throw InvalidResponse("Modrinth file URL host is not allowed."); + if (size < 0 || (size == 0 && !allowEmpty) || !IsHash(sha1, 40) || !IsHash(sha512, 128)) + throw InvalidResponse("Modrinth returned invalid file metadata."); + + var fullDestination = Path.GetFullPath(destination); + Directory.CreateDirectory(Path.GetDirectoryName(fullDestination)!); + var temporary = fullDestination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using var response = await SendAsync(new HttpRequestMessage(HttpMethod.Get, url), cancellationToken).ConfigureAwait(false); + if (allowedHosts is not null) + { + var responseUri = response.RequestMessage?.RequestUri ?? url; + if (responseUri.Scheme != Uri.UriSchemeHttps || responseUri.Port != 443 + || !string.IsNullOrEmpty(responseUri.UserInfo) || !allowedHosts.Contains(responseUri.IdnHost)) + throw InvalidResponse("Modrinth file redirect host is not allowed."); + } + if (response.Content.Headers.ContentLength is { } length && length != size) + throw IntegrityFailure("Modrinth file size did not match its version metadata."); + await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + using (var sha1Hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA1)) + using (var sha512Hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512)) + { + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + total += read; + if (total > size) throw IntegrityFailure("Modrinth file exceeded its declared size."); + sha1Hash.AppendData(buffer, 0, read); + sha512Hash.AppendData(buffer, 0, read); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + if (total != size || !HashEquals(sha1Hash.GetHashAndReset(), sha1) + || !HashEquals(sha512Hash.GetHashAndReset(), sha512)) + throw IntegrityFailure("Modrinth file failed its SHA-1 or SHA-512 check."); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporary, fullDestination, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (FileNotFoundException) { } + } + } + + internal static async Task IsVerifiedFileAsync(string path, long size, string sha1, string sha512, + CancellationToken cancellationToken) + { + if (!File.Exists(path) || new FileInfo(path).Length != size + || !IsHash(sha1, 40) || !IsHash(sha512, 128)) + { + return false; + } + + await using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var sha1Hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + using var sha512Hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[81920]; + long total = 0; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + total += read; + if (total > size) return false; + sha1Hash.AppendData(buffer, 0, read); + sha512Hash.AppendData(buffer, 0, read); + } + + return total == size + && HashEquals(sha1Hash.GetHashAndReset(), sha1) + && HashEquals(sha512Hash.GetHashAndReset(), sha512); + } + + public void Dispose() + { + if (_ownsHttpClient) _httpClient.Dispose(); + } + + private async Task GetBytesAsync(Uri uri, CancellationToken cancellationToken) + { + using var response = await SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken).ConfigureAwait(false); + if (response.Content.Headers.ContentLength is { } length && length > MaximumResponseSize) + throw InvalidResponse("Modrinth response is too large."); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > MaximumResponseSize) throw InvalidResponse("Modrinth response is too large."); + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + private async Task PostJsonAsync(string path, T body, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_base, path)) + { + Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body, typeInfo)) + }; + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + using var response = await SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.Content.Headers.ContentLength is { } length && length > MaximumResponseSize) + throw InvalidResponse("Modrinth response is too large."); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + if (output.Length + read > MaximumResponseSize) throw InvalidResponse("Modrinth response is too large."); + output.Write(buffer, 0, read); + } + + return output.ToArray(); + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response; + using (request) + { + RequireHttps(request.RequestUri ?? throw InvalidResponse("Modrinth request has no URL.")); + request.Headers.UserAgent.ParseAdd(UserAgent); + response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + } + + if (!response.IsSuccessStatusCode) + { + var status = (int)response.StatusCode; + response.Dispose(); + throw new MojangException(MojangError.Http, $"Modrinth returned HTTP {status}.", status); + } + + return response; + } + + private async Task LoadFileCacheAsync(CancellationToken cancellationToken) + { + try + { + if (!File.Exists(_cacheFile)) return new ModrinthFileCache(); + var info = new FileInfo(_cacheFile); + if (info.Length > MaximumResponseSize) throw new InvalidDataException("Modrinth file cache is too large."); + var bytes = await File.ReadAllBytesAsync(_cacheFile, cancellationToken).ConfigureAwait(false); + var cache = JsonSerializer.Deserialize(bytes, HakoJsonContext.Default.ModrinthFileCache) + ?? throw new JsonException("Modrinth file cache is empty."); + if (!IsValidCache(cache)) throw new InvalidDataException("Modrinth file cache has an invalid structure."); + return cache; + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) when (exception is IOException or InvalidDataException or UnauthorizedAccessException or JsonException) + { + _cacheWasInvalid = true; + return new ModrinthFileCache(); + } + } + + private static bool IsValidCache(ModrinthFileCache cache) + { + if (cache.Files is null || cache.Versions is null) return false; + foreach (var (path, entry) in cache.Files) + { + if (entry is null || !Path.IsPathFullyQualified(path) || entry.Size < 0 + || entry.ModifiedUtcTicks < 0 || entry.ModifiedUtcTicks > DateTime.MaxValue.Ticks + || !IsHash(entry.Sha512, 128)) + return false; + } + + foreach (var (hash, match) in cache.Versions) + { + if (match is null || !IsHash(hash, 128) || !IsProjectId(match.ProjectId) || !IsProjectId(match.VersionId) + || !string.Equals(hash, match.Sha512, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; + } + + private async Task SaveFileCacheAsync(ModrinthFileCache cache, CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(_cacheFile)!); + var temporary = _cacheFile + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(cache, HakoJsonContext.Default.ModrinthFileCache); + await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporary, _cacheFile, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (FileNotFoundException) { } + } + } + + private static async Task HashFileAsync(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[81920]; + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + hash.AppendData(buffer, 0, read); + } + + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private Uri BuildUri(string path, IReadOnlyDictionary parameters) + { + var query = string.Join('&', parameters.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))); + return new Uri(_base, path + (query.Length == 0 ? "" : "?" + query)); + } + + private static string[] CheckIds(IReadOnlyList ids) + { + if (ids.Any(id => !IsProjectId(id))) + throw new MojangException(MojangError.Invalid, "Modrinth IDs must contain only ASCII letters and numbers."); + return ids.ToArray(); + } + + private static bool IsProjectId(string? id) => !string.IsNullOrEmpty(id) && id.All(char.IsAsciiLetterOrDigit); + + private static string Loader(ModrinthContentKind kind) => kind switch + { + ModrinthContentKind.Mod => "fabric", + ModrinthContentKind.ResourcePack => "minecraft", + ModrinthContentKind.Datapack => "datapack", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string SortName(ModrinthSort sort) => sort switch + { + ModrinthSort.Relevance => "relevance", + ModrinthSort.Downloads => "downloads", + ModrinthSort.Newest => "newest", + ModrinthSort.Updated => "updated", + _ => throw new ArgumentOutOfRangeException(nameof(sort)) + }; + + private static int ChannelPriority(string channel) => channel switch + { + "release" => 0, + "beta" => 1, + "alpha" => 2, + _ => -1 + }; + + private static bool IsHash(string? value, int length) => value is { Length: var size } + && size == length && value.All(Uri.IsHexDigit); + private static bool HashEquals(byte[] hash, string expected) => Convert.ToHexString(hash).Equals(expected, StringComparison.OrdinalIgnoreCase); + private static void RequireHttps(Uri uri) + { + if (!uri.IsAbsoluteUri || uri.Scheme != Uri.UriSchemeHttps) + throw InvalidResponse("Modrinth URLs must use HTTPS."); + } + private static MojangException InvalidResponse(string message) => new(MojangError.Invalid, message); + private static MojangException IntegrityFailure(string message) => new(MojangError.Integrity, message); + private static Uri EnsureTrailingSlash(Uri uri) => new(uri.AbsoluteUri.TrimEnd('/') + "/"); +} diff --git a/windows/Hako.Core/Minecraft/MojangClient.cs b/windows/Hako.Core/Minecraft/MojangClient.cs new file mode 100644 index 0000000..2735e22 --- /dev/null +++ b/windows/Hako.Core/Minecraft/MojangClient.cs @@ -0,0 +1,624 @@ +using System.Net.Http.Headers; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; + +namespace Hako.Core.Minecraft; + +public sealed class MojangClient : IDisposable +{ + public const string CatalogUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + public const string RuntimeIndexUrl = "https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"; + public const string AssetBaseUrl = "https://resources.download.minecraft.net/"; + private const int MaximumMetadataBytes = 128 * 1024 * 1024; + private static readonly TimeSpan DefaultReadIdleTimeout = TimeSpan.FromSeconds(60); + + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly IHostArchitectureProvider _hostArchitectureProvider; + private readonly TimeSpan _readIdleTimeout; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _runtimeIndexGate = new(1, 1); + private Dictionary>>? _runtimeIndex; + + public MojangClient( + HttpClient? httpClient = null, + IHostArchitectureProvider? hostArchitectureProvider = null, + TimeSpan? readIdleTimeout = null, + TimeProvider? timeProvider = null) + { + _readIdleTimeout = readIdleTimeout ?? DefaultReadIdleTimeout; + if (_readIdleTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(readIdleTimeout)); + } + + _timeProvider = timeProvider ?? TimeProvider.System; + _ownsHttpClient = httpClient is null; + _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + _hostArchitectureProvider = hostArchitectureProvider ?? new WindowsHostArchitectureProvider(); + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + var version = typeof(MojangClient).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; + _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Hako", version)); + } + } + + public async Task CatalogAsync(CancellationToken cancellationToken = default) + { + var bytes = await GetBytesAsync(CatalogUrl, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(bytes, MojangJsonContext.Default.MojangCatalog) + ?? throw Invalid("Mojang returned an empty version catalog."); + } + + public Task PrepareAsync(MojangVersionEntry version, CancellationToken cancellationToken = default) => + PrepareCoreAsync(version, _hostArchitectureProvider.GetHostArchitecture(), null, cancellationToken); + + public Task PrepareAsync( + MojangVersionEntry version, + HostArchitecture host, + CancellationToken cancellationToken = default) => + PrepareCoreAsync(version, host, null, cancellationToken); + + public Task PrepareAsync( + MojangVersionEntry version, + HostArchitecture host, + GameArchitecture pinned, + CancellationToken cancellationToken = default) => + PrepareCoreAsync(version, host, pinned, cancellationToken); + + public Task PrepareAsync( + MojangVersionEntry version, + GameArchitecture pinned, + CancellationToken cancellationToken = default) => + PrepareCoreAsync(version, _hostArchitectureProvider.GetHostArchitecture(), pinned, cancellationToken); + + internal async Task SendAsync(string url, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + { + throw Invalid("Mojang download URLs must use HTTPS."); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new MojangException(MojangError.Http, "The Mojang request timed out.", innerException: exception); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException exception) + { + throw new MojangException(MojangError.Http, "The Mojang request failed.", innerException: exception); + } + + if (!response.IsSuccessStatusCode) + { + var statusCode = (int)response.StatusCode; + response.Dispose(); + throw new MojangException(MojangError.Http, $"The Mojang server returned HTTP {statusCode}.", statusCode); + } + + return response; + } + + internal async Task GetBytesAsync(string url, CancellationToken cancellationToken, long? expectedSize = null) + { + if (expectedSize is < 0) + { + throw Invalid("Mojang returned an invalid file size."); + } + + using var response = await SendAsync(url, cancellationToken).ConfigureAwait(false); + if (response.Content.Headers.ContentLength is > MaximumMetadataBytes) + { + throw Invalid("Mojang metadata exceeds the supported size limit."); + } + + if (expectedSize is { } expectedContentLength && response.Content.Headers.ContentLength is { } contentLength + && contentLength > expectedContentLength) + { + throw new MojangException(MojangError.Integrity, "A Mojang response is larger than its declared size."); + } + + Stream stream; + try + { + stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new MojangException(MojangError.Http, "The Mojang response stream timed out.", innerException: exception); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new MojangException(MojangError.Http, "The Mojang response stream could not be opened.", innerException: exception); + } + + await using var responseStream = stream; + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + while (true) + { + var sizeLimit = expectedSize is { } size ? Math.Min(size, MaximumMetadataBytes) : MaximumMetadataBytes; + var remaining = sizeLimit - buffer.Length; + var readLength = remaining < chunk.Length ? (int)remaining + 1 : chunk.Length; + var read = await ReadWithIdleTimeoutAsync(responseStream, chunk.AsMemory(0, readLength), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer.Length + read > MaximumMetadataBytes + || expectedSize is { } expectedSizeLimit && buffer.Length + read > expectedSizeLimit) + { + if (expectedSize is { } declaredSize && buffer.Length + read > declaredSize) + { + throw new MojangException(MojangError.Integrity, "A Mojang response is larger than its declared size."); + } + + throw Invalid("Mojang metadata exceeds the supported size limit."); + } + + await buffer.WriteAsync(chunk.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + } + + return buffer.ToArray(); + } + + internal async Task GetVerifiedBytesAsync(MojangDownload download, CancellationToken cancellationToken) + { + var bytes = await GetBytesAsync(download.Url, cancellationToken, download.Size).ConfigureAwait(false); + Verify(bytes, download); + return bytes; + } + + internal async ValueTask ReadWithIdleTimeoutAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) + { + using var idleTimeout = new CancellationTokenSource(_readIdleTimeout, _timeProvider); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, idleTimeout.Token); + try + { + return await stream.ReadAsync(buffer, linkedCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested && idleTimeout.IsCancellationRequested) + { + throw new MojangException(MojangError.Http, "The Mojang response stopped sending data.", innerException: exception); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new MojangException(MojangError.Http, "The Mojang response timed out while being read.", innerException: exception); + } + catch (HttpRequestException exception) + { + throw new MojangException(MojangError.Http, "The Mojang response could not be read.", innerException: exception); + } + catch (IOException exception) + { + throw new MojangException(MojangError.Http, "The Mojang response could not be read.", innerException: exception); + } + } + + public void Dispose() + { + _runtimeIndexGate.Dispose(); + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + /// + /// Reads the version description installed in a build, verified against the SHA-1 pinned in its profile. + /// Returns null when the build has no matching installed description yet. + /// + public static MojangVersionMetadata? ReadInstalledManifest(string instanceRoot, Instances.GameInstance instance) + { + ArgumentNullException.ThrowIfNull(instance); + var path = Instances.InstanceStorage.ContainedPath( + $"minecraft/versions/{instance.VersionID}/{instance.VersionID}.json", instanceRoot, allowBaseReparsePoint: true); + if (!File.Exists(path)) + { + return null; + } + + var data = File.ReadAllBytes(path); + if (!string.Equals(Convert.ToHexString(SHA1.HashData(data)), instance.MetadataSHA1, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var manifest = JsonSerializer.Deserialize(data, MojangJsonContext.Default.MojangVersionMetadata); + return manifest is not null && string.Equals(manifest.Id, instance.VersionID, StringComparison.Ordinal) ? manifest : null; + } + + internal static void Verify(ReadOnlySpan bytes, MojangDownload download) + { + ValidateDownload(download); + var hash = Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant(); + if (!string.Equals(hash, download.Sha1, StringComparison.OrdinalIgnoreCase) + || download.Size is { } expectedSize && bytes.Length != expectedSize) + { + throw new MojangException(MojangError.Integrity, "A downloaded Mojang file failed its SHA-1 or size check."); + } + } + + internal static void ValidateDownload(MojangDownload download) + { + if (!Uri.TryCreate(download.Url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + { + throw Invalid("Mojang download URLs must use HTTPS."); + } + + if (download.Sha1.Length != 40 || !download.Sha1.All(Uri.IsHexDigit) || download.Size is < 0) + { + throw Invalid("Mojang returned invalid file integrity metadata."); + } + } + + private async Task PrepareCoreAsync( + MojangVersionEntry version, + HostArchitecture host, + GameArchitecture? pinned, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(version.Id)) + { + throw Invalid("The Mojang version catalog entry has no ID."); + } + + var manifestData = await GetVerifiedBytesAsync(new MojangDownload + { + Url = version.Url, + Sha1 = version.Sha1 + }, cancellationToken).ConfigureAwait(false); + var manifest = JsonSerializer.Deserialize(manifestData, MojangJsonContext.Default.MojangVersionMetadata) + ?? throw Invalid("Mojang returned an empty version description."); + + if (manifest.Downloads is null || manifest.Libraries is null + || string.IsNullOrWhiteSpace(manifest.Java.Component) || manifest.Java.MajorVersion < 1) + { + throw Invalid("Mojang returned an incomplete version description."); + } + + if (!string.Equals(version.Id, manifest.Id, StringComparison.Ordinal) + || !manifest.Downloads.TryGetValue("client", out var clientDownload)) + { + throw Unsupported("Mojang does not provide a client for this version."); + } + + if (clientDownload is null) + { + throw Invalid("Mojang returned an empty client download entry."); + } + + ValidateDownload(clientDownload); + + var runtimeIndex = await GetRuntimeIndexAsync(cancellationToken).ConfigureAwait(false); + var candidates = CandidateArchitectures(host, pinned); + MojangException? unsupported = null; + foreach (var architecture in candidates) + { + try + { + var runtime = FindRuntime(runtimeIndex, manifest.Java, architecture); + var libraries = MojangCompatibility.Libraries(manifest, architecture); + foreach (var library in libraries) + { + ValidateDownload(library.Download); + } + + ValidateDownload(runtime.Manifest); + var runtimeManifestData = await GetVerifiedBytesAsync(runtime.Manifest, cancellationToken).ConfigureAwait(false); + var runtimeManifest = JsonSerializer.Deserialize(runtimeManifestData, MojangJsonContext.Default.MojangJavaRuntimeManifest) + ?? throw Invalid("Mojang returned an empty Java runtime manifest."); + if (runtimeManifest.Files is null) + { + throw Invalid("Mojang returned an incomplete Java runtime manifest."); + } + + var linkEntry = runtimeManifest.Files.FirstOrDefault(file => file.Value.Type == "link"); + if (linkEntry.Value is not null) + { + throw Invalid($"The Java runtime contains a link at '{linkEntry.Key}'. Hako does not create Windows reparse points."); + } + + if (!runtimeManifest.Files.TryGetValue("bin/javaw.exe", out var javaw) || javaw.Type != "file") + { + throw Unsupported($"Mojang's {architecture.MojangRuntimeKey()} Java runtime has no Windows javaw.exe."); + } + + foreach (var file in runtimeManifest.Files.Values.Where(file => file.Type == "file")) + { + if (file.Downloads?.GetValueOrDefault("raw") is not { } raw) + { + throw Unsupported($"Mojang's {architecture.MojangRuntimeKey()} Java runtime is incomplete."); + } + + ValidateDownload(raw); + } + + return new PreparedInstallation( + version, + architecture, + manifest, + manifestData, + runtime, + runtimeManifest, + runtimeManifestData, + libraries); + } + catch (MojangException exception) when (exception.Error == MojangError.Unsupported) + { + unsupported = exception; + } + } + + throw unsupported ?? Unsupported("This Windows version has no compatible Minecraft architecture."); + } + + private async Task>>> GetRuntimeIndexAsync( + CancellationToken cancellationToken) + { + if (_runtimeIndex is not null) + { + return _runtimeIndex; + } + + await _runtimeIndexGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_runtimeIndex is null) + { + var bytes = await GetBytesAsync(RuntimeIndexUrl, cancellationToken).ConfigureAwait(false); + _runtimeIndex = JsonSerializer.Deserialize(bytes, MojangJsonContext.Default.DictionaryStringDictionaryStringListMojangJavaRuntimePackage) + ?? throw Invalid("Mojang returned an empty Java runtime catalog."); + } + + return _runtimeIndex; + } + finally + { + _runtimeIndexGate.Release(); + } + } + + private static IReadOnlyList CandidateArchitectures(HostArchitecture host, GameArchitecture? pinned) + { + if (pinned is { } selected) + { + var supported = host.Machine switch + { + HostMachineArchitecture.X64 => selected == GameArchitecture.X64, + HostMachineArchitecture.X86 => selected == GameArchitecture.X86, + HostMachineArchitecture.Arm64 => selected == GameArchitecture.Arm64 + || selected == GameArchitecture.X64 && host.Amd64UserEnabled, + _ => false + }; + return supported ? [selected] : throw Unsupported("The pinned game architecture cannot run on this Windows system."); + } + + return host.Machine switch + { + HostMachineArchitecture.X64 => [GameArchitecture.X64], + HostMachineArchitecture.X86 => [GameArchitecture.X86], + HostMachineArchitecture.Arm64 when host.Amd64UserEnabled => [GameArchitecture.Arm64, GameArchitecture.X64], + HostMachineArchitecture.Arm64 => [GameArchitecture.Arm64], + _ => throw Unsupported("The Windows machine architecture could not be determined.") + }; + } + + private static MojangJavaRuntimePackage FindRuntime( + Dictionary>> index, + MojangJavaRequirement requirement, + GameArchitecture architecture) + { + if (index.TryGetValue(architecture.MojangRuntimeKey(), out var components) + && components.TryGetValue(requirement.Component, out var packages)) + { + var matching = packages + .Where(package => package is not null && package.MajorVersion == requirement.MajorVersion) + .OrderByDescending(package => package.Version.Released, StringComparer.Ordinal) + .FirstOrDefault(); + if (matching is not null) + { + return matching; + } + } + + throw Unsupported($"Mojang does not provide Java {requirement.MajorVersion} for {architecture.MojangRuntimeKey()}."); + } + + private static MojangException Invalid(string message) => new(MojangError.Invalid, message); + private static MojangException Unsupported(string message) => new(MojangError.Unsupported, message); +} + +public static class MojangCompatibility +{ + public static IReadOnlyList Libraries( + MojangVersionMetadata manifest, + GameArchitecture architecture, + IReadOnlyDictionary? features = null) + { + var libraries = manifest.Libraries; + var selectedByIndex = Enumerable.Range(0, libraries.Count).Select(_ => new List()).ToArray(); + var mappedGroups = new HashSet(StringComparer.Ordinal); + + for (var index = 0; index < libraries.Count; index++) + { + var library = libraries[index]; + if (library.Natives?.ContainsKey("windows") != true) + { + continue; + } + + if (!library.CouldApplyToWindows(features)) + { + continue; + } + + if (!library.IsAllowed(architecture, features)) + { + continue; + } + + var classifier = library.Natives["windows"] + .Replace("${arch}", architecture.NativeArchSubstitution(), StringComparison.Ordinal); + if (!NativeClassifierMatches(classifier, architecture)) + { + throw new MojangException(MojangError.Unsupported, $"The {architecture} native library set is incomplete."); + } + + var native = library.Downloads?.Classifiers?.GetValueOrDefault(classifier); + if (native?.Path is null) + { + throw new MojangException(MojangError.Unsupported, $"Mojang does not provide {classifier} for {library.Name}."); + } + + if (library.Downloads?.Artifact is { } artifact) + { + selectedByIndex[index].Add(ToInstallation(artifact)); + } + + selectedByIndex[index].Add(new LibraryInstallation( + native, + native.Path, + IsNative: true, + ExtractNatives: library.Extract is not null, + ExtractionExcludes: library.Extract?.Exclude ?? [])); + mappedGroups.Add(library.NativeGroup); + } + + var nativeGroups = libraries + .Select((library, index) => (Library: library, Index: index)) + .Where(item => IsWindowsNative(item.Library) && item.Library.CouldApplyToWindows(features)) + .GroupBy(item => item.Library.NativeGroup, StringComparer.Ordinal); + + foreach (var group in nativeGroups) + { + if (mappedGroups.Contains(group.Key)) + { + continue; + } + + var candidate = group + .Where(item => item.Library.IsAllowed(architecture, features) && NativeMatches(item.Library, architecture)) + .OrderByDescending(item => NativeSpecificity(item.Library.Classifier, architecture)) + .ThenBy(item => item.Index) + .FirstOrDefault(); + var download = candidate.Library?.Downloads?.Artifact; + if (candidate.Library is null || download?.Path is null) + { + throw new MojangException(MojangError.Unsupported, $"Mojang does not provide a complete {architecture} native library set."); + } + + selectedByIndex[candidate.Index].Add(new LibraryInstallation( + download, + download.Path, + IsNative: true, + ExtractNatives: candidate.Library.Extract is not null, + ExtractionExcludes: candidate.Library.Extract?.Exclude ?? [])); + mappedGroups.Add(group.Key); + } + + var selected = new List(); + for (var index = 0; index < libraries.Count; index++) + { + selected.AddRange(selectedByIndex[index]); + var library = libraries[index]; + if (!library.IsAllowed(architecture, features)) + { + continue; + } + + if (IsAnyNative(library) || library.Natives?.ContainsKey("windows") == true) + { + continue; + } + + if (library.Downloads?.Artifact is null && library.Natives is { Count: > 0 }) + { + continue; + } + + if (library.Downloads?.Artifact is not { } artifact) + { + throw new MojangException(MojangError.Unsupported, $"Mojang does not provide an artifact for {library.Name}."); + } + + selected.Add(ToInstallation(artifact)); + } + + return selected; + } + + private static LibraryInstallation ToInstallation(MojangDownload download) + { + if (download.Path is null) + { + throw new MojangException(MojangError.Invalid, "A Mojang library is missing its relative path."); + } + + return new LibraryInstallation(download, download.Path); + } + + private static bool IsAnyNative(MojangLibrary library) => library.Classifier?.StartsWith("natives-", StringComparison.OrdinalIgnoreCase) == true; + + private static bool IsWindowsNative(MojangLibrary library) => + library.Classifier?.StartsWith("natives-windows", StringComparison.OrdinalIgnoreCase) == true; + + private static bool NativeMatches(MojangLibrary library, GameArchitecture architecture) => + library.Classifier is { } classifier && NativeClassifierMatches(classifier, architecture); + + private static bool NativeClassifierMatches(string classifier, GameArchitecture architecture) + { + if (!classifier.StartsWith("natives-windows", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var arm = classifier.Contains("arm64", StringComparison.OrdinalIgnoreCase) + || classifier.Contains("aarch64", StringComparison.OrdinalIgnoreCase); + var x86 = IsX86Only(classifier); + var generic = string.Equals(classifier, "natives-windows", StringComparison.OrdinalIgnoreCase); + var expected = architecture.MojangNativeClassifier(); + return architecture switch + { + GameArchitecture.Arm64 => arm && classifier.StartsWith("natives-windows", StringComparison.OrdinalIgnoreCase) + && (classifier.Contains(expected, StringComparison.OrdinalIgnoreCase) + || classifier.Contains("natives-windows-aarch64", StringComparison.OrdinalIgnoreCase)), + GameArchitecture.X64 => !arm && !x86 && classifier.StartsWith(expected, StringComparison.OrdinalIgnoreCase), + GameArchitecture.X86 => !arm && (x86 || generic) + && (classifier.StartsWith(expected, StringComparison.OrdinalIgnoreCase) + || classifier.EndsWith("-32", StringComparison.OrdinalIgnoreCase) + || generic), + _ => false + }; + } + + private static bool IsX86Only(string? classifier) => classifier is not null + && (classifier.Contains("-x86", StringComparison.OrdinalIgnoreCase) + && !classifier.Contains("-x86_64", StringComparison.OrdinalIgnoreCase) + || classifier.EndsWith("-32", StringComparison.OrdinalIgnoreCase)); + + private static int NativeSpecificity(string? classifier, GameArchitecture architecture) => architecture switch + { + GameArchitecture.Arm64 => classifier?.Contains("arm64", StringComparison.OrdinalIgnoreCase) == true + || classifier?.Contains("aarch64", StringComparison.OrdinalIgnoreCase) == true ? 2 : 0, + GameArchitecture.X64 => classifier?.EndsWith("-64", StringComparison.OrdinalIgnoreCase) == true ? 2 : 1, + GameArchitecture.X86 => IsX86Only(classifier) ? 2 : 1, + _ => 0 + }; +} diff --git a/windows/Hako.Core/Minecraft/MojangModels.cs b/windows/Hako.Core/Minecraft/MojangModels.cs new file mode 100644 index 0000000..f6b5309 --- /dev/null +++ b/windows/Hako.Core/Minecraft/MojangModels.cs @@ -0,0 +1,419 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hako.Core.Minecraft; + +public sealed record MojangCatalog +{ + public MojangLatestVersions Latest { get; init; } = new(); + public List Versions { get; init; } = []; +} + +public sealed record MojangLatestVersions +{ + public string? Release { get; init; } + public string? Snapshot { get; init; } +} + +public sealed record MojangVersionEntry +{ + public string Id { get; init; } = ""; + public string Type { get; init; } = ""; + public string Url { get; init; } = ""; + public string Sha1 { get; init; } = ""; + public long? ComplianceLevel { get; init; } + public string? Time { get; init; } + public string? ReleaseTime { get; init; } +} + +public sealed record MojangDownload +{ + public string Url { get; init; } = ""; + public string Sha1 { get; init; } = ""; + public long? Size { get; init; } + public string? Path { get; init; } + public string? Id { get; init; } +} + +public sealed record MojangVersionMetadata +{ + public string Id { get; init; } = ""; + public string? Type { get; init; } + public string? Time { get; init; } + public string? ReleaseTime { get; init; } + public long? ComplianceLevel { get; init; } + public string? ClientVersion { get; init; } + public string? MainClass { get; init; } + public int? MinimumLauncherVersion { get; init; } + public string? MinecraftArguments { get; init; } + public Dictionary Downloads { get; init; } = new(StringComparer.OrdinalIgnoreCase); + public List Libraries { get; init; } = []; + public MojangJavaRequirement? JavaVersion { get; init; } + public MojangAssetIndexReference? AssetIndex { get; init; } + public string? Assets { get; init; } + public Dictionary>? Arguments { get; init; } + public Dictionary? Logging { get; init; } + + public MojangJavaRequirement Java => JavaVersion ?? new MojangJavaRequirement { Component = "jre-legacy", MajorVersion = 8 }; + public bool LegacyTexturepacks => Assets == "pre-1.6" || AssetIndex?.Id == "pre-1.6"; +} + +public sealed record MojangJavaRequirement +{ + public string Component { get; init; } = ""; + public int MajorVersion { get; init; } +} + +[JsonConverter(typeof(MojangArgumentConverter))] +public sealed record MojangArgument +{ + public List? Rules { get; init; } + public JsonElement Value { get; init; } + + public IReadOnlyList Values => Value.ValueKind switch + { + JsonValueKind.String => [Value.GetString() ?? ""], + JsonValueKind.Array => Value.EnumerateArray() + .Where(value => value.ValueKind == JsonValueKind.String) + .Select(value => value.GetString() ?? "") + .ToArray(), + _ => [] + }; + + public bool IsAllowed(GameArchitecture architecture, IReadOnlyDictionary? features = null) + { + if (Rules is not { Count: > 0 }) + { + return true; + } + + var allowed = false; + foreach (var rule in Rules) + { + if (rule.Matches(architecture, features)) + { + allowed = rule.Action == "allow"; + } + } + + return allowed; + } +} + +public sealed class MojangArgumentConverter : JsonConverter +{ + public override MojangArgument Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.String) + { + return new MojangArgument { Value = root.Clone() }; + } + + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("value", out var value)) + { + throw new JsonException("Mojang arguments must be strings or objects with a value."); + } + + MojangRule[]? rules = null; + if (root.TryGetProperty("rules", out var rulesJson)) + { + rules = JsonSerializer.Deserialize(rulesJson, MojangJsonContext.Default.MojangRuleArray); + } + + return new MojangArgument { Rules = rules?.ToList(), Value = value.Clone() }; + } + + public override void Write(Utf8JsonWriter writer, MojangArgument value, JsonSerializerOptions options) + { + if (value.Rules is not { Count: > 0 }) + { + value.Value.WriteTo(writer); + return; + } + + writer.WriteStartObject(); + writer.WritePropertyName("rules"); + JsonSerializer.Serialize(writer, value.Rules.ToArray(), MojangJsonContext.Default.MojangRuleArray); + writer.WritePropertyName("value"); + value.Value.WriteTo(writer); + writer.WriteEndObject(); + } +} + +public sealed record MojangRule +{ + public string Action { get; init; } = ""; + public MojangOperatingSystem? Os { get; init; } + public Dictionary? Features { get; init; } + + public bool Matches(GameArchitecture architecture, IReadOnlyDictionary? features = null) + { + if (Os?.Name is not null && !string.Equals(Os.Name, "windows", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (Os?.Arch is { Length: > 0 } pattern && !MatchesArchitecture(pattern, architecture)) + { + return false; + } + + var currentVersion = Environment.OSVersion.Version; + var currentVersionText = currentVersion.ToString(); + if (Os?.Version is { Length: > 0 } versionPattern && !RegexMatches(versionPattern, currentVersionText)) + { + return false; + } + + if (Os?.VersionRange is { } range) + { + if (range.Min is { Length: > 0 } minimum && CompareVersions(currentVersionText, minimum) < 0) + { + return false; + } + + if (range.Max is { Length: > 0 } maximum && CompareVersions(currentVersionText, maximum) > 0) + { + return false; + } + } + + return FeaturesMatch(features); + } + + internal bool CouldApplyToWindows(IReadOnlyDictionary? features = null) + { + if (Os?.Name is not null && !string.Equals(Os.Name, "windows", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (Os?.Version is { Length: > 0 } versionPattern + && !RegexMatches(versionPattern, Environment.OSVersion.Version.ToString())) + { + return false; + } + + return FeaturesMatch(features); + } + + private bool FeaturesMatch(IReadOnlyDictionary? features) => (Features ?? []).All(feature => + (features is not null && features.TryGetValue(feature.Key, out var enabled) ? enabled : false) == feature.Value); + + private static bool MatchesArchitecture(string pattern, GameArchitecture architecture) + { + var aliases = architecture switch + { + GameArchitecture.Arm64 => new[] { "aarch64", "arm64" }, + GameArchitecture.X64 => ["amd64"], + GameArchitecture.X86 => ["x86"], + _ => [] + }; + var canonical = architecture.RuleArchitecture(); + + return aliases.Any(alias => RegexMatchesArchitecture(pattern, alias)) + || RegexMatchesArchitecture(pattern, canonical); + } + + private static bool RegexMatchesArchitecture(string pattern, string value) => + RegexMatches($"^(?:{pattern})$", value); + + private static bool RegexMatches(string pattern, string value) + { + try + { + return System.Text.RegularExpressions.Regex.IsMatch(value, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase + | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + } + catch (ArgumentException) + { + return false; + } + } + + private static int CompareVersions(string left, string right) + { + if (Version.TryParse(left, out var leftVersion) && Version.TryParse(right, out var rightVersion)) + { + return leftVersion.CompareTo(rightVersion); + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } +} + +public sealed record MojangOperatingSystem +{ + public string? Name { get; init; } + public string? Arch { get; init; } + public string? Version { get; init; } + public MojangVersionRange? VersionRange { get; init; } +} + +public sealed record MojangVersionRange +{ + public string? Min { get; init; } + public string? Max { get; init; } +} + +public sealed record MojangLibrary +{ + public string Name { get; init; } = ""; + public MojangLibraryDownloads? Downloads { get; init; } + public Dictionary? Natives { get; init; } + public MojangLibraryExtraction? Extract { get; init; } + public List? Rules { get; init; } + + public string NativeGroup => string.Join(':', Name.Split(':').Take(3)); + public string? Classifier => Name.Split(':').ElementAtOrDefault(3); + + public bool IsAllowed(GameArchitecture architecture, IReadOnlyDictionary? features = null) + { + if (Rules is not { Count: > 0 }) + { + return true; + } + + var allowed = false; + foreach (var rule in Rules) + { + if (rule.Matches(architecture, features)) + { + allowed = rule.Action == "allow"; + } + } + + return allowed; + } + + public bool CouldApplyToWindows(IReadOnlyDictionary? features = null) => + Rules is not { Count: > 0 } || Rules.Any(rule => rule.CouldApplyToWindows(features)); +} + +public sealed record MojangLibraryDownloads +{ + public MojangDownload? Artifact { get; init; } + public Dictionary? Classifiers { get; init; } +} + +public sealed record MojangLibraryExtraction +{ + public List? Exclude { get; init; } +} + +public sealed record MojangLoggingConfiguration +{ + public MojangDownload File { get; init; } = new(); + public string? Argument { get; init; } + public string? Type { get; init; } +} + +public sealed record MojangAssetIndexReference +{ + public string Id { get; init; } = ""; + public string Url { get; init; } = ""; + public string Sha1 { get; init; } = ""; + public long Size { get; init; } + public long TotalSize { get; init; } + + public MojangDownload Download => new() { Url = Url, Sha1 = Sha1, Size = Size }; +} + +public sealed record MojangAssetIndex +{ + public Dictionary Objects { get; init; } = new(StringComparer.Ordinal); + public bool Virtual { get; init; } + + [JsonPropertyName("map_to_resources")] + public bool MapToResources { get; init; } +} + +public sealed record MojangAssetObject +{ + public string Hash { get; init; } = ""; + public long Size { get; init; } +} + +public sealed record MojangJavaRuntimePackage +{ + public MojangDownload Manifest { get; init; } = new(); + public MojangRuntimeVersion Version { get; init; } = new(); + + public int? MajorVersion + { + get + { + var name = Version.Name; + var start = name.StartsWith("1.", StringComparison.Ordinal) ? 2 : 0; + var end = start; + while (end < name.Length && char.IsAsciiDigit(name[end])) + { + end++; + } + + return int.TryParse(name.AsSpan(start, end - start), out var parsed) ? parsed : null; + } + } +} + +public sealed record MojangRuntimeVersion +{ + public string Name { get; init; } = ""; + public string Released { get; init; } = ""; +} + +public sealed record MojangJavaRuntimeManifest +{ + public Dictionary Files { get; init; } = new(StringComparer.Ordinal); +} + +public sealed record MojangJavaRuntimeEntry +{ + public string Type { get; init; } = ""; + public Dictionary? Downloads { get; init; } + public bool Executable { get; init; } + public string? Target { get; init; } +} + +public sealed record LibraryInstallation( + MojangDownload Download, + string Path, + bool IsNative = false, + bool ExtractNatives = false, + IReadOnlyList? ExtractionExcludes = null); + +public enum MojangError +{ + Unsupported, + Invalid, + Http, + Integrity +} + +public sealed class MojangException(MojangError error, string message, int? statusCode = null, Exception? innerException = null) + : Exception(message, innerException) +{ + public MojangError Error { get; } = error; + public int? StatusCode { get; } = statusCode; +} + +public sealed record PreparedInstallation( + MojangVersionEntry Version, + GameArchitecture Architecture, + MojangVersionMetadata Manifest, + byte[] ManifestData, + MojangJavaRuntimePackage Runtime, + MojangJavaRuntimeManifest RuntimeManifest, + byte[] RuntimeManifestData, + IReadOnlyList Libraries); + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(MojangCatalog))] +[JsonSerializable(typeof(MojangVersionMetadata))] +[JsonSerializable(typeof(MojangAssetIndex))] +[JsonSerializable(typeof(MojangJavaRuntimeManifest))] +[JsonSerializable(typeof(MojangRule[]))] +[JsonSerializable(typeof(Dictionary>>))] +internal partial class MojangJsonContext : JsonSerializerContext; diff --git a/windows/Hako.Core/Minecraft/Win32Process.cs b/windows/Hako.Core/Minecraft/Win32Process.cs new file mode 100644 index 0000000..9d565e5 --- /dev/null +++ b/windows/Hako.Core/Minecraft/Win32Process.cs @@ -0,0 +1,311 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace Hako.Core.Minecraft; + +internal static unsafe partial class Win32Process +{ + internal const int MaximumCommandLineCharacters = 32767; + internal const uint CreateNoWindow = 0x08000000; + internal const uint CreateUnicodeEnvironment = 0x00000400; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const nint ProcThreadAttributeHandleList = 0x00020002; + private const uint StartfUseStdHandles = 0x00000100; + private const uint HandleFlagInherit = 0x00000001; + private const uint ProcessQueryLimitedInformation = 0x00001000; + private const uint Synchronize = 0x00100000; + private const uint Infinite = 0xffffffff; + private const ulong WindowsToUnixEpochTicks = 116444736000000000; + + internal static (SafeProcessHandle Process, uint ProcessId) Start( + string executable, + IReadOnlyList arguments, + string workingDirectory, + SafeFileHandle logHandle) + { + var commandLine = BuildCommandLine(executable, arguments); + var environmentBlock = BuildEnvironmentBlock(); + var environmentMemory = Marshal.StringToHGlobalUni(environmentBlock); + nint attributeList = 0; + var attributeListInitialized = false; + var inheritedHandle = logHandle.DangerousGetHandle(); + try + { + if (!SetHandleInformation(inheritedHandle, HandleFlagInherit, HandleFlagInherit)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + // Only the log handle may be inherited: other inheritable handles of Hako (for example pipes + // of a concurrent Java probe) must not leak into a game that outlives the launcher. + // Known limitation: while the log handle is marked inheritable, a concurrent Process.Start without + // a handle list (for example a Java probe) may inherit it for that child's lifetime. + nuint attributeListSize = 0; + _ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize); + attributeList = Marshal.AllocHGlobal((nint)attributeListSize); + if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + attributeListInitialized = true; + if (!UpdateProcThreadAttribute(attributeList, 0, ProcThreadAttributeHandleList, &inheritedHandle, + (nuint)sizeof(nint), 0, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + var startup = new StartupInfoEx + { + StartupInfo = new StartupInfo + { + Size = (uint)Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StdOutput = inheritedHandle, + StdError = inheritedHandle + }, + AttributeList = attributeList + }; + ProcessInformation information; + // CreateProcessW may write into the command line, so it gets a private null-terminated copy. + var commandLineBuffer = new char[commandLine.Length + 1]; + commandLine.CopyTo(commandLineBuffer); + fixed (char* mutableCommandLine = commandLineBuffer) + { + if (!CreateProcessW(executable, mutableCommandLine, 0, 0, 1, + CreateNoWindow | CreateUnicodeEnvironment | ExtendedStartupInfoPresent, environmentMemory, + workingDirectory, ref startup, out information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + _ = CloseHandle(information.Thread); + return (new SafeProcessHandle(information.Process, ownsHandle: true), information.ProcessId); + } + finally + { + if (attributeListInitialized) + { + DeleteProcThreadAttributeList(attributeList); + } + + if (attributeList != 0) + { + Marshal.FreeHGlobal(attributeList); + } + + Marshal.FreeHGlobal(environmentMemory); + _ = SetHandleInformation(inheritedHandle, HandleFlagInherit, 0); + } + } + + internal static SafeProcessHandle Open(uint processId) => + OpenProcess(ProcessQueryLimitedInformation | Synchronize, 0, processId); + + internal static bool TryReadCreationTime(SafeProcessHandle process, out ulong startSeconds, out ulong startMicroseconds) + { + startSeconds = 0; + startMicroseconds = 0; + if (process.IsInvalid || process.IsClosed || !GetProcessTimes(process, out var creation, out _, out _, out _)) + { + return false; + } + + var ticks = ((ulong)creation.High << 32) | creation.Low; + if (ticks < WindowsToUnixEpochTicks) + { + return false; + } + + var unixMicroseconds = (ticks - WindowsToUnixEpochTicks) / 10; + startSeconds = unixMicroseconds / 1_000_000; + startMicroseconds = unixMicroseconds % 1_000_000; + return true; + } + + internal static uint Wait(SafeProcessHandle process, uint milliseconds) => WaitForSingleObject(process, milliseconds); + + internal static uint GetExitCode(SafeProcessHandle process) + { + if (!GetExitCodeProcess(process, out var exitCode)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return exitCode; + } + + internal static void Terminate(SafeProcessHandle process, uint exitCode) + { + if (!process.IsInvalid && !process.IsClosed) + { + _ = TerminateProcess(process, exitCode); + _ = WaitForSingleObject(process, Infinite); + } + } + + internal static string BuildCommandLine(string executable, IReadOnlyList arguments) + { + var commandLine = string.Join(' ', new[] { executable }.Concat(arguments).Select(QuoteArgument)); + if (commandLine.Length + 1 > MaximumCommandLineCharacters) + { + throw new MinecraftLaunchException(MinecraftLaunchError.CommandLineTooLong); + } + + return commandLine; + } + + internal static string QuoteArgument(string argument) + { + if (argument.Length > 0 && !argument.Any(character => char.IsWhiteSpace(character) || character == '"')) + { + return argument; + } + + var result = new StringBuilder(argument.Length + 2); + result.Append('"'); + var backslashes = 0; + foreach (var character in argument) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + result.Append('\\', backslashes * 2 + 1).Append('"'); + backslashes = 0; + continue; + } + + result.Append('\\', backslashes).Append(character); + backslashes = 0; + } + + result.Append('\\', backslashes * 2).Append('"'); + return result.ToString(); + } + + private static string BuildEnvironmentBlock() + { + var entries = Environment.GetEnvironmentVariables() + .Cast() + .Where(entry => entry.Key is string name + && !string.Equals(name, "JAVA_TOOL_OPTIONS", StringComparison.OrdinalIgnoreCase) + && !string.Equals(name, "_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase) + && !string.Equals(name, "JDK_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase)) + .Select(entry => (Name: (string)entry.Key, Value: entry.Value?.ToString() ?? "")) + .OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase) + .Select(entry => entry.Name + "=" + entry.Value); + return string.Join('\0', entries) + "\0\0"; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfo + { + public uint Size; + public nint Reserved; + public nint Desktop; + public nint Title; + public uint X; + public uint Y; + public uint XSize; + public uint YSize; + public uint XCountChars; + public uint YCountChars; + public uint FillAttribute; + public uint Flags; + public ushort ShowWindow; + public ushort Reserved2Length; + public nint Reserved2; + public nint StdInput; + public nint StdOutput; + public nint StdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + public StartupInfo StartupInfo; + public nint AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + public nint Process; + public nint Thread; + public uint ProcessId; + public uint ThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FileTime + { + public uint Low; + public uint High; + } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CreateProcessW( + string applicationName, + char* commandLine, + nint processAttributes, + nint threadAttributes, + int inheritHandles, + uint creationFlags, + nint environment, + string currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [LibraryImport("kernel32.dll", EntryPoint = "InitializeProcThreadAttributeList", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool InitializeProcThreadAttributeList(nint attributeList, int attributeCount, int flags, ref nuint size); + + [LibraryImport("kernel32.dll", EntryPoint = "UpdateProcThreadAttribute", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UpdateProcThreadAttribute( + nint attributeList, uint flags, nint attribute, void* value, nuint size, nint previousValue, nint returnSize); + + [LibraryImport("kernel32.dll", EntryPoint = "DeleteProcThreadAttributeList")] + private static partial void DeleteProcThreadAttributeList(nint attributeList); + + [LibraryImport("kernel32.dll", EntryPoint = "SetHandleInformation", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetHandleInformation(nint handle, uint mask, uint flags); + + [LibraryImport("kernel32.dll", EntryPoint = "OpenProcess", SetLastError = true)] + private static partial SafeProcessHandle OpenProcess(uint desiredAccess, int inheritHandle, uint processId); + + [LibraryImport("kernel32.dll", EntryPoint = "GetProcessTimes", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetProcessTimes( + SafeProcessHandle process, + out FileTime creationTime, + out FileTime exitTime, + out FileTime kernelTime, + out FileTime userTime); + + [LibraryImport("kernel32.dll", EntryPoint = "WaitForSingleObject", SetLastError = true)] + private static partial uint WaitForSingleObject(SafeProcessHandle handle, uint milliseconds); + + [LibraryImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool TerminateProcess(SafeProcessHandle process, uint exitCode); + + [LibraryImport("kernel32.dll", EntryPoint = "GetExitCodeProcess", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetExitCodeProcess(SafeProcessHandle process, out uint exitCode); + + [LibraryImport("kernel32.dll", EntryPoint = "CloseHandle", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CloseHandle(nint handle); + +} diff --git a/windows/Hako.Core/Minecraft/WorldMetadata.cs b/windows/Hako.Core/Minecraft/WorldMetadata.cs new file mode 100644 index 0000000..9b0aabf --- /dev/null +++ b/windows/Hako.Core/Minecraft/WorldMetadata.cs @@ -0,0 +1,542 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +namespace Hako.Core.Minecraft; + +public enum WorldMetadataError +{ + InvalidGzip, + InvalidNbt, + InvalidText, + CompressedDataTooLarge, + DecompressedDataTooLarge, + NestingTooDeep, + ReadFailure +} + +public sealed class WorldMetadataException(WorldMetadataError error, string message, Exception? innerException = null) + : IOException(message, innerException) +{ + public WorldMetadataError Error { get; } = error; +} + +public sealed record WorldMetadata(string? Name, string? Version, int? GameType, DateTimeOffset? LastPlayed) +{ + public const int MaximumBytes = 16 * 1024 * 1024; + public const int MaximumDepth = 64; + + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private static readonly uint[] Crc32Table = CreateCrc32Table(); + + /// Reads the supported fields from bounded gzip-compressed NBT. + public static WorldMetadata Read(ReadOnlySpan gzipData, CancellationToken cancellationToken = default) + { + var bytes = Decompress(gzipData, cancellationToken); + return new NbtReader(bytes, cancellationToken).Read(); + } + + /// Reads a level.dat file without changing it. + public static WorldMetadata ReadFile(string path, CancellationToken cancellationToken = default) + { + try + { + using var input = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 81920, + FileOptions.SequentialScan); + using var compressed = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = input.Read(buffer, 0, buffer.Length); + if (read == 0) break; + if (compressed.Length + read > MaximumBytes) + { + throw new WorldMetadataException(WorldMetadataError.CompressedDataTooLarge, + "The compressed world metadata exceeds the size limit."); + } + + compressed.Write(buffer, 0, read); + } + + return Read(compressed.GetBuffer().AsSpan(0, checked((int)compressed.Length)), cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (WorldMetadataException) + { + throw; + } + catch (IOException exception) + { + throw new WorldMetadataException(WorldMetadataError.ReadFailure, + "The world metadata could not be read.", exception); + } + catch (UnauthorizedAccessException exception) + { + throw new WorldMetadataException(WorldMetadataError.ReadFailure, + "The world metadata could not be read.", exception); + } + } + + /// Changes only Data/LevelName in the NBT payload; all other NBT bytes retain their order and content. + public static byte[] Renamed(ReadOnlySpan gzipData, string name, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(name); + var bytes = Decompress(gzipData, cancellationToken); + var reader = new NbtReader(bytes, cancellationToken); + _ = reader.Read(); + var encodedName = EncodeModifiedUtf8(name); + if (encodedName.Length > ushort.MaxValue) + { + throw new WorldMetadataException(WorldMetadataError.InvalidText, + "The replacement world name is too long for NBT."); + } + + byte[] renamed; + if (reader.LevelNameLengthOffset is { } lengthOffset + && reader.LevelNameValueOffset is { } valueOffset + && reader.LevelNameByteLength is { } oldLength) + { + var newLength = checked(bytes.Length - 2 - oldLength + 2 + encodedName.Length); + EnsureExpandedSize(newLength); + renamed = new byte[newLength]; + bytes.AsSpan(0, lengthOffset).CopyTo(renamed); + BinaryPrimitives.WriteUInt16BigEndian(renamed.AsSpan(lengthOffset, 2), (ushort)encodedName.Length); + encodedName.CopyTo(renamed.AsSpan(lengthOffset + 2)); + var tailStart = valueOffset + oldLength; + bytes.AsSpan(tailStart).CopyTo(renamed.AsSpan(lengthOffset + 2 + encodedName.Length)); + } + else if (reader.DataEndOffset is { } dataEnd) + { + var tagName = EncodeModifiedUtf8("LevelName"); + var additionLength = checked(1 + 2 + tagName.Length + 2 + encodedName.Length); + var newLength = checked(bytes.Length + additionLength); + EnsureExpandedSize(newLength); + renamed = new byte[newLength]; + bytes.AsSpan(0, dataEnd).CopyTo(renamed); + var offset = dataEnd; + renamed[offset++] = 8; + BinaryPrimitives.WriteUInt16BigEndian(renamed.AsSpan(offset, 2), (ushort)tagName.Length); + offset += 2; + tagName.CopyTo(renamed.AsSpan(offset)); + offset += tagName.Length; + BinaryPrimitives.WriteUInt16BigEndian(renamed.AsSpan(offset, 2), (ushort)encodedName.Length); + offset += 2; + encodedName.CopyTo(renamed.AsSpan(offset)); + bytes.AsSpan(dataEnd).CopyTo(renamed.AsSpan(offset + encodedName.Length)); + } + else + { + throw InvalidNbt("The world metadata does not contain a Data compound."); + } + + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + const int chunkSize = 32 * 1024; + for (var offset = 0; offset < renamed.Length; offset += chunkSize) + { + cancellationToken.ThrowIfCancellationRequested(); + var length = Math.Min(chunkSize, renamed.Length - offset); + gzip.Write(renamed, offset, length); + } + } + + if (output.Length > MaximumBytes) + { + throw new WorldMetadataException(WorldMetadataError.CompressedDataTooLarge, + "The compressed world metadata exceeds the size limit."); + } + return output.ToArray(); + } + + private static byte[] Decompress(ReadOnlySpan gzipData, CancellationToken cancellationToken) + { + if (gzipData.Length > MaximumBytes) + { + throw new WorldMetadataException(WorldMetadataError.CompressedDataTooLarge, + "The compressed world metadata exceeds the size limit."); + } + + if (gzipData.Length < 18) + { + throw new WorldMetadataException(WorldMetadataError.InvalidGzip, "The world metadata is not valid gzip data."); + } + + try + { + using var input = new MemoryStream(gzipData.ToArray(), writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + var buffer = new byte[81920]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = gzip.Read(buffer, 0, buffer.Length); + if (read == 0) break; + if (output.Length + read > MaximumBytes) + { + throw new WorldMetadataException(WorldMetadataError.DecompressedDataTooLarge, + "The decompressed world metadata exceeds the size limit."); + } + + output.Write(buffer, 0, read); + } + + var decompressed = output.ToArray(); + var footer = gzipData[^8..]; + var expectedCrc = BinaryPrimitives.ReadUInt32LittleEndian(footer[..4]); + var expectedSize = BinaryPrimitives.ReadUInt32LittleEndian(footer[4..]); + if (expectedSize != (uint)decompressed.Length || expectedCrc != ComputeCrc32(decompressed, cancellationToken)) + { + throw new InvalidDataException("The gzip trailer does not match the decompressed content."); + } + + return decompressed; + } + catch (OperationCanceledException) + { + throw; + } + catch (WorldMetadataException) + { + throw; + } + catch (InvalidDataException exception) + { + throw new WorldMetadataException(WorldMetadataError.InvalidGzip, + "The world metadata is not valid gzip data.", exception); + } + catch (IOException exception) + { + throw new WorldMetadataException(WorldMetadataError.InvalidGzip, + "The world metadata is not valid gzip data.", exception); + } + } + + private static byte[] EncodeModifiedUtf8(string value) + { + var output = new byte[checked(value.Length * 3)]; + var offset = 0; + foreach (var unit in value) + { + if (unit is > '\0' and <= '\u007f') + { + output[offset++] = (byte)unit; + } + else if (unit <= '\u07ff') + { + output[offset++] = (byte)(0xc0 | (unit >> 6)); + output[offset++] = (byte)(0x80 | (unit & 0x3f)); + } + else + { + output[offset++] = (byte)(0xe0 | (unit >> 12)); + output[offset++] = (byte)(0x80 | ((unit >> 6) & 0x3f)); + output[offset++] = (byte)(0x80 | (unit & 0x3f)); + } + } + + return output.AsSpan(0, offset).ToArray(); + } + + private static string DecodeUtf8(byte[] bytes, int offset, int length) + { + try + { + return StrictUtf8.GetString(bytes, offset, length); + } + catch (DecoderFallbackException) + { + var end = checked(offset + length); + var units = new char[length]; + var count = 0; + while (offset < end) + { + var first = bytes[offset++]; + if (first is >= 1 and <= 0x7f) + { + units[count++] = (char)first; + continue; + } + + if (first is >= 0xc0 and <= 0xdf) + { + if (offset >= end) throw InvalidText(); + var second = bytes[offset++]; + if ((second & 0xc0) != 0x80) throw InvalidText(); + var unit = (char)(((first & 0x1f) << 6) | (second & 0x3f)); + if (unit == '\0') + { + if (first != 0xc0 || second != 0x80) throw InvalidText(); + } + else if (unit < 0x80) + { + throw InvalidText(); + } + + units[count++] = unit; + continue; + } + + if (first is >= 0xe0 and <= 0xef) + { + if (offset + 1 >= end) throw InvalidText(); + var second = bytes[offset++]; + var third = bytes[offset++]; + if ((second & 0xc0) != 0x80 || (third & 0xc0) != 0x80) throw InvalidText(); + var unit = (char)(((first & 0x0f) << 12) | ((second & 0x3f) << 6) | (third & 0x3f)); + if (unit < 0x0800) throw InvalidText(); + units[count++] = unit; + continue; + } + + throw InvalidText(); + } + + return new string(units, 0, count); + } + catch (WorldMetadataException) + { + throw; + } + } + + private static void EnsureExpandedSize(int length) + { + if (length > MaximumBytes) + { + throw new WorldMetadataException(WorldMetadataError.DecompressedDataTooLarge, + "The decompressed world metadata exceeds the size limit."); + } + } + + private static uint ComputeCrc32(ReadOnlySpan bytes, CancellationToken cancellationToken) + { + var crc = uint.MaxValue; + for (var index = 0; index < bytes.Length; index++) + { + if ((index & 0xffff) == 0) cancellationToken.ThrowIfCancellationRequested(); + crc = (crc >> 8) ^ Crc32Table[(crc ^ bytes[index]) & 0xff]; + } + return ~crc; + } + + private static uint[] CreateCrc32Table() + { + var table = new uint[256]; + for (uint index = 0; index < table.Length; index++) + { + var value = index; + for (var bit = 0; bit < 8; bit++) + { + value = (value & 1) == 0 ? value >> 1 : 0xedb88320 ^ (value >> 1); + } + table[index] = value; + } + return table; + } + + private static WorldMetadataException InvalidNbt(string message = "The world metadata contains invalid NBT data.") => + new(WorldMetadataError.InvalidNbt, message); + + private static WorldMetadataException InvalidText() => + new(WorldMetadataError.InvalidText, "The world metadata contains invalid UTF-8 text."); + + private enum ContainerKind { None, Root, Data, Version } + + private sealed class NbtReader(byte[] bytes, CancellationToken cancellationToken) + { + private int _offset; + private bool _hasData; + private string? _name; + private string? _version; + private int? _gameType; + private DateTimeOffset? _lastPlayed; + + public int? LevelNameLengthOffset { get; private set; } + public int? LevelNameValueOffset { get; private set; } + public int? LevelNameByteLength { get; private set; } + public int? DataEndOffset { get; private set; } + + public WorldMetadata Read() + { + cancellationToken.ThrowIfCancellationRequested(); + if (ReadByte() != 10) throw InvalidNbt("The world metadata root must be an NBT compound."); + _ = ReadString(); + ReadCompound(ContainerKind.Root, depth: 0); + if (!_hasData || _offset != bytes.Length) throw InvalidNbt(); + return new WorldMetadata(_name, _version, _gameType, _lastPlayed); + } + + private void ReadCompound(ContainerKind container, int depth) + { + EnsureDepth(depth); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var type = ReadByte(); + if (type == 0) + { + if (container == ContainerKind.Data) DataEndOffset = _offset - 1; + return; + } + + if (type is < 1 or > 12) throw InvalidNbt("The world metadata contains an unsupported NBT tag."); + var name = ReadString(); + var child = container switch + { + ContainerKind.Root when type == 10 && name == "Data" => ContainerKind.Data, + ContainerKind.Data when type == 10 && name == "Version" => ContainerKind.Version, + _ => ContainerKind.None + }; + if (child == ContainerKind.Data) _hasData = true; + ReadPayload(type, name, child, depth + 1, container); + } + } + + private void ReadPayload(byte type, string name, ContainerKind child, int depth, ContainerKind parent) + { + EnsureDepth(depth); + switch (type) + { + case 1: Skip(1); break; + case 2: Skip(2); break; + case 3: + var integer = ReadInt32(); + if (parent == ContainerKind.Data && name == "GameType") _gameType = integer; + break; + case 4: + var timestamp = ReadInt64(); + if (parent == ContainerKind.Data && name == "LastPlayed" && timestamp > 0) + { + try { _lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(timestamp); } + catch (ArgumentOutOfRangeException) { _lastPlayed = null; } + } + break; + case 5: Skip(4); break; + case 6: Skip(8); break; + case 7: SkipArray(1); break; + case 8: + var lengthOffset = _offset; + var length = ReadUInt16(); + var valueOffset = _offset; + if (parent == ContainerKind.Data && name == "LevelName") + { + EnsureAvailable(length); + var value = DecodeUtf8(bytes, valueOffset, length); + LevelNameLengthOffset = lengthOffset; + LevelNameValueOffset = valueOffset; + LevelNameByteLength = length; + if (!string.IsNullOrEmpty(value)) _name = value; + } + else if (parent == ContainerKind.Version && name == "Name") + { + EnsureAvailable(length); + var value = DecodeUtf8(bytes, valueOffset, length); + if (!string.IsNullOrEmpty(value)) _version = value; + } + Skip(length); + break; + case 9: + var elementType = ReadByte(); + var count = ReadLength(); + if (!(elementType is >= 1 and <= 12) && !(elementType == 0 && count == 0)) + throw InvalidNbt("The world metadata contains an invalid NBT list."); + if (count > bytes.Length - _offset) throw InvalidNbt(); + for (var index = 0; index < count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + ReadPayload(elementType, string.Empty, ContainerKind.None, depth + 1, ContainerKind.None); + } + break; + case 10: + ReadCompound(child, depth); + break; + case 11: SkipArray(4); break; + case 12: SkipArray(8); break; + default: throw InvalidNbt("The world metadata contains an unsupported NBT tag."); + } + } + + private void SkipArray(int elementWidth) + { + var count = ReadLength(); + var byteLength = (long)count * elementWidth; + if (byteLength > int.MaxValue) throw InvalidNbt(); + Skip((int)byteLength); + } + + private int ReadLength() + { + var length = ReadInt32(); + if (length < 0) throw InvalidNbt("The world metadata contains a negative NBT length."); + return length; + } + + private string ReadString() + { + var length = ReadUInt16(); + EnsureAvailable(length); + var text = DecodeUtf8(bytes, _offset, length); + Skip(length); + return text; + } + + private byte ReadByte() + { + if (_offset >= bytes.Length) throw InvalidNbt(); + return bytes[_offset++]; + } + + private ushort ReadUInt16() + { + EnsureAvailable(2); + var value = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(_offset, 2)); + _offset += 2; + return value; + } + + private int ReadInt32() + { + EnsureAvailable(4); + var value = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(_offset, 4)); + _offset += 4; + return value; + } + + private long ReadInt64() + { + EnsureAvailable(8); + var value = BinaryPrimitives.ReadInt64BigEndian(bytes.AsSpan(_offset, 8)); + _offset += 8; + return value; + } + + private void Skip(int count) + { + if (count < 0) throw InvalidNbt(); + EnsureAvailable(count); + _offset += count; + } + + private void EnsureAvailable(int count) + { + if (count < 0 || count > bytes.Length - _offset) throw InvalidNbt(); + } + + private static void EnsureDepth(int depth) + { + if (depth > MaximumDepth) + { + throw new WorldMetadataException(WorldMetadataError.NestingTooDeep, + "The world metadata exceeds the NBT nesting limit."); + } + } + } +} diff --git a/windows/Hako.Core/MinecraftSessionCoordinator.cs b/windows/Hako.Core/MinecraftSessionCoordinator.cs new file mode 100644 index 0000000..2f25fad --- /dev/null +++ b/windows/Hako.Core/MinecraftSessionCoordinator.cs @@ -0,0 +1,349 @@ +using System.ComponentModel; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace Hako.Core; + +public enum MinecraftSessionStatus +{ + Idle, + Connecting, + Connected, + Failed +} + +/// +/// Refreshes an online Minecraft session for launch. Notifications are raised on the thread that +/// performs the refresh; UI subscribers must marshal them to their DispatcherQueue. +/// +public sealed class MinecraftSessionCoordinator : INotifyPropertyChanged +{ + private readonly AppCoordinator _app; + private readonly IAccountStore _accountStore; + private readonly IMicrosoftAuthService _authService; + private readonly TimeProvider _timeProvider; + private readonly object _sync = new(); + private MinecraftSessionStatus _status; + private AuthErrorKind? _error; + private CancellationTokenSource? _refreshCancellation; + private TaskCompletionSource? _refreshCompletion; + + internal MinecraftSessionCoordinator( + AppCoordinator app, + IAccountStore accountStore, + IMicrosoftAuthService authService, + TimeProvider? timeProvider = null) + { + _app = app; + _accountStore = accountStore; + _authService = authService; + _timeProvider = timeProvider ?? TimeProvider.System; + _app.AccountGenerationChanged += OnAccountGenerationChanged; + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public MinecraftSessionStatus Status + { + get { lock (_sync) return _status; } + private set => SetProperty(ref _status, value); + } + + public AuthErrorKind? Error + { + get { lock (_sync) return _error; } + private set => SetProperty(ref _error, value); + } + + public string? CurrentAccountXuid => _app.CaptureAccountState().Account?.Xuid; + + public Task ConnectForLaunchAsync(CancellationToken cancellationToken = default) + { + var snapshot = _app.CaptureAccountState(); + Task sharedTask; + TaskCompletionSource? completion = null; + CancellationTokenSource? refreshCancellation = null; + + lock (_sync) + { + if (_refreshCompletion is { Task.IsCompleted: false } active) + { + sharedTask = active.Task; + } + else if (snapshot.Account is { MinecraftAccessToken.Length: > 0, MinecraftTokenExpiresAt: { } expiresAt, + MinecraftProfileId.Length: > 0, MinecraftName.Length: > 0 } account + && expiresAt > _timeProvider.GetUtcNow().AddMinutes(1)) + { + Status = MinecraftSessionStatus.Connected; + Error = null; + return Task.FromResult(account); + } + else if (snapshot.Account is null) + { + Status = MinecraftSessionStatus.Failed; + Error = AuthErrorKind.MinecraftProfileMissing; + return Task.FromException(new AuthServiceException(AuthErrorKind.MinecraftProfileMissing)); + } + else + { + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + refreshCancellation = new CancellationTokenSource(); + _refreshCompletion = completion; + _refreshCancellation = refreshCancellation; + Status = MinecraftSessionStatus.Connecting; + Error = null; + sharedTask = completion.Task; + } + } + + if (completion is not null && refreshCancellation is not null && snapshot.Account is { } accountToRefresh) + { + _ = RunRefreshAsync(accountToRefresh, snapshot.Generation, refreshCancellation, completion); + } + + return sharedTask.WaitAsync(cancellationToken); + } + + public void CancelRefresh() + { + lock (_sync) + { + _refreshCancellation?.Cancel(); + _refreshCancellation = null; + _refreshCompletion = null; + Status = MinecraftSessionStatus.Idle; + Error = null; + } + } + + private async Task RunRefreshAsync( + AccountSession originalAccount, + long generation, + CancellationTokenSource cancellation, + TaskCompletionSource completion) + { + try + { + AccountSession connected; + try + { + connected = await RefreshOnceAsync(originalAccount, generation, cancellation.Token).ConfigureAwait(false); + } + catch (AccountChangedException) + { + // The account was replaced within the same generation (for example by a refresh cancelled via + // CancelRefresh that had already committed a rotated token): retry once from the current account. + var state = _app.CaptureAccountState(); + if (state.Generation != generation || state.Account is null) + { + throw new OperationCanceledException(cancellation.Token); + } + + connected = await RefreshOnceAsync(state.Account, generation, cancellation.Token).ConfigureAwait(false); + } + + if (!IsCurrentGeneration(generation)) + { + throw new OperationCanceledException(cancellation.Token); + } + + SetResultIfCurrent(generation, cancellation, completion, connected); + } + catch (AccountChangedException) + { + // The account changed again during the retry; it is not a network failure, so the launch is cancelled. + completion.TrySetCanceled(cancellation.Token); + SetIdleIfCurrentRefresh(cancellation, completion); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested || !IsCurrentGeneration(generation)) + { + completion.TrySetCanceled(cancellation.Token); + SetIdleIfCurrentRefresh(cancellation, completion); + } + catch (AuthServiceException exception) + { + SetFailureIfCurrent(generation, cancellation, completion, exception.Kind); + } + catch (AccountStoreException) + { + SetFailureIfCurrent(generation, cancellation, completion, AuthErrorKind.Persistence); + } + catch (HttpRequestException) + { + SetFailureIfCurrent(generation, cancellation, completion, AuthErrorKind.Network); + } + catch (JsonException) + { + SetFailureIfCurrent(generation, cancellation, completion, AuthErrorKind.InvalidResponse); + } + catch (OperationCanceledException) + { + SetFailureIfCurrent(generation, cancellation, completion, AuthErrorKind.Network); + } + catch + { + SetFailureIfCurrent(generation, cancellation, completion, AuthErrorKind.InvalidResponse); + } + finally + { + lock (_sync) + { + if (ReferenceEquals(_refreshCancellation, cancellation)) + { + _refreshCancellation = null; + _refreshCompletion = null; + } + } + + cancellation.Dispose(); + } + } + + private async Task RefreshOnceAsync(AccountSession account, long generation, CancellationToken cancellationToken) + { + var currentAccount = account; + var connection = await _authService.ConnectMinecraftAsync( + account.MicrosoftRefreshToken, + async (rotatedRefreshToken, token) => + { + var updated = currentAccount with { MicrosoftRefreshToken = rotatedRefreshToken }; + await SaveCurrentAccountAsync(currentAccount, updated, generation, token).ConfigureAwait(false); + currentAccount = updated; + }, + cancellationToken).ConfigureAwait(false); + + var connected = currentAccount with + { + MinecraftProfileId = connection.ProfileId, + MinecraftName = connection.ProfileName, + MinecraftAccessToken = connection.AccessToken, + MinecraftTokenExpiresAt = connection.ExpiresAt + }; + await SaveCurrentAccountAsync(currentAccount, connected, generation, cancellationToken).ConfigureAwait(false); + return connected; + } + + private async Task SaveCurrentAccountAsync( + AccountSession expected, + AccountSession updated, + long generation, + CancellationToken cancellationToken) + { + var saved = await _accountStore.SaveAsync(updated, persist => + _app.TryCommitAccountUpdate(expected, generation, updated, persist), cancellationToken).ConfigureAwait(false); + if (!saved) + { + if (cancellationToken.IsCancellationRequested || !IsCurrentGeneration(generation)) + { + throw new OperationCanceledException(cancellationToken); + } + + throw new AccountChangedException(); + } + } + + private sealed class AccountChangedException : Exception; + + private bool IsCurrentGeneration(long generation) => _app.CaptureAccountState().Generation == generation; + + private void SetResultIfCurrent( + long generation, + CancellationTokenSource cancellation, + TaskCompletionSource completion, + AccountSession account) + { + if (!IsCurrentGeneration(generation) || cancellation.IsCancellationRequested) + { + completion.TrySetCanceled(cancellation.Token); + return; + } + + lock (_sync) + { + if (!ReferenceEquals(_refreshCompletion, completion) || cancellation.IsCancellationRequested) + { + completion.TrySetCanceled(cancellation.Token); + return; + } + + Error = null; + Status = MinecraftSessionStatus.Connected; + completion.TrySetResult(account); + } + } + + private void SetFailureIfCurrent( + long generation, + CancellationTokenSource cancellation, + TaskCompletionSource completion, + AuthErrorKind error) + { + var isCurrent = IsCurrentGeneration(generation); + lock (_sync) + { + if (!ReferenceEquals(_refreshCompletion, completion)) + { + completion.TrySetCanceled(cancellation.Token); + return; + } + + if (isCurrent && !cancellation.IsCancellationRequested) + { + Error = error; + Status = MinecraftSessionStatus.Failed; + completion.TrySetException(new AuthServiceException(error)); + } + else + { + Status = MinecraftSessionStatus.Idle; + Error = null; + completion.TrySetCanceled(cancellation.Token); + } + } + } + + private void SetIdleIfCurrentRefresh(CancellationTokenSource cancellation, TaskCompletionSource completion) + { + lock (_sync) + { + if (ReferenceEquals(_refreshCompletion, completion) && ReferenceEquals(_refreshCancellation, cancellation)) + { + Status = MinecraftSessionStatus.Idle; + Error = null; + } + } + } + + private void OnAccountGenerationChanged(object? sender, EventArgs args) => CancelRefresh(); + + private void SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + lock (_sync) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return; + } + + field = value; + } + + var handlers = PropertyChanged; + if (handlers is null) + { + return; + } + + foreach (PropertyChangedEventHandler handler in handlers.GetInvocationList()) + { + try + { + handler(this, new PropertyChangedEventArgs(propertyName)); + } + catch (Exception) + { + } + } + } +} diff --git a/windows/Hako.Core/Playtime/PlaytimeClock.cs b/windows/Hako.Core/Playtime/PlaytimeClock.cs new file mode 100644 index 0000000..66db82d --- /dev/null +++ b/windows/Hako.Core/Playtime/PlaytimeClock.cs @@ -0,0 +1,127 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.Win32.SafeHandles; +using Hako.Core.Minecraft; + +namespace Hako.Core.Playtime; + +public interface IPlaytimeClock +{ + ulong GetUnbiasedTicks(); +} + +public sealed partial class WindowsPlaytimeClock : IPlaytimeClock +{ + public ulong GetUnbiasedTicks() + { + if (!QueryUnbiasedInterruptTime(out var ticks)) throw new Win32Exception(Marshal.GetLastWin32Error()); + return ticks; + } + + [LibraryImport("kernel32.dll", EntryPoint = "QueryUnbiasedInterruptTime", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool QueryUnbiasedInterruptTime(out ulong unbiasedTime); +} + +public interface IPlaytimeProcessProbe +{ + PlaytimeProcessIdentity? ReadCurrentIdentity(); + bool IsRunning(PlaytimeProcessIdentity identity); + bool WaitForExitOrTimeout(PlaytimeProcessIdentity identity, TimeSpan timeout); +} + +public sealed class WindowsPlaytimeProcessProbe : IPlaytimeProcessProbe +{ + public PlaytimeProcessIdentity? ReadCurrentIdentity() => ReadIdentity(Environment.ProcessId); + + public bool IsRunning(PlaytimeProcessIdentity identity) + { + using var process = Win32Process.Open((uint)identity.Pid); + return Matches(process, identity) && Win32Process.Wait(process, 0) == 0x00000102; + } + + public bool WaitForExitOrTimeout(PlaytimeProcessIdentity identity, TimeSpan timeout) + { + using var process = Win32Process.Open((uint)identity.Pid); + if (!Matches(process, identity)) return true; + var milliseconds = timeout.TotalMilliseconds >= uint.MaxValue - 1 + ? uint.MaxValue - 1 + : (uint)Math.Max(0, timeout.TotalMilliseconds); + return Win32Process.Wait(process, milliseconds) != 0x00000102; + } + + internal static PlaytimeProcessIdentity? ReadIdentity(int pid) + { + if (pid <= 0) return null; + using var process = Win32Process.Open((uint)pid); + return !process.IsInvalid && Win32Process.TryReadCreationTime(process, out var seconds, out var microseconds) + ? new PlaytimeProcessIdentity(pid, seconds, microseconds) + : null; + } + + private static bool Matches(SafeProcessHandle process, PlaytimeProcessIdentity identity) => + !process.IsInvalid && Win32Process.TryReadCreationTime(process, out var seconds, out var microseconds) + && seconds == identity.StartSeconds && microseconds == identity.StartMicroseconds; +} + +public interface IPlaytimeHelperLauncher +{ + Task TryStartAsync(string journalPath, CancellationToken cancellationToken = default); +} + +public static class PlaytimeHelperHost +{ + public static int Run(string[] arguments, IPlaytimeClock? clock = null, IPlaytimeProcessProbe? processProbe = null, + TimeSpan? checkpointInterval = null) + { + if (arguments.Length != 1 || !PlaytimePaths.TryValidateHelperPath(arguments[0], out var path, out var sessionID)) return 2; + clock ??= new WindowsPlaytimeClock(); + processProbe ??= new WindowsPlaytimeProcessProbe(); + var interval = checkpointInterval ?? TimeSpan.FromSeconds(10); + if (interval <= TimeSpan.Zero) return 2; + + try + { + using var sessionLock = PlaytimeJournalLock.TryAcquire(Path.ChangeExtension(path, ".lock")); + if (sessionLock is null) return 0; + var journal = PlaytimeJson.ReadJournal(path); + if (journal.SessionID != sessionID || journal.Process is not { } process || journal.IsFinished) return 2; + var identity = new PlaytimeProcessIdentity(process.Pid, process.StartSeconds, process.StartMicroseconds); + var helper = processProbe.ReadCurrentIdentity(); + if (helper is null) return 3; + journal.Helper = helper; + + if (!processProbe.IsRunning(identity)) + { + // No observation of the original process: only its saved checkpoint is trustworthy. + journal.IsFinished = true; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + return 0; + } + + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + while (true) + { + if (processProbe.WaitForExitOrTimeout(identity, interval)) + { + journal.ElapsedSeconds = Math.Max(journal.ElapsedSeconds, + journal.BaseElapsedSeconds + Elapsed(journal.StartedUnbiasedTicks, clock.GetUnbiasedTicks())); + journal.IsFinished = true; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + return 0; + } + + journal.ElapsedSeconds = Math.Max(journal.ElapsedSeconds, + journal.BaseElapsedSeconds + Elapsed(journal.StartedUnbiasedTicks, clock.GetUnbiasedTicks())); + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or Win32Exception or ArgumentException) + { + return 3; + } + } + + internal static double Elapsed(ulong started, ulong current) => current <= started ? 0 : (current - started) / 10_000_000d; +} diff --git a/windows/Hako.Core/Playtime/PlaytimeCoordinator.cs b/windows/Hako.Core/Playtime/PlaytimeCoordinator.cs new file mode 100644 index 0000000..dbec3e4 --- /dev/null +++ b/windows/Hako.Core/Playtime/PlaytimeCoordinator.cs @@ -0,0 +1,902 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core.Minecraft; + +namespace Hako.Core.Playtime; + +public interface IAccountPlaytimeTransfer +{ + Task PrepareGuestTransferAsync(string targetXuid, CancellationToken cancellationToken = default); + Task CompleteGuestTransferAsync(Guid transferID, CancellationToken cancellationToken = default); + Task CancelGuestTransferAsync(Guid transferID, CancellationToken cancellationToken = default); +} + +internal enum PlaytimeTransferCheckpoint +{ + PendingWritten, + BeforeApply, + Applied +} + +public sealed record PlaytimeTrackingRequest(Guid SessionID, string JournalPath, string OwnerKey, ulong StartedUnbiasedTicks); + +/// Shared game-root ledger and session-journal coordinator; journals are checkpointed by Hako.exe's helper. +public sealed class PlaytimeCoordinator : INotifyPropertyChanged, IAccountPlaytimeTransfer, IAsyncDisposable +{ + public static readonly TimeSpan CheckpointInterval = TimeSpan.FromSeconds(10); + private readonly PlaytimeLedgerStore _ledger; + private readonly string _gameRoot; + private readonly IPlaytimeClock _clock; + private readonly IPlaytimeProcessProbe _processProbe; + private readonly IPlaytimeHelperLauncher? _helperLauncher; + private readonly TimeProvider _timeProvider; + private readonly bool _preview; + private readonly Action? _transferCheckpoint; + private readonly SemaphoreSlim _sessionStartGate = new(1, 1); + private readonly SemaphoreSlim _reconcileGate = new(1, 1); + private readonly HashSet _launchingSessions = []; + private readonly Dictionary _helperNextAttempt = []; + private readonly Dictionary _activeRecords = []; + private readonly object _stateSync = new(); + private Dictionary _owners = new(StringComparer.Ordinal); + private CancellationTokenSource? _pollingCancellation; + private Task? _pollingTask; + private string? _errorResourceKey; + private string? _errorDetail; + private long _revision; + + public PlaytimeCoordinator(string gameRoot, IPlaytimeHelperLauncher? helperLauncher = null, + IPlaytimeClock? clock = null, IPlaytimeProcessProbe? processProbe = null, + TimeProvider? timeProvider = null) + : this(gameRoot, helperLauncher, clock, processProbe, timeProvider, preview: false, transferCheckpoint: null) + { + } + + public static PlaytimeCoordinator CreatePreview(string gameRoot) => + new(gameRoot, helperLauncher: null, clock: null, processProbe: null, timeProvider: null, + preview: true, transferCheckpoint: null); + + internal void SeedPreviewInstance(Guid instanceID) + { + if (!_preview || instanceID == Guid.Empty) return; + lock (_stateSync) + { + _owners["account:preview-xuid"] = new PlaytimeSnapshot(12_345, + new Dictionary { [instanceID] = 3_726 }); + } + } + + internal PlaytimeCoordinator(string gameRoot, IPlaytimeHelperLauncher? helperLauncher, + IPlaytimeClock? clock, IPlaytimeProcessProbe? processProbe, TimeProvider? timeProvider, + bool preview, Action? transferCheckpoint) + { + _gameRoot = Path.GetFullPath(gameRoot); + _ledger = new PlaytimeLedgerStore(_gameRoot); + _helperLauncher = helperLauncher; + _clock = clock ?? new WindowsPlaytimeClock(); + _processProbe = processProbe ?? new WindowsPlaytimeProcessProbe(); + _timeProvider = timeProvider ?? TimeProvider.System; + _preview = preview; + _transferCheckpoint = transferCheckpoint; + if (preview) + { + _owners["account:preview-xuid"] = new PlaytimeSnapshot(12_345, new Dictionary()); + _owners["guest"] = new PlaytimeSnapshot(4_321, new Dictionary()); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public string? ErrorResourceKey { get { lock (_stateSync) return _errorResourceKey; } } + public string? ErrorDetail { get { lock (_stateSync) return _errorDetail; } } + public long Revision { get { lock (_stateSync) return _revision; } } + + public double TotalSeconds(string? xuid) => GetSnapshot(OwnerKey(xuid)).TotalSeconds; + + public double InstanceSeconds(Guid instanceID, string? xuid) => + GetSnapshot(OwnerKey(xuid)).Instances.GetValueOrDefault(instanceID); + + public async Task InitializeAsync(string? currentXuid, CancellationToken cancellationToken = default) + { + if (_preview) return; + await RecoverPendingTransferAsync(currentXuid, cancellationToken).ConfigureAwait(false); + await RefreshAsync(cancellationToken).ConfigureAwait(false); + } + + public void Start() + { + if (_preview || _pollingCancellation is not null) return; + _pollingCancellation = new CancellationTokenSource(); + _pollingTask = PollAsync(_pollingCancellation.Token); + } + + public async Task StopAsync() + { + var cancellation = _pollingCancellation; + if (cancellation is null) return; + _pollingCancellation = null; + cancellation.Cancel(); + try { if (_pollingTask is not null) await _pollingTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + cancellation.Dispose(); + _pollingTask = null; + } + + public async ValueTask DisposeAsync() => await StopAsync().ConfigureAwait(false); + + public async Task BeginSessionAsync(Guid instanceID, string? xuid, + CancellationToken cancellationToken = default) + { + if (_preview || instanceID == Guid.Empty) return null; + await _sessionStartGate.WaitAsync(cancellationToken).ConfigureAwait(false); + var sessionID = Guid.NewGuid(); + var ownerKey = OwnerKey(xuid); + lock (_stateSync) _launchingSessions.Add(sessionID); + try + { + try + { + await _ledger.MutateAsync(ledger => + { + ledger.Sessions.Add(sessionID, new PlaytimeSessionRecord + { + SessionID = sessionID, + InstanceID = instanceID, + OwnerKey = ownerKey + }); + return true; + }, cancellationToken).ConfigureAwait(false); + + var started = _clock.GetUnbiasedTicks(); + var journalPath = PlaytimePaths.Journal(_gameRoot, sessionID); + var journal = new PlaytimeSessionJournal + { + SessionID = sessionID, + InstanceID = instanceID, + OwnerKey = ownerKey, + StartedUnbiasedTicks = started + }; + await Task.Run(() => + { + Directory.CreateDirectory(PlaytimePaths.JournalDirectory(_gameRoot)); + using var journalLock = PlaytimeJournalLock.TryAcquire(PlaytimePaths.JournalLock(_gameRoot, sessionID)) + ?? throw new IOException("The playtime session journal is already locked."); + PlaytimeJson.AtomicWrite(journalPath, PlaytimeJson.SerializeJournal(journal)); + }, cancellationToken).ConfigureAwait(false); + return new PlaytimeTrackingRequest(sessionID, journalPath, ownerKey, started); + } + catch (OperationCanceledException) + { + await CleanupStartingSessionAsync(sessionID).ConfigureAwait(false); + throw; + } + catch (Exception exception) + { + await CleanupStartingSessionAsync(sessionID).ConfigureAwait(false); + SetError("PlaytimeStorageError", exception.Message); + return null; + } + } + finally { _sessionStartGate.Release(); } + } + + private async Task CleanupStartingSessionAsync(Guid sessionID) + { + lock (_stateSync) _launchingSessions.Remove(sessionID); + try { await _ledger.MutateAsync(ledger => ledger.Sessions.Remove(sessionID), CancellationToken.None).ConfigureAwait(false); } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + DeleteJournalFiles(sessionID); + } + + /// Attaches a durable journal to the exact process recorded by the game runner and starts its observer. + public async Task AttachProcessAsync(PlaytimeTrackingRequest tracking, GameProcessRecord process, + CancellationToken cancellationToken = default) + { + if (_preview || process.SessionID != tracking.SessionID || process.InstanceID == Guid.Empty) return; + await _sessionStartGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var updated = await Task.Run(() => UpdateJournal(tracking.JournalPath, tracking.SessionID, journal => + { + if (journal.IsFinished || journal.InstanceID != process.InstanceID) + throw new JsonException("The playtime session journal is invalid."); + journal.Process = process; + journal.Helper = null; + journal.StartedUnbiasedTicks = _clock.GetUnbiasedTicks(); + journal.BaseElapsedSeconds = 0; + journal.ElapsedSeconds = 0; + return true; + }), cancellationToken).ConfigureAwait(false); + if (updated?.Process != process) throw new JsonException("The playtime session journal could not be attached to the game process."); + lock (_stateSync) + { + _launchingSessions.Remove(tracking.SessionID); + _activeRecords[tracking.SessionID] = process; + } + + await TryStartHelperAsync(tracking.JournalPath, cancellationToken).ConfigureAwait(false); + await RefreshAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + finally + { + lock (_stateSync) _launchingSessions.Remove(tracking.SessionID); + _sessionStartGate.Release(); + } + } + + /// Restores an already running process without creating a second session for its saved SessionID. + public async Task ResumeSessionAsync(GameProcessRecord process, string? currentXuid, + CancellationToken cancellationToken = default) + { + if (_preview) return process; + var sessionID = process.SessionID ?? LegacySessionID(process); + var tracked = process with { SessionID = sessionID }; + var ownerKey = OwnerKey(currentXuid); + var creditedSeconds = 0d; + try + { + var journalPath = PlaytimePaths.Journal(_gameRoot, sessionID); + var existingJournal = File.Exists(journalPath) + ? await Task.Run(() => PlaytimeJson.ReadJournal(journalPath), cancellationToken).ConfigureAwait(false) + : null; + if (existingJournal is not null) + { + if (existingJournal.SessionID != sessionID || existingJournal.InstanceID != tracked.InstanceID) + throw new JsonException("The saved playtime session is invalid."); + ownerKey = existingJournal.OwnerKey; + } + + await _ledger.MutateAsync(ledger => + { + if (!ledger.Sessions.TryGetValue(sessionID, out var session)) + { + ledger.Sessions.Add(sessionID, new PlaytimeSessionRecord + { + SessionID = sessionID, + InstanceID = tracked.InstanceID, + OwnerKey = ownerKey + }); + } + else if (session.InstanceID != tracked.InstanceID) + { + throw new JsonException("The running game record refers to a different playtime session."); + } + else + { + ownerKey = session.OwnerKey; + creditedSeconds = session.CreditedSeconds; + } + + return true; + }, cancellationToken).ConfigureAwait(false); + + if (existingJournal?.Helper is { } existingHelper + && _processProbe.IsRunning(existingHelper)) + { + if (existingJournal.Process is not { } existingProcess + || !SameProcess(existingProcess, tracked)) + throw new JsonException("A live helper belongs to a different game process."); + lock (_stateSync) _activeRecords[sessionID] = tracked; + return tracked; + } + + await Task.Run(() => + { + Directory.CreateDirectory(PlaytimePaths.JournalDirectory(_gameRoot)); + if (File.Exists(journalPath)) + { + UpdateJournal(journalPath, sessionID, journal => + { + if (journal.InstanceID != tracked.InstanceID || journal.IsFinished) + throw new JsonException("The saved playtime session is invalid."); + if (journal.Process is { } existingProcess && !SameProcess(existingProcess, tracked)) + throw new JsonException("The saved playtime session belongs to a different game process."); + journal.Process = tracked; + journal.Helper = null; + return true; + }); + return; + } + + var journal = new PlaytimeSessionJournal + { + SessionID = sessionID, + InstanceID = tracked.InstanceID, + OwnerKey = ownerKey, + Process = tracked, + StartedUnbiasedTicks = _clock.GetUnbiasedTicks(), + BaseElapsedSeconds = creditedSeconds, + ElapsedSeconds = creditedSeconds + }; + using var journalLock = PlaytimeJournalLock.TryAcquire(PlaytimePaths.JournalLock(_gameRoot, sessionID)) + ?? throw new IOException("The playtime session journal is already locked."); + PlaytimeJson.AtomicWrite(journalPath, PlaytimeJson.SerializeJournal(journal)); + }, cancellationToken).ConfigureAwait(false); + + lock (_stateSync) _activeRecords[sessionID] = tracked; + await TryStartHelperAsync(journalPath, cancellationToken).ConfigureAwait(false); + return tracked; + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + SetError("PlaytimeStorageError", exception.Message); + return process; + } + } + + public async Task CancelSessionAsync(Guid sessionID, CancellationToken cancellationToken = default) + { + if (_preview) return; + lock (_stateSync) + { + _launchingSessions.Remove(sessionID); + _activeRecords.Remove(sessionID); + } + + try + { + var path = PlaytimePaths.Journal(_gameRoot, sessionID); + if (File.Exists(path)) + { + var journal = await Task.Run(() => PlaytimeJson.ReadJournal(path), cancellationToken).ConfigureAwait(false); + if (journal.Process is not null) + { + // Cancellation of a launch cannot discard time once a game has been attached. + await ReconcileAsync(cancellationToken).ConfigureAwait(false); + return; + } + + await Task.Run(() => UpdateJournal(path, sessionID, journal => + { + journal.IsFinished = true; + return true; + }), cancellationToken).ConfigureAwait(false); + } + + await _ledger.MutateAsync(ledger => ledger.Sessions.Remove(sessionID), cancellationToken).ConfigureAwait(false); + DeleteJournalFiles(sessionID); + await RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + } + + public async Task ReconcileAsync(CancellationToken cancellationToken = default) + { + if (_preview) return; + await _reconcileGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var directory = PlaytimePaths.JournalDirectory(_gameRoot); + if (!Directory.Exists(directory)) + { + await RemoveOrphanSessionsAsync([], cancellationToken).ConfigureAwait(false); + await RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + ClearErrorIf("PlaytimeStorageError"); + return; + } + + if (!await Task.Run(() => PlaytimePaths.HasNoReparsePoints(directory), cancellationToken).ConfigureAwait(false)) + { + SetError("PlaytimeStorageError", "The playtime journal directory contains a reparse point."); + return; + } + + var journals = await Task.Run(() => Directory.GetFiles(directory, "*.json", SearchOption.TopDirectoryOnly), cancellationToken).ConfigureAwait(false); + var succeeded = true; + foreach (var journalPath in journals) + { + cancellationToken.ThrowIfCancellationRequested(); + succeeded &= await ReconcileJournalAsync(journalPath, cancellationToken).ConfigureAwait(false); + } + + await RemoveOrphanSessionsAsync(journals, cancellationToken).ConfigureAwait(false); + + await RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + if (succeeded) ClearErrorIf("PlaytimeStorageError"); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + finally { _reconcileGate.Release(); } + } + + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + await ReconcileAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task PrepareGuestTransferAsync(string targetXuid, CancellationToken cancellationToken = default) + { + if (_preview || string.IsNullOrWhiteSpace(targetXuid)) return null; + await _sessionStartGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await ReconcileAsync(cancellationToken).ConfigureAwait(false); + var id = await PrepareTransferAtBoundaryAsync(targetXuid, cancellationToken).ConfigureAwait(false); + if (id == Guid.Empty) return null; + _transferCheckpoint?.Invoke(PlaytimeTransferCheckpoint.PendingWritten); + await RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + return id; + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) + { + SetError("PlaytimeStorageError", exception.Message); + return null; + } + finally { _sessionStartGate.Release(); } + } + + public async Task CompleteGuestTransferAsync(Guid transferID, CancellationToken cancellationToken = default) + { + if (_preview || transferID == Guid.Empty) return; + try + { + _transferCheckpoint?.Invoke(PlaytimeTransferCheckpoint.BeforeApply); + await _ledger.MutateAsync(ledger => ApplyPendingTransfer(ledger, transferID), cancellationToken).ConfigureAwait(false); + try { _transferCheckpoint?.Invoke(PlaytimeTransferCheckpoint.Applied); } + finally { await RefreshSnapshotAsync(CancellationToken.None).ConfigureAwait(false); } + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + } + + public async Task CancelGuestTransferAsync(Guid transferID, CancellationToken cancellationToken = default) + { + if (_preview || transferID == Guid.Empty) return; + try + { + await _ledger.MutateAsync(ledger => + { + if (ledger.PendingTransfer?.ID == transferID) ledger.PendingTransfer = null; + return true; + }, cancellationToken).ConfigureAwait(false); + await RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + } + + public async Task RecoverPendingTransferAsync(string? currentXuid, CancellationToken cancellationToken = default) + { + if (_preview) return; + try + { + await _ledger.MutateAsync(ledger => + { + var pending = ledger.PendingTransfer; + if (pending is null) return false; + if (currentXuid is not null && string.Equals(currentXuid, pending.TargetXuid, StringComparison.Ordinal)) + ApplyPendingTransfer(ledger, pending.ID); + else + ledger.PendingTransfer = null; + return true; + }, cancellationToken).ConfigureAwait(false); + await RefreshSnapshotAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) { SetError("PlaytimeStorageError", exception.Message); } + } + + public static Guid LegacySessionID(GameProcessRecord record) + { + Span bytes = stackalloc byte[32]; + record.InstanceID.TryWriteBytes(bytes); + BitConverter.TryWriteBytes(bytes[16..20], record.Pid); + BitConverter.TryWriteBytes(bytes[20..28], record.StartSeconds); + BitConverter.TryWriteBytes(bytes[28..32], (uint)record.StartMicroseconds); + return new Guid(SHA256.HashData(bytes)[..16]); + } + + private async Task PollAsync(CancellationToken cancellationToken) + { + try + { + using var timer = new PeriodicTimer(CheckpointInterval, _timeProvider); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + await RefreshAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { } + } + + private async Task ReconcileJournalAsync(string path, CancellationToken cancellationToken) + { + PlaytimeSessionJournal journal; + try { journal = await Task.Run(() => PlaytimeJson.ReadJournal(path), cancellationToken).ConfigureAwait(false); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + SetError("PlaytimeStorageError", exception.Message); + return false; + } + + var initialLedger = await _ledger.ReadAsync(cancellationToken).ConfigureAwait(false); + var sessionExists = initialLedger.Sessions.ContainsKey(journal.SessionID); + var transferHoldsSession = initialLedger.PendingTransfer?.SessionIDs.Contains(journal.SessionID) == true; + var isLaunching = false; + lock (_stateSync) isLaunching = _launchingSessions.Contains(journal.SessionID); + var helperRunning = journal.Helper is { } helper && _processProbe.IsRunning(helper); + if (!sessionExists) + { + if (!helperRunning && !isLaunching) + { + var finalized = await Task.Run(() => TryUpdateJournalUnderLock(path, journal.SessionID, current => + { + if (current.Helper is { } helper && _processProbe.IsRunning(helper)) return false; + current.IsFinished = true; + return true; + }), cancellationToken).ConfigureAwait(false); + if (finalized is not null) DeleteJournalFiles(journal.SessionID); + } + return true; + } + + var gameIdentity = journal.Process is { } record + ? new PlaytimeProcessIdentity(record.Pid, record.StartSeconds, record.StartMicroseconds) + : null; + var gameRunning = gameIdentity is not null && _processProbe.IsRunning(gameIdentity); + + if (!journal.IsFinished && !transferHoldsSession && !helperRunning && !isLaunching) + { + var finalizedOrCheckpointed = await Task.Run(() => TryUpdateJournalUnderLock(path, journal.SessionID, current => + { + var currentGame = current.Process is { } currentProcess + ? new PlaytimeProcessIdentity(currentProcess.Pid, currentProcess.StartSeconds, currentProcess.StartMicroseconds) + : null; + var currentGameRunning = currentGame is not null && _processProbe.IsRunning(currentGame); + var currentHelperRunning = current.Helper is { } currentHelper && _processProbe.IsRunning(currentHelper); + if (currentHelperRunning) return false; + current.Helper = null; + if (currentGameRunning) + { + current.ElapsedSeconds = Math.Max(current.ElapsedSeconds, + current.BaseElapsedSeconds + PlaytimeHelperHost.Elapsed(current.StartedUnbiasedTicks, _clock.GetUnbiasedTicks())); + return true; + } + + current.IsFinished = true; + return true; + }), cancellationToken).ConfigureAwait(false); + if (finalizedOrCheckpointed is { } updated) + { + journal = updated; + gameRunning = updated.Process is { } updatedProcess + && _processProbe.IsRunning(new PlaytimeProcessIdentity(updatedProcess.Pid, updatedProcess.StartSeconds, updatedProcess.StartMicroseconds)); + if (gameRunning) await TryStartHelperAsync(path, cancellationToken).ConfigureAwait(false); + } + } + + var elapsed = journal.ElapsedSeconds; + var isFinished = journal.IsFinished; + var credited = await _ledger.MutateAsync(ledger => + { + if (!ledger.Sessions.TryGetValue(journal.SessionID, out var session)) return false; + if (ledger.PendingTransfer?.SessionIDs.Contains(journal.SessionID) == true) return false; + Credit(ledger, session, elapsed); + if (isFinished) ledger.Sessions.Remove(journal.SessionID); + return true; + }, cancellationToken).ConfigureAwait(false); + + // A pending transfer pins the ledger owner and must retain the final checkpoint until credit commits. + if (isFinished && credited) + { + lock (_stateSync) _activeRecords.Remove(journal.SessionID); + await Task.Run(() => DeleteFinishedJournal(path, journal.SessionID), cancellationToken).ConfigureAwait(false); + } + + return true; + } + + private PlaytimeSessionJournal? TryUpdateJournalUnderLock(string path, Guid sessionID, Func update) + { + var lockPath = Path.ChangeExtension(path, ".lock"); + using var journalLock = PlaytimeJournalLock.TryAcquire(lockPath); + if (journalLock is null || !File.Exists(path)) return null; + var current = PlaytimeJson.ReadJournal(path); + if (!update(current)) return null; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(current)); + return current; + } + + private PlaytimeSessionJournal? UpdateJournal(string path, Guid sessionID, Func update) + { + using var journalLock = PlaytimeJournalLock.TryAcquire(Path.ChangeExtension(path, ".lock")); + if (journalLock is null) throw new IOException("The playtime session journal is locked by another observer."); + var journal = File.Exists(path) ? PlaytimeJson.ReadJournal(path) : null; + if (journal is null || journal.SessionID != sessionID || !update(journal)) return journal; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + return journal; + } + + private async Task TryStartHelperAsync(string journalPath, CancellationToken cancellationToken) + { + if (_helperLauncher is null) return; + try + { + var journal = await Task.Run(() => PlaytimeJson.ReadJournal(journalPath), cancellationToken).ConfigureAwait(false); + if (journal.Helper is { } helper && _processProbe.IsRunning(helper)) + { + ClearErrorIf("PlaytimeHelperStartFailed"); + lock (_stateSync) _helperNextAttempt.Remove(journal.SessionID); + return; + } + + lock (_stateSync) + { + if (_helperNextAttempt.TryGetValue(journal.SessionID, out var nextAttempt) + && _timeProvider.GetUtcNow() < nextAttempt) return; + } + + var started = await _helperLauncher.TryStartAsync(journalPath, cancellationToken).ConfigureAwait(false); + lock (_stateSync) _helperNextAttempt[journal.SessionID] = _timeProvider.GetUtcNow() + CheckpointInterval; + if (!started) + SetError("PlaytimeHelperStartFailed", null); + else + ClearErrorIf("PlaytimeHelperStartFailed"); + } + catch (OperationCanceledException) { throw; } + catch (Exception exception) { SetError("PlaytimeHelperStartFailed", exception.Message); } + } + + private async Task RefreshSnapshotAsync(CancellationToken cancellationToken) + { + var ledger = await _ledger.ReadAsync(cancellationToken).ConfigureAwait(false); + var next = ledger.Owners.ToDictionary( + pair => pair.Key, + pair => new PlaytimeSnapshot(pair.Value.TotalSeconds, new Dictionary(pair.Value.Instances)), + StringComparer.Ordinal); + bool changed; + lock (_stateSync) + { + changed = !SnapshotsEqual(_owners, next); + if (changed) + { + _owners = next; + _revision++; + } + } + + if (changed) OnPropertyChanged(nameof(Revision)); + } + + private void ClearErrorIf(string key) + { + bool changed; + lock (_stateSync) + { + changed = _errorResourceKey == key; + if (changed) + { + _errorResourceKey = null; + _errorDetail = null; + } + } + + if (changed) OnPropertyChanged(nameof(ErrorResourceKey)); + } + + private PlaytimeSnapshot GetSnapshot(string ownerKey) + { + lock (_stateSync) return _owners.GetValueOrDefault(ownerKey) ?? new PlaytimeSnapshot(0, new Dictionary()); + } + + private void SetError(string? key, string? detail) + { + bool changed; + lock (_stateSync) + { + changed = _errorResourceKey != key || _errorDetail != detail; + _errorResourceKey = key; + _errorDetail = detail; + } + + if (changed) OnPropertyChanged(nameof(ErrorResourceKey)); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + var handlers = PropertyChanged; + if (handlers is null) return; + foreach (PropertyChangedEventHandler handler in handlers.GetInvocationList()) + { + try { handler(this, new PropertyChangedEventArgs(propertyName)); } + catch (Exception) { } + } + } + + private static bool SnapshotsEqual(Dictionary first, Dictionary second) + { + if (first.Count != second.Count) return false; + foreach (var (key, value) in first) + { + if (!second.TryGetValue(key, out var other) || value.TotalSeconds != other.TotalSeconds + || value.Instances.Count != other.Instances.Count + || value.Instances.Any(pair => other.Instances.GetValueOrDefault(pair.Key, double.NaN) != pair.Value)) return false; + } + + return true; + } + + private static string OwnerKey(string? xuid) => xuid is null ? "guest" : "account:" + xuid; + + private static bool ApplyPendingTransfer(PlaytimeLedger ledger, Guid transferID) + { + if (ledger.AppliedTransfers.Contains(transferID)) + { + if (ledger.PendingTransfer?.ID == transferID) ledger.PendingTransfer = null; + return true; + } + + var pending = ledger.PendingTransfer; + if (pending is null || pending.ID != transferID) return false; + var targetKey = OwnerKey(pending.TargetXuid); + var guest = ledger.Owners.GetValueOrDefault("guest") ?? new PlaytimeOwnerRecord(); + var target = ledger.Owners.GetValueOrDefault(targetKey) ?? new PlaytimeOwnerRecord(); + target.TotalSeconds += pending.GuestSecondsAtBoundary; + guest.TotalSeconds = Math.Max(0, guest.TotalSeconds - pending.GuestSecondsAtBoundary); + foreach (var (instanceID, seconds) in pending.PerInstanceAtBoundary) + { + target.Instances[instanceID] = target.Instances.GetValueOrDefault(instanceID) + seconds; + guest.Instances[instanceID] = Math.Max(0, guest.Instances.GetValueOrDefault(instanceID) - seconds); + if (guest.Instances[instanceID] == 0) guest.Instances.Remove(instanceID); + } + + foreach (var sessionID in pending.SessionIDs) + { + if (ledger.Sessions.TryGetValue(sessionID, out var session) && session.OwnerKey == "guest") + session.OwnerKey = targetKey; + } + + ledger.Owners["guest"] = guest; + ledger.Owners[targetKey] = target; + ledger.AppliedTransfers.Add(transferID); + ledger.PendingTransfer = null; + return true; + } + + private async Task PrepareTransferAtBoundaryAsync(string targetXuid, CancellationToken cancellationToken) + { + await _reconcileGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var initial = await _ledger.ReadAsync(cancellationToken).ConfigureAwait(false); + if (initial.PendingTransfer is { } existing) + { + if (existing.TargetXuid == targetXuid) return existing.ID; + throw new IOException("Another guest playtime transfer is pending."); + } + + var guestSessions = initial.Sessions.Values + .Where(session => session.OwnerKey == "guest") + .ToArray(); + var boundaries = new Dictionary(); + var launching = new HashSet(); + lock (_stateSync) launching.UnionWith(_launchingSessions); + + foreach (var session in guestSessions) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = PlaytimePaths.Journal(_gameRoot, session.SessionID); + if (!File.Exists(path)) + { + boundaries[session.SessionID] = (session.CreditedSeconds, launching.Contains(session.SessionID)); + continue; + } + + var journal = await Task.Run(() => PlaytimeJson.ReadJournal(path), cancellationToken).ConfigureAwait(false); + if (journal.SessionID != session.SessionID || journal.InstanceID != session.InstanceID) + throw new JsonException("The playtime session journal does not match its ledger entry."); + + var active = launching.Contains(session.SessionID); + if (journal.Process is { } process) + { + var identity = new PlaytimeProcessIdentity(process.Pid, process.StartSeconds, process.StartMicroseconds); + active = _processProbe.IsRunning(identity); + } + var elapsed = journal.ElapsedSeconds; + if (active && !journal.IsFinished && journal.Process is not null) + elapsed = Math.Max(elapsed, + journal.BaseElapsedSeconds + PlaytimeHelperHost.Elapsed(journal.StartedUnbiasedTicks, _clock.GetUnbiasedTicks())); + else if (active && !journal.IsFinished) + elapsed = Math.Max(elapsed, session.CreditedSeconds); + // The helper may hold the journal lock for the lifetime of the game. Its atomic checkpoint + // can be read without that lock; this snapshot and uptime define the transfer boundary. + // A dead game is not a final checkpoint: an observer may still be publishing its last interval. + // Pin unfinished sessions too; after apply, their remaining delta belongs to the target owner. + boundaries[session.SessionID] = (elapsed, !journal.IsFinished); + } + + return await _ledger.MutateAsync(ledger => + { + if (ledger.PendingTransfer is { } pending) + { + if (pending.TargetXuid == targetXuid) return pending.ID; + throw new IOException("Another guest playtime transfer is pending."); + } + + foreach (var (sessionID, boundary) in boundaries) + { + if (!ledger.Sessions.TryGetValue(sessionID, out var session) || session.OwnerKey != "guest") continue; + Credit(ledger, session, boundary.Elapsed); + if (!boundary.Retain) ledger.Sessions.Remove(sessionID); + } + + var guest = ledger.Owners.GetValueOrDefault("guest") ?? new PlaytimeOwnerRecord(); + var sessionIDs = boundaries.Where(pair => pair.Value.Retain + && ledger.Sessions.TryGetValue(pair.Key, out var session) && session.OwnerKey == "guest") + .Select(pair => pair.Key).ToArray(); + if (guest.TotalSeconds <= 0 && guest.Instances.Count == 0 && sessionIDs.Length == 0) return Guid.Empty; + + var transfer = new PlaytimePendingTransfer + { + ID = Guid.NewGuid(), + TargetXuid = targetXuid, + GuestSecondsAtBoundary = guest.TotalSeconds, + PerInstanceAtBoundary = new Dictionary(guest.Instances), + SessionIDs = sessionIDs + }; + ledger.PendingTransfer = transfer; + return transfer.ID; + }, cancellationToken).ConfigureAwait(false); + } + finally + { + _reconcileGate.Release(); + } + } + + private async Task RemoveOrphanSessionsAsync(IReadOnlyCollection journalPaths, CancellationToken cancellationToken) + { + var journalIDs = journalPaths.Select(path => Guid.TryParseExact(Path.GetFileNameWithoutExtension(path), "D", out var id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty).ToHashSet(); + var ledger = await _ledger.ReadAsync(cancellationToken).ConfigureAwait(false); + var active = new HashSet(); + lock (_stateSync) active.UnionWith(_launchingSessions); + lock (_stateSync) active.UnionWith(_activeRecords.Keys); + var pending = ledger.PendingTransfer?.SessionIDs.ToHashSet() ?? []; + var orphans = ledger.Sessions.Keys.Where(id => !journalIDs.Contains(id) && !active.Contains(id) && !pending.Contains(id)).ToHashSet(); + if (orphans.Count == 0) return; + await _ledger.MutateAsync(current => + { + foreach (var id in orphans) current.Sessions.Remove(id); + return true; + }, cancellationToken).ConfigureAwait(false); + } + + private static bool SameProcess(GameProcessRecord first, GameProcessRecord second) => + first.InstanceID == second.InstanceID && first.Pid == second.Pid + && first.StartSeconds == second.StartSeconds && first.StartMicroseconds == second.StartMicroseconds; + + private static void Credit(PlaytimeLedger ledger, PlaytimeSessionRecord session, double elapsedSeconds) + { + if (!PlaytimeJson.IsFiniteNonnegative(elapsedSeconds)) return; + var delta = elapsedSeconds - session.CreditedSeconds; + if (delta <= 0) return; + var owner = ledger.Owners.GetValueOrDefault(session.OwnerKey) ?? new PlaytimeOwnerRecord(); + owner.TotalSeconds += delta; + owner.Instances[session.InstanceID] = owner.Instances.GetValueOrDefault(session.InstanceID) + delta; + session.CreditedSeconds = elapsedSeconds; + ledger.Owners[session.OwnerKey] = owner; + } + + private void DeleteJournalFiles(Guid sessionID) => DeleteFinishedJournal( + PlaytimePaths.Journal(_gameRoot, sessionID), sessionID); + + private void DeleteFinishedJournal(string path, Guid sessionID) + { + try { File.Delete(path); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + var lockPath = PlaytimePaths.JournalLock(_gameRoot, sessionID); + try { File.Delete(lockPath); } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/windows/Hako.Core/Playtime/PlaytimeFormatter.cs b/windows/Hako.Core/Playtime/PlaytimeFormatter.cs new file mode 100644 index 0000000..f6e0ac6 --- /dev/null +++ b/windows/Hako.Core/Playtime/PlaytimeFormatter.cs @@ -0,0 +1,39 @@ +using System.Globalization; + +namespace Hako.Core.Playtime; + +public static class PlaytimeFormatter +{ + public static string Format(double seconds, AppLanguage language, Func localize) + { + ArgumentNullException.ThrowIfNull(localize); + if (!double.IsFinite(seconds) || seconds <= 0) return localize("PlaytimeZero"); + var minutes = (long)Math.Floor(seconds / 60); + if (minutes == 0) return localize("PlaytimeLessThanMinute"); + if (minutes < 60) return Unit(minutes, "Minute", language, localize); + + var hours = minutes / 60; + var remainingMinutes = minutes % 60; + if (hours < 24) + { + return remainingMinutes == 0 + ? Unit(hours, "Hour", language, localize) + : Unit(hours, "Hour", language, localize) + " " + Unit(remainingMinutes, "Minute", language, localize); + } + + var days = hours / 24; + var remainingHours = hours % 24; + return remainingHours == 0 + ? Unit(days, "Day", language, localize) + : Unit(days, "Day", language, localize) + " " + Unit(remainingHours, "Hour", language, localize); + } + + private static string Unit(long value, string unit, AppLanguage language, Func localize) + { + var category = language == AppLanguage.Russian + ? value % 10 == 1 && value % 100 != 11 ? "One" + : value % 10 is >= 2 and <= 4 && value % 100 is not (>= 12 and <= 14) ? "Few" : "Many" + : value == 1 ? "One" : "Other"; + return string.Format(CultureInfo.InvariantCulture, localize("Playtime" + unit + category), value); + } +} diff --git a/windows/Hako.Core/Playtime/PlaytimeModels.cs b/windows/Hako.Core/Playtime/PlaytimeModels.cs new file mode 100644 index 0000000..5b92093 --- /dev/null +++ b/windows/Hako.Core/Playtime/PlaytimeModels.cs @@ -0,0 +1,52 @@ +using Hako.Core.Minecraft; + +namespace Hako.Core.Playtime; + +public sealed class PlaytimeLedger +{ + public int FormatVersion { get; set; } = 1; + public Dictionary Owners { get; set; } = new(StringComparer.Ordinal); + public Dictionary Sessions { get; set; } = []; + public PlaytimePendingTransfer? PendingTransfer { get; set; } + public HashSet AppliedTransfers { get; set; } = []; +} + +public sealed class PlaytimeOwnerRecord +{ + public double TotalSeconds { get; set; } + public Dictionary Instances { get; set; } = []; +} + +public sealed class PlaytimeSessionRecord +{ + public Guid SessionID { get; set; } + public Guid InstanceID { get; set; } + public string OwnerKey { get; set; } = "guest"; + public double CreditedSeconds { get; set; } +} + +public sealed class PlaytimePendingTransfer +{ + public Guid ID { get; set; } + public string TargetXuid { get; set; } = string.Empty; + public double GuestSecondsAtBoundary { get; set; } + public Dictionary PerInstanceAtBoundary { get; set; } = []; + public Guid[] SessionIDs { get; set; } = []; +} + +public sealed class PlaytimeSessionJournal +{ + public Guid SessionID { get; set; } + public Guid InstanceID { get; set; } + public string OwnerKey { get; set; } = "guest"; + public GameProcessRecord? Process { get; set; } + public PlaytimeProcessIdentity? Helper { get; set; } + public ulong StartedUnbiasedTicks { get; set; } + public double BaseElapsedSeconds { get; set; } + public double ElapsedSeconds { get; set; } + public bool IsFinished { get; set; } +} + +public sealed record PlaytimeProcessIdentity(int Pid, ulong StartSeconds, ulong StartMicroseconds); + +public sealed record PlaytimeSnapshot(double TotalSeconds, IReadOnlyDictionary Instances); diff --git a/windows/Hako.Core/Playtime/PlaytimeStorage.cs b/windows/Hako.Core/Playtime/PlaytimeStorage.cs new file mode 100644 index 0000000..c1abe91 --- /dev/null +++ b/windows/Hako.Core/Playtime/PlaytimeStorage.cs @@ -0,0 +1,303 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Hako.Core.Minecraft; + +namespace Hako.Core.Playtime; + +public static class PlaytimePaths +{ + public static string Root(string gameRoot) => Path.Combine(Path.GetFullPath(gameRoot), ".hako-playtime"); + public static string Ledger(string gameRoot) => Path.Combine(Root(gameRoot), "playtime.json"); + public static string JournalDirectory(string gameRoot) => Path.Combine(Root(gameRoot), "sessions"); + public static string Journal(string gameRoot, Guid sessionID) => Path.Combine(JournalDirectory(gameRoot), $"{sessionID:D}.json"); + public static string JournalLock(string gameRoot, Guid sessionID) => Path.Combine(JournalDirectory(gameRoot), $"{sessionID:D}.lock"); + + public static bool TryValidateHelperPath(string journalPath, out string fullPath, out Guid sessionID) + { + fullPath = string.Empty; + sessionID = Guid.Empty; + try + { + if (!Path.IsPathFullyQualified(journalPath)) return false; + fullPath = Path.GetFullPath(journalPath); + if (!string.Equals(Path.GetExtension(fullPath), ".json", StringComparison.OrdinalIgnoreCase) + || !Guid.TryParseExact(Path.GetFileNameWithoutExtension(fullPath), "D", out sessionID)) return false; + + var sessions = Path.GetDirectoryName(fullPath); + var playtime = sessions is null ? null : Path.GetDirectoryName(sessions); + if (sessions is null || playtime is null + || !string.Equals(Path.GetFileName(sessions), "sessions", StringComparison.OrdinalIgnoreCase) + || !string.Equals(Path.GetFileName(playtime), ".hako-playtime", StringComparison.OrdinalIgnoreCase) + || !File.Exists(fullPath)) return false; + + return HasNoReparsePoints(fullPath); + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException) + { + return false; + } + } + + internal static bool HasNoReparsePoints(string path) + { + var fullPath = Path.GetFullPath(path); + var volumeRoot = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(volumeRoot)) return false; + var current = volumeRoot; + var relative = fullPath[volumeRoot.Length..]; + var components = relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); + for (var index = 0; index < components.Length; index++) + { + current = Path.Combine(current, components[index]); + var exists = index == components.Length - 1 ? File.Exists(current) || Directory.Exists(current) : Directory.Exists(current); + if (!exists) return false; + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false; + } + + return true; + } + + internal static bool RequireSafePath(string path, bool directory, bool allowMissingLeaf) + { + var parent = Path.GetDirectoryName(Path.GetFullPath(path)); + if (parent is null || !HasNoReparsePoints(parent)) + throw new IOException("The playtime path has a missing or reparse parent."); + FileAttributes attributes; + try { attributes = File.GetAttributes(path); } + catch (FileNotFoundException) when (allowMissingLeaf) { return false; } + catch (DirectoryNotFoundException) when (allowMissingLeaf) { return false; } + if ((attributes & FileAttributes.ReparsePoint) != 0 + || ((attributes & FileAttributes.Directory) != 0) != directory) + throw new IOException("The playtime path has an invalid file type or reparse point."); + return true; + } +} + +internal static class PlaytimeJson +{ + internal const int MaximumJournalBytes = 64 * 1024; + internal const int MaximumLedgerBytes = 1024 * 1024; + + internal static PlaytimeSessionJournal ReadJournal(string path) + { + var journal = JsonSerializer.Deserialize(ReadBoundedBytes(path, MaximumJournalBytes), HakoJsonContext.Default.PlaytimeSessionJournal) + ?? throw new JsonException("The playtime journal is empty."); + if (journal.SessionID == Guid.Empty || journal.InstanceID == Guid.Empty + || journal.SessionID != Guid.Parse(Path.GetFileNameWithoutExtension(path)) + || !IsOwnerKey(journal.OwnerKey) || !IsFiniteNonnegative(journal.ElapsedSeconds) + || !IsFiniteNonnegative(journal.BaseElapsedSeconds) + || (journal.Process is { } process && (process.SessionID != journal.SessionID + || process.InstanceID != journal.InstanceID || process.Pid <= 0 + || process.StartSeconds == 0 || process.StartMicroseconds >= 1_000_000)) + || (journal.Helper is { } helper && (helper.Pid <= 0 || helper.StartSeconds == 0 || helper.StartMicroseconds >= 1_000_000))) + { + throw new JsonException("The playtime journal is invalid."); + } + + return journal; + } + + internal static bool IsOwnerKey(string? ownerKey) => ownerKey == "guest" + || (ownerKey is { Length: > 8 and <= 264 } + && ownerKey.StartsWith("account:", StringComparison.Ordinal) + && !ownerKey.AsSpan(8).ContainsAny('\0', '\r', '\n')); + + internal static bool IsFiniteNonnegative(double value) => double.IsFinite(value) && value >= 0; + + internal static byte[] SerializeJournal(PlaytimeSessionJournal journal) => + JsonSerializer.SerializeToUtf8Bytes(journal, HakoJsonContext.Default.PlaytimeSessionJournal); + + internal static byte[] ReadBoundedBytes(string path, int maximumBytes) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (stream.Length is <= 0 || stream.Length > maximumBytes) + throw new JsonException("The playtime data size is invalid."); + var bytes = new byte[(int)stream.Length]; + var offset = 0; + while (offset < bytes.Length) + { + var read = stream.Read(bytes, offset, bytes.Length - offset); + if (read == 0) throw new JsonException("The playtime data ended unexpectedly."); + offset += read; + } + + if (stream.ReadByte() != -1) throw new JsonException("The playtime data exceeds its size limit."); + return bytes; + } + + internal static void AtomicWrite(string path, ReadOnlySpan bytes) + { + PlaytimePaths.RequireSafePath(path, directory: false, allowMissingLeaf: true); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + PlaytimePaths.RequireSafePath(temporary, directory: false, allowMissingLeaf: true); + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + PlaytimePaths.RequireSafePath(path, directory: false, allowMissingLeaf: true); + File.Move(temporary, path, overwrite: true); + } + finally + { + try { File.Delete(temporary); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } +} + +internal sealed class PlaytimeJournalLock : IDisposable +{ + private readonly FileStream _stream; + private PlaytimeJournalLock(FileStream stream) => _stream = stream; + + internal static PlaytimeJournalLock? TryAcquire(string path) + { + var parent = Path.GetDirectoryName(path) ?? throw new IOException("A containing directory is required."); + Directory.CreateDirectory(parent); + if (!PlaytimePaths.HasNoReparsePoints(parent)) throw new IOException("The playtime journal directory contains a reparse point."); + if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new IOException("The playtime journal lock is a reparse point."); + try + { + return new PlaytimeJournalLock(new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.WriteThrough)); + } + catch (IOException exception) when ((exception.HResult & 0xffff) is 32 or 33) + { + return null; + } + } + + public void Dispose() => _stream.Dispose(); +} + +internal sealed class PlaytimeLedgerStore +{ + private static readonly System.Collections.Concurrent.ConcurrentDictionary Gates = new(StringComparer.OrdinalIgnoreCase); + private readonly string _directory; + private readonly string _gameRoot; + private readonly string _path; + private readonly string _lockPath; + private readonly SemaphoreSlim _gate; + + internal PlaytimeLedgerStore(string gameRoot) + { + _gameRoot = Path.GetFullPath(gameRoot); + _directory = PlaytimePaths.Root(gameRoot); + _path = Path.Combine(_directory, "playtime.json"); + _lockPath = Path.Combine(_directory, "playtime.lock"); + _gate = Gates.GetOrAdd(_path, static _ => new SemaphoreSlim(1, 1)); + } + + internal async Task MutateAsync(Func update, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await Task.Run(() => + { + ValidatePaths(); + Directory.CreateDirectory(_directory); + ValidatePaths(); + }, cancellationToken).ConfigureAwait(false); + FileStream ledgerLock; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidatePaths(); + try + { + ledgerLock = new FileStream(_lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1, FileOptions.WriteThrough); + break; + } + catch (IOException exception) when ((exception.HResult & 0xffff) is 32 or 33) + { + await Task.Delay(TimeSpan.FromMilliseconds(20), cancellationToken).ConfigureAwait(false); + } + } + + using (ledgerLock) + { + return await Task.Run(() => + { + var ledger = ReadCore(); + var result = update(ledger); + WriteCore(ledger); + return result; + }, cancellationToken).ConfigureAwait(false); + } + } + finally + { + _gate.Release(); + } + } + + internal async Task ReadAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await Task.Run(ReadCore, cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private PlaytimeLedger ReadCore() + { + ValidatePaths(); + if (!File.Exists(_path)) return new PlaytimeLedger(); + var ledger = JsonSerializer.Deserialize(PlaytimeJson.ReadBoundedBytes(_path, PlaytimeJson.MaximumLedgerBytes), HakoJsonContext.Default.PlaytimeLedger) + ?? throw new JsonException("The playtime ledger is empty."); + if (ledger.FormatVersion != 1 || ledger.Owners is null || ledger.Sessions is null || ledger.AppliedTransfers is null) + throw new JsonException("The playtime ledger format is unsupported."); + foreach (var (key, owner) in ledger.Owners) + { + if (!PlaytimeJson.IsOwnerKey(key) || owner is null || owner.Instances is null + || !PlaytimeJson.IsFiniteNonnegative(owner.TotalSeconds) + || owner.Instances.Any(pair => pair.Key == Guid.Empty || !PlaytimeJson.IsFiniteNonnegative(pair.Value))) + throw new JsonException("The playtime owner data is invalid."); + } + + foreach (var (id, session) in ledger.Sessions) + { + if (id == Guid.Empty || session is null || session.SessionID != id || session.InstanceID == Guid.Empty + || !PlaytimeJson.IsOwnerKey(session.OwnerKey) || !PlaytimeJson.IsFiniteNonnegative(session.CreditedSeconds)) + throw new JsonException("The playtime session data is invalid."); + } + + if (ledger.AppliedTransfers.Contains(Guid.Empty) + || (ledger.PendingTransfer is { } pending + && (pending.ID == Guid.Empty || !PlaytimeJson.IsOwnerKey("account:" + pending.TargetXuid) + || !PlaytimeJson.IsFiniteNonnegative(pending.GuestSecondsAtBoundary) + || pending.PerInstanceAtBoundary is null + || pending.PerInstanceAtBoundary.Any(pair => pair.Key == Guid.Empty || !PlaytimeJson.IsFiniteNonnegative(pair.Value)) + || pending.SessionIDs is null || pending.SessionIDs.Any(id => id == Guid.Empty) + || pending.SessionIDs.Distinct().Count() != pending.SessionIDs.Length))) + throw new JsonException("The playtime transfer data is invalid."); + + return ledger; + } + + private void ValidatePaths() + { + PlaytimePaths.RequireSafePath(_gameRoot, directory: true, allowMissingLeaf: false); + if (!PlaytimePaths.RequireSafePath(_directory, directory: true, allowMissingLeaf: true)) return; + PlaytimePaths.RequireSafePath(_path, directory: false, allowMissingLeaf: true); + PlaytimePaths.RequireSafePath(_lockPath, directory: false, allowMissingLeaf: true); + } + + private void WriteCore(PlaytimeLedger ledger) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(ledger, HakoJsonContext.Default.PlaytimeLedger); + if (bytes.Length > PlaytimeJson.MaximumLedgerBytes) throw new IOException("The playtime ledger is too large."); + PlaytimeJson.AtomicWrite(_path, bytes); + } +} diff --git a/windows/Hako.Core/Properties/AssemblyInfo.cs b/windows/Hako.Core/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d0cb569 --- /dev/null +++ b/windows/Hako.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Hako.Tests")] +[assembly: InternalsVisibleTo("Hako")] diff --git a/windows/Hako.Core/RecycleBin.cs b/windows/Hako.Core/RecycleBin.cs new file mode 100644 index 0000000..a85de49 --- /dev/null +++ b/windows/Hako.Core/RecycleBin.cs @@ -0,0 +1,411 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Runtime.Versioning; +using Hako.Core.Instances; + +namespace Hako.Core; + +public enum RecycleBinError +{ + OperationFailed +} + +public sealed class RecycleBinException : IOException +{ + public RecycleBinException(string message, Exception? innerException = null) + : base(message, innerException) => Error = RecycleBinError.OperationFailed; + + public RecycleBinError Error { get; } +} + +public enum RecycleBinItemStatus +{ + Recycled, + NotRecyclable, + Failed, + Cancelled +} + +public sealed record RecycleBinItemResult(string Path, RecycleBinItemStatus Status, string? Error = null); +public sealed record RecycleBinResult(IReadOnlyList Items); + +[SupportedOSPlatform("windows")] +public sealed class RecycleBin +{ + private readonly IRecycleBinOperation _operation; + + public RecycleBin() : this(new ShellRecycleBinOperation()) { } + + internal RecycleBin(IRecycleBinOperation operation) => _operation = operation; + + public Task DeleteAsync(string path, CancellationToken cancellationToken = default) => + DeleteAsync([path], cancellationToken); + + public async Task DeleteAsync(IEnumerable paths, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(paths); + var results = new List(); + foreach (var path in paths) + { + var fullPath = path ?? string.Empty; + try + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + fullPath = Path.GetFullPath(path); + if (cancellationToken.IsCancellationRequested) + { + results.Add(new RecycleBinItemResult(fullPath, RecycleBinItemStatus.Cancelled)); + continue; + } + + var status = await _operation.DeleteAsync(fullPath, cancellationToken); + results.Add(new RecycleBinItemResult(fullPath, status)); + } + catch (OperationCanceledException exception) + { + results.Add(new RecycleBinItemResult(fullPath, RecycleBinItemStatus.Cancelled, exception.Message)); + } + catch (Exception exception) + { + results.Add(new RecycleBinItemResult(fullPath, RecycleBinItemStatus.Failed, exception.Message)); + } + } + + return new RecycleBinResult(results); + } + + public void PermanentDelete(string path, string allowedRoot) => PermanentDelete([path], allowedRoot); + + public void PermanentDelete(IEnumerable paths, string allowedRoot) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(allowedRoot); + if (!Path.IsPathFullyQualified(allowedRoot)) + { + throw new ArgumentException("The allowed root must be fully qualified.", nameof(allowedRoot)); + } + + var fullRoot = Path.GetFullPath(allowedRoot); + if (Path.GetDirectoryName(fullRoot) is null) + { + throw new ArgumentException("The allowed root cannot be a volume root.", nameof(allowedRoot)); + } + + try + { + if ((File.GetAttributes(fullRoot) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException("The allowed root cannot be a reparse point.", nameof(allowedRoot)); + } + } + catch (FileNotFoundException) + { + } + catch (DirectoryNotFoundException) + { + } + + foreach (var path in paths) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + var fullPath = ValidatePermanentDeletePath(path, fullRoot); + InstanceStorage.DeleteEntryWithoutFollowingReparsePoints(fullPath); + } + } + + private static string ValidatePermanentDeletePath(string path, string allowedRoot) + { + if (!Path.IsPathFullyQualified(path)) + { + throw new ArgumentException("A fully qualified path is required.", nameof(path)); + } + + var fullPath = Path.GetFullPath(path); + if (Path.GetDirectoryName(fullPath) is null) + { + throw new ArgumentException("A volume root cannot be deleted.", nameof(path)); + } + + var relative = Path.GetRelativePath(allowedRoot, fullPath); + if (relative == "." || relative == ".." + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || Path.IsPathRooted(relative)) + { + throw new ArgumentException("The path must be inside the allowed root.", nameof(path)); + } + + var parts = relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); + var current = allowedRoot; + for (var index = 0; index < parts.Length - 1; index++) + { + current = Path.Combine(current, parts[index]); + try + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException("A path cannot pass through a reparse point.", nameof(path)); + } + } + catch (FileNotFoundException) + { + break; + } + catch (DirectoryNotFoundException) + { + break; + } + } + + return fullPath; + } +} + +internal interface IRecycleBinOperation +{ + Task DeleteAsync(string path, CancellationToken cancellationToken); +} + +[SupportedOSPlatform("windows")] +internal sealed partial class ShellRecycleBinOperation : IRecycleBinOperation +{ + public Task DeleteAsync(string path, CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + completion.TrySetResult(DeleteOnSta(path)); + } + catch (Exception exception) + { + completion.TrySetException(exception); + } + }) + { + IsBackground = true, + Name = "Hako Recycle Bin" + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return completion.Task; + } + + private static RecycleBinItemStatus DeleteOnSta(string path) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("The Windows Recycle Bin is only available on Windows."); + } + + var initialized = CoInitializeEx(IntPtr.Zero, 0x2); + if (initialized < 0) + { + Marshal.ThrowExceptionForHR(initialized); + } + + IFileOperation? operation = null; + IShellItem? item = null; + uint cookie = 0; + var advised = false; + try + { + var classId = new Guid("3AD05575-8857-4850-9277-11B85BDB8E09"); + var interfaceId = new Guid("947AAB5F-0A5C-4C13-B4D6-4BF7836FC9F8"); + var createResult = CoCreateInstance(classId, IntPtr.Zero, 0x17, interfaceId, out var createdOperation); + Marshal.ThrowExceptionForHR(createResult); + operation = createdOperation; + + var sink = new RecycleBinProgressSink(); + operation.SetOperationFlags( + 0x00000004u // FOF_SILENT + | 0x00000010u // FOF_NOCONFIRMATION + | 0x00000040u // FOF_ALLOWUNDO + | 0x00000400u // FOF_NOERRORUI + | 0x00080000u // FOFX_RECYCLEONDELETE + | 0x00100000u); // FOFX_EARLYFAILURE + operation.Advise(sink, out cookie); + advised = true; + + try + { + var itemId = new Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"); + // SHCreateItemFromParsingName expects a Shell parsing name, not the Win32 \\?\ namespace. + var shellItemResult = SHCreateItemFromParsingName(path, IntPtr.Zero, itemId, out var createdItem); + Marshal.ThrowExceptionForHR(shellItemResult); + item = createdItem; + operation.DeleteItem(item, IntPtr.Zero); + var operationResult = operation.PerformOperations(); + operation.GetAnyOperationsAborted(out var aborted); + + if (sink.NotRecyclable) + { + return RecycleBinItemStatus.NotRecyclable; + } + + if (operationResult < 0 || aborted || !sink.DeleteSucceeded || !sink.RecycleWasRequested) + { + throw new RecycleBinException("The item may have been deleted. Check whether it still exists."); + } + + return RecycleBinItemStatus.Recycled; + } + finally + { + try + { + if (advised) + { + operation.Unadvise(cookie); + } + } + finally + { + var shellItem = item; + item = null; + ReleaseComObject(shellItem); + } + } + } + finally + { + try + { + var shellItem = item; + item = null; + ReleaseComObject(shellItem); + } + finally + { + try + { + var shellOperation = operation; + operation = null; + ReleaseComObject(shellOperation); + } + finally + { + CoUninitialize(); + } + } + } + } + + private static void ReleaseComObject(object? value) + { + if (value is ComObject com) + { + com.FinalRelease(); + } + } + + [LibraryImport("ole32.dll", EntryPoint = "CoInitializeEx")] + private static partial int CoInitializeEx(IntPtr reserved, uint coInit); + + [LibraryImport("ole32.dll", EntryPoint = "CoUninitialize")] + private static partial void CoUninitialize(); + + [LibraryImport("ole32.dll", EntryPoint = "CoCreateInstance")] + private static partial int CoCreateInstance(in Guid classId, IntPtr outer, uint context, in Guid interfaceId, out IFileOperation operation); + + [LibraryImport("shell32.dll", EntryPoint = "SHCreateItemFromParsingName", StringMarshalling = StringMarshalling.Utf16)] + private static partial int SHCreateItemFromParsingName(string path, IntPtr bindContext, in Guid interfaceId, out IShellItem item); +} + +[GeneratedComInterface] +[Guid("947AAB5F-0A5C-4C13-B4D6-4BF7836FC9F8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal partial interface IFileOperation +{ + void Advise(IFileOperationProgressSink sink, out uint cookie); + void Unadvise(uint cookie); + void SetOperationFlags(uint flags); + void SetProgressMessage([MarshalAs(UnmanagedType.LPWStr)] string message); + void SetProgressDialog(IntPtr dialog); + void SetProperties(IntPtr propertyChangeArray); + void SetOwnerWindow(IntPtr ownerWindow); + void ApplyPropertiesToItem(IShellItem item); + void ApplyPropertiesToItems(IntPtr items); + void RenameItem(IShellItem item, [MarshalAs(UnmanagedType.LPWStr)] string name, IFileOperationProgressSink? sink); + void RenameItems(IntPtr items, [MarshalAs(UnmanagedType.LPWStr)] string name); + void MoveItem(IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name, IFileOperationProgressSink? sink); + void MoveItems(IntPtr items, IShellItem destination); + void CopyItem(IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name, IFileOperationProgressSink? sink); + void CopyItems(IntPtr items, IShellItem destination); + void DeleteItem(IShellItem item, IntPtr sink); + void DeleteItems(IntPtr items); + void NewItem(IShellItem destination, uint attributes, [MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.LPWStr)] string templateName, IFileOperationProgressSink? sink); + [PreserveSig] + int PerformOperations(); + void GetAnyOperationsAborted([MarshalAs(UnmanagedType.Bool)] out bool aborted); +} + +[GeneratedComInterface] +[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal partial interface IShellItem { } + +[GeneratedComInterface] +[Guid("04B0F1A7-9490-44BC-96E1-4296A31252E2")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal partial interface IFileOperationProgressSink +{ + void StartOperations(); + void FinishOperations(int result); + void PreRenameItem(uint flags, IShellItem item, [MarshalAs(UnmanagedType.LPWStr)] string name); + void PostRenameItem(uint flags, IShellItem item, [MarshalAs(UnmanagedType.LPWStr)] string name, int result, IShellItem? newItem); + void PreMoveItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name); + void PostMoveItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name, int result, IShellItem? newItem); + void PreCopyItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name); + void PostCopyItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name, int result, IShellItem? newItem); + [PreserveSig] + int PreDeleteItem(uint flags, IShellItem item); + void PostDeleteItem(uint flags, IShellItem item, int result, IShellItem? newItem); + void PreNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name); + void PostNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.LPWStr)] string templateName, uint attributes, int result, IShellItem? newItem); + void UpdateProgress(uint workTotal, uint workSoFar); + void ResetTimer(); + void PauseTimer(); + void ResumeTimer(); +} + +[GeneratedComClass] +internal partial class RecycleBinProgressSink : IFileOperationProgressSink +{ + internal const uint TransferDeleteRecycleIfPossible = 0x80; + internal const int HResultAbort = unchecked((int)0x80004004); + + internal bool DeleteSucceeded { get; private set; } + internal bool RecycleWasRequested { get; private set; } + internal bool NotRecyclable { get; private set; } + + public void StartOperations() { } + public void FinishOperations(int result) { } + public void PreRenameItem(uint flags, IShellItem item, string name) { } + public void PostRenameItem(uint flags, IShellItem item, string name, int result, IShellItem? newItem) { } + public void PreMoveItem(uint flags, IShellItem item, IShellItem destination, string name) { } + public void PostMoveItem(uint flags, IShellItem item, IShellItem destination, string name, int result, IShellItem? newItem) { } + public void PreCopyItem(uint flags, IShellItem item, IShellItem destination, string name) { } + public void PostCopyItem(uint flags, IShellItem item, IShellItem destination, string name, int result, IShellItem? newItem) { } + + public int PreDeleteItem(uint flags, IShellItem item) + { + if ((flags & TransferDeleteRecycleIfPossible) == 0) + { + NotRecyclable = true; + return HResultAbort; + } + + RecycleWasRequested = true; + return 0; + } + + public void PostDeleteItem(uint flags, IShellItem item, int result, IShellItem? newItem) => DeleteSucceeded = result >= 0 && newItem is not null; + public void PreNewItem(uint flags, IShellItem destination, string name) { } + public void PostNewItem(uint flags, IShellItem destination, string name, string templateName, uint attributes, int result, IShellItem? newItem) { } + public void UpdateProgress(uint workTotal, uint workSoFar) { } + public void ResetTimer() { } + public void PauseTimer() { } + public void ResumeTimer() { } +} diff --git a/windows/Hako.Core/UserSettings.cs b/windows/Hako.Core/UserSettings.cs index 279186e..cb09d7d 100644 --- a/windows/Hako.Core/UserSettings.cs +++ b/windows/Hako.Core/UserSettings.cs @@ -1,3 +1,11 @@ namespace Hako.Core; -public sealed record UserSettings(string? Language); +public sealed record UserSettings( + string? Language, + string? JavaPath = null, + string? JavaArguments = null, + string? MinecraftArguments = null, + bool? Fullscreen = null, + int? WindowWidth = null, + int? WindowHeight = null, + int? MaximumMemoryMiB = null); diff --git a/windows/Hako.Core/UserSettingsService.cs b/windows/Hako.Core/UserSettingsService.cs new file mode 100644 index 0000000..53f3cd5 --- /dev/null +++ b/windows/Hako.Core/UserSettingsService.cs @@ -0,0 +1,212 @@ +using System.Text.Json; + +namespace Hako.Core; + +public sealed class UserSettingsService(IUserSettingsStore store) +{ + private readonly SemaphoreSlim _saveGate = new(1, 1); + private readonly object _sync = new(); + private UserSettings _snapshot = new(null); + private UserSettings _savedSnapshot = new(null); + private bool _loadFailed; + private long _languageSelection; + private long _gameSettingsSelection; + + public UserSettings Current + { + get + { + lock (_sync) + { + return _snapshot; + } + } + } + + public string? SavedLanguage + { + get + { + lock (_sync) + { + return _savedSnapshot.Language; + } + } + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + await _saveGate.WaitAsync(cancellationToken); + try + { + try + { + var loaded = await store.LoadAsync(cancellationToken); + lock (_sync) + { + _snapshot = loaded ?? new UserSettings(null); + _savedSnapshot = _snapshot; + _loadFailed = false; + } + + return loaded; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + lock (_sync) + { + _loadFailed = true; + } + + throw; + } + } + finally + { + _saveGate.Release(); + } + } + + public async Task SetLanguageAsync(AppLanguage language, CancellationToken cancellationToken = default) + { + long selection; + lock (_sync) + { + selection = ++_languageSelection; + _snapshot = _snapshot with { Language = language.ToTag() }; + } + + await SaveSnapshotAsync(cancellationToken, () => + { + lock (_sync) + { + if (selection == _languageSelection) + { + _snapshot = _snapshot with { Language = _savedSnapshot.Language }; + } + } + }); + } + + public async Task SetGameLaunchDefaultsAsync(GameLaunchDefaults defaults, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(defaults); + long selection; + lock (_sync) + { + selection = ++_gameSettingsSelection; + _snapshot = WithGameDefaults(_snapshot, defaults); + } + + await SaveSnapshotAsync(cancellationToken, () => + { + lock (_sync) + { + if (selection == _gameSettingsSelection) + { + _snapshot = WithGameFields(_snapshot, _savedSnapshot); + } + } + }); + } + + public GameLaunchDefaults GetGameLaunchDefaults() => GameLaunchDefaults.From(Current); + + private async Task SaveSnapshotAsync(CancellationToken cancellationToken, Action rollback) + { + var acquired = false; + try + { + await _saveGate.WaitAsync(cancellationToken); + acquired = true; + UserSettings snapshot; + bool loadFailed; + lock (_sync) + { + snapshot = _snapshot; + loadFailed = _loadFailed; + } + + if (loadFailed) + { + UserSettings loaded; + try + { + loaded = await store.LoadAsync(cancellationToken) ?? new UserSettings(null); + } + catch (JsonException) + { + loaded = new UserSettings(null); + } + + lock (_sync) + { + _savedSnapshot = loaded; + _snapshot = MergeNonNullSettings(loaded, _snapshot); + _loadFailed = false; + snapshot = _snapshot; + } + } + + await store.SaveAsync(snapshot, cancellationToken); + lock (_sync) + { + _savedSnapshot = snapshot; + } + } + catch + { + if (!acquired) + { + await _saveGate.WaitAsync(CancellationToken.None); + acquired = true; + } + + rollback(); + throw; + } + finally + { + if (acquired) + { + _saveGate.Release(); + } + } + } + + private static UserSettings WithGameDefaults(UserSettings settings, GameLaunchDefaults defaults) => settings with + { + JavaPath = defaults.JavaPath, + JavaArguments = defaults.JavaArguments, + MinecraftArguments = defaults.MinecraftArguments, + Fullscreen = defaults.Fullscreen, + WindowWidth = defaults.WindowWidth, + WindowHeight = defaults.WindowHeight, + MaximumMemoryMiB = defaults.MaximumMemoryMiB + }; + + private static UserSettings WithGameFields(UserSettings target, UserSettings source) => target with + { + JavaPath = source.JavaPath, + JavaArguments = source.JavaArguments, + MinecraftArguments = source.MinecraftArguments, + Fullscreen = source.Fullscreen, + WindowWidth = source.WindowWidth, + WindowHeight = source.WindowHeight, + MaximumMemoryMiB = source.MaximumMemoryMiB + }; + + private static UserSettings MergeNonNullSettings(UserSettings source, UserSettings changes) => new( + changes.Language ?? source.Language, + changes.JavaPath ?? source.JavaPath, + changes.JavaArguments ?? source.JavaArguments, + changes.MinecraftArguments ?? source.MinecraftArguments, + changes.Fullscreen ?? source.Fullscreen, + changes.WindowWidth ?? source.WindowWidth, + changes.WindowHeight ?? source.WindowHeight, + changes.MaximumMemoryMiB ?? source.MaximumMemoryMiB); +} diff --git a/windows/Hako.Tests/AppCoordinatorTests.cs b/windows/Hako.Tests/AppCoordinatorTests.cs index d6524e4..fed4c3e 100644 --- a/windows/Hako.Tests/AppCoordinatorTests.cs +++ b/windows/Hako.Tests/AppCoordinatorTests.cs @@ -41,6 +41,30 @@ public async Task GuestDoesNotPersistAndRestartReturnsToWelcome() Assert.IsNull(restarted.Account); } + [TestMethod] + public async Task BackFromSignInReturnsToTheSameBuildAndClosingDeletedBuildOpensBuilds() + { + var auth = new SequenceAuthService { HoldTokenWait = true }; + var coordinator = new AppCoordinator(new MemorySettingsStore(), new MemoryAccountStore(), auth); + await coordinator.InitializeAsync(); + coordinator.ContinueWithoutAccount(); + var id = Guid.NewGuid(); + coordinator.OpenInstance(id); + var attempt = coordinator.BeginSignIn(); + await auth.TokenWaitStarted.Task; + + coordinator.BackFromSignIn(); + await attempt; + + Assert.AreEqual(LauncherScreen.Instance, coordinator.Screen); + Assert.AreEqual(id, coordinator.SelectedInstanceId); + coordinator.CloseInstance(Guid.NewGuid()); + Assert.AreEqual(LauncherScreen.Instance, coordinator.Screen); + coordinator.CloseInstance(id); + Assert.AreEqual(LauncherScreen.Builds, coordinator.Screen); + Assert.IsNull(coordinator.SelectedInstanceId); + } + [TestMethod] public async Task BackFromGuestProfileReturnsToSameProfileAndLanguageSwitchKeepsLoginAttempt() { @@ -243,4 +267,51 @@ public async Task FailedLatestLanguageSaveRollsBackToLastSavedLanguage() Assert.AreEqual(AppLanguage.Russian, coordinator.Language); Assert.AreEqual("ru", settings.Settings?.Language); } + + [TestMethod] + public async Task LaterLanguageSaveFailureRollsBackToTheActuallySavedLanguage() + { + var settings = new NthFailingSettingsStore(new UserSettings("ru"), failOnWrite: 2); + var coordinator = new AppCoordinator(settings, new MemoryAccountStore(), new SequenceAuthService()); + await coordinator.InitializeAsync(); + + var english = coordinator.SetLanguageAsync(AppLanguage.English); + await settings.FirstSaveReached.Task; + var russian = coordinator.SetLanguageAsync(AppLanguage.Russian); + settings.ReleaseFirstSave.TrySetResult(); + + await english; + await Assert.ThrowsExactlyAsync(() => russian); + + Assert.AreEqual(AppLanguage.English, coordinator.Language); + Assert.AreEqual("en", settings.Settings?.Language); + } + + private sealed class NthFailingSettingsStore(UserSettings? settings, int failOnWrite) : IUserSettingsStore + { + private int _writeCount; + + public UserSettings? Settings { get; private set; } = settings; + public TaskCompletionSource FirstSaveReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseFirstSave { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.FromResult(Settings); + + public async Task SaveAsync(UserSettings newSettings, CancellationToken cancellationToken = default) + { + var write = Interlocked.Increment(ref _writeCount); + if (write == 1) + { + FirstSaveReached.TrySetResult(); + await ReleaseFirstSave.Task.WaitAsync(cancellationToken); + } + + if (write == failOnWrite) + { + throw new IOException("Injected settings write failure."); + } + + Settings = newSettings; + } + } } diff --git a/windows/Hako.Tests/DisplayPathTests.cs b/windows/Hako.Tests/DisplayPathTests.cs new file mode 100644 index 0000000..59c0fb9 --- /dev/null +++ b/windows/Hako.Tests/DisplayPathTests.cs @@ -0,0 +1,15 @@ +namespace Hako.Tests; + +[TestClass] +public sealed class DisplayPathTests +{ + [TestMethod] + public void ShortensOnlyPathsInsideUserProfile() + { + Assert.AreEqual(@"%USERPROFILE%\.hako\Build", DisplayPath.Format(@"C:\Users\Alex\.hako\Build", @"C:\Users\Alex")); + Assert.AreEqual(@"%USERPROFILE%\.hako\Build", DisplayPath.Format(@"c:\users\ALEX\.hako\Build", @"C:\Users\Alex\")); + Assert.AreEqual(@"C:\Users\Alexander\.hako\Build", DisplayPath.Format(@"C:\Users\Alexander\.hako\Build", @"C:\Users\Alex")); + Assert.AreEqual(@"D:\Temp\hako-preview\Build", DisplayPath.Format(@"D:\Temp\hako-preview\Build", @"C:\Users\Alex")); + Assert.AreEqual(@"C:\Users\Alex\.hako\Build", DisplayPath.Format(@"C:\Users\Alex\.hako\Build", "")); + } +} diff --git a/windows/Hako.Tests/FabricTests.cs b/windows/Hako.Tests/FabricTests.cs new file mode 100644 index 0000000..8502dc7 --- /dev/null +++ b/windows/Hako.Tests/FabricTests.cs @@ -0,0 +1,553 @@ +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class FabricTests +{ + [TestMethod] + public void VersionPredicatesUseAndInsideAlternativesAndSupportRangesWildcardsAndPrereleases() + { + Assert.IsTrue(new FabricVersionPredicate(">=0.16 <0.17").Matches("0.16.9")); + Assert.IsFalse(new FabricVersionPredicate(">=0.16 <0.17").Matches("0.17.0")); + Assert.IsTrue(new FabricVersionPredicate("~1.2.3").Matches("1.2.9")); + Assert.IsFalse(new FabricVersionPredicate("~1.2.3").Matches("1.3.0")); + Assert.IsTrue(new FabricVersionPredicate("^0.18.4").Matches("0.19.5")); + Assert.IsTrue(new FabricVersionPredicate("1.2.x").Matches("1.2.99")); + Assert.IsFalse(new FabricVersionPredicate("1.2.x").Matches("1.3.0")); + Assert.IsFalse(new FabricVersionPredicate(">=1.0.0").Matches("1.0.0-beta.2")); + Assert.IsTrue(new FabricVersionPredicate(["<0.10", ">=0.19"]).Matches("0.19.1")); + Assert.IsFalse(new FabricVersionPredicate(["<0.10", ">=0.19"]).Matches("0.15.0")); + + var fromMacFixture = JsonSerializer.Deserialize( + """{"id":"fabric-api","depends":{"fabricloader":["<0.10",">=0.19"],"java":">=17"}}""", + HakoJsonContext.Default.FabricModMetadata)!; + Assert.IsTrue(fromMacFixture.Supports("0.19.1", 17)); + Assert.IsFalse(fromMacFixture.Supports("0.15.0", 17)); + } + + [TestMethod] + public void WildcardComparisonsHonorInclusiveAndExclusiveBoundaries() + { + var minimum = new FabricVersionPredicate(">=0.16.x"); + Assert.IsFalse(minimum.Matches("0.15.9")); + Assert.IsTrue(minimum.Matches("0.16.0")); + Assert.IsTrue(minimum.Matches("0.16.9")); + Assert.IsTrue(minimum.Matches("0.17.2")); + + Assert.IsFalse(new FabricVersionPredicate(">0.16.x").Matches("0.16.9")); + Assert.IsTrue(new FabricVersionPredicate(">0.16.x").Matches("0.17.0")); + Assert.IsTrue(new FabricVersionPredicate("<=0.16.x").Matches("0.16.9")); + Assert.IsFalse(new FabricVersionPredicate("<=0.16.x").Matches("0.17.0")); + Assert.IsTrue(new FabricVersionPredicate("<0.16.x").Matches("0.15.9")); + Assert.IsFalse(new FabricVersionPredicate("<0.16.x").Matches("0.16.0")); + } + + [TestMethod] + public void LatestApiSelectionPrefersReleaseThenBetaThenAlphaAndUsesNewestWithinChannel() + { + ModrinthVersion Version(string id, string channel, string date) => new() + { + Id = id, + ProjectId = FabricClient.FabricApiProjectId, + VersionNumber = id, + VersionType = channel, + DatePublished = DateTimeOffset.Parse(date), + GameVersions = ["1.20.4"], + Loaders = ["fabric"] + }; + var versions = new[] + { + Version("alpha-new", "alpha", "2025-04-01T00:00:00Z"), + Version("beta-new", "beta", "2025-05-01T00:00:00Z"), + Version("release-old", "release", "2024-01-01T00:00:00Z"), + Version("release-new", "release", "2024-02-01T00:00:00Z"), + Version("other-minecraft", "release", "2026-01-01T00:00:00Z") with { GameVersions = ["1.20.1"] } + }; + + Assert.AreEqual("release-new", FabricClient.SelectLatestApiVersion(versions, "1.20.4").Id); + Assert.AreEqual("beta-new", FabricClient.SelectLatestApiVersion(versions.Where(item => item.VersionType != "release"), "1.20.4").Id); + Assert.AreEqual("alpha-new", FabricClient.SelectLatestApiVersion(versions.Where(item => item.VersionType == "alpha"), "1.20.4").Id); + } + + [TestMethod] + public async Task LateFabricPreparationCannotReplaceTheApiThatWillBeSaved() + { + var firstCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var firstCancellation = new CancellationTokenSource(); + var selectedVersion = "A"; + FabricPreparation? currentPreparation = null; + var firstPreparation = Preparation("api-A"); + var secondPreparation = Preparation("api-B"); + + var first = FabricPreparationCommit.PrepareAndCommitIfCurrentAsync( + () => firstCompletion.Task, + () => !firstCancellation.IsCancellationRequested && selectedVersion == "A", + preparation => currentPreparation = preparation); + + selectedVersion = "B"; + firstCancellation.Cancel(); + var second = FabricPreparationCommit.PrepareAndCommitIfCurrentAsync( + () => secondCompletion.Task, + () => selectedVersion == "B", + preparation => currentPreparation = preparation); + + secondCompletion.SetResult(secondPreparation); + Assert.IsTrue(await second); + firstCompletion.SetResult(firstPreparation); + Assert.IsFalse(await first); + + var saved = new FabricConfiguration("0.16.9", currentPreparation?.Api); + Assert.AreEqual(secondPreparation.Api, saved.Api); + } + + [TestMethod] + public async Task PreparationChecksApiHashAndFiltersLoadersByApiAndJavaRequirements() + { + using var temporary = new TemporaryDirectory(); + var apiJar = CreateApiJar("""{"id":"fabric-api","depends":{"fabricloader":">=0.16 <0.17","java":">=17 <22"}}"""); + var sha1 = Hash1(apiJar); + var sha512 = Hash512(apiJar); + var apiJson = ApiVersionsJson(apiJar, sha1, sha512); + var handler = new CallbackHttpHandler((request, _) => + { + Assert.AreEqual(FabricClient.UserAgent, request.Headers.UserAgent.ToString()); + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/versions/loader/1.20.4", StringComparison.Ordinal)) + return Task.FromResult(JsonResponse("""[{"loader":{"version":"0.16.9","stable":true}},{"loader":{"version":"0.17.2","stable":true}},{"loader":{"version":"0.16.8","stable":false}}]""")); + if (path.EndsWith("/project/P7dR8mSH/version", StringComparison.Ordinal)) + return Task.FromResult(JsonResponse(apiJson)); + if (request.RequestUri.AbsoluteUri == "https://cdn.test/fabric-api.jar") + return Task.FromResult(BytesResponse(apiJar)); + throw new AssertFailedException($"Unexpected Fabric request: {request.RequestUri}"); + }); + using var client = new FabricClient(new HttpClient(handler), cacheRoot: Path.Combine(temporary.Root, "FabricAPI"), + metaBase: new Uri("https://meta.test/v2/"), modrinthBase: new Uri("https://api.test/v2/")); + + var result = await client.PrepareAsync("1.20.4", 17); + + Assert.AreEqual("release", result.Api.Channel); + CollectionAssert.AreEqual(new[] { "0.16.9", "0.16.8" }, result.Loaders.Select(loader => loader.Version).ToArray()); + Assert.IsTrue(File.Exists(Path.Combine(temporary.Root, "FabricAPI", sha512 + ".jar"))); + Assert.AreEqual(3, handler.RequestCount); + } + + [TestMethod] + public async Task LoaderCatalogTreatsBadRequestWithEmptyArrayAsEmptyAndRejectsOtherBodies() + { + using var emptyClient = new FabricClient(new HttpClient(new CallbackHttpHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("[]") }))), + cacheRoot: Path.GetTempPath(), metaBase: new Uri("https://meta.test/v2/")); + + var loaders = await emptyClient.GetLoaderVersionsAsync("1.20.4"); + + Assert.AreEqual(0, loaders.Count); + + using var invalidClient = new FabricClient(new HttpClient(new CallbackHttpHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"error\":\"missing\"}") }))), + cacheRoot: Path.GetTempPath(), metaBase: new Uri("https://meta.test/v2/")); + var exception = await Assert.ThrowsExactlyAsync(() => invalidClient.GetLoaderVersionsAsync("1.20.4")); + Assert.AreEqual(MojangError.Http, exception.Error); + Assert.AreEqual((int)HttpStatusCode.BadRequest, exception.StatusCode); + } + + [TestMethod] + public async Task ProfileMustInheritSelectedMinecraftAndMavenLibrariesUseSha1SidecarWhenNeeded() + { + var libraryBytes = Encoding.UTF8.GetBytes("fabric library"); + var checksum = Hash1(libraryBytes); + var handler = new CallbackHttpHandler((request, _) => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/profile/json", StringComparison.Ordinal)) + return Task.FromResult(JsonResponse("""{"id":"fabric-loader-0.16.9-1.20.4","inheritsFrom":"1.20.4","mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient","libraries":[{"name":"org.example:sample:1.2.3","url":"https://maven.test/","size":14}]}""")); + if (request.RequestUri.AbsoluteUri == "https://maven.test/org/example/sample/1.2.3/sample-1.2.3.jar.sha1") + return Task.FromResult(TextResponse(checksum + "\n")); + throw new AssertFailedException($"Unexpected Fabric request: {request.RequestUri}"); + }); + using var client = new FabricClient(new HttpClient(handler), cacheRoot: Path.GetTempPath(), metaBase: new Uri("https://meta.test/v2/")); + + var (profile, _) = await client.GetProfileAsync("1.20.4", "0.16.9"); + var libraries = await client.ResolveLibrariesAsync(profile); + + Assert.AreEqual("1.20.4", profile.InheritsFrom); + Assert.AreEqual("org/example/sample/1.2.3/sample-1.2.3.jar", libraries.Single().Path); + Assert.AreEqual(checksum, libraries.Single().Download.Sha1); + Assert.AreEqual(2, handler.RequestCount); + + var wrongParent = new FabricProfile { Id = "fabric-loader-0.16.9-1.20.1", InheritsFrom = "1.20.1", MainClass = "Main" }; + Assert.ThrowsExactly(() => wrongParent.Validate("1.20.4", "0.16.9")); + } + + [TestMethod] + public async Task InstallerWritesFabricProfileLibrariesAndProvisionedApiWithPinnedHashes() + { + using var temporary = new TemporaryDirectory(); + var apiJar = CreateApiJar("""{"id":"fabric-api","depends":{"fabricloader":">=0.16 <0.17","java":">=17"}}"""); + var api = Descriptor(apiJar); + var clientBytes = Encoding.UTF8.GetBytes("minecraft client"); + var javaBytes = Encoding.UTF8.GetBytes("java runtime"); + var libraryBytes = Encoding.UTF8.GetBytes("fabric maven lib"); + var profileBytes = JsonSerializer.SerializeToUtf8Bytes(new + { + id = "fabric-loader-0.16.9-1.20.4", + inheritsFrom = "1.20.4", + mainClass = "net.fabricmc.loader.impl.launch.knot.KnotClient", + arguments = new { jvm = new[] { "-Dfabric.loader=true" } }, + libraries = new[] { new { name = "org.example:sample:1.2.3", url = "https://maven.test/", sha1 = Hash1(libraryBytes), size = libraryBytes.Length } } + }); + var fabricHandler = new CallbackHttpHandler((request, _) => request.RequestUri!.AbsoluteUri switch + { + "https://meta.test/v2/versions/loader/1.20.4/0.16.9/profile/json" => Task.FromResult(BytesResponse(profileBytes)), + "https://cdn.test/fabric-api.jar" => Task.FromResult(BytesResponse(apiJar)), + _ => throw new AssertFailedException($"Unexpected Fabric request: {request.RequestUri}") + }); + var mojangHandler = new CallbackHttpHandler((request, _) => request.RequestUri!.AbsoluteUri switch + { + "https://test/client.jar" => Task.FromResult(BytesResponse(clientBytes)), + "https://test/javaw.exe" => Task.FromResult(BytesResponse(javaBytes)), + "https://maven.test/org/example/sample/1.2.3/sample-1.2.3.jar" => Task.FromResult(BytesResponse(libraryBytes)), + _ => throw new AssertFailedException($"Unexpected download: {request.RequestUri}") + }); + using var mojang = new MojangClient(new HttpClient(mojangHandler)); + using var fabric = new FabricClient(new HttpClient(fabricHandler), cacheRoot: Path.Combine(temporary.Root, "FabricAPI"), + metaBase: new Uri("https://meta.test/v2/")); + var prepared = Prepared("1.20.4", clientBytes, javaBytes); + var installer = new MinecraftInstaller(mojang, fabric); + + var result = await installer.InstallFabricAsync(prepared, Path.Combine(temporary.Root, "Build"), + new FabricConfiguration("0.16.9", api)); + var root = Path.Combine(temporary.Root, "Build"); + + Assert.AreEqual(Hash1(profileBytes), result.FabricProfileSHA1); + CollectionAssert.AreEqual(profileBytes, File.ReadAllBytes(Path.Combine(root, "minecraft", ".hako-fabric.json"))); + Assert.AreEqual(Hash1(libraryBytes), Hash1(File.ReadAllBytes(Path.Combine(root, "minecraft", "libraries", "org", "example", "sample", "1.2.3", "sample-1.2.3.jar")))); + CollectionAssert.AreEqual(apiJar, File.ReadAllBytes(Path.Combine(root, "minecraft", "mods", api.Filename))); + Assert.IsTrue(InstanceContent.ApiWasProvisioned(root)); + Assert.AreEqual(3, mojangHandler.RequestCount); + Assert.AreEqual(2, fabricHandler.RequestCount); + + var installedApi = Path.Combine(root, "minecraft", "mods", api.Filename); + var disabledApi = installedApi + ".disabled"; + File.Move(installedApi, disabledApi); + var repeated = await installer.InstallFabricAsync(prepared, root, new FabricConfiguration("0.16.9", api)); + + Assert.AreEqual(Hash1(profileBytes), repeated.FabricProfileSHA1); + Assert.IsFalse(File.Exists(installedApi)); + CollectionAssert.AreEqual(apiJar, File.ReadAllBytes(disabledApi)); + Assert.IsTrue(InstanceContent.ApiWasProvisioned(root)); + Assert.AreEqual(3, fabricHandler.RequestCount); + } + + [TestMethod] + public async Task InstallerCompletesFabricInstallationWithoutOptionalApi() + { + using var temporary = new TemporaryDirectory(); + var clientBytes = Encoding.UTF8.GetBytes("minecraft client"); + var javaBytes = Encoding.UTF8.GetBytes("java runtime"); + var profileBytes = JsonSerializer.SerializeToUtf8Bytes(new + { + id = "fabric-loader-0.16.9-1.20.4", + inheritsFrom = "1.20.4", + mainClass = "net.fabricmc.loader.impl.launch.knot.KnotClient", + libraries = Array.Empty() + }); + var fabricHandler = new CallbackHttpHandler((request, _) => + { + Assert.AreEqual("https://meta.test/v2/versions/loader/1.20.4/0.16.9/profile/json", request.RequestUri!.AbsoluteUri); + return Task.FromResult(BytesResponse(profileBytes)); + }); + var mojangHandler = new CallbackHttpHandler((request, _) => request.RequestUri!.AbsoluteUri switch + { + "https://test/client.jar" => Task.FromResult(BytesResponse(clientBytes)), + "https://test/javaw.exe" => Task.FromResult(BytesResponse(javaBytes)), + _ => throw new AssertFailedException($"Unexpected download: {request.RequestUri}") + }); + using var mojang = new MojangClient(new HttpClient(mojangHandler)); + using var fabric = new FabricClient(new HttpClient(fabricHandler), cacheRoot: Path.Combine(temporary.Root, "FabricAPI"), + metaBase: new Uri("https://meta.test/v2/")); + var root = Path.Combine(temporary.Root, "Build"); + + var result = await new MinecraftInstaller(mojang, fabric).InstallFabricAsync( + Prepared("1.20.4", clientBytes, javaBytes), root, new FabricConfiguration("0.16.9")); + + Assert.AreEqual(Hash1(profileBytes), result.FabricProfileSHA1); + CollectionAssert.AreEqual(profileBytes, File.ReadAllBytes(Path.Combine(root, "minecraft", ".hako-fabric.json"))); + Assert.IsTrue(Directory.Exists(Path.Combine(root, "minecraft", "mods"))); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Root, "FabricAPI"))); + Assert.AreEqual(1, fabricHandler.RequestCount); + Assert.AreEqual(2, mojangHandler.RequestCount); + } + + [TestMethod] + public async Task ProvisionApiDoesNotReinstallAfterUserDeletesIt() + { + using var temporary = new TemporaryDirectory(); + var apiJar = CreateApiJar("""{"id":"fabric-api","depends":{}}"""); + var api = Descriptor(apiJar); + var cache = Path.Combine(temporary.Root, "cache.jar"); + var root = Path.Combine(temporary.Root, "Build"); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + await File.WriteAllBytesAsync(cache, apiJar); + + await InstanceContent.ProvisionAPIAsync(api, cache, root, CancellationToken.None); + var installed = Path.Combine(root, "minecraft", "mods", api.Filename); + Assert.IsTrue(File.Exists(installed)); + File.Delete(installed); + await InstanceContent.ProvisionAPIAsync(api, cache, root, CancellationToken.None); + + Assert.IsTrue(InstanceContent.ApiWasProvisioned(root)); + Assert.IsFalse(File.Exists(installed)); + } + + [TestMethod] + public void MacRegistryFixtureRoundTripsWithSwiftKeysAndOptionalFieldsOmitted() + { + using var temporary = new TemporaryDirectory(); + var minecraft = Path.Combine(temporary.Root, "minecraft"); + Directory.CreateDirectory(minecraft); + var fixture = """{"apiProvisioned":true,"files":{"fabric-api-0.100.jar":{"source":"modrinth","projectID":"P7dR8mSH","versionID":"abc123","pageURL":"https://modrinth.com/mod/fabric-api/version/abc123","sha512":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","api":{"projectID":"P7dR8mSH","versionID":"abc123","version":"0.100","channel":"release","filename":"fabric-api-0.100.jar","url":"https://cdn.test/fabric-api.jar","size":4,"sha1":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","sha512":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"versionNumber":"0.100"}}}"""; + File.WriteAllText(Path.Combine(minecraft, ".hako-mods.json"), fixture); + + var registry = InstanceContent.ReadRegistry(temporary.Root); + Assert.IsTrue(registry.ApiProvisioned); + Assert.AreEqual(ModSource.Modrinth, registry.Files["fabric-api-0.100.jar"].Source); + Assert.IsNull(registry.Files["fabric-api-0.100.jar"].IconURL); + InstanceContent.WriteRegistry(temporary.Root, registry); + + using var document = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(minecraft, ".hako-mods.json"))); + var root = document.RootElement; + Assert.IsTrue(root.TryGetProperty("apiProvisioned", out _)); + var origin = root.GetProperty("files").GetProperty("fabric-api-0.100.jar"); + CollectionAssert.AreEquivalent(new[] { "apiProvisioned", "files" }, root.EnumerateObject().Select(property => property.Name).ToArray()); + Assert.AreEqual("modrinth", origin.GetProperty("source").GetString()); + Assert.IsTrue(origin.TryGetProperty("projectID", out _)); + Assert.IsTrue(origin.TryGetProperty("pageURL", out _)); + Assert.IsFalse(origin.TryGetProperty("iconURL", out _)); + Assert.IsFalse(origin.TryGetProperty("fileType", out _)); + Assert.IsTrue(origin.GetProperty("api").TryGetProperty("sha512", out _)); + } + + [TestMethod] + public async Task RegistryRejectsMissingOrNullRequiredStructureWithoutOverwritingIt() + { + using var temporary = new TemporaryDirectory(); + var minecraft = Path.Combine(temporary.Root, "minecraft"); + Directory.CreateDirectory(minecraft); + var registryPath = Path.Combine(minecraft, ".hako-mods.json"); + const string validOrigin = "{\"source\":\"local\",\"projectID\":\"local\",\"versionID\":\"local\",\"pageURL\":\"https://modrinth.com/\",\"sha512\":\"hash\"}"; + static string Registry(string origin) => $"{{\"apiProvisioned\":false,\"files\":{{\"item.jar\":{origin}}}}}"; + var invalidRegistries = new[] + { + "{\"files\":{}}", + "{\"apiProvisioned\":false,\"files\":null}", + "{\"apiProvisioned\":false,\"files\":{\"item.jar\":null}}", + Registry(validOrigin.Replace(",\"sha512\":\"hash\"", "", StringComparison.Ordinal)), + Registry(validOrigin.Replace("\"projectID\":\"local\"", "\"projectID\":null", StringComparison.Ordinal)), + Registry(validOrigin.Replace("\"source\":\"local\"", "\"source\":null", StringComparison.Ordinal)), + Registry(validOrigin.Replace("\"pageURL\":\"https://modrinth.com/\"", "\"pageURL\":null", StringComparison.Ordinal)), + Registry(validOrigin.Replace("}", ",\"api\":{\"projectID\":null}}", StringComparison.Ordinal)) + }; + var api = Descriptor([1, 2, 3]); + + foreach (var invalid in invalidRegistries) + { + await File.WriteAllTextAsync(registryPath, invalid); + await Assert.ThrowsExactlyAsync(() => InstanceContent.ProvisionAPIAsync( + api, Path.Combine(temporary.Root, "missing.jar"), temporary.Root, CancellationToken.None)); + Assert.AreEqual(invalid, await File.ReadAllTextAsync(registryPath)); + } + } + + [TestMethod] + public async Task LaunchUsesFabricMainClassVersionAndLibraryOverridesAndMissingProfileFails() + { + using var temporary = new TemporaryDirectory(); + var root = temporary.Root; + var manifest = new MojangVersionMetadata { Id = "1.20.4", MainClass = "vanilla.Main", MinecraftArguments = "--version ${version_name}" }; + var fabricLibrary = new LibraryInstallation(new MojangDownload(), "org/example/library/2.0/library-2.0.jar"); + var fabricClassifiedLibrary = new LibraryInstallation(new MojangDownload(), "org/example/library/2.0/library-2.0-windows.jar"); + var baseLibraries = new[] + { + new LibraryInstallation(new MojangDownload(), "org/example/library/1.0/library-1.0.jar"), + new LibraryInstallation(new MojangDownload(), "org/example/library/1.0/library-1.0-windows.jar"), + new LibraryInstallation(new MojangDownload(), "org/example/other/1.0/other-1.0.jar") + }; + var profile = new MinecraftLaunchProfile("net.fabricmc.loader.impl.launch.knot.KnotClient", "1.20.4", + "fabric-loader-0.16.9-1.20.4", [fabricLibrary, fabricClassifiedLibrary], + JvmArguments: [Argument("-Dfabric.jvm=true")], GameArguments: [Argument("--fabricLaunch=true")]); + + foreach (var source in Enum.GetValues()) + { + var plan = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + source, new InstanceParameters { MaximumMemoryMiB = 2048 }, GameArchitecture.X64, + libraries: baseLibraries, profile: profile); + var classpath = plan.Arguments[Array.IndexOf(plan.Arguments.ToArray(), "-cp") + 1]; + + Assert.IsTrue(plan.Arguments.Contains(profile.MainClass)); + Assert.IsTrue(plan.Arguments.Contains("fabric-loader-0.16.9-1.20.4")); + Assert.IsTrue(plan.Arguments.Contains("-Dfabric.jvm=true")); + Assert.IsTrue(plan.Arguments.Contains("--fabricLaunch=true")); + StringAssert.Contains(classpath, "library-2.0.jar"); + StringAssert.Contains(classpath, "library-2.0-windows.jar"); + StringAssert.Contains(classpath, "other-1.0.jar"); + Assert.IsFalse(classpath.Contains("library-1.0.jar", StringComparison.Ordinal)); + } + + var missing = await Assert.ThrowsExactlyAsync(() => + MinecraftLaunchPlan.LoadInstalledFabricProfileAsync(root, "1.20.4", "0.16.9", Hash1(Encoding.UTF8.GetBytes("absent")))); + Assert.AreEqual(MinecraftLaunchError.FabricProfileMissing, missing.Error); + + var installedProfile = JsonSerializer.SerializeToUtf8Bytes(new FabricProfile + { + Id = "fabric-loader-0.16.9-1.20.4", + InheritsFrom = "1.20.4", + MainClass = "net.fabricmc.loader.impl.launch.knot.KnotClient" + }, HakoJsonContext.Default.FabricProfile); + var fabricProfilePath = Path.Combine(root, "minecraft", ".hako-fabric.json"); + Directory.CreateDirectory(Path.GetDirectoryName(fabricProfilePath)!); + await File.WriteAllBytesAsync(fabricProfilePath, installedProfile); + var damaged = await Assert.ThrowsExactlyAsync(() => + MinecraftLaunchPlan.LoadInstalledFabricProfileAsync(root, "1.20.4", "0.16.9", Hash1(Encoding.UTF8.GetBytes("different profile")))); + Assert.AreEqual(MinecraftLaunchError.FabricProfileMissing, damaged.Error); + Assert.IsInstanceOfType(damaged.InnerException); + Assert.AreEqual(MojangError.Integrity, ((MojangException)damaged.InnerException!).Error); + } + + [TestMethod] + public async Task RegistryDamageIsNotSilentlyReplacedAndModNameCollisionIgnoresCaseAndDisabledSuffix() + { + using var temporary = new TemporaryDirectory(); + var registryPath = Path.Combine(temporary.Root, "minecraft", ".hako-mods.json"); + Directory.CreateDirectory(Path.GetDirectoryName(registryPath)!); + await File.WriteAllTextAsync(registryPath, "{broken"); + var apiJar = CreateApiJar("""{"id":"fabric-api","depends":{}}"""); + try + { + await InstanceContent.ProvisionAPIAsync(Descriptor(apiJar), Path.Combine(temporary.Root, "missing.jar"), + temporary.Root, CancellationToken.None); + Assert.Fail("The malformed registry should be rejected."); + } + catch (JsonException) + { + } + Assert.AreEqual("{broken", await File.ReadAllTextAsync(registryPath)); + File.Delete(registryPath); + + var mods = Path.Combine(temporary.Root, "minecraft", "mods"); + Directory.CreateDirectory(mods); + await File.WriteAllTextAsync(Path.Combine(mods, "Existing.jar.disabled"), "user choice"); + var source = Path.Combine(temporary.Root, "source.jar"); + await File.WriteAllBytesAsync(source, Encoding.UTF8.GetBytes("jar")); + await Assert.ThrowsExactlyAsync(() => InstanceContent.PublishJarAsync(source, "existing.JAR", temporary.Root, + origin: null, CancellationToken.None)); + } + + private static PreparedInstallation Prepared(string versionId, byte[] clientBytes, byte[] javaBytes) + { + var manifest = new MojangVersionMetadata + { + Id = versionId, + MainClass = "net.minecraft.client.main.Main", + JavaVersion = new MojangJavaRequirement { Component = "java-runtime-gamma", MajorVersion = 17 }, + Downloads = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["client"] = new() { Url = "https://test/client.jar", Sha1 = Hash1(clientBytes), Size = clientBytes.Length } + } + }; + var manifestData = JsonSerializer.SerializeToUtf8Bytes(manifest, MojangJsonContext.Default.MojangVersionMetadata); + var runtimeManifest = new MojangJavaRuntimeManifest + { + Files = new Dictionary + { + ["bin/javaw.exe"] = new() + { + Type = "file", + Downloads = new Dictionary + { + ["raw"] = new() { Url = "https://test/javaw.exe", Sha1 = Hash1(javaBytes), Size = javaBytes.Length } + } + } + } + }; + var runtimeData = JsonSerializer.SerializeToUtf8Bytes(runtimeManifest, MojangJsonContext.Default.MojangJavaRuntimeManifest); + return new PreparedInstallation( + new MojangVersionEntry { Id = versionId, Url = "https://test/version.json", Sha1 = Hash1(manifestData) }, + GameArchitecture.X64, + manifest, + manifestData, + new MojangJavaRuntimePackage + { + Manifest = new MojangDownload { Url = "https://test/runtime.json", Sha1 = Hash1(runtimeData), Size = runtimeData.Length }, + Version = new MojangRuntimeVersion { Name = "17.0.1", Released = "2025-01-01" } + }, + runtimeManifest, + runtimeData, + []); + } + + private static string ApiVersionsJson(byte[] jar, string sha1, string sha512) + { + var versions = new[] + { + new { id = "release", project_id = FabricClient.FabricApiProjectId, version_number = "0.100.0", version_type = "release", + date_published = "2024-01-01T00:00:00Z", game_versions = new[] { "1.20.4" }, loaders = new[] { "fabric" }, + files = new[] { new { filename = "fabric-api.jar", url = "https://cdn.test/fabric-api.jar", size = jar.Length, + hashes = new { sha1, sha512 }, primary = true, file_type = (string?)null } } }, + new { id = "newer-beta", project_id = FabricClient.FabricApiProjectId, version_number = "0.101.0-beta.1", version_type = "beta", + date_published = "2026-01-01T00:00:00Z", game_versions = new[] { "1.20.4" }, loaders = new[] { "fabric" }, + files = new[] { new { filename = "fabric-api-beta.jar", url = "https://cdn.test/fabric-api.jar", size = jar.Length, + hashes = new { sha1, sha512 }, primary = true, file_type = (string?)null } } } + }; + return JsonSerializer.Serialize(versions); + } + + private static FabricApiDescriptor Descriptor(byte[] bytes) => new( + FabricClient.FabricApiProjectId, "api-version", "0.100.0", "release", "fabric-api.jar", + "https://cdn.test/fabric-api.jar", bytes.Length, Hash1(bytes), Hash512(bytes)); + + private static FabricPreparation Preparation(string versionId) => new( + new FabricApiDescriptor(FabricClient.FabricApiProjectId, versionId, "0.100.0", "release", "fabric-api.jar", + "https://cdn.test/fabric-api.jar", 1, "sha1", "sha512"), + new FabricModMetadata(), + [new FabricLoaderVersion("0.16.9", true)]); + + private static byte[] CreateApiJar(string metadata) + { + using var output = new MemoryStream(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + using (var writer = new StreamWriter(archive.CreateEntry("fabric.mod.json").Open(), new UTF8Encoding(false))) + writer.Write(metadata); + return output.ToArray(); + } + + private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static HttpResponseMessage TextResponse(string text) => new(HttpStatusCode.OK) + { + Content = new StringContent(text, Encoding.UTF8, "text/plain") + }; + + private static HttpResponseMessage BytesResponse(byte[] bytes) => new(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes) + }; + + private static string Hash1(byte[] bytes) => Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant(); + private static string Hash512(byte[] bytes) => Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + + private static MojangArgument Argument(string value) => new() + { + Value = JsonDocument.Parse(JsonSerializer.Serialize(value)).RootElement.Clone() + }; +} diff --git a/windows/Hako.Tests/Fixtures/Modpack/plan.json b/windows/Hako.Tests/Fixtures/Modpack/plan.json new file mode 100644 index 0000000..2f80b16 --- /dev/null +++ b/windows/Hako.Tests/Fixtures/Modpack/plan.json @@ -0,0 +1,42 @@ +{ + "formatVersion": 1, + "projectID": "fixtureproject", + "versionID": "fixtureversion", + "versionNumber": "1.0.0", + "pageURL": "https://modrinth.com/modpack/fixture", + "archive": { + "filename": "fixture.mrpack", + "url": "https://cdn.modrinth.com/fixture.mrpack", + "size": 4, + "hashes": { + "sha1": "0000000000000000000000000000000000000000", + "sha512": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "primary": true + }, + "manifest": { + "formatVersion": 1, + "game": "minecraft", + "versionId": "fixture-pack", + "name": "Fixture Pack", + "files": [ + { + "path": "mods/fixture.jar", + "hashes": { + "sha1": "1111111111111111111111111111111111111111", + "sha512": "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" + }, + "env": { + "client": "optional", + "server": "optional" + }, + "downloads": ["https://cdn.modrinth.com/files/fixture.jar"], + "fileSize": 4 + } + ], + "dependencies": { + "minecraft": "1.21.4", + "fabric-loader": "0.16.10" + } + } +} diff --git a/windows/Hako.Tests/GameLaunchCoordinatorTests.cs b/windows/Hako.Tests/GameLaunchCoordinatorTests.cs new file mode 100644 index 0000000..1ec21f3 --- /dev/null +++ b/windows/Hako.Tests/GameLaunchCoordinatorTests.cs @@ -0,0 +1,288 @@ +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class GameLaunchCoordinatorTests +{ + [TestMethod] + public async Task BuildThatIsNotReadyCannotLaunch() + { + await using var context = await LaunchContext.CreateAsync(); + var instance = context.CreateInstance(ready: false); + + var error = await Assert.ThrowsExactlyAsync(() => context.Coordinator.LaunchAsync(instance.Id)); + + Assert.AreEqual(MinecraftLaunchError.BuildNotReady, error.Error); + Assert.AreEqual(0, context.PrepareCount); + } + + [TestMethod] + public async Task OfflineLaunchUsesLegacyIdentityWithoutRefreshAndReportsSessionLifecycle() + { + await using var context = await LaunchContext.CreateAsync(expireMinecraftToken: true); + var instance = context.CreateInstance(offline: true); + GameSessionEventArgs? started = null; + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + context.Coordinator.GameSessionStarted += (_, args) => started = args; + context.Coordinator.GameSessionEnded += (_, args) => ended.TrySetResult(args); + + await context.Coordinator.LaunchAsync(instance.Id); + + Assert.AreEqual("legacy", context.LastRequest!.Identity.UserType); + Assert.AreEqual("0", context.LastRequest.Identity.AccessToken); + Assert.AreEqual(0, context.Auth.ConnectionCount); + Assert.AreEqual(GameRunPhase.Running, context.Coordinator.GetState(instance.Id)!.Phase); + Assert.IsNotNull(started); + Assert.IsNotNull(started.Record.SessionID); + context.Exit.TrySetResult(0); + var end = await ended.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual(0, end.ExitCode); + Assert.AreEqual(started.Record, end.Record); + Assert.IsNull(context.Coordinator.GetState(instance.Id)); + Assert.IsFalse(context.Store.IsLaunchBusy(instance.Id)); + } + + [TestMethod] + public async Task NonZeroExitCodeIsReportedWithLog() + { + await using var context = await LaunchContext.CreateAsync(); + var instance = context.CreateInstance(offline: true); + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + context.Coordinator.GameSessionEnded += (_, _) => ended.TrySetResult(); + + await context.Coordinator.LaunchAsync(instance.Id); + context.Exit.TrySetResult(3); + await ended.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + var state = context.Coordinator.GetState(instance.Id)!; + Assert.AreEqual(GameRunPhase.Failed, state.Phase); + Assert.AreEqual(3, state.ExitCode); + Assert.AreEqual("LaunchGameExitCode", state.ErrorResourceKey); + Assert.IsNotNull(state.LogPath); + } + + [TestMethod] + public async Task RunningBuildCannotBeLaunchedTwice() + { + await using var context = await LaunchContext.CreateAsync(); + var instance = context.CreateInstance(offline: true); + await context.Coordinator.LaunchAsync(instance.Id); + + var error = await Assert.ThrowsExactlyAsync(() => context.Coordinator.LaunchAsync(instance.Id)); + + Assert.AreEqual(MinecraftLaunchError.BuildAlreadyRunning, error.Error); + Assert.AreEqual(1, context.StartCount); + context.Exit.TrySetResult(0); + } + + [TestMethod] + public async Task GlobalJavaPathIsUsedOnlyForGlobalArgumentSource() + { + await using var context = await LaunchContext.CreateAsync(useGlobalJava: true); + var global = context.CreateInstance(offline: true, source: LaunchArgumentSource.Global); + var custom = context.CreateInstance(offline: true, source: LaunchArgumentSource.Custom, name: "Custom"); + + await context.Coordinator.LaunchAsync(global.Id); + Assert.AreEqual(context.GlobalJava, context.LastRequest!.Executable); + await context.Coordinator.LaunchAsync(custom.Id); + + Assert.AreEqual( + Path.Combine(context.Store.GetInstanceDirectory(custom.Id), "java", "bin", "javaw.exe"), + context.LastRequest!.Executable); + context.Exit.TrySetResult(0); + } + + [TestMethod] + public async Task MissingGlobalJavaFailsWithoutFallbackAndReleasesLease() + { + await using var context = await LaunchContext.CreateAsync(useGlobalJava: true); + var instance = context.CreateInstance(offline: true, source: LaunchArgumentSource.Global); + File.Delete(context.GlobalJava); + + var error = await Assert.ThrowsExactlyAsync(() => context.Coordinator.LaunchAsync(instance.Id)); + + Assert.AreEqual(JavaValidationError.MissingExecutable, error.Error); + Assert.AreEqual(0, context.PrepareCount); + var state = context.Coordinator.GetState(instance.Id)!; + Assert.AreEqual(GameRunPhase.Failed, state.Phase); + Assert.AreEqual("LaunchJavaMissing", state.ErrorResourceKey); + Assert.IsFalse(context.Store.IsLaunchBusy(instance.Id)); + } + + [TestMethod] + public async Task OnlineLaunchRefreshesExpiredSessionAndPassesMinecraftIdentity() + { + await using var context = await LaunchContext.CreateAsync(expireMinecraftToken: true); + var instance = context.CreateInstance(); + + await context.Coordinator.LaunchAsync(instance.Id); + + Assert.AreEqual(1, context.Auth.ConnectionCount); + Assert.AreEqual("msa", context.LastRequest!.Identity.UserType); + Assert.AreEqual("minecraft-connected-secret", context.LastRequest.Identity.AccessToken); + Assert.AreEqual("profileid", context.LastRequest.Identity.Uuid); + context.Exit.TrySetResult(0); + } + + [TestMethod] + public async Task SessionReplacedDuringPreparationStopsLaunch() + { + await using var context = await LaunchContext.CreateAsync(); + var instance = context.CreateInstance(); + context.OnPrepare = () => + { + var (account, generation) = context.App.CaptureAccountState(); + Assert.IsTrue(context.App.TryCommitAccountUpdate(account!, generation, + account! with { MinecraftAccessToken = "replaced-secret" }, () => { })); + }; + + var error = await Assert.ThrowsExactlyAsync(() => context.Coordinator.LaunchAsync(instance.Id)); + + Assert.AreEqual(MinecraftLaunchError.SessionChanged, error.Error); + Assert.AreEqual(0, context.StartCount); + Assert.IsFalse(context.Store.IsLaunchBusy(instance.Id)); + } + + [TestMethod] + public async Task DamagedRecordDoesNotStopRestoringOtherBuilds() + { + await using var context = await LaunchContext.CreateAsync(); + var damaged = context.CreateInstance(offline: true, name: "Damaged"); + var running = context.CreateInstance(offline: true, name: "Running"); + var damagedRoot = context.Store.GetInstanceDirectory(damaged.Id); + File.WriteAllText(Path.Combine(damagedRoot, "minecraft", ".hako-running.json"), "not json"); + var runningRoot = context.Store.GetInstanceDirectory(running.Id); + var launched = await new GameProcessRunner().StartAsync(new MinecraftLaunchPlan( + Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 2"], + Path.Combine(runningRoot, "minecraft"), 8), + running.Id, runningRoot, Guid.NewGuid()); + var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + context.Coordinator.GameSessionEnded += (_, args) => ended.TrySetResult(args); + + await context.Coordinator.RestoreRunningGamesAsync(); + + Assert.AreEqual("LaunchProcessRecordInvalid", context.Coordinator.GetState(damaged.Id)!.ErrorResourceKey); + Assert.IsFalse(context.Store.IsLaunchBusy(damaged.Id)); + Assert.AreEqual(GameRunPhase.Running, context.Coordinator.GetState(running.Id)!.Phase); + var end = await ended.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.AreEqual(launched.Record, end.Record); + await launched.Completion.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.IsNull(context.Coordinator.GetState(running.Id)); + } + + private sealed class LaunchContext : IAsyncDisposable + { + private readonly TemporaryDirectory _temporary = new(); + private int _prepareCount; + private int _startCount; + + private LaunchContext() + { + } + + public AppCoordinator App { get; private set; } = null!; + public RefreshAuthService Auth { get; } = new(); + public InstanceStore Store { get; private set; } = null!; + public GameLaunchCoordinator Coordinator { get; private set; } = null!; + public string GlobalJava { get; private set; } = ""; + public MinecraftLaunchRequest? LastRequest { get; private set; } + public Action? OnPrepare { get; set; } + public TaskCompletionSource Exit { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int PrepareCount => Volatile.Read(ref _prepareCount); + public int StartCount => Volatile.Read(ref _startCount); + + public static async Task CreateAsync(bool expireMinecraftToken = false, bool useGlobalJava = false) + { + var context = new LaunchContext(); + Directory.CreateDirectory(context._temporary.Root); + context.GlobalJava = Path.Combine(context._temporary.Root, "Global Java", "javaw.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(context.GlobalJava)!); + File.WriteAllText(context.GlobalJava, ""); + var time = new ManualTimeProvider(); + if (expireMinecraftToken) + { + time.Advance(TimeSpan.FromMinutes(90)); + } + + var settings = new UserSettingsService(new MemorySettingsStore( + new UserSettings("en", JavaPath: useGlobalJava ? context.GlobalJava : null))); + context.App = new AppCoordinator(settings, new MemoryAccountStore(TestData.Account()), context.Auth, time); + await context.App.InitializeAsync(); + context.Store = new InstanceStore(context._temporary.Paths); + await context.Store.LoadAsync(); + context.Coordinator = new GameLaunchCoordinator( + context.Store, + settings, + context.App.MinecraftSessions, + new GameProcessRunner(), + javaValidation: null, + prepare: context.PrepareAsync, + start: context.StartAsync, + timeProvider: time); + return context; + } + + public GameInstance CreateInstance( + bool ready = true, + bool offline = false, + LaunchArgumentSource source = LaunchArgumentSource.Mojang, + string name = "Build") + { + var prepared = new PreparedInstallation( + new MojangVersionEntry { Id = "1.21", Type = "release", Url = "https://test.invalid/1.21.json", Sha1 = new string('a', 40) }, + GameArchitecture.X64, + new MojangVersionMetadata + { + Id = "1.21", + JavaVersion = new MojangJavaRequirement { Component = "java-runtime-delta", MajorVersion = 21 } + }, + [], + new MojangJavaRuntimePackage(), + new MojangJavaRuntimeManifest(), + [], + []); + var instance = Store.Create(new InstanceDraft { Name = name, OfflineMode = offline, ArgumentSource = source }, prepared); + var root = Store.GetInstanceDirectory(instance.Id); + Directory.CreateDirectory(Path.Combine(root, "java", "bin")); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + File.WriteAllText(Path.Combine(root, "java", "bin", "javaw.exe"), ""); + if (ready) + { + Store.CompleteInstallation(instance.Id, new InstallationResult(GameArchitecture.X64, "bin/javaw.exe", 21, false)); + } + + return Store.List().Single(candidate => candidate.Id == instance.Id); + } + + private Task PrepareAsync(MinecraftLaunchRequest request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _prepareCount); + LastRequest = request; + OnPrepare?.Invoke(); + return Task.FromResult(new MinecraftLaunchPlan(request.Executable, [], Path.Combine(request.InstanceRoot, "minecraft"), 21)); + } + + private Task StartAsync( + MinecraftLaunchPlan plan, + Guid instanceId, + string instanceRoot, + Guid? sessionId, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _startCount); + var record = new GameProcessRecord(instanceId, 4242, 1_700_000_000, 0, sessionId); + return Task.FromResult(new GameProcessLaunch(record, Path.Combine(instanceRoot, "minecraft", "logs", "hako-launch.log"), Exit.Task)); + } + + public async ValueTask DisposeAsync() + { + Exit.TrySetResult(0); + await Store.DisposeAsync(); + await _temporary.DisposeAsync(); + } + } +} diff --git a/windows/Hako.Tests/GameLaunchDefaultsTests.cs b/windows/Hako.Tests/GameLaunchDefaultsTests.cs new file mode 100644 index 0000000..3f1113f --- /dev/null +++ b/windows/Hako.Tests/GameLaunchDefaultsTests.cs @@ -0,0 +1,23 @@ +using Hako.Core; + +namespace Hako.Tests; + +[TestClass] +public sealed class GameLaunchDefaultsTests +{ + [TestMethod] + [DataRow("1280", 1280)] + [DataRow("1", 1)] + [DataRow("0", null)] + [DataRow("-720", null)] + [DataRow("+720", null)] + [DataRow(" 720", null)] + [DataRow("720.5", null)] + [DataRow("1 280", null)] + [DataRow("", null)] + [DataRow("99999999999", null)] + public void WindowDimensionAcceptsOnlyPositiveIntegers(string text, int? expected) + { + Assert.AreEqual(expected, GameLaunchDefaults.WindowDimension(text)); + } +} diff --git a/windows/Hako.Tests/GameProcessRunnerTests.cs b/windows/Hako.Tests/GameProcessRunnerTests.cs new file mode 100644 index 0000000..49d1ebd --- /dev/null +++ b/windows/Hako.Tests/GameProcessRunnerTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using Hako.Core; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class GameProcessRunnerTests +{ + [TestMethod] + public async Task RunnerWritesLogAndSwiftCompatibleProcessRecordThenRemovesItOnExit() + { + using var temporary = new TemporaryDirectory(); + var instanceRoot = CreateInstanceRoot(temporary); + var sessionId = Guid.NewGuid(); + var runner = new GameProcessRunner(); + var launch = await runner.StartAsync(CreatePowerShellPlan(instanceRoot, + "Start-Sleep -Seconds 2; [Console]::WriteLine('Hako runner log'); exit 7"), + Guid.NewGuid(), instanceRoot, sessionId); + var recordPath = Path.Combine(instanceRoot, "minecraft", ".hako-running.json"); + + Assert.IsTrue(File.Exists(recordPath)); + Assert.IsTrue(File.Exists(launch.LogPath)); + Assert.IsTrue(launch.Record.Pid > 0); + Assert.IsTrue(launch.Record.StartSeconds > 0); + Assert.IsTrue(launch.Record.StartMicroseconds < 1_000_000); + using var json = JsonDocument.Parse(File.ReadAllBytes(recordPath)); + Assert.AreEqual(launch.Record.InstanceID, json.RootElement.GetProperty("instanceID").GetGuid()); + Assert.AreEqual(launch.Record.Pid, json.RootElement.GetProperty("pid").GetInt32()); + Assert.AreEqual(launch.Record.StartSeconds, json.RootElement.GetProperty("startSeconds").GetUInt64()); + Assert.AreEqual(launch.Record.StartMicroseconds, json.RootElement.GetProperty("startMicroseconds").GetUInt64()); + Assert.AreEqual(sessionId, json.RootElement.GetProperty("sessionID").GetGuid()); + Assert.AreEqual(7, await launch.Completion.WaitAsync(TimeSpan.FromSeconds(10))); + using var log = new FileStream(launch.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(log); + StringAssert.Contains(reader.ReadToEnd(), "Hako runner log"); + Assert.IsFalse(File.Exists(recordPath)); + } + + [TestMethod] + public async Task RestoreAttachesToLiveProcessAndWaitsForItsExit() + { + using var temporary = new TemporaryDirectory(); + var instanceRoot = CreateInstanceRoot(temporary); + var instanceId = Guid.NewGuid(); + var launched = await new GameProcessRunner().StartAsync(CreatePowerShellPlan(instanceRoot, "Start-Sleep -Seconds 3; exit 0"), instanceId, instanceRoot); + + var restored = await new GameProcessRunner().RestoreAsync(instanceId, instanceRoot); + + Assert.IsNotNull(restored); + Assert.AreEqual(launched.Record, restored!.Record); + Assert.AreEqual(0, await restored.Completion.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.AreEqual(0, await launched.Completion.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.IsFalse(File.Exists(Path.Combine(instanceRoot, "minecraft", ".hako-running.json"))); + } + + [TestMethod] + public async Task ExitCompletesWhenRunningRecordIsTemporarilyLocked() + { + using var temporary = new TemporaryDirectory(); + var instanceRoot = CreateInstanceRoot(temporary); + var instanceId = Guid.NewGuid(); + var launch = await new GameProcessRunner().StartAsync( + CreatePowerShellPlan(instanceRoot, "Start-Sleep -Seconds 3; exit 0"), instanceId, instanceRoot); + var recordPath = Path.Combine(instanceRoot, "minecraft", ".hako-running.json"); + + using (new FileStream(recordPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + Assert.AreEqual(0, await launch.Completion.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.IsTrue(File.Exists(recordPath)); + } + + Assert.IsNull(await new GameProcessRunner().RestoreAsync(instanceId, instanceRoot)); + Assert.IsFalse(File.Exists(recordPath)); + } + + [TestMethod] + public async Task RestoreRejectsReusedPidWhenInjectedCreationTimeDoesNotMatch() + { + using var temporary = new TemporaryDirectory(); + var instanceRoot = CreateInstanceRoot(temporary); + var instanceId = Guid.NewGuid(); + var launched = await new GameProcessRunner().StartAsync(CreatePowerShellPlan(instanceRoot, "Start-Sleep -Seconds 3; exit 0"), instanceId, instanceRoot); + var reusedPidRecord = launched.Record with { StartSeconds = launched.Record.StartSeconds + 1 }; + WriteRecord(instanceRoot, reusedPidRecord); + var runner = new GameProcessRunner(_ => (true, launched.Record.StartSeconds, launched.Record.StartMicroseconds)); + + var restored = await runner.RestoreAsync(instanceId, instanceRoot); + + Assert.IsNull(restored); + Assert.IsFalse(File.Exists(Path.Combine(instanceRoot, "minecraft", ".hako-running.json"))); + Assert.AreEqual(0, await launched.Completion.WaitAsync(TimeSpan.FromSeconds(10))); + } + + [TestMethod] + public async Task OldProcessObserverDoesNotDeleteAReplacedRunningRecord() + { + using var temporary = new TemporaryDirectory(); + var instanceRoot = CreateInstanceRoot(temporary); + var instanceId = Guid.NewGuid(); + var launched = await new GameProcessRunner().StartAsync(CreatePowerShellPlan(instanceRoot, "Start-Sleep -Seconds 3; exit 0"), instanceId, instanceRoot); + var replacement = launched.Record with { SessionID = Guid.NewGuid() }; + WriteRecord(instanceRoot, replacement); + + Assert.AreEqual(0, await launched.Completion.WaitAsync(TimeSpan.FromSeconds(10))); + + var current = JsonSerializer.Deserialize( + File.ReadAllBytes(Path.Combine(instanceRoot, "minecraft", ".hako-running.json")), + HakoJsonContext.Default.GameProcessRecord); + Assert.AreEqual(replacement, current); + } + + [TestMethod] + public void Java8CommandLineOverWindowsLimitHasStructuredError() + { + var error = Assert.ThrowsExactly(() => + Win32Process.BuildCommandLine("java.exe", [new string('a', Win32Process.MaximumCommandLineCharacters)])); + + Assert.AreEqual(MinecraftLaunchError.CommandLineTooLong, error.Error); + Assert.AreEqual("LaunchCommandLineTooLong", error.ResourceKey); + } + + private static string CreateInstanceRoot(TemporaryDirectory temporary) + { + var root = Path.Combine(temporary.Paths.GameRoot, "Build"); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + return root; + } + + private static MinecraftLaunchPlan CreateCommandPlan(string instanceRoot, string command) => new( + Environment.GetEnvironmentVariable("ComSpec") ?? Path.Combine(Environment.SystemDirectory, "cmd.exe"), + ["/d", "/c", command], + Path.Combine(instanceRoot, "minecraft"), + 8); + + private static MinecraftLaunchPlan CreatePowerShellPlan(string instanceRoot, string command) => new( + Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], + Path.Combine(instanceRoot, "minecraft"), + 8); + + private static void WriteRecord(string instanceRoot, GameProcessRecord record) + { + File.WriteAllBytes( + Path.Combine(instanceRoot, "minecraft", ".hako-running.json"), + JsonSerializer.SerializeToUtf8Bytes(record, HakoJsonContext.Default.GameProcessRecord)); + } +} diff --git a/windows/Hako.Tests/Hako.Tests.csproj b/windows/Hako.Tests/Hako.Tests.csproj index 3a77727..0fe6312 100644 --- a/windows/Hako.Tests/Hako.Tests.csproj +++ b/windows/Hako.Tests/Hako.Tests.csproj @@ -26,12 +26,24 @@ + + + + + + + + + + + + diff --git a/windows/Hako.Tests/HostArchitectureTests.cs b/windows/Hako.Tests/HostArchitectureTests.cs new file mode 100644 index 0000000..69fde6a --- /dev/null +++ b/windows/Hako.Tests/HostArchitectureTests.cs @@ -0,0 +1,67 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class HostArchitectureTests +{ + private const uint MachineTypeAttributeUserEnabled = 0x00000001; + private const ushort ImageFileMachineAmd64 = 0x8664; + + [TestMethod] + public void HResultSuccessAndUserEnabledAllowAmd64() + { + var enabled = WindowsHostArchitectureProvider.IsAmd64UserEnabled((ushort machine, out uint attributes) => + { + Assert.AreEqual(ImageFileMachineAmd64, machine); + attributes = MachineTypeAttributeUserEnabled; + return 0; + }); + + Assert.IsTrue(enabled); + } + + [TestMethod] + public void FailedHResultDoesNotAllowAmd64() + { + var enabled = WindowsHostArchitectureProvider.IsAmd64UserEnabled((ushort _, out uint attributes) => + { + attributes = MachineTypeAttributeUserEnabled; + return -1; + }); + + Assert.IsFalse(enabled); + } + + [TestMethod] + public void MissingMachineTypeAttributesEntryPointDoesNotAllowAmd64() + { + var enabled = WindowsHostArchitectureProvider.IsAmd64UserEnabled((ushort _, out uint _) => throw new EntryPointNotFoundException()); + + Assert.IsFalse(enabled); + } + + [TestMethod] + public void NativeAmd64MachineTypeAttributesSignatureReturnsUserEnabledOnX64() + { + if (!OperatingSystem.IsWindows() || RuntimeInformation.OSArchitecture != Architecture.X64) + { + Assert.Inconclusive("This integration check requires an x64 Windows host."); + } + + try + { + var hResult = WindowsHostArchitectureProvider.QueryMachineTypeAttributes(ImageFileMachineAmd64, out var attributes); + + Assert.IsTrue(hResult >= 0, $"GetMachineTypeAttributes failed with HRESULT 0x{hResult:X8}."); + Assert.AreNotEqual(0u, attributes & MachineTypeAttributeUserEnabled); + } + catch (EntryPointNotFoundException) + { + Assert.Inconclusive("GetMachineTypeAttributes is not available on this Windows version."); + } + } +} diff --git a/windows/Hako.Tests/InstallationCoordinatorTests.cs b/windows/Hako.Tests/InstallationCoordinatorTests.cs new file mode 100644 index 0000000..d57930b --- /dev/null +++ b/windows/Hako.Tests/InstallationCoordinatorTests.cs @@ -0,0 +1,1258 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Runtime.Versioning; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstallationCoordinatorTests +{ + [TestMethod] + public async Task QueueUsesCreatedAtFifoAndRunsOnlyOneInstallerAtATime() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var newest = store.Create(new InstanceDraft { Name = "Newest" }, "newest"); + var oldest = store.Create(new InstanceDraft { Name = "Oldest" }, "oldest"); + var middle = store.Create(new InstanceDraft { Name = "Middle" }, "middle"); + await UpdateInstancesAsync(store, instances => instances.Select(instance => instance.Id switch + { + var id when id == oldest.Id => instance with { CreatedAt = DateTimeOffset.Parse("2024-01-01T00:00:00Z") }, + var id when id == middle.Id => instance with { CreatedAt = DateTimeOffset.Parse("2024-02-01T00:00:00Z") }, + _ => instance with { CreatedAt = DateTimeOffset.Parse("2024-03-01T00:00:00Z") } + }).ToList()); + + var releases = new Dictionary(StringComparer.Ordinal) + { + ["oldest"] = NewSignal(), + ["middle"] = NewSignal(), + ["newest"] = NewSignal() + }; + var started = new ConcurrentQueue(); + var startedSignals = releases.Keys.ToDictionary(key => key, _ => NewSignal(), StringComparer.Ordinal); + var active = 0; + var maxActive = 0; + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + var current = Interlocked.Increment(ref active); + UpdateMaximum(ref maxActive, current); + started.Enqueue(prepared.Version.Id); + startedSignals[prepared.Version.Id].TrySetResult(); + try + { + await releases[prepared.Version.Id].Task.WaitAsync(cancellationToken); + return Result(prepared.Architecture); + } + finally + { + Interlocked.Decrement(ref active); + } + }); + + await coordinator.StartAsync(); + await startedSignals["oldest"].Task.WaitAsync(TimeSpan.FromSeconds(10)); + releases["oldest"].TrySetResult(); + await startedSignals["middle"].Task.WaitAsync(TimeSpan.FromSeconds(10)); + releases["middle"].TrySetResult(); + await startedSignals["newest"].Task.WaitAsync(TimeSpan.FromSeconds(10)); + releases["newest"].TrySetResult(); + + await WaitForStateAsync(store, oldest.Id, InstanceInstallationState.Ready); + await WaitForStateAsync(store, middle.Id, InstanceInstallationState.Ready); + await WaitForStateAsync(store, newest.Id, InstanceInstallationState.Ready); + + CollectionAssert.AreEqual(new[] { "oldest", "middle", "newest" }, started.ToArray()); + Assert.AreEqual(1, maxActive); + } + + [TestMethod] + public async Task PauseKeepsBuildInstallingUntilCanceledInstallerWorkHasFinished() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var prepared = CreatePrepared("pause-build", GameArchitecture.X64); + var instance = store.Create(new InstanceDraft { Name = "Pause Build" }, prepared); + var enteredInstall = NewSignal(); + var cancellationObserved = NewSignal(); + var finishWriter = NewSignal(); + var canceledAfterPauseWasPersisted = false; + var progressReported = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (value, _, progress, cancellationToken) => + { + enteredInstall.TrySetResult(); + progress!.Report(new InstallationProgress(InstallationProgressStage.Downloading, 10, 20, 1, 2)); + progressReported.TrySetResult(); + using var registration = cancellationToken.Register(() => + { + canceledAfterPauseWasPersisted = store.Instances.Single(item => item.Id == instance.Id).PauseRequested; + cancellationObserved.TrySetResult(); + }); + await finishWriter.Task; + cancellationToken.ThrowIfCancellationRequested(); + return Result(value.Architecture); + }); + + await coordinator.StartAsync(); + await enteredInstall.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await progressReported.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual(InstallationProgressStage.Downloading, coordinator.Progress[instance.Id].Stage); + Assert.AreEqual(10, coordinator.Progress[instance.Id].CompletedBytes); + Assert.AreEqual(1, coordinator.Progress[instance.Id].CompletedFiles); + await coordinator.PauseAsync(instance.Id); + await cancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsTrue(canceledAfterPauseWasPersisted); + Assert.AreEqual(InstanceInstallationState.Installing, store.Instances.Single(item => item.Id == instance.Id).State); + Assert.IsTrue(store.Instances.Single(item => item.Id == instance.Id).PauseRequested); + Assert.IsNotNull(store.ManagementBlockedReason(store.Instances.Single(item => item.Id == instance.Id))); + Assert.IsFalse(store.TryBeginContent(instance.Id, out _, out var busyReason)); + Assert.AreEqual(InstanceStorageError.BuildInstalling, busyReason); + Assert.AreEqual(InstallationProgressStage.Pausing, coordinator.Progress[instance.Id].Stage); + Assert.ThrowsExactly(() => store.Rename(instance.Id, "Changed")); + Assert.ThrowsExactly(() => store.Update(instance.Id, new InstanceDraft { Name = "Changed" })); + Assert.ThrowsExactly(() => store.UpdateSettings(instance.Id, new InstanceDraft { Name = "Pause Build" })); + Assert.ThrowsExactly(() => store.Delete(instance.Id)); + + finishWriter.TrySetResult(); + await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Paused); + Assert.IsTrue(store.Instances.Single(item => item.Id == instance.Id).PauseRequested); + Assert.IsNull(store.ManagementBlockedReason(store.Instances.Single(item => item.Id == instance.Id))); + store.Delete(instance.Id); + Assert.IsFalse(store.Instances.Any(item => item.Id == instance.Id)); + } + + [TestMethod] + public async Task PauseAfterInstallerReturnsLeavesBuildPausedWithoutAnActiveTask() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var prepared = CreatePrepared("pause-race", GameArchitecture.X64); + var instance = store.Create(new InstanceDraft { Name = "Pause Race" }, prepared); + var installStarted = NewSignal(); + var releaseInstall = NewSignal(); + var installerReturned = NewSignal(); + var releasedForPause = 0; + var pauseRecordObserved = 0; + var installerFinishedBeforeCancel = 0; + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (value, _, _, _) => + { + installStarted.TrySetResult(); + await releaseInstall.Task; + installerReturned.TrySetResult(); + return Result(value.Architecture); + }); + store.InstancesChanged += (_, _) => + { + var current = store.Instances.Single(item => item.Id == instance.Id); + if (current.State == InstanceInstallationState.Installing + && current.PauseRequested + && Interlocked.Exchange(ref releasedForPause, 1) == 0) + { + Volatile.Write(ref pauseRecordObserved, 1); + releaseInstall.TrySetResult(); + if (installerReturned.Task.Wait(TimeSpan.FromSeconds(10))) + { + Volatile.Write(ref installerFinishedBeforeCancel, 1); + } + } + }; + + await coordinator.StartAsync(); + await installStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await coordinator.PauseAsync(instance.Id); + var paused = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Paused); + await WaitForActiveAsync(coordinator, null); + + Assert.AreEqual(1, Volatile.Read(ref pauseRecordObserved)); + Assert.AreEqual(1, Volatile.Read(ref installerFinishedBeforeCancel)); + Assert.IsTrue(paused.PauseRequested); + Assert.IsNull(coordinator.ActiveInstanceId); + Assert.IsFalse(coordinator.Progress.ContainsKey(instance.Id)); + } + + [TestMethod] + public async Task PauseRequestedInstallationRecoversAsPaused() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Interrupted Pause" }, CreatePrepared("paused", GameArchitecture.X64)); + await UpdateInstancesAsync(store, instances => instances.Select(item => item.Id == instance.Id + ? item with { State = InstanceInstallationState.Installing, PauseRequested = true } + : item).ToList()); + var prepareCount = 0; + var coordinator = CreateCoordinator( + temporary, + store, + (_, _, _) => + { + Interlocked.Increment(ref prepareCount); + throw new AssertFailedException("A recovered pause must not start preparation."); + }, + (_, _, _, _) => throw new AssertFailedException("A recovered pause must not start installation.")); + + await coordinator.StartAsync(); + + var recovered = store.Instances.Single(item => item.Id == instance.Id); + Assert.AreEqual(InstanceInstallationState.Paused, recovered.State); + Assert.IsTrue(recovered.PauseRequested); + Assert.AreEqual(0, prepareCount); + } + + [TestMethod] + public async Task InterruptedInstallationReturnsToQueueAndPreparesWithPinnedArchitecture() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Interrupted Install" }, CreatePrepared("interrupted", GameArchitecture.Arm64)); + await UpdateInstancesAsync(store, instances => instances.Select(item => item.Id == instance.Id + ? item with { State = InstanceInstallationState.Installing, PauseRequested = false } + : item).ToList()); + GameArchitecture? usedPinnedArchitecture = null; + var installationStarted = NewSignal(); + var finishInstall = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => + { + usedPinnedArchitecture = pinned; + return Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)); + }, + async (prepared, _, _, cancellationToken) => + { + installationStarted.TrySetResult(); + await finishInstall.Task.WaitAsync(cancellationToken); + return Result(prepared.Architecture); + }); + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.AreEqual(InstanceInstallationState.Installing, store.Instances.Single(item => item.Id == instance.Id).State); + Assert.AreEqual(GameArchitecture.Arm64, usedPinnedArchitecture); + finishInstall.TrySetResult(); + await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + } + + [TestMethod] + public async Task ResumeAndRetryUsePinnedArchitectureAndSuccessfulInstallStoresJavaDetails() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Resume Retry" }, CreatePrepared("resume-retry", GameArchitecture.Arm64)); + await UpdateInstancesAsync(store, instances => instances.Select(item => item.Id == instance.Id + ? item with { State = InstanceInstallationState.Paused, PauseRequested = true } + : item).ToList()); + var pinnedArchitectures = new ConcurrentQueue(); + var installAttempt = 0; + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => + { + pinnedArchitectures.Enqueue(pinned); + return Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)); + }, + (prepared, _, _, _) => + { + if (Interlocked.Increment(ref installAttempt) == 1) + { + throw new MojangException(MojangError.Http, "The test download failed."); + } + + return Task.FromResult(new InstallationResult(prepared.Architecture, "bin/javaw.exe", 21, false)); + }); + + await coordinator.StartAsync(); + await coordinator.ResumeAsync(instance.Id); + var failed = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Failed); + + Assert.AreEqual("mojang.http", failed.ErrorCode); + Assert.AreEqual("The test download failed.", failed.ErrorMessage); + await coordinator.RetryAsync(instance.Id); + var ready = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + + Assert.AreEqual(2, pinnedArchitectures.Count); + Assert.IsTrue(pinnedArchitectures.All(architecture => architecture == GameArchitecture.Arm64)); + Assert.AreEqual("bin/javaw.exe", ready.JavaExecutable); + Assert.AreEqual(21, ready.JavaMajorVersion); + Assert.AreEqual("Arm64", ready.Architecture); + Assert.IsNull(ready.ErrorCode); + Assert.IsFalse(ready.PauseRequested); + } + + [TestMethod] + public async Task FailureOfOneBuildDoesNotBlockTheNextQueuedBuild() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var first = store.Create(new InstanceDraft { Name = "First Failure" }, "first-failure"); + var second = store.Create(new InstanceDraft { Name = "Second Success" }, "second-success"); + var started = new ConcurrentQueue(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (prepared, _, _, _) => + { + started.Enqueue(prepared.Version.Id); + if (prepared.Version.Id == "first-failure") + { + throw new MojangException(MojangError.Unsupported, "No compatible runtime."); + } + + return Task.FromResult(Result(prepared.Architecture)); + }); + + await coordinator.StartAsync(); + var failed = await WaitForStateAsync(store, first.Id, InstanceInstallationState.Failed); + var ready = await WaitForStateAsync(store, second.Id, InstanceInstallationState.Ready); + + Assert.AreEqual("mojang.unsupported", failed.ErrorCode); + Assert.AreEqual(InstanceInstallationState.Ready, ready.State); + CollectionAssert.AreEqual(new[] { "first-failure", "second-success" }, started.ToArray()); + } + + [TestMethod] + public async Task FirstEnqueueUsesVerifiedPreparationAndCreatePinsItsMetadataAndArchitecture() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var coordinator = CreateCoordinator( + temporary, + store, + (_, _, _) => throw new AssertFailedException("The verified preparation should be reused."), + (prepared, root, progress, _) => + { + Assert.AreEqual(GameArchitecture.X64, prepared.Architecture); + Assert.IsTrue(Directory.Exists(Path.Combine(root, "java"))); + progress!.Report(new InstallationProgress(InstallationProgressStage.Downloading, 4, 8, 1, 2)); + return Task.FromResult(new InstallationResult(GameArchitecture.X64, "bin/javaw.exe", 17, true)); + }); + await coordinator.StartAsync(); + + var prepared = CreatePrepared("verified", GameArchitecture.X64); + var instance = await coordinator.CreateAndEnqueueAsync(new InstanceDraft { Name = "Verified Build" }, prepared); + Assert.AreEqual(prepared.Version.Url, instance.MetadataURL); + Assert.AreEqual(prepared.Version.Sha1, instance.MetadataSHA1); + Assert.AreEqual("X64", instance.Architecture); + Assert.AreEqual(17, instance.JavaMajorVersion); + + var ready = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + + Assert.AreEqual("bin/javaw.exe", ready.JavaExecutable); + Assert.AreEqual(17, ready.JavaMajorVersion); + Assert.IsTrue(ready.LegacyTexturepacks); + } + + [TestMethod] + public async Task CreateAndEnqueueKeepsPauseAndRepreparesWhenPausedDuringCreate() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + InstallationCoordinator? coordinator = null; + Exception? pauseError = null; + var pauseRequested = 0; + var prepareCount = 0; + var installCount = 0; + GameArchitecture? pinnedArchitecture = null; + coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => + { + Interlocked.Increment(ref prepareCount); + pinnedArchitecture = pinned; + return Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)); + }, + (prepared, _, _, _) => + { + Interlocked.Increment(ref installCount); + return Task.FromResult(Result(prepared.Architecture)); + }); + store.InstancesChanged += (_, _) => + { + var created = store.Instances.FirstOrDefault(item => item.Name == "Paused During Create"); + if (created?.State == InstanceInstallationState.Queued + && Interlocked.Exchange(ref pauseRequested, 1) == 0) + { + try + { + coordinator!.PauseAsync(created.Id).GetAwaiter().GetResult(); + } + catch (Exception exception) + { + pauseError = exception; + } + } + }; + + await coordinator.StartAsync(); + var createdBuild = await coordinator.CreateAndEnqueueAsync( + new InstanceDraft { Name = "Paused During Create" }, + CreatePrepared("paused-during-create", GameArchitecture.X64)); + + Assert.IsNull(pauseError); + Assert.AreEqual(1, Volatile.Read(ref pauseRequested)); + Assert.AreEqual(InstanceInstallationState.Paused, createdBuild.State); + Assert.IsTrue(createdBuild.PauseRequested); + Assert.AreEqual(0, Volatile.Read(ref prepareCount)); + Assert.AreEqual(0, Volatile.Read(ref installCount)); + + await coordinator.ResumeAsync(createdBuild.Id); + var ready = await WaitForStateAsync(store, createdBuild.Id, InstanceInstallationState.Ready); + + Assert.AreEqual(1, Volatile.Read(ref prepareCount)); + Assert.AreEqual(GameArchitecture.X64, pinnedArchitecture); + Assert.AreEqual(1, Volatile.Read(ref installCount)); + Assert.IsFalse(ready.PauseRequested); + } + + [TestMethod] + public async Task PauseWithIOExceptionPersistsPausedAndKeepsPausingProgress() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Pause IOException" }, CreatePrepared("pause-io", GameArchitecture.X64)); + var installationStarted = NewSignal(); + var cancellationObserved = NewSignal(); + var progressAfterPause = NewSignal(); + var pauseWasPersisted = false; + InstallationCoordinator? coordinator = null; + coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (_, _, progress, cancellationToken) => + { + installationStarted.TrySetResult(); + progress!.Report(new InstallationProgress(InstallationProgressStage.Downloading, 1, 1000)); + using var registration = cancellationToken.Register(() => + { + pauseWasPersisted = store.Instances.Single(item => item.Id == instance.Id).PauseRequested; + cancellationObserved.TrySetResult(); + }); + await cancellationObserved.Task; + progress.Report(new InstallationProgress(InstallationProgressStage.Downloading, 2, 1000)); + progressAfterPause.TrySetResult(coordinator!.Progress[instance.Id].Stage); + throw new IOException("The stream failed while cancellation was in progress."); + }); + var terminal = WaitForTerminalAndCheckAccessAsync(store, instance.Id, InstanceInstallationState.Paused); + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await coordinator.PauseAsync(instance.Id); + var visibleStage = await progressAfterPause.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var result = await terminal.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsTrue(pauseWasPersisted); + Assert.AreEqual(InstallationProgressStage.Pausing, visibleStage); + Assert.AreEqual(InstanceInstallationState.Paused, result.Instance.State); + Assert.IsTrue(result.Instance.PauseRequested); + Assert.IsNull(result.Error); + } + + [TestMethod] + public async Task DisposeAfterSuccessfulInstallationResultPersistsReady() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Dispose Successful Install" }, + CreatePrepared("dispose-success", GameArchitecture.X64)); + var installationStarted = NewSignal(); + var releaseInstallation = NewSignal(); + var leaseReleased = NewSignal(); + var allowFinalization = NewSignal(); + var cancellationObserved = NewSignal(); + CancellationTokenRegistration cancellationRegistration = default; + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + installationStarted.TrySetResult(); + cancellationRegistration = cancellationToken.Register(() => cancellationObserved.TrySetResult()); + await releaseInstallation.Task; + return Result(prepared.Architecture); + }); + EventHandler holdFinalization = (_, _) => + { + leaseReleased.TrySetResult(); + allowFinalization.Task.GetAwaiter().GetResult(); + }; + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + store.BusyStateChanged += holdFinalization; + releaseInstallation.TrySetResult(); + try + { + await leaseReleased.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var disposeTask = coordinator.DisposeAsync().AsTask(); + await cancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsFalse(disposeTask.IsCompleted); + + allowFinalization.TrySetResult(); + await disposeTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + allowFinalization.TrySetResult(); + store.BusyStateChanged -= holdFinalization; + cancellationRegistration.Dispose(); + } + + var completed = store.Instances.Single(item => item.Id == instance.Id); + Assert.AreEqual(InstanceInstallationState.Ready, completed.State); + Assert.IsFalse(completed.PauseRequested); + Assert.IsNull(completed.ErrorCode); + } + + [TestMethod] + public async Task DisposeLeavesActiveInstallationForTheNextCoordinatorToResume() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Dispose Active Install" }, + CreatePrepared("dispose-active", GameArchitecture.X64)); + var installationStarted = NewSignal(); + var releaseInstallation = NewSignal(); + var cancellationObserved = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + installationStarted.TrySetResult(); + try + { + await releaseInstallation.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + cancellationObserved.TrySetResult(); + throw; + } + + return Result(prepared.Architecture); + }); + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await coordinator.DisposeAsync(); + await cancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsNull(coordinator.ActiveInstanceId); + var interrupted = store.Instances.Single(item => item.Id == instance.Id); + Assert.AreEqual(InstanceInstallationState.Installing, interrupted.State); + Assert.IsFalse(interrupted.PauseRequested); + + var resumedInstallationStarted = NewSignal(); + var releaseResumedInstallation = NewSignal(); + var resumedCoordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + resumedInstallationStarted.TrySetResult(); + await releaseResumedInstallation.Task.WaitAsync(cancellationToken); + return Result(prepared.Architecture); + }); + + await resumedCoordinator.StartAsync(); + await resumedInstallationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + var resumed = store.Instances.Single(item => item.Id == instance.Id); + Assert.AreEqual(InstanceInstallationState.Installing, resumed.State); + Assert.IsFalse(resumed.PauseRequested); + + releaseResumedInstallation.TrySetResult(); + await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + } + + [TestMethod] + public async Task UserPauseRemainsPausedWhenCoordinatorIsDisposed() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Dispose Paused Install" }, + CreatePrepared("dispose-paused", GameArchitecture.X64)); + var installationStarted = NewSignal(); + var cancellationObserved = NewSignal(); + var allowUnwind = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (_, _, _, cancellationToken) => + { + installationStarted.TrySetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + cancellationObserved.TrySetResult(); + await allowUnwind.Task; + throw; + } + + throw new InvalidOperationException("The installation was not cancelled."); + }); + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await coordinator.PauseAsync(instance.Id); + await cancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var disposeTask = coordinator.DisposeAsync().AsTask(); + Assert.IsFalse(disposeTask.IsCompleted); + + allowUnwind.TrySetResult(); + await disposeTask.WaitAsync(TimeSpan.FromSeconds(10)); + + var paused = store.Instances.Single(item => item.Id == instance.Id); + Assert.AreEqual(InstanceInstallationState.Paused, paused.State); + Assert.IsTrue(paused.PauseRequested); + } + + [TestMethod] + public async Task ReadyEventFiresAfterLeaseIsReleased() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Ready Lease" }, CreatePrepared("ready-lease", GameArchitecture.X64)); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (prepared, _, _, _) => Task.FromResult(Result(prepared.Architecture))); + var terminal = WaitForTerminalAndCheckAccessAsync(store, instance.Id, InstanceInstallationState.Ready); + + await coordinator.StartAsync(); + var result = await terminal.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.AreEqual(InstanceInstallationState.Ready, result.Instance.State); + Assert.IsNull(result.Error); + Assert.IsFalse(store.Instances.Any(item => item.Id == instance.Id)); + } + + [TestMethod] + public async Task QueuedBuildCanBePausedWhileEarlierBuildIsInstalling() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var first = store.Create(new InstanceDraft { Name = "Active First" }, "active-first"); + var second = store.Create(new InstanceDraft { Name = "Queued Second" }, "queued-second"); + await UpdateInstancesAsync(store, instances => instances.Select(item => item.Id == first.Id + ? item with { CreatedAt = DateTimeOffset.Parse("2024-01-01T00:00:00Z") } + : item with { CreatedAt = DateTimeOffset.Parse("2024-02-01T00:00:00Z") }).ToList()); + var firstStarted = NewSignal(); + var releaseFirst = NewSignal(); + var started = new ConcurrentQueue(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + started.Enqueue(prepared.Version.Id); + if (prepared.Version.Id == "active-first") + { + firstStarted.TrySetResult(); + await releaseFirst.Task.WaitAsync(cancellationToken); + } + + return Result(prepared.Architecture); + }); + + await coordinator.StartAsync(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await coordinator.PauseAsync(second.Id); + Assert.AreEqual(InstanceInstallationState.Paused, store.Instances.Single(item => item.Id == second.Id).State); + releaseFirst.TrySetResult(); + await WaitForStateAsync(store, first.Id, InstanceInstallationState.Ready); + await WaitForActiveAsync(coordinator, null); + + CollectionAssert.AreEqual(new[] { "active-first" }, started.ToArray()); + } + + [TestMethod] + public async Task ResumeDuringInstallingPauseRequestIsRejected() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Pause Transition" }, CreatePrepared("pause-transition", GameArchitecture.X64)); + var installationStarted = NewSignal(); + var releaseInstall = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + installationStarted.TrySetResult(); + await releaseInstall.Task; + cancellationToken.ThrowIfCancellationRequested(); + return Result(prepared.Architecture); + }); + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await coordinator.PauseAsync(instance.Id); + + await Assert.ThrowsExactlyAsync(() => coordinator.ResumeAsync(instance.Id)); + releaseInstall.TrySetResult(); + await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Paused); + } + + [TestMethod] + public async Task BusyFirstBuildDoesNotBlockNextQueuedBuild() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var first = store.Create(new InstanceDraft { Name = "Busy First" }, "busy-first"); + var second = store.Create(new InstanceDraft { Name = "Free Second" }, "free-second"); + await UpdateInstancesAsync(store, instances => instances.Select(item => item.Id == first.Id + ? item with { CreatedAt = DateTimeOffset.Parse("2024-01-01T00:00:00Z") } + : item with { CreatedAt = DateTimeOffset.Parse("2024-02-01T00:00:00Z") }).ToList()); + store.SetInstallationStatus(first.Id, InstanceInstallationState.Ready, pauseRequested: false); + Assert.IsTrue(store.TryBeginContent(first.Id, out var busyLease, out _)); + store.SetInstallationStatus(first.Id, InstanceInstallationState.Queued, pauseRequested: false); + var secondStarted = NewSignal(); + var releaseSecond = NewSignal(); + var firstStarted = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, cancellationToken) => + { + if (prepared.Version.Id == "free-second") + { + secondStarted.TrySetResult(); + await releaseSecond.Task.WaitAsync(cancellationToken); + } + else + { + firstStarted.TrySetResult(); + } + + return Result(prepared.Architecture); + }); + + await coordinator.StartAsync(); + await secondStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual(second.Id, coordinator.ActiveInstanceId); + busyLease!.Dispose(); + releaseSecond.TrySetResult(); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await WaitForStateAsync(store, first.Id, InstanceInstallationState.Ready); + await WaitForStateAsync(store, second.Id, InstanceInstallationState.Ready); + } + + [TestMethod] + public async Task EnqueueingSameIdRepeatedlyStartsOnlyOneInstallation() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Repeated Enqueue" }, CreatePrepared("repeat-enqueue", GameArchitecture.X64)); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Ready, pauseRequested: false); + Assert.IsTrue(store.TryBeginContent(instance.Id, out var busyLease, out _)); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Queued, pauseRequested: false); + var installCount = 0; + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (prepared, _, _, _) => + { + Interlocked.Increment(ref installCount); + return Task.FromResult(Result(prepared.Architecture)); + }); + + await coordinator.StartAsync(); + await coordinator.EnqueueAsync(instance.Id); + await coordinator.EnqueueAsync(instance.Id); + busyLease!.Dispose(); + await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + + Assert.AreEqual(1, installCount); + } + + [TestMethod] + public async Task ProgressIsThrottledByFractionAndTimeButStageChangesAreAlwaysReported() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Progress Throttle" }, CreatePrepared("progress-throttle", GameArchitecture.X64)); + var timeProvider = new ManualTimeProvider(); + var observed = new ConcurrentQueue(); + InstallationCoordinator? coordinator = null; + coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (prepared, _, progress, _) => + { + progress!.Report(new InstallationProgress(InstallationProgressStage.Downloading, 0, 1000)); + progress.Report(new InstallationProgress(InstallationProgressStage.Downloading, 1, 1000)); + timeProvider.Advance(TimeSpan.FromMilliseconds(149)); + progress.Report(new InstallationProgress(InstallationProgressStage.Downloading, 1, 1000)); + timeProvider.Advance(TimeSpan.FromMilliseconds(1)); + progress.Report(new InstallationProgress(InstallationProgressStage.Downloading, 1, 1000)); + progress.Report(new InstallationProgress(InstallationProgressStage.PreparingAssets, 1, 1000)); + progress.Report(new InstallationProgress(InstallationProgressStage.Complete, 1000, 1000)); + return Task.FromResult(Result(prepared.Architecture)); + }, + timeProvider); + coordinator.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(InstallationCoordinator.Progress) + && coordinator.Progress.TryGetValue(instance.Id, out var current)) + { + observed.Enqueue(current); + } + }; + + await coordinator.StartAsync(); + await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + + var reports = observed.ToArray(); + CollectionAssert.AreEqual(new long[] { 0, 1 }, reports + .Where(item => item.Stage == InstallationProgressStage.Downloading) + .Select(item => item.CompletedBytes).ToArray()); + Assert.IsTrue(reports.Any(item => item.Stage == InstallationProgressStage.PreparingAssets)); + Assert.IsTrue(reports.Any(item => item.Stage == InstallationProgressStage.Complete)); + } + + [TestMethod] + public async Task LoadErrorDuringInstallationIsReportedAsQueueError() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Load Error" }, CreatePrepared("load-error", GameArchitecture.X64)); + var installationStarted = NewSignal(); + var releaseInstall = NewSignal(); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + async (prepared, _, _, _) => + { + installationStarted.TrySetResult(); + await releaseInstall.Task; + return Result(prepared.Architecture); + }); + + await coordinator.StartAsync(); + await installationStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + await File.WriteAllTextAsync(Path.Combine(temporary.Paths.GameRoot, "instances.json"), "not-json"); + await store.LoadAsync(); + Assert.IsNotNull(store.LoadError); + var queueError = WaitForQueueErrorAsync(coordinator); + releaseInstall.TrySetResult(); + + var message = await queueError.WaitAsync(TimeSpan.FromSeconds(10)); + await WaitForActiveAsync(coordinator, null); + + Assert.IsFalse(string.IsNullOrWhiteSpace(message)); + Assert.IsNull(coordinator.ActiveInstanceId); + } + + [TestMethod] + public async Task RetryCanRecoverInstallingBuildWhenTerminalRegistryWriteFailed() + { + await using var temporary = new TemporaryDirectory(); + var originalStore = await LoadStoreAsync(temporary); + var instance = originalStore.Create(new InstanceDraft { Name = "Orphaned Install" }, CreatePrepared("orphaned-install", GameArchitecture.X64)); + var failTerminalWrite = 0; + var store = temporary.CreateStore(() => + { + if (Volatile.Read(ref failTerminalWrite) != 0) + { + throw new IOException("Injected terminal registry write failure."); + } + }); + await store.LoadAsync(); + var attempt = 0; + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (prepared, _, _, _) => + { + if (Interlocked.Increment(ref attempt) == 1) + { + Volatile.Write(ref failTerminalWrite, 1); + } + + return Task.FromResult(Result(prepared.Architecture)); + }); + + await coordinator.StartAsync(); + await WaitForQueueErrorAsync(coordinator); + await WaitForActiveAsync(coordinator, null); + Assert.AreEqual(InstanceInstallationState.Installing, store.Instances.Single(item => item.Id == instance.Id).State); + Assert.IsFalse(store.Instances.Single(item => item.Id == instance.Id).PauseRequested); + Assert.IsNotNull(coordinator.QueueError); + + Volatile.Write(ref failTerminalWrite, 0); + await coordinator.RetryAsync(instance.Id); + var ready = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Ready); + + Assert.AreEqual(2, attempt); + Assert.AreEqual(InstanceInstallationState.Ready, ready.State); + } + + [TestMethod] + public async Task CompleteInstallationValidationFailureIsPersistedAsFailed() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Architecture Mismatch" }, + CreatePrepared("architecture-mismatch", GameArchitecture.X64)); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (_, _, _, _) => Task.FromResult(Result(GameArchitecture.Arm64))); + + await coordinator.StartAsync(); + var failed = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Failed); + await WaitForActiveAsync(coordinator, null); + + Assert.AreEqual("storage.operationfailed", failed.ErrorCode); + StringAssert.Contains(failed.ErrorMessage!, "different architecture"); + Assert.IsFalse(failed.PauseRequested); + } + + [TestMethod] + public async Task TerminalRegistryWriteFailureFallsBackToFailedState() + { + await using var temporary = new TemporaryDirectory(); + var failNextCommit = 0; + var store = temporary.CreateStore(() => + { + if (Interlocked.Exchange(ref failNextCommit, 0) == 1) + { + throw new IOException("Injected terminal registry write failure."); + } + }); + await store.LoadAsync(); + var instance = store.Create(new InstanceDraft { Name = "Failed Terminal Write" }, + CreatePrepared("failed-terminal-write", GameArchitecture.X64)); + var coordinator = CreateCoordinator( + temporary, + store, + (version, pinned, _) => Task.FromResult(CreatePrepared(version.Id, pinned ?? GameArchitecture.X64)), + (prepared, _, _, _) => + { + Volatile.Write(ref failNextCommit, 1); + return Task.FromResult(Result(prepared.Architecture)); + }); + + await coordinator.StartAsync(); + var failed = await WaitForStateAsync(store, instance.Id, InstanceInstallationState.Failed); + await WaitForActiveAsync(coordinator, null); + + Assert.AreEqual("installation.unexpected", failed.ErrorCode); + StringAssert.Contains(failed.ErrorMessage!, "Injected terminal registry write failure."); + Assert.IsFalse(failed.PauseRequested); + Assert.AreEqual("Injected terminal registry write failure.", coordinator.QueueError); + } + + private static InstallationCoordinator CreateCoordinator( + TemporaryDirectory temporary, + InstanceStore store, + Func> prepare, + Func?, CancellationToken, Task> install, + TimeProvider? timeProvider = null) => + temporary.Track(new InstallationCoordinator(store, prepare, install, timeProvider)); + + private static PreparedInstallation CreatePrepared(string versionId, GameArchitecture architecture) + { + var version = new MojangVersionEntry + { + Id = versionId, + Type = "release", + Url = $"https://test.invalid/{versionId}.json", + Sha1 = new string('a', 40) + }; + var manifest = new MojangVersionMetadata + { + Id = versionId, + JavaVersion = new MojangJavaRequirement { Component = "java-runtime-gamma", MajorVersion = 17 } + }; + return new PreparedInstallation( + version, + architecture, + manifest, + [], + new MojangJavaRuntimePackage(), + new MojangJavaRuntimeManifest(), + [], + []); + } + + private static InstallationResult Result(GameArchitecture architecture) => + new(architecture, "bin/javaw.exe", 17, false); + + private static async Task LoadStoreAsync(TemporaryDirectory temporary) + { + var store = temporary.CreateStore(); + await store.LoadAsync(); + return store; + } + + private static async Task UpdateInstancesAsync( + InstanceStore store, + Func, List> update) + { + var registryPath = Path.Combine(store.Instances.Count > 0 + ? Path.GetDirectoryName(store.GetInstanceDirectory(store.Instances[0].Id))! + : throw new InvalidOperationException("At least one build is needed to update the test registry."), "instances.json"); + var registry = JsonSerializer.Deserialize(await File.ReadAllBytesAsync(registryPath), + HakoJsonContext.Default.InstanceRegistryFile)!; + var instances = update(registry.Instances); + var updated = registry with { Instances = instances }; + await File.WriteAllBytesAsync(registryPath, JsonSerializer.SerializeToUtf8Bytes(updated, + HakoJsonContext.Default.InstanceRegistryFile)); + await store.LoadAsync(); + } + + private static async Task WaitForStateAsync( + InstanceStore store, + Guid id, + InstanceInstallationState state) + { + GameInstance? matching = null; + var completed = NewSignal(); + void Check() + { + matching = store.Instances.FirstOrDefault(instance => instance.Id == id && instance.State == state); + if (matching is not null) + { + completed.TrySetResult(); + } + } + + EventHandler handler = (_, _) => Check(); + store.InstancesChanged += handler; + try + { + Check(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + return matching!; + } + finally + { + store.InstancesChanged -= handler; + } + } + + private static async Task WaitForActiveAsync(InstallationCoordinator coordinator, Guid? id) + { + var completed = NewSignal(); + void Check() + { + if (coordinator.ActiveInstanceId == id) + { + completed.TrySetResult(); + } + } + + PropertyChangedEventHandler handler = (_, args) => + { + if (args.PropertyName == nameof(InstallationCoordinator.ActiveInstanceId)) + { + Check(); + } + }; + coordinator.PropertyChanged += handler; + try + { + Check(); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + coordinator.PropertyChanged -= handler; + } + } + + private static async Task WaitForQueueErrorAsync(InstallationCoordinator coordinator) + { + var completed = NewSignal(); + void Check() + { + if (coordinator.QueueError is { } value) + { + completed.TrySetResult(value); + } + } + + PropertyChangedEventHandler handler = (_, args) => + { + if (args.PropertyName == nameof(InstallationCoordinator.QueueError)) + { + Check(); + } + }; + coordinator.PropertyChanged += handler; + try + { + Check(); + return await completed.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + coordinator.PropertyChanged -= handler; + } + } + + private static Task WaitForTerminalAndCheckAccessAsync( + InstanceStore store, + Guid id, + InstanceInstallationState expectedState) + { + var completed = NewSignal(); + var checking = 0; + EventHandler? handler = null; + handler = (_, _) => + { + var instance = store.Instances.FirstOrDefault(item => item.Id == id); + if (instance?.State != expectedState || Interlocked.Exchange(ref checking, 1) != 0) + { + return; + } + + store.InstancesChanged -= handler; + Exception? error = null; + try + { + if (!store.TryBeginLaunch(id, out var launchLease, out var reason)) + { + throw new InvalidOperationException($"TryBeginLaunch failed at {expectedState} with {reason}."); + } + + launchLease!.Dispose(); + store.Rename(id, "After Terminal"); + store.Delete(id); + } + catch (Exception exception) + { + error = exception; + } + + completed.TrySetResult(new TerminalEventResult(instance, error)); + }; + store.InstancesChanged += handler; + return completed.Task; + } + + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed record TerminalEventResult(GameInstance Instance, Exception? Error); + + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => Interlocked.Read(ref _timestamp); + public void Advance(TimeSpan elapsed) => Interlocked.Add(ref _timestamp, elapsed.Ticks); + } + + private static void UpdateMaximum(ref int target, int value) + { + var current = Volatile.Read(ref target); + while (value > current) + { + var previous = Interlocked.CompareExchange(ref target, value, current); + if (previous == current) + { + return; + } + + current = previous; + } + } + + private sealed class TemporaryDirectory : IDisposable, IAsyncDisposable + { + private readonly List _resources = []; + + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Hako.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + Paths = HakoPaths.CreateIsolated(System.IO.Path.Combine(Path, "game"), System.IO.Path.Combine(Path, "data")); + } + + public string Path { get; } + public HakoPaths Paths { get; } + + public InstanceStore CreateStore( + Action? beforeRegistryCommit = null, + Action? beforeDeleteStagingCleanup = null) => + Track(new InstanceStore(Paths, beforeRegistryCommit, beforeDeleteStagingCleanup)); + + public T Track(T resource) where T : IAsyncDisposable + { + _resources.Add(resource); + return resource; + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + public async ValueTask DisposeAsync() + { + List? errors = null; + for (var index = _resources.Count - 1; index >= 0; index--) + { + try + { + await _resources[index].DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + errors ??= []; + errors.Add(exception); + } + } + + if (errors is not null) + { + throw new AggregateException("Failed to dispose test resources.", errors); + } + + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} diff --git a/windows/Hako.Tests/InstanceBackupsAndScreenshotsTests.cs b/windows/Hako.Tests/InstanceBackupsAndScreenshotsTests.cs new file mode 100644 index 0000000..93168ee --- /dev/null +++ b/windows/Hako.Tests/InstanceBackupsAndScreenshotsTests.cs @@ -0,0 +1,585 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstanceBackupsAndScreenshotsTests +{ + private static readonly DateTimeOffset BackupTime = + new DateTimeOffset(2026, 10, 23, 14, 7, 8, TimeSpan.Zero).AddTicks(9876543); + + [TestMethod] + public async Task ScreenshotsListDirectVisiblePngFilesInNewestFirstOrderAndMissingFolderStaysMissing() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var screenshots = new InstanceScreenshots(store); + + Assert.AreEqual(0, (await screenshots.ListAsync(instance.Id)).Count); + var folder = Path.Combine(root, "minecraft", "screenshots"); + Assert.IsFalse(Directory.Exists(folder)); + + Directory.CreateDirectory(folder); + var newest = Path.Combine(folder, "new.png"); + var firstByName = Path.Combine(folder, "a.png"); + var secondByName = Path.Combine(folder, "z.png"); + await File.WriteAllBytesAsync(newest, [1]); + await File.WriteAllBytesAsync(firstByName, [2]); + await File.WriteAllBytesAsync(secondByName, [3]); + await File.WriteAllBytesAsync(Path.Combine(folder, "not-png.jpg"), [4]); + await File.WriteAllBytesAsync(Path.Combine(folder, ".hidden.png"), [5]); + Directory.CreateDirectory(Path.Combine(folder, "folder.png")); + File.SetAttributes(Path.Combine(folder, ".hidden.png"), + File.GetAttributes(Path.Combine(folder, ".hidden.png")) | FileAttributes.Hidden); + var sameTime = new DateTime(2026, 10, 23, 14, 7, 8, DateTimeKind.Utc); + File.SetCreationTimeUtc(firstByName, sameTime); + File.SetCreationTimeUtc(secondByName, sameTime); + File.SetCreationTimeUtc(newest, sameTime.AddMinutes(1)); + + var listed = await screenshots.ListAsync(instance.Id); + + CollectionAssert.AreEqual(new[] { "new.png", "a.png", "z.png" }, listed.Select(item => item.Name).ToArray()); + } + + [TestMethod] + public async Task ScreenshotTrashRevalidatesOwnershipAndReturnsPartialRecycleResultsWhileGameRuns() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var folder = Path.Combine(root, "minecraft", "screenshots"); + Directory.CreateDirectory(folder); + var kept = Path.Combine(folder, "kept.png"); + var failed = Path.Combine(folder, "failed.png"); + var outside = Path.Combine(temporary.Root, "outside.png"); + await File.WriteAllBytesAsync(kept, [1]); + await File.WriteAllBytesAsync(failed, [2]); + await File.WriteAllBytesAsync(outside, [3]); + var operation = new CallbackRecycleOperation((path, _) => + { + if (path == Path.GetFullPath(failed)) return Task.FromResult(RecycleBinItemStatus.Failed); + File.Delete(path); + return Task.FromResult(RecycleBinItemStatus.Recycled); + }); + var screenshots = new InstanceScreenshots(store, new RecycleBin(operation)); + Assert.IsTrue(store.TryBeginLaunch(instance.Id, out var launchLease, out _)); + using (launchLease) + { + var result = await screenshots.TrashAsync(instance.Id, + [ + new InstanceScreenshot(kept, null), + new InstanceScreenshot(failed, null), + new InstanceScreenshot(outside, null) + ]); + + CollectionAssert.AreEqual(new[] + { + RecycleBinItemStatus.Recycled, RecycleBinItemStatus.Failed, RecycleBinItemStatus.Failed + }, result.Results.Items.Select(item => item.Status).ToArray()); + } + + Assert.IsFalse(File.Exists(kept)); + Assert.IsTrue(File.Exists(failed)); + Assert.IsTrue(File.Exists(outside)); + } + + [TestMethod] + public async Task ScreenshotListingAndTrashRejectScreenshotFolderJunctions() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var screenshotParent = Path.Combine(root, "minecraft"); + Directory.CreateDirectory(screenshotParent); + var target = Path.Combine(temporary.Root, "outside-screenshots"); + Directory.CreateDirectory(target); + await File.WriteAllBytesAsync(Path.Combine(target, "outside.png"), [9]); + using var junction = CreateJunction(temporary.Root, Path.Combine(screenshotParent, "screenshots"), target); + + await Assert.ThrowsExactlyAsync(() => new InstanceScreenshots(store).ListAsync(instance.Id)); + await Assert.ThrowsExactlyAsync(() => new InstanceScreenshots(store).TrashAsync(instance.Id, + [new InstanceScreenshot(Path.Combine(target, "outside.png"), null)])); + Assert.IsTrue(File.Exists(Path.Combine(target, "outside.png"))); + } + + [TestMethod] + public async Task DuplicateCopiesIndependentReadyFabricProfileAndModpackStateButStripsLaunchArtifacts() + { + await using var temporary = new TemporaryDirectory(); + var plan = CompletedModpackPlan(); + var (store, source, root) = await temporary.CreateReadyInstanceAsync("Build", + new InstanceDraft + { + Name = "Build", + IconSymbol = "", + IconData = [10, 11, 12], + ModLoader = InstanceModLoader.Fabric, + FabricConfiguration = new FabricConfiguration("0.16.9"), + ArgumentSource = LaunchArgumentSource.Custom, + OfflineMode = true, + OfflineUsername = "PreviewUser", + Parameters = new InstanceParameters { JavaArguments = "-Dfixture=true", MaximumMemoryMiB = 3072 } + }, plan); + Directory.CreateDirectory(Path.Combine(root, "java", "runtime")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", "mods")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", ".hako-launch")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", "logs")); + Directory.CreateDirectory(Path.Combine(root, ".hako-game")); + await File.WriteAllBytesAsync(Path.Combine(root, "java", "runtime", "javaw.exe"), [21, 22, 23]); + await File.WriteAllBytesAsync(Path.Combine(root, "minecraft", "mods", "local.jar"), [31, 32, 33]); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", ".hako-running.json"), "{}"); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", ".hako-launch", "args.json"), "session"); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", "logs", "hako-launch.log"), "session log"); + await File.WriteAllTextAsync(Path.Combine(root, ".hako-game", "arguments"), "transient"); + var occupied = store.Create(new InstanceDraft { Name = "Build 2" }, "1.20.4"); + var occupiedFolder = store.GetInstanceDirectory(occupied.Id); + await File.WriteAllTextAsync(Path.Combine(occupiedFolder, "owner.txt"), "keep"); + await temporary.SetReadyAsync(store, occupied.Id); + + var duplicate = await store.DuplicateAsync(source.Id); + var duplicateRoot = store.GetInstanceDirectory(duplicate.Id); + + Assert.AreEqual("Build 3", duplicate.Name); + Assert.AreNotEqual(source.Id, duplicate.Id); + Assert.IsTrue(duplicate.CreatedAt > source.CreatedAt); + Assert.AreEqual(source.IconSymbol, duplicate.IconSymbol); + Assert.AreNotEqual(source.IconRevision, duplicate.IconRevision); + Assert.AreEqual(source.FabricConfiguration, duplicate.FabricConfiguration); + Assert.AreEqual(source.Parameters, duplicate.Parameters); + Assert.IsTrue(duplicate.OfflineMode); + Assert.AreEqual("PreviewUser", duplicate.OfflineUsername); + CollectionAssert.AreEqual(new byte[] { 10, 11, 12 }, await File.ReadAllBytesAsync(Path.Combine(duplicateRoot, "icon.png"))); + CollectionAssert.AreEqual(new byte[] { 21, 22, 23 }, await File.ReadAllBytesAsync(Path.Combine(duplicateRoot, "java", "runtime", "javaw.exe"))); + CollectionAssert.AreEqual(new byte[] { 31, 32, 33 }, await File.ReadAllBytesAsync(Path.Combine(duplicateRoot, "minecraft", "mods", "local.jar"))); + Assert.IsFalse(File.Exists(Path.Combine(duplicateRoot, "minecraft", ".hako-running.json"))); + Assert.IsFalse(Directory.Exists(Path.Combine(duplicateRoot, "minecraft", ".hako-launch"))); + Assert.IsFalse(File.Exists(Path.Combine(duplicateRoot, "minecraft", "logs", "hako-launch.log"))); + Assert.IsFalse(Directory.Exists(Path.Combine(duplicateRoot, ".hako-game"))); + Assert.IsTrue(ModpackPlan.Read(duplicateRoot)!.Completed); + Assert.AreEqual("keep", await File.ReadAllTextAsync(Path.Combine(occupiedFolder, "owner.txt"))); + Assert.IsTrue(File.Exists(Path.Combine(root, "minecraft", ".hako-running.json"))); + } + + [TestMethod] + public async Task DuplicateRollbackLeavesSourceAndPreexistingUserFoldersUntouchedWhenRegistryCommitFails() + { + await using var temporary = new TemporaryDirectory(); + var initial = await temporary.CreateReadyInstanceAsync("Original"); + await File.WriteAllTextAsync(Path.Combine(initial.Root, "user.txt"), "source"); + var failing = await temporary.LoadStoreAsync(() => throw new IOException("registry fault")); + + await Assert.ThrowsExactlyAsync(() => failing.DuplicateAsync(initial.Instance.Id)); + + Assert.AreEqual(1, failing.List().Count); + Assert.AreEqual("source", await File.ReadAllTextAsync(Path.Combine(initial.Root, "user.txt"))); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Original 2"))); + Assert.AreEqual(0, Directory.GetDirectories(temporary.Paths.GameRoot, ".hako-duplicate-*").Length); + } + + [TestMethod] + public async Task DuplicateRejectsLockedSourceFilesAndPrecancelledCopyWithoutPublishing() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var file = Path.Combine(root, "user.dat"); + await File.WriteAllTextAsync(file, "keep"); + using (new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.None)) + await Assert.ThrowsExactlyAsync(() => store.DuplicateAsync(instance.Id)); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAsync(() => store.DuplicateAsync(instance.Id, cancellation.Token)); + Assert.AreEqual("keep", await File.ReadAllTextAsync(file)); + Assert.AreEqual(1, store.List().Count); + Assert.AreEqual(0, Directory.GetDirectories(temporary.Paths.GameRoot, ".hako-duplicate-*").Length); + } + + [TestMethod] + public async Task DuplicateRejectsNestedJunctionWithoutReadingOrChangingItsTarget() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var target = Path.Combine(temporary.Root, "external-tree"); + Directory.CreateDirectory(target); + var sentinel = Path.Combine(target, "keep.txt"); + await File.WriteAllTextAsync(sentinel, "outside"); + using var junction = CreateJunction(temporary.Root, Path.Combine(root, "minecraft", "linked"), target); + + var exception = await Assert.ThrowsExactlyAsync(() => store.DuplicateAsync(instance.Id)); + + Assert.AreEqual(InstanceStorageError.ReparsePointNotAllowed, exception.Error); + Assert.AreEqual("outside", await File.ReadAllTextAsync(sentinel)); + Assert.AreEqual(1, store.List().Count); + Assert.AreEqual(0, Directory.GetDirectories(temporary.Paths.GameRoot, ".hako-duplicate-*").Length); + } + + [TestMethod] + public async Task InstanceBackupMatchesMacManifestAndStreamsWholeInstanceWithoutTransientSessionFiles() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(draft: new InstanceDraft + { + Name = "Build", + IconSymbol = "", + IconData = [8, 9] + }); + Directory.CreateDirectory(Path.Combine(root, "java", "runtime")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", "mods")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", ".hako-launch")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", "logs")); + Directory.CreateDirectory(Path.Combine(root, ".hako-game")); + await File.WriteAllBytesAsync(Path.Combine(root, "java", "runtime", "javaw.exe"), [1, 2, 3]); + await File.WriteAllBytesAsync(Path.Combine(root, "minecraft", "mods", "local.jar"), [4, 5, 6]); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", ".hako-running.json"), "{}"); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", ".hako-launch", "args.json"), "session"); + await File.WriteAllTextAsync(Path.Combine(root, ".hako-game", "arguments"), "transient"); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", "logs", "hako-launch.log"), "transient launch log"); + var modHash = Convert.ToHexString(SHA512.HashData([4, 5, 6])).ToLowerInvariant(); + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", ".hako-mods.json"), + "{\"apiProvisioned\":false,\"files\":{\"local.jar\":{\"source\":\"modrinth\",\"projectID\":\"project-id\",\"versionID\":\"version-id\",\"pageURL\":\"https://modrinth.com/mod/project-id\",\"sha512\":\"" + + modHash + "\"}}}"); + var content = new InstanceContent(store); + var backup = new InstanceBackup(store, content, () => BackupTime, null); + + var result = await backup.CreateAsync(instance.Id); + + Assert.IsTrue(result.Succeeded, result.Failure?.Diagnostic); + using var stream = File.OpenRead(result.Path!); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + CollectionAssert.AreEquivalent(new[] + { + "data.json", "icon.png", "java/", "java/runtime/", "java/runtime/javaw.exe", + "minecraft/", "minecraft/logs/", "minecraft/mods/", "minecraft/mods/local.jar", + "minecraft/.hako-mods.json" + }, archive.Entries.Select(entry => entry.FullName).ToArray()); + var json = await ReadJsonAsync(archive.GetEntry("data.json")!); + Assert.AreEqual(1, json.RootElement.GetProperty("formatVersion").GetInt32()); + Assert.AreEqual("2026-10-23T14:07:08Z", json.RootElement.GetProperty("backupCreatedAt").GetString()); + Assert.AreEqual(instance.Name, json.RootElement.GetProperty("name").GetString()); + Assert.AreEqual(instance.FolderName, json.RootElement.GetProperty("folderName").GetString()); + Assert.IsFalse(json.RootElement.GetProperty("profile").GetProperty("offlineMode").GetBoolean()); + Assert.AreEqual("mojang", json.RootElement.GetProperty("profile").GetProperty("argumentSource").GetString()); + CollectionAssert.AreEqual(new byte[] { 8, 9 }, Convert.FromBase64String(json.RootElement.GetProperty("iconPNG").GetString()!)); + Assert.IsFalse(json.RootElement.TryGetProperty("loaderVersion", out _)); + Assert.AreEqual("project-id", json.RootElement.GetProperty("mods")[0].GetProperty("projectID").GetString()); + Assert.AreEqual("version-id", json.RootElement.GetProperty("mods")[0].GetProperty("versionID").GetString()); + Assert.IsFalse(archive.Entries.Any(entry => entry.FullName.EndsWith("hako-launch.log", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task WorldBackupPreservesNestedDataJsonDatapackOriginsHashesAndSwiftDateShape() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var world = Path.Combine(root, "minecraft", "saves", "Save"); + Directory.CreateDirectory(Path.Combine(world, "datapacks", "local-folder")); + Directory.CreateDirectory(Path.Combine(world, ".hako-disabled-datapacks")); + await File.WriteAllBytesAsync(Path.Combine(world, "level.dat"), + WorldTestFixture.Create("Save", lastPlayed: BackupTime.ToUnixTimeMilliseconds()).Compressed); + await File.WriteAllTextAsync(Path.Combine(world, "data.json"), "{\"save\":\"mine\"}"); + var packBytes = CreateDatapackZip(); + await File.WriteAllBytesAsync(Path.Combine(world, "datapacks", "enabled.zip"), packBytes); + await File.WriteAllBytesAsync(Path.Combine(world, ".hako-disabled-datapacks", "disabled.zip"), packBytes); + await File.WriteAllTextAsync(Path.Combine(world, "datapacks", "local-folder", "pack.mcmeta"), + "{\"pack\":{\"pack_format\":48,\"description\":\"folder pack\"}}"); + await File.WriteAllBytesAsync(Path.Combine(world, "datapacks", "local-folder", "pack.png"), [20, 21]); + await File.WriteAllBytesAsync(Path.Combine(world, "icon.png"), [30, 31]); + var hash = Convert.ToHexString(SHA512.HashData(packBytes)).ToLowerInvariant(); + await File.WriteAllTextAsync(Path.Combine(world, ".hako-datapacks.json"), + "{\"files\":{\"enabled.zip\":{\"source\":\"modrinth\",\"projectID\":\"project\",\"versionID\":\"version\",\"pageURL\":\"https://modrinth.com/datapack/project\",\"sha512\":\"" + + hash + "\"}}}"); + var backups = new WorldBackup(store, new InstanceContent(store), new InstanceWorlds(store), + () => BackupTime, null); + + var result = await backups.CreateAsync(instance.Id, "Save"); + + Assert.IsTrue(result.Succeeded, result.Failure?.Diagnostic); + using var stream = File.OpenRead(result.Path!); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + Assert.IsNotNull(archive.GetEntry("data.json")); + Assert.IsNotNull(archive.GetEntry("world/data.json")); + Assert.AreEqual("{\"save\":\"mine\"}", await ReadEntryTextAsync(archive.GetEntry("world/data.json")!)); + Assert.AreEqual("{\"files\":{\"enabled.zip\":{\"source\":\"modrinth\",\"projectID\":\"project\",\"versionID\":\"version\",\"pageURL\":\"https://modrinth.com/datapack/project\",\"sha512\":\"" + + hash + "\"}}}", await ReadEntryTextAsync(archive.GetEntry("world/.hako-datapacks.json")!)); + var json = await ReadJsonAsync(archive.GetEntry("data.json")!); + Assert.AreEqual("Save", json.RootElement.GetProperty("name").GetString()); + Assert.AreEqual("Save", json.RootElement.GetProperty("folderName").GetString()); + Assert.AreEqual("world", json.RootElement.GetProperty("worldPath").GetString()); + Assert.AreEqual("2026-10-23T14:07:08Z", json.RootElement.GetProperty("backupCreatedAt").GetString()); + Assert.AreEqual("2026-10-23T14:07:08Z", json.RootElement.GetProperty("lastPlayed").GetString()); + CollectionAssert.AreEqual(new byte[] { 30, 31 }, Convert.FromBase64String(json.RootElement.GetProperty("iconPNG").GetString()!)); + var packs = json.RootElement.GetProperty("datapacks").EnumerateArray().ToArray(); + var enabled = packs.Single(item => item.GetProperty("file").GetString() == "enabled.zip"); + Assert.AreEqual("datapacks/enabled.zip", enabled.GetProperty("path").GetString()); + Assert.AreEqual(hash, enabled.GetProperty("sha512").GetString()); + var disabled = packs.Single(item => item.GetProperty("file").GetString() == "disabled.zip"); + Assert.IsFalse(disabled.GetProperty("enabled").GetBoolean()); + Assert.AreEqual(".hako-disabled-datapacks/disabled.zip", disabled.GetProperty("path").GetString()); + var folderPack = packs.Single(item => item.GetProperty("file").GetString() == "local-folder"); + Assert.IsTrue(folderPack.GetProperty("isDirectory").GetBoolean()); + Assert.IsFalse(folderPack.TryGetProperty("sha512", out _)); + CollectionAssert.AreEqual(new byte[] { 20, 21 }, Convert.FromBase64String(folderPack.GetProperty("iconPNG").GetString()!)); + } + + [TestMethod] + public async Task BackupCollisionNeverOverwritesAndCancellationRemovesOwnedPartialArchive() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var backupsDirectory = Path.Combine(temporary.Paths.GameRoot, InstanceStorage.BackupsFolder); + Directory.CreateDirectory(backupsDirectory); + var target = Path.Combine(backupsDirectory, $"{instance.FolderName}_2026-10-23_14-07-08.hakobackup"); + await File.WriteAllTextAsync(target, "keep"); + var backup = new InstanceBackup(store, new InstanceContent(store), () => BackupTime, null); + + var conflict = await backup.CreateAsync(instance.Id); + + Assert.IsFalse(conflict.Succeeded); + Assert.AreEqual(BackupFailureReason.DestinationConflict, conflict.Failure!.Reason); + Assert.AreEqual("keep", await File.ReadAllTextAsync(target)); + + File.Delete(target); + using var cancellation = new CancellationTokenSource(); + var cancelledBackup = new InstanceBackup(store, new InstanceContent(store), () => BackupTime, cancellation.Cancel); + var cancelled = await cancelledBackup.CreateAsync(instance.Id, cancellation.Token); + Assert.AreEqual(BackupFailureReason.Cancelled, cancelled.Failure!.Reason); + Assert.AreEqual(0, Directory.GetFiles(backupsDirectory, "*.partial").Length); + Assert.AreEqual(0, Directory.GetFiles(backupsDirectory, ".*.partial").Length); + Assert.IsTrue(Directory.Exists(root)); + } + + [TestMethod] + public async Task BackupDateOptionalFieldsAndLargeFileRoundTripWithoutLoadingArchiveAsOneBuffer() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var largePath = Path.Combine(root, "java", "large-runtime.bin"); + var expectedHash = await WriteLargeFileAsync(largePath, 20 * 1024 * 1024); + var backup = new InstanceBackup(store, new InstanceContent(store), () => BackupTime, null); + + var result = await backup.CreateAsync(instance.Id); + + Assert.IsTrue(result.Succeeded, result.Failure?.Diagnostic); + using var stream = File.OpenRead(result.Path!); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var json = await ReadJsonAsync(archive.GetEntry("data.json")!); + Assert.IsFalse(json.RootElement.TryGetProperty("iconPNG", out _)); + Assert.IsFalse(json.RootElement.TryGetProperty("loaderVersion", out _)); + Assert.IsFalse(json.RootElement.GetProperty("profile").TryGetProperty("fabricConfiguration", out _)); + await using var extracted = archive.GetEntry("java/large-runtime.bin")!.Open(); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[81920]; + while (true) + { + var read = await extracted.ReadAsync(buffer); + if (read == 0) break; + hash.AppendData(buffer, 0, read); + } + Assert.AreEqual(expectedHash, Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant()); + } + + [TestMethod] + public async Task WorldBackupCollisionNeverOverwritesAndCancellationRemovesPartialArchive() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await temporary.CreateReadyInstanceAsync(); + var world = Path.Combine(root, "minecraft", "saves", "Save"); + Directory.CreateDirectory(world); + await File.WriteAllBytesAsync(Path.Combine(world, "level.dat"), + WorldTestFixture.Create("Save", lastPlayed: BackupTime.ToUnixTimeMilliseconds()).Compressed); + await File.WriteAllTextAsync(Path.Combine(world, "data.json"), "{\"save\":\"kept\"}"); + var content = new InstanceContent(store); + var worlds = new InstanceWorlds(store); + var backup = new WorldBackup(store, content, worlds, () => BackupTime, null); + + var created = await backup.CreateAsync(instance.Id, "Save"); + + Assert.IsTrue(created.Succeeded, created.Failure?.Diagnostic); + var expectedPath = Path.Combine(temporary.Paths.GameRoot, InstanceStorage.WorldsFolder, + $"{instance.FolderName}_Save_2026-10-23_14-07-08.hakoworld"); + Assert.AreEqual(expectedPath, created.Path); + var originalBytes = await File.ReadAllBytesAsync(expectedPath); + var conflict = await backup.CreateAsync(instance.Id, "Save"); + Assert.AreEqual(BackupFailureReason.DestinationConflict, conflict.Failure!.Reason); + CollectionAssert.AreEqual(originalBytes, await File.ReadAllBytesAsync(expectedPath)); + + File.Delete(expectedPath); + using var cancellation = new CancellationTokenSource(); + var cancelledBackup = new WorldBackup(store, content, worlds, () => BackupTime, cancellation.Cancel); + var cancelled = await cancelledBackup.CreateAsync(instance.Id, "Save", cancellation.Token); + + Assert.AreEqual(BackupFailureReason.Cancelled, cancelled.Failure!.Reason); + var outputDirectory = Path.GetDirectoryName(expectedPath)!; + Assert.AreEqual(0, Directory.GetFiles(outputDirectory, "*.partial").Length); + Assert.AreEqual(0, Directory.GetFiles(outputDirectory, ".*.partial").Length); + Assert.AreEqual("{\"save\":\"kept\"}", await File.ReadAllTextAsync(Path.Combine(world, "data.json"))); + } + + private static ModpackPlan CompletedModpackPlan() + { + var bytes = Encoding.UTF8.GetBytes("completed modpack archive"); + var plan = new ModpackPlan + { + ProjectId = "fixturepack", + VersionId = "fixtureversion", + VersionNumber = "1.0", + PageUrl = new Uri("https://modrinth.com/modpack/fixturepack"), + Archive = new ModpackFile + { + Filename = "fixture.mrpack", + Url = new Uri("https://cdn.modrinth.com/data/fixturepack/versions/fixtureversion/fixture.mrpack"), + Size = bytes.Length, + Hashes = new Dictionary + { + ["sha1"] = Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant(), + ["sha512"] = Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant() + }, + Primary = true + }, + Manifest = new ModpackManifest + { + FormatVersion = 1, + Game = "minecraft", + VersionId = "fixtureversion", + Name = "Fixture pack", + Dependencies = new Dictionary { ["minecraft"] = "1.20.4", ["fabric-loader"] = "0.16.9" } + }, + Completed = true + }; + plan.Validate(); + return plan; + } + + private static byte[] CreateDatapackZip() + { + using var output = new MemoryStream(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + using (var writer = new StreamWriter(archive.CreateEntry("pack.mcmeta").Open())) + writer.Write("{\"pack\":{\"pack_format\":48,\"description\":\"fixture\"}}"); + return output.ToArray(); + } + + private static async Task WriteLargeFileAsync(string path, int length) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA512); + var buffer = new byte[81920]; + for (var index = 0; index < buffer.Length; index++) buffer[index] = (byte)(index * 31); + await using var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + var remaining = length; + while (remaining > 0) + { + var count = Math.Min(remaining, buffer.Length); + await output.WriteAsync(buffer.AsMemory(0, count)); + hash.AppendData(buffer, 0, count); + remaining -= count; + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static async Task ReadJsonAsync(ZipArchiveEntry entry) + { + await using var stream = entry.Open(); + return await JsonDocument.ParseAsync(stream); + } + + private static async Task ReadEntryTextAsync(ZipArchiveEntry entry) + { + await using var stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + private static JunctionCleanup CreateJunction(string root, string junction, string target) + { + var command = "$ErrorActionPreference = 'Stop'; New-Item -ItemType Junction -Path '" + + junction.Replace("'", "''") + "' -Target '" + target.Replace("'", "''") + "' | Out-Null"; + using var process = Process.Start(new ProcessStartInfo("powershell.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command } + }) ?? throw new InvalidOperationException("Could not start PowerShell to create the isolated test junction."); + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, process.StandardError.ReadToEnd()); + Assert.IsTrue((File.GetAttributes(junction) & FileAttributes.ReparsePoint) != 0); + return new JunctionCleanup(root, junction, target); + } + + private sealed class JunctionCleanup(string root, string junction, string target) : IDisposable + { + public void Dispose() + { + var fullRoot = Path.GetFullPath(root) + Path.DirectorySeparatorChar; + Assert.IsTrue(Path.GetFullPath(junction).StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(Path.GetFullPath(target).StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)); + if ((File.GetAttributes(junction) & FileAttributes.ReparsePoint) != 0) Directory.Delete(junction); + Assert.IsTrue(Directory.Exists(target)); + } + } + + private sealed class CallbackRecycleOperation(Func> action) + : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) => + action(path, cancellationToken); + } + + private sealed class TemporaryDirectory : IDisposable, IAsyncDisposable + { + internal string Root { get; } = Path.Combine(Path.GetTempPath(), "Hako.Tests", Guid.NewGuid().ToString("N")); + internal HakoPaths Paths { get; } + + internal TemporaryDirectory() + { + Paths = HakoPaths.CreateIsolated(Path.Combine(Root, "Game"), Path.Combine(Root, "Data")); + Directory.CreateDirectory(Root); + } + + internal async Task LoadStoreAsync(Action? beforeRegistryCommit = null) + { + var store = new InstanceStore(Paths, beforeRegistryCommit); + await store.LoadAsync(); + return store; + } + + internal async Task<(InstanceStore Store, GameInstance Instance, string Root)> CreateReadyInstanceAsync( + string name = "Build", InstanceDraft? draft = null, ModpackPlan? plan = null) + { + var store = await LoadStoreAsync(); + var sourceDraft = draft ?? new InstanceDraft { Name = name }; + sourceDraft = sourceDraft with { Name = name }; + var instance = store.Create(sourceDraft, "1.20.4", "https://example.test/version", "fixture-sha1", + javaMajorVersion: 21, modpackPlan: plan); + await SetReadyAsync(store, instance.Id); + instance = store.List().Single(item => item.Id == instance.Id); + return (store, instance, store.GetInstanceDirectory(instance.Id)); + } + + internal async Task SetReadyAsync(InstanceStore store, Guid id) + { + await Task.Run(() => store.SetInstallationStatus(id, InstanceInstallationState.Ready, pauseRequested: false)); + } + + public void Dispose() + { + var fullRoot = Path.GetFullPath(Root); + if (Directory.Exists(fullRoot)) Directory.Delete(fullRoot, recursive: true); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/windows/Hako.Tests/InstanceContentControllerTests.cs b/windows/Hako.Tests/InstanceContentControllerTests.cs new file mode 100644 index 0000000..0c6f04a --- /dev/null +++ b/windows/Hako.Tests/InstanceContentControllerTests.cs @@ -0,0 +1,1920 @@ +using System.IO.Compression; +using System.Net; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstanceContentControllerTests +{ + [TestMethod] + public async Task InstallWalksRequiredDependenciesBreadthFirstDeduplicatesCyclesAndResolvesVersionOnly() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.ObserveContentDirectory(Path.Combine(root, "minecraft", "mods")); + server.AddProject("rootmod1", "Root", "mod"); + server.AddProject("depmod01", "Dependency mod", "mod"); + server.AddProject("packdep1", "Dependency pack", "resourcepack"); + var rootFile = server.File("root.jar", Encoding.UTF8.GetBytes("root")); + var modFile = server.File("dep.jar", Encoding.UTF8.GetBytes("dependency")); + var packFile = server.File("dep.zip", Encoding.UTF8.GetBytes("resourcepack")); + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [rootFile], + [Dependency(projectId: "depmod01"), Dependency(projectId: "depmod01"), Dependency(versionId: "packver01"), Dependency(projectId: "packdep1", type: "optional")]); + server.AddVersion("depmod01", "depver001", "1.0", "release", "2025-01-02T00:00:00Z", + ModrinthContentKind.Mod, [modFile], [Dependency(projectId: "rootmod1")]); + server.AddVersion("packdep1", "packver01", "1.0", "release", "2025-01-03T00:00:00Z", + ModrinthContentKind.ResourcePack, [packFile]); + + var requests = new List(); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (request, _) => { requests.Add(request); return Task.FromResult(ContentConfirmationChoice.Primary); }); + + InstanceContentState? packInitial = null; + Task? packRead = null; + Task? packCheck = null; + controller.StateChanged += (_, args) => + { + if (args.Key.Kind != ModrinthContentKind.ResourcePack || packInitial is not null) return; + packInitial = args.State; + packRead = controller.ReloadAsync(instance.Id, ModrinthContentKind.ResourcePack); + packCheck = controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.ResourcePack); + }; + var result = await controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, + server.Project("rootmod1")); + await Task.WhenAll(packRead!, packCheck!); + Assert.IsTrue(packInitial!.IsBusy); + var packState = controller.GetState(instance.Id, ModrinthContentKind.ResourcePack); + Assert.AreEqual(packInitial.Revision, packState.Revision); + Assert.IsFalse(packState.IsBusy); + Assert.AreEqual("dep.zip", packState.Items.Single().Name); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + Assert.AreEqual(1, requests.Count); + Assert.AreEqual(ContentConfirmationCode.Dependencies, requests[0].Code); + Assert.IsTrue(requests[0].PrimaryActionAvailable); + CollectionAssert.AreEquivalent(new[] { "depmod01", "packdep1" }, requests[0].Projects.Select(project => project.Id).ToArray()); + Assert.AreEqual(3, server.DownloadRequests); + Assert.IsTrue(server.AllDownloadsSawEmptyContentDirectory, "No file may be published before every planned download finishes."); + var mods = await content.ListAsync(instance.Id, ModrinthContentKind.Mod); + var packs = await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack); + Assert.AreEqual("depmod01", mods.Single(item => item.Name == "dep.jar").Origin?.ProjectID); + Assert.AreEqual("rootmod1", mods.Single(item => item.Name == "root.jar").Origin?.ProjectID); + Assert.AreEqual("packdep1", packs.Single().Origin?.ProjectID); + Assert.AreEqual(root, store.GetInstanceDirectory(instance.Id)); + } + + [TestMethod] + public async Task DisabledDependencyIsOfferedForEnableAndUnsupportedOrMissingDependenciesAreExplained() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("rootmod1", "Root", "mod"); + server.AddProject("depmod01", "Disabled dependency", "mod"); + server.AddProject("missing01", "Missing compatible version", "mod"); + server.AddProject("datapack1", "Unsupported dependency", "datapack"); + var disabledBytes = Encoding.UTF8.GetBytes("installed dependency"); + var source = Path.Combine(temporary.Root, "dep.jar"); + await File.WriteAllBytesAsync(source, disabledBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + var modFolder = Path.Combine(root, "minecraft", "mods"); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = false, + Files = new Dictionary(StringComparer.Ordinal) + { + ["dep.jar"] = Origin("depmod01", "depver001", disabledBytes) + } + }); + var disabled = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + await content.SetEnabledAsync(instance.Id, disabled, ModrinthContentKind.Mod, enabled: false); + var rootFile = server.File("root.jar", Encoding.UTF8.GetBytes("root")); + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [rootFile], + [Dependency(projectId: "depmod01"), Dependency(projectId: "missing01"), Dependency(projectId: "datapack1")]); + server.AddVersion("missing01", "missingold", "old", "release", "2024-12-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("missing-old.jar", [1])], gameVersions: ["1.20.3"]); + + ContentConfirmationRequest? request = null; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (value, _) => { request = value; return Task.FromResult(ContentConfirmationChoice.Primary); }); + var result = await controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1")); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + Assert.IsNotNull(request); + Assert.IsTrue(request!.Projects.Any(project => project.Id == "depmod01" + && project.NoteCode == ContentConfirmationNoteCode.DisabledWillBeEnabled)); + Assert.IsTrue(request.Projects.Any(project => project.Id == "missing01" + && project.NoteCode == ContentConfirmationNoteCode.NoCompatibleVersion)); + Assert.IsTrue(request.Projects.Any(project => project.Id == "datapack1" + && project.NoteCode == ContentConfirmationNoteCode.Unsupported)); + Assert.IsTrue(File.Exists(Path.Combine(modFolder, "dep.jar"))); + Assert.IsFalse(File.Exists(Path.Combine(modFolder, "dep.jar.disabled"))); + Assert.IsTrue((await content.ListAsync(instance.Id, ModrinthContentKind.Mod)) + .Single(item => item.Name == "dep.jar").Enabled); + Assert.IsFalse(File.Exists(Path.Combine(modFolder, "missing-old.jar"))); + } + + [TestMethod] + public async Task CorruptOriginRegistryKeepsFilesVisibleAndBlocksMutationsWithoutOverwritingBytes() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var modFolder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(modFolder); + await File.WriteAllBytesAsync(Path.Combine(modFolder, "existing.jar"), [1, 2, 3]); + var registryPath = Path.Combine(root, "minecraft", ".hako-mods.json"); + var corruptRegistry = Encoding.UTF8.GetBytes("{ broken registry"); + await File.WriteAllBytesAsync(registryPath, corruptRegistry); + var source = Path.Combine(temporary.Root, "new.jar"); + await File.WriteAllBytesAsync(source, [4, 5, 6]); + using var server = new ModrinthServer(); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual(ContentFailureCode.RegistryCorrupt, state.Error?.Code); + Assert.AreEqual("existing.jar", state.Items.Single().Name); + Assert.IsNull(state.Items.Single().Origin); + + var import = await controller.ImportLocalAsync(instance.Id, ModrinthContentKind.Mod, source); + + Assert.IsFalse(import.Succeeded); + Assert.AreEqual(ContentFailureCode.RegistryCorrupt, import.Failure?.Code); + CollectionAssert.AreEqual(corruptRegistry, await File.ReadAllBytesAsync(registryPath)); + Assert.IsFalse(File.Exists(Path.Combine(modFolder, "new.jar"))); + } + + [TestMethod] + public async Task DependencyLimitIsShownAndOnlyFirstTwentyNineDependenciesArePlanned() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("rootmod1", "Root", "mod"); + var dependencies = new List(); + var rootFile = server.File("root.jar", [0]); + for (var index = 1; index <= 30; index++) + { + var id = $"dep{index:0000}"; + server.AddProject(id, id, "mod"); + server.AddVersion(id, $"ver{index:0000}", "1.0", "release", "2025-01-02T00:00:00Z", + ModrinthContentKind.Mod, [server.File($"{id}.jar", [(byte)index])]); + dependencies.Add(Dependency(projectId: id)); + } + + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [rootFile], dependencies); + ContentConfirmationRequest? request = null; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (value, _) => { request = value; return Task.FromResult(ContentConfirmationChoice.Primary); }); + + var result = await controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1")); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + Assert.IsNotNull(request); + Assert.IsTrue(request!.Projects.Any(project => project.NoteCode == ContentConfirmationNoteCode.ProjectLimitReached)); + Assert.AreEqual(30, (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Count); + } + + [TestMethod] + public async Task DependencyPlanWithoutUsableEntriesOffersRootOnlyAlternative() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("rootmod1", "Root", "mod"); + server.AddProject("badpack01", "Not handled here", "datapack"); + server.AddProject("missing01", "No matching version", "resourcepack"); + var rootFile = server.File("root.jar", [1, 2]); + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [rootFile], [Dependency(projectId: "badpack01"), Dependency(projectId: "missing01")]); + server.AddVersion("missing01", "missingold", "old", "release", "2024-12-01T00:00:00Z", + ModrinthContentKind.ResourcePack, [server.File("old.zip", [5])], gameVersions: ["1.20.3"]); + ContentConfirmationRequest? request = null; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (value, _) => { request = value; return Task.FromResult(ContentConfirmationChoice.Alternative); }); + + var result = await controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1")); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + Assert.IsNotNull(request); + Assert.IsFalse(request!.PrimaryActionAvailable); + Assert.IsTrue(request.AlternativeActionAvailable); + Assert.AreEqual(1, (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Count); + Assert.AreEqual(0, (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Count); + } + + [TestMethod] + [DoNotParallelize] + public async Task CancellationDuringConfirmationOrDownloadLeavesFilesUntouchedAndReleasesLease() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("rootmod1", "Root", "mod"); + server.AddProject("depmod01", "Dependency", "mod"); + var rootFile = server.File("root.jar", [1]); + var depFile = server.File("dep.jar", [2]); + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [rootFile], [Dependency(projectId: "depmod01")]); + server.AddVersion("depmod01", "depver001", "1.0", "release", "2025-01-02T00:00:00Z", + ModrinthContentKind.Mod, [depFile]); + var confirmationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, async (_, token) => + { + confirmationStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return ContentConfirmationChoice.Primary; + }); + using var cancellation = new CancellationTokenSource(); + var install = controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1"), + cancellationToken: cancellation.Token); + await confirmationStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsFalse(store.TryBeginLaunch(instance.Id, out _, out var launchReason)); + Assert.AreEqual(InstanceStorageError.BuildFilesBusy, launchReason); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Queued, pauseRequested: false); + Assert.IsFalse(store.TryBeginInstallation(instance.Id, out _, out var installReason)); + Assert.AreEqual(InstanceStorageError.BuildFilesBusy, installReason); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Ready, pauseRequested: false); + cancellation.Cancel(); + await Assert.ThrowsExactlyAsync(() => install); + Assert.AreEqual(0, Directory.Exists(Path.Combine(root, "minecraft", "mods")) + ? Directory.EnumerateFileSystemEntries(Path.Combine(root, "minecraft", "mods")).Count() : 0); + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).IsBusy); + Assert.IsTrue(store.TryBeginLaunch(instance.Id, out var launchLease, out _)); + launchLease!.Dispose(); + + server.OnDownload = async token => await Task.Delay(Timeout.InfiniteTimeSpan, token); + var single = server.WithProjectVersion("rootmod1", "rootver01", "1.0", "release", [rootFile]); + server.ReplaceVersions("rootmod1", [single]); + var stagingBeforeCancellation = Directory.GetDirectories(Path.GetTempPath(), "Hako-Modrinth-*") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + using var nextCancellation = new CancellationTokenSource(); + var downloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.DownloadStarted = downloadStarted; + var downloadTask = controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1"), + cancellationToken: nextCancellation.Token); + await downloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + nextCancellation.Cancel(); + await Assert.ThrowsExactlyAsync(() => downloadTask); + Assert.AreEqual(0, Directory.Exists(Path.Combine(root, "minecraft", "mods")) + ? Directory.EnumerateFileSystemEntries(Path.Combine(root, "minecraft", "mods")).Count() : 0); + CollectionAssert.AreEqual(stagingBeforeCancellation.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray(), + Directory.GetDirectories(Path.GetTempPath(), "Hako-Modrinth-*").OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray()); + } + + [TestMethod] + public async Task DecliningRootCollisionDoesNotReportInstallSuccessOrChangeExistingFile() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("rootmod1", "Root", "mod"); + var existingBytes = Encoding.UTF8.GetBytes("user file"); + var folder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(folder); + await File.WriteAllBytesAsync(Path.Combine(folder, "root.jar"), existingBytes); + var file = server.File("root.jar", Encoding.UTF8.GetBytes("downloaded")); + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [file]); + var prompts = 0; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (request, _) => + { + prompts++; + Assert.AreEqual(ContentConfirmationCode.ReplaceExisting, request.Code); + return Task.FromResult(ContentConfirmationChoice.Cancel); + }); + + var result = await controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1")); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(1, prompts); + CollectionAssert.AreEqual(existingBytes, await File.ReadAllBytesAsync(Path.Combine(folder, "root.jar"))); + Assert.IsNull((await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single().Origin); + } + + [TestMethod] + public async Task FabricLoaderAndJavaPredicatesRequireDestructiveConfirmationAndCancellationChangesNothing() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary, loader: "0.16.9", java: 17); + using var server = new ModrinthServer(); + server.AddProject("rootmod1", "Root", "mod"); + var jar = FabricJar("root", "{\"fabricloader\":\">=0.20.0\",\"java\":\">=21\"}"); + server.AddVersion("rootmod1", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("root.jar", jar)]); + ContentConfirmationRequest? prompt = null; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (request, _) => { prompt = request; return Task.FromResult(ContentConfirmationChoice.Cancel); }); + + var result = await controller.InstallAsync(instance.Id, ModrinthContentKind.Mod, server.Project("rootmod1")); + + Assert.IsFalse(result.Succeeded); + Assert.IsNotNull(prompt); + Assert.AreEqual(ContentConfirmationCode.IncompatibleContent, prompt!.Code); + Assert.IsTrue(prompt.Destructive); + Assert.IsTrue(prompt.Projects.Any(project => project.NoteCode == ContentConfirmationNoteCode.FabricLoaderRequirement)); + Assert.IsTrue(prompt.Projects.Any(project => project.NoteCode == ContentConfirmationNoteCode.JavaRequirement)); + Assert.AreEqual(0, Directory.Exists(Path.Combine(root, "minecraft", "mods")) + ? Directory.EnumerateFileSystemEntries(Path.Combine(root, "minecraft", "mods")).Count() : 0); + } + + [TestMethod] + public async Task CheckRecognizesManualDisabledFileAndUpdatePreservesDisabledStateAndOrigin() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("project01", "Recognized mod", "mod", iconUrl: "https://cdn.modrinth.com/icon.png"); + var oldBytes = Encoding.UTF8.GetBytes("old bytes"); + var oldVersion = server.AddVersion("project01", "oldver001", "1.0", "beta", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("old.jar", oldBytes)]); + var newVersion = server.AddVersion("project01", "newver001", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("new.jar", Encoding.UTF8.GetBytes("new bytes"))]); + server.Recognize(Hash(oldBytes), oldVersion); + server.AddUpdate(Hash(oldBytes), newVersion); + var source = Path.Combine(temporary.Root, "old.jar"); + await File.WriteAllBytesAsync(source, oldBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + var enabled = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + await content.SetEnabledAsync(instance.Id, enabled, ModrinthContentKind.Mod, enabled: false); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsFalse(state.IsCheckingUpdates); + Assert.AreEqual("project01", state.Items.Single().Origin?.ProjectID); + Assert.AreEqual("oldver001", state.Items.Single().Origin?.VersionID); + Assert.AreEqual("https://modrinth.com/mod/project01", state.Items.Single().Origin?.PageURL.AbsoluteUri); + Assert.IsTrue(state.HasUpdates, state.UpdateMessage?.ToString()); + + var updateResult = await controller.UpdateAsync(instance.Id, ModrinthContentKind.Mod, state.Items.Single()); + + Assert.IsTrue(updateResult.Succeeded, updateResult.Failure?.ToString()); + var updated = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + Assert.IsFalse(updated.Enabled); + Assert.AreEqual("new.jar.disabled", updated.Name); + Assert.AreEqual("newver001", updated.Origin?.VersionID); + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("new bytes"), await File.ReadAllBytesAsync(updated.Path)); + } + + [TestMethod] + public async Task UpdateExpectedHashProtectsChangedFileAndOlderReleaseIsNotOfferedToNewerBeta() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("project01", "Mod", "mod"); + var oldBytes = Encoding.UTF8.GetBytes("beta file"); + var oldVersion = server.AddVersion("project01", "betaver01", "1.0-beta", "beta", "2025-03-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod.jar", oldBytes)]); + var olderRelease = server.AddVersion("project01", "release01", "1.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod.jar", Encoding.UTF8.GetBytes("older release"))]); + server.Recognize(Hash(oldBytes), oldVersion); + server.AddUpdate(Hash(oldBytes), olderRelease); + var source = Path.Combine(temporary.Root, "mod.jar"); + await File.WriteAllBytesAsync(source, oldBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).HasUpdates); + + var newer = server.AddVersion("project01", "newver001", "2.0", "release", "2025-04-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("new.jar", Encoding.UTF8.GetBytes("new bytes"))]); + server.AddUpdate(Hash(oldBytes), newer); + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsTrue(state.HasUpdates, state.UpdateMessage?.ToString()); + await File.WriteAllBytesAsync(state.Items.Single().Path, Encoding.UTF8.GetBytes("changed by user")); + var result = await controller.UpdateAsync(instance.Id, ModrinthContentKind.Mod, state.Items.Single()); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(ContentFailureCode.ContentOperationFailed, result.Failure?.Code); + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("changed by user"), await File.ReadAllBytesAsync(state.Items.Single().Path)); + } + + [TestMethod] + public async Task LinkedResourcePackUpdateUsesDatapackVersionsAndRetainsFileType() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("datapack1", "Datapack", "mod"); + var oldPackBytes = Encoding.UTF8.GetBytes("old linked pack"); + var oldPack = server.File("linked.zip", oldPackBytes, "required-resource-pack"); + var oldMain = server.File("data.zip", [1]); + var oldVersion = server.AddVersion("datapack1", "oldpack01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [oldMain, oldPack]); + var nextPack = server.File("linked.zip", Encoding.UTF8.GetBytes("updated linked pack"), "required-resource-pack"); + var nextMain = server.File("data.zip", [2]); + var newVersion = server.AddVersion("datapack1", "newpack01", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Datapack, [nextMain, nextPack]); + server.Recognize(Hash(oldPackBytes), oldVersion); + server.AddUpdate(Hash(oldPackBytes), newVersion); + var source = Path.Combine(temporary.Root, "linked.zip"); + await File.WriteAllBytesAsync(source, oldPackBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.ResourcePack, source); + InstanceContent.WriteRegistry(root, ".hako-resourcepacks.json", new ModRegistry + { + ApiProvisioned = false, + Files = new Dictionary(StringComparer.Ordinal) + { + ["linked.zip"] = new ModOrigin + { + Source = ModSource.Modrinth, + ProjectID = "datapack1", + VersionID = oldVersion.Id, + PageURL = new Uri("https://modrinth.com/mod/datapack1"), + Sha512 = Hash(oldPackBytes), + FileType = "required-resource-pack" + } + } + }); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.ResourcePack); + var state = controller.GetState(instance.Id, ModrinthContentKind.ResourcePack); + Assert.IsTrue(state.HasUpdates, state.UpdateMessage?.ToString()); + var result = await controller.UpdateAsync(instance.Id, ModrinthContentKind.ResourcePack, state.Items.Single()); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + var updated = (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Single(); + Assert.AreEqual("newpack01", updated.Origin?.VersionID); + Assert.AreEqual("required-resource-pack", updated.Origin?.FileType); + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("updated linked pack"), await File.ReadAllBytesAsync(updated.Path)); + } + + [TestMethod] + public async Task FabricApiUpdateIsNotOfferedWhenLatestFileRejectsPinnedLoader() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary, loader: "0.16.9"); + using var server = new ModrinthServer(); + var currentFile = server.File("fabric-api-current.jar", FabricJar("fabric_api", "{}")); + var current = new FabricApiDescriptor(FabricClient.FabricApiProjectId, "apivers01", "1.0.0", "release", + currentFile.Filename, currentFile.Url.AbsoluteUri, currentFile.Bytes.Length, currentFile.Sha1, currentFile.Sha512); + var incompatibleFile = server.File("fabric-api-new.jar", FabricJar("fabric_api", "{\"fabricloader\":\">=0.17.0\"}")); + server.AddVersion(FabricClient.FabricApiProjectId, "apivers02", "2.0.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [incompatibleFile]); + var modFolder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(modFolder); + await File.WriteAllBytesAsync(Path.Combine(modFolder, currentFile.Filename), currentFile.Bytes); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) + { + [currentFile.Filename.ToLowerInvariant()] = ModOrigin.FromApi(current) + } + }); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsFalse(state.HasUpdates); + Assert.AreEqual(ContentFailureCode.FabricApiIncompatible, state.UpdateMessage?.Code); + CollectionAssert.AreEqual(currentFile.Bytes, await File.ReadAllBytesAsync(Path.Combine(modFolder, currentFile.Filename))); + } + + [TestMethod] + [DataRow("none", false)] + [DataRow("accept", false)] + [DataRow("accept", true)] + [DataRow("decline", false)] + [DataRow("cancel", true)] + [DataRow("cancel-token", false)] + [DataRow("bulk", true)] + public async Task CompatibleFabricApiUpdateReplacesFileAndKeepsApiDescriptor(string scenario, bool disabled) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary, loader: "0.16.9"); + using var server = new ModrinthServer(); + var currentFile = server.File("fabric-api-old.jar", FabricJar("fabric_api", "{\"fabricloader\":\">=0.16.0\"}")); + var current = new FabricApiDescriptor(FabricClient.FabricApiProjectId, "apivers01", "1.0.0", "release", + currentFile.Filename, currentFile.Url.AbsoluteUri, currentFile.Bytes.Length, currentFile.Sha1, currentFile.Sha512); + var nextFile = server.File("fabric-api-new.jar", FabricJar("fabric_api_new", "{\"fabricloader\":\">=0.16.0\"}")); + server.AddVersion(FabricClient.FabricApiProjectId, "apivers02", "2.0.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [nextFile]); + var modFolder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(modFolder); + var oldPath = Path.Combine(modFolder, currentFile.Filename + (disabled ? ".disabled" : "")); + await File.WriteAllBytesAsync(oldPath, currentFile.Bytes); + var collisionPath = Path.Combine(modFolder, nextFile.Filename); + var foreign = Encoding.UTF8.GetBytes("foreign content"); + if (scenario != "none") await File.WriteAllBytesAsync(collisionPath, foreign); + // A separate update proves bulk continues after the API confirmation requirement. + var otherBytes = Encoding.UTF8.GetBytes("other old"); + var otherNext = Encoding.UTF8.GetBytes("other next"); + if (scenario == "bulk") + { + server.AddProject("othermod1", "Other mod", "mod"); + var old = server.AddVersion("othermod1", "otherold1", "1", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("other.jar", otherBytes)]); + var next = server.AddVersion("othermod1", "othernew1", "2", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("other.jar", otherNext)]); + server.Recognize(Hash(otherBytes), old); + server.AddUpdate(Hash(otherBytes), next); + await File.WriteAllBytesAsync(Path.Combine(modFolder, "other.jar"), otherBytes); + } + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) + { + [currentFile.Filename.ToLowerInvariant()] = ModOrigin.FromApi(current) + } + }); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + using var cancellation = new CancellationTokenSource(); + var confirmations = 0; + var controller = new InstanceContentController(store, content, modrinth, fabric, (request, _) => + { + confirmations++; + Assert.AreEqual(ContentConfirmationCode.ReplaceExisting, request.Code); + Assert.AreEqual(nextFile.Filename, request.Arguments["filename"]); + if (scenario == "cancel-token") + { + cancellation.Cancel(); + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously).Task; + } + return Task.FromResult(scenario == "decline" ? ContentConfirmationChoice.Alternative + : scenario is "cancel" or "cancel-token" ? ContentConfirmationChoice.Cancel : ContentConfirmationChoice.Primary); + }); + + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsTrue(state.HasUpdates, state.UpdateMessage?.ToString()); + var oldItem = state.Items.Single(item => item.Path == oldPath); + var registryPath = Path.Combine(root, "minecraft", ".hako-mods.json"); + var registryBefore = await File.ReadAllBytesAsync(registryPath); + if (scenario == "bulk") + { + var bulk = await controller.UpdateAllAsync(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual(ContentFailureCode.ConfirmationRequired, bulk.Items.First().Failure?.Code); + Assert.IsTrue(bulk.Items.Last().Succeeded); + Assert.AreEqual(0, confirmations); + CollectionAssert.AreEqual(otherNext, await File.ReadAllBytesAsync(Path.Combine(modFolder, "other.jar"))); + CollectionAssert.AreEqual(currentFile.Bytes, await File.ReadAllBytesAsync(oldPath)); + CollectionAssert.AreEqual(foreign, await File.ReadAllBytesAsync(collisionPath)); + Assert.AreEqual(current.VersionID, InstanceContent.ReadRegistry(root).Files[currentFile.Filename].Api?.VersionID); + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).IsBusy); + return; + } + var updateTask = controller.UpdateAsync(instance.Id, ModrinthContentKind.Mod, oldItem, cancellation.Token); + if (scenario == "cancel-token") + await Assert.ThrowsAsync(() => updateTask); + var result = scenario == "cancel-token" ? null : await updateTask; + Assert.AreEqual(scenario == "none" ? 0 : 1, confirmations); + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).IsBusy); + if (scenario is "decline" or "cancel" or "cancel-token") + { + if (result is not null) Assert.IsFalse(result.Succeeded); + CollectionAssert.AreEqual(currentFile.Bytes, await File.ReadAllBytesAsync(oldPath)); + CollectionAssert.AreEqual(foreign, await File.ReadAllBytesAsync(collisionPath)); + CollectionAssert.AreEqual(registryBefore, await File.ReadAllBytesAsync(registryPath)); + return; + } + + Assert.IsTrue(result!.Succeeded, result.Failure?.ToString()); + var updated = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + Assert.AreEqual(nextFile.Filename + (disabled ? ".disabled" : ""), updated.Name); + Assert.AreEqual(!disabled, updated.Enabled); + Assert.IsFalse(File.Exists(oldPath)); + Assert.AreEqual("0.16.9", store.List().Single().FabricConfiguration?.LoaderVersion); + Assert.AreEqual(21, store.List().Single().JavaMajorVersion); + Assert.AreEqual("apivers02", updated.Origin?.Api?.VersionID); + Assert.AreEqual(nextFile.Sha512, updated.Origin?.Api?.Sha512); + CollectionAssert.AreEqual(nextFile.Bytes, await File.ReadAllBytesAsync(updated.Path)); + } + + [TestMethod] + public async Task BulkUpdateRequiresNoDialogsContinuesAfterFailureAndUpdatesRemainingFiles() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("firstmod", "First", "mod"); + server.AddProject("secondmod", "Second", "mod"); + var oldFirst = Encoding.UTF8.GetBytes("first-old"); + var oldSecond = Encoding.UTF8.GetBytes("second-old"); + var firstVersion = server.AddVersion("firstmod", "firstold", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("a.jar", oldFirst)]); + var secondVersion = server.AddVersion("secondmod", "secondold", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("b.jar", oldSecond)]); + var firstNext = server.AddVersion("firstmod", "firstnew", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("a.jar", [10])], [Dependency(projectId: "missingdep")]); + var secondNext = server.AddVersion("secondmod", "secondnew", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("b.jar", [20])]); + server.AddProject("missingdep", "Missing dependency", "mod"); + server.AddVersion("missingdep", "olddep01", "old", "release", "2024-12-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("olddep.jar", [30])], gameVersions: ["1.20.3"]); + server.Recognize(Hash(oldFirst), firstVersion); + server.Recognize(Hash(oldSecond), secondVersion); + server.AddUpdate(Hash(oldFirst), firstNext); + server.AddUpdate(Hash(oldSecond), secondNext); + foreach (var (filename, bytes) in new[] { ("a.jar", oldFirst), ("b.jar", oldSecond) }) + { + var source = Path.Combine(temporary.Root, filename); + await File.WriteAllBytesAsync(source, bytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + } + var dialogs = 0; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (_, _) => { dialogs++; return Task.FromResult(ContentConfirmationChoice.Primary); }); + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + + var result = await controller.UpdateAllAsync(instance.Id, ModrinthContentKind.Mod); + + Assert.AreEqual(0, dialogs); + Assert.IsTrue(result.HasFailures, result.Items.FirstOrDefault(item => !item.Succeeded)?.Failure?.ToString()); + Assert.AreEqual(2, result.Items.Count); + Assert.IsFalse(result.Items.Single(item => item.Filename == "a.jar").Succeeded); + Assert.AreEqual(ContentFailureCode.ConfirmationRequired, result.Items.Single(item => item.Filename == "a.jar").Failure?.Code); + Assert.IsTrue(result.Items.Single(item => item.Filename == "b.jar").Succeeded); + var files = await content.ListAsync(instance.Id, ModrinthContentKind.Mod); + CollectionAssert.AreEqual(new byte[] { 20 }, await File.ReadAllBytesAsync(files.Single(item => item.Name == "b.jar").Path)); + CollectionAssert.AreEqual(oldFirst, await File.ReadAllBytesAsync(files.Single(item => item.Name == "a.jar").Path)); + } + + [TestMethod] + public async Task StaleUpdateResponseCannotReplaceRenamedInstanceState() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("project01", "Mod", "mod"); + var oldBytes = Encoding.UTF8.GetBytes("old"); + var old = server.AddVersion("project01", "oldver001", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod.jar", oldBytes)]); + var next = server.AddVersion("project01", "newver001", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod.jar", [2])]); + server.Recognize(Hash(oldBytes), old); + server.AddUpdate(Hash(oldBytes), next); + var source = Path.Combine(temporary.Root, "mod.jar"); + await File.WriteAllBytesAsync(source, oldBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeVersionUpdate = async (hashes, token) => + { + held.TrySetResult(); + await release.Task.WaitAsync(token); + }; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var itemPathBeforeCheck = controller.GetState(instance.Id, ModrinthContentKind.Mod).Items.Single().Path; + + var check = controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + await held.Task.WaitAsync(TimeSpan.FromSeconds(5)); + store.Rename(instance.Id, "Renamed build"); + release.TrySetResult(); + await check; + + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsFalse(state.IsCheckingUpdates); + Assert.IsFalse(state.HasUpdates); + Assert.AreEqual(itemPathBeforeCheck, state.Items.Single().Path); + } + + [TestMethod] + public async Task ReloadStartedWhenUpdateCheckPublishesProjectClearsProjectFromIdleSnapshot() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("project01", "Mod", "mod"); + var oldBytes = Encoding.UTF8.GetBytes("old"); + var old = server.AddVersion("project01", "oldver001", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod.jar", oldBytes)]); + server.AddUpdate(Hash(oldBytes), server.AddVersion("project01", "newver001", "2.0", "release", + "2025-02-01T00:00:00Z", ModrinthContentKind.Mod, [server.File("new.jar", [2])])); + server.Recognize(Hash(oldBytes), old); + var source = Path.Combine(temporary.Root, "mod.jar"); + await File.WriteAllBytesAsync(source, oldBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + var reloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reloadTriggered = false; + string? observedProjectId = null; + controller.StateChanged += (_, args) => + { + if (reloadTriggered || args.Key.InstanceId != instance.Id + || args.Key.Kind != ModrinthContentKind.Mod || args.State.CurrentProjectId is null) return; + reloadTriggered = true; + observedProjectId = args.State.CurrentProjectId; + reloadStarted.TrySetResult(controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod)); + }; + + var check = controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + var reload = await reloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.WhenAll(check, reload); + + var state = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual("project01", observedProjectId); + Assert.IsFalse(state.IsBusy); + Assert.IsFalse(state.IsLoading); + Assert.IsFalse(state.IsCheckingUpdates); + Assert.IsNull(state.CurrentProjectId); + Assert.AreEqual("mod.jar", state.Items.Single().Name); + Assert.AreEqual(Path.Combine(root, "minecraft", "mods", "mod.jar"), state.Items.Single().Path); + } + + [TestMethod] + public async Task LateUpdateCheckCannotReplaceNewerReloadSnapshot() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("project01", "Mod", "mod"); + var oldBytes = Encoding.UTF8.GetBytes("old"); + var old = server.AddVersion("project01", "oldver001", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod.jar", oldBytes)]); + var next = server.AddVersion("project01", "newver001", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("mod-new.jar", [2])]); + server.Recognize(Hash(oldBytes), old); + server.AddUpdate(Hash(oldBytes), next); + var source = Path.Combine(temporary.Root, "mod.jar"); + await File.WriteAllBytesAsync(source, oldBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, source); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.BeforeVersionUpdate = async (_, token) => + { + held.TrySetResult(); + await release.Task.WaitAsync(token); + }; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + + var check = controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + await held.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var reloadState = controller.GetState(instance.Id, ModrinthContentKind.Mod); + release.TrySetResult(); + await check; + + var final = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsFalse(final.IsCheckingUpdates); + Assert.IsFalse(final.HasUpdates); + Assert.AreEqual(reloadState.Items.Single().Path, final.Items.Single().Path); + Assert.IsNull(final.Items.Single().Origin); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task RegistryReadErrorDuringDeleteConfirmationDoesNotStrandBusyOrClearAnotherMutation(bool cancelToken) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var folder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(folder); + var path = Path.Combine(folder, "existing.jar"); + await File.WriteAllBytesAsync(path, [1, 2, 3]); + var registryPath = Path.Combine(root, "minecraft", ".hako-mods.json"); + var corrupt = Encoding.UTF8.GetBytes("{ broken registry"); + await File.WriteAllBytesAsync(registryPath, corrupt); + using var server = new ModrinthServer(); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var choice = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var controller = new InstanceContentController(store, content, modrinth, fabric, (_, _) => + { + entered.TrySetResult(); + return choice.Task; + }); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var item = controller.GetState(instance.Id, ModrinthContentKind.Mod).Items.Single(); + var delete = controller.DeleteAsync(instance.Id, ModrinthContentKind.Mod, item, cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var busy = controller.GetState(instance.Id, ModrinthContentKind.Mod); + await controller.InstalledProjectsAsync(instance.Id, ModrinthContentKind.Mod); + var afterRead = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsTrue(afterRead.IsBusy); + Assert.AreEqual(busy.Revision, afterRead.Revision); + Assert.AreEqual(ContentFailureCode.RegistryCorrupt, afterRead.Error?.Code); + if (cancelToken) + { + cancellation.Cancel(); + await Assert.ThrowsExactlyAsync(() => delete); + } + else + { + choice.SetResult(ContentConfirmationChoice.Cancel); + Assert.IsFalse((await delete).Succeeded); + } + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).IsBusy); + CollectionAssert.AreEqual(corrupt, await File.ReadAllBytesAsync(registryPath)); + + // Repair only this isolated fixture, then verify both reads and a new mutation execute. + InstanceContent.WriteRegistry(root, new ModRegistry()); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var reloaded = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsNull(reloaded.Error); + var result = await controller.SetEnabledAsync(instance.Id, ModrinthContentKind.Mod, reloaded.Items.Single(), false); + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).Items.Single().Enabled); + } + + [TestMethod] + [DataRow("cancel", false)] + [DataRow("blocked-old", false)] + [DataRow("registry", false)] + [DataRow("registry", true)] + [DataRow("success", false)] + [DataRow("success", true)] + [DataRow("decline", false)] + [DataRow("recycle-failed", false)] + [DataRow("recycle-cancelled", true)] + [DataRow("rollback-locked", false)] + [DataRow("rollback-locked", true)] + [DataRow("cancel-after-commit", false)] + public async Task UpdateCollisionPublishesReversiblyAndRecyclesOnlyAfterRegistryCommit(string scenario, bool disabled) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, _) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + server.AddProject("project01", "Updated mod", "mod"); + server.AddProject("foreign01", "Foreign mod", "mod"); + var oldBytes = Encoding.UTF8.GetBytes("original old file"); + var collisionBytes = Encoding.UTF8.GetBytes("user collision file"); + var updatedBytes = Encoding.UTF8.GetBytes("updated file"); + var oldVersion = server.AddVersion("project01", "oldver001", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("old.jar", oldBytes)]); + var nextVersion = server.AddVersion("project01", "newver001", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("new.jar", updatedBytes)]); + var foreignVersion = server.AddVersion("foreign01", "foreignv1", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("new.jar", collisionBytes)]); + server.Recognize(Hash(oldBytes), oldVersion); + server.Recognize(Hash(collisionBytes), foreignVersion); + server.AddUpdate(Hash(oldBytes), nextVersion); + var folder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(folder); + var oldPath = Path.Combine(folder, disabled ? "old.jar.disabled" : "old.jar"); + var collisionPath = Path.Combine(folder, "new.jar"); + await File.WriteAllBytesAsync(oldPath, oldBytes); + await File.WriteAllBytesAsync(collisionPath, collisionBytes); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) + { + ["old.jar"] = Origin("project01", "oldver001", oldBytes), + ["new.jar"] = Origin("foreign01", "foreignv1", collisionBytes) + } + }); + var registryPath = Path.Combine(root, "minecraft", ".hako-mods.json"); + var registryBytes = await File.ReadAllBytesAsync(registryPath); + using var cancellation = new CancellationTokenSource(); + var confirmed = false; + var stagedCollision = false; + var recycled = new List(); + FileStream? targetLock = null; + var recycler = new CallbackRecycleOperation(async (path, token) => + { + Assert.IsFalse(token.IsCancellationRequested); + Assert.AreEqual("newver001", InstanceContent.ReadRegistry(root).Files["new.jar"].VersionID); + recycled.Add(await File.ReadAllBytesAsync(path)); + if (scenario == "cancel-after-commit") cancellation.Cancel(); + if (scenario == "recycle-failed") throw new IOException("Injected recycle failure"); + if (scenario == "recycle-cancelled") throw new OperationCanceledException("Injected shell cancellation"); + File.Delete(path); + return RecycleBinItemStatus.Recycled; + }); + var content = new InstanceContent(store, new RecycleBin(recycler), + beforeRegistryCommit: () => + { + if (confirmed && scenario == "registry") throw new IOException("Injected registry failure"); + if (confirmed && scenario == "rollback-locked") + { + targetLock = new FileStream(Path.Combine(folder, disabled ? "new.jar.disabled" : "new.jar"), + FileMode.Open, FileAccess.Read, FileShare.Read); + throw new IOException("Injected registry failure with locked publication"); + } + }, + beforeReplacementPublish: () => + { + Assert.IsTrue(confirmed); + Assert.IsFalse(File.Exists(collisionPath)); + Assert.IsTrue(File.Exists(oldPath)); + stagedCollision = true; + if (scenario == "cancel") cancellation.Cancel(); + }); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + FileStream? oldLock = null; + var controller = new InstanceContentController(store, content, modrinth, fabric, (request, _) => + { + Assert.AreEqual(ContentConfirmationCode.ReplaceExisting, request.Code); + confirmed = true; + if (scenario == "blocked-old") + oldLock = new FileStream(oldPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return Task.FromResult(scenario == "decline" ? ContentConfirmationChoice.Cancel : ContentConfirmationChoice.Primary); + }); + await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + var oldItem = controller.GetState(instance.Id, ModrinthContentKind.Mod).Items.Single(item => item.Path == oldPath); + Assert.IsTrue(controller.GetState(instance.Id, ModrinthContentKind.Mod).HasUpdates); + registryBytes = await File.ReadAllBytesAsync(registryPath); + ContentActionResult? result = null; + try + { + var task = controller.UpdateAsync(instance.Id, ModrinthContentKind.Mod, oldItem, cancellation.Token); + if (scenario == "cancel") await Assert.ThrowsExactlyAsync(() => task); + else result = await task; + } + finally + { + oldLock?.Dispose(); + targetLock?.Dispose(); + } + + Assert.IsTrue(confirmed); + Assert.AreEqual(scenario != "decline", stagedCollision); + Assert.IsFalse(controller.GetState(instance.Id, ModrinthContentKind.Mod).IsBusy); + if (scenario is "success" or "recycle-failed" or "recycle-cancelled" or "cancel-after-commit") + { + Assert.AreEqual(scenario is "success" or "cancel-after-commit", result!.Succeeded, result.Failure?.ToString()); + Assert.IsFalse(File.Exists(oldPath)); + var target = Path.Combine(folder, disabled ? "new.jar.disabled" : "new.jar"); + CollectionAssert.AreEqual(updatedBytes, await File.ReadAllBytesAsync(target)); + var registry = InstanceContent.ReadRegistry(root); + Assert.IsTrue(registry.ApiProvisioned); + Assert.IsFalse(registry.Files.ContainsKey("old.jar")); + Assert.AreEqual("newver001", registry.Files["new.jar"].VersionID); + CollectionAssert.AreEqual(collisionBytes, recycled.Single()); + var backups = Directory.GetFiles(folder, "*.hako-backup-*"); + if (scenario is "success" or "cancel-after-commit") Assert.AreEqual(0, backups.Length); + else + { + CollectionAssert.AreEqual(collisionBytes, await File.ReadAllBytesAsync(backups.Single())); + StringAssert.Contains(result.Failure!.Arguments["diagnostic"], backups.Single()); + } + } + else + { + if (result is not null) Assert.IsFalse(result.Succeeded); + CollectionAssert.AreEqual(oldBytes, await File.ReadAllBytesAsync(oldPath)); + var backups = Directory.GetFiles(folder, "*.hako-backup-*"); + if (scenario == "rollback-locked") + { + StringAssert.Contains(result!.Failure!.Arguments["diagnostic"], "Could not remove published entry"); + CollectionAssert.AreEqual(updatedBytes, + await File.ReadAllBytesAsync(Path.Combine(folder, disabled ? "new.jar.disabled" : "new.jar"))); + if (!disabled) + { + CollectionAssert.AreEqual(collisionBytes, await File.ReadAllBytesAsync(backups.Single())); + StringAssert.Contains(result.Failure.Arguments["diagnostic"], backups.Single()); + } + } + if (scenario != "rollback-locked" || disabled) + CollectionAssert.AreEqual(collisionBytes, await File.ReadAllBytesAsync(collisionPath)); + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + Assert.AreEqual("oldver001", InstanceContent.ReadRegistry(root).Files["old.jar"].VersionID); + Assert.AreEqual("foreignv1", InstanceContent.ReadRegistry(root).Files["new.jar"].VersionID); + Assert.AreEqual(0, recycled.Count); + Assert.AreEqual(scenario == "rollback-locked" && !disabled ? 1 : 0, backups.Length); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ReadInitialEventCanStartMutationWithoutPublishingStaleRead(bool checkUpdates) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + var path = Path.Combine(root, "minecraft", "mods", "local.jar"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, "local mod"); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var item = controller.GetState(instance.Id, ModrinthContentKind.Mod).Items.Single(); + Task? mutation = null; + InstanceContentState? initial = null; + controller.StateChanged += (_, args) => + { + if (initial is not null) return; + initial = args.State; + mutation = controller.SetEnabledAsync(instance.Id, ModrinthContentKind.Mod, item, false); + }; + if (checkUpdates) await controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod); + else await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var mutationResult = await mutation!.WaitAsync(TimeSpan.FromSeconds(5)); + var failure = mutationResult.Failure is { } contentFailure + ? $"{contentFailure.Code}: {string.Join(", ", contentFailure.Arguments.Select(argument => $"{argument.Key}={argument.Value}"))}" + : "Mutation did not succeed."; + Assert.IsTrue(mutationResult.Succeeded, failure); + Assert.AreEqual(checkUpdates, initial!.IsCheckingUpdates); + Assert.AreEqual(!checkUpdates, initial.IsLoading); + var after = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.IsTrue(after.Revision > initial.Revision); + Assert.IsFalse(after.IsBusy); + Assert.IsFalse(after.IsLoading); + Assert.IsFalse(after.IsCheckingUpdates); + Assert.IsFalse(after.Items.Single().Enabled); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, false)] + [DataRow(false, true)] + [DataRow(true, true)] + public async Task MutationStartRejectsReentrantAndConcurrentReads(bool checkUpdates, bool concurrent) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + using var server = new ModrinthServer(); + var path = Path.Combine(root, "minecraft", "mods", "local.jar"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, "local mod"); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + await controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + var before = controller.GetState(instance.Id, ModrinthContentKind.Mod); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + Task? read = null; + InstanceContentState? initial = null; + controller.StateChanged += (_, args) => + { + if (args.State.Revision <= before.Revision || initial is not null) return; + initial = args.State; + if (concurrent) + { + entered.SetResult(); + release.Wait(TimeSpan.FromSeconds(5)); + } + else read = checkUpdates + ? controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod) + : controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + }; + var mutation = Task.Run(() => controller.SetEnabledAsync(instance.Id, ModrinthContentKind.Mod, before.Items.Single(), false)); + if (concurrent) + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + try + { + read = checkUpdates ? controller.CheckUpdatesAsync(instance.Id, ModrinthContentKind.Mod) + : controller.ReloadAsync(instance.Id, ModrinthContentKind.Mod); + await read.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { release.Set(); } + } + Assert.IsTrue((await mutation.WaitAsync(TimeSpan.FromSeconds(5))).Succeeded); + await read!.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsTrue(initial!.IsBusy); + var after = controller.GetState(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual(initial.Revision, after.Revision); + Assert.IsFalse(after.IsBusy); + Assert.IsFalse(after.IsLoading); + Assert.IsFalse(after.IsCheckingUpdates); + Assert.IsFalse(after.Items.Single().Enabled); + Assert.IsTrue(after.Items.Single().Path.EndsWith(".disabled", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task WorldDatapackControllerScopesStateAndRejectsWorldRemovedDuringDownload() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var worldA = CreateWorldFolder(root, "WorldA"); + var worldB = CreateWorldFolder(root, "WorldB"); + using var server = new ModrinthServer(); + server.AddProject("datapack1", "World pack", "datapack"); + var bytes = DatapackZip("world A"); + var file = server.File("world.zip", bytes); + server.AddVersion("datapack1", "worldver1", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [file]); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + var changedKeys = new HashSet(); + controller.StateChanged += (_, args) => changedKeys.Add(args.Key); + var targetA = InstanceContentTarget.WorldDatapacks("WorldA"); + var targetB = InstanceContentTarget.WorldDatapacks("WorldB"); + + await controller.ReloadAsync(instance.Id, targetA); + await controller.ReloadAsync(instance.Id, targetB); + var revisionB = controller.GetState(instance.Id, targetB).Revision; + var downloadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseDownload = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.DownloadStarted = downloadStarted; + server.OnDownload = async token => await releaseDownload.Task.WaitAsync(token); + var installA = controller.InstallAsync(instance.Id, targetA, server.Project("datapack1")); + await downloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsTrue(controller.GetState(instance.Id, targetA).IsBusy); + Assert.IsFalse(controller.GetState(instance.Id, targetB).IsBusy); + Assert.AreEqual(revisionB, controller.GetState(instance.Id, targetB).Revision); + releaseDownload.SetResult(); + Assert.IsTrue((await installA).Succeeded); + server.OnDownload = null; + server.DownloadStarted = null; + + Assert.AreEqual(1, controller.GetState(instance.Id, targetA).Items.Count); + Assert.AreEqual(0, controller.GetState(instance.Id, targetB).Items.Count); + Assert.IsTrue(changedKeys.Contains(new InstanceContentStateKey(instance.Id, ModrinthContentKind.Datapack, "WorldA"))); + Assert.IsTrue(changedKeys.Contains(new InstanceContentStateKey(instance.Id, ModrinthContentKind.Datapack, "WorldB"))); + Assert.IsTrue(File.Exists(Path.Combine(worldA, "datapacks", "world.zip"))); + Assert.IsFalse(Directory.Exists(Path.Combine(worldB, "datapacks"))); + + var disappearingWorld = CreateWorldFolder(root, "Disappearing"); + server.AddProject("gonepack1", "Disappearing pack", "datapack"); + server.AddVersion("gonepack1", "gonever01", "1.0", "release", "2025-01-02T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("gone.zip", DatapackZip("gone"))]); + server.OnDownload = _ => + { + Directory.Delete(disappearingWorld, recursive: true); + return Task.CompletedTask; + }; + + var failed = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("Disappearing"), + server.Project("gonepack1")); + Assert.IsFalse(failed.Succeeded); + Assert.AreEqual(ContentFailureCode.WorldUnavailable, failed.Failure?.Code); + Assert.IsFalse(Directory.Exists(disappearingWorld)); + } + + [TestMethod] + public async Task DatapackDependenciesPreferDatapackVersionsAndSelectionRoutesOnlyChosenPack() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary, + modLoader: InstanceModLoader.Vanilla); + var world = CreateWorldFolder(root, "Selected"); + var target = InstanceContentTarget.WorldDatapacks("Selected"); + using var server = new ModrinthServer(); + server.ObserveContentDirectories(Path.Combine(world, "datapacks"), Path.Combine(root, "minecraft", "resourcepacks")); + server.AddProject("rootpack", "Root pack", "datapack"); + server.AddProject("dependency", "Multi-type dependency", "mod", allProjectTypes: ["mod", "datapack"]); + var rootFile = server.File("root.zip", DatapackZip("root")); + server.AddVersion("rootpack", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [rootFile], [Dependency(projectId: "dependency")]); + var dependencyFile = server.File("dependency.zip", DatapackZip("dependency")); + var requiredPack = server.File("required.zip", DatapackZip("required"), "required-resource-pack", primary: false); + var optionalPack = server.File("optional.zip", DatapackZip("optional"), "optional-resource-pack", primary: false); + server.AddVersion("dependency", "depver001", "2.0", "release", "2025-01-02T00:00:00Z", + ModrinthContentKind.Datapack, [dependencyFile, requiredPack, optionalPack]); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var requests = new List(); + var controller = new InstanceContentController(store, content, modrinth, fabric, + confirmSelection: (request, _) => + { + requests.Add(request); + var result = request.Code == ContentConfirmationCode.DatapackResourcePacks + ? new ContentConfirmationResult(ContentConfirmationChoice.Primary, "optional.zip") + : new ContentConfirmationResult(ContentConfirmationChoice.Primary); + return Task.FromResult(result); + }); + + var result = await controller.InstallAsync(instance.Id, target, server.Project("rootpack")); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + var resourceSelection = requests.Single(request => request.Code == ContentConfirmationCode.DatapackResourcePacks); + Assert.AreEqual(target, resourceSelection.Target); + var selectedFiles = resourceSelection.Files!; + CollectionAssert.AreEqual(new[] { "required.zip", "optional.zip" }, selectedFiles.Select(file => file.Filename).ToArray()); + Assert.IsTrue(selectedFiles[0].Required); + Assert.IsFalse(selectedFiles[1].Required); + Assert.IsTrue(server.AllDownloadsSawEmptyContentDirectories); + Assert.IsTrue(File.Exists(Path.Combine(world, "datapacks", "root.zip"))); + Assert.IsTrue(File.Exists(Path.Combine(world, "datapacks", "dependency.zip"))); + Assert.AreEqual("dependency", (await content.ListAsync(instance.Id, target)) + .Single(item => item.Name == "dependency.zip").Origin?.ProjectID); + Assert.IsTrue(File.Exists(Path.Combine(root, "minecraft", "resourcepacks", "optional.zip"))); + var selectedPackOrigin = (await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single().Origin; + Assert.AreEqual("dependency", selectedPackOrigin?.ProjectID); + Assert.AreEqual("depver001", selectedPackOrigin?.VersionID); + Assert.AreEqual("optional-resource-pack", selectedPackOrigin?.FileType); + Assert.AreEqual(optionalPack.Sha512, selectedPackOrigin?.Sha512); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "resourcepacks", "required.zip"))); + Assert.IsFalse(Directory.Exists(Path.Combine(root, "minecraft", "mods")) + && Directory.EnumerateFileSystemEntries(Path.Combine(root, "minecraft", "mods")).Any()); + Assert.IsTrue(File.Exists(Path.Combine(world, "level.dat"))); + + var alternateWorld = CreateWorldFolder(root, "WithoutPack"); + var alternateController = new InstanceContentController(store, content, modrinth, fabric, + confirmSelection: (request, _) => Task.FromResult(new ContentConfirmationResult( + request.Code == ContentConfirmationCode.DatapackResourcePacks + ? ContentConfirmationChoice.Alternative : ContentConfirmationChoice.Primary))); + var alternate = await alternateController.InstallAsync(instance.Id, + InstanceContentTarget.WorldDatapacks("WithoutPack"), server.Project("rootpack")); + Assert.IsTrue(alternate.Succeeded, alternate.Failure?.ToString()); + Assert.IsTrue(File.Exists(Path.Combine(alternateWorld, "datapacks", "dependency.zip"))); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "resourcepacks", "required.zip"))); + + var cancelledWorld = CreateWorldFolder(root, "Cancelled"); + var cancelledController = new InstanceContentController(store, content, modrinth, fabric, + confirmSelection: (request, _) => Task.FromResult(new ContentConfirmationResult( + request.Code == ContentConfirmationCode.DatapackResourcePacks + ? ContentConfirmationChoice.Cancel : ContentConfirmationChoice.Primary))); + var cancelled = await cancelledController.InstallAsync(instance.Id, + InstanceContentTarget.WorldDatapacks("Cancelled"), server.Project("rootpack")); + Assert.IsFalse(cancelled.Succeeded); + Assert.IsFalse(Directory.Exists(Path.Combine(cancelledWorld, "datapacks"))); + + var defaultWorld = CreateWorldFolder(root, "LegacyChoice"); + var legacyController = new InstanceContentController(store, content, modrinth, fabric, + (_, _) => Task.FromResult(ContentConfirmationChoice.Primary)); + var legacyChoice = await legacyController.InstallAsync(instance.Id, + InstanceContentTarget.WorldDatapacks("LegacyChoice"), server.Project("rootpack")); + Assert.IsTrue(legacyChoice.Succeeded, legacyChoice.Failure?.ToString()); + Assert.IsTrue(File.Exists(Path.Combine(root, "minecraft", "resourcepacks", "required.zip"))); + Assert.IsFalse(File.Exists(Path.Combine(defaultWorld, "datapacks", "required.zip"))); + } + + [TestMethod] + public async Task VanillaWorldDatapackInstallExplainsFabricOnlyDependency() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary, + modLoader: InstanceModLoader.Vanilla); + CreateWorldFolder(root, "World"); + using var server = new ModrinthServer(); + server.AddProject("rootpack", "Root pack", "datapack"); + server.AddProject("moddep01", "Fabric-only dependency", "mod", allProjectTypes: ["mod"]); + server.AddVersion("rootpack", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("root.zip", DatapackZip("root"))], + [Dependency(projectId: "moddep01")]); + server.AddVersion("moddep01", "modver001", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Mod, [server.File("dependency.jar", FabricJar("dependency", "{}"))]); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + ContentConfirmationRequest? dependencyRequest = null; + var controller = new InstanceContentController(store, content, modrinth, fabric, + (request, _) => + { + if (request.Code == ContentConfirmationCode.Dependencies) dependencyRequest = request; + return Task.FromResult(ContentConfirmationChoice.Primary); + }); + + var result = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("World"), + server.Project("rootpack")); + + Assert.IsFalse(result.Succeeded); + Assert.IsTrue(dependencyRequest!.Projects.Any(project => project.Id == "moddep01" + && project.NoteCode == ContentConfirmationNoteCode.RequiresFabric)); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "saves", "World", "datapacks", "root.zip"))); + Assert.IsFalse(Directory.Exists(Path.Combine(root, "minecraft", "mods"))); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task DatapackLinkedResourcePackReusesMatchingHashAndEnablesDisabledPack(bool storedOrigin) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var firstWorld = CreateWorldFolder(root, "ReusePack"); + var secondWorld = CreateWorldFolder(root, "EnablePack"); + var linkedPackBytes = DatapackZip("linked resource pack"); + var localPackPath = Path.Combine(temporary.Root, "local-resource-pack.zip"); + await File.WriteAllBytesAsync(localPackPath, linkedPackBytes); + await content.ImportAsync(instance.Id, InstanceContentTarget.Packs, localPackPath); + if (storedOrigin) + { + var item = (await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single(); + await content.WithContentLeaseAsync(instance.Id, async (leasedRoot, leasedInstance, token) => + await content.RecordOriginUnderLeaseAsync(leasedRoot, leasedInstance, item, InstanceContentTarget.Packs, + Hash(linkedPackBytes), Origin("unrelated", "oldversion", linkedPackBytes) with + { + FileType = "optional-resource-pack", + Api = new FabricApiDescriptor("unrelated", "oldversion", "old", "release", + "local-resource-pack.zip", "https://example.test/pack.zip", linkedPackBytes.Length, + Convert.ToHexString(SHA1.HashData(linkedPackBytes)).ToLowerInvariant(), Hash(linkedPackBytes)), + IconURL = new Uri("https://example.test/icon.png") + }, token)); + } + var originalOrigin = (await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single().Origin; + var registryPath = Path.Combine(root, "minecraft", ".hako-resourcepacks.json"); + var registryBytes = await File.ReadAllBytesAsync(registryPath); + + using var server = new ModrinthServer(); + server.AddProject("rootpack", "Root pack", "datapack"); + var attached = server.File("linked.zip", linkedPackBytes, "required-resource-pack", primary: false); + server.AddVersion("rootpack", "rootver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("root.zip", DatapackZip("root")), attached]); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var cancelSelection = true; + var controller = new InstanceContentController(store, content, modrinth, fabric, + confirmSelection: async (request, _) => + { + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + return new ContentConfirmationResult(cancelSelection ? ContentConfirmationChoice.Cancel + : ContentConfirmationChoice.Primary, request.Code == ContentConfirmationCode.DatapackResourcePacks + ? "linked.zip" : null); + }); + + var cancelled = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("ReusePack"), + server.Project("rootpack")); + Assert.IsFalse(cancelled.Succeeded); + Assert.AreEqual(0, server.DownloadRequests); + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + Assert.IsFalse(Directory.Exists(Path.Combine(firstWorld, "datapacks"))); + cancelSelection = false; + + var first = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("ReusePack"), + server.Project("rootpack")); + Assert.IsTrue(first.Succeeded, first.Failure?.ToString()); + Assert.AreEqual(1, server.DownloadRequests); + var installedPack = (await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single(); + Assert.IsTrue(installedPack.Enabled); + Assert.AreEqual(originalOrigin, installedPack.Origin); + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + + await content.SetEnabledAsync(instance.Id, installedPack, InstanceContentTarget.Packs, enabled: false); + registryBytes = await File.ReadAllBytesAsync(registryPath); + cancelSelection = true; + var cancelledDisabled = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("EnablePack"), + server.Project("rootpack")); + Assert.IsFalse(cancelledDisabled.Succeeded); + Assert.IsFalse((await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single().Enabled); + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + cancelSelection = false; + server.OnDownload = async _ => + { + Assert.IsFalse((await content.ListUnderLeaseAsync(root, instance, InstanceContentTarget.Packs, true, + CancellationToken.None)).Single().Enabled); + throw new IOException("Injected download failure."); + }; + var failed = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("EnablePack"), + server.Project("rootpack")); + Assert.IsFalse(failed.Succeeded); + Assert.IsFalse((await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single().Enabled); + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + server.OnDownload = async _ => Assert.IsFalse((await content.ListUnderLeaseAsync(root, instance, + InstanceContentTarget.Packs, true, CancellationToken.None)).Single().Enabled); + var second = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("EnablePack"), + server.Project("rootpack")); + Assert.IsTrue(second.Succeeded, second.Failure?.ToString()); + var reenabledPack = (await content.ListAsync(instance.Id, InstanceContentTarget.Packs)).Single(); + Assert.IsTrue(reenabledPack.Enabled); + Assert.AreEqual(originalOrigin, reenabledPack.Origin); + CollectionAssert.AreEqual(registryBytes, await File.ReadAllBytesAsync(registryPath)); + Assert.IsTrue(File.Exists(Path.Combine(firstWorld, "datapacks", "root.zip"))); + Assert.IsTrue(File.Exists(Path.Combine(secondWorld, "datapacks", "root.zip"))); + var disabledPacksFolder = Path.Combine(root, "minecraft", ".hako-disabled-resourcepacks"); + Assert.IsFalse(Directory.Exists(disabledPacksFolder) && Directory.EnumerateFileSystemEntries(disabledPacksFolder).Any()); + } + + [TestMethod] + public async Task IncompatibleDatapackInAnotherWorldDoesNotBlockSelectedWorldInstall() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary, + modLoader: InstanceModLoader.Vanilla); + var selectedWorld = CreateWorldFolder(root, "Selected"); + CreateWorldFolder(root, "Other"); + var otherTarget = InstanceContentTarget.WorldDatapacks("Other"); + var otherSource = Path.Combine(temporary.Root, "conflict.zip"); + var conflictBytes = DatapackZip("other world conflict"); + await File.WriteAllBytesAsync(otherSource, conflictBytes); + await content.ImportAsync(instance.Id, otherTarget, otherSource); + var otherItem = (await content.ListAsync(instance.Id, otherTarget)).Single(); + await content.WithContentLeaseAsync(instance.Id, async (leasedRoot, leasedInstance, token) => + { + await content.RecordOriginUnderLeaseAsync(leasedRoot, leasedInstance, otherItem, otherTarget, + Hash(conflictBytes), Origin("conflict01", "conflictver", conflictBytes), token); + }); + using var server = new ModrinthServer(); + server.AddProject("conflict01", "Other-world datapack", "datapack"); + server.AddProject("install01", "Selected-world datapack", "datapack"); + server.AddVersion("install01", "installver", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("install.zip", DatapackZip("selected"))], + [Dependency(projectId: "conflict01", type: "incompatible")]); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var confirmations = new List(); + var controller = new InstanceContentController(store, content, modrinth, fabric, + (request, _) => + { + confirmations.Add(request.Code); + return Task.FromResult(ContentConfirmationChoice.Primary); + }); + + var result = await controller.InstallAsync(instance.Id, InstanceContentTarget.WorldDatapacks("Selected"), + server.Project("install01")); + + Assert.IsTrue(result.Succeeded, result.Failure?.ToString()); + Assert.IsFalse(confirmations.Contains(ContentConfirmationCode.IncompatibleContent)); + Assert.IsTrue(File.Exists(Path.Combine(selectedWorld, "datapacks", "install.zip"))); + Assert.IsTrue(File.Exists(Path.Combine(root, "minecraft", "saves", "Other", "datapacks", "conflict.zip"))); + } + + [TestMethod] + public async Task CorruptWorldDatapackRegistryFallsBackForListingAndBlocksMutation() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorldFolder(root, "Corrupt"); + var datapacks = Path.Combine(world, "datapacks"); + Directory.CreateDirectory(datapacks); + var bytes = DatapackZip("visible with corrupt registry"); + var path = Path.Combine(datapacks, "pack.zip"); + await File.WriteAllBytesAsync(path, bytes); + var registryPath = Path.Combine(world, ".hako-datapacks.json"); + const string corruptRegistry = "{broken"; + await File.WriteAllTextAsync(registryPath, corruptRegistry); + using var server = new ModrinthServer(); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + var target = InstanceContentTarget.WorldDatapacks("Corrupt"); + + await controller.ReloadAsync(instance.Id, target); + var state = controller.GetState(instance.Id, target); + Assert.AreEqual(ContentFailureCode.RegistryCorrupt, state.Error?.Code); + Assert.AreEqual(1, state.Items.Count); + var mutation = await controller.SetEnabledAsync(instance.Id, target, state.Items.Single(), enabled: false); + + Assert.IsFalse(mutation.Succeeded); + Assert.AreEqual(ContentFailureCode.RegistryCorrupt, mutation.Failure?.Code); + CollectionAssert.AreEqual(bytes, await File.ReadAllBytesAsync(path)); + Assert.AreEqual(corruptRegistry, await File.ReadAllTextAsync(registryPath)); + } + + [TestMethod] + public async Task LateUpdateCheckForRemovedWorldPublishesWorldUnavailableAndClearsStaleItems() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorldFolder(root, "LateRemoval"); + var target = InstanceContentTarget.WorldDatapacks("LateRemoval"); + var oldBytes = DatapackZip("old"); + var oldSource = Path.Combine(temporary.Root, "pack.zip"); + await File.WriteAllBytesAsync(oldSource, oldBytes); + await content.ImportAsync(instance.Id, target, oldSource); + using var server = new ModrinthServer(); + server.AddProject("project01", "Pack", "datapack"); + var oldVersion = server.AddVersion("project01", "oldver01", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("pack.zip", oldBytes)]); + var next = server.AddVersion("project01", "newver01", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("pack.zip", DatapackZip("new"))]); + server.Recognize(Hash(oldBytes), oldVersion); + server.AddUpdate(Hash(oldBytes), next); + server.BeforeVersionUpdate = (_, _) => + { + Directory.Delete(world, recursive: true); + return Task.CompletedTask; + }; + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var controller = new InstanceContentController(store, content, modrinth, fabric); + + await controller.CheckUpdatesAsync(instance.Id, target); + + var state = controller.GetState(instance.Id, target); + Assert.AreEqual(ContentFailureCode.WorldUnavailable, state.Error?.Code); + Assert.AreEqual(ContentFailureCode.WorldUnavailable, state.UpdateMessage?.Code); + Assert.AreEqual(0, state.Items.Count); + Assert.AreEqual(0, state.Updates.Count); + Assert.IsFalse(state.IsCheckingUpdates); + Assert.IsFalse(Directory.Exists(world)); + } + + [TestMethod] + public async Task DatapackUpdateAllReportsConfirmationRequiredWithoutDialogAndContinuesOtherItems() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root, content) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorldFolder(root, "Updates"); + var target = InstanceContentTarget.WorldDatapacks("Updates"); + using var server = new ModrinthServer(); + var oldABytes = DatapackZip("old A"); + var oldBBytes = DatapackZip("old B"); + var newABytes = DatapackZip("new A"); + var newBBytes = DatapackZip("new B"); + var oldAFile = server.File("a.zip", oldABytes); + var oldBFile = server.File("b.zip", oldBBytes); + server.AddProject("firstpack", "First pack", "datapack"); + server.AddProject("secondpack", "Second pack", "datapack"); + var oldA = server.AddVersion("firstpack", "firstold1", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [oldAFile]); + var oldB = server.AddVersion("secondpack", "secondold", "1.0", "release", "2025-01-01T00:00:00Z", + ModrinthContentKind.Datapack, [oldBFile]); + var nextA = server.AddVersion("firstpack", "firstnew1", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Datapack, + [server.File("a.zip", newABytes), server.File("attached.zip", DatapackZip("attached"), "required-resource-pack", primary: false)]); + var nextB = server.AddVersion("secondpack", "secondnew", "2.0", "release", "2025-02-01T00:00:00Z", + ModrinthContentKind.Datapack, [server.File("b.zip", newBBytes)]); + server.Recognize(Hash(oldABytes), oldA); + server.Recognize(Hash(oldBBytes), oldB); + server.AddUpdate(Hash(oldABytes), nextA); + server.AddUpdate(Hash(oldBBytes), nextB); + await File.WriteAllBytesAsync(Path.Combine(temporary.Root, "a.zip"), oldABytes); + await File.WriteAllBytesAsync(Path.Combine(temporary.Root, "b.zip"), oldBBytes); + await content.ImportAsync(instance.Id, target, Path.Combine(temporary.Root, "a.zip")); + await content.ImportAsync(instance.Id, target, Path.Combine(temporary.Root, "b.zip")); + var itemB = (await content.ListAsync(instance.Id, target)).Single(item => item.Name == "b.zip"); + await content.SetEnabledAsync(instance.Id, itemB, target, enabled: false); + var levelBefore = await File.ReadAllBytesAsync(Path.Combine(world, "level.dat")); + using var modrinth = server.CreateModrinth(temporary.Paths); + using var fabric = server.CreateFabric(temporary.Paths); + var dialogCount = 0; + var controller = new InstanceContentController(store, content, modrinth, fabric, + (_, _) => + { + dialogCount++; + return Task.FromResult(ContentConfirmationChoice.Primary); + }); + + await controller.CheckUpdatesAsync(instance.Id, target); + Assert.AreEqual(2, controller.GetState(instance.Id, target).Updates.Count); + Assert.AreEqual("firstold1", controller.GetState(instance.Id, target).Items + .Single(item => item.Name == "a.zip").Origin?.VersionID); + var result = await controller.UpdateAllAsync(instance.Id, target); + + Assert.AreEqual(2, result.Items.Count); + Assert.IsFalse(result.Items.Single(item => item.Filename == "a.zip").Succeeded); + Assert.AreEqual(ContentFailureCode.ConfirmationRequired, result.Items.Single(item => item.Filename == "a.zip").Failure?.Code); + Assert.IsTrue(result.Items.Single(item => item.Filename == "b.zip").Succeeded); + Assert.AreEqual(0, dialogCount); + CollectionAssert.AreEqual(oldABytes, await File.ReadAllBytesAsync(Path.Combine(world, "datapacks", "a.zip"))); + var updatedB = (await content.ListAsync(instance.Id, target)).Single(item => item.Name == "b.zip"); + Assert.IsTrue(updatedB.DisabledPack); + Assert.AreEqual("secondnew", updatedB.Origin?.VersionID); + CollectionAssert.AreEqual(newBBytes, await File.ReadAllBytesAsync(updatedB.Path)); + CollectionAssert.AreEqual(levelBefore, await File.ReadAllBytesAsync(Path.Combine(world, "level.dat"))); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "resourcepacks", "attached.zip"))); + } + + private static string CreateWorldFolder(string root, string worldId) + { + var path = Path.Combine(root, "minecraft", "saves", worldId); + Directory.CreateDirectory(path); + File.WriteAllBytes(Path.Combine(path, "level.dat"), WorldTestFixture.Create(worldId).Compressed); + return path; + } + + private static byte[] DatapackZip(string description) + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) + { + using (var writer = new StreamWriter(archive.CreateEntry("pack.mcmeta").Open(), new UTF8Encoding(false))) + writer.Write("{\"pack\":{\"pack_format\":15,\"description\":\"" + description + "\"}}"); + using (var writer = new StreamWriter(archive.CreateEntry("data/fixture.txt").Open(), new UTF8Encoding(false))) + writer.Write(description); + } + + return memory.ToArray(); + } + + private sealed class CallbackRecycleOperation(Func> action) + : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) => action(path, cancellationToken); + } + + private static async Task<(InstanceStore Store, GameInstance Instance, string Root, InstanceContent Content)> CreateReadyInstanceAsync( + TemporaryDirectory temporary, string name = "Build", string loader = "0.16.9", int java = 21, + InstanceModLoader modLoader = InstanceModLoader.Fabric) + { + var store = temporary.CreateStore(); + await store.LoadAsync(); + var draft = new InstanceDraft + { + Name = name, + ModLoader = modLoader, + JavaMajorVersion = java, + FabricConfiguration = modLoader == InstanceModLoader.Fabric ? new FabricConfiguration(loader) : null + }; + var instance = store.Create(draft, "1.20.4", javaMajorVersion: java); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Ready, pauseRequested: false); + instance = store.List().Single(candidate => candidate.Id == instance.Id); + return (store, instance, store.GetInstanceDirectory(instance.Id), + new InstanceContent(store, new RecycleBin(new TestRecycleOperation()))); + } + + private static ModrinthDependency Dependency(string? projectId = null, string? versionId = null, string type = "required") => + new() { ProjectId = projectId, VersionId = versionId, Type = type }; + + private static ModOrigin Origin(string projectId, string versionId, byte[] bytes) => new() + { + Source = ModSource.Modrinth, + ProjectID = projectId, + VersionID = versionId, + PageURL = new Uri($"https://modrinth.com/mod/{projectId}"), + Sha512 = Hash(bytes), + VersionNumber = versionId + }; + + private static byte[] FabricJar(string id, string depends) + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) + using (var writer = new StreamWriter(archive.CreateEntry("fabric.mod.json").Open())) + writer.Write($"{{\"schemaVersion\":1,\"id\":\"{id}\",\"version\":\"1.0\",\"depends\":{depends}}}"); + return memory.ToArray(); + } + + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + + private sealed class TestRecycleOperation : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(path)) File.Delete(path); + else if (Directory.Exists(path)) Directory.Delete(path, recursive: true); + return Task.FromResult(RecycleBinItemStatus.Recycled); + } + } + + private sealed class ModrinthServer : IDisposable + { + private static readonly Uri ApiBase = new("https://api.test/v2/"); + private readonly Dictionary _projects = new(StringComparer.Ordinal); + private readonly Dictionary> _versions = new(StringComparer.Ordinal); + private readonly Dictionary _versionsById = new(StringComparer.Ordinal); + private readonly Dictionary _recognized = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _updates = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _downloads = new(StringComparer.Ordinal); + private readonly HttpClient _http; + private int _downloadRequests; + private int _allDownloadsSawEmpty = 1; + private string[] _observedContentDirectories = []; + + public ModrinthServer() => _http = new HttpClient(new CallbackHttpHandler(HandleAsync)); + public int DownloadRequests => Volatile.Read(ref _downloadRequests); + public bool AllDownloadsSawEmptyContentDirectory => Volatile.Read(ref _allDownloadsSawEmpty) == 1; + public bool AllDownloadsSawEmptyContentDirectories => AllDownloadsSawEmptyContentDirectory; + public Func? OnDownload { get; set; } + public TaskCompletionSource? DownloadStarted { get; set; } + public Func, CancellationToken, Task>? BeforeVersionUpdate { get; set; } + + public void ObserveContentDirectory(string path) => ObserveContentDirectories(path); + + public void ObserveContentDirectories(params string[] paths) => _observedContentDirectories = paths; + + public ModrinthClient CreateModrinth(HakoPaths paths) => new(_http, ApiBase, paths); + public FabricClient CreateFabric(HakoPaths paths) => new(_http, paths, modrinthBase: ApiBase); + + public ModrinthProject Project(string id) + { + var project = _projects[id]; + return new ModrinthProject + { + Id = project.Id, + Slug = project.Id, + Title = project.Title, + Description = "", + IconUrl = project.IconUrl, + ProjectType = project.Type, + AllProjectTypes = project.AllProjectTypes + }; + } + + public void AddProject(string id, string title, string type, string? iconUrl = null, + IReadOnlyList? allProjectTypes = null) => + _projects[id] = new ProjectData(id, title, type, iconUrl is null ? null : new Uri(iconUrl), allProjectTypes ?? [type]); + + public VersionData AddVersion(string projectId, string id, string number, string channel, string published, + ModrinthContentKind kind, IReadOnlyList files, IReadOnlyList? dependencies = null, + IReadOnlyList? gameVersions = null) + { + var loaders = kind switch + { + ModrinthContentKind.Mod => new[] { "fabric" }, + ModrinthContentKind.ResourcePack => new[] { "minecraft" }, + _ => new[] { "datapack" } + }; + var version = BuildVersion(projectId, id, number, channel, published, gameVersions ?? ["1.20.4"], + loaders, files, dependencies ?? []); + _versionsById[id] = version; + if (!_versions.TryGetValue(projectId, out var entries)) _versions[projectId] = entries = []; + entries.Add(version); + return version; + } + + public VersionData WithProjectVersion(string projectId, string id, string number, string channel, + IReadOnlyList files) => BuildVersion(projectId, id, number, channel, "2025-01-01T00:00:00Z", + ["1.20.4"], ["fabric"], files, []); + + public void ReplaceVersions(string projectId, IReadOnlyList versions) => _versions[projectId] = versions.ToList(); + + public FileData File(string filename, byte[] bytes, string? fileType = null, bool primary = true) + { + var url = new Uri($"https://cdn.modrinth.com/{Guid.NewGuid():N}/{Uri.EscapeDataString(filename)}"); + _downloads[url.AbsolutePath] = bytes; + return new FileData(filename, url, bytes, fileType, primary); + } + + public void Recognize(string hash, VersionData version) => _recognized[hash] = version; + + public void AddUpdate(string currentHash, VersionData next) + { + if (!_updates.TryGetValue(currentHash, out var versions)) _updates[currentHash] = versions = []; + versions.Add(next); + } + + public void Dispose() => _http.Dispose(); + + private async Task HandleAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri!; + if (uri.Host == "cdn.modrinth.com") + { + Interlocked.Increment(ref _downloadRequests); + if (DownloadStarted is not null) DownloadStarted.TrySetResult(); + if (OnDownload is not null) await OnDownload(cancellationToken); + foreach (var contentDirectory in _observedContentDirectories) + { + if (Directory.Exists(contentDirectory) && Directory.EnumerateFileSystemEntries(contentDirectory).Any()) + { + Interlocked.Exchange(ref _allDownloadsSawEmpty, 0); + break; + } + } + return JsonBytes(HttpStatusCode.OK, _downloads[uri.AbsolutePath]); + } + + if (uri.AbsolutePath.EndsWith("/project", StringComparison.Ordinal)) + return Json(HttpStatusCode.NotFound, "{}"); + if (uri.AbsolutePath.EndsWith("/version_files/update", StringComparison.Ordinal)) + { + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + var hashes = body.RootElement.GetProperty("hashes").EnumerateArray().Select(value => value.GetString()!).ToArray(); + if (BeforeVersionUpdate is not null) await BeforeVersionUpdate(hashes, cancellationToken); + var channels = body.RootElement.GetProperty("version_types").EnumerateArray().Select(value => value.GetString()!).ToArray(); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var hash in hashes) + { + if (!_updates.TryGetValue(hash, out var candidates)) continue; + var match = candidates.Where(candidate => channels.Contains(candidate.Channel, StringComparer.Ordinal)) + .OrderByDescending(candidate => candidate.Published, StringComparer.Ordinal).FirstOrDefault(); + if (match is not null) result[hash] = JsonDocument.Parse(match.Json).RootElement.Clone(); + } + + return Json(HttpStatusCode.OK, JsonSerializer.Serialize(result)); + } + + if (uri.AbsolutePath.EndsWith("/version_files", StringComparison.Ordinal)) + { + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var hash in body.RootElement.GetProperty("hashes").EnumerateArray().Select(value => value.GetString()!)) + { + if (_recognized.TryGetValue(hash, out var version)) + result[hash] = new { id = version.Id, project_id = version.ProjectId, date_published = version.Published }; + } + + return Json(HttpStatusCode.OK, JsonSerializer.Serialize(result)); + } + + if (uri.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal)) + { + var ids = ReadQueryArray(uri, "ids"); + return Json(HttpStatusCode.OK, "[" + string.Join(',', ids.Where(_versionsById.ContainsKey).Select(id => _versionsById[id].Json)) + "]"); + } + + if (uri.AbsolutePath.EndsWith("/projects", StringComparison.Ordinal)) + { + var ids = ReadQueryArray(uri, "ids"); + return Json(HttpStatusCode.OK, "[" + string.Join(',', ids.Where(_projects.ContainsKey).Select(id => _projects[id].Json)) + "]"); + } + + if (uri.AbsolutePath.Contains("/project/", StringComparison.Ordinal) && uri.AbsolutePath.EndsWith("/version", StringComparison.Ordinal)) + { + var projectId = uri.AbsolutePath.Split('/')[^2]; + return Json(HttpStatusCode.OK, "[" + string.Join(',', _versions.GetValueOrDefault(projectId, []).Select(version => version.Json)) + "]"); + } + + return Json(HttpStatusCode.NotFound, "{}"); + } + + private static VersionData BuildVersion(string projectId, string id, string number, string channel, + string published, IReadOnlyList games, IReadOnlyList loaders, IReadOnlyList files, + IReadOnlyList dependencies) + { + var dependencyRows = dependencies.Select(dependency => new + { + version_id = dependency.VersionId, + project_id = dependency.ProjectId, + dependency_type = dependency.Type + }).ToArray(); + var json = JsonSerializer.Serialize(new + { + id, + project_id = projectId, + version_number = number, + version_type = channel, + date_published = published, + game_versions = games, + loaders, + dependencies = dependencyRows, + files = files.Select(file => new + { + filename = file.Filename, + url = file.Url.AbsoluteUri, + size = file.Bytes.Length, + hashes = new { sha1 = file.Sha1, sha512 = file.Sha512 }, + primary = file.Primary, + file_type = file.FileType + }) + }); + return new VersionData(projectId, id, number, channel, published, loaders, files, dependencies, json); + } + + private static string[] ReadQueryArray(Uri uri, string name) + { + foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var components = part.Split('=', 2); + if (Uri.UnescapeDataString(components[0]) != name || components.Length != 2) continue; + return JsonSerializer.Deserialize(Uri.UnescapeDataString(components[1])) ?? []; + } + + return []; + } + + private static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static HttpResponseMessage JsonBytes(HttpStatusCode status, byte[] bytes) => new(status) + { + Content = new ByteArrayContent(bytes) + }; + + private sealed record ProjectData(string Id, string Title, string Type, Uri? IconUrl, + IReadOnlyList AllProjectTypes) + { + public string Json => JsonSerializer.Serialize(new + { + id = Id, + slug = Id, + title = Title, + description = "", + icon_url = IconUrl?.AbsoluteUri, + project_type = Type, + all_project_types = AllProjectTypes + }); + } + + public sealed record FileData(string Filename, Uri Url, byte[] Bytes, string? FileType, bool Primary) + { + public string Sha1 => Convert.ToHexString(SHA1.HashData(Bytes)).ToLowerInvariant(); + public string Sha512 => Hash(Bytes); + } + + public sealed record VersionData(string ProjectId, string Id, string Number, string Channel, string Published, + IReadOnlyList Loaders, IReadOnlyList Files, IReadOnlyList Dependencies, + string Json); + } +} diff --git a/windows/Hako.Tests/InstanceContentTests.cs b/windows/Hako.Tests/InstanceContentTests.cs new file mode 100644 index 0000000..c8ce240 --- /dev/null +++ b/windows/Hako.Tests/InstanceContentTests.cs @@ -0,0 +1,534 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstanceContentTests +{ + [TestMethod] + public async Task LocalImportCopiesJarZipAndPackFolderAndClearsModOrigin() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary, legacyTexturepacks: false); + var content = new InstanceContent(store); + var minecraft = Path.Combine(root, "minecraft"); + var mods = Path.Combine(minecraft, "mods"); + Directory.CreateDirectory(mods); + var oldBytes = Encoding.UTF8.GetBytes("old mod"); + var newBytes = Encoding.UTF8.GetBytes("local replacement"); + await File.WriteAllBytesAsync(Path.Combine(mods, "Local.jar"), oldBytes); + var original = Origin("old-version", oldBytes); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) { ["local.jar"] = original } + }); + + var sourceJar = Path.Combine(temporary.Root, "Local.jar"); + await File.WriteAllBytesAsync(sourceJar, newBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, sourceJar, replace: true); + var listedMods = await content.ListAsync(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual(1, listedMods.Count); + Assert.IsNull(listedMods.Single().Origin); + CollectionAssert.AreEqual(newBytes, await File.ReadAllBytesAsync(Path.Combine(mods, "Local.jar"))); + var modRegistry = InstanceContent.ReadRegistry(root); + Assert.IsTrue(modRegistry.ApiProvisioned); + Assert.IsFalse(modRegistry.Files.ContainsKey("local.jar")); + + var zipSource = Path.Combine(temporary.Root, "Pack.zip"); + var zipBytes = Encoding.UTF8.GetBytes("zip bytes remain an archive"); + await File.WriteAllBytesAsync(zipSource, zipBytes); + await content.ImportAsync(instance.Id, ModrinthContentKind.ResourcePack, zipSource); + CollectionAssert.AreEqual(zipBytes, await File.ReadAllBytesAsync(Path.Combine(minecraft, "resourcepacks", "Pack.zip"))); + + var folderSource = Path.Combine(temporary.Root, "FolderPack"); + Directory.CreateDirectory(folderSource); + await File.WriteAllTextAsync(Path.Combine(folderSource, "pack.mcmeta"), "{\"pack\":{\"pack_format\":15,\"description\":\"test\"}}"); + await File.WriteAllBytesAsync(Path.Combine(folderSource, "pack.png"), [1, 2, 3]); + await content.ImportAsync(instance.Id, ModrinthContentKind.ResourcePack, folderSource); + Assert.IsTrue(File.Exists(Path.Combine(minecraft, "resourcepacks", "FolderPack", "pack.mcmeta"))); + Assert.IsTrue(File.Exists(Path.Combine(minecraft, "resourcepacks", "FolderPack", "pack.png"))); + Assert.AreEqual(2, (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Count); + } + + [TestMethod] + public async Task MacOsModAndResourcepackRegistryJSONLoadsWithOptionalOriginsAndExactKeys() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var bytes = Encoding.UTF8.GetBytes("macOS format"); + var sha512 = Hash(bytes); + var minecraft = Path.Combine(root, "minecraft"); + var mods = Path.Combine(minecraft, "mods"); + var packs = Path.Combine(minecraft, "resourcepacks"); + Directory.CreateDirectory(mods); + Directory.CreateDirectory(packs); + await File.WriteAllBytesAsync(Path.Combine(mods, "Fixture.jar.disabled"), bytes); + await File.WriteAllBytesAsync(Path.Combine(packs, "Fixture.zip"), bytes); + var origin = $"{{\"source\":\"modrinth\",\"projectID\":\"project123\",\"versionID\":\"version123\",\"pageURL\":\"https://modrinth.com/mod/project123\",\"sha512\":\"{sha512}\"}}"; + await File.WriteAllTextAsync(Path.Combine(minecraft, ".hako-mods.json"), + $"{{\"apiProvisioned\":true,\"files\":{{\"fixture.jar\":{origin}}}}}"); + await File.WriteAllTextAsync(Path.Combine(minecraft, ".hako-resourcepacks.json"), + $"{{\"apiProvisioned\":false,\"files\":{{\"fixture.zip\":{origin}}}}}"); + + var content = new InstanceContent(store); + var mod = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + var pack = (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Single(); + + Assert.IsFalse(mod.Enabled); + Assert.AreEqual("project123", mod.Origin?.ProjectID); + Assert.IsNull(mod.Origin?.VersionNumber); + Assert.AreEqual("version123", pack.Origin?.VersionID); + Assert.IsNull(pack.Origin?.IconURL); + Assert.IsTrue(InstanceContent.ReadRegistry(root).ApiProvisioned); + Assert.IsFalse(InstanceContent.ReadRegistry(root, ".hako-resourcepacks.json").ApiProvisioned); + } + + [TestMethod] + public async Task ListingAndTogglingDoNotLoseOriginAndApiProvisioningState() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var bytes = Encoding.UTF8.GetBytes("fabric api mod"); + var folder = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(folder); + var path = Path.Combine(folder, "fabric-api.jar"); + await File.WriteAllBytesAsync(path, bytes); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) { ["fabric-api.jar"] = Origin("api-version", bytes) } + }); + var content = new InstanceContent(store); + + var enabled = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + await content.SetEnabledAsync(instance.Id, enabled, ModrinthContentKind.Mod, false); + var disabled = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + + Assert.AreEqual("api-version", disabled.Origin?.VersionID); + Assert.IsTrue(InstanceContent.ReadRegistry(root).ApiProvisioned); + Assert.AreEqual(Hash(bytes), disabled.Origin?.Sha512); + } + + [TestMethod] + public async Task TogglePreservesLogicalModAndPackAndUsesSwiftDisabledPackFolders() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary, legacyTexturepacks: false); + var content = new InstanceContent(store); + var modSource = Path.Combine(temporary.Root, "toggle.jar"); + await File.WriteAllTextAsync(modSource, "mod"); + await content.ImportAsync(instance.Id, ModrinthContentKind.Mod, modSource); + var mod = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + await content.SetEnabledAsync(instance.Id, mod, ModrinthContentKind.Mod, enabled: false); + var afterDisable = await content.ListAsync(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual(1, afterDisable.Count, "Files: " + string.Join(",", Directory.GetFiles(Path.Combine(root, "minecraft", "mods")).Select(Path.GetFileName))); + Assert.AreEqual(0, (await content.ListAsync(instance.Id, ModrinthContentKind.Mod, includeDisabled: false)).Count); + var disabledMod = afterDisable.Single(); + Assert.IsFalse(disabledMod.Enabled); + Assert.AreEqual("toggle.jar.disabled", disabledMod.Name); + await content.SetEnabledAsync(instance.Id, disabledMod, ModrinthContentKind.Mod, enabled: true); + Assert.AreEqual("toggle.jar", (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single().Name); + + var packFolder = Path.Combine(temporary.Root, "PackFolder"); + Directory.CreateDirectory(packFolder); + await File.WriteAllTextAsync(Path.Combine(packFolder, "pack.mcmeta"), "{\"pack\":{}}"); + await content.ImportAsync(instance.Id, ModrinthContentKind.ResourcePack, packFolder); + var pack = (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Single(); + await content.SetEnabledAsync(instance.Id, pack, ModrinthContentKind.ResourcePack, enabled: false); + var disabledPack = (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Single(); + Assert.IsTrue(disabledPack.DisabledPack); + Assert.AreEqual(Path.Combine("minecraft", ".hako-disabled-resourcepacks", "PackFolder"), + Path.GetRelativePath(root, disabledPack.Path)); + await content.SetEnabledAsync(instance.Id, disabledPack, ModrinthContentKind.ResourcePack, enabled: true); + Assert.IsFalse((await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).Single().DisabledPack); + + var (legacyStore, legacyInstance, legacyRoot) = await CreateReadyInstanceAsync(temporary, "Legacy", legacyTexturepacks: true); + var legacyContent = new InstanceContent(legacyStore); + var legacySource = Path.Combine(temporary.Root, "LegacyPack.zip"); + await File.WriteAllBytesAsync(legacySource, [4, 5, 6]); + await legacyContent.ImportAsync(legacyInstance.Id, ModrinthContentKind.ResourcePack, legacySource); + var legacyPack = (await legacyContent.ListAsync(legacyInstance.Id, ModrinthContentKind.ResourcePack)).Single(); + Assert.IsTrue(legacyPack.Path.Contains(Path.Combine("minecraft", "texturepacks"), StringComparison.OrdinalIgnoreCase)); + await legacyContent.SetEnabledAsync(legacyInstance.Id, legacyPack, ModrinthContentKind.ResourcePack, enabled: false); + var legacyDisabled = (await legacyContent.ListAsync(legacyInstance.Id, ModrinthContentKind.ResourcePack)).Single(); + Assert.AreEqual(Path.Combine("minecraft", ".hako-disabled-texturepacks", "LegacyPack.zip"), + Path.GetRelativePath(legacyRoot, legacyDisabled.Path)); + } + + [TestMethod] + public async Task CaseInsensitiveDisabledCollisionsPreserveBothFiles() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var content = new InstanceContent(store); + var mods = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(mods); + await File.WriteAllTextAsync(Path.Combine(mods, "collision.jar"), "enabled"); + await File.WriteAllTextAsync(Path.Combine(mods, "COLLISION.jar.disabled"), "disabled"); + var mod = (await content.ListAsync(instance.Id, ModrinthContentKind.Mod)).First(item => item.Name == "collision.jar"); + await Assert.ThrowsExactlyAsync(() => content.SetEnabledAsync(instance.Id, mod, ModrinthContentKind.Mod, false)); + Assert.AreEqual("enabled", await File.ReadAllTextAsync(Path.Combine(mods, "collision.jar"))); + Assert.AreEqual("disabled", await File.ReadAllTextAsync(Path.Combine(mods, "COLLISION.jar.disabled"))); + + var enabledFolder = Path.Combine(root, "minecraft", "resourcepacks"); + var disabledFolder = Path.Combine(root, "minecraft", ".hako-disabled-resourcepacks"); + Directory.CreateDirectory(enabledFolder); + Directory.CreateDirectory(disabledFolder); + await File.WriteAllBytesAsync(Path.Combine(enabledFolder, "Texture.zip"), [1]); + await File.WriteAllBytesAsync(Path.Combine(disabledFolder, "texture.ZIP"), [2]); + var pack = (await content.ListAsync(instance.Id, ModrinthContentKind.ResourcePack)).First(item => item.Name == "Texture.zip"); + await Assert.ThrowsExactlyAsync(() => content.SetEnabledAsync(instance.Id, pack, ModrinthContentKind.ResourcePack, false)); + CollectionAssert.AreEqual(new byte[] { 1 }, await File.ReadAllBytesAsync(Path.Combine(enabledFolder, "Texture.zip"))); + CollectionAssert.AreEqual(new byte[] { 2 }, await File.ReadAllBytesAsync(Path.Combine(disabledFolder, "texture.ZIP"))); + } + + [TestMethod] + public async Task ReplaceChecksExpectedHashPreservesDisabledStateAndRollsBackPublicationFailure() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var initialContent = new InstanceContent(store); + var oldBytes = Encoding.UTF8.GetBytes("old mod bytes"); + var newBytes = Encoding.UTF8.GetBytes("new mod bytes"); + var oldSource = Path.Combine(temporary.Root, "old.jar"); + var newSource = Path.Combine(temporary.Root, "new.jar"); + await File.WriteAllBytesAsync(oldSource, oldBytes); + await File.WriteAllBytesAsync(newSource, newBytes); + await initialContent.ImportAsync(instance.Id, ModrinthContentKind.Mod, oldSource); + var modFolder = Path.Combine(root, "minecraft", "mods"); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) { ["old.jar"] = Origin("old-version", oldBytes) } + }); + var item = (await initialContent.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + await initialContent.SetEnabledAsync(instance.Id, item, ModrinthContentKind.Mod, enabled: false); + var disabled = (await initialContent.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + var expected = Hash(oldBytes); + var failure = new InstanceContent(store, null, () => throw new IOException("Injected registry commit failure.")); + + await Assert.ThrowsExactlyAsync(() => failure.ReplaceAsync(instance.Id, disabled, ModrinthContentKind.Mod, + newSource, "new.jar", expected, Origin("new-version", newBytes))); + CollectionAssert.AreEqual(oldBytes, await File.ReadAllBytesAsync(Path.Combine(modFolder, "old.jar.disabled"))); + Assert.IsFalse(File.Exists(Path.Combine(modFolder, "new.jar.disabled"))); + var unchanged = InstanceContent.ReadRegistry(root); + Assert.IsTrue(unchanged.ApiProvisioned); + Assert.AreEqual("old-version", unchanged.Files["old.jar"].VersionID); + + var changedHash = await Assert.ThrowsExactlyAsync(() => initialContent.ReplaceAsync(instance.Id, disabled, + ModrinthContentKind.Mod, newSource, "new.jar", Hash(Encoding.UTF8.GetBytes("stale")), Origin("new-version", newBytes))); + StringAssert.Contains(changedHash.Message, "changed"); + + await initialContent.ReplaceAsync(instance.Id, disabled, ModrinthContentKind.Mod, newSource, "new.jar", expected, + Origin("new-version", newBytes)); + CollectionAssert.AreEqual(newBytes, await File.ReadAllBytesAsync(Path.Combine(modFolder, "new.jar.disabled"))); + Assert.IsFalse(File.Exists(Path.Combine(modFolder, "old.jar.disabled"))); + var updated = InstanceContent.ReadRegistry(root); + Assert.IsTrue(updated.ApiProvisioned); + Assert.IsFalse(updated.Files.ContainsKey("old.jar")); + Assert.AreEqual("new-version", updated.Files["new.jar"].VersionID); + } + + [TestMethod] + public async Task ImportRollbackKeepsBackupAndReportsBothFailuresWhenSameNamedTargetIsLocked() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var mods = Path.Combine(root, "minecraft", "mods"); + Directory.CreateDirectory(mods); + var oldBytes = Encoding.UTF8.GetBytes("old mod"); + var newBytes = Encoding.UTF8.GetBytes("new mod"); + var target = Path.Combine(mods, "same.jar"); + var source = Path.Combine(temporary.Root, "same.jar"); + await File.WriteAllBytesAsync(target, oldBytes); + await File.WriteAllBytesAsync(source, newBytes); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) { ["same.jar"] = Origin("old-version", oldBytes) } + }); + + FileStream? targetLock = null; + var registryFailure = new IOException("Injected registry commit failure."); + var content = new InstanceContent(store, null, () => + { + targetLock = new FileStream(target, FileMode.Open, FileAccess.Read, FileShare.Read); + throw registryFailure; + }); + IOException failure; + try + { + failure = await Assert.ThrowsExactlyAsync(() => content.ImportAsync( + instance.Id, ModrinthContentKind.Mod, source, replace: true)); + } + finally + { + targetLock?.Dispose(); + } + + Assert.IsInstanceOfType(failure.InnerException); + var rollbackErrors = (AggregateException)failure.InnerException!; + Assert.AreSame(registryFailure, rollbackErrors.InnerExceptions[0]); + Assert.AreEqual(3, rollbackErrors.InnerExceptions.Count); + StringAssert.Contains(failure.Message, registryFailure.Message); + var backup = Directory.GetFiles(mods, "same.jar.hako-backup-*").Single(); + StringAssert.Contains(failure.Message, backup); + CollectionAssert.AreEqual(newBytes, await File.ReadAllBytesAsync(target)); + CollectionAssert.AreEqual(oldBytes, await File.ReadAllBytesAsync(backup)); + var registry = InstanceContent.ReadRegistry(root); + Assert.IsTrue(registry.ApiProvisioned); + Assert.AreEqual("old-version", registry.Files["same.jar"].VersionID); + } + + [TestMethod] + public async Task ReplaceRollbackRestoresOldNameWhenLockedDifferentTargetCannotBeRemoved() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var initial = new InstanceContent(store); + var oldBytes = Encoding.UTF8.GetBytes("old mod"); + var newBytes = Encoding.UTF8.GetBytes("new mod"); + var oldSource = Path.Combine(temporary.Root, "old.jar"); + var newSource = Path.Combine(temporary.Root, "new.jar"); + await File.WriteAllBytesAsync(oldSource, oldBytes); + await File.WriteAllBytesAsync(newSource, newBytes); + await initial.ImportAsync(instance.Id, ModrinthContentKind.Mod, oldSource); + var item = (await initial.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + var mods = Path.Combine(root, "minecraft", "mods"); + var target = Path.Combine(mods, "new.jar"); + + FileStream? targetLock = null; + var registryFailure = new IOException("Injected registry commit failure."); + var content = new InstanceContent(store, null, () => + { + targetLock = new FileStream(target, FileMode.Open, FileAccess.Read, FileShare.Read); + throw registryFailure; + }); + IOException failure; + try + { + failure = await Assert.ThrowsExactlyAsync(() => content.ReplaceAsync(instance.Id, item, + ModrinthContentKind.Mod, newSource, "new.jar", Hash(oldBytes), Origin("new-version", newBytes))); + } + finally + { + targetLock?.Dispose(); + } + + Assert.IsInstanceOfType(failure.InnerException); + var rollbackErrors = (AggregateException)failure.InnerException!; + Assert.AreSame(registryFailure, rollbackErrors.InnerExceptions[0]); + Assert.AreEqual(2, rollbackErrors.InnerExceptions.Count); + StringAssert.Contains(failure.Message, registryFailure.Message); + CollectionAssert.AreEqual(oldBytes, await File.ReadAllBytesAsync(Path.Combine(mods, "old.jar"))); + CollectionAssert.AreEqual(newBytes, await File.ReadAllBytesAsync(target)); + Assert.AreEqual(0, Directory.GetFiles(mods, "*.hako-backup-*").Length); + var items = await initial.ListAsync(instance.Id, ModrinthContentKind.Mod); + Assert.AreEqual(2, items.Count); + CollectionAssert.AreEquivalent(new[] { "old.jar", "new.jar" }, items.Select(item => item.Name).ToArray()); + } + + [TestMethod] + public async Task ReplaceRollbackRetainsBackupWhenSameNamedTargetIsLocked() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var initial = new InstanceContent(store); + var oldBytes = Encoding.UTF8.GetBytes("old mod"); + var newBytes = Encoding.UTF8.GetBytes("new mod"); + var oldSource = Path.Combine(temporary.Root, "same.jar"); + var newSource = Path.Combine(temporary.Root, "replacement.jar"); + await File.WriteAllBytesAsync(oldSource, oldBytes); + await File.WriteAllBytesAsync(newSource, newBytes); + await initial.ImportAsync(instance.Id, ModrinthContentKind.Mod, oldSource); + var item = (await initial.ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + var mods = Path.Combine(root, "minecraft", "mods"); + var target = Path.Combine(mods, "same.jar"); + + FileStream? targetLock = null; + var registryFailure = new IOException("Injected registry commit failure."); + var content = new InstanceContent(store, null, () => + { + targetLock = new FileStream(target, FileMode.Open, FileAccess.Read, FileShare.Read); + throw registryFailure; + }); + IOException failure; + try + { + failure = await Assert.ThrowsExactlyAsync(() => content.ReplaceAsync(instance.Id, item, + ModrinthContentKind.Mod, newSource, "same.jar", Hash(oldBytes), Origin("new-version", newBytes))); + } + finally + { + targetLock?.Dispose(); + } + + Assert.IsInstanceOfType(failure.InnerException); + var rollbackErrors = (AggregateException)failure.InnerException!; + Assert.AreSame(registryFailure, rollbackErrors.InnerExceptions[0]); + Assert.AreEqual(3, rollbackErrors.InnerExceptions.Count); + StringAssert.Contains(failure.Message, registryFailure.Message); + var backup = Directory.GetFiles(mods, "same.jar.hako-backup-*").Single(); + StringAssert.Contains(failure.Message, backup); + CollectionAssert.AreEqual(newBytes, await File.ReadAllBytesAsync(target)); + CollectionAssert.AreEqual(oldBytes, await File.ReadAllBytesAsync(backup)); + } + + [TestMethod] + public async Task FailedRecycleLeavesFileAndRegistryWhileSuccessfulRecycleRemovesOnlyOrigin() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var file = Path.Combine(root, "minecraft", "mods", "owned.jar"); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + var bytes = Encoding.UTF8.GetBytes("owned mod"); + await File.WriteAllBytesAsync(file, bytes); + InstanceContent.WriteRegistry(root, new ModRegistry + { + ApiProvisioned = true, + Files = new Dictionary(StringComparer.Ordinal) { ["owned.jar"] = Origin("owned-version", bytes) } + }); + var item = (await new InstanceContent(store).ListAsync(instance.Id, ModrinthContentKind.Mod)).Single(); + + var failedContent = new InstanceContent(store, new RecycleBin(new FakeRecycleOperation(RecycleBinItemStatus.Failed))); + Assert.AreEqual(RecycleBinItemStatus.Failed, await failedContent.TrashAsync(instance.Id, item, ModrinthContentKind.Mod)); + Assert.IsTrue(File.Exists(file)); + Assert.IsTrue(InstanceContent.ReadRegistry(root).Files.ContainsKey("owned.jar")); + + var successfulContent = new InstanceContent(store, new RecycleBin(new FakeRecycleOperation(RecycleBinItemStatus.Recycled, delete: true))); + Assert.AreEqual(RecycleBinItemStatus.Recycled, await successfulContent.TrashAsync(instance.Id, item, ModrinthContentKind.Mod)); + Assert.IsFalse(File.Exists(file)); + var registry = InstanceContent.ReadRegistry(root); + Assert.IsTrue(registry.ApiProvisioned); + Assert.IsFalse(registry.Files.ContainsKey("owned.jar")); + } + + [TestMethod] + public async Task CorruptRegistryIsNeverOverwrittenAndLeaseBlocksMutation() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var registryPath = Path.Combine(root, "minecraft", ".hako-resourcepacks.json"); + Directory.CreateDirectory(Path.GetDirectoryName(registryPath)!); + await File.WriteAllTextAsync(registryPath, "{broken"); + var source = Path.Combine(temporary.Root, "local.zip"); + await File.WriteAllBytesAsync(source, [1, 2, 3]); + var content = new InstanceContent(store); + JsonException? registryError = null; + try { await content.ImportAsync(instance.Id, ModrinthContentKind.ResourcePack, source); } + catch (JsonException exception) { registryError = exception; } + Assert.IsNotNull(registryError); + Assert.AreEqual("{broken", await File.ReadAllTextAsync(registryPath)); + Assert.IsFalse(Directory.Exists(Path.Combine(root, "minecraft", "resourcepacks"))); + + File.Delete(registryPath); + Assert.IsTrue(store.TryBeginContent(instance.Id, out var lease, out _)); + try + { + var blocked = await Assert.ThrowsExactlyAsync(() => content.ImportAsync( + instance.Id, ModrinthContentKind.ResourcePack, source)); + Assert.AreEqual(InstanceStorageError.BuildFilesBusy, blocked.Error); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "resourcepacks", "local.zip"))); + } + finally + { + lease!.Dispose(); + } + } + + [TestMethod] + public async Task PackFolderImportRejectsNestedJunctionInsteadOfFollowingIt() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var source = Path.Combine(temporary.Root, "LinkedPack"); + var outside = Path.Combine(temporary.Root, "Outside"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(source, "pack.mcmeta"), "{\"pack\":{}}"); + await File.WriteAllTextAsync(Path.Combine(outside, "secret.txt"), "outside"); + var junction = Path.Combine(source, "linked"); + CreateJunction(junction, outside); + try + { + await Assert.ThrowsExactlyAsync(() => new InstanceContent(store).ImportAsync( + instance.Id, ModrinthContentKind.ResourcePack, source)); + Assert.IsFalse(Directory.Exists(Path.Combine(root, "minecraft", "resourcepacks", "LinkedPack"))); + Assert.AreEqual("outside", await File.ReadAllTextAsync(Path.Combine(outside, "secret.txt"))); + } + finally + { + Directory.Delete(junction); + } + } + + private static async Task<(InstanceStore Store, GameInstance Instance, string Root)> CreateReadyInstanceAsync( + TemporaryDirectory temporary, string name = "Build", bool legacyTexturepacks = false) + { + var store = temporary.CreateStore(); + if (!store.IsLoaded) await store.LoadAsync(); + var instance = store.Create(new InstanceDraft { Name = name }, "1.20.4", legacyTexturepacks: legacyTexturepacks); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + var registry = JsonSerializer.Deserialize(await File.ReadAllBytesAsync(registryPath), HakoJsonContext.Default.InstanceRegistryFile)!; + var ready = registry with + { + Instances = registry.Instances.Select(candidate => candidate.Id == instance.Id + ? candidate with { State = InstanceInstallationState.Ready } + : candidate).ToList() + }; + await File.WriteAllBytesAsync(registryPath, JsonSerializer.SerializeToUtf8Bytes(ready, HakoJsonContext.Default.InstanceRegistryFile)); + await store.LoadAsync(); + return (store, store.List().Single(candidate => candidate.Id == instance.Id), store.GetInstanceDirectory(instance.Id)); + } + + private static ModOrigin Origin(string versionId, byte[] bytes) => new() + { + Source = ModSource.Modrinth, + ProjectID = "project123", + VersionID = versionId, + PageURL = new Uri("https://modrinth.com/mod/project123"), + Sha512 = Hash(bytes), + VersionNumber = versionId + }; + + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + + private static void CreateJunction(string junction, string target) + { + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, target } + }); + process!.WaitForExit(); + if (process.ExitCode != 0) Assert.Inconclusive("Could not create a junction for the isolated reparse-point test."); + } + + private sealed class FakeRecycleOperation(RecycleBinItemStatus status, bool delete = false) : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (delete && status == RecycleBinItemStatus.Recycled) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) != 0) Directory.Delete(path, recursive: true); + else File.Delete(path); + } + + return Task.FromResult(status); + } + } +} diff --git a/windows/Hako.Tests/InstanceDatapackContentTests.cs b/windows/Hako.Tests/InstanceDatapackContentTests.cs new file mode 100644 index 0000000..9dfeb8a --- /dev/null +++ b/windows/Hako.Tests/InstanceDatapackContentTests.cs @@ -0,0 +1,438 @@ +using System.IO.Compression; +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstanceDatapackContentTests +{ + [TestMethod] + public async Task WorldTargetsIsolateFilesOriginsAndDisabledStateWithoutChangingLevelMetadata() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var worldsRoot = Path.Combine(root, "minecraft", "saves"); + var worldA = CreateWorld(worldsRoot, "WorldA", [1, 2, 3]); + var worldB = CreateWorld(worldsRoot, "WorldB", [4, 5, 6]); + var levelA = await File.ReadAllBytesAsync(Path.Combine(worldA, "level.dat")); + var levelB = await File.ReadAllBytesAsync(Path.Combine(worldB, "level.dat")); + var sourceA = Path.Combine(temporary.Root, "Pack.zip"); + var sourceB = Path.Combine(temporary.Root, "Other.zip"); + var bytesA = CreateDatapackZip("A"); + var bytesB = CreateDatapackZip("B"); + await File.WriteAllBytesAsync(sourceA, bytesA); + await File.WriteAllBytesAsync(sourceB, bytesB); + var content = new InstanceContent(store); + var targetA = InstanceContentTarget.WorldDatapacks("WorldA"); + var targetB = InstanceContentTarget.WorldDatapacks("WorldB"); + + await content.ImportAsync(instance.Id, targetA, sourceA); + await content.ImportAsync(instance.Id, targetB, sourceA); + var itemA = (await content.ListAsync(instance.Id, targetA)).Single(); + await content.WithContentLeaseAsync(instance.Id, async (leasedRoot, leasedInstance, token) => + { + await content.RecordOriginUnderLeaseAsync(leasedRoot, leasedInstance, itemA, targetA, Hash(bytesA), + Origin("version-a", bytesA), token); + }); + + var itemB = (await content.ListAsync(instance.Id, targetB)).Single(); + await content.WithContentLeaseAsync(instance.Id, async (leasedRoot, leasedInstance, token) => + { + await content.RecordOriginUnderLeaseAsync(leasedRoot, leasedInstance, itemB, targetB, Hash(bytesA), + Origin("version-b", bytesA), token); + }); + var legacyOrigin = new Dictionary + { + ["source"] = "modrinth", + ["projectID"] = "datapack01", + ["versionID"] = "version-a", + ["pageURL"] = "https://modrinth.com/datapack/datapack01", + ["sha512"] = Hash(bytesA), + ["versionNumber"] = "version-a" + }; + await File.WriteAllTextAsync(Path.Combine(worldA, ".hako-datapacks.json"), JsonSerializer.Serialize(new + { + files = new Dictionary> { ["pack.zip"] = legacyOrigin } + })); + + var caseVariant = Path.Combine(temporary.Root, "pack.ZIP"); + await File.WriteAllBytesAsync(caseVariant, CreateDatapackZip("collision")); + await Assert.ThrowsExactlyAsync(() => content.ImportAsync(instance.Id, targetA, caseVariant)); + await content.SetEnabledAsync(instance.Id, itemA, targetA, enabled: false); + + var disabledA = (await content.ListAsync(instance.Id, targetA)).Single(); + Assert.IsTrue(disabledA.DisabledPack); + Assert.AreEqual("version-a", disabledA.Origin?.VersionID); + Assert.IsFalse((await content.ListAsync(instance.Id, targetB)).Single().DisabledPack); + Assert.AreEqual("version-b", (await content.ListAsync(instance.Id, targetB)).Single().Origin?.VersionID); + + var replacementFolder = Path.Combine(temporary.Root, "replacement"); + Directory.CreateDirectory(replacementFolder); + var localReplacement = Path.Combine(replacementFolder, "Pack.zip"); + var replacementBytes = CreateDatapackZip("local replacement"); + await File.WriteAllBytesAsync(localReplacement, replacementBytes); + await content.ImportAsync(instance.Id, targetA, localReplacement, replace: true); + var local = (await content.ListAsync(instance.Id, targetA)).Single(); + Assert.IsTrue(local.DisabledPack); + Assert.IsNull(local.Origin); + Assert.AreEqual("version-b", (await content.ListAsync(instance.Id, targetB)).Single().Origin?.VersionID); + + var registryA = Path.Combine(worldA, ".hako-datapacks.json"); + var registryB = Path.Combine(worldB, ".hako-datapacks.json"); + Assert.IsTrue(File.Exists(registryA)); + Assert.IsTrue(File.Exists(registryB)); + Assert.IsTrue((await File.ReadAllTextAsync(registryA)).Contains("apiProvisioned", StringComparison.Ordinal)); + Assert.IsTrue((await File.ReadAllTextAsync(registryB)).Contains("version-b", StringComparison.Ordinal)); + + await content.SetEnabledAsync(instance.Id, local, targetA, enabled: true); + CollectionAssert.AreEqual(replacementBytes, + await File.ReadAllBytesAsync(Path.Combine(worldA, "datapacks", "Pack.zip"))); + CollectionAssert.AreEqual(bytesA, + await File.ReadAllBytesAsync(Path.Combine(worldB, "datapacks", "Pack.zip"))); + CollectionAssert.AreEqual(levelA, await File.ReadAllBytesAsync(Path.Combine(worldA, "level.dat"))); + CollectionAssert.AreEqual(levelB, await File.ReadAllBytesAsync(Path.Combine(worldB, "level.dat"))); + } + + [TestMethod] + public async Task DatapackImportsValidateZipAndFolderMetadataBeforeCreatingWorldContentFolders() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(Path.Combine(root, "minecraft", "saves"), "World", [9]); + var target = InstanceContentTarget.WorldDatapacks("World"); + var content = new InstanceContent(store); + var validZip = Path.Combine(temporary.Root, "valid.zip"); + await File.WriteAllBytesAsync(validZip, CreateDatapackZip("valid")); + await content.ImportAsync(instance.Id, target, validZip); + + var folder = Path.Combine(temporary.Root, "FolderPack"); + Directory.CreateDirectory(folder); + await File.WriteAllTextAsync(Path.Combine(folder, "pack.mcmeta"), "{\"pack\":{\"pack_format\":15}}"); + await File.WriteAllTextAsync(Path.Combine(folder, "data.txt"), "folder payload"); + await content.ImportAsync(instance.Id, target, folder); + + var invalidPackages = new[] + { + CreateZip("nested.zip", ("root/pack.mcmeta", "{\"pack\":{}}")), + CreateZip("malformed.zip", ("pack.mcmeta", "{")), + CreateZip("not-pack.zip", ("pack.mcmeta", "{\"pack\":[]}")), + CreateZip("linked-metadata.zip", ("pack.mcmeta", "{\"pack\":{}}"), symlink: true), + CreateZip("oversized.zip", ("pack.mcmeta", "{\"pack\":{}}" + new string(' ', 1024 * 1024))) + }; + + foreach (var (filename, bytes) in invalidPackages) + { + var source = Path.Combine(temporary.Root, filename); + await File.WriteAllBytesAsync(source, bytes); + await Assert.ThrowsExactlyAsync( + () => content.ImportAsync(instance.Id, target, source)); + Assert.IsFalse(File.Exists(Path.Combine(world, "datapacks", filename))); + } + + var noMetadataFolder = Path.Combine(temporary.Root, "NoMetadata"); + Directory.CreateDirectory(Path.Combine(noMetadataFolder, "nested")); + await File.WriteAllTextAsync(Path.Combine(noMetadataFolder, "nested", "pack.mcmeta"), "{\"pack\":{}}"); + await Assert.ThrowsExactlyAsync( + () => content.ImportAsync(instance.Id, target, noMetadataFolder)); + + Assert.AreEqual(2, (await content.ListAsync(instance.Id, target)).Count); + Assert.IsFalse(File.Exists(Path.Combine(world, ".hako-disabled-datapacks"))); + } + + [TestMethod] + public async Task MissingOrCorruptWorldCannotBeRecreatedByDatapackImport() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var source = Path.Combine(temporary.Root, "valid.zip"); + await File.WriteAllBytesAsync(source, CreateDatapackZip("missing")); + var target = InstanceContentTarget.WorldDatapacks("Missing"); + var failure = await Assert.ThrowsExactlyAsync( + () => new InstanceContent(store).ImportAsync(instance.Id, target, source)); + Assert.AreEqual(InstanceWorldFailureReason.WorldMissing, failure.Error); + Assert.IsFalse(Directory.Exists(Path.Combine(root, "minecraft", "saves", "Missing"))); + Assert.IsFalse(Directory.Exists(Path.Combine(root, "minecraft", "saves"))); + + var world = CreateWorld(Path.Combine(root, "minecraft", "saves"), "LostMetadata", [1]); + File.Delete(Path.Combine(world, "level.dat")); + await Assert.ThrowsExactlyAsync( + () => new InstanceContent(store).ImportAsync(instance.Id, + InstanceContentTarget.WorldDatapacks("LostMetadata"), source)); + Assert.IsFalse(Directory.Exists(Path.Combine(world, "datapacks"))); + } + + [TestMethod] + public async Task DatapackReplacementChecksExpectedHashAndRollsBackRegistryFailure() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(Path.Combine(root, "minecraft", "saves"), "World", [7, 8, 9]); + var target = InstanceContentTarget.WorldDatapacks("World"); + var oldBytes = CreateDatapackZip("old"); + var newBytes = CreateDatapackZip("new"); + var oldSource = Path.Combine(temporary.Root, "Pack.zip"); + var newSource = Path.Combine(temporary.Root, "new.zip"); + await File.WriteAllBytesAsync(oldSource, oldBytes); + await File.WriteAllBytesAsync(newSource, newBytes); + var content = new InstanceContent(store); + await content.ImportAsync(instance.Id, target, oldSource); + var item = (await content.ListAsync(instance.Id, target)).Single(); + var origin = Origin("old", oldBytes); + await content.WithContentLeaseAsync(instance.Id, async (leasedRoot, leasedInstance, token) => + { + await content.RecordOriginUnderLeaseAsync(leasedRoot, leasedInstance, item, target, Hash(oldBytes), origin, token); + }); + var levelBefore = await File.ReadAllBytesAsync(Path.Combine(world, "level.dat")); + var failure = new InstanceContent(store, null, () => throw new IOException("Injected registry failure.")); + + await Assert.ThrowsExactlyAsync(() => failure.ReplaceAsync(instance.Id, item, target, newSource, + "Pack.zip", Hash(oldBytes), Origin("new", newBytes))); + var stale = await Assert.ThrowsExactlyAsync(() => content.ReplaceAsync(instance.Id, item, target, + newSource, "Pack.zip", Hash(Encoding.UTF8.GetBytes("stale")), Origin("new", newBytes))); + + StringAssert.Contains(stale.Message, "changed"); + CollectionAssert.AreEqual(oldBytes, await File.ReadAllBytesAsync(Path.Combine(world, "datapacks", "Pack.zip"))); + Assert.AreEqual("old", (await content.ListAsync(instance.Id, target)).Single().Origin?.VersionID); + CollectionAssert.AreEqual(levelBefore, await File.ReadAllBytesAsync(Path.Combine(world, "level.dat"))); + } + + [TestMethod] + public async Task DatapackFolderImportRejectsNestedJunctionWithoutFollowingIt() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var source = Path.Combine(temporary.Root, "FolderPack"); + var outside = Path.Combine(temporary.Root, "Outside"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(source, "pack.mcmeta"), "{\"pack\":{}}"); + await File.WriteAllTextAsync(Path.Combine(outside, "secret.txt"), "outside"); + var junction = Path.Combine(source, "linked"); + CreateJunction(junction, outside); + var world = CreateWorld(Path.Combine(root, "minecraft", "saves"), "World", [2]); + try + { + await Assert.ThrowsExactlyAsync(() => new InstanceContent(store).ImportAsync(instance.Id, + InstanceContentTarget.WorldDatapacks("World"), source)); + Assert.IsFalse(Directory.Exists(Path.Combine(world, "datapacks", "FolderPack"))); + Assert.AreEqual("outside", await File.ReadAllTextAsync(Path.Combine(outside, "secret.txt"))); + } + finally + { + Directory.Delete(junction); + } + } + + [TestMethod] + public async Task DatapackTargetRejectsJunctionWorldWithoutWritingThroughIt() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var saves = Path.Combine(root, "minecraft", "saves"); + var actualWorld = CreateWorld(saves, "Actual", [1, 2]); + var linkedWorld = Path.Combine(saves, "Linked"); + CreateJunction(linkedWorld, actualWorld); + var source = Path.Combine(temporary.Root, "pack.zip"); + await File.WriteAllBytesAsync(source, CreateDatapackZip("linked world")); + try + { + var failure = await Assert.ThrowsExactlyAsync(() => new InstanceContent(store).ImportAsync( + instance.Id, InstanceContentTarget.WorldDatapacks("Linked"), source)); + Assert.AreEqual(InstanceWorldFailureReason.ReparsePointNotAllowed, failure.Error); + Assert.IsFalse(Directory.Exists(Path.Combine(actualWorld, "datapacks"))); + } + finally + { + Directory.Delete(linkedWorld); + } + } + + [TestMethod] + public async Task DatapackArchiveEntryLimitAndCancellationAreEnforced() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var validPath = Path.Combine(temporary.Root, "valid.zip"); + await File.WriteAllBytesAsync(validPath, CreateDatapackZip("valid")); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsExactlyAsync(() => Task.Run( + () => InstanceContent.ValidateDatapackPackage(validPath, cancellation.Token))); + + var tooManyEntriesPath = Path.Combine(temporary.Root, "too-many.zip"); + await File.WriteAllBytesAsync(tooManyEntriesPath, CreateZipWithEntries(100_001)); + await Assert.ThrowsExactlyAsync(() => Task.Run( + () => InstanceContent.ValidateDatapackPackage(tooManyEntriesPath, CancellationToken.None))); + } + + [TestMethod] + public async Task LegacyKindApiRejectsDatapacksWithoutAWorldTarget() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, _) = await CreateReadyInstanceAsync(temporary); + var content = new InstanceContent(store); + await Assert.ThrowsExactlyAsync( + () => content.ListAsync(instance.Id, ModrinthContentKind.Datapack)); + } + + [TestMethod] + [DataRow("World", false)] + [DataRow("saves", false)] + [DataRow("root", false)] + [DataRow("World", true)] + [DataRow("saves", true)] + [DataRow("root", true)] + [DataRow("none", true)] + public async Task FolderImportNeverRecreatesParentsDeletedDuringNestedCopy(string deletedParent, bool cancel) + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var saves = Path.Combine(root, "minecraft", "saves"); + var world = CreateWorld(saves, "World", [1, 2, 3]); + var source = Path.Combine(temporary.Root, "FolderPack"); + var nested = Path.Combine(source, "nested"); + Directory.CreateDirectory(Path.Combine(nested, "child")); + await File.WriteAllTextAsync(Path.Combine(source, "pack.mcmeta"), "{\"pack\":{}}"); + var payload = Path.Combine(nested, "child", "payload.txt"); + await File.WriteAllTextAsync(payload, "source intact"); + var removed = deletedParent == "root" ? root : deletedParent == "saves" ? saves : world; + using var cancellation = new CancellationTokenSource(); + var hookCalled = false; + var content = new InstanceContent(store, null, null, beforeCopyDirectory: path => + { + if (path != nested) return; + hookCalled = true; + Assert.IsTrue(Directory.EnumerateDirectories(Path.Combine(world, "datapacks"), ".hako-import-*").Any()); + Assert.IsFalse(File.Exists(Path.Combine(world, ".hako-datapacks.json"))); + // All paths are fixture-owned and contained in this test's temporary directory. + Assert.IsTrue(Path.GetFullPath(removed).StartsWith(Path.GetFullPath(temporary.Root) + Path.DirectorySeparatorChar)); + if (deletedParent != "none") Directory.Delete(removed, recursive: true); + if (cancel) cancellation.Cancel(); + }); + + Exception? failure = null; + try + { + await content.ImportAsync(instance.Id, InstanceContentTarget.WorldDatapacks("World"), source, + cancellationToken: cancellation.Token); + } + catch (Exception exception) { failure = exception; } + + Assert.IsTrue(hookCalled); + Assert.IsNotNull(failure); + if (cancel) Assert.IsInstanceOfType(failure); + if (deletedParent == "none") + { + Assert.IsTrue(Directory.Exists(world)); + Assert.IsTrue(File.Exists(Path.Combine(world, "level.dat"))); + Assert.IsFalse(Directory.EnumerateFileSystemEntries(Path.Combine(world, "datapacks")).Any()); + } + else + { + Assert.IsFalse(Directory.Exists(removed), "The deleted parent must remain absent."); + Assert.IsFalse(Directory.Exists(world)); + Assert.IsFalse(File.Exists(Path.Combine(world, "level.dat"))); + } + Assert.IsFalse(File.Exists(Path.Combine(world, ".hako-datapacks.json"))); + Assert.IsFalse(Directory.EnumerateFileSystemEntries(temporary.Root, ".hako-import-*", SearchOption.AllDirectories).Any()); + Assert.AreEqual("source intact", await File.ReadAllTextAsync(payload)); + Assert.AreEqual("{\"pack\":{}}", await File.ReadAllTextAsync(Path.Combine(source, "pack.mcmeta"))); + } + + private static async Task<(InstanceStore Store, GameInstance Instance, string Root)> CreateReadyInstanceAsync( + TemporaryDirectory temporary) + { + var store = temporary.CreateStore(); + await store.LoadAsync(); + var instance = store.Create(new InstanceDraft { Name = "Datapack Tests" }, "1.20.4"); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Ready, pauseRequested: false); + instance = store.List().Single(candidate => candidate.Id == instance.Id); + return (store, instance, store.GetInstanceDirectory(instance.Id)); + } + + private static string CreateWorld(string savesRoot, string worldId, byte[] payload) + { + var world = Path.Combine(savesRoot, worldId); + Directory.CreateDirectory(world); + var metadata = WorldTestFixture.Create(worldId, extra: WorldTestFixture.ByteArrayTag("fixture", payload)); + File.WriteAllBytes(Path.Combine(world, "level.dat"), metadata.Compressed); + return world; + } + + private static byte[] CreateDatapackZip(string payload) + { + using var output = new MemoryStream(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + using (var writer = new StreamWriter(archive.CreateEntry("pack.mcmeta").Open(), new UTF8Encoding(false))) + writer.Write("{\"pack\":{\"pack_format\":15,\"description\":\"test\"}}"); + using (var writer = new StreamWriter(archive.CreateEntry("data/fixture.txt").Open(), new UTF8Encoding(false))) + writer.Write(payload); + } + + return output.ToArray(); + } + + private static (string Filename, byte[] Bytes) CreateZip(string filename, (string Entry, string Content) metadata, + bool symlink = false) + { + using var output = new MemoryStream(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(metadata.Entry); + if (symlink) entry.ExternalAttributes = unchecked((int)(0xA000u << 16)); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(metadata.Content); + } + + return (filename, output.ToArray()); + } + + private static byte[] CreateZipWithEntries(int payloadCount) + { + using var output = new MemoryStream(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + using (var writer = new StreamWriter(archive.CreateEntry("pack.mcmeta").Open(), new UTF8Encoding(false))) + writer.Write("{\"pack\":{}}"); + for (var index = 0; index < payloadCount; index++) + archive.CreateEntry($"entry-{index}.txt"); + } + + return output.ToArray(); + } + + private static ModOrigin Origin(string versionId, byte[] bytes) => new() + { + Source = ModSource.Modrinth, + ProjectID = "datapack01", + VersionID = versionId, + PageURL = new Uri($"https://modrinth.com/datapack/datapack01/version/{versionId}"), + Sha512 = Hash(bytes), + VersionNumber = versionId + }; + + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + + private static void CreateJunction(string junction, string target) + { + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, target } + }); + process!.WaitForExit(); + if (process.ExitCode != 0) Assert.Inconclusive("Could not create a junction for the isolated datapack test."); + } +} diff --git a/windows/Hako.Tests/InstanceIconGlyphsTests.cs b/windows/Hako.Tests/InstanceIconGlyphsTests.cs new file mode 100644 index 0000000..aa33edb --- /dev/null +++ b/windows/Hako.Tests/InstanceIconGlyphsTests.cs @@ -0,0 +1,42 @@ +using System.Xml.Linq; + +namespace Hako.Tests; + +[TestClass] +public sealed class InstanceIconGlyphsTests +{ + [TestMethod] + public void BuiltInIconsKeepTheMacSymbolNamesStoredInProfiles() + { + // instances.json shares iconSymbol values with macOS (InstanceIconPicker in InstanceComponents.swift). + CollectionAssert.AreEqual( + new[] { "shippingbox.fill", "cube.fill", "leaf.fill", "flame.fill", "bolt.fill", "moon.stars.fill" }, + Hako.InstanceIconGlyphs.BuiltIn.Select(icon => icon.Symbol).ToArray()); + Assert.AreEqual(Hako.InstanceIconGlyphs.BuiltIn.Count, Hako.InstanceIconGlyphs.BuiltIn.Select(icon => icon.Glyph).Distinct().Count()); + Assert.AreEqual("shippingbox.fill", Hako.InstanceIconGlyphs.DefaultSymbol); + } + + [TestMethod] + [DataRow("")] + [DataRow(null)] + [DataRow("star.fill")] + public void CustomPicturesAndUnknownSymbolsFallBackToTheBox(string? symbol) + { + Assert.AreEqual(Hako.InstanceIconGlyphs.Glyph("shippingbox.fill"), Hako.InstanceIconGlyphs.Glyph(symbol)); + Assert.AreNotEqual(Hako.InstanceIconGlyphs.Glyph("shippingbox.fill"), Hako.InstanceIconGlyphs.Glyph("flame.fill")); + } + + [TestMethod] + public void IconTitlesHaveRussianAndEnglishText() + { + foreach (var language in new[] { "ru-RU", "en-US" }) + { + var names = XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Resources", language, "Resources.resw")) + .Root!.Elements("data").Select(element => (string?)element.Attribute("name")).ToHashSet(); + foreach (var icon in Hako.InstanceIconGlyphs.BuiltIn) + { + Assert.IsTrue(names.Contains(icon.TitleKey), $"{language}: {icon.TitleKey}"); + } + } + } +} diff --git a/windows/Hako.Tests/InstanceIconImageTests.cs b/windows/Hako.Tests/InstanceIconImageTests.cs new file mode 100644 index 0000000..fe1714a --- /dev/null +++ b/windows/Hako.Tests/InstanceIconImageTests.cs @@ -0,0 +1,72 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace Hako.Tests; + +[TestClass] +public sealed class InstanceIconImageTests +{ + [TestMethod] + public async Task PngAndJpegBecomeSquare256PixelPng() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + foreach (var (encoder, name) in new[] { (BitmapEncoder.PngEncoderId, "wide.png"), (BitmapEncoder.JpegEncoderId, "tall.jpg") }) + { + var path = Path.Combine(temporary.Root, name); + var (width, height) = name == "wide.png" ? (600u, 300u) : (200u, 500u); + await File.WriteAllBytesAsync(path, await EncodeAsync(encoder, width, height)); + + var icon = await Hako.InstanceIconImage.NormalizeAsync(path); + + var decoder = await DecodeAsync(icon); + Assert.AreEqual(BitmapDecoder.PngDecoderId, decoder.DecoderInformation.CodecId, name); + Assert.AreEqual(256u, decoder.PixelWidth, name); + Assert.AreEqual(256u, decoder.PixelHeight, name); + } + } + + [TestMethod] + public async Task OtherFormatsAndDamagedFilesAreRejected() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var bmp = Path.Combine(temporary.Root, "icon.bmp"); + await File.WriteAllBytesAsync(bmp, await EncodeAsync(BitmapEncoder.BmpEncoderId, 64, 64)); + var garbage = Path.Combine(temporary.Root, "icon.png"); + await File.WriteAllBytesAsync(garbage, [1, 2, 3, 4, 5]); + + await Assert.ThrowsExactlyAsync(() => Hako.InstanceIconImage.NormalizeAsync(bmp)); + await Assert.ThrowsAsync(() => Hako.InstanceIconImage.NormalizeAsync(garbage)); + } + + private static async Task EncodeAsync(Guid encoderId, uint width, uint height) + { + var pixels = new byte[width * height * 4]; + for (var index = 0; index < pixels.Length; index += 4) + { + pixels[index] = 40; + pixels[index + 1] = 96; + pixels[index + 2] = 224; + pixels[index + 3] = 255; + } + + using var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(encoderId, stream); + encoder.SetPixelData(BitmapPixelFormat.Bgra8, BitmapAlphaMode.Ignore, width, height, 96, 96, pixels); + await encoder.FlushAsync(); + var result = new byte[stream.Size]; + stream.Seek(0); + await stream.ReadAsync(result.AsBuffer(), (uint)result.Length, InputStreamOptions.None); + return result; + } + + private static async Task DecodeAsync(byte[] data) + { + var stream = new InMemoryRandomAccessStream(); + await stream.WriteAsync(data.AsBuffer()); + stream.Seek(0); + return await BitmapDecoder.CreateAsync(stream); + } +} diff --git a/windows/Hako.Tests/InstanceStoreTests.cs b/windows/Hako.Tests/InstanceStoreTests.cs new file mode 100644 index 0000000..c755177 --- /dev/null +++ b/windows/Hako.Tests/InstanceStoreTests.cs @@ -0,0 +1,1088 @@ +using Hako.Core; +using Hako.Core.Instances; +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstanceStoreTests +{ + [TestMethod] + public async Task NamesAreTrimmedAndRestrictedToOneToSixtyAsciiLettersDigitsAndSpaces() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + + Assert.AreEqual("My_Build_2", store.ValidateName(" My Build 2 ")); + foreach (var value in new[] { "", " ", new string('a', 61), "Build!", "Сборка" }) + { + Assert.ThrowsExactly(() => store.ValidateName(value)); + } + } + + [TestMethod] + public async Task ReservedAndCaseInsensitiveNameCollisionsAreRejected() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + foreach (var value in new[] { "backups", "WORLDS", "con", "PRN", "Aux", "nul", "COM1", "com9", "LPT1", "lpt9" }) + { + Assert.ThrowsExactly(() => store.ValidateName(value)); + } + + var created = store.Create(new InstanceDraft { Name = "My Build" }, "1.20.4"); + Assert.ThrowsExactly(() => store.ValidateName("my build")); + Assert.AreEqual("My_Build", store.ValidateName("My Build", excluding: created.Id)); + } + + [TestMethod] + public async Task CaseInsensitiveFilesystemCollisionIsRejected() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + Directory.CreateDirectory(Path.Combine(temporary.Paths.GameRoot, "Loose_Files")); + var store = await LoadStoreAsync(temporary); + + Assert.ThrowsExactly(() => store.ValidateName("loose files")); + } + + [TestMethod] + public async Task RegistryIsCamelCaseAndContainsFullProfileState() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft + { + Name = "Fabric Build", + ModLoader = InstanceModLoader.Fabric, + FabricConfiguration = new FabricConfiguration("0.16.9"), + ArgumentSource = LaunchArgumentSource.Custom, + OfflineMode = true, + OfflineUsername = "Player_2", + Parameters = new InstanceParameters { JavaArguments = "-Dtest=yes", MaximumMemoryMiB = 2048 } + }, "1.21.4", "https://example.test/version", "sha1"); + + var json = File.ReadAllText(Path.Combine(temporary.Paths.GameRoot, "instances.json"), Encoding.UTF8); + StringAssert.Contains(json, "\"formatVersion\": 1"); + StringAssert.Contains(json, "\"folderName\": \"Fabric_Build\""); + StringAssert.Contains(json, "\"argumentSource\": \"custom\""); + StringAssert.Contains(json, "\"modLoader\": \"fabric\""); + StringAssert.Contains(json, "\"state\": \"queued\""); + StringAssert.Contains(json, "\"pauseRequested\": false"); + StringAssert.Contains(json, "\"fabricConfiguration\":"); + Assert.AreEqual(instance, store.List().Single()); + } + + [TestMethod] + public async Task FailedAtomicWriteLeavesPreviousRegistryAndRemovesTemporaryFile() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var instance = initial.Create(new InstanceDraft { Name = "Original" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + var original = File.ReadAllBytes(registryPath); + var failing = await LoadStoreAsync(temporary, () => throw new IOException("Injected before atomic replace.")); + + Assert.ThrowsExactly(() => failing.Rename(instance.Id, "Changed")); + + CollectionAssert.AreEqual(original, File.ReadAllBytes(registryPath)); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Original"))); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Changed"))); + Assert.AreEqual(0, Directory.GetFiles(temporary.Paths.GameRoot, "instances.json.*.tmp").Length); + } + + [TestMethod] + public async Task CorruptAndUnknownVersionRegistriesAreNeverOverwritten() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Protected Build" }, "1.20.4"); + var update = new InstanceDraft { Name = "Renamed Build" }; + foreach (var content in new[] { "{ broken", "{\"formatVersion\":2,\"instances\":[]}" }) + { + File.WriteAllText(registryPath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + var original = File.ReadAllBytes(registryPath); + await store.LoadAsync(); + Assert.IsNotNull(store.LoadError); + var expectedError = content.Contains("formatVersion", StringComparison.Ordinal) + ? InstanceRegistryError.UnsupportedVersion + : InstanceRegistryError.Corrupt; + Assert.AreEqual(expectedError, store.LoadError!.Error, store.LoadError.Message); + Assert.AreEqual("InstanceRegistryDamaged", store.LoadError!.ResourceKey); + foreach (var operation in new Action[] + { + () => store.Create(new InstanceDraft { Name = "Blocked" }, "1.20.4"), + () => store.Rename(instance.Id, "Blocked"), + () => store.Update(instance.Id, update), + () => store.UpdateSettings(instance.Id, update), + () => store.Delete(instance.Id) + }) + { + var exception = Assert.ThrowsExactly(operation); + Assert.AreEqual(expectedError, exception.Error); + } + + CollectionAssert.AreEqual(original, File.ReadAllBytes(registryPath)); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Blocked"))); + } + } + + [TestMethod] + public async Task InvalidRegistryEntriesAreReportedAsCorrupt() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + var valid = new GameInstance { Name = "First", FolderName = "First" }; + var invalidRegistries = new[] + { + new InstanceRegistryFile(1, [valid, valid with { Name = "Second", FolderName = "Second" }]), + new InstanceRegistryFile(1, [valid, valid with { Id = Guid.NewGuid(), Name = "Second", FolderName = "first" }]), + new InstanceRegistryFile(1, [valid with { Name = "Bad!", FolderName = "Bad!" }]), + new InstanceRegistryFile(1, [valid with + { + Name = "Fabric", + FolderName = "Fabric", + ModLoader = InstanceModLoader.Fabric, + FabricConfiguration = new FabricConfiguration("") + }]) + }; + + foreach (var invalid in invalidRegistries) + { + var original = JsonSerializer.SerializeToUtf8Bytes(invalid, HakoJsonContext.Default.InstanceRegistryFile); + await File.WriteAllBytesAsync(registryPath, original); + var store = temporary.CreateStore(); + + await store.LoadAsync(); + + Assert.AreEqual(InstanceRegistryError.Corrupt, store.LoadError?.Error); + Assert.ThrowsExactly(() => store.Create(new InstanceDraft { Name = "Blocked" }, "1.20.4")); + CollectionAssert.AreEqual(original, await File.ReadAllBytesAsync(registryPath)); + } + } + + [TestMethod] + public async Task RegistrySymlinkIsReportedAsLoadErrorInsteadOfThrowingFromConstruction() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + var target = Path.Combine(temporary.Path, "external-instances.json"); + await File.WriteAllTextAsync(target, "{\"formatVersion\":1,\"instances\":[]}"); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + try + { + File.CreateSymbolicLink(registryPath, target); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException or PlatformNotSupportedException) + { + Assert.Inconclusive("This environment does not allow creating a file symlink: " + exception.Message); + } + + var store = temporary.CreateStore(); + await store.LoadAsync(); + + Assert.IsNotNull(store.LoadError); + Assert.AreEqual(InstanceRegistryError.ReadFailure, store.LoadError.Error); + Assert.AreEqual("InstanceRegistryUnavailable", store.LoadError.ResourceKey); + Assert.AreEqual("{\"formatVersion\":1,\"instances\":[]}", await File.ReadAllTextAsync(target)); + } + + [TestMethod] + public async Task InstanceListsUseTheLoadedSnapshotAndEventFailuresDoNotFailCommittedOperations() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + store.InstancesChanged += (_, _) => throw new InvalidOperationException("Injected observer failure."); + store.PropertyChanged += (_, _) => throw new InvalidOperationException("Injected observer failure."); + + var created = store.Create(new InstanceDraft { Name = "Snapshot" }, "1.20.4"); + Assert.AreEqual(created, store.List().Single()); + + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + await File.WriteAllTextAsync(registryPath, "{ broken"); + Assert.AreEqual(created, store.Instances.Single()); + + await store.LoadAsync(); + + Assert.AreEqual(0, store.List().Count); + Assert.AreEqual(InstanceRegistryError.Corrupt, store.LoadError?.Error); + } + + [TestMethod] + public async Task TryBeginOperationsReturnRegistryReasonsBeforeLoadAndAfterLoadFailure() + { + await using var temporary = new TemporaryDirectory(); + var store = temporary.CreateStore(); + var id = Guid.NewGuid(); + + Assert.IsFalse(store.TryBeginLaunch(id, out var launchBeforeLoad, out var launchBeforeLoadReason)); + Assert.IsNull(launchBeforeLoad); + Assert.AreEqual(InstanceStorageError.InstanceRegistryNotLoaded, launchBeforeLoadReason); + Assert.IsFalse(store.TryBeginContent(id, out var contentBeforeLoad, out var contentBeforeLoadReason)); + Assert.IsNull(contentBeforeLoad); + Assert.AreEqual(InstanceStorageError.InstanceRegistryNotLoaded, contentBeforeLoadReason); + + Directory.CreateDirectory(temporary.Paths.GameRoot); + await File.WriteAllTextAsync(Path.Combine(temporary.Paths.GameRoot, "instances.json"), "{ broken"); + await store.LoadAsync(); + + Assert.IsFalse(store.TryBeginLaunch(id, out var launchAfterFailure, out var launchAfterFailureReason)); + Assert.IsNull(launchAfterFailure); + Assert.AreEqual(InstanceStorageError.InstanceRegistryUnavailable, launchAfterFailureReason); + Assert.IsFalse(store.TryBeginContent(id, out var contentAfterFailure, out var contentAfterFailureReason)); + Assert.IsNull(contentAfterFailure); + Assert.AreEqual(InstanceStorageError.InstanceRegistryUnavailable, contentAfterFailureReason); + } + + [TestMethod] + public async Task CreateFailureRemovesOnlyItsNewFolder() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary, () => throw new IOException("Injected registry failure.")); + + Assert.ThrowsExactly(() => store.Create(new InstanceDraft { Name = "New Build" }, "1.20.4")); + + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "New_Build"))); + Assert.IsFalse(File.Exists(Path.Combine(temporary.Paths.GameRoot, "instances.json"))); + Assert.AreEqual(0, Directory.GetDirectories(temporary.Paths.GameRoot).Length); + } + + [TestMethod] + public async Task RenameFailureRollsBackAndCaseOnlyRenameUsesTemporaryFolder() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var instance = initial.Create(new InstanceDraft { Name = "First Build" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var failing = await LoadStoreAsync(temporary, () => throw new IOException("Injected registry failure.")); + + Assert.ThrowsExactly(() => failing.Rename(instance.Id, "Second Build")); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "First_Build"))); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Second_Build"))); + + initial.Rename(instance.Id, "FIRST BUILD"); + var renamedDirectory = Directory.GetDirectories(temporary.Paths.GameRoot).Single(path => Path.GetFileName(path).StartsWith("FIRST", StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual("FIRST_BUILD", Path.GetFileName(renamedDirectory)); + Assert.AreEqual("FIRST BUILD", initial.List().Single().Name); + } + + [TestMethod] + public async Task RenameRollbackFailureKeepsTheOriginalOperationFailureAsPrimary() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var instance = initial.Create(new InstanceDraft { Name = "Original" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var primaryFailure = new IOException("Primary registry failure."); + var failing = await LoadStoreAsync(temporary, () => + { + File.WriteAllText(Path.Combine(temporary.Paths.GameRoot, instance.FolderName), "blocks rollback"); + throw primaryFailure; + }); + + var exception = Assert.ThrowsExactly(() => failing.Rename(instance.Id, "Changed")); + + Assert.AreSame(primaryFailure, exception.InnerExceptions[0]); + } + + [TestMethod] + public async Task DeleteFailureRestoresFolderAndMissingFolderDoesNotBlockRemoval() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var instance = initial.Create(new InstanceDraft { Name = "Delete Me" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var failing = await LoadStoreAsync(temporary, () => throw new IOException("Injected registry failure.")); + + Assert.ThrowsExactly(() => failing.Delete(instance.Id)); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Delete_Me"))); + Assert.AreEqual(instance.Id, initial.List().Single().Id); + + Directory.Delete(Path.Combine(temporary.Paths.GameRoot, "Delete_Me"), recursive: true); + initial.Delete(instance.Id); + Assert.AreEqual(0, initial.List().Count); + await initial.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsFalse(Directory.EnumerateFileSystemEntries(temporary.Paths.GameRoot) + .Any(path => Path.GetFileName(path).StartsWith(".delete-", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public async Task DeleteAfterDisposeLeavesStagingForTheNextLoadToClean() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + var instance = store.Create(new InstanceDraft { Name = "Delete After Dispose" }, "1.20.4"); + await SetStateAsync(temporary.Paths, store, InstanceInstallationState.Ready); + await store.DisposeAsync(); + + store.Delete(instance.Id); + + Assert.AreEqual(0, store.List().Count); + var staging = Directory.EnumerateDirectories(temporary.Paths.GameRoot) + .Single(path => Path.GetFileName(path).StartsWith(".delete-", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(Directory.Exists(staging)); + + var nextStore = await LoadStoreAsync(temporary); + await nextStore.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsFalse(Directory.Exists(staging)); + } + + [TestMethod] + public async Task DeleteCleanupRemovesNestedJunctionWithoutTouchingItsTarget() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Junction Build" }, "1.20.4"); + var buildDirectory = Path.Combine(temporary.Paths.GameRoot, instance.FolderName); + var outsideTarget = Path.Combine(temporary.Path, "outside-target"); + Directory.CreateDirectory(outsideTarget); + var targetFile = Path.Combine(outsideTarget, "keep.txt"); + await File.WriteAllTextAsync(targetFile, "keep"); + CreateJunction(Path.Combine(buildDirectory, "outside"), outsideTarget); + await SetStateAsync(temporary.Paths, store, InstanceInstallationState.Ready); + + var removedName = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var watcher = new FileSystemWatcher(temporary.Paths.GameRoot, ".delete-*") + { + NotifyFilter = NotifyFilters.DirectoryName, + EnableRaisingEvents = true + }; + watcher.Deleted += (_, args) => + { + removedName.TrySetResult(args.Name!); + }; + + store.Delete(instance.Id); + await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + var stagingName = await removedName.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsTrue(stagingName.StartsWith($".delete-{instance.Id:N}-", StringComparison.Ordinal)); + Assert.AreEqual(73, stagingName.Length); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, stagingName))); + Assert.AreEqual("keep", await File.ReadAllTextAsync(targetFile)); + } + + [TestMethod] + public async Task LoadPublishesSnapshotBeforeBackgroundStagingCleanupCompletes() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + await initial.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + var instance = initial.Create(new InstanceDraft { Name = "Background Cleanup" }, "1.20.4"); + var staging = Path.Combine(temporary.Paths.GameRoot, ".delete-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(staging); + var cleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var continueCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task? disposeTask = null; + var store = temporary.CreateStore(null, () => + { + cleanupStarted.TrySetResult(); + continueCleanup.Task.GetAwaiter().GetResult(); + }); + + try + { + await store.LoadAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsTrue(store.IsLoaded); + Assert.AreEqual(instance.Id, store.List().Single().Id); + await cleanupStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsTrue(Directory.Exists(staging)); + disposeTask = store.DisposeAsync().AsTask(); + Assert.IsFalse(disposeTask.IsCompleted); + } + finally + { + continueCleanup.TrySetResult(); + } + + await disposeTask!.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsFalse(Directory.Exists(staging)); + } + + [TestMethod] + public async Task LoadCleansOnlyExactLegacyAndNewDeleteStagingNames() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + var foreign = Path.Combine(temporary.Paths.GameRoot, ".delete-whatever"); + var legacyN = Path.Combine(temporary.Paths.GameRoot, ".delete-" + Guid.NewGuid().ToString("N")); + var legacyD = Path.Combine(temporary.Paths.GameRoot, ".delete-" + Guid.NewGuid().ToString("D")); + var newFormat = Path.Combine(temporary.Paths.GameRoot, + $".delete-{Guid.NewGuid():N}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(foreign); + Directory.CreateDirectory(legacyN); + Directory.CreateDirectory(legacyD); + Directory.CreateDirectory(newFormat); + + var store = temporary.CreateStore(); + await store.LoadAsync(); + await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.IsTrue(Directory.Exists(foreign)); + Assert.IsFalse(Directory.Exists(legacyN)); + Assert.IsFalse(Directory.Exists(legacyD)); + Assert.IsFalse(Directory.Exists(newFormat)); + } + + [TestMethod] + public async Task LoadReconcilesInterruptedDeleteAndKeepsBuildWhenItsFolderWasRestored() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + await initial.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + var moved = initial.Create(new InstanceDraft { Name = "Interrupted Delete" }, "1.20.4"); + var restored = initial.Create(new InstanceDraft { Name = "Restored Build" }, "1.20.4"); + var movedStaging = Path.Combine(temporary.Paths.GameRoot, $".delete-{moved.Id:N}-{Guid.NewGuid():N}"); + Directory.Move(Path.Combine(temporary.Paths.GameRoot, moved.FolderName), movedStaging); + var restoredStaging = Path.Combine(temporary.Paths.GameRoot, $".delete-{restored.Id:N}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(restoredStaging); + await File.WriteAllTextAsync(Path.Combine(restoredStaging, "leftover.txt"), "leftover"); + + var store = temporary.CreateStore(); + await store.LoadAsync(); + await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.AreEqual(restored.Id, store.List().Single().Id); + Assert.IsFalse(Directory.Exists(movedStaging)); + Assert.IsFalse(Directory.Exists(restoredStaging)); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, moved.FolderName))); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, restored.FolderName))); + } + + [TestMethod] + public async Task RenameAndUpdateAreBlockedWhileQueuedOrInstalling() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var created = store.Create(new InstanceDraft { Name = "Queued Build" }, "1.20.4"); + var update = new InstanceDraft { Name = "Renamed Build" }; + + var queuedRename = Assert.ThrowsExactly(() => store.Rename(created.Id, "Renamed Build")); + var queuedUpdate = Assert.ThrowsExactly(() => store.Update(created.Id, update)); + var queuedSettingsUpdate = Assert.ThrowsExactly(() => store.UpdateSettings(created.Id, update)); + var queuedDelete = Assert.ThrowsExactly(() => store.Delete(created.Id)); + Assert.AreEqual(InstanceStorageError.BuildInstalling, queuedRename.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, queuedUpdate.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, queuedSettingsUpdate.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, queuedDelete.Error); + + await SetStateAsync(temporary.Paths, store, InstanceInstallationState.Installing); + var installingRename = Assert.ThrowsExactly(() => store.Rename(created.Id, "Renamed Build")); + var installingUpdate = Assert.ThrowsExactly(() => store.Update(created.Id, update)); + var installingSettingsUpdate = Assert.ThrowsExactly(() => store.UpdateSettings(created.Id, update)); + var installingDelete = Assert.ThrowsExactly(() => store.Delete(created.Id)); + Assert.AreEqual(InstanceStorageError.BuildInstalling, installingRename.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, installingUpdate.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, installingSettingsUpdate.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, installingDelete.Error); + } + + [TestMethod] + public async Task SettingsCanChangeWhileTheGameRunsButRenameCannot() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var created = store.Create(new InstanceDraft { Name = "Running Build" }, "1.20.4"); + await SetStateAsync(temporary.Paths, store, InstanceInstallationState.Ready); + + Assert.IsTrue(store.TryBeginLaunch(created.Id, out var lease, out _)); + using (lease) + { + store.UpdateSettings(created.Id, new InstanceDraft { Name = "Ignored", OfflineMode = true, OfflineUsername = "Steve" }); + var rename = Assert.ThrowsExactly(() => store.Rename(created.Id, "Other Name")); + + Assert.AreEqual(InstanceStorageError.BuildRunning, rename.Error); + Assert.AreEqual(InstanceStorageError.BuildRunning, store.ManagementBlockedError(store.List().Single())); + } + + var updated = store.List().Single(); + Assert.IsTrue(updated.OfflineMode); + Assert.AreEqual("Running Build", updated.Name); + Assert.IsNull(store.ManagementBlockedError(updated)); + } + + [TestMethod] + public async Task TryBeginLaunchAndDeleteAreSerializedByTheStoreGate() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var created = initial.Create(new InstanceDraft { Name = "Atomic Busy" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + + Assert.IsTrue(initial.TryBeginLaunch(created.Id, out var launchLease, out var launchReason)); + Assert.IsNull(launchReason); + using (launchLease) + { + var blockedDelete = Assert.ThrowsExactly(() => initial.Delete(created.Id)); + Assert.AreEqual(InstanceStorageError.BuildRunning, blockedDelete.Error); + } + + var deleteEnteredCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var continueDelete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deleting = await LoadStoreAsync(temporary, () => + { + deleteEnteredCommit.TrySetResult(); + continueDelete.Task.GetAwaiter().GetResult(); + }); + var deleteTask = Task.Run(() => deleting.Delete(created.Id)); + await deleteEnteredCommit.Task; + var beginEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var beginTask = Task.Run(() => + { + beginEntered.TrySetResult(); + try + { + return deleting.TryBeginLaunch(created.Id, out _, out _); + } + catch (KeyNotFoundException) + { + return false; + } + }); + await beginEntered.Task; + continueDelete.TrySetResult(); + + await deleteTask; + await deleting.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsFalse(await beginTask); + Assert.IsFalse(deleting.IsLaunchBusy(created.Id)); + } + + [TestMethod] + public async Task BusyFlagsRemainSafeUnderConcurrentReadsAndWrites() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var instance = store.Create(new InstanceDraft { Name = "Busy Build" }, "1.20.4"); + await SetStateAsync(temporary.Paths, store, InstanceInstallationState.Ready); + var id = instance.Id; + var notificationCount = 0; + store.BusyStateChanged += (_, _) => Interlocked.Increment(ref notificationCount); + using var start = new ManualResetEventSlim(); + var workers = Enumerable.Range(0, 12).Select(workerIndex => Task.Run(() => + { + start.Wait(); + for (var iteration = 0; iteration < 500; iteration++) + { + if (store.TryBeginLaunch(id, out var launch, out _)) + { + store.IsLaunchBusy(id); + launch!.Dispose(); + } + + if (store.TryBeginContent(id, out var content, out _)) + { + store.IsContentBusy(id); + content!.Dispose(); + } + } + })).ToArray(); + start.Set(); + await Task.WhenAll(workers); + + Assert.IsTrue(store.TryBeginLaunch(id, out var finalLease, out _)); + Assert.IsTrue(store.IsLaunchBusy(id)); + Assert.IsTrue(notificationCount > 0); + finalLease!.Dispose(); + } + + [TestMethod] + public async Task BusyChecksDoNotWaitForRunningStoreOperation() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + initial.Create(new InstanceDraft { Name = "Busy Build" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + using var committing = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var store = await LoadStoreAsync(temporary, () => + { + committing.Set(); + release.Wait(); + }); + var instance = store.List().Single(); + Assert.IsTrue(store.TryBeginContent(instance.Id, out var lease, out _)); + lease!.Dispose(); + Assert.IsTrue(store.TryBeginLaunch(instance.Id, out var launch, out _)); + + var update = Task.Run(() => store.UpdateSettings(instance.Id, new InstanceDraft { Name = instance.Name, OfflineMode = true, OfflineUsername = "Steve" })); + try + { + Assert.IsTrue(committing.Wait(TimeSpan.FromSeconds(10))); + Assert.IsTrue(store.IsLaunchBusy(instance.Id)); + Assert.IsFalse(store.IsContentBusy(instance.Id)); + Assert.AreEqual(InstanceStorageError.BuildRunning, store.ManagementBlockedError(instance)); + } + finally + { + release.Set(); + await update; + launch!.Dispose(); + } + + Assert.IsFalse(store.IsLaunchBusy(instance.Id)); + } + + [TestMethod] + public void ContainedPathRejectsTraversalAbsolutePathsAndReparsePoints() + { + using var temporary = new TemporaryDirectory(); + var root = temporary.Paths.GameRoot; + Directory.CreateDirectory(root); + foreach (var path in new[] { "..\\outside", "a\\..\\b", Path.Combine(root, "absolute"), "C:\\absolute" }) + { + Assert.ThrowsExactly(() => InstanceStorage.ContainedPath(path, root)); + } + + var target = Path.Combine(root, "target"); + var junction = Path.Combine(root, "junction"); + Directory.CreateDirectory(target); + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, target } + }); + process!.WaitForExit(); + if (process.ExitCode != 0) + { + Assert.Inconclusive("Could not create a junction in the temporary test folder: " + process.StandardError.ReadToEnd()); + } + + try + { + Assert.ThrowsExactly(() => InstanceStorage.ContainedPath("junction\\file.txt", root)); + } + finally + { + Directory.Delete(junction); + } + } + + [TestMethod] + public void RootLockCanOnlyBeHeldByOneProcessAtATime() + { + using var temporary = new TemporaryDirectory(); + var first = HakoRootLock.Acquire(temporary.Paths); + try + { + Assert.ThrowsExactly(() => HakoRootLock.Acquire(temporary.Paths)); + } + finally + { + first.Dispose(); + } + + using var second = HakoRootLock.Acquire(temporary.Paths); + } + + [TestMethod] + public void RootLockAlsoPreventsAnotherGameRootFromSharingTheDataRoot() + { + using var temporary = new TemporaryDirectory(); + var firstPaths = temporary.Paths; + var secondPaths = new HakoPaths( + Path.Combine(temporary.Path, "other-game"), + firstPaths.DataRoot, + Path.Combine(temporary.Path, "other-cache")); + using var first = HakoRootLock.Acquire(firstPaths); + + var exception = Assert.ThrowsExactly(() => HakoRootLock.Acquire(secondPaths)); + + Assert.AreEqual(Path.Combine(firstPaths.DataRoot, ".hako-lock"), exception.LockPath); + } + + [TestMethod] + public void RootLockWrapsOnlySharingAndLockViolationsAsAlreadyOpen() + { + Assert.IsTrue(HakoRootLock.IsLockViolation(new SyntheticIOException(32))); + Assert.IsTrue(HakoRootLock.IsLockViolation(new SyntheticIOException(33))); + Assert.IsFalse(HakoRootLock.IsLockViolation(new SyntheticIOException(5))); + + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + Directory.CreateDirectory(Path.Combine(temporary.Paths.GameRoot, ".hako-lock")); + + try + { + using var ignored = HakoRootLock.Acquire(temporary.Paths); + Assert.Fail("A directory at the lock path should fail as a storage error."); + } + catch (HakoRootInUseException) + { + Assert.Fail("A non-sharing storage error must not be reported as another Hako instance."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + [TestMethod] + public async Task RootJunctionIsAcceptedButNestedJunctionIsRejected() + { + await using var temporary = new TemporaryDirectory(); + var target = Path.Combine(temporary.Path, "game-target"); + var rootJunction = Path.Combine(temporary.Path, "game-link"); + var nestedTarget = Path.Combine(temporary.Path, "nested-target"); + Directory.CreateDirectory(target); + Directory.CreateDirectory(nestedTarget); + CreateJunction(rootJunction, target); + var paths = new HakoPaths(rootJunction, Path.Combine(temporary.Path, "data"), Path.Combine(temporary.Path, "cache")); + var storage = new InstanceStorage(paths); + CreateJunction(Path.Combine(target, "nested-link"), nestedTarget); + + try + { + using var rootLock = HakoRootLock.Acquire(paths); + Assert.AreEqual(Path.Combine(rootJunction, "Build"), storage.Directory("Build")); + Assert.ThrowsExactly(() => storage.Directory("nested-link")); + var store = temporary.Track(new InstanceStore(paths)); + await store.LoadAsync(); + Assert.AreEqual(0, store.List().Count); + } + finally + { + Directory.Delete(Path.Combine(target, "nested-link")); + Directory.Delete(rootJunction); + } + } + + [TestMethod] + public async Task KilledLockHolderReleasesGameRootForTheNextProcess() + { + await using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + var powerShell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + if (!File.Exists(powerShell)) + { + Assert.Inconclusive("Windows PowerShell is not available to run the lock-holder child process."); + } + + var lockPath = Path.Combine(temporary.Paths.GameRoot, ".hako-lock").Replace("'", "''", StringComparison.Ordinal); + var script = "$stream = [System.IO.File]::Open('" + lockPath + + "', [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None);" + + " [Console]::Out.WriteLine('LOCKED'); [Console]::Out.Flush();" + + " ([System.Threading.ManualResetEvent]::new($false)).WaitOne() | Out-Null"; + var encodedScript = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); + using var process = Process.Start(new ProcessStartInfo(powerShell) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedScript } + })!; + + try + { + var line = await process.StandardOutput.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.AreEqual("LOCKED", line); + Assert.ThrowsExactly(() => HakoRootLock.Acquire(temporary.Paths)); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + + using (await AcquireRootLockWhenReleasedAsync(temporary.Paths)) + { + } + await DeleteLockFileWhenReleasedAsync(Path.Combine(temporary.Paths.GameRoot, ".hako-lock")); + await DeleteLockFileWhenReleasedAsync(Path.Combine(temporary.Paths.DataRoot, ".hako-lock")); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + + [TestMethod] + public void OfflineUsernameUsesThreeToSixteenAsciiLettersDigitsAndUnderscores() + { + Assert.IsTrue(OfflineUsername.IsValid("Player_2")); + Assert.IsTrue(OfflineUsername.IsValid("Ab1")); + Assert.IsFalse(OfflineUsername.IsValid("ab")); + Assert.IsFalse(OfflineUsername.IsValid(new string('a', 17))); + Assert.IsFalse(OfflineUsername.IsValid("Игрок")); + Assert.IsFalse(OfflineUsername.IsValid("player-name")); + } + + [TestMethod] + public async Task UpdateIsTransactionalAndManagementBlockReasonsTrackBusyFlags() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var created = initial.Create(new InstanceDraft { Name = "Original" }, "1.20.4"); + Assert.IsNotNull(initial.ManagementBlockedReason(created)); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var draft = new InstanceDraft + { + Name = "Updated", + OfflineMode = true, + OfflineUsername = "Player_2", + ArgumentSource = LaunchArgumentSource.Custom, + Parameters = new InstanceParameters { JavaArguments = "-Dchanged=yes", MaximumMemoryMiB = 1024 } + }; + var failing = await LoadStoreAsync(temporary, () => throw new IOException("Injected registry failure.")); + + Assert.ThrowsExactly(() => failing.Update(created.Id, draft)); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Original"))); + Assert.AreEqual("Original", initial.List().Single().Name); + + initial.Update(created.Id, draft); + var updated = initial.List().Single(); + Assert.AreEqual("Updated", updated.Name); + Assert.AreEqual("Updated", updated.FolderName); + Assert.IsTrue(updated.OfflineMode); + Assert.AreEqual(LaunchArgumentSource.Custom, updated.ArgumentSource); + Assert.AreEqual("-Dchanged=yes", updated.Parameters.JavaArguments); + Assert.IsFalse(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Original"))); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Paths.GameRoot, "Updated"))); + + Assert.IsNull(initial.ManagementBlockedReason(updated)); + Assert.IsTrue(initial.TryBeginLaunch(updated.Id, out var launchLease, out _)); + using (launchLease) + { + Assert.IsNotNull(initial.ManagementBlockedReason(updated)); + } + Assert.IsNull(initial.ManagementBlockedReason(updated)); + Assert.IsTrue(initial.TryBeginContent(updated.Id, out var contentLease, out _)); + using (contentLease) + { + Assert.IsNotNull(initial.ManagementBlockedReason(updated)); + } + Assert.IsNull(initial.ManagementBlockedReason(updated)); + } + + [TestMethod] + public async Task UpdateSettingsChangesOnlyProfileSettingsAndRollsBackIconOnFailure() + { + await using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary); + var created = initial.Create(new InstanceDraft { Name = "Settings Build" }, "1.20.4"); + var originalIcon = new byte[] { 1, 2, 3 }; + File.WriteAllBytes(Path.Combine(temporary.Paths.GameRoot, "Settings_Build", "icon.png"), originalIcon); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var update = new InstanceDraft + { + Name = "Ignored Name", + IconSymbol = "", + IconData = [7, 8, 9], + ArgumentSource = LaunchArgumentSource.Global, + Parameters = new InstanceParameters { MinecraftArguments = "--new", MaximumMemoryMiB = 1024 } + }; + var failing = await LoadStoreAsync(temporary, () => throw new IOException("Injected registry failure.")); + + Assert.ThrowsExactly(() => failing.UpdateSettings(created.Id, update)); + CollectionAssert.AreEqual(originalIcon, File.ReadAllBytes(Path.Combine(temporary.Paths.GameRoot, "Settings_Build", "icon.png"))); + + initial.UpdateSettings(created.Id, update); + var updated = initial.List().Single(); + Assert.AreEqual("Settings Build", updated.Name); + Assert.AreEqual(LaunchArgumentSource.Global, updated.ArgumentSource); + Assert.AreEqual("--new", updated.Parameters.MinecraftArguments); + CollectionAssert.AreEqual(update.IconData, File.ReadAllBytes(Path.Combine(temporary.Paths.GameRoot, "Settings_Build", "icon.png"))); + } + + [TestMethod] + public async Task RenamePreservesCustomIconFile() + { + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); + var created = store.Create(new InstanceDraft { Name = "Icon Build" }, "1.20.4"); + var icon = new byte[] { 8, 6, 7, 5, 3, 0, 9 }; + var oldIconPath = Path.Combine(temporary.Paths.GameRoot, created.FolderName, "icon.png"); + await File.WriteAllBytesAsync(oldIconPath, icon); + await SetStateAsync(temporary.Paths, store, InstanceInstallationState.Ready); + + store.Rename(created.Id, "Renamed Icon Build"); + + CollectionAssert.AreEqual(icon, await File.ReadAllBytesAsync( + Path.Combine(temporary.Paths.GameRoot, "Renamed_Icon_Build", "icon.png"))); + Assert.AreEqual(created.IconRevision, store.List().Single().IconRevision); + } + + [TestMethod] + public void IsolatedArgumentsRequireTwoAbsoluteRootsAndUseTheirOwnInstanceKey() + { + using var temporary = new TemporaryDirectory(); + Assert.ThrowsExactly(() => HakoPaths.ParseOverrides(["--hako-root", Path.Combine(temporary.Path, "game")])); + Assert.ThrowsExactly(() => HakoPaths.ParseOverrides(["--hako-root"])); + + var game = Path.Combine(temporary.Path, "game"); + var data = Path.Combine(temporary.Path, "data"); + Assert.ThrowsExactly(() => HakoPaths.ParseOverrides(["--hako-root", game, "--data-root", Path.Combine(game, "data")])); + Assert.ThrowsExactly(() => HakoPaths.ParseOverrides(["--hako-root", game, "--data-root", temporary.Path])); + var options = HakoPaths.ParseOverrides(["--hako-root", game, "--data-root", data]); + var paths = HakoPaths.CreateIsolated(options.GameRoot!, options.DataRoot!); + Assert.IsTrue(paths.Isolated); + StringAssert.StartsWith(paths.GetInstanceKey(), "Hako.Isolated."); + Assert.AreNotEqual("Hako.Main", paths.GetInstanceKey()); + Assert.AreEqual(Path.Combine(data, "Cache"), paths.CacheRoot); + } + + private sealed class TemporaryDirectory : IDisposable, IAsyncDisposable + { + private readonly List _resources = []; + + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Hako.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + Paths = new HakoPaths(System.IO.Path.Combine(Path, "game"), System.IO.Path.Combine(Path, "data"), System.IO.Path.Combine(Path, "cache")); + } + + public string Path { get; } + public HakoPaths Paths { get; } + + public InstanceStore CreateStore( + Action? beforeRegistryCommit = null, + Action? beforeDeleteStagingCleanup = null) => + Track(new InstanceStore(Paths, beforeRegistryCommit, beforeDeleteStagingCleanup)); + + public T Track(T resource) where T : IAsyncDisposable + { + _resources.Add(resource); + return resource; + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + public async ValueTask DisposeAsync() + { + List? errors = null; + for (var index = _resources.Count - 1; index >= 0; index--) + { + try + { + await _resources[index].DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + errors ??= []; + errors.Add(exception); + } + } + + if (errors is not null) + { + throw new AggregateException("Failed to dispose test resources.", errors); + } + + if (Directory.Exists(Path)) + { + var deadline = Stopwatch.StartNew(); + while (true) + { + try + { + Directory.Delete(Path, recursive: true); + break; + } + catch (IOException exception) when (HakoRootLock.IsLockViolation(exception) + && deadline.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(25).ConfigureAwait(false); + } + } + } + } + } + + private static async Task DeleteLockFileWhenReleasedAsync(string path) + { + var deadline = Stopwatch.StartNew(); + while (true) + { + try + { + File.Delete(path); + return; + } + catch (IOException exception) when (HakoRootLock.IsLockViolation(exception) + && deadline.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(25); + } + } + } + + private static async Task AcquireRootLockWhenReleasedAsync(HakoPaths paths) + { + var deadline = Stopwatch.StartNew(); + while (true) + { + try + { + return HakoRootLock.Acquire(paths); + } + catch (HakoRootInUseException exception) when (exception.InnerException is IOException inner + && HakoRootLock.IsLockViolation(inner) + && deadline.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(25).ConfigureAwait(false); + } + } + } + + private sealed class SyntheticIOException : IOException + { + public SyntheticIOException(int error) + { + HResult = unchecked((int)0x80070000) | error; + } + } + + private static async Task LoadStoreAsync( + TemporaryDirectory temporary, + Action? beforeRegistryCommit = null, + Action? beforeDeleteStagingCleanup = null) + { + var store = temporary.CreateStore(beforeRegistryCommit, beforeDeleteStagingCleanup); + await store.LoadAsync(); + return store; + } + + private static async Task SetStateAsync(HakoPaths paths, InstanceStore store, InstanceInstallationState state) + { + var registryPath = Path.Combine(paths.GameRoot, "instances.json"); + var registry = JsonSerializer.Deserialize(File.ReadAllBytes(registryPath), HakoJsonContext.Default.InstanceRegistryFile)!; + var updated = registry with + { + Instances = registry.Instances.Select(instance => instance with { State = state }).ToList() + }; + File.WriteAllBytes(registryPath, JsonSerializer.SerializeToUtf8Bytes(updated, HakoJsonContext.Default.InstanceRegistryFile)); + await store.LoadAsync(); + } + + private static void CreateJunction(string junction, string target) + { + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, target } + }); + process!.WaitForExit(); + if (process.ExitCode != 0) + { + Assert.Inconclusive("Could not create a junction in the temporary test folder: " + process.StandardError.ReadToEnd()); + } + } +} diff --git a/windows/Hako.Tests/InstanceWorldsTests.cs b/windows/Hako.Tests/InstanceWorldsTests.cs new file mode 100644 index 0000000..7dde725 --- /dev/null +++ b/windows/Hako.Tests/InstanceWorldsTests.cs @@ -0,0 +1,529 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class InstanceWorldsTests +{ + [TestMethod] + public async Task ListsModernOldAndCorruptWorldsWithFallbackAndUnknownFields() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var modern = CreateWorld(root, "Modern", WorldTestFixture.Create("Modern", "1.21.1", 123, 2000).Compressed); + var old = CreateWorld(root, "Old", levelOld: WorldTestFixture.Create("Old metadata", version: null, + gameType: 0, lastPlayed: 1000).Compressed); + var recovered = CreateWorld(root, "Recovered", [1, 2, 3], + WorldTestFixture.Create("Recovered from old", lastPlayed: 3000).Compressed); + var broken = CreateWorld(root, "Broken", [1, 2, 3], [4, 5, 6]); + var worlds = new InstanceWorlds(store); + + var listed = await worlds.ListAsync(instance.Id); + + CollectionAssert.AreEqual(new[] { "Recovered", "Modern", "Old", "Broken" }, listed.Select(item => item.Id).ToArray()); + Assert.AreEqual("Recovered from old", listed[0].Name); + Assert.IsNull(listed[0].MetadataError); + Assert.AreEqual("Old metadata", listed.Single(item => item.Id == "Old").Name); + Assert.IsNull(listed.Single(item => item.Id == "Old").Version); + Assert.AreEqual(0, listed.Single(item => item.Id == "Old").GameType); + Assert.AreEqual(123, listed.Single(item => item.Id == "Modern").GameType); + Assert.AreEqual(WorldMetadataError.InvalidGzip, listed.Single(item => item.Id == "Broken").MetadataError); + Assert.AreEqual("Broken", listed.Single(item => item.Id == "Broken").Name); + Assert.IsTrue(File.Exists(Path.Combine(modern, "level.dat"))); + Assert.IsTrue(Directory.Exists(recovered)); + Assert.IsTrue(Directory.Exists(broken)); + } + + [TestMethod] + public async Task MissingSavesReturnsEmptyWithoutCreatingItAndHiddenWorldsAreIncluded() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var worlds = new InstanceWorlds(store); + + Assert.AreEqual(0, (await worlds.ListAsync(instance.Id)).Count); + var saves = Path.Combine(root, "minecraft", "saves"); + Assert.IsFalse(Directory.Exists(saves)); + + var hidden = CreateWorld(root, ".Hidden", WorldTestFixture.Create("Hidden World").Compressed); + File.SetAttributes(hidden, File.GetAttributes(hidden) | FileAttributes.Hidden); + CreateWorld(root, "FolderFallback", WorldTestFixture.Create(includeName: false).Compressed); + var listed = await worlds.ListAsync(instance.Id); + Assert.AreEqual(".Hidden", listed.Single(item => item.Id == ".Hidden").Id); + Assert.AreEqual("Hidden World", listed.Single(item => item.Id == ".Hidden").Name); + Assert.AreEqual("FolderFallback", listed.Single(item => item.Id == "FolderFallback").Name); + } + + [TestMethod] + public async Task SizeCountsHiddenDimensionsAndDatapacksAndIconHasAFourMiBLimit() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var folder = CreateWorld(root, "Sized", WorldTestFixture.Create().Compressed); + var dimensionFile = Path.Combine(folder, "DIM-1", "region", "r.0.0.mca"); + Directory.CreateDirectory(Path.GetDirectoryName(dimensionFile)!); + await File.WriteAllBytesAsync(dimensionFile, new byte[131]); + await File.WriteAllBytesAsync(Path.Combine(folder, ".hidden"), new byte[29]); + var datapack = Path.Combine(folder, "datapacks", "local.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(datapack)!); + await File.WriteAllBytesAsync(datapack, new byte[53]); + var iconPath = Path.Combine(folder, "icon.png"); + await File.WriteAllBytesAsync(iconPath, new byte[8]); + var metadataLength = new FileInfo(Path.Combine(folder, "level.dat")).Length; + var expectedSize = metadataLength + 131 + 29 + 53 + 8; + + var world = (await new InstanceWorlds(store).ListAsync(instance.Id)).Single(); + + Assert.AreEqual(expectedSize, world.Size); + CollectionAssert.AreEqual(new byte[8], world.IconData); + await File.WriteAllBytesAsync(iconPath, new byte[4 * 1024 * 1024 + 1]); + world = (await new InstanceWorlds(store).ListAsync(instance.Id)).Single(); + Assert.IsNull(world.IconData); + Assert.AreEqual(expectedSize + 4 * 1024 * 1024 - 7, world.Size); + } + + [TestMethod] + public void FolderContractsRejectWindowsUnsafeIdsAndDoNotCreateDatapacks() + { + using var temporary = new TemporaryDirectory(); + var (store, instance, root) = CreateReadyInstanceAsync(temporary).GetAwaiter().GetResult(); + var folder = CreateWorld(root, "Real", WorldTestFixture.Create().Compressed); + var expectedDatapacks = Path.Combine(folder, "datapacks"); + + Assert.AreEqual(expectedDatapacks, InstanceWorlds.DatapacksFolder("Real", root)); + Assert.IsFalse(Directory.Exists(expectedDatapacks)); + foreach (var invalid in new[] { "", ".", "..", "../Outside", "name:stream", "CON", "name.", "name ", + @"C:\outside", @"\\server\share" }) + { + Assert.AreEqual(InstanceWorldFailureReason.InvalidWorldId, + Assert.ThrowsExactly(() => InstanceWorlds.WorldFolder(invalid, root)).Error); + } + + Directory.Delete(folder, recursive: true); + Assert.AreEqual(InstanceWorldFailureReason.WorldMissing, + Assert.ThrowsExactly(() => InstanceWorlds.DatapacksFolder("Real", root)).Error); + Assert.IsFalse(Directory.Exists(folder)); + } + + [TestMethod] + public async Task FolderContractsRejectWorldAndDatapacksJunctionsAndListRejectsSavesJunction() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var realWorld = CreateWorld(root, "Real", WorldTestFixture.Create().Compressed); + var outsideWorld = Path.Combine(temporary.Root, "outside-world"); + Directory.CreateDirectory(outsideWorld); + await File.WriteAllBytesAsync(Path.Combine(outsideWorld, "level.dat"), WorldTestFixture.Create().Compressed); + var saves = Path.Combine(root, "minecraft", "saves"); + var worldJunction = Path.Combine(saves, "Alias"); + using var worldLink = CreateJunction(temporary.Root, worldJunction, outsideWorld); + + Assert.AreEqual(InstanceWorldFailureReason.ReparsePointNotAllowed, + Assert.ThrowsExactly(() => InstanceWorlds.WorldFolder("Alias", root)).Error); + Assert.AreEqual("Real", (await new InstanceWorlds(store).ListAsync(instance.Id)).Single().Id); + worldLink.Remove(); + + var datapacksTarget = Path.Combine(temporary.Root, "outside-datapacks"); + Directory.CreateDirectory(datapacksTarget); + var datapacks = Path.Combine(realWorld, "datapacks"); + using var datapacksLink = CreateJunction(temporary.Root, datapacks, datapacksTarget); + Assert.AreEqual(InstanceWorldFailureReason.ReparsePointNotAllowed, + Assert.ThrowsExactly(() => InstanceWorlds.DatapacksFolder("Real", root)).Error); + + datapacksLink.Remove(); + var movedSaves = Path.Combine(root, "minecraft", "saves-real"); + Directory.Move(saves, movedSaves); + using var savesLink = CreateJunction(temporary.Root, saves, movedSaves); + var listing = await Assert.ThrowsExactlyAsync(() => new InstanceWorlds(store).ListAsync(instance.Id)); + Assert.AreEqual(InstanceWorldFailureReason.ReparsePointNotAllowed, listing.Error); + } + + [TestMethod] + public async Task ListingFallsBackToOrdinaryOldMetadataWhenPrimaryIsAReparsePoint() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(root, "Fallback", levelOld: WorldTestFixture.Create("Old metadata").Compressed); + var outside = Path.Combine(temporary.Root, "outside-primary-metadata"); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "sentinel.txt"), "keep"); + using var primaryLink = CreateJunction(temporary.Root, Path.Combine(world, "level.dat"), outside); + + var listed = await new InstanceWorlds(store).ListAsync(instance.Id); + + var recovered = listed.Single(); + Assert.AreEqual("Fallback", recovered.Id); + Assert.AreEqual("Old metadata", recovered.Name); + Assert.IsNull(recovered.MetadataError); + Assert.AreEqual("keep", await File.ReadAllTextAsync(Path.Combine(outside, "sentinel.txt"))); + } + + [TestMethod] + public async Task ListingKeepsWorldWhenIconIsAReparsePoint() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(root, "IconLink", WorldTestFixture.Create("Listed world").Compressed); + var outside = Path.Combine(temporary.Root, "outside-icon"); + Directory.CreateDirectory(outside); + await File.WriteAllTextAsync(Path.Combine(outside, "sentinel.txt"), "keep"); + using var iconLink = CreateJunction(temporary.Root, Path.Combine(world, "icon.png"), outside); + + var listed = await new InstanceWorlds(store).ListAsync(instance.Id); + + Assert.AreEqual(1, listed.Count); + Assert.AreEqual("Listed world", listed[0].Name); + Assert.IsNull(listed[0].IconData); + Assert.AreEqual("keep", await File.ReadAllTextAsync(Path.Combine(outside, "sentinel.txt"))); + } + + [TestMethod] + public async Task ListingSkipsWorldWhenBothMetadataPathsAreReparsePoints() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(root, "MetadataLinks"); + var outsidePrimary = Path.Combine(temporary.Root, "outside-primary"); + var outsideOld = Path.Combine(temporary.Root, "outside-old"); + Directory.CreateDirectory(outsidePrimary); + Directory.CreateDirectory(outsideOld); + var outsidePrimaryMetadata = WorldTestFixture.Create("Outside primary").Compressed; + var outsideOldMetadata = WorldTestFixture.Create("Outside old").Compressed; + await File.WriteAllBytesAsync(Path.Combine(outsidePrimary, "level.dat"), outsidePrimaryMetadata); + await File.WriteAllBytesAsync(Path.Combine(outsideOld, "level.dat_old"), outsideOldMetadata); + using var primaryLink = CreateJunction(temporary.Root, Path.Combine(world, "level.dat"), outsidePrimary); + using var oldLink = CreateJunction(temporary.Root, Path.Combine(world, "level.dat_old"), outsideOld); + + var listed = await new InstanceWorlds(store).ListAsync(instance.Id); + + Assert.AreEqual(0, listed.Count); + CollectionAssert.AreEqual(outsidePrimaryMetadata, + await File.ReadAllBytesAsync(Path.Combine(outsidePrimary, "level.dat"))); + CollectionAssert.AreEqual(outsideOldMetadata, + await File.ReadAllBytesAsync(Path.Combine(outsideOld, "level.dat_old"))); + } + + [TestMethod] + public async Task DuplicateUsesCaseInsensitiveSuffixAndRenamesBothFilesWithoutChangingSource() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var main = WorldTestFixture.Create("Display name", extra: WorldTestFixture.ByteArrayTag("future", [8, 7, 6])); + var old = WorldTestFixture.Create("Old display name", lastPlayed: 123); + var source = CreateWorld(root, "World", main.Compressed, old.Compressed); + Directory.CreateDirectory(Path.Combine(source, "datapacks", "nested")); + await File.WriteAllTextAsync(Path.Combine(source, "datapacks", "nested", "pack.mcmeta"), "keep"); + await File.WriteAllTextAsync(Path.Combine(source, ".secret"), "private"); + var originalMain = await File.ReadAllBytesAsync(Path.Combine(source, "level.dat")); + var originalOld = await File.ReadAllBytesAsync(Path.Combine(source, "level.dat_old")); + Directory.CreateDirectory(Path.Combine(root, "minecraft", "saves", "wORLD 2")); + var worlds = new InstanceWorlds(store); + + var result = await worlds.DuplicateAsync(instance.Id, "World"); + + Assert.IsTrue(result.Succeeded, result.FailureReason?.ToString()); + Assert.AreEqual("World 3", result.WorldId); + var duplicate = Path.Combine(root, "minecraft", "saves", result.WorldId!); + Assert.AreEqual("Display name 3", Hako.Core.Minecraft.WorldMetadata.Read( + await File.ReadAllBytesAsync(Path.Combine(duplicate, "level.dat"))).Name); + Assert.AreEqual("Display name 3", Hako.Core.Minecraft.WorldMetadata.Read( + await File.ReadAllBytesAsync(Path.Combine(duplicate, "level.dat_old"))).Name); + CollectionAssert.AreEqual(originalMain, await File.ReadAllBytesAsync(Path.Combine(source, "level.dat"))); + CollectionAssert.AreEqual(originalOld, await File.ReadAllBytesAsync(Path.Combine(source, "level.dat_old"))); + Assert.AreEqual("keep", await File.ReadAllTextAsync(Path.Combine(duplicate, "datapacks", "nested", "pack.mcmeta"))); + Assert.AreEqual("private", await File.ReadAllTextAsync(Path.Combine(duplicate, ".secret"))); + await File.WriteAllTextAsync(Path.Combine(duplicate, ".secret"), "changed"); + Assert.AreEqual("private", await File.ReadAllTextAsync(Path.Combine(source, ".secret"))); + Assert.IsFalse(Directory.EnumerateDirectories(Path.Combine(root, "minecraft", "saves")) + .Any(path => Path.GetFileName(path).StartsWith(".hako-world-copy-", StringComparison.OrdinalIgnoreCase))); + } + + [TestMethod] + public async Task DuplicateCopiesUnreadableMetadataByteForByte() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var main = new byte[] { 1, 2, 3, 4 }; + var old = new byte[] { 5, 6, 7, 8 }; + var source = CreateWorld(root, "Broken", main, old); + + var result = await new InstanceWorlds(store).DuplicateAsync(instance.Id, "Broken"); + + Assert.IsTrue(result.Succeeded, result.FailureReason?.ToString()); + var duplicate = Path.Combine(root, "minecraft", "saves", result.WorldId!); + CollectionAssert.AreEqual(main, await File.ReadAllBytesAsync(Path.Combine(duplicate, "level.dat"))); + CollectionAssert.AreEqual(old, await File.ReadAllBytesAsync(Path.Combine(duplicate, "level.dat_old"))); + CollectionAssert.AreEqual(main, await File.ReadAllBytesAsync(Path.Combine(source, "level.dat"))); + } + + [TestMethod] + public async Task DuplicateCancellationCleansItsOwnedStage() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var source = CreateWorld(root, "Large", WorldTestFixture.Create().Compressed); + var saves = Path.Combine(root, "minecraft", "saves"); + using var cancellation = new CancellationTokenSource(); + var stageCreated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var duplicate = new InstanceWorlds(store, null, stage => + { + stageCreated.TrySetResult(stage); + cancellation.Token.WaitHandle.WaitOne(); + cancellation.Token.ThrowIfCancellationRequested(); + }).DuplicateAsync(instance.Id, "Large", cancellation.Token); + var stage = await stageCreated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsTrue(Directory.Exists(stage)); + + cancellation.Cancel(); + await Assert.ThrowsExactlyAsync(() => duplicate); + Assert.IsFalse(Directory.EnumerateDirectories(saves).Any(path => + Path.GetFileName(path).StartsWith(".hako-world-copy-", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(Directory.Exists(source)); + } + + [TestMethod] + public async Task DuplicateRejectsNestedJunctionWithoutTouchingItsTarget() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var source = CreateWorld(root, "Linked", WorldTestFixture.Create().Compressed); + var target = Path.Combine(temporary.Root, "outside-world-data"); + Directory.CreateDirectory(target); + await File.WriteAllTextAsync(Path.Combine(target, "secret.txt"), "keep"); + var junction = Path.Combine(source, "linked"); + using var junctionLink = CreateJunction(temporary.Root, junction, target); + + var result = await new InstanceWorlds(store).DuplicateAsync(instance.Id, "Linked"); + + Assert.IsFalse(result.Succeeded); + Assert.AreEqual(InstanceWorldFailureReason.ReparsePointNotAllowed, result.FailureReason); + Assert.AreEqual("keep", await File.ReadAllTextAsync(Path.Combine(target, "secret.txt"))); + Assert.IsTrue(Directory.Exists(source)); + } + + [TestMethod] + public async Task ListingSkipsJunctionSizeAndDuplicateRejectsItsReparsePoint() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var source = CreateWorld(root, "Linked", WorldTestFixture.Create().Compressed); + var target = Path.Combine(temporary.Root, "outside-world-data"); + Directory.CreateDirectory(target); + await File.WriteAllBytesAsync(Path.Combine(target, "secret.bin"), new byte[4096]); + var junction = Path.Combine(source, "linked"); + using var junctionLink = CreateJunction(temporary.Root, junction, target); + var worlds = new InstanceWorlds(store); + + var listed = (await worlds.ListAsync(instance.Id)).Single(); + + Assert.AreEqual(new FileInfo(Path.Combine(source, "level.dat")).Length, listed.Size); + var duplicated = await worlds.DuplicateAsync(instance.Id, "Linked"); + Assert.AreEqual(InstanceWorldFailureReason.ReparsePointNotAllowed, duplicated.FailureReason); + Assert.AreEqual(4096, new FileInfo(Path.Combine(target, "secret.bin")).Length); + } + + [TestMethod] + public async Task TrashFailureRetainsWorldAndReportsTypedStatus() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(root, "Keep", WorldTestFixture.Create().Compressed); + var worlds = new InstanceWorlds(store, new RecycleBin(new FailedRecycleBinOperation())); + + var result = await worlds.TrashAsync(instance.Id, "Keep"); + + Assert.AreEqual(RecycleBinItemStatus.Failed, result.Status); + Assert.AreEqual(InstanceWorldFailureReason.RecycleBinFailed, result.FailureReason); + Assert.IsTrue(Directory.Exists(world)); + Assert.IsTrue(File.Exists(Path.Combine(world, "level.dat"))); + } + + [TestMethod] + public async Task DuplicateHonorsContentGameAndInstallationLeases() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + CreateWorld(root, "World", WorldTestFixture.Create().Compressed); + var worlds = new InstanceWorlds(store); + + Assert.IsTrue(store.TryBeginLaunch(instance.Id, out var launchLease, out _)); + using (launchLease!) + { + var running = await worlds.DuplicateAsync(instance.Id, "World"); + Assert.AreEqual(InstanceWorldFailureReason.BuildRunning, running.FailureReason); + } + + Assert.IsTrue(store.TryBeginContent(instance.Id, out var contentLease, out _)); + using (contentLease!) + { + var busy = await worlds.DuplicateAsync(instance.Id, "World"); + Assert.AreEqual(InstanceWorldFailureReason.BuildFilesBusy, busy.FailureReason); + } + + var queued = store.Create(new InstanceDraft { Name = "Queued" }, "1.20.4"); + var installing = await worlds.DuplicateAsync(queued.Id, "World"); + Assert.AreEqual(InstanceWorldFailureReason.BuildInstalling, installing.FailureReason); + } + + [TestMethod] + public async Task TrashRechecksWorldAfterItDisappears() + { + await using var temporary = new TemporaryDirectory(); + var (store, instance, root) = await CreateReadyInstanceAsync(temporary); + var world = CreateWorld(root, "Gone", WorldTestFixture.Create().Compressed); + Directory.Delete(world, recursive: true); + + var result = await new InstanceWorlds(store, new RecycleBin(new FailedRecycleBinOperation())) + .TrashAsync(instance.Id, "Gone"); + + Assert.AreEqual(InstanceWorldFailureReason.WorldMissing, result.FailureReason); + Assert.IsFalse(Directory.Exists(world)); + } + + private static async Task<(InstanceStore Store, GameInstance Instance, string Root)> CreateReadyInstanceAsync( + TemporaryDirectory temporary) + { + var store = temporary.CreateStore(); + await store.LoadAsync(); + var instance = store.Create(new InstanceDraft { Name = "World Tests" }, "1.20.4"); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + var registry = JsonSerializer.Deserialize(await File.ReadAllBytesAsync(registryPath), + HakoJsonContext.Default.InstanceRegistryFile)!; + var ready = registry with + { + Instances = registry.Instances.Select(item => item.Id == instance.Id + ? item with { State = InstanceInstallationState.Ready } + : item).ToList() + }; + await File.WriteAllBytesAsync(registryPath, + JsonSerializer.SerializeToUtf8Bytes(ready, HakoJsonContext.Default.InstanceRegistryFile)); + await store.LoadAsync(); + var loaded = store.List().Single(item => item.Id == instance.Id); + return (store, loaded, store.GetInstanceDirectory(instance.Id)); + } + + private static string CreateWorld(string instanceRoot, string id, byte[]? level = null, byte[]? levelOld = null) + { + var folder = Path.Combine(instanceRoot, "minecraft", "saves", id); + Directory.CreateDirectory(folder); + if (level is not null) File.WriteAllBytes(Path.Combine(folder, "level.dat"), level); + if (levelOld is not null) File.WriteAllBytes(Path.Combine(folder, "level.dat_old"), levelOld); + return folder; + } + + private static JunctionCleanup CreateJunction(string temporaryRoot, string junction, string target) + { + if (!OperatingSystem.IsWindows()) Assert.Inconclusive("Junction tests require Windows."); + + var cleanup = new JunctionCleanup(temporaryRoot, junction, target); + try + { + var command = "$ErrorActionPreference = 'Stop'; New-Item -ItemType Junction -Path " + + PowerShellLiteral(junction) + " -Target " + PowerShellLiteral(target) + " | Out-Null"; + using var process = Process.Start(new ProcessStartInfo("powershell.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command } + }) ?? throw new InvalidOperationException("Could not start Windows PowerShell to create a test junction."); + process.WaitForExit(); + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + if (process.ExitCode != 0) + { + Assert.Fail("Could not create a junction in the isolated test folder (exit code " + + process.ExitCode + "): " + standardOutput + standardError); + } + + cleanup.AssertCreated(); + return cleanup; + } + catch + { + cleanup.Dispose(); + throw; + } + } + + private static string PowerShellLiteral(string value) => "'" + value.Replace("'", "''") + "'"; + + private sealed class JunctionCleanup(string temporaryRoot, string junction, string target) : IDisposable + { + private bool _removed; + + public void AssertCreated() + { + AssertTestOwnedPath(junction); + AssertTestOwnedPath(target); + var attributes = File.GetAttributes(junction); + Assert.IsTrue((attributes & FileAttributes.ReparsePoint) != 0, + "PowerShell did not create a reparse-point junction at the test-owned path."); + Assert.IsTrue((attributes & FileAttributes.Directory) != 0, + "The test junction is not a directory reparse point."); + Assert.IsTrue(Directory.Exists(target), "The test-owned junction target was not created."); + } + + public void Remove() => Dispose(); + + public void Dispose() + { + if (_removed) return; + AssertTestOwnedPath(junction); + AssertTestOwnedPath(target); + Assert.IsTrue(Directory.Exists(target), "The test-owned junction target disappeared before cleanup."); + + if (TryGetAttributes(junction, out var attributes)) + { + Assert.IsTrue((attributes & FileAttributes.ReparsePoint) != 0 + && (attributes & FileAttributes.Directory) != 0, + "Refusing to delete a test path that is not a directory reparse point."); + Directory.Delete(junction); + } + + Assert.IsTrue(Directory.Exists(target), "Removing the junction deleted its target."); + _removed = true; + } + + private void AssertTestOwnedPath(string path) + { + var root = Path.GetFullPath(temporaryRoot).TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var fullPath = Path.GetFullPath(path); + Assert.IsTrue(fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase), + "Refusing to remove a path outside this test's temporary directory."); + } + + private static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) + { + attributes = default; + return false; + } + catch (DirectoryNotFoundException) + { + attributes = default; + return false; + } + } + } + + private sealed class FailedRecycleBinOperation : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) => + Task.FromResult(RecycleBinItemStatus.Failed); + } +} diff --git a/windows/Hako.Tests/JavaArgumentFileTests.cs b/windows/Hako.Tests/JavaArgumentFileTests.cs new file mode 100644 index 0000000..efac413 --- /dev/null +++ b/windows/Hako.Tests/JavaArgumentFileTests.cs @@ -0,0 +1,254 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class JavaArgumentFileTests +{ + [TestMethod] + public void JavaArgumentFileEscapesQuotesAndWindowsBackslashes() + { + Assert.AreEqual("\"C:\\\\folder with space\\\\file\\\"name\"", + GameProcessRunner.FormatArgumentFileValue("C:\\folder with space\\file\"name")); + Assert.AreEqual("\"\"", GameProcessRunner.FormatArgumentFileValue("")); + Assert.AreEqual("Кириллица", GameProcessRunner.FormatArgumentFileValue("Кириллица")); + } + + [TestMethod] + public async Task RealJavaReadsLongArgumentFileWithPathsAndEscapingWhenAvailable() + { + var java = FindJava("java.exe"); + var javac = FindJava("javac.exe"); + var jarTool = FindJava("jar.exe"); + if (java is null || javac is null || jarTool is null) + { + Assert.Inconclusive("A local JDK with java.exe, javac.exe, and jar.exe is not installed; the test does not download Java."); + } + + using var temporary = new TemporaryDirectory(); + var major = await new JavaLaunchValidation().ValidateAsync(java!, 8, GameArchitecture.X64, TimeSpan.FromSeconds(10)); + // Builds launch bin/javaw.exe: its properties must also reach the redirected probe output. + var javaw = Path.Combine(Path.GetDirectoryName(java!)!, "javaw.exe"); + if (File.Exists(javaw)) + { + Assert.AreEqual(major, await new JavaLaunchValidation().ValidateAsync(javaw, 8, GameArchitecture.X64, TimeSpan.FromSeconds(10))); + } + + var codePage = JavaAnsiEncoding.CurrentCodePage; + var supportsCyrillic = JavaAnsiEncoding.CanRepresent("Кириллица", codePage); + var instanceRoot = Path.Combine(temporary.Root, supportsCyrillic ? "Кириллица space" : "Argument File Space", "backslash folder", "instance"); + var libraryRoot = Path.Combine(instanceRoot, "minecraft", "libraries"); + Directory.CreateDirectory(libraryRoot); + var jarPath = CompileProbe(javac!, jarTool!, libraryRoot); + var relativeJarPath = Path.GetRelativePath(libraryRoot, jarPath).Replace('\\', '/'); + var libraries = Enumerable.Range(0, 650) + .Select(_ => new LibraryInstallation(new MojangDownload(), relativeJarPath)) + .ToArray(); + + var expectedArguments = new List { "argument with spaces", @"C:\back\slash\file.txt", new string('x', 1200) }; + if (supportsCyrillic) + { + expectedArguments.Add("Привет Minecraft"); + } + + var manifest = JsonSerializer.Deserialize(""" + {"id":"argfile-probe","mainClass":"HakoArgumentEcho", + "arguments":{"jvm":["-cp","${classpath}"],"game":[]}} + """, MojangJsonContext.Default.MojangVersionMetadata)!; + var parameters = new InstanceParameters + { + MaximumMemoryMiB = 512, + MinecraftArguments = LaunchArguments.Format(expectedArguments) + }; + var launchPlan = MinecraftLaunchPlan.Build(manifest, instanceRoot, java!, MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Custom, parameters, GameArchitecture.X64, libraries: libraries, javaMajorVersion: major); + var classpath = launchPlan.Arguments[Array.IndexOf(launchPlan.Arguments.ToArray(), "-cp") + 1]; + Assert.IsTrue(classpath.Length > Win32CommandLineLimit, "The fixture must exceed the Windows command-line limit."); + + if (!supportsCyrillic) + { + var unsupported = MinecraftLaunchPlan.Build(manifest, instanceRoot, java!, MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Custom, + parameters with { MinecraftArguments = LaunchArguments.Format(expectedArguments.Append("Кириллица")) }, + GameArchitecture.X64, libraries: libraries, javaMajorVersion: major); + var encodingError = await Assert.ThrowsExactlyAsync(() => + new GameProcessRunner().StartAsync(unsupported, Guid.NewGuid(), instanceRoot)); + Assert.AreEqual(MinecraftLaunchError.ArgumentFileEncoding, encodingError.Error); + } + + var process = await new GameProcessRunner().StartAsync(launchPlan, Guid.NewGuid(), instanceRoot); + Assert.AreEqual(0, await process.Completion.WaitAsync(TimeSpan.FromSeconds(30))); + var actual = File.ReadAllLines(process.LogPath).Select(line => Encoding.UTF8.GetString(Convert.FromBase64String(line))).ToArray(); + + CollectionAssert.AreEqual(expectedArguments, actual[^expectedArguments.Count..]); + Assert.AreEqual(0, Directory.GetFiles(Path.Combine(instanceRoot, "minecraft", ".hako-launch"), "*.args").Length); + Assert.IsFalse(File.Exists(Path.Combine(instanceRoot, "minecraft", ".hako-running.json"))); + if (!supportsCyrillic) + { + Assert.Inconclusive($"The ANSI code page {codePage} can't represent Cyrillic; the Cyrillic instance path was not checked with real Java."); + } + } + + [TestMethod] + public void JavaArgumentFileQuotesApostrophesAndCommentMarkers() + { + Assert.AreEqual("\"O'Neil\"", GameProcessRunner.FormatArgumentFileValue("O'Neil")); + Assert.AreEqual("\"#foo\"", GameProcessRunner.FormatArgumentFileValue("#foo")); + Assert.AreEqual("\"a#b\"", GameProcessRunner.FormatArgumentFileValue("a#b")); + } + + [TestMethod] + public void ArgumentFileUsesStrictAnsiCodePage() + { + string[] arguments = ["-Dname=Привет", "C:\\Игры\\Hako"]; + + var cyrillic = GameProcessRunner.EncodeArgumentFile(arguments, 1251); + CollectionAssert.AreEqual(new byte[] { 0xcf, 0xf0, 0xe8 }, cyrillic[7..10]); + + var western = Assert.ThrowsExactly(() => GameProcessRunner.EncodeArgumentFile(arguments, 1252)); + Assert.AreEqual(MinecraftLaunchError.ArgumentFileEncoding, western.Error); + + var utf8 = GameProcessRunner.EncodeArgumentFile(arguments, JavaAnsiEncoding.Utf8CodePage); + CollectionAssert.AreEqual(new UTF8Encoding(false).GetBytes("-Dname=Привет\r\n\"C:\\\\Игры\\\\Hako\"\r\n"), utf8); + } + + [TestMethod] + public void AnsiEncodingRejectsBestFitSubstitutions() + { + // Windows-1252 best-fit maps U+0100 (Ā) to 'A' and U+221E (∞) to '8'. + Assert.IsFalse(JavaAnsiEncoding.CanRepresent("\u0100", 1252)); + Assert.IsFalse(JavaAnsiEncoding.CanRepresent("\u221e", 1252)); + Assert.IsFalse(JavaAnsiEncoding.CanRepresent("\ud800", JavaAnsiEncoding.Utf8CodePage)); + Assert.IsTrue(JavaAnsiEncoding.CanRepresent("é", 1252)); + } + + [TestMethod] + public void Java8CommandLineMustBeRepresentableInAnsiCodePage() + { + var plan = new MinecraftLaunchPlan("C:\\Java\\bin\\javaw.exe", ["-Dname=Привет", "Main"], "C:\\", 8); + + Assert.AreEqual("-Dname=Привет", GameProcessRunner.PrepareArguments(plan, 1251).Arguments[0]); + var error = Assert.ThrowsExactly(() => GameProcessRunner.PrepareArguments(plan, 1252)); + Assert.AreEqual(MinecraftLaunchError.ArgumentFileEncoding, error.Error); + } + + [TestMethod] + public void MissingNativesFolderUnderNonAnsiRootIsCreatedBeforeShortNamesAreResolved() + { + using var temporary = new TemporaryDirectory(); + var root = Path.Combine(temporary.Root, "Сборка"); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + if (JavaAnsiEncoding.GetShortPath(root) is not { } shortRoot || !JavaAnsiEncoding.CanRepresent(shortRoot, 1252)) + { + Assert.Inconclusive("The temporary volume has no 8.3 names."); + return; + } + + var natives = Path.Combine(root, "minecraft", "natives"); + var plan = new MinecraftLaunchPlan("C:\\Java\\bin\\javaw.exe", ["-Djava.library.path=" + natives, "Main"], root, 17); + Assert.ThrowsExactly(() => GameProcessRunner.PrepareArguments(plan, 1252)); + + GameProcessRunner.EnsureLaunchDirectories(root); + var argument = GameProcessRunner.PrepareArguments(plan, 1252).Arguments[0]; + + Assert.IsTrue(JavaAnsiEncoding.CanRepresent(argument, 1252), argument); + Assert.IsTrue(Directory.Exists(Path.Combine(root, "minecraft", "logs"))); + } + + [TestMethod] + public void UnrepresentablePathsUseShortNamesOrFail() + { + static string? ShortPath(string path) => path switch + { + "C:\\Игры\\lib.jar" => "C:\\B1B2~1\\lib.jar", + "C:\\Игры" => "C:\\B1B2~1", + _ => null + }; + var plan = new MinecraftLaunchPlan("C:\\Java\\bin\\javaw.exe", + ["-cp", "C:\\Игры\\lib.jar;C:\\ascii.jar", "-Djava.library.path=C:\\Игры", "--gameDir", "C:\\Игры", "Main"], "C:\\", 17); + + var arguments = GameProcessRunner.PrepareArguments(plan, 1252, ShortPath).Arguments; + + CollectionAssert.AreEqual(new[] + { + "-cp", "C:\\B1B2~1\\lib.jar;C:\\ascii.jar", "-Djava.library.path=C:\\B1B2~1", "--gameDir", "C:\\B1B2~1", "Main" + }, arguments.ToArray()); + Assert.AreEqual("@C:\\B1B2~1", JavaAnsiEncoding.AdaptPaths("@C:\\Игры", 1252, ShortPath)); + var error = Assert.ThrowsExactly(() => + JavaAnsiEncoding.AdaptPaths("C:\\Нет 8.3", 1252, ShortPath)); + Assert.AreEqual(MinecraftLaunchError.ArgumentFileEncoding, error.Error); + } + + private const int Win32CommandLineLimit = 32767; + + private static string? FindJava(string executableName) + { + var candidates = new List(); + var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME"); + if (!string.IsNullOrWhiteSpace(javaHome)) + { + candidates.Add(Path.Combine(javaHome, "bin", executableName)); + } + + foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)) + { + if (!string.IsNullOrWhiteSpace(directory)) + { + candidates.Add(Path.Combine(directory.Trim('"'), executableName)); + } + } + + return candidates.FirstOrDefault(File.Exists); + } + + private static string CompileProbe(string javac, string jarTool, string root) + { + var sourceDirectory = Path.Combine(root, "source with spaces"); + var classesDirectory = Path.Combine(root, "class output"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(classesDirectory); + var source = Path.Combine(sourceDirectory, "HakoArgumentEcho.java"); + File.WriteAllText(source, """ + import java.nio.charset.StandardCharsets; + import java.util.Base64; + public class HakoArgumentEcho { + public static void main(String[] args) { + for (String arg : args) System.out.println(Base64.getEncoder().encodeToString(arg.getBytes(StandardCharsets.UTF_8))); + } + } + """, new UTF8Encoding(false)); + RunTool(javac, ["-d", classesDirectory, source]); + var jarPath = Path.Combine(root, "probe with spaces.jar"); + RunTool(jarTool, ["cf", jarPath, "-C", classesDirectory, "."]); + return jarPath; + } + + private static void RunTool(string executable, IReadOnlyList arguments) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + Assert.IsTrue(process.Start()); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, stdout + stderr); + } +} diff --git a/windows/Hako.Tests/JavaLaunchValidationTests.cs b/windows/Hako.Tests/JavaLaunchValidationTests.cs new file mode 100644 index 0000000..9b36ec9 --- /dev/null +++ b/windows/Hako.Tests/JavaLaunchValidationTests.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using System.Text; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class JavaLaunchValidationTests +{ + [TestMethod] + public async Task FakeJavaScriptReportsVersionAndPinnedArchitecture() + { + using var temporary = new TemporaryDirectory(); + var script = CreateJavaScript(temporary.Root, "@echo off\necho java.version = 17.0.10\necho os.arch = amd64\n"); + var validation = new JavaLaunchValidation(new ScriptJavaProbe()); + + var major = await validation.ValidateAsync(script, 17, GameArchitecture.X64, TimeSpan.FromSeconds(2)); + + Assert.AreEqual(17, major); + await Assert.ThrowsExactlyAsync(() => + validation.ValidateAsync(script, 18, GameArchitecture.X64, TimeSpan.FromSeconds(2))); + var mismatch = await Assert.ThrowsExactlyAsync(() => + validation.ValidateAsync(script, 17, GameArchitecture.Arm64, TimeSpan.FromSeconds(2))); + Assert.AreEqual(JavaValidationError.ArchitectureMismatch, mismatch.Error); + } + + [TestMethod] + public async Task FakeJavaTimeoutIsReportedAndRemovedJavaOptionsAreNotPassedToProbe() + { + using var temporary = new TemporaryDirectory(); + var script = CreateJavaScript(temporary.Root, "@echo off\n:again\ngoto again\n"); + var probe = new ScriptJavaProbe(); + var validation = new JavaLaunchValidation(probe); + + var error = await Assert.ThrowsExactlyAsync(() => + validation.ValidateAsync(script, 17, GameArchitecture.X64, TimeSpan.FromMilliseconds(150))); + + Assert.AreEqual(JavaValidationError.ProbeTimeout, error.Error); + Assert.IsFalse(probe.LastEnvironment!.Keys.Any(key => key is "JAVA_TOOL_OPTIONS" or "_JAVA_OPTIONS" or "JDK_JAVA_OPTIONS")); + } + + [TestMethod] + public async Task MissingJavaExecutableHasStructuredError() + { + var validation = new JavaLaunchValidation(new ScriptJavaProbe()); + var error = await Assert.ThrowsExactlyAsync(() => + validation.ValidateAsync("missing-java.exe", 8, GameArchitecture.X64)); + + Assert.AreEqual(JavaValidationError.MissingExecutable, error.Error); + Assert.AreEqual("LaunchJavaMissing", error.ResourceKey); + } + + private static string CreateJavaScript(string root, string contents) + { + Directory.CreateDirectory(root); + var path = Path.Combine(root, "fake java.cmd"); + File.WriteAllText(path, contents, Encoding.ASCII); + return path; + } + + private sealed class ScriptJavaProbe : IJavaValidationProbe + { + public IReadOnlyDictionary? LastEnvironment { get; private set; } + + public async Task RunAsync( + string executable, + IReadOnlyList arguments, + IReadOnlyDictionary environment, + TimeSpan timeout, + CancellationToken cancellationToken) + { + LastEnvironment = environment; + var commandInterpreter = Environment.GetEnvironmentVariable("ComSpec") ?? Path.Combine(Environment.SystemDirectory, "cmd.exe"); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = commandInterpreter, + WorkingDirectory = Path.GetDirectoryName(executable)!, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + process.StartInfo.ArgumentList.Add("/d"); + process.StartInfo.ArgumentList.Add("/c"); + process.StartInfo.ArgumentList.Add(executable); + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.StartInfo.Environment.Clear(); + foreach (var (name, value) in environment) + { + if (value is not null) + { + process.StartInfo.Environment[name] = value; + } + } + + Assert.IsTrue(process.Start()); + var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = process.StandardError.ReadToEndAsync(cancellationToken); + using var timeoutSource = new CancellationTokenSource(timeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutSource.Token); + try + { + await process.WaitForExitAsync(linked.Token); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + + if (cancellationToken.IsCancellationRequested) + { + throw; + } + + throw new TimeoutException(); + } + + var output = await stdout + await stderr; + return new JavaProbeResult(process.ExitCode, Encoding.Default.GetBytes(output)); + } + } +} diff --git a/windows/Hako.Tests/LaunchArgumentsTests.cs b/windows/Hako.Tests/LaunchArgumentsTests.cs new file mode 100644 index 0000000..48f422b --- /dev/null +++ b/windows/Hako.Tests/LaunchArgumentsTests.cs @@ -0,0 +1,54 @@ +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class LaunchArgumentsTests +{ + [TestMethod] + public void ParseSupportsQuotedAndEscapedArgumentsWithoutShellExpansion() + { + var parsed = LaunchArguments.Parse("\"path with spaces\" 'single quoted' escaped\\ value $HOME"); + + CollectionAssert.AreEqual(new[] { "path with spaces", "single quoted", "escaped value", "$HOME" }, parsed.ToArray()); + } + + [TestMethod] + public void ParseAllowsEmptyQuotedArgumentAndRejectsUnclosedEscapeOrQuote() + { + CollectionAssert.AreEqual(new[] { "", "next" }, LaunchArguments.Parse("\"\" next").ToArray()); + Assert.AreEqual("LaunchArgumentsInvalid", Assert.ThrowsExactly(() => LaunchArguments.Parse("\"open")).ResourceKey); + Assert.ThrowsExactly(() => LaunchArguments.Parse("tail\\")); + } + + [TestMethod] + public void ApplyingMemoryReplacesAllMaximumsAndClampsInitialHeap() + { + var policy = new JavaMemoryPolicy(16UL * 1024 * 1024 * 1024); + var result = LaunchArguments.ApplyMemory( + ["-Xmx1G", "-Xmx", "2048M", "-XX:MaxHeapSize=2G", "-Xms4G", "-XX:InitialHeapSize=8G", "-Dtest=yes"], + 1024, + GameArchitecture.X64, + policy); + + Assert.AreEqual(1, result.Count(argument => argument.StartsWith("-Xmx", StringComparison.Ordinal))); + CollectionAssert.AreEqual(new[] { "-Xms1024M", "-XX:InitialHeapSize=1024M", "-Dtest=yes", "-Xmx1024M" }, + result.Where(argument => argument is "-Xms1024M" or "-XX:InitialHeapSize=1024M" or "-Dtest=yes" or "-Xmx1024M").ToArray()); + Assert.IsFalse(result.Any(argument => argument.StartsWith("-XX:MaxHeapSize=", StringComparison.Ordinal))); + } + + [TestMethod] + public void MemoryPolicyUsesPhysicalMemoryMinimumStepWarningAndX86Limit() + { + const ulong mib = 1024 * 1024; + var policy = new JavaMemoryPolicy(1024 * mib); + Assert.AreEqual(512, policy.Normalize(0, GameArchitecture.X64)); + Assert.AreEqual(768, policy.Normalize(900, GameArchitecture.X64)); + Assert.IsTrue(policy.Warns(768)); + Assert.IsFalse(policy.Warns(512)); + + var largeMemory = new JavaMemoryPolicy(64UL * 1024 * 1024 * 1024); + Assert.AreEqual(1536, largeMemory.MaximumMiB(GameArchitecture.X86)); + Assert.AreEqual(8192, largeMemory.Normalize(8192, GameArchitecture.X64)); + } +} diff --git a/windows/Hako.Tests/LocalizationResourcesTests.cs b/windows/Hako.Tests/LocalizationResourcesTests.cs index e23dd1d..ee00ecb 100644 --- a/windows/Hako.Tests/LocalizationResourcesTests.cs +++ b/windows/Hako.Tests/LocalizationResourcesTests.cs @@ -1,4 +1,8 @@ using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Hako; +using System.Globalization; using System.Xml.Linq; namespace Hako.Tests; @@ -23,7 +27,9 @@ public void EveryMappedErrorAndStartupNoticeHasRussianAndEnglishText() var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); var keys = Enum.GetValues().Select(error => error.ResourceKey()) - .Concat(Enum.GetValues().Select(notice => notice.ResourceKey())); + .Concat(Enum.GetValues().Select(notice => notice.ResourceKey())) + .Concat(Enum.GetValues().Select(state => state.ResourceKey())) + .Concat(Enum.GetValues().Select(stage => stage.ResourceKey())); foreach (var key in keys) { @@ -32,6 +38,209 @@ public void EveryMappedErrorAndStartupNoticeHasRussianAndEnglishText() } } + [TestMethod] + public void EveryInstallationErrorCodeHasRussianAndEnglishResource() + { + var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); + var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); + var codes = Enum.GetValues().Select(error => $"mojang.{error.ToString().ToLowerInvariant()}") + .Concat(Enum.GetValues().Select(error => $"storage.{error.ToString().ToLowerInvariant()}")) + .Concat(["installation.cancelled", "installation.unexpected"]); + + foreach (var code in codes) + { + var key = InstallationResourceKeys.ErrorResourceKey(code); + if (code != "installation.unexpected") + { + Assert.AreNotEqual("InstallationErrorUnexpected", key, code); + } + CollectionAssert.Contains(russian, key, code); + CollectionAssert.Contains(english, key, code); + } + + Assert.AreEqual("InstallationErrorMojangIntegrity", InstallationResourceKeys.ErrorResourceKey("mojang.integrity")); + Assert.AreEqual("InstallationErrorUnexpected", InstallationResourceKeys.ErrorResourceKey("unrecognized.error")); + } + + [TestMethod] + public void UiExceptionsMapToSpecificLocalizedErrors() + { + var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); + var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); + var keys = Enum.GetValues().Select(error => error.ResourceKey()) + .Concat(Enum.GetValues().Select(error => error.ResourceKey())) + .ToList(); + + foreach (var key in keys) + { + Assert.AreNotEqual("InstallationErrorUnexpected", key); + CollectionAssert.Contains(russian, key); + CollectionAssert.Contains(english, key); + } + + Assert.AreEqual("InstallationErrorStorageDuplicateBuildName", InstallationResourceKeys.ErrorResourceKey( + new InstanceStorageException(InstanceStorageError.DuplicateBuildName, "duplicate"))); + Assert.AreEqual("InstanceRegistryDamaged", InstallationResourceKeys.ErrorResourceKey( + new InstanceRegistryException("damaged"))); + Assert.AreEqual("InstallationErrorMojangUnsupported", InstallationResourceKeys.ErrorResourceKey( + new MojangException(MojangError.Unsupported, "unsupported"))); + Assert.AreEqual("InstallationErrorMojangHttp", InstallationResourceKeys.ErrorResourceKey(new HttpRequestException())); + Assert.AreEqual("InstallationErrorStorageOperationFailed", InstallationResourceKeys.ErrorResourceKey(new IOException("in use"))); + Assert.AreEqual("InstallationErrorUnexpected", InstallationResourceKeys.ErrorResourceKey(new InvalidOperationException())); + } + + [TestMethod] + public void EveryLaunchErrorShownByTheProfileHasRussianAndEnglishText() + { + var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); + var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); + var keys = Enum.GetValues().Select(error => new MinecraftLaunchException(error).ResourceKey) + .Concat(Enum.GetValues().Select(error => new JavaLaunchValidationException(error).ResourceKey)) + .Concat(Enum.GetValues().Select(error => new InstanceRegistryException(error, "registry").ResourceKey)) + // Keys GameLaunchCoordinator assigns directly to GameRunState. + .Concat(["LaunchProcessRecordInvalid", "LaunchProcessStartFailed", "LaunchGameExitCode"]); + + foreach (var key in keys) + { + CollectionAssert.Contains(russian, key); + CollectionAssert.Contains(english, key); + } + } + + [TestMethod] + public void EveryContentConfirmationNoteAndFailureCodeMapsToRussianAndEnglishResources() + { + var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); + var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); + var keys = Enum.GetValues() + .SelectMany(code => new[] + { + ContentResourceKeys.ConfirmationTitle(code), + ContentResourceKeys.ConfirmationMessage(code), + ContentResourceKeys.ConfirmationPrimary(code), + ContentResourceKeys.ConfirmationAlternative(code) + }) + .Concat(Enum.GetValues().Select(ContentResourceKeys.ConfirmationNote)) + .Concat(Enum.GetValues().Select(ContentResourceKeys.Failure)); + + foreach (var key in keys) + { + CollectionAssert.Contains(russian, key); + CollectionAssert.Contains(english, key); + } + } + + [TestMethod] + [DataRow("ru-RU")] + [DataRow("en-US")] + public void ContentFailuresFormatArgumentsAndPreserveRecoveryDiagnostic(string language) + { + var strings = XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Resources", language, "Resources.resw")) + .Root!.Elements("data").ToDictionary(element => (string)element.Attribute("name")!, element => element.Element("value")!.Value); + var failure = new ContentFailure(ContentFailureCode.FabricApiIncompatible, + new Dictionary { ["version"] = "0.100.8", ["loader"] = ">=0.16.3", ["java"] = ">=21" }); + + var message = ContentResourceKeys.FormatFailureDetails(failure, key => strings[key]); + StringAssert.Contains(message, "0.100.8"); + StringAssert.Contains(message, ">=0.16.3"); + StringAssert.Contains(message, ">=21"); + Assert.IsFalse(message.Contains('{')); + Assert.AreEqual(ContentResourceKeys.FormatFailure(failure, key => strings[key]), message); + + var recovery = new ContentFailure(ContentFailureCode.ContentOperationFailed, + new Dictionary { ["diagnostic"] = @"Recovery backup: C:\isolated\.hako-backup-123" }); + Assert.AreEqual(strings["ContentFailureOperationFailed"] + "\n" + strings["ContentDiagnosticLabel"] + " " + recovery.Arguments["diagnostic"], + ContentResourceKeys.FormatFailureDetails(recovery, key => strings[key])); + var bulk = new ContentFailure(ContentFailureCode.BulkUpdateFailed, + new Dictionary { ["failureCount"] = "7" }); + StringAssert.Contains(ContentResourceKeys.FormatFailure(bulk, key => strings[key]), "7"); + } + + [TestMethod] + public void EveryWorldFailureMetadataAndGameTypeMappingHasRussianAndEnglishText() + { + var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); + var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); + var keys = Enum.GetValues().Select(WorldResourceKeys.Failure) + .Concat(Enum.GetValues().Select(WorldResourceKeys.MetadataError)) + .Concat(new int?[] { null, 0, 1, 2, 3, 99 }.Select(WorldResourceKeys.GameType)); + + foreach (var key in keys) + { + CollectionAssert.Contains(russian, key); + CollectionAssert.Contains(english, key); + } + } + + [TestMethod] + public void EveryBackupFailureAndDiagnosticFormatHasRussianAndEnglishResources() + { + var russian = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "ru-RU", "Resources.resw")); + var english = ReadResourceNames(Path.Combine(AppContext.BaseDirectory, "Resources", "en-US", "Resources.resw")); + var keys = Enum.GetValues().Select(BackupResourceKeys.FailureKey) + .Concat([ + "BackupDiagnosticFormat", "BackupDiagnosticPathFormat", + "InstanceDuplicate", "InstanceDuplicating", "InstanceBackup", "InstanceBackupInProgress", + "InstanceBackupSucceededMessage", "WorldsBackup", "WorldBackupInProgress", + "WorldBackupSucceededMessage", "BackupSucceededTitle", "BackupShowInExplorer", + "ScreenshotsTitle", "ScreenshotsRefresh", "ScreenshotsOpenFolder", "ScreenshotsDeleteSelected", + "ScreenshotsLoading", "ScreenshotsOperationInProgress", "ScreenshotsEmpty", "ScreenshotsCountFormat", + "ScreenshotsSelectionFormat", "ScreenshotsOperationBusy", "ScreenshotsActions", "ScreenshotsOpen", + "ScreenshotsOpenFailed", "ScreenshotsDelete", "ScreenshotsDeleteTitle", "ScreenshotsDeleteMessage", + "ScreenshotsDeleteConfirm", "ScreenshotsDeletedFormat", "ScreenshotsDeletePartialFormat", + "ScreenshotsActionFailed" + ]); + + foreach (var key in keys) + { + CollectionAssert.Contains(russian, key); + CollectionAssert.Contains(english, key); + } + } + + [TestMethod] + [DataRow("ru-RU")] + [DataRow("en-US")] + public void BackupFailuresPreserveDiagnosticAndDestinationPath(string language) + { + var strings = XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Resources", language, "Resources.resw")) + .Root!.Elements("data").ToDictionary(element => (string)element.Attribute("name")!, + element => element.Element("value")!.Value); + + var message = BackupResourceKeys.FormatFailure( + new BackupFailure(BackupFailureReason.DestinationConflict, "the timestamp is occupied", @"C:\isolated\backups\Build.hakobackup"), + key => strings[key]); + + StringAssert.Contains(message, strings["BackupDestinationConflict"]); + StringAssert.Contains(message, "the timestamp is occupied"); + StringAssert.Contains(message, @"C:\isolated\backups\Build.hakobackup"); + Assert.IsFalse(message.Contains('{')); + } + + [TestMethod] + [DataRow("ru-RU")] + [DataRow("en-US")] + public void WorldMessagesAndUnknownDisplayValuesFormatArguments(string language) + { + var strings = XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Resources", language, "Resources.resw")) + .Root!.Elements("data").ToDictionary(element => (string)element.Attribute("name")!, element => element.Element("value")!.Value); + var title = WorldResourceKeys.FormatMessage("WorldDuplicateTitle", key => strings[key], "Survival"); + var message = WorldResourceKeys.FormatMessage("WorldDuplicateMessage", key => strings[key], "Survival", "Preview build"); + var completed = WorldResourceKeys.FormatMessage("WorldDuplicateSucceeded", key => strings[key], "Survival", "Survival 2"); + var culture = CultureInfo.GetCultureInfo(language); + var size = WorldResourceKeys.FormatSize(1_572_864, culture, key => strings[key]); + + StringAssert.Contains(title, "Survival"); + StringAssert.Contains(message, "Preview build"); + StringAssert.Contains(completed, "Survival 2"); + Assert.IsFalse(title.Contains('{')); + Assert.IsFalse(message.Contains('{')); + Assert.IsFalse(completed.Contains('{')); + Assert.AreEqual(strings["WorldSizeUnknown"], WorldResourceKeys.FormatSize(null, culture, key => strings[key])); + StringAssert.Contains(size, language == "ru-RU" ? "1,5" : "1.5"); + Assert.AreEqual(strings["WorldLastPlayedUnknown"], WorldResourceKeys.FormatLastPlayed(null, culture, key => strings[key])); + } + private static List ReadResourceNames(string path) => XDocument.Load(path) .Root! .Elements("data") diff --git a/windows/Hako.Tests/MicrosoftDeviceAuthTests.cs b/windows/Hako.Tests/MicrosoftDeviceAuthTests.cs index f951a07..c0d8fc9 100644 --- a/windows/Hako.Tests/MicrosoftDeviceAuthTests.cs +++ b/windows/Hako.Tests/MicrosoftDeviceAuthTests.cs @@ -66,6 +66,135 @@ public async Task PendingResponseWaitsAtIntervalThenReturnsTokenAndDisplayEmail( Assert.AreEqual(2, handler.RequestCount); } + [TestMethod] + public async Task MinecraftRefreshPersistsRotatedTokenBeforeXboxAndConnectsProfile() + { + var persistedRefreshToken = ""; + var handler = new CallbackHttpHandler(async (request, cancellationToken) => + { + var uri = request.RequestUri?.AbsoluteUri; + if (uri == "https://login.microsoftonline.com/consumers/oauth2/v2.0/token") + { + var form = TestData.ParseForm(await request.Content!.ReadAsStringAsync(cancellationToken)); + Assert.AreEqual("refresh_token", form["grant_type"]); + Assert.AreEqual("old-refresh", form["refresh_token"]); + return TestData.Json(HttpStatusCode.OK, """{"access_token":"new-ms-access","refresh_token":"rotated-refresh"}"""); + } + + if (uri == "https://user.auth.xboxlive.com/user/authenticate") + { + Assert.AreEqual("rotated-refresh", persistedRefreshToken); + return TestData.Json(HttpStatusCode.OK, """{"Token":"user-token"}"""); + } + + return uri switch + { + "https://xsts.auth.xboxlive.com/xsts/authorize" => TestData.Json(HttpStatusCode.OK, + """{"Token":"minecraft-xsts","DisplayClaims":{"xui":[{"uhs":"user-hash"}]}}"""), + "https://api.minecraftservices.com/authentication/login_with_xbox" => TestData.Json(HttpStatusCode.OK, + """{"access_token":"minecraft-access","expires_in":3600}"""), + "https://api.minecraftservices.com/minecraft/profile" => TestData.Json(HttpStatusCode.OK, + """{"id":"profile-id","name":"Player"}"""), + _ => throw new AssertFailedException("Unexpected refresh endpoint."), + }; + }); + using var http = new HttpClient(handler); + var auth = new MicrosoftDeviceAuth(http, new ManualTimeProvider()); + + var connection = await auth.ConnectMinecraftAsync("old-refresh", (token, _) => + { + Assert.AreEqual(1, handler.RequestCount); + persistedRefreshToken = token; + return Task.CompletedTask; + }, CancellationToken.None); + + Assert.AreEqual("profile-id", connection.ProfileId); + Assert.AreEqual("Player", connection.ProfileName); + Assert.AreEqual("minecraft-access", connection.AccessToken); + Assert.AreEqual(5, handler.RequestCount); + Assert.IsFalse(connection.ToString().Contains("minecraft-access", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task MinecraftRefreshKeepsRotatedTokenWhenXboxFailsAfterRefresh() + { + var rotated = ""; + var handler = new CallbackHttpHandler((request, _) => + { + if (request.RequestUri?.AbsoluteUri == "https://login.microsoftonline.com/consumers/oauth2/v2.0/token") + { + return Task.FromResult(TestData.Json(HttpStatusCode.OK, + """{"access_token":"new-access","refresh_token":"rotated"}""")); + } + + throw new HttpRequestException("simulated network failure"); + }); + using var http = new HttpClient(handler); + var auth = new MicrosoftDeviceAuth(http, TimeProvider.System); + + await Assert.ThrowsExactlyAsync(() => auth.ConnectMinecraftAsync("old", (token, _) => + { + rotated = token; + return Task.CompletedTask; + }, CancellationToken.None)); + + Assert.AreEqual("rotated", rotated); + Assert.AreEqual(2, handler.RequestCount); + } + + [TestMethod] + public async Task RejectedRefreshTokenHasSpecificError() + { + var handler = new CallbackHttpHandler((_, _) => Task.FromResult( + TestData.Json(HttpStatusCode.BadRequest, """{"error":"invalid_grant"}"""))); + using var http = new HttpClient(handler); + var auth = new MicrosoftDeviceAuth(http, TimeProvider.System); + + var error = await Assert.ThrowsExactlyAsync(() => auth.ConnectMinecraftAsync( + "revoked", (_, _) => Task.CompletedTask, CancellationToken.None)); + + Assert.AreEqual(AuthErrorKind.RefreshTokenRejected, error.Kind); + Assert.AreEqual("MinecraftRefreshTokenRejected", error.Kind.ResourceKey()); + Assert.AreEqual(1, handler.RequestCount); + } + + [TestMethod] + public async Task StrictRefreshSeparatesMissingMinecraftLicenseAndProfile() + { + foreach (var (status, endpoint, expected) in new[] + { + (HttpStatusCode.Forbidden, "https://api.minecraftservices.com/authentication/login_with_xbox", AuthErrorKind.MinecraftLicenseMissing), + (HttpStatusCode.NotFound, "https://api.minecraftservices.com/minecraft/profile", AuthErrorKind.MinecraftProfileMissing) + }) + { + var rotated = false; + var handler = new CallbackHttpHandler((request, _) => Task.FromResult(request.RequestUri?.AbsoluteUri switch + { + "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" => TestData.Json(HttpStatusCode.OK, + """{"access_token":"new-access","refresh_token":"rotated"}"""), + "https://user.auth.xboxlive.com/user/authenticate" => TestData.Json(HttpStatusCode.OK, """{"Token":"user-token"}"""), + "https://xsts.auth.xboxlive.com/xsts/authorize" => TestData.Json(HttpStatusCode.OK, + """{"Token":"xsts","DisplayClaims":{"xui":[{"uhs":"hash"}]}}"""), + "https://api.minecraftservices.com/authentication/login_with_xbox" when endpoint.EndsWith("login_with_xbox", StringComparison.Ordinal) + && status == HttpStatusCode.Forbidden => TestData.Json(status, "{}"), + "https://api.minecraftservices.com/authentication/login_with_xbox" => TestData.Json(HttpStatusCode.OK, + """{"access_token":"minecraft","expires_in":3600}"""), + _ => TestData.Json(status, "{}") + })); + using var http = new HttpClient(handler); + var auth = new MicrosoftDeviceAuth(http, TimeProvider.System); + + var error = await Assert.ThrowsExactlyAsync(() => auth.ConnectMinecraftAsync("old", (token, _) => + { + rotated = token == "rotated"; + return Task.CompletedTask; + }, CancellationToken.None)); + + Assert.IsTrue(rotated); + Assert.AreEqual(expected, error.Kind); + } + } + [TestMethod] public async Task SlowDownAddsFiveSecondsToFollowingPolls() { diff --git a/windows/Hako.Tests/MinecraftInstallationTests.cs b/windows/Hako.Tests/MinecraftInstallationTests.cs new file mode 100644 index 0000000..4bd0bae --- /dev/null +++ b/windows/Hako.Tests/MinecraftInstallationTests.cs @@ -0,0 +1,1638 @@ +using System.Collections.Concurrent; +using System.IO.Compression; +using System.Net; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class MinecraftInstallationTests +{ + [TestMethod] + [DataRow("1.6.4.json", 8, "X64", "Arm64", "jre-legacy", false)] + [DataRow("1.6.4.json", 8, "X86", "X86", "jre-legacy", false)] + [DataRow("1.19.json", 17, "Arm64", "Arm64", "java-runtime-gamma", true)] + [DataRow("22w18a.json", 17, "X64", "Arm64", "java-runtime-gamma", true)] + public async Task MojangFixturesPrepareLegacyModernAndSnapshotMetadata( + string fixtureName, + int javaMajor, + string architectureName, + string hostMachineName, + string javaComponent, + bool hasModernArguments) + { + using var temporary = new TestDirectory(); + var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Mojang", fixtureName); + var manifestBytes = await File.ReadAllBytesAsync(fixturePath); + using var document = JsonDocument.Parse(manifestBytes); + var versionId = document.RootElement.GetProperty("id").GetString()!; + var runtimeManifest = Encoding.UTF8.GetBytes("{\"files\":{\"bin/javaw.exe\":{\"type\":\"file\",\"downloads\":{\"raw\":{\"url\":\"https://test/javaw.exe\",\"sha1\":\"" + Hash("java") + "\",\"size\":4}}}}}"); + var runtimeIndexBytes = RewriteRuntimeFixture(await File.ReadAllBytesAsync( + Path.Combine(AppContext.BaseDirectory, "Fixtures", "Mojang", "java-runtimes.json")), runtimeManifest); + var versionUrl = $"https://test/{versionId}.json"; + var handler = new PayloadHandler(new Dictionary(StringComparer.Ordinal) + { + [versionUrl] = manifestBytes, + [MojangClient.RuntimeIndexUrl] = runtimeIndexBytes, + ["https://test/runtime-manifest.json"] = runtimeManifest + }); + using var client = new MojangClient(new HttpClient(handler)); + var hostMachine = Enum.Parse(hostMachineName); + var prepared = await client.PrepareAsync(new MojangVersionEntry + { + Id = versionId, + Type = "release", + Url = versionUrl, + Sha1 = Hash(manifestBytes) + }, new HostArchitecture(hostMachine, Amd64UserEnabled: hostMachine == HostMachineArchitecture.Arm64)); + + Assert.AreEqual(versionId, prepared.Manifest.Id); + Assert.AreEqual(javaMajor, prepared.Manifest.Java.MajorVersion); + Assert.AreEqual(javaMajor, prepared.Runtime.MajorVersion); + Assert.AreEqual(architectureName, prepared.Architecture.ToString()); + Assert.IsTrue(prepared.Libraries.Count > 0); + Assert.AreEqual(javaComponent, prepared.Manifest.Java.Component); + Assert.AreEqual(hasModernArguments, prepared.Manifest.Arguments is not null); + Assert.AreEqual(!hasModernArguments, !string.IsNullOrWhiteSpace(prepared.Manifest.MinecraftArguments)); + } + + [TestMethod] + public async Task CatalogAsyncReadsTheOfficialVersionManifestWithHakoUserAgent() + { + const string catalogJson = "{\"latest\":{\"release\":\"1.20.4\",\"snapshot\":\"24w01a\"},\"versions\":[{\"id\":\"1.20.4\",\"type\":\"release\",\"url\":\"https://test/version.json\",\"time\":\"2024-01-01T00:00:00+00:00\",\"releaseTime\":\"2024-01-01T00:00:00+00:00\",\"sha1\":\"0123456789012345678901234567890123456789\",\"complianceLevel\":1}]}"; + var handler = new PayloadHandler(new Dictionary(StringComparer.Ordinal) + { + [MojangClient.CatalogUrl] = Encoding.UTF8.GetBytes(catalogJson) + }); + using var http = new HttpClient(handler); + using var client = new MojangClient(http); + + var catalog = await client.CatalogAsync(); + + Assert.AreEqual("1.20.4", catalog.Latest.Release); + Assert.AreEqual("1.20.4", catalog.Versions.Single().Id); + Assert.IsTrue(handler.UserAgents.Single().StartsWith("Hako/", StringComparison.Ordinal)); + } + + [TestMethod] + public void RulesMatchWindowsArchitectureVersionAndFeatures() + { + var rule = new MojangRule + { + Action = "allow", + Os = new MojangOperatingSystem { Name = "windows", Arch = "^(amd64|x86_64)$" } + }; + var featureRule = new MojangRule { Action = "allow", Features = new Dictionary { ["has_custom_resolution"] = false } }; + + Assert.IsTrue(rule.Matches(GameArchitecture.X64)); + Assert.IsFalse(rule.Matches(GameArchitecture.Arm64)); + Assert.IsFalse(new MojangRule { Action = "allow", Os = new MojangOperatingSystem { Name = "osx" } }.Matches(GameArchitecture.X64)); + Assert.IsTrue(featureRule.Matches(GameArchitecture.X64)); + Assert.IsTrue(new MojangRule + { + Action = "allow", + Os = new MojangOperatingSystem { Name = "windows", Version = "^" + Environment.OSVersion.Version.Major + "\\." } + }.Matches(GameArchitecture.X64)); + Assert.IsFalse(new MojangRule + { + Action = "allow", + Os = new MojangOperatingSystem { Arch = "^amd64$" } + }.Matches(GameArchitecture.Arm64)); + } + + [TestMethod] + [DataRow("1.19.json")] + [DataRow("22w18a.json")] + public async Task X86RuleFromMojangFixturesMatchesOnlyX86(string fixtureName) + { + var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Mojang", fixtureName); + var manifest = JsonSerializer.Deserialize(await File.ReadAllBytesAsync(fixturePath), MojangJsonContext.Default.MojangVersionMetadata)!; + var argument = manifest.Arguments!["jvm"].Single(value => value.Values.Contains("-Xss1M")); + var rule = argument.Rules!.Single(value => value.Os?.Arch == "x86"); + + Assert.IsTrue(rule.Matches(GameArchitecture.X86)); + Assert.IsFalse(rule.Matches(GameArchitecture.X64)); + Assert.IsFalse(rule.Matches(GameArchitecture.Arm64)); + } + + [TestMethod] + public void LegacyNativeArchSubstitutionSelectsTheX86Classifier() + { + var manifest = new MojangVersionMetadata + { + Libraries = + [ + new MojangLibrary + { + Name = "org.test:legacy:1.0", + Natives = new Dictionary { ["windows"] = "natives-windows-${arch}" }, + Downloads = new MojangLibraryDownloads + { + Classifiers = new Dictionary + { + ["natives-windows-32"] = Download("legacy-32.jar") + } + } + } + ] + }; + + var libraries = MojangCompatibility.Libraries(manifest, GameArchitecture.X86); + + Assert.AreEqual("legacy-32.jar", libraries.Single().Path); + } + + [TestMethod] + public void LibrariesRetainManifestOrderWhenNativeLibrariesAreSelected() + { + var manifest = new MojangVersionMetadata + { + Libraries = + [ + new MojangLibrary { Name = "org.test:first:1.0", Downloads = new MojangLibraryDownloads { Artifact = Download("first.jar") } }, + new MojangLibrary + { + Name = "org.test:native:1.0", + Natives = new Dictionary { ["windows"] = "natives-windows-${arch}" }, + Downloads = new MojangLibraryDownloads + { + Classifiers = new Dictionary + { + ["natives-windows-64"] = Download("native.jar") + } + } + }, + new MojangLibrary { Name = "org.test:last:1.0", Downloads = new MojangLibraryDownloads { Artifact = Download("last.jar") } } + ] + }; + + var libraries = MojangCompatibility.Libraries(manifest, GameArchitecture.X64); + + CollectionAssert.AreEqual(new[] { "first.jar", "native.jar", "last.jar" }, libraries.Select(library => library.Path).ToArray()); + } + + [TestMethod] + public void LegacyWindowsNativeWithoutSuffixIsSupportedOnX86() + { + var manifest = new MojangVersionMetadata + { + Libraries = + [ + new MojangLibrary + { + Name = "org.lwjgl:lwjgl:2.9.1:natives-windows", + Downloads = new MojangLibraryDownloads { Artifact = Download("lwjgl-windows.jar") } + } + ] + }; + + var libraries = MojangCompatibility.Libraries(manifest, GameArchitecture.X86); + + Assert.AreEqual("lwjgl-windows.jar", libraries.Single().Path); + Assert.IsTrue(libraries.Single().IsNative); + } + + [TestMethod] + [DataRow("8u74-b01", 8)] + [DataRow("1.8.0_202", 8)] + [DataRow("17.0.15", 17)] + [DataRow("25.0.1", 25)] + public void JavaRuntimeVersionsUseMojangMajorVersionFormats(string name, int major) + { + var runtime = new MojangJavaRuntimePackage { Version = new MojangRuntimeVersion { Name = name } }; + + Assert.AreEqual(major, runtime.MajorVersion); + } + + [TestMethod] + public async Task CompleteArm64RuntimeAndNativeSetIsPreferred() + { + var prepared = await PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.Arm64), ("lwjgl", GameArchitecture.X64)], + [GameArchitecture.Arm64, GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.Arm64, true)); + + Assert.AreEqual(GameArchitecture.Arm64, prepared.Architecture); + Assert.AreEqual("windows-arm64", prepared.Architecture.MojangRuntimeKey()); + Assert.IsTrue(prepared.Libraries.Single(library => library.IsNative).Path.Contains("arm64", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task MissingArm64JavaFallsBackAsACompleteX64Set() + { + var prepared = await PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.Arm64), ("lwjgl", GameArchitecture.X64)], + [GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.Arm64, true)); + + Assert.AreEqual(GameArchitecture.X64, prepared.Architecture); + Assert.IsTrue(prepared.Libraries.Single(library => library.IsNative).Path.Contains("natives-windows", StringComparison.Ordinal)); + Assert.IsFalse(prepared.Libraries.Single(library => library.IsNative).Path.Contains("arm64", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task MissingOneArm64NativeGroupFallsBackForTheWholeInstance() + { + var prepared = await PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.Arm64), ("lwjgl", GameArchitecture.X64), ("joml", GameArchitecture.X64)], + [GameArchitecture.Arm64, GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.Arm64, true)); + + Assert.AreEqual(GameArchitecture.X64, prepared.Architecture); + Assert.AreEqual(2, prepared.Libraries.Count(library => library.IsNative)); + Assert.IsTrue(prepared.Libraries.Where(library => library.IsNative).All(library => !library.Path.Contains("arm64", StringComparison.Ordinal))); + } + + [TestMethod] + public async Task IncompleteArm64AndX64SetsAreUnsupported() + { + var exception = await Assert.ThrowsExactlyAsync(() => PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.Arm64), ("joml", GameArchitecture.X64)], + [GameArchitecture.Arm64, GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.Arm64, true))); + + Assert.AreEqual(MojangError.Unsupported, exception.Error); + } + + [TestMethod] + public async Task X64FallbackRequiresAmd64EmulationAndPinnedArchitectureIsRetained() + { + var unsupported = await Assert.ThrowsExactlyAsync(() => PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.X64)], + [GameArchitecture.Arm64, GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.Arm64, false))); + Assert.AreEqual(MojangError.Unsupported, unsupported.Error); + + var pinned = await PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.Arm64), ("lwjgl", GameArchitecture.X64)], + [GameArchitecture.Arm64, GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.Arm64, true), + GameArchitecture.X64); + Assert.AreEqual(GameArchitecture.X64, pinned.Architecture); + } + + [TestMethod] + public async Task X64AndX86HostsUseOnlyTheirMatchingGameArchitecture() + { + var x64 = await PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.Arm64), ("lwjgl", GameArchitecture.X64)], + [GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.X64, false)); + var x86 = await PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.X86)], + [GameArchitecture.X86], + new HostArchitecture(HostMachineArchitecture.X86, false)); + + Assert.AreEqual(GameArchitecture.X64, x64.Architecture); + Assert.AreEqual(GameArchitecture.X86, x86.Architecture); + Assert.AreEqual("windows-x86", x86.Architecture.MojangRuntimeKey()); + } + + [TestMethod] + public async Task MissingVersionFilesAreUnsupportedButHttpFailuresRemainHttpErrors() + { + const string versionUrl = "https://test/no-client.json"; + var noClient = Encoding.UTF8.GetBytes("{\"id\":\"no-client\",\"downloads\":{},\"libraries\":[]}"); + var handler = new PayloadHandler(new Dictionary(StringComparer.Ordinal) { [versionUrl] = noClient }); + using var client = new MojangClient(new HttpClient(handler)); + var unsupported = await Assert.ThrowsExactlyAsync(() => client.PrepareAsync( + new MojangVersionEntry { Id = "no-client", Url = versionUrl, Sha1 = Hash(noClient) }, + new HostArchitecture(HostMachineArchitecture.X64, false))); + Assert.AreEqual(MojangError.Unsupported, unsupported.Error); + + var unavailable = await Assert.ThrowsExactlyAsync(() => client.CatalogAsync()); + Assert.AreEqual(MojangError.Http, unavailable.Error); + } + + [TestMethod] + public async Task InstallerWritesVerifiedFilesAndCopiesVirtualAndMappedAssets() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: true); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var result = await installer.InstallAsync(fixture.Prepared, temporary.Path); + + Assert.AreEqual(GameArchitecture.X64, result.Architecture); + Assert.AreEqual("bin/javaw.exe", result.JavaExecutable); + Assert.AreEqual("bin/javaw.exe", new GameInstance().JavaExecutable); + Assert.AreEqual(17, result.JavaMajorVersion); + Assert.AreEqual("javaw content", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "java", "bin", "javaw.exe"))); + Assert.AreEqual("client content", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "minecraft", "versions", "install-test", "install-test.jar"))); + Assert.AreEqual("library content", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "minecraft", "libraries", "org", "test", "library.jar"))); + Assert.AreEqual("asset content", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "minecraft", "assets", "objects", Hash("asset content")[..2], Hash("asset content")))); + Assert.AreEqual("asset content", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "minecraft", "assets", "virtual", "test-index", "lang", "en_us.json"))); + Assert.AreEqual("asset content", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "minecraft", "resources", "lang", "en_us.json"))); + Assert.AreEqual("log config", await File.ReadAllTextAsync(Path.Combine(temporary.Path, "minecraft", "assets", "log_configs", "client.xml"))); + Assert.IsTrue(File.Exists(Path.Combine(temporary.Path, "minecraft", "versions", "install-test", "install-test.json"))); + Assert.IsTrue(File.Exists(Path.Combine(temporary.Path, "minecraft", "assets", "indexes", "test-index.json"))); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Path, "minecraft", "mods"))); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Path, "minecraft", "resourcepacks"))); + } + + [TestMethod] + public async Task ValidFilesAreReusedAndCorruptFilesAreDownloadedAgain() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + var clientPath = Path.Combine(temporary.Path, "minecraft", "versions", "install-test", "install-test.jar"); + + await installer.InstallAsync(fixture.Prepared, temporary.Path); + var firstCount = fixture.Handler.RequestCount("https://test/client.jar"); + await installer.InstallAsync(fixture.Prepared, temporary.Path); + Assert.AreEqual(firstCount, fixture.Handler.RequestCount("https://test/client.jar")); + + await File.WriteAllTextAsync(clientPath, "corrupted"); + await installer.InstallAsync(fixture.Prepared, temporary.Path); + + Assert.AreEqual(firstCount + 1, fixture.Handler.RequestCount("https://test/client.jar")); + Assert.AreEqual("client content", await File.ReadAllTextAsync(clientPath)); + } + + [TestMethod] + [DataRow("sha1")] + [DataRow("size")] + [DataRow("extra-bytes")] + public async Task InstallerRejectsBadSha1OrSizeWithoutPublishingPartialFiles(string failure) + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture( + includeAssets: false, + badClientSha1: failure == "sha1", + badClientSize: failure == "size", + oversizedClient: failure == "extra-bytes"); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual(MojangError.Integrity, exception.Error); + Assert.IsFalse(File.Exists(Path.Combine(temporary.Path, "minecraft", "versions", "install-test", "install-test.jar"))); + Assert.AreEqual(0, Directory.EnumerateFiles(temporary.Path, "*.tmp", SearchOption.AllDirectories).Count()); + } + + [TestMethod] + public async Task CancellationRemovesTemporaryDownloads() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false, blockDownloads: true); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + using var cancellation = new CancellationTokenSource(); + var install = installer.InstallAsync(fixture.Prepared, temporary.Path, cancellationToken: cancellation.Token); + + await fixture.Handler.FirstBodyRead.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + await Assert.ThrowsAsync(() => install); + + Assert.AreEqual(0, Directory.EnumerateFiles(temporary.Path, "*.tmp", SearchOption.AllDirectories).Count()); + } + + [TestMethod] + public async Task HttpFailureCancelsBlockedDownloadAndPreservesHttpError() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture( + includeAssets: false, + failingUrl: "https://test/client.jar", + blockedUrl: "https://test/javaw.exe"); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var installation = installer.InstallAsync(fixture.Prepared, temporary.Path); + var exception = await Assert.ThrowsExactlyAsync(() => installation.WaitAsync(TimeSpan.FromSeconds(5))); + await fixture.Handler.BlockingReadCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreEqual(MojangError.Http, exception.Error); + Assert.AreEqual(500, exception.StatusCode); + Assert.AreEqual(0, Directory.EnumerateFiles(temporary.Path, "*.tmp", SearchOption.AllDirectories).Count()); + } + + [TestMethod] + public async Task UserCancellationTakesPriorityOverAFailFastDownloadError() + { + using var temporary = new TestDirectory(); + using var cancellation = new CancellationTokenSource(); + var fixture = CreateInstallFixture( + includeAssets: false, + failingUrl: "https://test/client.jar", + blockedUrl: "https://test/javaw.exe", + cancelOnFailFast: cancellation); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + var installation = installer.InstallAsync(fixture.Prepared, temporary.Path, cancellationToken: cancellation.Token); + + var exception = await Assert.ThrowsAsync(() => installation.WaitAsync(TimeSpan.FromSeconds(5))); + await fixture.Handler.BlockingReadCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreEqual(cancellation.Token, exception.CancellationToken); + } + + [TestMethod] + public async Task InstallerLimitsConcurrentDownloadsToFour() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false, gateDownloads: true); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + var installation = installer.InstallAsync(fixture.Prepared, temporary.Path); + + await fixture.Handler.FourConcurrentResponsesStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.AreEqual(4, fixture.Handler.MaximumConcurrentResponses); + fixture.Handler.ReleaseGatedResponses(); + await installation; + + Assert.IsTrue(fixture.Handler.MaximumConcurrentResponses <= 4); + } + + [TestMethod] + public async Task InstallationProgressIsMonotonicAndReachesTheTotal() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + var progress = new RecordingProgress(); + + await installer.InstallAsync(fixture.Prepared, temporary.Path, progress); + + var reports = progress.Reports; + Assert.IsTrue(reports.Length > 0); + for (var index = 1; index < reports.Length; index++) + { + Assert.IsTrue(reports[index].CompletedBytes >= reports[index - 1].CompletedBytes); + } + + Assert.AreEqual(InstallationProgressStage.Complete, reports[^1].Stage); + Assert.AreEqual(reports[^1].TotalBytes, reports[^1].CompletedBytes); + } + + [TestMethod] + public async Task InstallPreparationRunsOffTheCallingThread() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + var progress = new ThreadRecordingProgress(); + var callingThread = 0; + Task? installation = null; + var caller = new Thread(() => + { + callingThread = Environment.CurrentManagedThreadId; + installation = installer.InstallAsync(fixture.Prepared, temporary.Path, progress); + }); + caller.Start(); + caller.Join(); + + await installation!; + + Assert.AreNotEqual(callingThread, progress.FirstThreadId); + } + + [TestMethod] + public async Task MojangBodyReadIdleTimeoutIsAHttpFailure() + { + var stalledRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var http = new HttpClient(new StallingBodyHandler(stalledRead)); + using var client = new MojangClient(http, readIdleTimeout: TimeSpan.FromMilliseconds(100)); + + var exception = await Assert.ThrowsExactlyAsync(() => + client.GetBytesAsync("https://test/slow-body", CancellationToken.None, expectedSize: 100)); + await stalledRead.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreEqual(MojangError.Http, exception.Error); + } + + [TestMethod] + public async Task MojangBodyReadIOExceptionIsAHttpFailure() + { + using var http = new HttpClient(new StreamHandler(() => new IOExceptionAfterChunkStream(Encoding.UTF8.GetBytes("partial")))); + using var client = new MojangClient(http); + + var exception = await Assert.ThrowsExactlyAsync(() => + client.GetBytesAsync("https://test/broken-body", CancellationToken.None, expectedSize: 100)); + + Assert.AreEqual(MojangError.Http, exception.Error); + Assert.IsInstanceOfType(exception.InnerException); + } + + [TestMethod] + public async Task MojangReadAsStreamFailureIsAHttpFailure() + { + using var http = new HttpClient(new StreamOpenFailureHandler()); + using var client = new MojangClient(http); + + var exception = await Assert.ThrowsExactlyAsync(() => + client.GetBytesAsync("https://test/unreadable-body", CancellationToken.None)); + + Assert.AreEqual(MojangError.Http, exception.Error); + Assert.IsInstanceOfType(exception.InnerException); + } + + [TestMethod] + public async Task InstallerReadAsStreamFailureIsAHttpFailure() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false, streamOpenFailureUrl: "https://test/client.jar"); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual(MojangError.Http, exception.Error); + Assert.IsInstanceOfType(exception.InnerException); + } + + [TestMethod] + public void InstallerStaleTemporaryCleanupObservesCancellation() + { + using var temporary = new TestDirectory(); + var javaDirectory = Path.Combine(temporary.Path, "java"); + Directory.CreateDirectory(javaDirectory); + var staleTemporaryFile = Path.Combine(javaDirectory, ".hako-stale.tmp"); + File.WriteAllText(staleTemporaryFile, "still writing"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.ThrowsExactly(() => + MinecraftInstaller.CleanStaleTemporaryFiles(temporary.Path, cancellation.Token)); + Assert.IsTrue(File.Exists(staleTemporaryFile)); + } + + [TestMethod] + public async Task MojangIdleTimeoutAllowsSlowResponsesWithRegularChunks() + { + var clock = new ManualTimeProvider(); + var stream = new ControlledChunkStream(Encoding.UTF8.GetBytes("live")); + using var http = new HttpClient(new StreamHandler(() => stream)); + using var client = new MojangClient(http, readIdleTimeout: TimeSpan.FromSeconds(10), timeProvider: clock); + + var response = client.GetBytesAsync("https://test/live-body", CancellationToken.None, expectedSize: 4); + for (var index = 0; index < 4; index++) + { + await stream.ReadStarted(index).WaitAsync(TimeSpan.FromSeconds(5)); + clock.Advance(TimeSpan.FromSeconds(9)); + stream.Release(index); + } + + await stream.ReadStarted(4).WaitAsync(TimeSpan.FromSeconds(5)); + stream.Release(4); + + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("live"), await response.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [TestMethod] + public async Task AssetNamesAreValidatedOnlyWhenTheyAreMapped() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: true, assetNames: ["../unused.txt"], virtualAssets: false, mapToResources: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + await installer.InstallAsync(fixture.Prepared, temporary.Path); + + Assert.IsFalse(File.Exists(Path.Combine(temporary.Path, "unused.txt"))); + } + + [TestMethod] + public async Task AssetCopiesRejectCaseInsensitivePathCollisions() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: true, assetNames: ["Config.json", "config.json"]); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual(MojangError.Invalid, exception.Error); + } + + [TestMethod] + [DataRow(false, "resourcepacks")] + [DataRow(true, "texturepacks")] + public async Task InstallerCreatesModAndVersionAppropriatePackFolders(bool legacy, string packFolder) + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false); + var manifest = fixture.Prepared.Manifest with { Assets = legacy ? "pre-1.6" : "1.20" }; + var prepared = fixture.Prepared with { Manifest = manifest }; + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + await installer.InstallAsync(prepared, temporary.Path); + + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Path, "minecraft", "mods"))); + Assert.IsTrue(Directory.Exists(Path.Combine(temporary.Path, "minecraft", packFolder))); + } + + [TestMethod] + public async Task InstallationRemovesOnlyOrphanedHakoTemporaryFilesWithoutFollowingJunctions() + { + using var temporary = new TestDirectory(); + using var outside = new TestDirectory(); + var rootTemporary = Path.Combine(temporary.Path, ".hako-root.tmp"); + var unrelatedFile = Path.Combine(temporary.Path, "minecraft", "libraries", ".hako-old.tmpx"); + var javaTemporary = Path.Combine(temporary.Path, "java", ".hako-java.tmp"); + var libraryTemporary = Path.Combine(temporary.Path, "minecraft", "libraries", ".hako-library.tmp"); + var nested = Path.Combine(temporary.Path, "minecraft", "libraries", "nested"); + var minecraftTemporary = Path.Combine(temporary.Path, "minecraft", ".hako-root-file.tmp"); + var staleDirectory = Path.Combine(temporary.Path, "minecraft", ".hako-staging.tmp"); + var savesTemporary = Path.Combine(temporary.Path, "minecraft", "saves", "World", ".hako-save.tmp"); + var modsTemporary = Path.Combine(temporary.Path, "minecraft", "mods", ".hako-mod.tmp"); + var screenshotsTemporary = Path.Combine(temporary.Path, "minecraft", "screenshots", ".hako-shot.tmp"); + var resourcepacksTemporary = Path.Combine(temporary.Path, "minecraft", "resourcepacks", ".hako-pack.tmp"); + var outsideTemporary = Path.Combine(outside.Path, ".hako-outside.tmp"); + var junction = Path.Combine(temporary.Path, "minecraft", "libraries", "redirected"); + Directory.CreateDirectory(Path.GetDirectoryName(javaTemporary)!); + Directory.CreateDirectory(nested); + Directory.CreateDirectory(Path.GetDirectoryName(savesTemporary)!); + Directory.CreateDirectory(Path.GetDirectoryName(modsTemporary)!); + Directory.CreateDirectory(Path.GetDirectoryName(screenshotsTemporary)!); + Directory.CreateDirectory(Path.GetDirectoryName(resourcepacksTemporary)!); + Directory.CreateDirectory(staleDirectory); + await File.WriteAllTextAsync(rootTemporary, "keep"); + await File.WriteAllTextAsync(javaTemporary, "stale"); + await File.WriteAllTextAsync(libraryTemporary, "stale"); + await File.WriteAllTextAsync(minecraftTemporary, "stale"); + await File.WriteAllTextAsync(Path.Combine(staleDirectory, "partial"), "stale"); + await File.WriteAllTextAsync(Path.Combine(nested, ".hako-nested.tmp"), "stale"); + await File.WriteAllTextAsync(savesTemporary, "keep"); + await File.WriteAllTextAsync(modsTemporary, "keep"); + await File.WriteAllTextAsync(screenshotsTemporary, "keep"); + await File.WriteAllTextAsync(resourcepacksTemporary, "keep"); + await File.WriteAllTextAsync(unrelatedFile, "keep"); + await File.WriteAllTextAsync(outsideTemporary, "keep"); + CreateDirectorySymlink(junction, outside.Path); + var fixture = CreateInstallFixture(includeAssets: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + await installer.InstallAsync(fixture.Prepared, temporary.Path); + + Assert.IsFalse(File.Exists(javaTemporary)); + Assert.IsFalse(File.Exists(libraryTemporary)); + Assert.IsFalse(File.Exists(minecraftTemporary)); + Assert.IsFalse(Directory.Exists(staleDirectory)); + Assert.IsFalse(File.Exists(Path.Combine(nested, ".hako-nested.tmp"))); + Assert.IsTrue(File.Exists(rootTemporary)); + Assert.IsTrue(File.Exists(savesTemporary)); + Assert.IsTrue(File.Exists(modsTemporary)); + Assert.IsTrue(File.Exists(screenshotsTemporary)); + Assert.IsTrue(File.Exists(resourcepacksTemporary)); + Assert.IsTrue(File.Exists(unrelatedFile)); + Assert.IsTrue(File.Exists(outsideTemporary)); + Directory.Delete(junction); + } + + [TestMethod] + public async Task InstallationContinuesWhenStaleTemporaryFileIsInUse() + { + using var temporary = new TestDirectory(); + var libraryDirectory = Path.Combine(temporary.Path, "minecraft", "libraries"); + Directory.CreateDirectory(libraryDirectory); + var occupiedPath = Path.Combine(libraryDirectory, ".hako-occupied.tmp"); + await File.WriteAllTextAsync(occupiedPath, "keep while open"); + using var occupied = new FileStream(occupiedPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var fixture = CreateInstallFixture(includeAssets: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + await installer.InstallAsync(fixture.Prepared, temporary.Path); + + Assert.IsTrue(File.Exists(occupiedPath)); + Assert.IsTrue(File.Exists(Path.Combine(libraryDirectory, "org", "test", "library.jar"))); + } + + [TestMethod] + public async Task InstallationRejectsAReparsePointInAnInstallPath() + { + using var temporary = new TestDirectory(); + using var outside = new TestDirectory(); + var minecraftLink = Path.Combine(temporary.Path, "minecraft"); + CreateDirectorySymlink(minecraftLink, outside.Path); + var fixture = CreateInstallFixture(includeAssets: false); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual(InstanceStorageError.ReparsePointNotAllowed, exception.Error); + Assert.AreEqual(0, Directory.EnumerateFiles(outside.Path, "*", SearchOption.AllDirectories).Count()); + Directory.Delete(minecraftLink); + } + + [TestMethod] + [DataRow("../escape.dll")] + [DataRow("/absolute.dll")] + [DataRow("C:/outside.dll")] + [DataRow("folder/../../escape.dll")] + [DataRow("CON.txt")] + [DataRow("folder./native.dll")] + [DataRow("file:stream.dll")] + [DataRow("duplicate.dll|DUPLICATE.DLL")] + public async Task LegacyNativeArchivesRejectUnsafePathsBeforeExtraction(string entryPath) + { + using var temporary = new TestDirectory(); + var zip = CreateZip(entryPath.Split('|')); + var library = CreateNativeLibrary(zip, ["META-INF/"]); + var fixture = CreateInstallFixture(includeAssets: false, libraries: [library], nativeArchiveBytes: zip); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual(MojangError.Invalid, exception.Error); + Assert.IsFalse(File.Exists(Path.Combine(temporary.Path, "minecraft", "natives", "escape.dll"))); + Assert.AreEqual(0, Directory.Exists(Path.Combine(temporary.Path, "minecraft", "natives")) + ? Directory.EnumerateFiles(Path.Combine(temporary.Path, "minecraft", "natives"), "*", SearchOption.AllDirectories).Count() + : 0); + } + + [TestMethod] + public async Task LegacyNativeArchiveHonorsMetaInfExclusion() + { + using var temporary = new TestDirectory(); + var zip = CreateZip("META-INF/MANIFEST.MF", "org/test/native.dll"); + var fixture = CreateInstallFixture( + includeAssets: false, + libraries: [CreateNativeLibrary(zip, ["META-INF/"])], + nativeArchiveBytes: zip); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + await installer.InstallAsync(fixture.Prepared, temporary.Path); + + Assert.IsFalse(File.Exists(Path.Combine(temporary.Path, "minecraft", "natives", "META-INF", "MANIFEST.MF"))); + Assert.IsTrue(File.Exists(Path.Combine(temporary.Path, "minecraft", "natives", "org", "test", "native.dll"))); + } + + [TestMethod] + public async Task FailureInSecondNativeArchiveDoesNotPublishAnyStagedFiles() + { + using var temporary = new TestDirectory(); + var nativeDirectory = Path.Combine(temporary.Path, "minecraft", "natives"); + Directory.CreateDirectory(nativeDirectory); + var original = Path.Combine(nativeDirectory, "original.dll"); + await File.WriteAllTextAsync(original, "original"); + var firstArchive = CreateZip("org/test/first.dll"); + var secondArchive = Encoding.UTF8.GetBytes("not a ZIP archive"); + const string firstUrl = "https://test/native-first.zip"; + const string secondUrl = "https://test/native-second.zip"; + var fixture = CreateInstallFixture( + includeAssets: false, + libraries: + [ + CreateNativeLibrary(firstArchive, ["META-INF/"], firstUrl, "org/test/native-first.jar"), + CreateNativeLibrary(secondArchive, ["META-INF/"], secondUrl, "org/test/native-second.jar") + ], + downloadPayloadOverrides: new Dictionary(StringComparer.Ordinal) + { + [firstUrl] = firstArchive, + [secondUrl] = secondArchive + }); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + await Assert.ThrowsAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual("original", await File.ReadAllTextAsync(original)); + Assert.IsFalse(File.Exists(Path.Combine(nativeDirectory, "org", "test", "first.dll"))); + Assert.AreEqual(1, Directory.EnumerateFiles(nativeDirectory, "*", SearchOption.AllDirectories).Count()); + } + + [TestMethod] + public async Task NativePublicationFailureRestoresPreviousDirectory() + { + using var temporary = new TestDirectory(); + var minecraftDirectory = Path.Combine(temporary.Path, "minecraft"); + var nativeDirectory = Path.Combine(minecraftDirectory, "natives"); + var stagingDirectory = Path.Combine(minecraftDirectory, ".hako-natives-new.tmp"); + Directory.CreateDirectory(nativeDirectory); + Directory.CreateDirectory(stagingDirectory); + var original = Path.Combine(nativeDirectory, "original.dll"); + var staged = Path.Combine(stagingDirectory, "new.dll"); + await File.WriteAllTextAsync(original, "original"); + await File.WriteAllTextAsync(staged, "new"); + var moveCount = 0; + + void MoveDirectory(string source, string destination) + { + if (Interlocked.Increment(ref moveCount) == 2) + { + throw new IOException("Injected publication failure."); + } + + Directory.Move(source, destination); + } + + var exception = Assert.ThrowsExactly(() => + MinecraftInstaller.PublishNativeStaging(stagingDirectory, temporary.Path, MoveDirectory)); + + Assert.AreEqual("Injected publication failure.", exception.Message); + Assert.AreEqual("original", await File.ReadAllTextAsync(original)); + Assert.IsFalse(File.Exists(Path.Combine(nativeDirectory, "new.dll"))); + Assert.IsTrue(File.Exists(staged)); + } + + [TestMethod] + public async Task JavaRuntimeLinksMustStayInsideJavaAndAreNotCreatedAsReparsePoints() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false); + var files = new Dictionary(fixture.Prepared.RuntimeManifest.Files) + { + ["bin/java.exe"] = new() { Type = "link", Target = "../../outside.exe" } + }; + var prepared = fixture.Prepared with { RuntimeManifest = new MojangJavaRuntimeManifest { Files = files } }; + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(prepared, temporary.Path)); + + Assert.AreEqual(MojangError.Invalid, exception.Error); + Assert.IsFalse(File.Exists(Path.Combine(temporary.Path, "outside.exe"))); + Assert.AreEqual(0, Directory.EnumerateFiles(temporary.Path, "*.tmp", SearchOption.AllDirectories).Count()); + } + + [TestMethod] + public async Task CancellationDuringNativeEntryExtractionPublishesNoPartialFile() + { + using var temporary = new TestDirectory(); + var destinationDirectory = Path.Combine(temporary.Path, "minecraft", "natives"); + Directory.CreateDirectory(destinationDirectory); + var destination = Path.Combine(destinationDirectory, "native.dll"); + using var cancellation = new CancellationTokenSource(); + await using var source = new CancelAfterFirstChunkStream(Encoding.UTF8.GetBytes("native bytes"), cancellation); + + await Assert.ThrowsAsync(() => + MinecraftInstaller.ExtractNativeEntryAsync(source, destination, source.Length, cancellation.Token)); + + Assert.IsFalse(File.Exists(destination)); + Assert.AreEqual(0, Directory.EnumerateFiles(temporary.Path, ".hako-*.tmp", SearchOption.AllDirectories).Count()); + } + + [TestMethod] + public async Task PrepareRejectsJavaRuntimeLinkEntriesWithAUsefulError() + { + var exception = await Assert.ThrowsExactlyAsync(() => PrepareSyntheticAsync( + [("lwjgl", GameArchitecture.X64)], + [GameArchitecture.X64], + new HostArchitecture(HostMachineArchitecture.X64, false), + includeRuntimeLink: true)); + + Assert.AreEqual(MojangError.Invalid, exception.Error); + StringAssert.Contains(exception.Message, "link"); + } + + [TestMethod] + public async Task NonHttpsDownloadUrlIsRejectedBeforeARequestIsMade() + { + using var temporary = new TestDirectory(); + var fixture = CreateInstallFixture(includeAssets: false, clientUrl: "http://test/client.jar"); + using var client = new MojangClient(new HttpClient(fixture.Handler)); + var installer = new MinecraftInstaller(client); + + var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); + + Assert.AreEqual(MojangError.Invalid, exception.Error); + Assert.AreEqual(0, fixture.Handler.RequestCount("http://test/client.jar")); + } + + private static async Task PrepareSyntheticAsync( + IReadOnlyCollection<(string Group, GameArchitecture Architecture)> nativeVariants, + IReadOnlyCollection runtimeArchitectures, + HostArchitecture host, + GameArchitecture? pinned = null, + bool includeRuntimeLink = false) + { + var runtimeFiles = new JsonObject + { + ["bin/javaw.exe"] = new JsonObject + { + ["type"] = "file", + ["downloads"] = new JsonObject + { + ["raw"] = new JsonObject + { + ["url"] = "https://test/javaw.exe", + ["sha1"] = Hash("java"), + ["size"] = 4 + } + } + } + }; + if (includeRuntimeLink) + { + runtimeFiles["bin/linked.dll"] = new JsonObject { ["type"] = "link", ["target"] = "../linked.dll" }; + } + + var runtimeBytes = JsonSerializer.SerializeToUtf8Bytes(new JsonObject { ["files"] = runtimeFiles }); + var indexBytes = BuildRuntimeIndex(runtimeBytes, runtimeArchitectures); + var versionBytes = BuildVersionManifest(nativeVariants); + const string versionUrl = "https://test/version.json"; + var handler = new PayloadHandler(new Dictionary(StringComparer.Ordinal) + { + [versionUrl] = versionBytes, + [MojangClient.RuntimeIndexUrl] = indexBytes, + ["https://test/runtime-manifest.json"] = runtimeBytes + }); + using var client = new MojangClient(new HttpClient(handler)); + var entry = new MojangVersionEntry { Id = "test", Type = "release", Url = versionUrl, Sha1 = Hash(versionBytes) }; + return pinned is { } architecture + ? await client.PrepareAsync(entry, host, architecture) + : await client.PrepareAsync(entry, host); + } + + private static byte[] BuildVersionManifest(IReadOnlyCollection<(string Group, GameArchitecture Architecture)> nativeVariants) + { + var nativeBytes = Encoding.UTF8.GetBytes("native bytes"); + var libraries = nativeVariants.Select(variant => + { + var classifier = variant.Architecture switch + { + GameArchitecture.Arm64 => "natives-windows-arm64", + GameArchitecture.X64 => "natives-windows", + GameArchitecture.X86 => "natives-windows-x86", + _ => throw new ArgumentOutOfRangeException() + }; + var path = $"org/test/{variant.Group}/1.0/{variant.Group}-1.0-{classifier}.jar"; + return $"{{\"name\":\"org.test:{variant.Group}:1.0:{classifier}\",\"downloads\":{{\"artifact\":{{\"path\":\"{path}\",\"url\":\"https://test/native.jar\",\"sha1\":\"{Hash(nativeBytes)}\",\"size\":{nativeBytes.Length}}}}},\"rules\":[{{\"action\":\"allow\",\"os\":{{\"name\":\"windows\"}}}}]}}"; + }); + var json = "{\"id\":\"test\",\"javaVersion\":{\"component\":\"java-runtime-gamma\",\"majorVersion\":17}," + + "\"downloads\":{\"client\":{\"url\":\"https://test/client.jar\",\"sha1\":\"" + Hash("client") + "\",\"size\":6}}," + + "\"libraries\":[" + string.Join(',', libraries) + "]}"; + return Encoding.UTF8.GetBytes(json); + } + + private static byte[] BuildRuntimeIndex(byte[] runtimeManifest, IReadOnlyCollection architectures) + { + var package = "[ {\"manifest\":{\"url\":\"https://test/runtime-manifest.json\",\"sha1\":\"" + Hash(runtimeManifest) + + "\",\"size\":" + runtimeManifest.Length + "},\"version\":{\"name\":\"17.0.15\",\"released\":\"2025-01-01\"}} ]"; + var platforms = architectures.Select(architecture => "\"" + architecture.MojangRuntimeKey() + "\":{\"java-runtime-gamma\":" + package + "}"); + return Encoding.UTF8.GetBytes("{" + string.Join(',', platforms) + "}"); + } + + private static byte[] RewriteRuntimeFixture(byte[] runtimeIndexBytes, byte[] runtimeManifest) + { + var root = JsonNode.Parse(runtimeIndexBytes)!.AsObject(); + foreach (var platform in root.Where(property => property.Key.StartsWith("windows-", StringComparison.Ordinal))) + { + foreach (var component in platform.Value!.AsObject()) + { + foreach (var package in component.Value!.AsArray()) + { + var manifest = package!["manifest"]!.AsObject(); + manifest["url"] = "https://test/runtime-manifest.json"; + manifest["sha1"] = Hash(runtimeManifest); + manifest["size"] = runtimeManifest.Length; + } + } + } + + return Encoding.UTF8.GetBytes(root.ToJsonString()); + } + + private static InstallFixture CreateInstallFixture( + bool includeAssets, + bool badClientSha1 = false, + bool badClientSize = false, + bool oversizedClient = false, + string clientUrl = "https://test/client.jar", + bool blockDownloads = false, + string? failingUrl = null, + string? blockedUrl = null, + bool gateDownloads = false, + IReadOnlyList? assetNames = null, + bool virtualAssets = true, + bool mapToResources = true, + IReadOnlyCollection? libraries = null, + byte[]? nativeArchiveBytes = null, + IReadOnlyDictionary? downloadPayloadOverrides = null, + CancellationTokenSource? cancelOnFailFast = null, + string? streamOpenFailureUrl = null) + { + var javaBytes = Encoding.UTF8.GetBytes("javaw content"); + var clientBytes = Encoding.UTF8.GetBytes("client content"); + var libraryBytes = Encoding.UTF8.GetBytes("library content"); + var logBytes = Encoding.UTF8.GetBytes("log config"); + var assetBytes = Encoding.UTF8.GetBytes("asset content"); + var payloads = new Dictionary(StringComparer.Ordinal) + { + ["https://test/javaw.exe"] = javaBytes, + [clientUrl] = clientBytes, + ["https://test/library.jar"] = libraryBytes, + ["https://test/log-config.xml"] = logBytes + }; + MojangAssetIndexReference? assetReference = null; + if (includeAssets) + { + var assetHash = Hash(assetBytes); + var names = assetNames ?? ["lang/en_us.json"]; + var objects = names.ToDictionary(name => name, _ => new { hash = assetHash, size = assetBytes.Length }, StringComparer.Ordinal); + var indexBytes = JsonSerializer.SerializeToUtf8Bytes(new { objects, @virtual = virtualAssets, map_to_resources = mapToResources }); + payloads["https://test/asset-index.json"] = indexBytes; + payloads[MojangClient.AssetBaseUrl + assetHash[..2] + "/" + assetHash] = assetBytes; + assetReference = new MojangAssetIndexReference + { + Id = "test-index", + Url = "https://test/asset-index.json", + Sha1 = Hash(indexBytes), + Size = indexBytes.Length + }; + } + + if (libraries is not null) + { + foreach (var library in libraries) + { + payloads[library.Download.Url] = downloadPayloadOverrides is not null + && downloadPayloadOverrides.TryGetValue(library.Download.Url, out var overrideBytes) + ? overrideBytes + : nativeArchiveBytes ?? libraryBytes; + } + } + + var clientDownload = new MojangDownload + { + Url = clientUrl, + Sha1 = badClientSha1 ? Hash("incorrect") : Hash(clientBytes), + Size = badClientSize ? clientBytes.Length + 1 : oversizedClient ? clientBytes.Length - 1 : clientBytes.Length + }; + var manifest = new MojangVersionMetadata + { + Id = "install-test", + JavaVersion = new MojangJavaRequirement { Component = "java-runtime-gamma", MajorVersion = 17 }, + Downloads = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["client"] = clientDownload }, + Libraries = [], + AssetIndex = assetReference, + Logging = new Dictionary + { + ["client"] = new() + { + Argument = "-Dlog4j.configurationFile=${path}", + File = new MojangDownload + { + Id = "client.xml", + Url = "https://test/log-config.xml", + Sha1 = Hash(logBytes), + Size = logBytes.Length + } + } + } + }; + var runtimeManifest = new MojangJavaRuntimeManifest + { + Files = new Dictionary(StringComparer.Ordinal) + { + ["bin/javaw.exe"] = new() + { + Type = "file", + Downloads = new Dictionary + { + ["raw"] = new() { Url = "https://test/javaw.exe", Sha1 = Hash(javaBytes), Size = javaBytes.Length } + } + } + } + }; + var versionData = Encoding.UTF8.GetBytes("{\"id\":\"install-test\"}"); + var runtimeData = Encoding.UTF8.GetBytes( + "{\"files\":{\"bin/javaw.exe\":{\"type\":\"file\",\"downloads\":{\"raw\":{\"url\":\"https://test/javaw.exe\",\"sha1\":\"" + + Hash(javaBytes) + "\",\"size\":" + javaBytes.Length + "}}}}}"); + var prepared = new PreparedInstallation( + new MojangVersionEntry { Id = manifest.Id, Url = "https://test/version.json", Sha1 = Hash(versionData) }, + GameArchitecture.X64, + manifest, + versionData, + new MojangJavaRuntimePackage + { + Manifest = new MojangDownload { Url = "https://test/runtime-manifest.json", Sha1 = Hash(runtimeData), Size = runtimeData.Length }, + Version = new MojangRuntimeVersion { Name = "17.0.15", Released = "2025-01-01" } + }, + runtimeManifest, + runtimeData, + libraries?.ToArray() ?? [new LibraryInstallation( + new MojangDownload { Url = "https://test/library.jar", Sha1 = Hash(libraryBytes), Size = libraryBytes.Length }, + "org/test/library.jar")]); + + return new InstallFixture( + prepared, + new PayloadHandler(payloads, blockDownloads, failingUrl, blockedUrl, gateDownloads, cancelOnFailFast, streamOpenFailureUrl), + clientBytes); + } + + private static LibraryInstallation CreateNativeLibrary( + byte[] zipBytes, + IReadOnlyList excludes, + string url = "https://test/native.zip", + string? path = null) => + new(new MojangDownload + { + Url = url, + Sha1 = Hash(zipBytes), + Size = zipBytes.Length + }, path ?? "org/test/native.jar", IsNative: true, ExtractNatives: true, ExtractionExcludes: excludes); + + private static byte[] CreateZip(params string[] entryNames) + { + using var memory = new MemoryStream(); + using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName); + using var stream = entry.Open(); + if (!entryName.EndsWith('/')) + { + stream.Write(Encoding.UTF8.GetBytes("content")); + } + } + } + + return memory.ToArray(); + } + + private static string Hash(string text) => Hash(Encoding.UTF8.GetBytes(text)); + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant(); + private static MojangDownload Download(string path) => new() + { + Path = path, + Url = "https://test/" + path, + Sha1 = Hash("content"), + Size = 7 + }; + + private sealed record InstallFixture(PreparedInstallation Prepared, PayloadHandler Handler, byte[] ClientBytes); + + private sealed class PayloadHandler( + Dictionary payloads, + bool blockDownloads = false, + string? failingUrl = null, + string? blockedUrl = null, + bool gateDownloads = false, + CancellationTokenSource? cancelOnFailFast = null, + string? streamOpenFailureUrl = null) : HttpMessageHandler + { + private readonly ConcurrentDictionary _requests = new(StringComparer.Ordinal); + private readonly ConcurrentBag _userAgents = []; + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _activeResponses; + private int _maximumConcurrentResponses; + public TaskCompletionSource FirstBodyRead { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource BlockingReadCancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource FourConcurrentResponsesStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public IReadOnlyCollection UserAgents => _userAgents.ToArray(); + public int RequestCount(string url) => _requests.GetValueOrDefault(url); + public int MaximumConcurrentResponses => Volatile.Read(ref _maximumConcurrentResponses); + public void ReleaseGatedResponses() => _gate.TrySetResult(); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.AbsoluteUri; + _requests.AddOrUpdate(url, 1, static (_, count) => count + 1); + if (request.Headers.UserAgent.FirstOrDefault() is { } userAgent) + { + _userAgents.Add(userAgent.ToString()); + } + + if (string.Equals(url, failingUrl, StringComparison.Ordinal)) + { + await FirstBodyRead.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = cancelOnFailFast is null ? null : new CancelOnDisposeContent(cancelOnFailFast) + }; + } + + if (string.Equals(url, streamOpenFailureUrl, StringComparison.Ordinal)) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamOpenFailureContent() }; + } + + if (!payloads.TryGetValue(url, out var bytes)) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + if (gateDownloads) + { + var active = Interlocked.Increment(ref _activeResponses); + UpdateMaximum(active); + if (active == 4) + { + FourConcurrentResponsesStarted.TrySetResult(); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new GatedContent(bytes, _gate.Task, () => Interlocked.Decrement(ref _activeResponses)) + }; + } + + var shouldBlock = string.Equals(url, blockedUrl, StringComparison.Ordinal) + || blockDownloads && (url is "https://test/javaw.exe" or "https://test/client.jar"); + HttpContent content = shouldBlock + ? new StreamContent(new DelayedReadStream(bytes, FirstBodyRead, BlockingReadCancelled)) + : new ByteArrayContent(bytes); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + } + + private void UpdateMaximum(int active) + { + var current = Volatile.Read(ref _maximumConcurrentResponses); + while (active > current) + { + var previous = Interlocked.CompareExchange(ref _maximumConcurrentResponses, active, current); + if (previous == current) + { + break; + } + + current = previous; + } + } + } + + private sealed class DelayedReadStream(byte[] bytes, TaskCompletionSource firstRead, TaskCompletionSource cancelled) : Stream + { + private bool _read; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_read) + { + return WaitForCancellationAsync(cancellationToken, cancelled); + } + + _read = true; + var count = Math.Min(buffer.Length, bytes.Length); + bytes.AsMemory(0, count).CopyTo(buffer); + firstRead.TrySetResult(); + return ValueTask.FromResult(count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + private static async ValueTask WaitForCancellationAsync(CancellationToken cancellationToken, TaskCompletionSource cancelled) + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + catch (OperationCanceledException) + { + cancelled.TrySetResult(); + throw; + } + } + } + + private sealed class GatedContent(byte[] bytes, Task gate, Action disposed) : HttpContent + { + private int _isDisposed; + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => + stream.WriteAsync(bytes.AsMemory()).AsTask(); + + protected override bool TryComputeLength(out long length) + { + length = bytes.Length; + return true; + } + + protected override Task CreateContentReadStreamAsync() => Task.FromResult(new GatedReadStream(bytes, gate)); + + protected override void Dispose(bool disposing) + { + if (disposing && Interlocked.Exchange(ref _isDisposed, 1) == 0) + { + disposed(); + } + + base.Dispose(disposing); + } + } + + private sealed class GatedReadStream(byte[] bytes, Task gate) : MemoryStream(bytes, writable: false) + { + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + await gate.WaitAsync(cancellationToken); + return await base.ReadAsync(buffer, cancellationToken); + } + } + + private sealed class StallingBodyHandler(TaskCompletionSource stalledRead) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new StallingBodyStream(Encoding.UTF8.GetBytes("body"), stalledRead)) + }); + } + + private sealed class StallingBodyStream(byte[] bytes, TaskCompletionSource stalledRead) : MemoryStream(bytes, writable: false) + { + private bool _firstRead = true; + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_firstRead) + { + _firstRead = false; + return await base.ReadAsync(buffer, cancellationToken); + } + + stalledRead.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + } + + private sealed class StreamHandler(Func createStream) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(createStream()) + }); + } + + private sealed class StreamOpenFailureHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamOpenFailureContent() }); + } + + private sealed class StreamOpenFailureContent : HttpContent + { + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return true; + } + + protected override Task CreateContentReadStreamAsync() => + Task.FromException(new IOException("The response stream could not be opened.")); + } + + private sealed class IOExceptionAfterChunkStream(byte[] bytes) : MemoryStream(bytes, writable: false) + { + private bool _firstRead = true; + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_firstRead) + { + _firstRead = false; + return base.ReadAsync(buffer[..Math.Min(2, buffer.Length)], cancellationToken); + } + + return ValueTask.FromException(new IOException("The response body ended unexpectedly.")); + } + } + + private sealed class ControlledChunkStream(byte[] bytes) : Stream + { + private readonly TaskCompletionSource[] _readStarted = + Enumerable.Range(0, bytes.Length + 1).Select(_ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).ToArray(); + private readonly TaskCompletionSource[] _released = + Enumerable.Range(0, bytes.Length + 1).Select(_ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)).ToArray(); + private int _nextRead; + + public Task ReadStarted(int index) => _readStarted[index].Task; + public void Release(int index) => _released[index].TrySetResult(); + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var index = Interlocked.Increment(ref _nextRead) - 1; + _readStarted[index].TrySetResult(); + await _released[index].Task.WaitAsync(cancellationToken); + if (index == bytes.Length) + { + return 0; + } + + bytes.AsMemory(index, 1).CopyTo(buffer); + return 1; + } + } + + private sealed class ManualTimeProvider : TimeProvider + { + private readonly object _gate = new(); + private readonly List _timers = []; + + public void Advance(TimeSpan elapsed) + { + ManualTimer[] timers; + lock (_gate) + { + timers = _timers.ToArray(); + } + + foreach (var timer in timers) + { + timer.Advance(elapsed); + } + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new ManualTimer(callback, state); + timer.Change(dueTime, period); + lock (_gate) + { + _timers.Add(timer); + } + + return timer; + } + + private sealed class ManualTimer(TimerCallback callback, object? state) : ITimer + { + private readonly object _gate = new(); + private TimeSpan? _remaining; + private TimeSpan _period; + private bool _disposed; + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + lock (_gate) + { + if (_disposed) + { + return false; + } + + _remaining = dueTime == Timeout.InfiniteTimeSpan ? null : dueTime; + _period = period; + return true; + } + } + + public void Advance(TimeSpan elapsed) + { + TimerCallback? callbackToRun = null; + lock (_gate) + { + if (_disposed || _remaining is not { } remaining) + { + return; + } + + if (remaining > elapsed) + { + _remaining = remaining - elapsed; + return; + } + + callbackToRun = callback; + _remaining = _period > TimeSpan.Zero ? _period : null; + } + + callbackToRun?.Invoke(state); + } + + public void Dispose() + { + lock (_gate) + { + _disposed = true; + _remaining = null; + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } + } + + private sealed class CancelOnDisposeContent(CancellationTokenSource cancellation) : HttpContent + { + private int _disposed; + + protected override Task SerializeToStreamAsync(Stream stream, System.Net.TransportContext? context) => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 0; + return true; + } + + protected override void Dispose(bool disposing) + { + if (disposing && Interlocked.Exchange(ref _disposed, 1) == 0) + { + cancellation.Cancel(); + } + + base.Dispose(disposing); + } + } + + private sealed class CancelAfterFirstChunkStream(byte[] bytes, CancellationTokenSource cancellation) : MemoryStream(bytes, writable: false) + { + private bool _firstRead = true; + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_firstRead) + { + _firstRead = false; + return base.ReadAsync(buffer[..Math.Min(3, buffer.Length)], cancellationToken); + } + + cancellation.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(0); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly ConcurrentQueue _reports = new(); + public InstallationProgress[] Reports => _reports.ToArray(); + public void Report(InstallationProgress value) => _reports.Enqueue(value); + } + + private sealed class ThreadRecordingProgress : IProgress + { + private int _firstThreadId; + public int FirstThreadId => Volatile.Read(ref _firstThreadId); + + public void Report(InstallationProgress value) => + Interlocked.CompareExchange(ref _firstThreadId, Environment.CurrentManagedThreadId, 0); + } + + private static void CreateDirectorySymlink(string path, string target) + { + if (!CreateSymbolicLinkW(path, target, SymbolicLinkFlagDirectory | SymbolicLinkFlagAllowUnprivilegedCreate)) + { + throw new IOException($"Could not create a test directory symlink (Win32 error {Marshal.GetLastWin32Error()})."); + } + } + + private const uint SymbolicLinkFlagDirectory = 0x1; + private const uint SymbolicLinkFlagAllowUnprivilegedCreate = 0x2; + + [DllImport("kernel32.dll", EntryPoint = "CreateSymbolicLinkW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateSymbolicLinkW(string linkPath, string targetPath, uint flags); + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Hako.MinecraftTests." + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} diff --git a/windows/Hako.Tests/MinecraftLaunchPlanTests.cs b/windows/Hako.Tests/MinecraftLaunchPlanTests.cs new file mode 100644 index 0000000..fb90842 --- /dev/null +++ b/windows/Hako.Tests/MinecraftLaunchPlanTests.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class MinecraftLaunchPlanTests +{ + [TestMethod] + public void OfficialFixturesBuildForThePinnedWindowsArchitecture() + { + foreach (var version in new[] { "1.6.4", "1.19", "22w18a" }) + { + foreach (var architecture in new[] { GameArchitecture.X64, GameArchitecture.Arm64 }) + { + using var temporary = new TemporaryDirectory(); + var root = Path.Combine(temporary.Root, version); + Directory.CreateDirectory(root); + var manifest = ReadManifest(version); + var plan = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Mojang, new InstanceParameters { MaximumMemoryMiB = 2048 }, architecture, + libraries: [], javaMajorVersion: 17); + + Assert.AreEqual(Path.Combine(root, "minecraft"), plan.WorkingDirectory); + Assert.AreEqual("javaw.exe", plan.Executable); + Assert.IsTrue(plan.Arguments.Contains(manifest.MainClass)); + Assert.IsTrue(plan.Arguments.Contains("Player")); + var manifestArguments = manifest.MinecraftArguments + ?? string.Join(' ', manifest.Arguments!.Values.SelectMany(arguments => arguments).SelectMany(argument => argument.Values)); + if (manifestArguments.Contains("${auth_uuid}", StringComparison.Ordinal)) + { + Assert.IsTrue(plan.Arguments.Contains("a01e3843e5213998958af459800e4d11")); + } + + if (manifestArguments.Contains("${auth_session}", StringComparison.Ordinal)) + { + Assert.IsTrue(plan.Arguments.Contains("0")); + } + + if (manifestArguments.Contains("${user_type}", StringComparison.Ordinal)) + { + Assert.IsTrue(plan.Arguments.Contains("legacy")); + } + Assert.IsFalse(plan.Arguments.Any(argument => argument.Contains("${", StringComparison.Ordinal))); + Assert.IsFalse(plan.Arguments.Contains("-XstartOnFirstThread")); + } + } + } + + [TestMethod] + public void ClasspathKeepsManifestLibraryOrderAndPutsClientLast() + { + using var temporary = new TemporaryDirectory(); + var root = Path.Combine(temporary.Root, "Build"); + var manifest = ReadManifest("1.19"); + var libraries = new[] + { + new LibraryInstallation(new MojangDownload(), "org/example/first/1.0/first-1.0.jar"), + new LibraryInstallation(new MojangDownload(), "org/example/second/2.0/second-2.0.jar"), + new LibraryInstallation(new MojangDownload(), "org/example/native/3.0/native-3.0.jar", IsNative: true) + }; + + var plan = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Mojang, new InstanceParameters { MaximumMemoryMiB = 2048 }, GameArchitecture.X64, + libraries: libraries, javaMajorVersion: 17); + var classpathFlag = Array.IndexOf(plan.Arguments.ToArray(), "-cp"); + var classpath = plan.Arguments[classpathFlag + 1].Split(';'); + + CollectionAssert.AreEqual(new[] + { + Path.Combine(root, "minecraft", "libraries", "org", "example", "first", "1.0", "first-1.0.jar"), + Path.Combine(root, "minecraft", "libraries", "org", "example", "second", "2.0", "second-2.0.jar"), + Path.Combine(root, "minecraft", "versions", "1.19", "1.19.jar") + }, classpath); + } + + [TestMethod] + public void WindowAndFullscreenOptionsUseSelectedDimensions() + { + using var temporary = new TemporaryDirectory(); + var root = Path.Combine(temporary.Root, "Build"); + var manifest = ReadManifest("1.19"); + var windowed = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Mojang, new InstanceParameters { WindowWidth = 1600, WindowHeight = 900, MaximumMemoryMiB = 2048 }, + GameArchitecture.X64, libraries: [], javaMajorVersion: 17); + var fullscreen = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Mojang, new InstanceParameters { Fullscreen = true, MaximumMemoryMiB = 2048 }, + GameArchitecture.X64, libraries: [], javaMajorVersion: 17); + + var width = Array.IndexOf(windowed.Arguments.ToArray(), "--width"); + var height = Array.IndexOf(windowed.Arguments.ToArray(), "--height"); + Assert.AreEqual("1600", windowed.Arguments[width + 1]); + Assert.AreEqual("900", windowed.Arguments[height + 1]); + Assert.IsTrue(fullscreen.Arguments.Contains("--fullscreen")); + Assert.IsFalse(fullscreen.Arguments.Contains("--width")); + } + + [TestMethod] + public void VirtualAndMappedAssetsUseTheMinecraftAssetDirectories() + { + using var temporary = new TemporaryDirectory(); + var root = Path.Combine(temporary.Root, "Build"); + var manifest = new MojangVersionMetadata + { + Id = "1.19", + MainClass = "net.minecraft.client.main.Main", + Assets = "1.19", + MinecraftArguments = "--assetsDir ${game_assets}" + }; + var virtualAssets = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Mojang, new InstanceParameters { MaximumMemoryMiB = 2048 }, GameArchitecture.X64, + new MojangAssetIndex { Virtual = true }, [], javaMajorVersion: 17); + var mappedAssets = MinecraftLaunchPlan.Build(manifest, root, "javaw.exe", MinecraftLaunchIdentity.Offline("Player"), + LaunchArgumentSource.Mojang, new InstanceParameters { MaximumMemoryMiB = 2048 }, GameArchitecture.X64, + new MojangAssetIndex { MapToResources = true }, [], javaMajorVersion: 17); + + Assert.IsTrue(virtualAssets.Arguments.Contains(Path.Combine(root, "minecraft", "assets", "virtual", "1.19"))); + Assert.IsTrue(mappedAssets.Arguments.Contains(Path.Combine(root, "minecraft", "resources"))); + } + + [TestMethod] + public void CustomArgumentsCannotReplaceLauncherOwnedGameFlags() + { + using var temporary = new TemporaryDirectory(); + var error = Assert.ThrowsExactly(() => MinecraftLaunchPlan.ArgumentTemplates( + ReadManifest("1.6.4"), LaunchArgumentSource.Custom, + new InstanceParameters { MinecraftArguments = "--username somebody-else" }, GameArchitecture.X64)); + + Assert.AreEqual(MinecraftLaunchError.ReservedGameArgument, error.Error); + Assert.AreEqual("LaunchReservedGameArgument", error.ResourceKey); + } + + [TestMethod] + public void OfflineUuidMatchesJavaNameUuidAndIsCaseSensitive() + { + Assert.AreEqual("b50ad385829d3141a2167e7d7539ba7f", MinecraftLaunchIdentity.Offline("Notch").Uuid); + Assert.AreEqual("a01e3843e5213998958af459800e4d11", MinecraftLaunchIdentity.Offline("Player").Uuid); + Assert.AreNotEqual(MinecraftLaunchIdentity.Offline("Player").Uuid, MinecraftLaunchIdentity.Offline("player").Uuid); + Assert.AreEqual("0", MinecraftLaunchIdentity.Offline("Player").AccessToken); + Assert.AreEqual("legacy", MinecraftLaunchIdentity.Offline("Player").UserType); + Assert.IsFalse(MinecraftLaunchIdentity.Offline("Player").ToString().Contains("access-token", StringComparison.Ordinal)); + } + + [TestMethod] + public void NativeClassifierReplacesArchUsingThePinnedBuildArchitecture() + { + var manifest = JsonSerializer.Deserialize(""" + {"id":"fixture","libraries":[ + {"name":"org.lwjgl:lwjgl:3.0","natives":{"windows":"natives-windows-${arch}"}, + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.0/lwjgl.jar"}, + "classifiers":{"natives-windows-64":{"path":"org/lwjgl/lwjgl/3.0/native-64.jar"}, + "natives-windows-arm64":{"path":"org/lwjgl/lwjgl/3.0/native-arm64.jar"}}}, + "extract":{"exclude":[]}} + ]} + """, MojangJsonContext.Default.MojangVersionMetadata)!; + + var x64 = MojangCompatibility.Libraries(manifest, GameArchitecture.X64); + var arm64 = MojangCompatibility.Libraries(manifest, GameArchitecture.Arm64); + + Assert.AreEqual("org/lwjgl/lwjgl/3.0/native-64.jar", x64.Single(library => library.IsNative).Path); + Assert.AreEqual("org/lwjgl/lwjgl/3.0/native-arm64.jar", arm64.Single(library => library.IsNative).Path); + } + + private static MojangVersionMetadata ReadManifest(string version) + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Mojang", version + ".json"); + return JsonSerializer.Deserialize(File.ReadAllBytes(path), MojangJsonContext.Default.MojangVersionMetadata) + ?? throw new AssertFailedException($"Fixture {version} is empty."); + } +} diff --git a/windows/Hako.Tests/MinecraftSessionCoordinatorTests.cs b/windows/Hako.Tests/MinecraftSessionCoordinatorTests.cs new file mode 100644 index 0000000..5046fff --- /dev/null +++ b/windows/Hako.Tests/MinecraftSessionCoordinatorTests.cs @@ -0,0 +1,145 @@ +using Hako.Core; + +namespace Hako.Tests; + +[TestClass] +public sealed class MinecraftSessionCoordinatorTests +{ + [TestMethod] + public async Task RotatedRefreshTokenIsSavedEvenWhenMinecraftConnectionFails() + { + var accountStore = new MemoryAccountStore(TestData.Account()); + var auth = new RefreshAuthService(async (_, persist, cancellationToken) => + { + await persist("rotated-refresh", cancellationToken); + throw new HttpRequestException("simulated Xbox network failure"); + }); + var app = await CreateAppAsync(accountStore, auth, expireMinecraftToken: true); + + var error = await Assert.ThrowsExactlyAsync(() => app.MinecraftSessions.ConnectForLaunchAsync()); + + Assert.AreEqual(AuthErrorKind.Network, error.Kind); + Assert.AreEqual("rotated-refresh", accountStore.SavedAccount!.MicrosoftRefreshToken); + Assert.AreEqual("rotated-refresh", app.Account!.MicrosoftRefreshToken); + Assert.AreEqual("minecraft-secret", app.Account.MinecraftAccessToken); + Assert.AreEqual(MinecraftSessionStatus.Failed, app.MinecraftSessions.Status); + } + + [TestMethod] + public async Task ParallelLaunchesShareOneRefreshRequest() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reachedMinecraft = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var auth = new RefreshAuthService(async (_, persist, cancellationToken) => + { + await persist("rotated", cancellationToken); + reachedMinecraft.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return TestData.MinecraftConnection(); + }); + var app = await CreateAppAsync(new MemoryAccountStore(TestData.Account()), auth, expireMinecraftToken: true); + + var first = app.MinecraftSessions.ConnectForLaunchAsync(); + await reachedMinecraft.Task; + var second = app.MinecraftSessions.ConnectForLaunchAsync(); + Assert.AreEqual(1, auth.ConnectionCount); + release.TrySetResult(); + var accounts = await Task.WhenAll(first, second); + + Assert.AreEqual(accounts[0], accounts[1]); + Assert.AreEqual(MinecraftSessionStatus.Connected, app.MinecraftSessions.Status); + } + + [TestMethod] + public async Task LogoutCancelsRefreshAndLateResultCannotRestoreAccountFile() + { + var reachedMinecraft = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var auth = new RefreshAuthService(async (_, persist, cancellationToken) => + { + await persist("rotated-before-logout", cancellationToken); + reachedMinecraft.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return TestData.MinecraftConnection(); + }); + var accountStore = new MemoryAccountStore(TestData.Account()); + var app = await CreateAppAsync(accountStore, auth, expireMinecraftToken: true); + var refresh = app.MinecraftSessions.ConnectForLaunchAsync(); + await reachedMinecraft.Task; + + await app.LogoutAsync(); + + try + { + await refresh; + Assert.Fail("The refresh should have been cancelled by logout."); + } + catch (OperationCanceledException) + { + } + + Assert.IsNull(accountStore.SavedAccount); + Assert.IsNull(app.Account); + Assert.AreEqual(MinecraftSessionStatus.Idle, app.MinecraftSessions.Status); + } + + [TestMethod] + public async Task AccountReplacedWithinGenerationRetriesRefreshInsteadOfReportingNetworkError() + { + AppCoordinator? app = null; + var refreshTokens = new List(); + var auth = new RefreshAuthService(async (refreshToken, persist, cancellationToken) => + { + refreshTokens.Add(refreshToken); + if (refreshTokens.Count == 1) + { + // Simulates a cancelled earlier refresh that committed its rotated token in the same generation. + var state = app!.CaptureAccountState(); + Assert.IsTrue(app.TryCommitAccountUpdate(state.Account!, state.Generation, + state.Account! with { MicrosoftRefreshToken = "rotated-by-cancelled-refresh" }, () => { })); + } + + await persist(refreshToken + "-rotated", cancellationToken); + return TestData.MinecraftConnection(); + }); + var accountStore = new MemoryAccountStore(TestData.Account()); + app = await CreateAppAsync(accountStore, auth, expireMinecraftToken: true); + + var connected = await app.MinecraftSessions.ConnectForLaunchAsync(); + + Assert.AreEqual(2, auth.ConnectionCount); + Assert.AreEqual("rotated-by-cancelled-refresh", refreshTokens[1]); + Assert.AreEqual("rotated-by-cancelled-refresh-rotated", connected.MicrosoftRefreshToken); + Assert.AreEqual(connected, app.Account); + Assert.AreEqual(MinecraftSessionStatus.Connected, app.MinecraftSessions.Status); + Assert.IsNull(app.MinecraftSessions.Error); + } + + [TestMethod] + public async Task CurrentMinecraftSessionSkipsRefreshUntilNearExpiry() + { + var auth = new RefreshAuthService(); + var app = await CreateAppAsync(new MemoryAccountStore(TestData.Account()), auth); + + var connected = await app.MinecraftSessions.ConnectForLaunchAsync(); + + Assert.AreEqual(TestData.Account(), connected); + Assert.AreEqual(0, auth.ConnectionCount); + Assert.AreEqual(MinecraftSessionStatus.Connected, app.MinecraftSessions.Status); + } + + private static async Task CreateAppAsync( + MemoryAccountStore accountStore, + IMicrosoftAuthService auth, + bool expireMinecraftToken = false) + { + var time = new ManualTimeProvider(); + if (expireMinecraftToken) + { + time.Advance(TimeSpan.FromHours(2)); + } + + var app = new AppCoordinator(new UserSettingsService(new MemorySettingsStore()), accountStore, auth, time); + await app.InitializeAsync(); + return app; + } +} diff --git a/windows/Hako.Tests/ModpackTests.cs b/windows/Hako.Tests/ModpackTests.cs new file mode 100644 index 0000000..67af3a3 --- /dev/null +++ b/windows/Hako.Tests/ModpackTests.cs @@ -0,0 +1,767 @@ +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class ModpackTests +{ + private static readonly Uri ApiBase = new("https://api.test/v2/"); + private static readonly Uri FabricBase = new("https://meta.test/v2/"); + private const string Minecraft = "1.21.4"; + private const string Loader = "0.16.10"; + + [TestMethod] + public void SwiftPlanFixtureUsesExactKeysAndMissingCompletedDefaultsFalse() + { + var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "Modpack", "plan.json"); + var json = File.ReadAllText(fixturePath); + var plan = JsonSerializer.Deserialize(json, HakoJsonContext.Default.ModpackPlan)!; + + Assert.AreEqual("fixtureproject", plan.ProjectId); + Assert.AreEqual("fixtureversion", plan.VersionId); + Assert.IsFalse(plan.Completed); + Assert.AreEqual("1.21.4", plan.Manifest.Minecraft); + Assert.AreEqual("0.16.10", plan.Manifest.LoaderVersion); + Assert.IsTrue(plan.Manifest.Files[0].InstallOnClient); + + using var document = JsonDocument.Parse(JsonSerializer.Serialize(plan, HakoJsonContext.Default.ModpackPlan)); + var root = document.RootElement; + Assert.IsTrue(root.TryGetProperty("projectID", out _)); + Assert.IsTrue(root.TryGetProperty("versionID", out _)); + Assert.IsTrue(root.TryGetProperty("versionNumber", out _)); + Assert.IsTrue(root.TryGetProperty("pageURL", out _)); + Assert.IsFalse(root.TryGetProperty("projectId", out _)); + Assert.AreEqual("https://modrinth.com/modpack/fixture", root.GetProperty("pageURL").GetString()); + Assert.AreEqual("optional", root.GetProperty("manifest").GetProperty("files")[0].GetProperty("env").GetProperty("client").GetString()); + } + + [TestMethod] + public void ManifestRequiresSupportedDependenciesSafePathsAndExactDownloadHosts() + { + var manifest = Manifest([ManifestFile("mods/optional.jar", Bytes("optional"), client: "optional"), + ManifestFile("mods/server.jar", Bytes("server"), client: "unsupported")]); + manifest.Validate(); + Assert.IsTrue(manifest.Files[0].InstallOnClient); + Assert.IsFalse(manifest.Files[1].InstallOnClient); + + Assert.ThrowsExactly(() => (manifest with + { + Dependencies = new Dictionary { ["minecraft"] = Minecraft, ["forge"] = "47" } + }).Validate()); + Assert.ThrowsExactly(() => (manifest with + { + Files = [ManifestFile("mods/bad.jar", Bytes("bad"), url: "https://evil.example/bad.jar")] + }).Validate()); + foreach (var url in new[] + { + "http://cdn.modrinth.com/files/mod.jar", + "https://cdn.modrinth.com.evil.example/files/mod.jar", + "https://cdn.modrinth.com:444/files/mod.jar", + "https://user@cdn.modrinth.com/files/mod.jar" + }) + { + Assert.ThrowsExactly(() => (manifest with + { + Files = [ManifestFile("mods/bad.jar", Bytes("bad"), url: url)] + }).Validate(), url); + } + var plan = MakePlan(Bytes("archive"), manifest); + Assert.ThrowsExactly(() => (plan with + { + Archive = plan.Archive with { Url = new Uri("https://cdn.modrinth.com:444/files/pack.mrpack") } + }).Validate()); + Assert.ThrowsExactly(() => (manifest with + { + Files = [ManifestFile("mods/case.jar", Bytes("a")), ManifestFile("mods/CASE.jar", Bytes("b"))] + }).Validate()); + Assert.ThrowsExactly(() => (manifest with + { + Files = [ManifestFile("mods/file", Bytes("a")), ManifestFile("mods/file/child", Bytes("b"))] + }).Validate()); + } + + [TestMethod] + [DataRow("../escape.jar")] + [DataRow("mods/../../escape.jar")] + [DataRow("C:/outside.jar")] + [DataRow("C:outside.jar")] + [DataRow("//server/share/file.jar")] + [DataRow(@"\\server\share\file.jar")] + [DataRow("mods/file.jar:stream")] + [DataRow("mods//file.jar")] + [DataRow("mods/")] + [DataRow("mods/file.jar.")] + [DataRow("mods/file.jar ")] + [DataRow("mods/CON.txt")] + [DataRow("mods/LPT1.jar")] + [DataRow(".hako-modpack.json")] + [DataRow("minecraft/options.txt")] + [DataRow("libraries/client.jar")] + [DataRow("assets/index.json")] + public void WindowsUnsafePathsAreRejected(string path) => + Assert.ThrowsExactly(() => ModpackPaths.Validate(path)); + + [TestMethod] + public void ArchiveAppliesCommonThenClientOverridesAndIgnoresServerOverrides() + { + using var temporary = new TemporaryDirectory(); + var archivePath = MakeArchive(temporary.Root, Manifest(), new Dictionary + { + ["overrides/config/settings.txt"] = Bytes("common"), + ["client-overrides/config/settings.txt"] = Bytes("client"), + ["server-overrides/config/settings.txt"] = Bytes("server") + }); + + var contents = ModpackArchive.Inspect(archivePath); + + Assert.AreEqual(1, contents.Overrides.Count); + Assert.AreEqual("config/settings.txt", contents.Overrides[0].Path); + Assert.AreEqual("client-overrides/config/settings.txt", contents.Overrides[0].EntryPath); + Assert.AreEqual(Bytes("client").Length, contents.Overrides[0].Size); + } + + [TestMethod] + public void ArchiveRejectsSymlinksAndBoundedMetadataAndExpandedSize() + { + using var temporary = new TemporaryDirectory(); + var symlink = MakeArchive(temporary.Root, Manifest(), new Dictionary { ["overrides/link"] = Bytes("target") }, + attributes: new Dictionary { ["overrides/link"] = unchecked((int)0xA0000000) }); + Assert.ThrowsExactly(() => ModpackArchive.Inspect(symlink)); + + var manyEntries = MakeArchive(temporary.Root, Manifest(), new Dictionary { ["overrides/a"] = [1] }); + Assert.ThrowsExactly(() => ModpackArchive.Inspect(manyEntries, + ModpackArchiveLimits.Default with { MaximumEntries = 1 })); + Assert.ThrowsExactly(() => ModpackArchive.Inspect(manyEntries, + ModpackArchiveLimits.Default with { MaximumIndexSize = 1 })); + Assert.ThrowsExactly(() => ModpackArchive.Inspect(manyEntries, + ModpackArchiveLimits.Default with { MaximumExpandedSize = 1 })); + Assert.ThrowsExactly(() => ModpackArchive.Inspect(manyEntries, + ModpackArchiveLimits.Default with { MaximumArchiveSize = 1 })); + } + + [TestMethod] + public async Task ModrinthModpackSearchUsesOptionalExactMinecraftFacetAndVersionMetadata() + { + using var temporary = new TemporaryDirectory(); + var requests = new List(); + var versionBytes = JsonSerializer.SerializeToUtf8Bytes(new[] + { + Version("release", "release", "2026-01-01T00:00:00Z", [Minecraft], ["fabric"], "pack.mrpack"), + Version("beta", "beta", "2026-02-01T00:00:00Z", [Minecraft], ["minecraft"], "pack-beta.mrpack"), + Version("forge", "release", "2026-03-01T00:00:00Z", [Minecraft], ["forge"], "forge.mrpack"), + Version("wrong-game", "release", "2026-04-01T00:00:00Z", ["1.20.1"], ["minecraft"], "old.mrpack"), + Version("wrong-file", "release", "2026-05-01T00:00:00Z", [Minecraft], ["minecraft"], "pack.jar") + }); + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((request, _) => + { + requests.Add(request.RequestUri!); + var body = request.RequestUri!.AbsolutePath == "/v2/search" + ? "{\"hits\":[],\"total_hits\":0}" + : Encoding.UTF8.GetString(versionBytes); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }); + })), ApiBase, cacheRoot: temporary.Root); + + await client.SearchModpacksAsync("c++", null, ModrinthSort.Downloads, offset: 20); + await client.SearchModpacksAsync("", Minecraft, ModrinthSort.Updated); + var versions = await client.GetModpackVersionsAsync("project123", Minecraft); + + var searchWithoutVersion = Uri.UnescapeDataString(requests[0].Query); + var searchWithVersion = Uri.UnescapeDataString(requests[1].Query); + StringAssert.Contains(searchWithoutVersion, "project_type:modpack"); + StringAssert.Contains(searchWithoutVersion, "categories:fabric"); + StringAssert.Contains(searchWithoutVersion, "categories:minecraft"); + StringAssert.Contains(searchWithoutVersion, "environment!=dedicated_server_only"); + Assert.IsFalse(searchWithoutVersion.Contains("versions:", StringComparison.Ordinal)); + StringAssert.Contains(searchWithVersion, "versions:1.21.4"); + StringAssert.Contains(requests[0].Query, "offset=20"); + StringAssert.Contains(requests[0].Query, "index=downloads"); + StringAssert.Contains(requests[1].Query, "index=updated"); + CollectionAssert.AreEqual(new[] { "release", "beta" }, versions.Select(item => item.Id).ToArray()); + } + + [TestMethod] + public async Task PreparePinsCatalogMinecraftAndFabricProfileWithoutRequestingFabricApi() + { + using var temporary = new TemporaryDirectory(); + var manifest = Manifest([], new Dictionary { ["minecraft"] = Minecraft, ["fabric-loader"] = Loader }); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary()); + var handler = new PackHttpHandler(archiveBytes); + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, metaBase: FabricBase, modrinthBase: ApiBase); + var installer = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var project = new ModrinthProject { Id = "project123", Slug = "pack", ProjectType = "modpack", Title = "Pack" }; + var version = ModpackVersion("version123", archiveBytes, [Minecraft], ["fabric"]); + + var prepared = await installer.PrepareAsync(project, version); + + Assert.AreEqual(Minecraft, prepared.Plan.Manifest.Minecraft); + Assert.AreEqual(Loader, prepared.Plan.Manifest.LoaderVersion); + Assert.AreEqual(Minecraft, prepared.Minecraft.Version.Id); + Assert.AreEqual("fabric-loader-0.16.10-1.21.4", prepared.FabricProfile!.Id); + Assert.AreEqual(0, handler.Requests.Count(uri => uri.AbsolutePath.Contains(FabricClient.FabricApiProjectId, StringComparison.Ordinal))); + Assert.AreEqual(0, handler.Requests.Count(uri => uri.AbsolutePath.Contains("api", StringComparison.OrdinalIgnoreCase))); + + await Assert.ThrowsExactlyAsync(() => installer.PrepareAsync(project, version, "1.20.1")); + } + + [TestMethod] + [DataRow("valid")] + [DataRow("sha1")] + [DataRow("sha512")] + [DataRow("body")] + public async Task PrepareAndInstallEmptyRequiredFileVerifiesBothHashesAndActualBody(string scenario) + { + using var temporary = new TemporaryDirectory(); + var file = ManifestFile("config/empty.txt", [], url: "https://cdn.modrinth.com/files/empty.txt"); + if (scenario is "sha1" or "sha512") + file.Hashes[scenario] = scenario == "sha1" ? Sha1(Bytes("wrong")) : Sha512(Bytes("wrong")); + var manifest = Manifest([file]); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary()); + var handler = new PackHttpHandler(archiveBytes, + new Dictionary { ["/files/empty.txt"] = scenario == "body" ? Bytes("not empty") : [] }) + { + DeclaredFileLength = 0 + }; + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, modrinthBase: ApiBase); + var installer = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var project = new ModrinthProject { Id = "project123", Slug = "pack", ProjectType = "modpack", Title = "Pack" }; + var prepared = await installer.PrepareAsync(project, ModpackVersion("version123", archiveBytes, [Minecraft], ["minecraft"])); + var root = Path.Combine(temporary.Root, "Instance"); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + prepared.Plan.Write(root); + var destination = Path.Combine(root, "minecraft", "config", "empty.txt"); + + if (scenario != "valid") + { + var error = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(root, null, CancellationToken.None)); + Assert.AreEqual(ModpackError.DamagedFile, error.Error); + Assert.IsFalse(File.Exists(destination)); + Assert.IsFalse(ModpackPlan.Read(root)!.Completed); + Assert.AreEqual(1, handler.Requests.Count(uri => uri.AbsolutePath == "/files/empty.txt")); + return; + } + + await installer.InstallAsync(root, null, CancellationToken.None); + Assert.IsTrue(File.Exists(destination)); + Assert.AreEqual(0L, new FileInfo(destination).Length); + Assert.IsTrue(ModpackPlan.Read(root)!.Completed); + Assert.AreEqual(1, handler.Requests.Count(uri => uri.AbsolutePath == "/files/empty.txt")); + var requests = handler.Requests.Count; + await (prepared.Plan with { Completed = false }).WriteAsync(root); + await installer.InstallAsync(root, null, CancellationToken.None); + Assert.AreEqual(requests, handler.Requests.Count, "Retry reuses the verified empty cache file."); + File.Delete(destination); + await installer.InstallAsync(root, null, CancellationToken.None); + Assert.IsFalse(File.Exists(destination), "A completed import must not replay a deleted empty file."); + Assert.AreEqual(requests, handler.Requests.Count); + } + + [TestMethod] + public async Task InstallStagesAllFilesThenPublishesOverridesAndCompletedReinstallDoesNotReplay() + { + using var temporary = new TemporaryDirectory(); + var required = Bytes("required mod"); + var optional = Bytes("optional mod"); + var unsupported = Bytes("server-only mod"); + var manifest = Manifest( + [ + ManifestFile("mods/required.jar", required, "required", "https://cdn.modrinth.com/files/required.jar"), + ManifestFile("mods/optional.jar", optional, "optional", "https://cdn.modrinth.com/files/optional.jar"), + ManifestFile("mods/server-only.jar", unsupported, "unsupported", "https://cdn.modrinth.com/files/server-only.jar") + ]); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary + { + ["overrides/config/settings.txt"] = Bytes("common"), + ["client-overrides/config/settings.txt"] = Bytes("client"), + ["server-overrides/config/server.txt"] = Bytes("server") + }); + var handler = new PackHttpHandler(archiveBytes, new Dictionary + { + ["/files/required.jar"] = required, + ["/files/optional.jar"] = optional, + ["/files/server-only.jar"] = unsupported + }); + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, modrinthBase: ApiBase); + var installer = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var plan = MakePlan(archiveBytes, manifest); + var root = Path.Combine(temporary.Root, "Instance"); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + plan.Write(root); + + await installer.InstallAsync(root, null, CancellationToken.None); + + Assert.AreEqual("required mod", await File.ReadAllTextAsync(Path.Combine(root, "minecraft", "mods", "required.jar"))); + Assert.AreEqual("optional mod", await File.ReadAllTextAsync(Path.Combine(root, "minecraft", "mods", "optional.jar"))); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "mods", "server-only.jar"))); + Assert.AreEqual("client", await File.ReadAllTextAsync(Path.Combine(root, "minecraft", "config", "settings.txt"))); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "config", "server.txt"))); + Assert.IsTrue(ModpackPlan.Read(root)!.Completed); + + await File.WriteAllTextAsync(Path.Combine(root, "minecraft", "mods", "required.jar"), "user edit"); + var requestsBeforeReplay = handler.Requests.Count; + await installer.InstallAsync(root, null, CancellationToken.None); + Assert.AreEqual("user edit", await File.ReadAllTextAsync(Path.Combine(root, "minecraft", "mods", "required.jar"))); + Assert.AreEqual(requestsBeforeReplay, handler.Requests.Count); + } + + [TestMethod] + public async Task FileDownloadFallsBackAfterWrongHashAndReusesVerifiedSha512Cache() + { + using var temporary = new TemporaryDirectory(); + var payload = Bytes("verified payload"); + var manifest = Manifest([ManifestFile("mods/verified.jar", payload, + url: "https://cdn.modrinth.com/files/first.jar") with + { + Downloads = + [ + new Uri("https://cdn.modrinth.com/files/first.jar"), + new Uri("https://raw.githubusercontent.com/hako-fixtures/pack/main/verified.jar") + ] + }]); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary()); + var handler = new PackHttpHandler(archiveBytes, + new Dictionary { ["/files/first.jar"] = payload, ["/hako-fixtures/pack/main/verified.jar"] = payload }) + { + FailFirstFileRequest = true + }; + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, modrinthBase: ApiBase); + var installer = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var plan = MakePlan(archiveBytes, manifest); + var root = Path.Combine(temporary.Root, "First Instance"); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + plan.Write(root); + + await installer.InstallAsync(root, null, CancellationToken.None); + + Assert.AreEqual("verified payload", await File.ReadAllTextAsync(Path.Combine(root, "minecraft", "mods", "verified.jar"))); + var fileRequests = handler.Requests.Where(uri => uri.AbsolutePath.EndsWith("verified.jar", StringComparison.Ordinal) + || uri.AbsolutePath.EndsWith("first.jar", StringComparison.Ordinal)).ToArray(); + CollectionAssert.AreEqual(new[] { "/files/first.jar", "/hako-fixtures/pack/main/verified.jar" }, + fileRequests.Select(uri => uri.AbsolutePath).ToArray()); + + var requestsAfterFirstInstall = handler.Requests.Count; + await (plan with { Completed = false }).WriteAsync(root); + await installer.InstallAsync(root, null, CancellationToken.None); + + Assert.AreEqual(requestsAfterFirstInstall, handler.Requests.Count, "The second import should use the verified SHA-512 cache."); + Assert.IsTrue(ModpackPlan.Read(root)!.Completed); + } + + [TestMethod] + public async Task CancellingModpackDownloadRemovesStagingAndDoesNotMarkPlanComplete() + { + using var temporary = new TemporaryDirectory(); + var payload = Bytes("cancelled payload"); + var manifest = Manifest([ManifestFile("mods/cancelled.jar", payload, + url: "https://cdn.modrinth.com/files/cancelled.jar")]); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary()); + var handler = new PackHttpHandler(archiveBytes, new Dictionary { ["/files/cancelled.jar"] = payload }) + { + BlockFileRequests = true + }; + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, modrinthBase: ApiBase); + var installer = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var root = Path.Combine(temporary.Root, "Cancelled Instance"); + Directory.CreateDirectory(Path.Combine(root, "minecraft")); + MakePlan(archiveBytes, manifest).Write(root); + using var cancellation = new CancellationTokenSource(); + + var install = installer.InstallAsync(root, null, cancellation.Token); + await handler.FileRequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + try + { + await install; + Assert.Fail("The canceled download must not complete the import."); + } + catch (OperationCanceledException) + { + } + + Assert.IsFalse(ModpackPlan.Read(root)!.Completed); + Assert.IsFalse(File.Exists(Path.Combine(root, "minecraft", "mods", "cancelled.jar"))); + Assert.AreEqual(0, Directory.GetDirectories(root, ".hako-modpack-*", SearchOption.TopDirectoryOnly).Length); + } + + [TestMethod] + public async Task QueueWritesPlanBeforeWorkWaitsForContentAndRetriesDamagedDownload() + { + await using var temporary = new TemporaryDirectory(); + var payload = Bytes("queued mod"); + var manifest = Manifest([ManifestFile("mods/queued.jar", payload, url: "https://cdn.modrinth.com/files/queued.jar")]); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary()); + var handler = new PackHttpHandler(archiveBytes, new Dictionary { ["/files/queued.jar"] = payload }) + { + FailFirstFileRequest = true, + BlockFileRequests = true + }; + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, modrinthBase: ApiBase); + var modpacks = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var store = temporary.CreateStore(); + await store.LoadAsync(); + var minecraft = PreparedMinecraft(); + var coordinator = new InstallationCoordinator(store, + (_, _, _) => Task.FromResult(minecraft), + (prepared, root, _, _) => + { + Assert.IsTrue(File.Exists(Path.Combine(root, ModpackPlan.Filename)), "The pinned plan must be durable before install work starts."); + return Task.FromResult(new InstallationResult(prepared.Architecture, "bin/javaw.exe", 21, false)); + }, + modpackInstaller: modpacks); + await coordinator.StartAsync(); + + var instance = await coordinator.CreateAndEnqueueModpackAsync( + new InstanceDraft { Name = "Queued Pack", ModLoader = InstanceModLoader.Vanilla }, + new PreparedModpack(MakePlan(archiveBytes, manifest), minecraft)); + Assert.IsNotNull(ModpackPlan.Read(store.GetInstanceDirectory(instance.Id))); + var failed = await WaitForTerminalStateAsync(store, instance.Id); + Assert.AreEqual(InstanceInstallationState.Failed, failed.State); + Assert.AreEqual("modpack.damagedfile", failed.ErrorCode); + Assert.IsFalse(ModpackPlan.Read(store.GetInstanceDirectory(instance.Id))!.Completed); + Assert.IsFalse(File.Exists(Path.Combine(store.GetInstanceDirectory(instance.Id), "minecraft", "mods", "queued.jar"))); + + await coordinator.RetryAsync(instance.Id); + await handler.FileRequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var installing = store.Instances.Single(item => item.Id == instance.Id); + Assert.AreEqual(InstanceInstallationState.Installing, installing.State); + Assert.IsFalse(ModpackPlan.Read(store.GetInstanceDirectory(instance.Id))!.Completed); + handler.ReleaseFileRequest.TrySetResult(); + var ready = await WaitForTerminalStateAsync(store, instance.Id); + Assert.AreEqual(InstanceInstallationState.Ready, ready.State); + Assert.IsTrue(ModpackPlan.Read(store.GetInstanceDirectory(instance.Id))!.Completed); + Assert.AreEqual("queued mod", await File.ReadAllTextAsync(Path.Combine(store.GetInstanceDirectory(instance.Id), "minecraft", "mods", "queued.jar"))); + + await coordinator.DisposeAsync(); + } + + [TestMethod] + public async Task PausedQueuedPlanResumesAndMissingPlanFailsInsteadOfInstallingVanilla() + { + await using var temporary = new TemporaryDirectory(); + var manifest = Manifest([]); + var archiveBytes = MakeArchiveBytes(manifest, new Dictionary()); + var handler = new PackHttpHandler(archiveBytes); + using var http = new HttpClient(handler); + using var mojang = new MojangClient(http, new FixedHostArchitectureProvider()); + using var fabric = new FabricClient(http, temporary.Paths, modrinthBase: ApiBase); + var modpacks = new ModpackInstaller(fabric.Modrinth, mojang, fabric, temporary.Paths); + var store = temporary.CreateStore(); + await store.LoadAsync(); + var minecraft = PreparedMinecraft(); + var instance = store.Create(new InstanceDraft { Name = "Paused Pack" }, minecraft, modpackPlan: MakePlan(archiveBytes, manifest)); + store.SetInstallationStatus(instance.Id, InstanceInstallationState.Paused, pauseRequested: true); + var coordinator = new InstallationCoordinator(store, + (_, _, _) => Task.FromResult(minecraft), + (prepared, _, _, _) => Task.FromResult(new InstallationResult(prepared.Architecture, "bin/javaw.exe", 21, false)), + modpackInstaller: modpacks); + await coordinator.StartAsync(); + Assert.AreEqual(InstanceInstallationState.Paused, store.Instances.Single(item => item.Id == instance.Id).State); + await coordinator.ResumeAsync(instance.Id); + Assert.AreEqual(InstanceInstallationState.Ready, (await WaitForTerminalStateAsync(store, instance.Id)).State); + await coordinator.DisposeAsync(); + + var missingStore = temporary.CreateStore(); + await missingStore.LoadAsync(); + var missing = missingStore.Create(new InstanceDraft { Name = "Missing Plan" }, minecraft, modpackPlan: MakePlan(archiveBytes, manifest)); + File.Delete(Path.Combine(missingStore.GetInstanceDirectory(missing.Id), ModpackPlan.Filename)); + var missingCoordinator = new InstallationCoordinator(missingStore, + (_, _, _) => Task.FromResult(minecraft), + (prepared, _, _, _) => Task.FromResult(new InstallationResult(prepared.Architecture, "bin/javaw.exe", 21, false)), + modpackInstaller: modpacks); + await missingCoordinator.StartAsync(); + var failed = await WaitForTerminalStateAsync(missingStore, missing.Id); + Assert.AreEqual(InstanceInstallationState.Failed, failed.State); + Assert.AreEqual("modpack.invalidmanifest", failed.ErrorCode); + await missingCoordinator.DisposeAsync(); + } + + private static ModpackManifest Manifest(IReadOnlyList? files = null, + Dictionary? dependencies = null) => new() + { + FormatVersion = 1, + Game = "minecraft", + VersionId = "pack-v1", + Name = "Fixture Pack", + Files = files?.ToList() ?? [], + Dependencies = dependencies ?? new Dictionary { ["minecraft"] = Minecraft } + }; + + private static ModpackManifestFile ManifestFile(string path, byte[] bytes, string client = "required", + string url = "https://cdn.modrinth.com/files/content.jar") => new() + { + Path = path, + Hashes = new Dictionary { ["sha1"] = Sha1(bytes), ["sha512"] = Sha512(bytes) }, + Environment = new ModpackEnvironment { Client = client, Server = "optional" }, + Downloads = [new Uri(url)], + FileSize = bytes.Length + }; + + private static ModpackPlan MakePlan(byte[] archive, ModpackManifest manifest) => new() + { + ProjectId = "project123", + VersionId = "version123", + VersionNumber = "1.0.0", + PageUrl = new Uri("https://modrinth.com/modpack/fixture"), + Archive = new ModpackFile + { + Filename = "fixture.mrpack", + Url = new Uri("https://cdn.modrinth.com/packs/fixture.mrpack"), + Size = archive.Length, + Hashes = new Dictionary { ["sha1"] = Sha1(archive), ["sha512"] = Sha512(archive) }, + Primary = true + }, + Manifest = manifest + }; + + private static string MakeArchive(string directory, ModpackManifest manifest, IReadOnlyDictionary entries, + IReadOnlyDictionary? attributes = null) + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".mrpack"); + File.WriteAllBytes(path, MakeArchiveBytes(manifest, entries, attributes)); + return path; + } + + private static byte[] MakeArchiveBytes(ModpackManifest manifest, IReadOnlyDictionary entries, + IReadOnlyDictionary? attributes = null) + { + using var output = new MemoryStream(); + using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + var index = archive.CreateEntry("modrinth.index.json", CompressionLevel.Fastest); + using (var stream = index.Open()) + JsonSerializer.Serialize(stream, manifest, HakoJsonContext.Default.ModpackManifest); + foreach (var (name, data) in entries) + { + var entry = archive.CreateEntry(name, CompressionLevel.Fastest); + if (attributes?.TryGetValue(name, out var entryAttributes) == true) entry.ExternalAttributes = entryAttributes; + using var stream = entry.Open(); + stream.Write(data); + } + } + + return output.ToArray(); + } + + private static ModrinthVersion Version(string id, string channel, string date, List games, List loaders, string filename) + { + var bytes = Bytes("archive"); + var file = new ModrinthFile + { + Filename = filename, + Url = new Uri("https://cdn.modrinth.com/" + filename), + Size = bytes.Length, + Hashes = new Dictionary { ["sha1"] = Sha1(bytes), ["sha512"] = Sha512(bytes) }, + Primary = true + }; + return new ModrinthVersion + { + Id = id, + ProjectId = "project123", + VersionNumber = id, + VersionType = channel, + DatePublished = DateTimeOffset.Parse(date), + GameVersions = games, + Loaders = loaders, + Files = [file] + }; + } + + private static ModrinthVersion ModpackVersion(string id, byte[] archive, List games, List loaders) => new() + { + Id = id, + ProjectId = "project123", + VersionNumber = "1.0.0", + VersionType = "release", + DatePublished = DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + GameVersions = games, + Loaders = loaders, + Files = [new ModrinthFile + { + Filename = "fixture.mrpack", + Url = new Uri("https://cdn.modrinth.com/packs/fixture.mrpack"), + Size = archive.Length, + Hashes = new Dictionary { ["sha1"] = Sha1(archive), ["sha512"] = Sha512(archive) }, + Primary = true + }] + }; + + private static PreparedInstallation PreparedMinecraft() + { + var version = new MojangVersionEntry + { + Id = Minecraft, + Type = "release", + Url = "https://piston-meta.mojang.com/metadata.json", + Sha1 = new string('a', 40) + }; + return new PreparedInstallation(version, GameArchitecture.X64, + new MojangVersionMetadata + { + Id = Minecraft, + Downloads = new Dictionary { ["client"] = new() { Url = "https://piston-meta.mojang.com/client.jar", Sha1 = new string('0', 40), Size = 0 } }, + Libraries = [], + JavaVersion = new MojangJavaRequirement { Component = "java-runtime", MajorVersion = 21 } + }, [], new MojangJavaRuntimePackage(), new MojangJavaRuntimeManifest(), [], []); + } + + private static async Task WaitForTerminalStateAsync(InstanceStore store, Guid id) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void Changed(object? _, EventArgs __) + { + var current = store.Instances.FirstOrDefault(item => item.Id == id); + if (current?.State is InstanceInstallationState.Ready or InstanceInstallationState.Failed) + completion.TrySetResult(current); + } + + store.InstancesChanged += Changed; + try + { + Changed(null, EventArgs.Empty); + return await completion.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + store.InstancesChanged -= Changed; + } + } + + private static byte[] Bytes(string text) => Encoding.UTF8.GetBytes(text); + private static string Sha1(byte[] bytes) => Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant(); + private static string Sha512(byte[] bytes) => Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + + private sealed class FixedHostArchitectureProvider : IHostArchitectureProvider + { + public HostArchitecture GetHostArchitecture() => new(HostMachineArchitecture.X64, false); + } + + private sealed class PackHttpHandler : HttpMessageHandler + { + private readonly byte[] _archive; + private readonly IReadOnlyDictionary _files; + private int _fileRequests; + + public PackHttpHandler(byte[] archive, IReadOnlyDictionary? files = null) + { + _archive = archive; + _files = files ?? new Dictionary(); + } + + public List Requests { get; } = []; + public bool FailFirstFileRequest { get; init; } + public bool BlockFileRequests { get; init; } + public long? DeclaredFileLength { get; init; } + public TaskCompletionSource FileRequestStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseFileRequest { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri!; + lock (Requests) Requests.Add(uri); + if (uri.AbsoluteUri == MojangClient.CatalogUrl) return Json(MojangCatalogJson()); + if (uri.AbsoluteUri == MojangClient.RuntimeIndexUrl) return Json(MojangRuntimeIndexJson()); + if (uri.AbsolutePath == "/metadata.json") return BytesResponse(MojangMetadataBytes(Minecraft)); + if (uri.AbsolutePath == "/runtime.json") return BytesResponse(MojangRuntimeBytes()); + if (uri.AbsolutePath == "/packs/fixture.mrpack") return BytesResponse(_archive); + if (uri.AbsolutePath == "/v2/versions/loader/1.21.4") + return Json("[{\"loader\":{\"version\":\"0.16.10\",\"stable\":true}}]"); + if (uri.AbsolutePath == "/v2/versions/loader/1.21.4/0.16.10/profile/json") + return Json("{\"id\":\"fabric-loader-0.16.10-1.21.4\",\"inheritsFrom\":\"1.21.4\",\"mainClass\":\"net.fabricmc.loader.impl.launch.knot.KnotClient\",\"libraries\":[]}"); + if (uri.AbsolutePath.Contains(FabricClient.FabricApiProjectId, StringComparison.Ordinal)) + return Json("[]"); + if (_files.TryGetValue(uri.AbsolutePath, out var bytes)) + { + var requestNumber = Interlocked.Increment(ref _fileRequests); + if (BlockFileRequests && (!FailFirstFileRequest || requestNumber > 1)) + { + FileRequestStarted.TrySetResult(); + await ReleaseFileRequest.Task.WaitAsync(cancellationToken); + } + + if (FailFirstFileRequest && requestNumber == 1) return BytesResponse(Bytes("wrong first payload")); + var response = BytesResponse(bytes); + if (DeclaredFileLength is { } length) response.Content.Headers.ContentLength = length; + return response; + } + + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + } + + private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static HttpResponseMessage BytesResponse(byte[] bytes) => new(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes) + }; + } + + private static string MojangCatalogJson() + { + var metadata = MojangMetadataBytes(Minecraft); + return JsonSerializer.Serialize(new + { + latest = new { release = Minecraft }, + versions = new[] { new { id = Minecraft, type = "release", url = "https://piston-meta.mojang.com/metadata.json", sha1 = Sha1(metadata) } } + }); + } + + private static byte[] MojangMetadataBytes(string id) => JsonSerializer.SerializeToUtf8Bytes(new + { + id, + downloads = new { client = new { url = "https://piston-meta.mojang.com/client.jar", sha1 = new string('0', 40), size = 0 } }, + libraries = Array.Empty(), + javaVersion = new { component = "java-runtime", majorVersion = 21 }, + mainClass = "net.minecraft.client.main.Main", + arguments = new { game = Array.Empty(), jvm = Array.Empty() } + }); + + private static byte[] MojangRuntimeBytes() => JsonSerializer.SerializeToUtf8Bytes(new + { + files = new Dictionary + { + ["bin/javaw.exe"] = new { type = "file", executable = true, + downloads = new { raw = new { url = "https://piston-meta.mojang.com/javaw.exe", sha1 = new string('0', 40), size = 0 } } } + } + }); + + private static string MojangRuntimeIndexJson() + { + var runtime = MojangRuntimeBytes(); + return JsonSerializer.Serialize(new Dictionary + { + ["windows-x64"] = new Dictionary + { + ["java-runtime"] = new[] + { + new { manifest = new { url = "https://piston-meta.mojang.com/runtime.json", sha1 = Sha1(runtime), size = runtime.Length }, + version = new { name = "21.0.1", released = "2026-01-01T00:00:00Z" } } + } + } + }); + } +} diff --git a/windows/Hako.Tests/ModrinthTests.cs b/windows/Hako.Tests/ModrinthTests.cs new file mode 100644 index 0000000..d1c8434 --- /dev/null +++ b/windows/Hako.Tests/ModrinthTests.cs @@ -0,0 +1,567 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Hako.Core.Minecraft; + +namespace Hako.Tests; + +[TestClass] +public sealed class ModrinthTests +{ + [TestMethod] + public void ProjectArraysRoundTripWithSourceGeneratedContext() + { + ModrinthProject[] projects = + [ + new() { Id = "dual", Slug = "dual-pack", Title = "Pack", Description = "Both types", + ProjectType = "resourcepack", AllProjectTypes = ["resourcepack", "datapack"], + IconUrl = new Uri("https://cdn.test/icon.png") }, + new() { Id = "empty-types", ProjectType = "mod", AllProjectTypes = [] } + ]; + var json = JsonSerializer.Serialize(projects, Hako.Core.HakoJsonContext.Default.ModrinthProjectArray); + var restored = JsonSerializer.Deserialize(json, Hako.Core.HakoJsonContext.Default.ModrinthProjectArray)!; + Assert.AreEqual(2, restored.Length); + Assert.AreEqual(projects[0].Id, restored[0].Id); + Assert.AreEqual(projects[0].Slug, restored[0].Slug); + Assert.AreEqual(projects[0].Title, restored[0].Title); + Assert.AreEqual(projects[0].Description, restored[0].Description); + Assert.AreEqual(projects[0].IconUrl, restored[0].IconUrl); + Assert.AreEqual(projects[0].ProjectType, restored[0].ProjectType); + CollectionAssert.AreEqual(projects[0].AllProjectTypes.ToArray(), restored[0].AllProjectTypes.ToArray()); + Assert.AreEqual(0, restored[1].AllProjectTypes.Count); + + var emptyJson = JsonSerializer.Serialize(Array.Empty(), Hako.Core.HakoJsonContext.Default.ModrinthProjectArray); + Assert.AreEqual("[]", emptyJson); + Assert.AreEqual(0, JsonSerializer.Deserialize(emptyJson, Hako.Core.HakoJsonContext.Default.ModrinthProjectArray)!.Length); + } + + private static readonly Uri ApiBase = new("https://api.test/v2/"); + + [TestMethod] + public async Task SearchUsesExactFacetsEscapesPlusAndSupportsPaginationAndSort() + { + using var temporary = new TemporaryDirectory(); + string? requestUri = null; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((request, _) => + { + requestUri = request.RequestUri!.AbsoluteUri; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, + """{"hits":[{"project_id":"project123","title":"Sodium","project_type":"mod"}],"total_hits":41}""")); + })), ApiBase, cacheRoot: temporary.Root); + + var page = await client.SearchAsync(" shaders + packs ", ModrinthContentKind.Mod, "1.20.4", + ModrinthSort.Updated, offset: 20, limit: 10); + + Assert.AreEqual(41, page.TotalHits); + Assert.AreEqual("project123", page.Hits.Single().Id); + StringAssert.Contains(requestUri!, "query=shaders%20%2B%20packs"); + StringAssert.Contains(requestUri!, "index=updated"); + StringAssert.Contains(requestUri!, "offset=20"); + StringAssert.Contains(requestUri!, "limit=10"); + var decoded = Uri.UnescapeDataString(requestUri!); + StringAssert.Contains(decoded, "project_type:mod"); + StringAssert.Contains(decoded, "categories:fabric"); + StringAssert.Contains(decoded, "versions:1.20.4"); + StringAssert.Contains(decoded, "environment!=dedicated_server_only"); + } + + [TestMethod] + public async Task ResourcepackSearchUsesOnlyResourcepackAndExactVersionFacets() + { + using var temporary = new TemporaryDirectory(); + string? requestUri = null; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((request, _) => + { + requestUri = request.RequestUri!.AbsoluteUri; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, "{\"hits\":[],\"total_hits\":0}")); + })), ApiBase, cacheRoot: temporary.Root); + + await client.SearchAsync("", ModrinthContentKind.ResourcePack, "26.1", offset: 40); + + var decoded = Uri.UnescapeDataString(requestUri!); + StringAssert.Contains(decoded, "project_type:resourcepack"); + StringAssert.Contains(decoded, "versions:26.1"); + Assert.IsFalse(decoded.Contains("categories:fabric", StringComparison.Ordinal)); + Assert.IsFalse(decoded.Contains("environment!=dedicated_server_only", StringComparison.Ordinal)); + Assert.IsFalse(decoded.Contains("query=", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task DatapackSearchUsesAllTypesCategoryAndMinecraftFacets() + { + using var temporary = new TemporaryDirectory(); + string? requestUri = null; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((request, _) => + { + requestUri = request.RequestUri!.AbsoluteUri; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, "{\"hits\":[],\"total_hits\":0}")); + })), ApiBase, cacheRoot: temporary.Root); + + await client.SearchAsync("world gen", ModrinthContentKind.Datapack, "1.20.4"); + + var decoded = Uri.UnescapeDataString(requestUri!); + StringAssert.Contains(decoded, "all_project_types:datapack"); + StringAssert.Contains(decoded, "categories:datapack"); + StringAssert.Contains(decoded, "versions:1.20.4"); + Assert.IsFalse(decoded.Contains("project_type:mod", StringComparison.Ordinal)); + Assert.IsFalse(decoded.Contains("categories:fabric", StringComparison.Ordinal)); + } + + [TestMethod] + public async Task DatapackVersionsUseDatapackLoaderAndExcludeAttachedResourcePackFiles() + { + using var temporary = new TemporaryDirectory(); + var bytes = Encoding.UTF8.GetBytes("archive bytes"); + var requestUri = ""; + var json = "[" + string.Join(',', + VersionJson("datapack", "release", "2025-01-02T00:00:00Z", ["1.20.4"], ["datapack"], + [FileJson("main.zip", bytes, primary: true), FileJson("required.zip", bytes, "required-resource-pack"), + FileJson("optional.zip", bytes, "optional-resource-pack")]), + VersionJson("wrong-loader", "release", "2025-01-04T00:00:00Z", ["1.20.4"], ["fabric"], + [FileJson("fabric.zip", bytes)]), + VersionJson("wrong-game", "release", "2025-01-05T00:00:00Z", ["1.20.5"], ["datapack"], + [FileJson("wrong.zip", bytes)]), + VersionJson("resource-only", "release", "2025-01-03T00:00:00Z", ["1.20.4"], ["datapack"], + [FileJson("attached.zip", bytes, "required-resource-pack", primary: true)])) + "]"; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((request, _) => + { + requestUri = request.RequestUri!.AbsoluteUri; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, json)); + })), ApiBase, cacheRoot: temporary.Root); + + var versions = await client.GetCompatibleVersionsAsync("project123", ModrinthContentKind.Datapack, "1.20.4"); + + CollectionAssert.AreEqual(new[] { "datapack" }, versions.Select(version => version.Id).ToArray()); + Assert.AreEqual("main.zip", versions.Single().InstallableFile(ModrinthContentKind.Datapack)!.Filename); + CollectionAssert.AreEqual(new[] { "required.zip", "optional.zip" }, + versions.Single().ResourcePacks.Select(file => file.Filename).ToArray()); + var decodedQuery = Uri.UnescapeDataString(requestUri).Replace("\r", "", StringComparison.Ordinal) + .Replace("\n", "", StringComparison.Ordinal).Replace(" ", "", StringComparison.Ordinal); + StringAssert.Contains(decodedQuery, "loaders=[\"datapack\"]"); + StringAssert.Contains(decodedQuery, "game_versions=[\"1.20.4\"]"); + } + + [TestMethod] + public async Task CompatibleVersionsUseActualVersionsFilesAndReleaseDateOrder() + { + using var temporary = new TemporaryDirectory(); + var good = Encoding.UTF8.GetBytes("valid"); + var bad = Encoding.UTF8.GetBytes("bad"); + var json = "[" + string.Join(',', + VersionJson("alpha", "alpha", "2025-01-04T00:00:00Z", ["1.20.4"], ["fabric"], [FileJson("dev.jar", bad, "dev-jar")]), + VersionJson("wrong-game", "release", "2025-01-05T00:00:00Z", ["1.20.5"], ["fabric"], [FileJson("game.jar", good)]), + VersionJson("wrong-loader", "release", "2025-01-06T00:00:00Z", ["1.20.4"], ["forge"], [FileJson("loader.jar", good)]), + VersionJson("beta", "beta", "2025-01-07T00:00:00Z", ["1.20.4"], ["fabric"], [FileJson("beta.jar", good)]), + VersionJson("release-old", "release", "2025-01-02T00:00:00Z", ["1.20.4"], ["fabric"], [FileJson("first.jar", good), FileJson("primary.jar", good, primary: true)]), + VersionJson("release-new", "release", "2025-01-08T00:00:00Z", ["1.20.4"], ["fabric"], [FileJson("release.jar", good)]) + ) + "]"; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((request, _) => + { + Assert.IsTrue(request.RequestUri!.AbsolutePath.EndsWith("/project/project123/version", StringComparison.Ordinal)); + return Task.FromResult(TestData.Json(HttpStatusCode.OK, json)); + })), ApiBase, cacheRoot: temporary.Root); + + var versions = await client.GetCompatibleVersionsAsync("project123", ModrinthContentKind.Mod, "1.20.4"); + + CollectionAssert.AreEqual(new[] { "release-new", "release-old", "beta" }, versions.Select(version => version.Id).ToArray()); + Assert.AreEqual("primary.jar", versions[1].InstallableFile(ModrinthContentKind.Mod)!.Filename); + Assert.AreEqual("beta", (await client.GetLatestVersionAsync("project123", ModrinthContentKind.Mod, "1.20.4", "beta"))!.Id); + await Assert.ThrowsExactlyAsync(() => client.GetLatestVersionAsync( + "project123", ModrinthContentKind.Mod, "1.20.4", "unknown")); + } + + [TestMethod] + public async Task VersionDependenciesDecodeAndNonInstallableJarVariantsAreExcluded() + { + using var temporary = new TemporaryDirectory(); + var bytes = Encoding.UTF8.GetBytes("dependency metadata"); + var json = VersionJson("version123", "release", "2025-01-01T00:00:00Z", ["1.20.4"], ["fabric"], + [FileJson("valid.jar", bytes)])[..^1] + + ",\"dependencies\":[{\"version_id\":\"dep123\",\"project_id\":null,\"dependency_type\":\"required\"}]}"; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((_, _) => + Task.FromResult(TestData.Json(HttpStatusCode.OK, "[" + json + "]")))), ApiBase, cacheRoot: temporary.Root); + + var version = (await client.GetVersionsAsync(["version123"])).Single(); + Assert.AreEqual("dep123", version.Dependencies.Single().VersionId); + Assert.AreEqual("required", version.Dependencies.Single().Type); + Assert.IsNull(version.Dependencies.Single().ProjectId); + Assert.AreEqual("valid.jar", version.InstallableFile(ModrinthContentKind.Mod)!.Filename); + + foreach (var name in new[] { "library-sources.jar", "library-dev.jar", "library-javadoc.jar", "CON.jar", "../escape.jar" }) + { + var file = new ModrinthFile + { + Filename = name, + Size = bytes.Length, + Hashes = new Dictionary { ["sha1"] = Hash1(bytes), ["sha512"] = Hash(bytes) } + }; + Assert.IsFalse(file.IsInstallable(ModrinthContentKind.Mod), name); + } + } + + [TestMethod] + public async Task ProjectsVersionsAndHashEndpointsValidateIdsAndPostSha512() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var requests = new List<(string Method, string Path, string? Body)>(); + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler(async (request, token) => + { + var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(token); + requests.Add((request.Method.Method, request.RequestUri!.AbsolutePath, body)); + return request.RequestUri!.AbsolutePath switch + { + "/v2/projects" => TestData.Json(HttpStatusCode.OK, "[{\"id\":\"project123\",\"title\":\"Project\",\"project_type\":\"mod\"}]"), + "/v2/versions" => TestData.Json(HttpStatusCode.OK, "[]"), + "/v2/version_files" => TestData.Json(HttpStatusCode.OK, "{}"), + _ => throw new AssertFailedException("Unexpected request: " + request.RequestUri) + }; + })), ApiBase, cacheRoot: temporary.Root); + + var project = (await client.GetProjectsAsync(["project123"])).Single(); + Assert.AreEqual("project123", project.Id); + Assert.AreEqual("https://modrinth.com/mod/project123", project.PageUrl.AbsoluteUri); + Assert.AreEqual(0, (await client.GetVersionsAsync(["version123"])).Count); + await Assert.ThrowsExactlyAsync(() => client.GetProjectsAsync(["bad/id"])); + + var path = Path.Combine(temporary.Root, "local.jar"); + var bytes = Encoding.UTF8.GetBytes("local file"); + await File.WriteAllBytesAsync(path, bytes); + await client.VersionsOfAsync([path]); + + Assert.AreEqual(3, requests.Count); + Assert.AreEqual("GET", requests[0].Method); + Assert.AreEqual("POST", requests[2].Method); + using var bodyJson = JsonDocument.Parse(requests[2].Body!); + Assert.AreEqual("sha512", bodyJson.RootElement.GetProperty("algorithm").GetString()); + Assert.AreEqual(Hash(bytes), bodyJson.RootElement.GetProperty("hashes")[0].GetString()); + } + + [TestMethod] + public async Task HashRecognitionReusesCacheInvalidatesChangedFileAndPrunesRemovedFiles() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var cacheRoot = Path.Combine(temporary.Root, "Cache"); + var file = Path.Combine(temporary.Root, "local.jar"); + var firstBytes = Encoding.UTF8.GetBytes("first"); + await File.WriteAllBytesAsync(file, firstBytes); + var requestCount = 0; + using var http = new HttpClient(new CallbackHttpHandler(async (request, token) => + { + requestCount++; + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token)); + var hashes = body.RootElement.GetProperty("hashes").EnumerateArray().Select(value => value.GetString()!).ToArray(); + var response = hashes.ToDictionary(hash => hash, hash => new + { + id = "version123", project_id = "project123", date_published = "2025-01-01T00:00:00Z" + }); + return TestData.Json(HttpStatusCode.OK, JsonSerializer.Serialize(response)); + })); + using (var client = new ModrinthClient(http, ApiBase, cacheRoot: cacheRoot)) + { + var first = await client.VersionsOfAsync([file]); + Assert.AreEqual("version123", first[file].VersionId); + Assert.AreEqual(1, requestCount); + Assert.AreEqual(1, (await client.VersionsOfAsync([file])).Count); + Assert.AreEqual(1, requestCount); + + await File.WriteAllBytesAsync(file, Encoding.UTF8.GetBytes("second")); + File.SetLastWriteTimeUtc(file, DateTime.UtcNow.AddMinutes(1)); + await client.VersionsOfAsync([file]); + Assert.AreEqual(2, requestCount); + + File.Delete(file); + await client.VersionsOfAsync([]); + } + + var cacheFile = Path.Combine(cacheRoot, "Modrinth", "files.json"); + using var cache = JsonDocument.Parse(await File.ReadAllBytesAsync(cacheFile)); + Assert.AreEqual(0, cache.RootElement.GetProperty("files").EnumerateObject().Count()); + Assert.AreEqual(0, cache.RootElement.GetProperty("versions").EnumerateObject().Count()); + } + + [TestMethod] + public async Task ClientsSharingRootReloadTheLatestSnapshotBeforeSaving() + { + using var temporary = new TemporaryDirectory(); + var cacheRoot = Path.Combine(temporary.Root, "Cache"); + var cacheFolder = Path.Combine(cacheRoot, "Modrinth"); + Directory.CreateDirectory(cacheFolder); + await File.WriteAllTextAsync(Path.Combine(cacheFolder, "files.json"), "{\"files\":{},\"versions\":{}}"); + var x = Path.Combine(temporary.Root, "x.jar"); + var y = Path.Combine(temporary.Root, "y.jar"); + var xBytes = Encoding.UTF8.GetBytes("x"); + var yBytes = Encoding.UTF8.GetBytes("y"); + await File.WriteAllBytesAsync(x, xBytes); + await File.WriteAllBytesAsync(y, yBytes); + var requestCount = 0; + using var http = new HttpClient(new CallbackHttpHandler(async (request, token) => + { + Interlocked.Increment(ref requestCount); + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token)); + var matches = body.RootElement.GetProperty("hashes").EnumerateArray().Select(value => value.GetString()!) + .ToDictionary(hash => hash, hash => new + { + id = "version123", project_id = "project123", date_published = "2025-01-01T00:00:00Z" + }); + return TestData.Json(HttpStatusCode.OK, JsonSerializer.Serialize(matches)); + })); + using var first = new ModrinthClient(http, ApiBase, cacheRoot: cacheRoot); + using var second = new ModrinthClient(http, ApiBase, cacheRoot: cacheRoot); + + await first.VersionsOfAsync([]); + await second.VersionsOfAsync([]); + Assert.AreEqual(1, (await first.VersionsOfAsync([x])).Count); + Assert.AreEqual(1, (await second.VersionsOfAsync([y])).Count); + Assert.AreEqual(2, requestCount); + + using var reader = new ModrinthClient(http, ApiBase, cacheRoot: cacheRoot); + var reread = await reader.VersionsOfAsync([x, y]); + Assert.AreEqual(2, reread.Count); + Assert.AreEqual(2, requestCount); + using var cache = JsonDocument.Parse(await File.ReadAllBytesAsync(Path.Combine(cacheFolder, "files.json"))); + Assert.AreEqual(2, cache.RootElement.GetProperty("files").EnumerateObject().Count()); + Assert.AreEqual(2, cache.RootElement.GetProperty("versions").EnumerateObject().Count()); + } + + [TestMethod] + public async Task ConcurrentClientsShareCacheUpdatesAndDifferentRootsDoNotBlockEachOther() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var sharedRoot = Path.Combine(temporary.Root, "Shared"); + var firstRoot = Path.Combine(temporary.Root, "First"); + var secondRoot = Path.Combine(temporary.Root, "Second"); + var files = Enumerable.Range(0, 4).Select(index => Path.Combine(temporary.Root, $"file-{index}.jar")).ToArray(); + var bytes = Enumerable.Range(0, files.Length).Select(index => Encoding.UTF8.GetBytes($"file-{index}")).ToArray(); + for (var index = 0; index < files.Length; index++) await File.WriteAllBytesAsync(files[index], bytes[index]); + + var requestCount = 0; + using var http = new HttpClient(new CallbackHttpHandler(async (request, token) => + { + Interlocked.Increment(ref requestCount); + await Task.Yield(); + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token)); + var matches = body.RootElement.GetProperty("hashes").EnumerateArray().Select(value => value.GetString()!) + .ToDictionary(hash => hash, hash => new + { + id = "version123", project_id = "project123", date_published = "2025-01-01T00:00:00Z" + }); + return TestData.Json(HttpStatusCode.OK, JsonSerializer.Serialize(matches)); + })); + using var first = new ModrinthClient(http, ApiBase, cacheRoot: sharedRoot); + using var second = new ModrinthClient(http, ApiBase, cacheRoot: sharedRoot); + + await Task.WhenAll(first.VersionsOfAsync([files[0]]), second.VersionsOfAsync([files[1]])); + using var sharedReader = new ModrinthClient(http, ApiBase, cacheRoot: sharedRoot); + Assert.AreEqual(2, (await sharedReader.VersionsOfAsync([files[0], files[1]])).Count); + Assert.AreEqual(2, requestCount); + + var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var bothEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseResponses = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var independentHttp = new HttpClient(new CallbackHttpHandler(async (request, token) => + { + if (Interlocked.Increment(ref requestCount) == 3) firstEntered.TrySetResult(); + if (Volatile.Read(ref requestCount) == 4) bothEntered.TrySetResult(); + await releaseResponses.Task.WaitAsync(token); + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token)); + var matches = body.RootElement.GetProperty("hashes").EnumerateArray().Select(value => value.GetString()!) + .ToDictionary(hash => hash, hash => new + { + id = "version123", project_id = "project123", date_published = "2025-01-01T00:00:00Z" + }); + return TestData.Json(HttpStatusCode.OK, JsonSerializer.Serialize(matches)); + })); + using var firstRootClient = new ModrinthClient(independentHttp, ApiBase, cacheRoot: firstRoot); + using var secondRootClient = new ModrinthClient(independentHttp, ApiBase, cacheRoot: secondRoot); + var firstRootTask = firstRootClient.VersionsOfAsync([files[2]]); + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var secondRootTask = secondRootClient.VersionsOfAsync([files[3]]); + var bothRootsEntered = false; + try + { + await bothEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + bothRootsEntered = true; + } + finally + { + releaseResponses.TrySetResult(); + } + + await Task.WhenAll(firstRootTask, secondRootTask); + Assert.IsTrue(bothRootsEntered, "Independent cache roots should not share a cache gate."); + Assert.AreEqual(4, requestCount); + } + + [TestMethod] + public async Task MalformedHashCacheIsIgnoredAndReplacedAfterSuccessfulLookup() + { + using var temporary = new TemporaryDirectory(); + var cacheRoot = Path.Combine(temporary.Root, "Cache"); + var cacheFolder = Path.Combine(cacheRoot, "Modrinth"); + Directory.CreateDirectory(cacheFolder); + await File.WriteAllTextAsync(Path.Combine(cacheFolder, "files.json"), "{broken"); + var bytes = Encoding.UTF8.GetBytes("sample"); + var file = Path.Combine(temporary.Root, "sample.jar"); + await File.WriteAllBytesAsync(file, bytes); + var clientRequests = 0; + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((_, _) => + { + clientRequests++; + var response = $"{{\"{Hash(bytes)}\":{{\"id\":\"version123\",\"project_id\":\"project123\",\"date_published\":\"2025-01-01T00:00:00Z\"}}}}"; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, response)); + })), ApiBase, cacheRoot: cacheRoot); + + Assert.AreEqual(1, (await client.VersionsOfAsync([file])).Count); + Assert.AreEqual(1, clientRequests); + using var cache = JsonDocument.Parse(await File.ReadAllBytesAsync(Path.Combine(cacheFolder, "files.json"))); + Assert.AreEqual(1, cache.RootElement.GetProperty("files").EnumerateObject().Count()); + + await File.WriteAllTextAsync(Path.Combine(cacheFolder, "files.json"), "{\"files\":{\"C:\\\\BAD.JAR\":null},\"versions\":{}}"); + using var secondClient = new ModrinthClient(new HttpClient(new CallbackHttpHandler((_, _) => + { + clientRequests++; + var response = $"{{\"{Hash(bytes)}\":{{\"id\":\"version123\",\"project_id\":\"project123\",\"date_published\":\"2025-01-01T00:00:00Z\"}}}}"; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, response)); + })), ApiBase, cacheRoot: cacheRoot); + Assert.AreEqual(1, (await secondClient.VersionsOfAsync([file])).Count); + Assert.AreEqual(2, clientRequests); + } + + [TestMethod] + public async Task UnknownHashesAreRememberedOnlyForTheCurrentClient() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var file = Path.Combine(temporary.Root, "unknown.jar"); + await File.WriteAllTextAsync(file, "unknown"); + var requests = 0; + using var http = new HttpClient(new CallbackHttpHandler((_, _) => + { + requests++; + return Task.FromResult(TestData.Json(HttpStatusCode.OK, "{}")); + })); + using (var first = new ModrinthClient(http, ApiBase, cacheRoot: Path.Combine(temporary.Root, "Cache"))) + { + Assert.AreEqual(0, (await first.VersionsOfAsync([file])).Count); + Assert.AreEqual(0, (await first.VersionsOfAsync([file])).Count); + } + + using var second = new ModrinthClient(http, ApiBase, cacheRoot: Path.Combine(temporary.Root, "Cache")); + await second.VersionsOfAsync([file]); + Assert.AreEqual(2, requests); + } + + [TestMethod] + public async Task UpdateLookupRequestsReleaseThenBetaThenAlphaAndChecksActualCompatibility() + { + using var temporary = new TemporaryDirectory(); + var payloads = new List(); + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler(async (request, token) => + { + payloads.Add(JsonDocument.Parse(await request.Content!.ReadAsStringAsync(token))); + var channel = payloads[^1].RootElement.GetProperty("version_types")[0].GetString(); + var version = channel == "alpha" + ? VersionJson("alpha123", "alpha", "2025-01-03T00:00:00Z", ["1.20.4"], ["fabric"], [FileJson("mod.jar", Encoding.UTF8.GetBytes("ok"))]) + : VersionJson("wrong123", channel!, "2025-01-02T00:00:00Z", ["1.20.5"], ["fabric"], [FileJson("mod.jar", Encoding.UTF8.GetBytes("ok"))]); + var response = channel == "alpha" ? "{" + JsonSerializer.Serialize(Hash(Encoding.UTF8.GetBytes("mod"))) + ":" + version + "}" : "{}"; + return TestData.Json(HttpStatusCode.OK, response); + })), ApiBase, cacheRoot: temporary.Root); + + var hash = Hash(Encoding.UTF8.GetBytes("mod")); + var result = await client.LatestVersionsAsync([hash], ModrinthContentKind.Mod, "1.20.4"); + + CollectionAssert.AreEqual(new[] { "release", "beta", "alpha" }, payloads.Select(document => + document.RootElement.GetProperty("version_types")[0].GetString()!).ToArray()); + Assert.AreEqual("alpha123", result[hash].Id); + foreach (var payload in payloads) payload.Dispose(); + } + + [TestMethod] + public async Task DownloadRejectsWrongSizeAndHashesAndRemovesStagingFiles() + { + using var temporary = new TemporaryDirectory(); + var bytes = Encoding.UTF8.GetBytes("payload"); + var handler = new CallbackHttpHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(bytes) + })); + using var client = new ModrinthClient(new HttpClient(handler), ApiBase, cacheRoot: temporary.Root); + var destination = Path.Combine(temporary.Root, "download.jar"); + + var wrongHash = await Assert.ThrowsExactlyAsync(() => client.DownloadFileAsync( + new Uri("https://cdn.test/mod.jar"), destination, bytes.Length, Hash1(Encoding.UTF8.GetBytes("wrong")), Hash(bytes))); + Assert.AreEqual(MojangError.Integrity, wrongHash.Error, wrongHash.Message); + Assert.IsFalse(File.Exists(destination)); + Assert.AreEqual(0, Directory.GetFiles(temporary.Root, "*.tmp").Length); + + var wrongSize = await Assert.ThrowsExactlyAsync(() => client.DownloadFileAsync( + new Uri("https://cdn.test/mod.jar"), destination, bytes.Length + 1, Hash1(bytes), Hash(bytes))); + Assert.AreEqual(MojangError.Integrity, wrongSize.Error); + Assert.AreEqual(0, Directory.GetFiles(temporary.Root, "*.tmp").Length); + + var emptyFile = await Assert.ThrowsExactlyAsync(() => client.DownloadFileAsync( + new Uri("https://cdn.test/mod.jar"), destination, 0, Hash1([]), Hash([]))); + Assert.AreEqual(MojangError.Invalid, emptyFile.Error, "Ordinary Modrinth downloads must still require a positive size."); + Assert.IsFalse(File.Exists(destination)); + } + + [TestMethod] + public async Task DownloadCancellationCleansPartialFile() + { + using var temporary = new TemporaryDirectory(); + var body = new BlockingContent(); + using var client = new ModrinthClient(new HttpClient(new CallbackHttpHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) { Content = body }))), ApiBase, cacheRoot: temporary.Root); + using var cancellation = new CancellationTokenSource(); + var destination = Path.Combine(temporary.Root, "cancelled.jar"); + var task = client.DownloadFileAsync(new Uri("https://cdn.test/mod.jar"), destination, 10, + new string('0', 40), new string('0', 128), cancellation.Token); + await body.ReadStarted.Task; + cancellation.Cancel(); + + await Assert.ThrowsExactlyAsync(() => task); + Assert.IsFalse(File.Exists(destination)); + Assert.AreEqual(0, Directory.GetFiles(temporary.Root, "*.tmp").Length); + } + + private static string VersionJson(string id, string channel, string date, string[] games, string[] loaders, string[] files) => + $$"""{"id":"{{id}}","project_id":"project123","version_number":"{{id}}","version_type":"{{channel}}","date_published":"{{date}}","game_versions":{{JsonSerializer.Serialize(games)}},"loaders":{{JsonSerializer.Serialize(loaders)}},"files":[{{string.Join(',', files)}}]}"""; + + private static string FileJson(string filename, byte[] bytes, string? fileType = null, bool primary = false) => + $$"""{"filename":"{{filename}}","url":"https://cdn.test/{{filename}}","size":{{bytes.Length}},"hashes":{"sha1":"{{Hash1(bytes)}}","sha512":"{{Hash(bytes)}}"},"primary":{{primary.ToString().ToLowerInvariant()}}{{(fileType is null ? "" : $",\"file_type\":\"{fileType}\"")}}}"""; + + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); + private static string Hash1(byte[] bytes) => Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant(); + + private sealed class BlockingContent : HttpContent + { + public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => Task.CompletedTask; + protected override bool TryComputeLength(out long length) { length = 0; return false; } + protected override Task CreateContentReadStreamAsync() => Task.FromResult(new BlockingStream(ReadStarted)); + } + + private sealed class BlockingStream(TaskCompletionSource readStarted) : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + readStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + } +} diff --git a/windows/Hako.Tests/NameValidationTests.cs b/windows/Hako.Tests/NameValidationTests.cs new file mode 100644 index 0000000..2182372 --- /dev/null +++ b/windows/Hako.Tests/NameValidationTests.cs @@ -0,0 +1,31 @@ +using Hako; + +namespace Hako.Tests; + +[TestClass] +public sealed class NameValidationTests +{ + [TestMethod] + public async Task InvalidatedAsyncNameCheckCannotReplaceTheLatestResult() + { + var validation = new NameValidation(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finishOldCheck = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var oldCheck = validation.ValidateAsync("New build", async _ => + { + started.SetResult(); + return await finishOldCheck.Task; + }); + + await started.Task; + validation.Invalidate(); + var currentCheck = await validation.ValidateAsync("New build", _ => Task.FromResult(null)); + finishOldCheck.SetResult("duplicate"); + var staleCheck = await oldCheck; + + Assert.IsTrue(currentCheck.IsCurrent); + Assert.IsNull(currentCheck.ErrorKey); + Assert.IsFalse(staleCheck.IsCurrent); + Assert.IsNull(staleCheck.ErrorKey); + } +} diff --git a/windows/Hako.Tests/PlaytimeTests.cs b/windows/Hako.Tests/PlaytimeTests.cs new file mode 100644 index 0000000..7266d14 --- /dev/null +++ b/windows/Hako.Tests/PlaytimeTests.cs @@ -0,0 +1,1034 @@ +using System.IO; +using Hako.Core; +using Hako.Core.Minecraft; +using Hako.Core.Playtime; + +namespace Hako.Tests; + +[TestClass] +public sealed class PlaytimeTests +{ + [TestMethod] + public async Task ReconcileCreditsOnlyPositiveElapsedDeltaAndIsIdempotent() + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, _) = await fixture.StartSessionAsync("guest"); + fixture.Clock.AdvanceSeconds(30); + + await fixture.Coordinator.RefreshAsync(); + await fixture.Coordinator.RefreshAsync(); + Assert.AreEqual(30, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.AreEqual(30, fixture.Coordinator.InstanceSeconds(fixture.InstanceID, null), 0.001); + + fixture.Clock.AdvanceSeconds(10); + await fixture.Coordinator.RefreshAsync(); + Assert.AreEqual(40, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.IsTrue(File.Exists(PlaytimePaths.Journal(fixture.GameRoot, sessionID))); + } + + [TestMethod] + public async Task SleepAndWallClockChangesDoNotAddElapsedTime() + { + await using var fixture = new PlaytimeFixture(); + await fixture.StartSessionAsync("guest"); + fixture.Clock.JumpWallClock(TimeSpan.FromHours(8)); + fixture.Clock.AdvanceSleep(TimeSpan.FromHours(2)); + await fixture.Coordinator.RefreshAsync(); + Assert.AreEqual(0, fixture.Coordinator.TotalSeconds(null), 0.001); + + fixture.Clock.AdvanceSeconds(5); + await fixture.Coordinator.RefreshAsync(); + Assert.AreEqual(5, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.AreEqual(TimeSpan.FromHours(10), fixture.Clock.WallClockElapsed); + } + + [TestMethod] + public async Task SessionOwnerDoesNotChangeOnLogoutAndResumeKeepsSavedOwner() + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, process) = await fixture.StartSessionAsync("account:first"); + fixture.Clock.AdvanceSeconds(15); + await fixture.Coordinator.RefreshAsync(); + + await using var second = fixture.CreateCoordinator(); + await second.ResumeSessionAsync(process, "second"); + fixture.Clock.AdvanceSeconds(10); + await second.RefreshAsync(); + + var ledger = await fixture.ReadLedgerAsync(); + Assert.AreEqual(1, ledger.Sessions.Count); + Assert.AreEqual("account:first", ledger.Sessions[sessionID].OwnerKey); + Assert.AreEqual(25, second.TotalSeconds("first"), 0.001); + Assert.AreEqual(0, second.TotalSeconds("second"), 0.001); + } + + [TestMethod] + public async Task ResumeAfterMissingJournalContinuesFromAlreadyCreditedSeconds() + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, process) = await fixture.StartSessionAsync("guest"); + fixture.Clock.AdvanceSeconds(10); + await fixture.Coordinator.RefreshAsync(); + File.Delete(PlaytimePaths.Journal(fixture.GameRoot, sessionID)); + + await using var restored = fixture.CreateCoordinator(); + await restored.ResumeSessionAsync(process, null); + fixture.Clock.AdvanceSeconds(5); + await restored.RefreshAsync(); + + Assert.AreEqual(15, restored.TotalSeconds(null), 0.001); + var journal = PlaytimeJson.ReadJournal(PlaytimePaths.Journal(fixture.GameRoot, sessionID)); + Assert.AreEqual(10, journal.BaseElapsedSeconds, 0.001); + } + + [TestMethod] + public async Task ReusedPidWithDifferentCreationTimeIsNeverCredited() + { + await using var fixture = new PlaytimeFixture(); + await fixture.StartSessionAsync("guest", pid: 271); + fixture.Clock.AdvanceSeconds(45); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(271, 100, 0), false); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(271, 200, 0), true); + + await fixture.Coordinator.RefreshAsync(); + + Assert.AreEqual(0, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.AreEqual(0, (await fixture.ReadLedgerAsync()).Sessions.Count); + } + + [TestMethod] + public async Task FinishedSessionKeepsTotalsAfterItsInstanceIsGone() + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, _) = await fixture.StartSessionAsync("account:123"); + fixture.Clock.AdvanceSeconds(17); + await fixture.Coordinator.RefreshAsync(); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 100, 0), false); + fixture.Clock.AdvanceSeconds(3_600); + + await fixture.Coordinator.RefreshAsync(); + + var ledger = await fixture.ReadLedgerAsync(); + Assert.AreEqual(17, fixture.Coordinator.TotalSeconds("123"), 0.001); + Assert.AreEqual(17, fixture.Coordinator.InstanceSeconds(fixture.InstanceID, "123"), 0.001); + Assert.IsTrue(ledger.Owners["account:123"].Instances.ContainsKey(fixture.InstanceID)); + Assert.IsFalse(ledger.Sessions.ContainsKey(sessionID)); + } + + [TestMethod] + public async Task ReconcileRemovesOrphanLedgerRowsAndJournalsSafely() + { + await using var fixture = new PlaytimeFixture(); + var missingJournalSession = Guid.NewGuid(); + var orphanJournalSession = Guid.NewGuid(); + await new PlaytimeLedgerStore(fixture.GameRoot).MutateAsync(ledger => + { + ledger.Sessions[missingJournalSession] = new PlaytimeSessionRecord + { + SessionID = missingJournalSession, + InstanceID = fixture.InstanceID, + OwnerKey = "guest" + }; + return true; + }); + var orphanPath = PlaytimePaths.Journal(fixture.GameRoot, orphanJournalSession); + Directory.CreateDirectory(Path.GetDirectoryName(orphanPath)!); + PlaytimeJson.AtomicWrite(orphanPath, PlaytimeJson.SerializeJournal(new PlaytimeSessionJournal + { + SessionID = orphanJournalSession, + InstanceID = fixture.InstanceID, + OwnerKey = "guest" + })); + + await fixture.Coordinator.RefreshAsync(); + + var ledger = await fixture.ReadLedgerAsync(); + Assert.IsFalse(ledger.Sessions.ContainsKey(missingJournalSession)); + Assert.IsFalse(File.Exists(orphanPath)); + } + + [TestMethod] + public async Task TwoAppServicesSharingGameRootCreditOnlyOnce() + { + await using var fixture = new PlaytimeFixture(); + await fixture.StartSessionAsync("guest"); + await using var second = fixture.CreateCoordinator(); + fixture.Clock.AdvanceSeconds(13); + + await fixture.Coordinator.RefreshAsync(); + await second.RefreshAsync(); + + Assert.AreEqual(13, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.AreEqual(13, second.TotalSeconds(null), 0.001); + } + + [TestMethod] + public async Task FailedHelperStartRetriesWhileLauncherIsOpen() + { + var timeProvider = new SignalingTimeProvider(); + await using var fixture = new PlaytimeFixture(timeProvider: timeProvider); + await fixture.StartSessionAsync("guest"); + Assert.AreEqual(1, fixture.Launcher.Attempts); + Assert.AreEqual("PlaytimeHelperStartFailed", fixture.Coordinator.ErrorResourceKey); + + fixture.Clock.AdvanceSeconds(12); + timeProvider.Advance(PlaytimeCoordinator.CheckpointInterval); + await fixture.Coordinator.RefreshAsync(); + + Assert.IsTrue(fixture.Launcher.Attempts >= 2); + Assert.AreEqual(12, fixture.Coordinator.TotalSeconds(null), 0.001); + } + + [TestMethod] + public async Task PeriodicReconcileUsesTenSecondTimerAndUnbiasedClock() + { + await using var fixture = new PlaytimeFixture(); + await fixture.StartSessionAsync("guest"); + var timerProvider = new SignalingTimeProvider(); + await using var polling = new PlaytimeCoordinator(fixture.GameRoot, fixture.Launcher, + fixture.Clock, fixture.Probe, timerProvider); + await polling.ResumeSessionAsync((await fixture.ReadLedgerAsync()).Sessions.Values + .Select(session => new GameProcessRecord(session.InstanceID, 151, 100, 0, session.SessionID)).Single(), null); + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + polling.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(PlaytimeCoordinator.Revision)) changed.TrySetResult(); + }; + polling.Start(); + await timerProvider.TimerCreated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Clock.AdvanceSeconds(10); + timerProvider.Advance(TimeSpan.FromSeconds(10)); + await changed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreEqual(10, polling.TotalSeconds(null), 0.001); + } + + [TestMethod] + public async Task GuestTransferCapturesBoundaryAndLeavesLaterGuestTimeBehind() + { + await using var fixture = new PlaytimeFixture(); + await fixture.StartSessionAsync("guest", pid: 151); + fixture.Clock.AdvanceSeconds(25); + await fixture.Coordinator.RefreshAsync(); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 100, 0), false); + await fixture.Coordinator.RefreshAsync(); + + var (ongoingSession, _) = await fixture.StartSessionAsync("guest", pid: 152); + fixture.Clock.AdvanceSeconds(5); + var transferID = await fixture.Coordinator.PrepareGuestTransferAsync("target-xuid"); + Assert.IsNotNull(transferID); + var pending = (await fixture.ReadLedgerAsync()).PendingTransfer; + Assert.IsNotNull(pending); + Assert.AreEqual(30, pending.GuestSecondsAtBoundary, 0.001); + CollectionAssert.Contains(pending.SessionIDs, ongoingSession); + + fixture.Clock.AdvanceSeconds(7); + var (postBoundarySession, _) = await fixture.StartSessionAsync("guest", pid: 153, + instanceID: Guid.NewGuid()); + fixture.Clock.AdvanceSeconds(3); + await fixture.Coordinator.RefreshAsync(); + await fixture.Coordinator.CompleteGuestTransferAsync(transferID!.Value); + await fixture.Coordinator.RefreshAsync(); + + Assert.AreEqual(40, fixture.Coordinator.TotalSeconds("target-xuid"), 0.001); + Assert.AreEqual(3, fixture.Coordinator.TotalSeconds(null), 0.001); + var ledger = await fixture.ReadLedgerAsync(); + Assert.AreEqual("account:target-xuid", ledger.Sessions[ongoingSession].OwnerKey); + Assert.AreEqual("guest", ledger.Sessions[postBoundarySession].OwnerKey); + Assert.IsNull(ledger.PendingTransfer); + Assert.IsTrue(ledger.AppliedTransfers.Contains(transferID.Value)); + } + + [TestMethod] + public async Task GuestTransferDoesNotWaitForTheRunningHelpersJournalLock() + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, _) = await fixture.StartSessionAsync("guest", pid: 152); + using var helperJournalLock = PlaytimeJournalLock.TryAcquire(PlaytimePaths.JournalLock(fixture.GameRoot, sessionID)); + Assert.IsNotNull(helperJournalLock); + fixture.Clock.AdvanceSeconds(5); + + var transferID = await fixture.Coordinator.PrepareGuestTransferAsync("target-xuid") + .WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.IsNotNull(transferID); + var pending = (await fixture.ReadLedgerAsync()).PendingTransfer; + Assert.IsNotNull(pending); + Assert.AreEqual(5, pending.GuestSecondsAtBoundary, 0.001); + CollectionAssert.Contains(pending.SessionIDs, sessionID); + await fixture.Coordinator.CompleteGuestTransferAsync(transferID!.Value); + Assert.AreEqual("account:target-xuid", (await fixture.ReadLedgerAsync()).Sessions[sessionID].OwnerKey); + } + + [TestMethod] + public async Task TransferPendingAfterFirstPhaseRecoversAgainstSavedAccount() + { + await using var fixture = new PlaytimeFixture(checkpoint => + { + if (checkpoint == PlaytimeTransferCheckpoint.PendingWritten) throw new IOException("fail after phase A"); + }); + await fixture.StartSessionAsync("guest"); + fixture.Clock.AdvanceSeconds(8); + await fixture.Coordinator.RefreshAsync(); + + var result = await fixture.Coordinator.PrepareGuestTransferAsync("target"); + + Assert.IsNull(result); + Assert.IsNotNull((await fixture.ReadLedgerAsync()).PendingTransfer); + await fixture.Coordinator.RecoverPendingTransferAsync("target"); + var ledger = await fixture.ReadLedgerAsync(); + Assert.IsNull(ledger.PendingTransfer); + Assert.IsTrue(ledger.AppliedTransfers.Count == 1); + Assert.AreEqual(8, fixture.Coordinator.TotalSeconds("target"), 0.001); + } + + [TestMethod] + public async Task PendingTransferAfterAccountSaveAppliesOnStartup() + { + await using var fixture = new PlaytimeFixture(checkpoint => + { + if (checkpoint == PlaytimeTransferCheckpoint.BeforeApply) throw new IOException("fail after phase B"); + }); + await fixture.StartSessionAsync("guest"); + fixture.Clock.AdvanceSeconds(12); + await fixture.Coordinator.RefreshAsync(); + var transferID = await fixture.Coordinator.PrepareGuestTransferAsync("saved-xuid"); + Assert.IsNotNull(transferID); + await fixture.Coordinator.CompleteGuestTransferAsync(transferID!.Value); + + await using var relaunched = fixture.CreateCoordinator(); + await relaunched.RecoverPendingTransferAsync("saved-xuid"); + + Assert.AreEqual(12, relaunched.TotalSeconds("saved-xuid"), 0.001); + Assert.AreEqual(0, relaunched.TotalSeconds(null), 0.001); + var ledger = await fixture.ReadLedgerAsync(); + Assert.IsNull(ledger.PendingTransfer); + Assert.IsTrue(ledger.AppliedTransfers.Contains(transferID.Value)); + } + + [TestMethod] + public async Task FailedSecondPhaseLeavesPendingForRecoveryAndWrongAccountDiscardsIt() + { + await using var fixture = new PlaytimeFixture(checkpoint => + { + if (checkpoint == PlaytimeTransferCheckpoint.BeforeApply) throw new IOException("fail before phase C"); + }); + await fixture.StartSessionAsync("guest"); + fixture.Clock.AdvanceSeconds(9); + await fixture.Coordinator.RefreshAsync(); + var transferID = await fixture.Coordinator.PrepareGuestTransferAsync("target"); + Assert.IsNotNull(transferID); + + await fixture.Coordinator.CompleteGuestTransferAsync(transferID!.Value); + Assert.AreEqual(transferID, (await fixture.ReadLedgerAsync()).PendingTransfer?.ID); + await fixture.Coordinator.RecoverPendingTransferAsync("different-account"); + + var ledger = await fixture.ReadLedgerAsync(); + Assert.IsNull(ledger.PendingTransfer); + Assert.AreEqual(9, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.AreEqual(0, fixture.Coordinator.TotalSeconds("target"), 0.001); + } + + [TestMethod] + public async Task TransferAppliedTwiceDoesNotDuplicateCredit() + { + await using var fixture = new PlaytimeFixture(checkpoint => + { + if (checkpoint == PlaytimeTransferCheckpoint.Applied) throw new IOException("fail after atomic phase C"); + }); + await fixture.StartSessionAsync("guest"); + fixture.Clock.AdvanceSeconds(11); + await fixture.Coordinator.RefreshAsync(); + var transferID = await fixture.Coordinator.PrepareGuestTransferAsync("target"); + Assert.IsNotNull(transferID); + + await fixture.Coordinator.CompleteGuestTransferAsync(transferID!.Value); + await fixture.Coordinator.CompleteGuestTransferAsync(transferID.Value); + + var ledger = await fixture.ReadLedgerAsync(); + Assert.AreEqual(11, fixture.Coordinator.TotalSeconds("target"), 0.001); + Assert.AreEqual(0, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.AreEqual(1, ledger.AppliedTransfers.Count); + } + + [TestMethod] + public async Task AccountSaveFailureCancelsPreparedGuestTransfer() + { + var accounts = new MemoryAccountStore { FailSave = true }; + var transfer = new RecordingTransfer(); + var coordinator = new AppCoordinator(new MemorySettingsStore(), accounts, new SequenceAuthService(), playtimeTransfer: transfer); + await coordinator.InitializeAsync(); + + await coordinator.BeginSignIn(); + + CollectionAssert.AreEqual(new[] { "prepare", "cancel" }, transfer.Events.ToArray()); + Assert.IsNull(accounts.SavedAccount); + } + + [TestMethod] + public async Task StaleSignInCancelsPendingTransferBeforeItCanCommit() + { + var accounts = new PausingAccountStore(); + var transfer = new RecordingTransfer(); + var coordinator = new AppCoordinator(new MemorySettingsStore(), accounts, new SequenceAuthService(), playtimeTransfer: transfer); + await coordinator.InitializeAsync(); + var attempt = coordinator.BeginSignIn(); + await accounts.FirstSaveReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + coordinator.BackFromSignIn(); + accounts.ReleaseFirstSave.TrySetResult(); + await attempt.WaitAsync(TimeSpan.FromSeconds(5)); + + CollectionAssert.AreEqual(new[] { "prepare", "cancel" }, transfer.Events.ToArray()); + Assert.IsNull(accounts.SavedAccount); + Assert.IsNull(coordinator.Account); + } + + [TestMethod] + public async Task LogoutWaitsUntilCommittedAccountTransferFinishes() + { + var accounts = new MemoryAccountStore(); + var transfer = new RecordingTransfer { PauseCompletion = true }; + var coordinator = new AppCoordinator(new MemorySettingsStore(), accounts, new SequenceAuthService(), playtimeTransfer: transfer); + await coordinator.InitializeAsync(); + var signIn = coordinator.BeginSignIn(); + await transfer.CompleteStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var logout = coordinator.LogoutAsync(); + Assert.IsFalse(logout.IsCompleted); + transfer.ReleaseCompletion.TrySetResult(); + await Task.WhenAll(signIn, logout).WaitAsync(TimeSpan.FromSeconds(5)); + + CollectionAssert.AreEqual(new[] { "prepare", "complete" }, transfer.Events.ToArray()); + Assert.IsNull(accounts.SavedAccount); + Assert.IsNull(coordinator.Account); + } + + [TestMethod] + public async Task HelperKeepsCheckpointingAfterCoordinatorDisposalAndRecoveryCreditsIt() + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, process) = await fixture.StartSessionAsync("guest", pid: 154); + var helperIdentity = new PlaytimeProcessIdentity(900, 500, 0); + var helperProbe = new ControlledHelperProbe(process, helperIdentity); + var journalPath = PlaytimePaths.Journal(fixture.GameRoot, sessionID); + var host = Task.Run(() => PlaytimeHelperHost.Run([journalPath], fixture.Clock, helperProbe, + TimeSpan.FromMilliseconds(1))); + + await helperProbe.FirstWaitReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Clock.AdvanceSeconds(30); + helperProbe.ContinueFirst.TrySetResult(); + await helperProbe.SecondWaitReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var checkpoint = PlaytimeJson.ReadJournal(journalPath); + Assert.AreEqual(30, checkpoint.ElapsedSeconds, 0.001); + await fixture.Coordinator.DisposeAsync(); + + fixture.Clock.AdvanceSleep(TimeSpan.FromHours(2)); + fixture.Clock.JumpWallClock(TimeSpan.FromDays(1)); + fixture.Clock.AdvanceSeconds(10); + helperProbe.GameExited = true; + helperProbe.ContinueSecond.TrySetResult(); + Assert.AreEqual(0, await host.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.IsTrue(PlaytimeJson.ReadJournal(journalPath).IsFinished); + + await using var relaunched = fixture.CreateCoordinator(helperLauncher: null); + await relaunched.ReconcileAsync(); + Assert.AreEqual(40, relaunched.TotalSeconds(null), 0.001); + Assert.IsFalse(File.Exists(journalPath)); + } + + [TestMethod] + public void HelperPathMustBeARealSessionJournalInsidePlaytimeDirectory() + { + using var temporary = new TemporaryDirectory(); + var id = Guid.NewGuid(); + var validPath = PlaytimePaths.Journal(temporary.Paths.GameRoot, id); + Directory.CreateDirectory(Path.GetDirectoryName(validPath)!); + File.WriteAllText(validPath, "{}"); + + Assert.IsTrue(PlaytimePaths.TryValidateHelperPath(validPath, out var fullPath, out var sessionID)); + Assert.AreEqual(Path.GetFullPath(validPath), fullPath); + Assert.AreEqual(id, sessionID); + Assert.IsFalse(PlaytimePaths.TryValidateHelperPath(Path.Combine(temporary.Root, "outside", $"{id:D}.json"), out _, out _)); + Assert.IsFalse(PlaytimePaths.TryValidateHelperPath("relative.json", out _, out _)); + } + + [TestMethod] + public async Task PreviewPlaytimeUsesFixturesWithoutCreatingItsStore() + { + using var temporary = new TemporaryDirectory(); + var preview = PlaytimeCoordinator.CreatePreview(temporary.Paths.GameRoot); + var instanceID = Guid.NewGuid(); + preview.SeedPreviewInstance(instanceID); + await preview.InitializeAsync("preview-xuid"); + preview.Start(); + + Assert.AreEqual(12_345, preview.TotalSeconds("preview-xuid"), 0.001); + Assert.AreEqual(4_321, preview.TotalSeconds(null), 0.001); + Assert.AreEqual(3_726, preview.InstanceSeconds(instanceID, "preview-xuid"), 0.001); + Assert.IsFalse(Directory.Exists(PlaytimePaths.Root(temporary.Paths.GameRoot))); + await preview.DisposeAsync(); + } + + [TestMethod] + public void LedgerLivesUnderGameRootWhileAccountDataRemainsSeparate() + { + using var temporary = new TemporaryDirectory(); + var ledgerPath = PlaytimePaths.Ledger(temporary.Paths.GameRoot); + + Assert.IsTrue(ledgerPath.StartsWith(PlaytimePaths.Root(temporary.Paths.GameRoot), StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse(ledgerPath.StartsWith(temporary.Paths.DataRoot, StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + public void FormatterUsesLocalizedPluralKeysAndDayUnits() + { + string Localize(string key) => key switch + { + "PlaytimeMinuteOne" => "{0} minute", + "PlaytimeMinuteFew" => "{0} minutes-few", + "PlaytimeMinuteMany" => "{0} minutes", + "PlaytimeMinuteOther" => "{0} minutes", + "PlaytimeHourOne" => "{0} hour", + "PlaytimeHourFew" => "{0} hours-few", + "PlaytimeHourMany" => "{0} hours", + "PlaytimeHourOther" => "{0} hours", + "PlaytimeDayOne" => "{0} day", + "PlaytimeDayFew" => "{0} days-few", + "PlaytimeDayMany" => "{0} days", + "PlaytimeDayOther" => "{0} days", + "PlaytimeZero" => "zero", + "PlaytimeLessThanMinute" => "under minute", + _ => key + }; + + Assert.AreEqual("1 minute", PlaytimeFormatter.Format(60, AppLanguage.English, Localize)); + Assert.AreEqual("2 minutes-few", PlaytimeFormatter.Format(120, AppLanguage.Russian, Localize)); + Assert.AreEqual("5 hours", PlaytimeFormatter.Format(18_000, AppLanguage.Russian, Localize)); + Assert.AreEqual("2 days-few", PlaytimeFormatter.Format(172_800, AppLanguage.Russian, Localize)); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, false)] + [DataRow(false, true)] + public async Task FinishedHelperJournalSurvivesPendingTransferAndCleanupRetry(bool failApply, bool failCleanup) + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, process) = await fixture.StartSessionAsync("guest"); + var helperIdentity = new PlaytimeProcessIdentity(900, 500, 0); + fixture.Probe.SetRunning(helperIdentity, true); + var helperProbe = new ControlledHelperProbe(process, helperIdentity); + var path = PlaytimePaths.Journal(fixture.GameRoot, sessionID); + var host = Task.Run(() => PlaytimeHelperHost.Run([path], fixture.Clock, helperProbe)); + await helperProbe.FirstWaitReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Clock.AdvanceSeconds(25); + var transferID = await fixture.Coordinator.PrepareGuestTransferAsync("target"); + Assert.IsNotNull(transferID); + Assert.AreEqual(25, (await fixture.ReadLedgerAsync()).PendingTransfer!.GuestSecondsAtBoundary, 0.001); + var (laterSession, _) = await fixture.StartSessionAsync("guest", 152, Guid.NewGuid()); + fixture.Clock.AdvanceSeconds(7); + helperProbe.ContinueFirst.TrySetResult(); + await helperProbe.SecondWaitReached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fixture.Clock.AdvanceSeconds(3); + helperProbe.GameExited = true; + helperProbe.ContinueSecond.TrySetResult(); + Assert.AreEqual(0, await host.WaitAsync(TimeSpan.FromSeconds(5))); + fixture.Probe.SetRunning(helperIdentity, false); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 100, 0), false); + await fixture.Coordinator.ReconcileAsync(); + Assert.IsTrue(File.Exists(path), "Pending transfer must retain the final checkpoint."); + Assert.AreEqual(35, PlaytimeJson.ReadJournal(path).ElapsedSeconds, 0.001); + using (var preventApply = failApply + ? new FileStream(PlaytimePaths.Ledger(fixture.GameRoot), FileMode.Open, FileAccess.Read, FileShare.Read) + : null) + await fixture.Coordinator.CompleteGuestTransferAsync(transferID.Value); + if (failApply) + { + await fixture.Coordinator.ReconcileAsync(); + Assert.IsTrue(File.Exists(path)); + Assert.IsNotNull((await fixture.ReadLedgerAsync()).PendingTransfer); + } + await using var startup = fixture.CreateCoordinator(); + using (var preventCleanup = failCleanup ? new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read) : null) + { + await startup.InitializeAsync("target"); + Assert.AreEqual(35, startup.TotalSeconds("target"), 0.001); + Assert.AreEqual(35, startup.InstanceSeconds(fixture.InstanceID, "target"), 0.001); + Assert.AreEqual(10, startup.TotalSeconds(null), 0.001); + var ledger = await fixture.ReadLedgerAsync(); + Assert.IsFalse(ledger.Sessions.ContainsKey(sessionID)); + Assert.AreEqual("guest", ledger.Sessions[laterSession].OwnerKey); + Assert.AreEqual(1, ledger.AppliedTransfers.Count); + if (failCleanup) Assert.IsTrue(File.Exists(path)); + } + await using var repeatedStartup = fixture.CreateCoordinator(); + await repeatedStartup.InitializeAsync("target"); + await repeatedStartup.CompleteGuestTransferAsync(transferID.Value); + await repeatedStartup.ReconcileAsync(); + Assert.AreEqual(35, repeatedStartup.TotalSeconds("target"), 0.001); + Assert.AreEqual(10, repeatedStartup.TotalSeconds(null), 0.001); + Assert.IsFalse(File.Exists(path)); + } + + [TestMethod] + [DataRow(true, "complete", true, 7d)] + [DataRow(false, "complete", true, 7d)] + [DataRow(true, "recover", true, 7d)] + [DataRow(false, "recover", true, 7d)] + [DataRow(true, "cancel", true, 7d)] + [DataRow(false, "discard", true, 7d)] + [DataRow(true, "complete", false, 7d)] + [DataRow(false, "complete", false, 7d)] + [DataRow(true, "complete", true, 0d)] + [DataRow(false, "complete", true, 0d)] + public async Task DeadGameBeforeTransferRetainsUnfinishedCheckpointUntilCredit( + bool finalBeforeResolution, string resolution, bool helperPublished, double checkpoint) + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, _) = await fixture.StartSessionAsync("guest"); + var path = PlaytimePaths.Journal(fixture.GameRoot, sessionID); + var journal = PlaytimeJson.ReadJournal(path); + var helper = new PlaytimeProcessIdentity(900, 500, 0); + journal.Helper = helperPublished ? helper : null; + journal.ElapsedSeconds = checkpoint; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + fixture.Probe.SetRunning(helper, true); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 100, 0), false); + fixture.Clock.AdvanceSeconds(25); + + Guid transferID; + Guid laterSession; + using (var helperLock = PlaytimeJournalLock.TryAcquire(PlaytimePaths.JournalLock(fixture.GameRoot, sessionID))) + { + Assert.IsNotNull(helperLock); + // The helper observed exit at 19s but has not yet published its final checkpoint. + var prepared = await fixture.Coordinator.PrepareGuestTransferAsync("target") + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.IsNotNull(prepared); + transferID = prepared.Value; + var pendingLedger = await fixture.ReadLedgerAsync(); + Assert.IsTrue(pendingLedger.Sessions.ContainsKey(sessionID), "Phase A must retain the unfinished session."); + CollectionAssert.Contains(pendingLedger.PendingTransfer!.SessionIDs, sessionID); + Assert.AreEqual(checkpoint, pendingLedger.PendingTransfer.GuestSecondsAtBoundary, 0.001); + (laterSession, _) = await fixture.StartSessionAsync("guest", 152, Guid.NewGuid()); + fixture.Clock.AdvanceSeconds(3); + + if (finalBeforeResolution) PublishFinal(); + await fixture.Coordinator.ReconcileAsync(); + Assert.IsTrue(File.Exists(path)); + Assert.IsTrue((await fixture.ReadLedgerAsync()).Sessions.ContainsKey(sessionID)); + + if (resolution == "complete") + await fixture.Coordinator.CompleteGuestTransferAsync(transferID); + else if (resolution == "cancel") + await fixture.Coordinator.CancelGuestTransferAsync(transferID); + else + { + await using var recovered = fixture.CreateCoordinator(); + await recovered.RecoverPendingTransferAsync(resolution == "recover" ? "target" : "different"); + } + + if (!finalBeforeResolution) + { + await fixture.Coordinator.ReconcileAsync(); + Assert.IsTrue(File.Exists(path)); + Assert.IsTrue((await fixture.ReadLedgerAsync()).Sessions.ContainsKey(sessionID)); + PublishFinal(); + } + } + + fixture.Probe.SetRunning(helper, false); + await using var startup = fixture.CreateCoordinator(); + await startup.InitializeAsync("target"); + await startup.ReconcileAsync(); + var applied = resolution is "complete" or "recover"; + Assert.AreEqual(applied ? 19 : 0, startup.TotalSeconds("target"), 0.001); + Assert.AreEqual(applied ? 19 : 0, startup.InstanceSeconds(fixture.InstanceID, "target"), 0.001); + Assert.AreEqual(applied ? 3 : 22, startup.TotalSeconds(null), 0.001); + var ledger = await fixture.ReadLedgerAsync(); + Assert.IsNull(ledger.PendingTransfer); + Assert.AreEqual(applied ? 1 : 0, ledger.AppliedTransfers.Count); + Assert.IsFalse(ledger.Sessions.ContainsKey(sessionID)); + Assert.AreEqual("guest", ledger.Sessions[laterSession].OwnerKey); + Assert.IsFalse(File.Exists(path)); + Assert.IsFalse(File.Exists(PlaytimePaths.JournalLock(fixture.GameRoot, sessionID))); + + void PublishFinal() + { + journal.ElapsedSeconds = 19; + journal.IsFinished = true; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + } + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task CancelAttachedSessionCreditsFinalCheckpointBeforeCleanup(bool finalBeforeCancel) + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, _) = await fixture.StartSessionAsync("guest"); + var path = PlaytimePaths.Journal(fixture.GameRoot, sessionID); + var journal = PlaytimeJson.ReadJournal(path); + var helper = new PlaytimeProcessIdentity(900, 500, 0); + fixture.Probe.SetRunning(helper, true); + journal.Helper = helper; + journal.ElapsedSeconds = finalBeforeCancel ? 19 : 7; + journal.IsFinished = finalBeforeCancel; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 100, 0), false); + + using (var helperLock = PlaytimeJournalLock.TryAcquire(PlaytimePaths.JournalLock(fixture.GameRoot, sessionID))) + { + Assert.IsNotNull(helperLock); + await fixture.Coordinator.CancelSessionAsync(sessionID).WaitAsync(TimeSpan.FromSeconds(5)); + if (!finalBeforeCancel) + { + Assert.IsTrue((await fixture.ReadLedgerAsync()).Sessions.ContainsKey(sessionID)); + Assert.IsTrue(File.Exists(path)); + journal.ElapsedSeconds = 19; + journal.IsFinished = true; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + } + } + fixture.Probe.SetRunning(helper, false); + await fixture.Coordinator.ReconcileAsync(); + + Assert.AreEqual(19, fixture.Coordinator.TotalSeconds(null), 0.001); + Assert.IsFalse((await fixture.ReadLedgerAsync()).Sessions.ContainsKey(sessionID)); + Assert.IsFalse(File.Exists(path)); + } + + [TestMethod] + [DataRow(false, 0d)] + [DataRow(false, 17d)] + [DataRow(true, 17d)] + public async Task LateHelperCannotCreditDeadOrReusedProcess(bool reusedPid, double checkpoint) + { + await using var fixture = new PlaytimeFixture(); + var (sessionID, _) = await fixture.StartSessionAsync("guest"); + var path = PlaytimePaths.Journal(fixture.GameRoot, sessionID); + var journal = PlaytimeJson.ReadJournal(path); + journal.BaseElapsedSeconds = Math.Min(5, checkpoint); + journal.ElapsedSeconds = checkpoint; + PlaytimeJson.AtomicWrite(path, PlaytimeJson.SerializeJournal(journal)); + fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 100, 0), false); + if (reusedPid) fixture.Probe.SetRunning(new PlaytimeProcessIdentity(151, 101, 0), true); + fixture.Clock.AdvanceSeconds(3_600); + fixture.Clock.AdvanceSleep(TimeSpan.FromHours(2)); + fixture.Clock.JumpWallClock(TimeSpan.FromDays(-1)); + Assert.AreEqual(0, PlaytimeHelperHost.Run([path], fixture.Clock, fixture.Probe)); + var finished = PlaytimeJson.ReadJournal(path); + Assert.IsTrue(finished.IsFinished); + Assert.AreEqual(checkpoint, finished.ElapsedSeconds, 0.001); + Assert.AreEqual(Math.Min(5, checkpoint), finished.BaseElapsedSeconds, 0.001); + } + + [TestMethod] + [DataRow("playtime")] + [DataRow("parent")] + [DataRow("root")] + [DataRow("ledger")] + [DataRow("lock")] + public async Task LedgerRecoveryRejectsReparseChainWithoutTouchingExternalData(string location) + { + using var temporary = new TemporaryDirectory(); + var external = Path.Combine(temporary.Root, "External"); + Directory.CreateDirectory(external); + var sentinel = Path.Combine(external, "keep.txt"); + File.WriteAllText(sentinel, "external bytes"); + var gameRoot = temporary.Paths.GameRoot; + string link; + if (location == "parent") + { + Directory.CreateDirectory(Path.Combine(external, "Game")); + link = Path.Combine(temporary.Root, "Parent"); + gameRoot = Path.Combine(link, "Game"); + } + else if (location == "root") link = gameRoot; + else + { + Directory.CreateDirectory(gameRoot); + link = location == "playtime" ? PlaytimePaths.Root(gameRoot) + : Path.Combine(PlaytimePaths.Root(gameRoot), location == "ledger" ? "playtime.json" : "playtime.lock"); + Directory.CreateDirectory(Path.GetDirectoryName(link)!); + } + var command = "$ErrorActionPreference = 'Stop'; New-Item -ItemType Junction -Path '" + + link.Replace("'", "''") + "' -Target '" + external.Replace("'", "''") + "' | Out-Null"; + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("powershell.exe") + { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, + ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command } + })!; + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, process.StandardError.ReadToEnd()); + try + { + var store = new PlaytimeLedgerStore(gameRoot); + await Assert.ThrowsExactlyAsync(() => store.ReadAsync()); + await Assert.ThrowsExactlyAsync(() => store.MutateAsync(ledger => + { + ledger.Owners["guest"] = new PlaytimeOwnerRecord { TotalSeconds = 99 }; + return true; + })); + await using var coordinator = new PlaytimeCoordinator(gameRoot); + await coordinator.InitializeAsync(null); + Assert.AreEqual("PlaytimeStorageError", coordinator.ErrorResourceKey); + Assert.AreEqual("external bytes", File.ReadAllText(sentinel)); + CollectionAssert.AreEquivalent(new[] { "keep.txt" }, Directory.GetFiles(external, "*", SearchOption.AllDirectories) + .Select(Path.GetFileName).ToArray()); + Assert.AreEqual(0, Directory.GetFiles(temporary.Root, "*.tmp", SearchOption.AllDirectories).Length); + Assert.AreEqual(0, Directory.GetFiles(temporary.Root, "*.lock", SearchOption.AllDirectories).Length); + } + finally { Directory.Delete(link); } + } + + [TestMethod] + [DataRow("playtime.json")] + [DataRow("playtime.lock")] + public async Task LedgerRejectsFileSymlinksBeforeReadingWritingOrCreatingLock(string filename) + { + using var temporary = new TemporaryDirectory(); + var directory = PlaytimePaths.Root(temporary.Paths.GameRoot); + Directory.CreateDirectory(directory); + var target = Path.Combine(temporary.Root, "external.json"); + File.WriteAllText(target, "external bytes"); + var link = Path.Combine(directory, filename); + try { File.CreateSymbolicLink(link, target); } + catch (UnauthorizedAccessException) { Assert.Inconclusive("File symlinks require Windows developer mode or privilege."); } + try + { + var store = new PlaytimeLedgerStore(temporary.Paths.GameRoot); + await Assert.ThrowsExactlyAsync(() => store.ReadAsync()); + await Assert.ThrowsExactlyAsync(() => store.MutateAsync(_ => true)); + Assert.ThrowsExactly(() => PlaytimeJson.AtomicWrite(link, new byte[] { 1, 2, 3 })); + Assert.AreEqual("external bytes", File.ReadAllText(target)); + Assert.IsTrue((File.GetAttributes(link) & FileAttributes.ReparsePoint) != 0); + CollectionAssert.AreEquivalent(new[] { filename }, Directory.GetFiles(directory).Select(Path.GetFileName).ToArray()); + } + finally { File.Delete(link); } + } + + [TestMethod] + public async Task LedgerRejectsMissingGameRootButCreatesItsOwnDirectory() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Root); + var store = new PlaytimeLedgerStore(temporary.Paths.GameRoot); + await Assert.ThrowsAsync(() => store.ReadAsync()); + await Assert.ThrowsAsync(() => store.MutateAsync(_ => true)); + Assert.IsFalse(Directory.Exists(temporary.Paths.GameRoot)); + Directory.CreateDirectory(temporary.Paths.GameRoot); + Assert.AreEqual(0, (await store.ReadAsync()).Owners.Count); + await store.MutateAsync(_ => true); + Assert.IsTrue(File.Exists(PlaytimePaths.Ledger(temporary.Paths.GameRoot))); + } + + private sealed class PlaytimeFixture : IAsyncDisposable + { + private readonly TemporaryDirectory _temporary = new(); + private readonly List _coordinators = []; + private readonly TimeProvider _timeProvider; + + public string GameRoot => _temporary.Paths.GameRoot; + public Guid InstanceID { get; } = Guid.NewGuid(); + public FakePlaytimeClock Clock { get; } = new(); + public FakeProcessProbe Probe { get; } = new(); + public FakeHelperLauncher Launcher { get; } = new(); + public PlaytimeCoordinator Coordinator { get; } + + public PlaytimeFixture(Action? checkpoint = null, TimeProvider? timeProvider = null) + { + Directory.CreateDirectory(GameRoot); + _timeProvider = timeProvider ?? TimeProvider.System; + Coordinator = CreateCoordinator(checkpoint: checkpoint); + } + + public PlaytimeCoordinator CreateCoordinator(IPlaytimeHelperLauncher? helperLauncher = null, + Action? checkpoint = null) => + Track(new PlaytimeCoordinator(GameRoot, helperLauncher ?? Launcher, Clock, Probe, _timeProvider, + preview: false, transferCheckpoint: checkpoint)); + + public async Task<(Guid SessionID, GameProcessRecord Process)> StartSessionAsync(string owner, + int pid = 151, Guid? instanceID = null) + { + var instance = instanceID ?? InstanceID; + var tracking = await Coordinator.BeginSessionAsync(instance, owner == "guest" ? null : owner["account:".Length..]); + Assert.IsNotNull(tracking); + var process = new GameProcessRecord(instance, pid, 100, 0, tracking.SessionID); + Probe.SetRunning(new PlaytimeProcessIdentity(pid, 100, 0), true); + await Coordinator.AttachProcessAsync(tracking, process); + return (tracking.SessionID, process); + } + + public async Task ReadLedgerAsync() => await new PlaytimeLedgerStore(GameRoot).ReadAsync(); + + private PlaytimeCoordinator Track(PlaytimeCoordinator coordinator) + { + _coordinators.Add(coordinator); + return coordinator; + } + + public async ValueTask DisposeAsync() + { + foreach (var coordinator in _coordinators.AsEnumerable().Reverse()) await coordinator.DisposeAsync(); + await _temporary.DisposeAsync(); + } + } + + private sealed class FakePlaytimeClock(ulong initial = 0) : IPlaytimeClock + { + private long _ticks = checked((long)initial); + private TimeSpan _wallClockElapsed; + public ulong GetUnbiasedTicks() => checked((ulong)Interlocked.Read(ref _ticks)); + public TimeSpan WallClockElapsed => _wallClockElapsed; + public void AdvanceSeconds(double seconds) => Interlocked.Add(ref _ticks, checked((long)(seconds * 10_000_000))); + public void AdvanceSleep(TimeSpan duration) => _wallClockElapsed += duration; + public void JumpWallClock(TimeSpan duration) => _wallClockElapsed += duration; + } + + private class FakeProcessProbe : IPlaytimeProcessProbe + { + private readonly object _sync = new(); + private readonly Dictionary _running = []; + private PlaytimeProcessIdentity? _current = new(900, 500, 0); + + public virtual PlaytimeProcessIdentity? ReadCurrentIdentity() => _current; + public void SetCurrent(PlaytimeProcessIdentity? identity) => _current = identity; + + public void SetRunning(PlaytimeProcessIdentity identity, bool running) + { + lock (_sync) _running[identity] = running; + } + + public virtual bool IsRunning(PlaytimeProcessIdentity identity) + { + lock (_sync) return _running.GetValueOrDefault(identity); + } + + public virtual bool WaitForExitOrTimeout(PlaytimeProcessIdentity identity, TimeSpan timeout) => !IsRunning(identity); + } + + private sealed class ControlledHelperProbe(GameProcessRecord process, PlaytimeProcessIdentity helper) + : FakeProcessProbe + { + private int _waitNumber; + public TaskCompletionSource FirstWaitReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SecondWaitReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ContinueFirst { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ContinueSecond { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool GameExited { get; set; } + + public override PlaytimeProcessIdentity? ReadCurrentIdentity() => helper; + + public override bool IsRunning(PlaytimeProcessIdentity identity) + { + if (identity.Pid == process.Pid) return !GameExited; + return identity == helper; + } + + public override bool WaitForExitOrTimeout(PlaytimeProcessIdentity identity, TimeSpan timeout) + { + var number = Interlocked.Increment(ref _waitNumber); + if (number == 1) + { + FirstWaitReached.TrySetResult(); + ContinueFirst.Task.GetAwaiter().GetResult(); + return false; + } + + SecondWaitReached.TrySetResult(); + ContinueSecond.Task.GetAwaiter().GetResult(); + return true; + } + } + + private sealed class FakeHelperLauncher : IPlaytimeHelperLauncher + { + private int _attempts; + public int Attempts => Volatile.Read(ref _attempts); + public bool Succeeds { get; set; } + + public Task TryStartAsync(string journalPath, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _attempts); + return Task.FromResult(Succeeds); + } + } + + private sealed class SignalingTimeProvider : TimeProvider + { + public TaskCompletionSource TimerCreated { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + private DateTimeOffset _now = DateTimeOffset.UnixEpoch; + private ManualTimer? _timer; + + public override DateTimeOffset GetUtcNow() => _now; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + TimerCreated.TrySetResult(); + _timer = new ManualTimer(this, callback, state, dueTime, period); + return _timer; + } + + public void Advance(TimeSpan amount) + { + _now += amount; + _timer?.Fire(); + } + + private sealed class ManualTimer(SignalingTimeProvider owner, TimerCallback callback, object? state, + TimeSpan dueTime, TimeSpan period) : ITimer + { + private DateTimeOffset _next = owner._now + dueTime; + private TimeSpan _period = period; + private bool _disposed; + + public bool Change(TimeSpan newDueTime, TimeSpan newPeriod) + { + _period = newPeriod; + _next = owner._now + newDueTime; + return !_disposed; + } + + public void Dispose() => _disposed = true; + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + + public void Fire() + { + if (_disposed || owner._now < _next) return; + callback(state); + _next = _period == Timeout.InfiniteTimeSpan ? DateTimeOffset.MaxValue : _next + _period; + } + } + } + + private sealed class RecordingTransfer : IAccountPlaytimeTransfer + { + private readonly List _events = []; + public IReadOnlyList Events { get { lock (_events) return _events.ToArray(); } } + public bool PauseCompletion { get; set; } + public TaskCompletionSource CompleteStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseCompletion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task PrepareGuestTransferAsync(string targetXuid, CancellationToken cancellationToken = default) + { + lock (_events) _events.Add("prepare"); + return Task.FromResult(Guid.NewGuid()); + } + + public async Task CompleteGuestTransferAsync(Guid transferID, CancellationToken cancellationToken = default) + { + lock (_events) _events.Add("complete"); + CompleteStarted.TrySetResult(); + if (PauseCompletion) await ReleaseCompletion.Task; + } + + public Task CancelGuestTransferAsync(Guid transferID, CancellationToken cancellationToken = default) + { + lock (_events) _events.Add("cancel"); + return Task.CompletedTask; + } + } +} diff --git a/windows/Hako.Tests/RecycleBinTests.cs b/windows/Hako.Tests/RecycleBinTests.cs new file mode 100644 index 0000000..4094948 --- /dev/null +++ b/windows/Hako.Tests/RecycleBinTests.cs @@ -0,0 +1,324 @@ +using Hako.Core; +using System.Diagnostics; +using System.Runtime.Versioning; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class RecycleBinTests +{ + [TestMethod] + public void ProgressSinkCancelsAnyDeleteNotMarkedForRecycle() + { + var sink = new RecycleBinProgressSink(); + + Assert.AreEqual(unchecked((int)0x80004004), RecycleBinProgressSink.HResultAbort); + Assert.AreEqual(RecycleBinProgressSink.HResultAbort, sink.PreDeleteItem(0, null!)); + Assert.AreEqual(RecycleBinProgressSink.HResultAbort, sink.PreDeleteItem(0x2, null!)); + Assert.IsFalse(sink.RecycleWasRequested); + Assert.IsTrue(sink.NotRecyclable); + Assert.AreEqual(0, sink.PreDeleteItem(RecycleBinProgressSink.TransferDeleteRecycleIfPossible, null!)); + Assert.IsTrue(sink.RecycleWasRequested); + + sink.PostDeleteItem(0, null!, unchecked((int)0x80004005), null); + Assert.IsFalse(sink.DeleteSucceeded); + sink.PostDeleteItem(0, null!, 0, null); + Assert.IsFalse(sink.DeleteSucceeded); + } + + [TestMethod] + public async Task UnavailableRecycleBinLeavesSourceFileInPlace() + { + using var temporary = new TemporaryDirectory(); + var file = Path.Combine(temporary.Path, "source.txt"); + await File.WriteAllTextAsync(file, "keep me"); + var recycleBin = new RecycleBin(new UnavailableOperation()); + + var result = await recycleBin.DeleteAsync(file); + + Assert.AreEqual(RecycleBinItemStatus.Failed, result.Items.Single().Status); + Assert.AreEqual("No recycle bin is available.", result.Items.Single().Error); + Assert.IsTrue(File.Exists(file)); + Assert.AreEqual("keep me", await File.ReadAllTextAsync(file)); + } + + [TestMethod] + public async Task NotRecyclableItemIsReturnedWithItsReasonAndRemainsInPlace() + { + using var temporary = new TemporaryDirectory(); + var file = Path.Combine(temporary.Path, "not-recyclable.txt"); + await File.WriteAllTextAsync(file, "keep me"); + var recycleBin = new RecycleBin(new NotRecyclableOperation()); + + var result = await recycleBin.DeleteAsync([file]); + + Assert.AreEqual(1, result.Items.Count); + Assert.AreEqual(Path.GetFullPath(file), result.Items[0].Path); + Assert.AreEqual(RecycleBinItemStatus.NotRecyclable, result.Items[0].Status); + Assert.IsTrue(File.Exists(file)); + Assert.AreEqual("keep me", await File.ReadAllTextAsync(file)); + } + + [TestMethod] + public async Task MixedBatchReturnsStatusAndErrorForEveryItemAfterPartialFailure() + { + using var temporary = new TemporaryDirectory(); + var paths = new[] { "recycled.txt", "failed.txt", "cancelled.txt" } + .Select(name => Path.Combine(temporary.Path, name)).ToArray(); + var recycleBin = new RecycleBin(new MixedOperation()); + + var result = await recycleBin.DeleteAsync(paths); + + Assert.AreEqual(3, result.Items.Count); + Assert.AreEqual(RecycleBinItemStatus.Recycled, result.Items[0].Status); + Assert.AreEqual(RecycleBinItemStatus.Failed, result.Items[1].Status); + Assert.AreEqual("Injected item failure.", result.Items[1].Error); + Assert.AreEqual(RecycleBinItemStatus.Cancelled, result.Items[2].Status); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task FileIsMovedToTheWindowsRecycleBin() + { + RequireRecycleBinIntegration(); + using var temporary = new TemporaryDirectory(); + var file = Path.Combine(temporary.Path, "trash-me.txt"); + await File.WriteAllTextAsync(file, "reversible"); + + var result = await new RecycleBin().DeleteAsync(file); + Assert.AreEqual(RecycleBinItemStatus.Recycled, result.Items.Single().Status, result.Items.Single().Error); + + Assert.IsFalse(File.Exists(file)); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task LongPathIsEitherRecycledOrReportedAsNotRecyclableWithoutDataLoss() + { + RequireRecycleBinIntegration(); + using var temporary = new TemporaryDirectory(); + var directory = temporary.Path; + try + { + for (var index = 0; index < 4; index++) + { + directory = Path.Combine(directory, new string((char)('a' + index), 80)); + Directory.CreateDirectory(directory); + } + + var file = Path.Combine(directory, "long-path.txt"); + await File.WriteAllTextAsync(file, "reversible"); + Assert.IsTrue(file.Length > 260); + var result = await new RecycleBin().DeleteAsync(file); + var item = result.Items.Single(); + if (item.Status == RecycleBinItemStatus.NotRecyclable) + { + Assert.IsTrue(File.Exists(file)); + Assert.AreEqual("reversible", await File.ReadAllTextAsync(file)); + } + else if (item.Status == RecycleBinItemStatus.Recycled) + { + Assert.IsFalse(File.Exists(file)); + } + else + { + Assert.Fail($"The Shell could not confirm the long-path result ({item.Status}): {item.Error}"); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive("This environment does not allow creating long-path test files: " + exception.Message); + } + } + + [TestMethod] + public async Task PermanentDeleteRemovesAFileBeyondTheLegacyPathLimit() + { + using var temporary = new TemporaryDirectory(); + var directory = temporary.Path; + try + { + for (var index = 0; index < 4; index++) + { + directory = Path.Combine(directory, new string((char)('d' + index), 80)); + Directory.CreateDirectory(directory); + } + + var file = Path.Combine(directory, "permanent-delete.txt"); + await File.WriteAllTextAsync(file, "remove"); + Assert.IsTrue(file.Length > 260); + + new RecycleBin().PermanentDelete(file, temporary.Path); + + Assert.IsFalse(File.Exists(file)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive("This environment does not allow creating long-path test files: " + exception.Message); + } + } + + [TestMethod] + public async Task PermanentDeleteRemovesNestedJunctionWithoutTouchingItsTarget() + { + using var temporary = new TemporaryDirectory(); + var folder = Path.Combine(temporary.Path, "to-delete"); + var target = Path.Combine(temporary.Path, "external-target"); + Directory.CreateDirectory(folder); + Directory.CreateDirectory(target); + var targetFile = Path.Combine(target, "keep.txt"); + await File.WriteAllTextAsync(targetFile, "keep"); + var junction = Path.Combine(folder, "outside"); + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, target } + }); + process!.WaitForExit(); + if (process.ExitCode != 0) + { + Assert.Inconclusive("Could not create a junction in the temporary test folder: " + process.StandardError.ReadToEnd()); + } + + new RecycleBin().PermanentDelete(folder, temporary.Path); + + Assert.IsFalse(Directory.Exists(folder)); + Assert.AreEqual("keep", await File.ReadAllTextAsync(targetFile)); + } + + [TestMethod] + public async Task PermanentDeleteRejectsRelativeVolumeRootOutsideAndReparseTraversalPaths() + { + using var temporary = new TemporaryDirectory(); + var root = temporary.Path; + var file = Path.Combine(root, "keep.txt"); + await File.WriteAllTextAsync(file, "keep"); + var recycleBin = new RecycleBin(new NotRecyclableOperation()); + + Assert.ThrowsExactly(() => recycleBin.PermanentDelete("relative.txt", root)); + Assert.ThrowsExactly(() => recycleBin.PermanentDelete(file, Path.GetPathRoot(root)!)); + Assert.ThrowsExactly(() => recycleBin.PermanentDelete(Path.GetPathRoot(root)!, root)); + Assert.ThrowsExactly(() => recycleBin.PermanentDelete(Path.Combine(Path.GetDirectoryName(root)!, "outside.txt"), root)); + + var outsideTarget = Path.Combine(temporary.Path, "outside-target"); + Directory.CreateDirectory(outsideTarget); + var outsideFile = Path.Combine(outsideTarget, "target.txt"); + await File.WriteAllTextAsync(outsideFile, "keep target"); + var junction = Path.Combine(root, "outside-link"); + using var process = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", junction, outsideTarget } + }); + process!.WaitForExit(); + if (process.ExitCode != 0) + { + Assert.Inconclusive("Could not create a junction in the temporary test folder: " + process.StandardError.ReadToEnd()); + } + + Assert.ThrowsExactly(() => recycleBin.PermanentDelete(Path.Combine(junction, "target.txt"), root)); + Assert.AreEqual("keep", await File.ReadAllTextAsync(file)); + Assert.AreEqual("keep target", await File.ReadAllTextAsync(outsideFile)); + Directory.Delete(junction); + + var rootTarget = Path.Combine(root, "root-target"); + Directory.CreateDirectory(rootTarget); + var rootTargetFile = Path.Combine(rootTarget, "keep.txt"); + await File.WriteAllTextAsync(rootTargetFile, "keep root target"); + var rootJunction = Path.Combine(root, "root-link"); + using var rootProcess = Process.Start(new ProcessStartInfo("cmd.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", rootJunction, rootTarget } + }); + rootProcess!.WaitForExit(); + if (rootProcess.ExitCode != 0) + { + Assert.Inconclusive("Could not create a root junction in the temporary test folder: " + rootProcess.StandardError.ReadToEnd()); + } + + Assert.ThrowsExactly(() => recycleBin.PermanentDelete(Path.Combine(rootJunction, "keep.txt"), rootJunction)); + Assert.AreEqual("keep root target", await File.ReadAllTextAsync(rootTargetFile)); + Directory.Delete(rootJunction); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task RepeatedRecycleOperationsReleaseComWrappersAcrossStaThreads() + { + RequireRecycleBinIntegration(); + using var temporary = new TemporaryDirectory(); + var recycleBin = new RecycleBin(); + for (var index = 0; index < 8; index++) + { + var file = Path.Combine(temporary.Path, $"release-{index}.txt"); + await File.WriteAllTextAsync(file, "reversible"); + + var item = (await recycleBin.DeleteAsync(file)).Items.Single(); + Assert.IsTrue(item.Status is RecycleBinItemStatus.Recycled or RecycleBinItemStatus.NotRecyclable, + $"Unexpected Shell result: {item.Status} ({item.Error})"); + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + + private sealed class UnavailableOperation : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) => + Task.FromException(new RecycleBinException("No recycle bin is available.")); + } + + private sealed class NotRecyclableOperation : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) => + Task.FromResult(RecycleBinItemStatus.NotRecyclable); + } + + private sealed class MixedOperation : IRecycleBinOperation + { + public Task DeleteAsync(string path, CancellationToken cancellationToken) => + Path.GetFileName(path) switch + { + "recycled.txt" => Task.FromResult(RecycleBinItemStatus.Recycled), + "failed.txt" => Task.FromException(new IOException("Injected item failure.")), + _ => Task.FromException(new OperationCanceledException("Injected cancellation.")) + }; + } + + private static void RequireRecycleBinIntegration() + { + if (Environment.GetEnvironmentVariable("HAKO_RUN_RECYCLE_BIN_TESTS") != "1") + { + Assert.Inconclusive("Set HAKO_RUN_RECYCLE_BIN_TESTS=1 to move test files to the real Windows Recycle Bin."); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Hako.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} diff --git a/windows/Hako.Tests/TestSupport.cs b/windows/Hako.Tests/TestSupport.cs index f4758c4..e2d846f 100644 --- a/windows/Hako.Tests/TestSupport.cs +++ b/windows/Hako.Tests/TestSupport.cs @@ -1,4 +1,5 @@ using Hako.Core; +using Hako.Core.Instances; using System.Net; using System.Text; @@ -290,6 +291,54 @@ public async Task WaitForTokenAsync(DeviceCode deviceCode, Cance } public Task SignInAsync(MicrosoftToken token, CancellationToken cancellationToken) => _signIn(Interlocked.Increment(ref _attempt)); + + public async Task ConnectMinecraftAsync( + string refreshToken, + Func persistRefreshToken, + CancellationToken cancellationToken) + { + await persistRefreshToken(refreshToken + "-rotated", cancellationToken); + return TestData.MinecraftConnection(); + } +} + +internal sealed class RefreshAuthService : IMicrosoftAuthService +{ + private readonly Func, CancellationToken, Task> _connect; + private int _connectionCount; + + public RefreshAuthService( + Func, CancellationToken, Task>? connect = null) + { + _connect = connect ?? DefaultConnectAsync; + } + + public int ConnectionCount => Volatile.Read(ref _connectionCount); + public TaskCompletionSource ConnectStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task RequestDeviceCodeAsync(CancellationToken cancellationToken) => Task.FromResult(TestData.DeviceCode()); + public Task WaitForTokenAsync(DeviceCode deviceCode, CancellationToken cancellationToken) => + Task.FromResult(new MicrosoftToken("access", "refresh", null)); + public Task SignInAsync(MicrosoftToken token, CancellationToken cancellationToken) => Task.FromResult(TestData.Account()); + + public Task ConnectMinecraftAsync( + string refreshToken, + Func persistRefreshToken, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _connectionCount); + ConnectStarted.TrySetResult(); + return _connect(refreshToken, persistRefreshToken, cancellationToken); + } + + private static async Task DefaultConnectAsync( + string refreshToken, + Func persistRefreshToken, + CancellationToken cancellationToken) + { + await persistRefreshToken("rotated-refresh", cancellationToken); + return TestData.MinecraftConnection(); + } } internal static class TestData @@ -299,6 +348,9 @@ internal static class TestData public static AccountSession Account(string xuid = "123", string gamertag = "HakoPlayer") => new(xuid, gamertag, "player@example.com", null, "profile-id", "Player", "refresh-secret", "minecraft-secret", DateTimeOffset.Parse("2025-01-01T01:00:00Z")); + public static MinecraftConnection MinecraftConnection() => + new("profile-id", "Player", "minecraft-connected-secret", DateTimeOffset.Parse("2025-01-01T02:00:00Z")); + public static HttpResponseMessage Json(HttpStatusCode status, string content) => new(status) { Content = new StringContent(content, Encoding.UTF8, "application/json") }; @@ -324,3 +376,40 @@ public static Dictionary ParseForm(string? body) => (body ?? str pair => Uri.UnescapeDataString(pair[0].Replace('+', ' ')), pair => Uri.UnescapeDataString((pair.Length > 1 ? pair[1] : string.Empty).Replace('+', ' '))); } + +internal sealed class TemporaryDirectory : IDisposable, IAsyncDisposable +{ + private readonly List _resources = []; + + public TemporaryDirectory() + { + Root = Path.Combine(Path.GetTempPath(), "Hako.Tests", Guid.NewGuid().ToString("N")); + Paths = HakoPaths.CreateIsolated(Path.Combine(Root, "Game"), Path.Combine(Root, "Data")); + } + + public string Root { get; } + public HakoPaths Paths { get; } + + public InstanceStore CreateStore() => Track(new InstanceStore(Paths)); + + public T Track(T resource) where T : IAsyncDisposable + { + _resources.Add(resource); + return resource; + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + public async ValueTask DisposeAsync() + { + for (var index = _resources.Count - 1; index >= 0; index--) + { + await _resources[index].DisposeAsync().ConfigureAwait(false); + } + + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } +} diff --git a/windows/Hako.Tests/UserSettingsServiceTests.cs b/windows/Hako.Tests/UserSettingsServiceTests.cs new file mode 100644 index 0000000..8f96cf0 --- /dev/null +++ b/windows/Hako.Tests/UserSettingsServiceTests.cs @@ -0,0 +1,247 @@ +using Hako.Core; +using System.Runtime.Versioning; + +namespace Hako.Tests; + +[TestClass] +[SupportedOSPlatform("windows")] +public sealed class UserSettingsServiceTests +{ + [TestMethod] + public async Task GameParametersAndLanguagePersistTogetherInEitherUpdateOrder() + { + using var temporary = new TemporaryDirectory(); + var store = new JsonUserSettingsStore(temporary.Path); + var service = new UserSettingsService(store); + await service.LoadAsync(); + var defaults = new GameLaunchDefaults("C:\\Java\\bin\\java.exe", "-Djava.test=1", "--demo", true, 1920, 1080, 2048); + + await service.SetGameLaunchDefaultsAsync(defaults); + await service.SetLanguageAsync(AppLanguage.English); + AssertSettings(await store.LoadAsync(), AppLanguage.English, defaults); + + await service.SetLanguageAsync(AppLanguage.Russian); + var laterDefaults = defaults with { Fullscreen = false, WindowWidth = 1600, MaximumMemoryMiB = 1024 }; + await service.SetGameLaunchDefaultsAsync(laterDefaults); + AssertSettings(await store.LoadAsync(), AppLanguage.Russian, laterDefaults); + } + + [TestMethod] + public async Task ConcurrentLanguageAndGameParameterSavesKeepTheCompleteLatestSnapshot() + { + var store = new PausingSettingsStore(new UserSettings("ru")); + var service = new UserSettingsService(store); + await service.LoadAsync(); + var defaults = new GameLaunchDefaults("java.exe", "-Xms512M", "--quickPlay", false, 1440, 900, 1024); + + var gameSave = service.SetGameLaunchDefaultsAsync(defaults); + await store.FirstSaveReached.Task; + var languageSave = service.SetLanguageAsync(AppLanguage.English); + store.ReleaseFirstSave.TrySetResult(); + await Task.WhenAll(gameSave, languageSave); + + AssertSettings(store.Settings, AppLanguage.English, defaults); + AssertSettings(service.Current, AppLanguage.English, defaults); + } + + [TestMethod] + public async Task FailedSaveRollsBackBeforeAQueuedSaveCapturesItsSnapshot() + { + var store = new FailingFirstThenPausingSecondSettingsStore(new UserSettings("ru")); + var service = new UserSettingsService(store); + await service.LoadAsync(); + var defaults = new GameLaunchDefaults("java.exe", "-Xms512M", "--demo", false, 1440, 900, 1024); + + var firstSave = service.SetLanguageAsync(AppLanguage.English); + await store.FirstSaveReached.Task; + var secondSave = service.SetGameLaunchDefaultsAsync(defaults); + Assert.AreEqual(defaults.JavaPath, service.Current.JavaPath); + store.ReleaseFirstSave.TrySetResult(); + + await store.SecondSaveReached.Task; + await Assert.ThrowsExactlyAsync(() => firstSave); + store.ReleaseSecondSave.TrySetResult(); + await secondSave; + + Assert.AreEqual("ru", store.Settings?.Language); + Assert.AreEqual("ru", service.Current.Language); + Assert.AreEqual(defaults.JavaPath, store.Settings?.JavaPath); + } + + [TestMethod] + public async Task SettingsFileContainingOnlyLanguageRemainsReadable() + { + using var temporary = new TemporaryDirectory(); + await File.WriteAllTextAsync(Path.Combine(temporary.Path, "settings.json"), "{\"language\":\"en\"}"); + var service = new UserSettingsService(new JsonUserSettingsStore(temporary.Path)); + + var loaded = await service.LoadAsync(); + var defaults = service.GetGameLaunchDefaults(); + + Assert.AreEqual(new UserSettings("en"), loaded); + Assert.AreEqual(AppLanguage.English, AppLanguages.Parse(service.Current.Language)); + Assert.AreEqual("", defaults.JavaPath); + Assert.AreEqual("", defaults.JavaArguments); + Assert.AreEqual("", defaults.MinecraftArguments); + Assert.IsFalse(defaults.Fullscreen); + Assert.AreEqual(1280, defaults.WindowWidth); + Assert.AreEqual(720, defaults.WindowHeight); + Assert.IsTrue(defaults.MaximumMemoryMiB is >= 512 and <= 4096); + } + + [TestMethod] + public async Task FailedLoadIsRetriedBeforeLanguageSaveAndNeverOverwritesUnreadableSettings() + { + var store = new FailingLoadSettingsStore(new UserSettings( + "ru", "C:\\Java\\bin\\java.exe", "-Xms2G", "--demo", true, 1920, 1080, 4096)); + var service = new UserSettingsService(store); + + await Assert.ThrowsExactlyAsync(() => service.LoadAsync()); + await Assert.ThrowsExactlyAsync(() => service.SetLanguageAsync(AppLanguage.English)); + + Assert.AreEqual(2, store.LoadCount); + Assert.AreEqual(0, store.SaveCount); + Assert.AreEqual("ru", store.PersistedSettings?.Language); + Assert.AreEqual("C:\\Java\\bin\\java.exe", store.PersistedSettings?.JavaPath); + Assert.AreEqual(4096, store.PersistedSettings?.MaximumMemoryMiB); + } + + [TestMethod] + public async Task SuccessfulRetryMergesLanguageWithSettingsReadFromDisk() + { + var store = new FailingLoadSettingsStore(new UserSettings( + "ru", "C:\\Java\\bin\\java.exe", "-Xms2G", "--demo", true, 1920, 1080, 4096)) + { + FailLoadCount = 1 + }; + var service = new UserSettingsService(store); + + await Assert.ThrowsExactlyAsync(() => service.LoadAsync()); + await service.SetLanguageAsync(AppLanguage.English); + + Assert.AreEqual(2, store.LoadCount); + Assert.AreEqual(1, store.SaveCount); + Assert.AreEqual("en", store.PersistedSettings?.Language); + Assert.AreEqual("C:\\Java\\bin\\java.exe", store.PersistedSettings?.JavaPath); + Assert.AreEqual("-Xms2G", store.PersistedSettings?.JavaArguments); + Assert.AreEqual("--demo", store.PersistedSettings?.MinecraftArguments); + Assert.AreEqual(true, store.PersistedSettings?.Fullscreen); + Assert.AreEqual(4096, store.PersistedSettings?.MaximumMemoryMiB); + } + + [TestMethod] + public async Task CorruptSettingsCanBeReplacedByTheFullCurrentSnapshot() + { + using var temporary = new TemporaryDirectory(); + var settingsPath = Path.Combine(temporary.Path, "settings.json"); + await File.WriteAllTextAsync(settingsPath, "{ broken"); + var store = new JsonUserSettingsStore(temporary.Path); + var service = new UserSettingsService(store); + + await Assert.ThrowsExactlyAsync(() => service.LoadAsync()); + await service.SetLanguageAsync(AppLanguage.English); + + var saved = await store.LoadAsync(); + Assert.IsNotNull(saved); + Assert.AreEqual("en", saved.Language); + Assert.AreEqual(service.Current, saved); + } + + [TestMethod] + public void NonpositiveMaximumMemoryUsesTheStandardInitialValue() + { + Assert.AreEqual(GameLaunchDefaults.Standard.MaximumMemoryMiB, GameLaunchDefaults.NormalizeMemory(0)); + Assert.AreEqual(GameLaunchDefaults.Standard.MaximumMemoryMiB, GameLaunchDefaults.NormalizeMemory(-1)); + Assert.AreEqual(GameLaunchDefaults.Standard.MaximumMemoryMiB, GameLaunchDefaults.From(new UserSettings("en", MaximumMemoryMiB: 0)).MaximumMemoryMiB); + } + + private static void AssertSettings(UserSettings? settings, AppLanguage language, GameLaunchDefaults expected) + { + Assert.IsNotNull(settings); + Assert.AreEqual(language.ToTag(), settings.Language); + Assert.AreEqual(expected.JavaPath, settings.JavaPath); + Assert.AreEqual(expected.JavaArguments, settings.JavaArguments); + Assert.AreEqual(expected.MinecraftArguments, settings.MinecraftArguments); + Assert.AreEqual(expected.Fullscreen, settings.Fullscreen); + Assert.AreEqual(expected.WindowWidth, settings.WindowWidth); + Assert.AreEqual(expected.WindowHeight, settings.WindowHeight); + Assert.AreEqual(GameLaunchDefaults.NormalizeMemory(expected.MaximumMemoryMiB), settings.MaximumMemoryMiB); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Hako.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + + private sealed class FailingLoadSettingsStore(UserSettings persistedSettings) : IUserSettingsStore + { + public UserSettings? PersistedSettings { get; private set; } = persistedSettings; + public int FailLoadCount { get; set; } = int.MaxValue; + public int LoadCount { get; private set; } + public int SaveCount { get; private set; } + + public Task LoadAsync(CancellationToken cancellationToken = default) + { + LoadCount++; + if (LoadCount <= FailLoadCount) + { + throw new IOException("Injected settings read failure."); + } + + return Task.FromResult(PersistedSettings); + } + + public Task SaveAsync(UserSettings settings, CancellationToken cancellationToken = default) + { + SaveCount++; + PersistedSettings = settings; + return Task.CompletedTask; + } + } + + private sealed class FailingFirstThenPausingSecondSettingsStore(UserSettings? settings) : IUserSettingsStore + { + private int _saveCount; + + public UserSettings? Settings { get; private set; } = settings; + public TaskCompletionSource FirstSaveReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseFirstSave { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SecondSaveReached { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseSecondSave { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.FromResult(Settings); + + public async Task SaveAsync(UserSettings settings, CancellationToken cancellationToken = default) + { + var save = Interlocked.Increment(ref _saveCount); + if (save == 1) + { + FirstSaveReached.TrySetResult(); + await ReleaseFirstSave.Task.WaitAsync(cancellationToken); + throw new IOException("Injected first settings write failure."); + } + + if (save == 2) + { + SecondSaveReached.TrySetResult(); + await ReleaseSecondSave.Task.WaitAsync(cancellationToken); + } + + Settings = settings; + } + } +} diff --git a/windows/Hako.Tests/WorldMetadataTests.cs b/windows/Hako.Tests/WorldMetadataTests.cs new file mode 100644 index 0000000..de2999a --- /dev/null +++ b/windows/Hako.Tests/WorldMetadataTests.cs @@ -0,0 +1,144 @@ +using Hako.Core.Minecraft; +using System.Text; + +namespace Hako.Tests; + +[TestClass] +public sealed class WorldMetadataTests +{ + [TestMethod] + public void ReadsMetadataFieldsAndLeavesMissingValuesUnknown() + { + var fixture = WorldTestFixture.Create("Modern", "1.21.1", 3, 1_700_000_000_123); + + var metadata = WorldMetadata.Read(fixture.Compressed); + + Assert.AreEqual("Modern", metadata.Name); + Assert.AreEqual("1.21.1", metadata.Version); + Assert.AreEqual(3, metadata.GameType); + Assert.AreEqual(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_123), metadata.LastPlayed); + + var missing = WorldMetadata.Read(WorldTestFixture.Create(includeName: false, version: null, + gameType: null, lastPlayed: null).Compressed); + Assert.IsNull(missing.Name); + Assert.IsNull(missing.Version); + Assert.IsNull(missing.GameType); + Assert.IsNull(missing.LastPlayed); + } + + [TestMethod] + public void ReadsStandardUtf8AndJavaModifiedUtf8NullAndSurrogatePairs() + { + const string unicode = "A\0🌸"; + var fixture = WorldTestFixture.Create(unicode); + var utf8 = new UTF8Encoding(false, true).GetBytes(unicode); + var standardNbt = ReplaceName(fixture, utf8); + + Assert.AreEqual(unicode, WorldMetadata.Read(WorldTestFixture.Gzip(standardNbt)).Name); + Assert.AreEqual(unicode, WorldMetadata.Read(fixture.Compressed).Name); + } + + [TestMethod] + public void RenamingPreservesEveryPayloadByteOutsideLevelName() + { + var unknown = WorldTestFixture.ByteArrayTag("FutureBytes", [0, 1, 2, 0xfe, 0xff]); + var fixture = WorldTestFixture.Create("Old", extra: unknown); + var renamed = WorldMetadata.Renamed(fixture.Compressed, "New\0🌸"); + var newName = WorldTestFixture.ModifiedUtf8("New\0🌸"); + var renamedNbt = WorldTestFixture.Decompress(renamed); + var before = fixture.Nbt.AsSpan(0, fixture.NameLengthOffset!.Value).ToArray(); + var after = fixture.Nbt.AsSpan(fixture.NameValueOffset!.Value + fixture.NameByteLength!.Value).ToArray(); + var newValueOffset = fixture.NameLengthOffset.Value + 2; + + CollectionAssert.AreEqual(before, renamedNbt.AsSpan(0, fixture.NameLengthOffset.Value).ToArray()); + Assert.AreEqual(newName.Length, System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian( + renamedNbt.AsSpan(fixture.NameLengthOffset.Value, 2))); + CollectionAssert.AreEqual(newName, renamedNbt.AsSpan(newValueOffset, newName.Length).ToArray()); + CollectionAssert.AreEqual(after, renamedNbt.AsSpan(newValueOffset + newName.Length).ToArray()); + Assert.AreEqual("New\0🌸", WorldMetadata.Read(renamed).Name); + } + + [TestMethod] + public void RenamingAddsMissingLevelNameInsideDataCompound() + { + var fixture = WorldTestFixture.Create(name: null, includeName: false, + extra: WorldTestFixture.ByteArrayTag("future", [8, 7, 6])); + + var renamed = WorldMetadata.Renamed(fixture.Compressed, "Copied"); + + Assert.AreEqual("Copied", WorldMetadata.Read(renamed).Name); + StringAssert.Contains(Encoding.Latin1.GetString(WorldTestFixture.Decompress(renamed)), "LevelName"); + } + + [TestMethod] + public void RejectsMalformedTagsLengthsNestingAndUtf8() + { + var negativeLength = WorldTestFixture.Create(extra: [7, 0, 1, (byte)'a', 255, 255, 255, 255]); + var truncatedArray = WorldTestFixture.Create(extra: [12, 0, 1, (byte)'a', 0, 0, 0, 10]); + var unsupportedTag = WorldTestFixture.Create(extra: [13, 0, 1, (byte)'a']); + var deep = new List(); + for (var index = 0; index < 66; index++) + { + deep.Add(10); + deep.AddRange([0, 1, (byte)'x']); + } + deep.AddRange(Enumerable.Repeat((byte)0, 66)); + var tooDeep = WorldTestFixture.Create(extra: deep.ToArray()); + var malformedTextFixture = WorldTestFixture.Create("x"); + var malformedText = ReplaceName(malformedTextFixture, [0xc1, 0x81]); + + Assert.AreEqual(WorldMetadataError.InvalidNbt, Assert.ThrowsExactly( + () => WorldMetadata.Read(negativeLength.Compressed)).Error); + Assert.AreEqual(WorldMetadataError.InvalidNbt, Assert.ThrowsExactly( + () => WorldMetadata.Read(truncatedArray.Compressed)).Error); + Assert.AreEqual(WorldMetadataError.InvalidNbt, Assert.ThrowsExactly( + () => WorldMetadata.Read(unsupportedTag.Compressed)).Error); + Assert.AreEqual(WorldMetadataError.NestingTooDeep, Assert.ThrowsExactly( + () => WorldMetadata.Read(tooDeep.Compressed)).Error); + Assert.AreEqual(WorldMetadataError.InvalidText, Assert.ThrowsExactly( + () => WorldMetadata.Read(WorldTestFixture.Gzip(malformedText))).Error); + } + + [TestMethod] + public void EnforcesCompressedExpandedAndCancellationLimits() + { + var oversizedInput = new byte[WorldMetadata.MaximumBytes + 1]; + var expanded = WorldTestFixture.Gzip(new byte[WorldMetadata.MaximumBytes + 1]); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + Assert.AreEqual(WorldMetadataError.CompressedDataTooLarge, Assert.ThrowsExactly( + () => WorldMetadata.Read(oversizedInput)).Error); + Assert.AreEqual(WorldMetadataError.DecompressedDataTooLarge, Assert.ThrowsExactly( + () => WorldMetadata.Read(expanded)).Error); + Assert.ThrowsExactly(() => WorldMetadata.Read( + WorldTestFixture.Create().Compressed, cancelled.Token)); + } + + [TestMethod] + public void RejectsTruncatedGzipInput() + { + var compressed = WorldTestFixture.Create().Compressed; + var badChecksum = compressed.ToArray(); + badChecksum[^8] ^= 1; + + Assert.AreEqual(WorldMetadataError.InvalidGzip, Assert.ThrowsExactly( + () => WorldMetadata.Read(compressed.AsSpan(0, compressed.Length - 8))).Error); + Assert.AreEqual(WorldMetadataError.InvalidGzip, Assert.ThrowsExactly( + () => WorldMetadata.Read(badChecksum)).Error); + } + + private static byte[] ReplaceName(WorldMetadataFixture fixture, byte[] encodedName) + { + var lengthOffset = fixture.NameLengthOffset!.Value; + var valueOffset = fixture.NameValueOffset!.Value; + var oldLength = fixture.NameByteLength!.Value; + var output = new byte[fixture.Nbt.Length - oldLength + encodedName.Length]; + fixture.Nbt.AsSpan(0, lengthOffset).CopyTo(output); + System.Buffers.Binary.BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(lengthOffset, 2), + checked((ushort)encodedName.Length)); + encodedName.CopyTo(output.AsSpan(lengthOffset + 2)); + fixture.Nbt.AsSpan(valueOffset + oldLength).CopyTo(output.AsSpan(lengthOffset + 2 + encodedName.Length)); + return output; + } +} diff --git a/windows/Hako.Tests/WorldTestFixture.cs b/windows/Hako.Tests/WorldTestFixture.cs new file mode 100644 index 0000000..13622ed --- /dev/null +++ b/windows/Hako.Tests/WorldTestFixture.cs @@ -0,0 +1,155 @@ +using System.Buffers.Binary; +using System.IO.Compression; +namespace Hako.Tests; + +internal sealed record WorldMetadataFixture(byte[] Compressed, byte[] Nbt, int? NameLengthOffset, + int? NameValueOffset, int? NameByteLength); + +internal static class WorldTestFixture +{ + public static WorldMetadataFixture Create(string? name = "World", string? version = "1.21.1", + int? gameType = 0, long? lastPlayed = 0, byte[]? extra = null, bool includeName = true) + { + using var stream = new MemoryStream(); + stream.WriteByte(10); + WriteString(stream, ""); + stream.WriteByte(10); + WriteString(stream, "Data"); + + int? lengthOffset = null; + int? valueOffset = null; + int? nameLength = null; + if (includeName && name is not null) + { + stream.WriteByte(8); + WriteString(stream, "LevelName"); + lengthOffset = checked((int)stream.Position); + var encoded = ModifiedUtf8(name); + WriteUInt16(stream, checked((ushort)encoded.Length)); + valueOffset = checked((int)stream.Position); + nameLength = encoded.Length; + stream.Write(encoded); + } + + if (gameType is { } mode) + { + stream.WriteByte(3); + WriteString(stream, "GameType"); + WriteInt32(stream, mode); + } + + if (lastPlayed is { } played) + { + stream.WriteByte(4); + WriteString(stream, "LastPlayed"); + WriteInt64(stream, played); + } + + if (version is not null) + { + stream.WriteByte(10); + WriteString(stream, "Version"); + stream.WriteByte(8); + WriteString(stream, "Name"); + WriteString(stream, version); + stream.WriteByte(0); + } + + if (extra is not null) stream.Write(extra); + stream.WriteByte(0); + stream.WriteByte(0); + var nbt = stream.ToArray(); + return new WorldMetadataFixture(Gzip(nbt), nbt, lengthOffset, valueOffset, nameLength); + } + + public static byte[] Gzip(byte[] input) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + gzip.Write(input); + } + return output.ToArray(); + } + + public static byte[] Decompress(byte[] compressed) + { + using var input = new MemoryStream(compressed, writable: false); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + + public static byte[] StringTag(string tagName, byte[] value) + { + using var stream = new MemoryStream(); + stream.WriteByte(8); + WriteString(stream, tagName); + WriteUInt16(stream, checked((ushort)value.Length)); + stream.Write(value); + return stream.ToArray(); + } + + public static byte[] ByteArrayTag(string tagName, byte[] value) + { + using var stream = new MemoryStream(); + stream.WriteByte(7); + WriteString(stream, tagName); + WriteInt32(stream, value.Length); + stream.Write(value); + return stream.ToArray(); + } + + public static byte[] ModifiedUtf8(string value) + { + using var stream = new MemoryStream(); + foreach (var unit in value) + { + if (unit is > '\0' and <= '\u007f') + { + stream.WriteByte((byte)unit); + } + else if (unit <= '\u07ff') + { + stream.WriteByte((byte)(0xc0 | (unit >> 6))); + stream.WriteByte((byte)(0x80 | (unit & 0x3f))); + } + else + { + stream.WriteByte((byte)(0xe0 | (unit >> 12))); + stream.WriteByte((byte)(0x80 | ((unit >> 6) & 0x3f))); + stream.WriteByte((byte)(0x80 | (unit & 0x3f))); + } + } + return stream.ToArray(); + } + + public static void WriteString(Stream stream, string value) + { + var bytes = ModifiedUtf8(value); + WriteUInt16(stream, checked((ushort)bytes.Length)); + stream.Write(bytes); + } + + public static void WriteUInt16(Stream stream, ushort value) + { + Span bytes = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(bytes, value); + stream.Write(bytes); + } + + public static void WriteInt32(Stream stream, int value) + { + Span bytes = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(bytes, value); + stream.Write(bytes); + } + + public static void WriteInt64(Stream stream, long value) + { + Span bytes = stackalloc byte[8]; + BinaryPrimitives.WriteInt64BigEndian(bytes, value); + stream.Write(bytes); + } +} diff --git a/windows/Hako/App.xaml.cs b/windows/Hako/App.xaml.cs index ad153cc..0b39393 100644 --- a/windows/Hako/App.xaml.cs +++ b/windows/Hako/App.xaml.cs @@ -1,60 +1,243 @@ using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Hako.Core.Playtime; +using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; namespace Hako; public partial class App : Application { private static Window? _window; - private readonly AppCoordinator _coordinator; - private readonly MinecraftSkinPreviewService _skinPreviewService; + private static HakoRootLock? _previewRootLock; + private static LauncherServices? _services; + private readonly PlaytimeCoordinator? _playtime; + private readonly AppCoordinator? _coordinator; + private readonly MinecraftSkinPreviewService? _skinPreviewService; private readonly ProfilePreviewOptions? _previewOptions; + private readonly HakoRootLock? _rootLock; + private readonly bool _rootAlreadyOpen; + private readonly string? _startupErrorResourceKey; internal static ProfilePreviewOptions? LaunchOptions { get; set; } + internal static HakoPaths? LaunchPaths { get; set; } + internal static string? PreviewRoot { get; set; } + internal static UserSettingsService? UserSettingsService { get; private set; } + internal static InstanceStore? InstanceStore { get; private set; } public App() { InitializeComponent(); _previewOptions = LaunchOptions; + try + { + LaunchPaths ??= HakoPaths.CreateProduction(AppDataDirectory.GetLocalPath()); + _rootLock = HakoRootLock.Acquire(LaunchPaths); + if (PreviewRoot is not null) + { + _previewRootLock = _rootLock; + } + } + catch (HakoRootInUseException) + { + _rootAlreadyOpen = true; + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _startupErrorResourceKey = "StartupStorageUnavailable"; + return; + } + _skinPreviewService = new MinecraftSkinPreviewService(); + _playtime = _previewOptions is null + ? new PlaytimeCoordinator(LaunchPaths!.GameRoot, new PlaytimeHelperLauncher()) + : PlaytimeCoordinator.CreatePreview(LaunchPaths!.GameRoot); if (_previewOptions is not null) { + var settingsService = new UserSettingsService(new InMemoryPreviewUserSettingsStore()); + UserSettingsService = settingsService; _coordinator = new AppCoordinator( - new InMemoryPreviewUserSettingsStore(), + settingsService, new InMemoryPreviewAccountStore(_previewOptions.CreateAccount()), - new PreviewAuthService()); + new PreviewAuthService(), + playtimeTransfer: _playtime); } else { - var dataDirectory = AppDataDirectory.GetLocalPath(); + var settingsService = new UserSettingsService(new JsonUserSettingsStore(LaunchPaths!.DataRoot)); + UserSettingsService = settingsService; _coordinator = new AppCoordinator( - new JsonUserSettingsStore(dataDirectory), - new AccountSessionStore(dataDirectory), - new MicrosoftDeviceAuth(new HttpClient(), TimeProvider.System)); + settingsService, + new AccountSessionStore(LaunchPaths!.DataRoot), + new MicrosoftDeviceAuth(new HttpClient(), TimeProvider.System), + playtimeTransfer: _playtime); } + } protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { - _window = new MainWindow(_coordinator, _skinPreviewService, _previewOptions); + if (_rootAlreadyOpen) + { + HakoStrings.SetLanguage(AppLanguages.Default, true); + _window = new Window + { + Title = "Hako", + Content = new TextBlock + { + Text = HakoStrings.Get("HakoAlreadyOpen"), + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(32) + } + }; + _window.Activate(); + return; + } + + if (_startupErrorResourceKey is not null) + { + ShowStartupError(); + return; + } + + InstanceStore = new InstanceStore(LaunchPaths!); + await InstanceStore.LoadAsync(); + + var previewBuilds = _previewOptions?.Mode == ProfilePreviewMode.Builds; + var previewModrinthHttpClient = previewBuilds ? PreviewBuildsFixture.CreateContentHttpClient() : null; + var mojang = previewBuilds ? new MojangClient(previewModrinthHttpClient) : new MojangClient(); + var fabric = previewBuilds + ? new FabricClient(previewModrinthHttpClient, paths: LaunchPaths, + metaBase: PreviewBuildsFixture.FabricMetaBase, modrinthBase: PreviewBuildsFixture.ModrinthBase) + : new FabricClient(paths: LaunchPaths); + var modrinth = fabric.Modrinth; + var modpacks = new ModpackInstaller(modrinth, mojang, fabric, LaunchPaths!); + var modpackCatalog = new ModpackCatalog(modrinth); + var installations = previewBuilds + ? PreviewBuildsFixture.CreateInstallations(InstanceStore) + : new InstallationCoordinator(InstanceStore, mojang, new MinecraftInstaller(mojang, fabric), modpacks); + var previewInstance = previewBuilds && InstanceStore.LoadError is null + ? PreviewBuildsFixture.Seed(InstanceStore, _previewOptions!.Page) + : (Guid?)null; + var games = new GameLaunchCoordinator(InstanceStore, UserSettingsService!, _coordinator!.MinecraftSessions, + new GameProcessRunner(), playtime: _playtime); + var content = new InstanceContent(InstanceStore); + var contentController = new InstanceContentController(InstanceStore, content, modrinth, fabric, + confirmSelection: HakoDialogs.ConfirmContentAsync); + var worlds = new InstanceWorlds(InstanceStore); + var screenshots = new InstanceScreenshots(InstanceStore); + var backups = new InstanceBackup(InstanceStore, content); + var worldBackups = new WorldBackup(InstanceStore, content, worlds); + _services = new LauncherServices(_coordinator, UserSettingsService!, InstanceStore, installations, games, mojang, fabric, + modrinth, modpacks, modpackCatalog, content, contentController, worlds, screenshots, backups, + worldBackups, previewModrinthHttpClient, _skinPreviewService!, _previewOptions, _playtime!); + + _window = new MainWindow(_services); _window.Activate(); await _coordinator.InitializeAsync(); + await _playtime!.RecoverPendingTransferAsync(_coordinator.Account?.Xuid); + if (InstanceStore.LoadError is null) + { + await StartBuildServicesAsync(installations, games); + } + await _playtime.RefreshAsync(); + _playtime.Start(); + if (_previewOptions is not null) { + if (previewInstance is { } fixtureInstance) _playtime.SeedPreviewInstance(fixtureInstance); if (_previewOptions.Mode == ProfilePreviewMode.Guest) { _coordinator.ContinueWithoutAccount(); - _coordinator.OpenSettings(SettingsSection.Profile); } - else + + switch (_previewOptions.Page) { - _coordinator.OpenSettings(SettingsSection.Profile); + case PreviewPage.Builds: + _coordinator.OpenBuilds(); + break; + case PreviewPage.Create: + _coordinator.OpenInstanceCreation(); + break; + case PreviewPage.Modpacks: + case PreviewPage.ModpackCreate: + _coordinator.OpenModpackCatalog(); + break; + case PreviewPage.Profile when previewInstance is { } id: + _coordinator.OpenInstance(id); + break; + case PreviewPage.Content or PreviewPage.ResourcePacks or PreviewPage.Catalog + or PreviewPage.Worlds or PreviewPage.Screenshots or PreviewPage.Datapacks or PreviewPage.DatapackCatalog + when previewInstance is { } id: + _coordinator.OpenInstance(id); + break; + case PreviewPage.SettingsGame: + _coordinator.OpenSettings(SettingsSection.Game); + break; + case PreviewPage.Playtime: + _coordinator.OpenSettings(SettingsSection.Profile); + break; + default: + if (_previewOptions.Mode != ProfilePreviewMode.Builds) + { + _coordinator.OpenSettings(SettingsSection.Profile); + } + + break; } } } + private static async Task StartBuildServicesAsync(InstallationCoordinator installations, GameLaunchCoordinator games) + { + try + { + await installations.StartAsync(); + } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"Could not start the installation queue: {exception}"); + } + + try + { + await games.RestoreRunningGamesAsync(); + } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"Could not restore running games: {exception}"); + } + } + + internal static WindowId? MainWindowId => _window?.AppWindow.Id; + + internal static Window? CurrentWindow => _window; + + /// Stops app-lifetime services before the window closes. Exiting does not pause the installation queue. + internal static async Task ShutdownServicesAsync() + { + if (_services is not { } services) + { + return; + } + + _services = null; + try { await services.Playtime.StopAsync(); } catch (Exception) { } + services.ModpackCatalog.Dispose(); + try { await services.Installations.DisposeAsync(); } catch (Exception) { } + try { await services.Store.DisposeAsync(); } catch (Exception) { } + services.Mojang.Dispose(); + services.Fabric.Dispose(); + services.PreviewModrinthHttpClient?.Dispose(); + } + internal static void ActivateMainWindow() { if (_window is null) @@ -70,4 +253,49 @@ internal static void ActivateMainWindow() _window.AppWindow.Show(); _window.Activate(); } + + internal static void CleanupPreviewRoot() + { + if (PreviewRoot is null) + { + return; + } + + _previewRootLock?.Dispose(); + _previewRootLock = null; + try + { + new RecycleBin().PermanentDelete(PreviewRoot, Path.GetDirectoryName(PreviewRoot)!); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + catch (ArgumentException) + { + } + + PreviewRoot = null; + } + + private static void ShowStartupError() + { + HakoStrings.SetLanguage(AppLanguages.Default, true); + _window = new Window + { + Title = "Hako", + Content = new TextBlock + { + Text = HakoStrings.Get("StartupStorageUnavailable"), + HorizontalAlignment = Microsoft.UI.Xaml.HorizontalAlignment.Center, + VerticalAlignment = Microsoft.UI.Xaml.VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(32) + } + }; + _window.Activate(); + } + } diff --git a/windows/Hako/BackupResourceKeys.cs b/windows/Hako/BackupResourceKeys.cs new file mode 100644 index 0000000..4d59832 --- /dev/null +++ b/windows/Hako/BackupResourceKeys.cs @@ -0,0 +1,33 @@ +using Hako.Core.Instances; + +namespace Hako; + +internal static class BackupResourceKeys +{ + internal static string FormatFailure(BackupFailure? failure, Func get) + { + if (failure is null) return get("BackupFailed"); + var message = get(FailureKey(failure.Reason)); + if (!string.IsNullOrWhiteSpace(failure.Diagnostic)) + message += "\n" + string.Format(get("BackupDiagnosticFormat"), failure.Diagnostic); + if (!string.IsNullOrWhiteSpace(failure.DiagnosticPath)) + message += "\n" + string.Format(get("BackupDiagnosticPathFormat"), failure.DiagnosticPath); + return message; + } + + internal static string FailureKey(BackupFailureReason reason) => reason switch + { + BackupFailureReason.BuildUnavailable => "BackupBuildUnavailable", + BackupFailureReason.BuildNotReady => "BackupBuildNotReady", + BackupFailureReason.BuildInstalling => "BackupBuildInstalling", + BackupFailureReason.BuildRunning => "BackupBuildRunning", + BackupFailureReason.BuildFilesBusy => "BackupBuildFilesBusy", + BackupFailureReason.WorldUnavailable => "BackupWorldUnavailable", + BackupFailureReason.ReservedFolderOccupied => "BackupReservedFolderOccupied", + BackupFailureReason.ReparsePointNotAllowed => "BackupReparsePointNotAllowed", + BackupFailureReason.DestinationConflict => "BackupDestinationConflict", + BackupFailureReason.SourceChanged => "BackupSourceChanged", + BackupFailureReason.Cancelled => "BackupCancelled", + _ => "BackupFailed" + }; +} diff --git a/windows/Hako/BuildsPage.xaml b/windows/Hako/BuildsPage.xaml index 12a2343..1a73729 100644 --- a/windows/Hako/BuildsPage.xaml +++ b/windows/Hako/BuildsPage.xaml @@ -1,13 +1,58 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:Hako"> + - + + + + + + + + + + + + + diff --git a/windows/Hako/BuildsPage.xaml.cs b/windows/Hako/BuildsPage.xaml.cs index 03c183f..95cce31 100644 --- a/windows/Hako/BuildsPage.xaml.cs +++ b/windows/Hako/BuildsPage.xaml.cs @@ -1,28 +1,64 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using Hako.Core.Instances; +using Hako.Core.Playtime; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; namespace Hako; public sealed partial class BuildsPage : Page { + private readonly ObservableCollection _cards = []; + private LauncherServices? _services; + private CoalescedRefresh? _refresh; + public BuildsPage() { InitializeComponent(); + Cards.ItemsSource = _cards; } protected override void OnNavigatedTo(NavigationEventArgs e) { base.OnNavigatedTo(e); + _services = (LauncherServices)e.Parameter; + _refresh = new CoalescedRefresh(DispatcherQueue, Refresh); + _services.Store.InstancesChanged += Changed; + _services.Store.BusyStateChanged += Changed; + _services.Installations.PropertyChanged += Changed; + _services.Games.RunStateChanged += Changed; + _services.App.MinecraftSessions.PropertyChanged += Changed; + _services.Playtime.PropertyChanged += Changed; HakoStrings.LanguageChanged += HakoStrings_LanguageChanged; RefreshStrings(); } protected override void OnNavigatedFrom(NavigationEventArgs e) { + _refresh?.Stop(); + if (_services is { } services) + { + services.Store.InstancesChanged -= Changed; + services.Store.BusyStateChanged -= Changed; + services.Installations.PropertyChanged -= Changed; + services.Games.RunStateChanged -= Changed; + services.App.MinecraftSessions.PropertyChanged -= Changed; + services.Playtime.PropertyChanged -= Changed; + } + HakoStrings.LanguageChanged -= HakoStrings_LanguageChanged; base.OnNavigatedFrom(e); } + private void Changed(object? sender, EventArgs e) => _refresh?.Request(); + private void HakoStrings_LanguageChanged(object? sender, EventArgs e) => RefreshStrings(); private void RefreshStrings() @@ -30,5 +66,200 @@ private void RefreshStrings() BuildsTitle.Text = HakoStrings.Get("BuildsTitle"); BuildsEmptyTitle.Text = HakoStrings.Get("BuildsEmptyTitle"); BuildsEmptyBody.Text = HakoStrings.Get("BuildsEmptyBody"); + CreateButton.Content = HakoStrings.Get("CreateBuildButton"); + PlaytimeError.Title = HakoStrings.Get("PlaytimeSummaryTitle"); + Refresh(); + } + + /// Updates cards in place: unchanged cards keep their elements and only changed properties notify. + private void Refresh() + { + if (_services is not { } services) + { + return; + } + + var loadError = services.Store.LoadError; + RegistryError.IsOpen = loadError is not null; + RegistryError.Title = loadError is null ? string.Empty : HakoStrings.Get("ErrorTitle"); + RegistryError.Message = loadError is null ? string.Empty : HakoStrings.Get(loadError.ResourceKey); + PlaytimeError.IsOpen = services.Playtime.ErrorResourceKey is not null; + PlaytimeError.Message = PlaytimeErrorText(services.Playtime.ErrorResourceKey, services.Playtime.ErrorDetail); + + var instances = services.Store.Instances.OrderBy(instance => instance.CreatedAt).ThenBy(instance => instance.Id).ToList(); + var progress = services.Installations.Progress; + var ids = instances.Select(instance => instance.Id).ToHashSet(); + for (var index = _cards.Count - 1; index >= 0; index--) + { + if (!ids.Contains(_cards[index].Id)) + { + _cards.RemoveAt(index); + } + } + + for (var index = 0; index < instances.Count; index++) + { + var instance = instances[index]; + var card = _cards.FirstOrDefault(item => item.Id == instance.Id); + if (card is null) + { + card = new BuildCardModel(instance.Id); + _cards.Insert(index, card); + } + else if (_cards.IndexOf(card) != index) + { + _cards.Move(_cards.IndexOf(card), index); + } + + card.Update(services, instance, progress.GetValueOrDefault(instance.Id)); + } + + var empty = instances.Count == 0; + EmptyPanel.Visibility = empty && loadError is null ? Visibility.Visible : Visibility.Collapsed; + CardsScroller.Visibility = empty ? Visibility.Collapsed : Visibility.Visible; + } + + private void CreateButton_Click(object sender, RoutedEventArgs e) => MainWindow.RequestCreation(App.CurrentWindow); + + private void Card_Tapped(object sender, TappedRoutedEventArgs e) + { + if (!e.Handled && sender is FrameworkElement { DataContext: BuildCardModel card }) + { + _services?.App.OpenInstance(card.Id); + } + } + + private void PlayButton_Click(object sender, RoutedEventArgs e) + { + if (_services is { } services && sender is FrameworkElement { Tag: BuildCardModel card }) + { + InstancePlayLogic.Launch(services, card.Id); + } + } + + private static string PlaytimeErrorText(string? key, string? detail) + { + if (key is null) return string.Empty; + var message = HakoStrings.Get(key); + return string.IsNullOrWhiteSpace(detail) + ? message + : message + " " + string.Format(HakoStrings.Get("PlaytimeErrorDetails"), detail); + } +} + +public sealed class BuildCardModel(Guid id) : INotifyPropertyChanged +{ + private static readonly Brush SecondaryBrush = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]; + private static readonly Brush CriticalBrush = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"]; + private Guid? _iconRevision; + + public event PropertyChangedEventHandler? PropertyChanged; + + public Guid Id { get; } = id; + public string Name { get; private set; } = ""; + public string Details { get; private set; } = ""; + public string? IconSymbol { get; private set; } + public ImageSource? IconImage { get; private set; } + public string Status { get; private set; } = ""; + public Brush StatusBrush { get; private set; } = SecondaryBrush; + public Visibility StatusVisibility { get; private set; } = Visibility.Collapsed; + public double Progress { get; private set; } + public bool ProgressIndeterminate { get; private set; } + public Visibility ProgressVisibility { get; private set; } = Visibility.Collapsed; + public Visibility PlayVisibility { get; private set; } = Visibility.Collapsed; + public bool PlayEnabled { get; private set; } + public string PlayText { get; private set; } = ""; + public string? PlayHint { get; private set; } + public string Playtime { get; private set; } = ""; + public Visibility PlaytimeVisibility { get; private set; } = Visibility.Collapsed; + + internal void Update(LauncherServices services, GameInstance instance, Hako.Core.Minecraft.InstallationProgress? progress) + { + Set(nameof(Name), Name, instance.Name, value => Name = value); + Set(nameof(Details), Details, InstanceText.Details(instance), value => Details = value); + Set(nameof(Playtime), Playtime, string.Format(HakoStrings.Get("PlaytimeInstanceFormat"), + PlaytimeFormatter.Format(services.Playtime.InstanceSeconds(instance.Id, services.App.Account?.Xuid), services.App.Language, HakoStrings.Get)), value => Playtime = value); + Set(nameof(PlaytimeVisibility), PlaytimeVisibility, readyVisibility(instance), value => PlaytimeVisibility = value); + Set(nameof(IconSymbol), IconSymbol, instance.IconSymbol, value => IconSymbol = value); + if (_iconRevision != instance.IconRevision) + { + _iconRevision = instance.IconRevision; + _ = LoadIconAsync(services, instance); + } + + var ready = instance.State == InstanceInstallationState.Ready; + string status; + var brush = SecondaryBrush; + double? fraction = null; + var showProgress = false; + if (ready) + { + var run = services.Games.GetState(instance.Id); + status = run?.Phase switch + { + Hako.Core.Instances.GameRunPhase.Preparing => HakoStrings.Get("PlayPreparing"), + Hako.Core.Instances.GameRunPhase.Running => HakoStrings.Get("GameRunning"), + Hako.Core.Instances.GameRunPhase.Failed => LaunchErrorText(run), + _ => "" + }; + if (run?.Phase == Hako.Core.Instances.GameRunPhase.Failed) + { + brush = CriticalBrush; + } + } + else if (instance.State == InstanceInstallationState.Failed) + { + status = InstanceText.InstallationError(instance); + brush = CriticalBrush; + } + else + { + status = InstanceText.Status(instance, progress); + showProgress = instance.State == InstanceInstallationState.Installing; + fraction = InstanceText.Fraction(progress); + } + + Set(nameof(Status), Status, status, value => Status = value); + Set(nameof(StatusBrush), StatusBrush, brush, value => StatusBrush = value); + Set(nameof(StatusVisibility), StatusVisibility, status.Length > 0 || ready ? Visibility.Visible : Visibility.Collapsed, value => StatusVisibility = value); + Set(nameof(ProgressVisibility), ProgressVisibility, showProgress ? Visibility.Visible : Visibility.Collapsed, value => ProgressVisibility = value); + Set(nameof(ProgressIndeterminate), ProgressIndeterminate, showProgress && fraction is null, value => ProgressIndeterminate = value); + Set(nameof(Progress), Progress, fraction ?? 0, value => Progress = value); + + var play = ready ? InstancePlayLogic.Evaluate(services, instance) : null; + Set(nameof(PlayVisibility), PlayVisibility, ready ? Visibility.Visible : Visibility.Collapsed, value => PlayVisibility = value); + Set(nameof(PlayEnabled), PlayEnabled, play?.Enabled == true, value => PlayEnabled = value); + Set(nameof(PlayText), PlayText, HakoStrings.Get("PlayButton"), value => PlayText = value); + Set(nameof(PlayHint), PlayHint, play?.Reason ?? HakoStrings.Get("PlayHint"), value => PlayHint = value); + } + + private static Visibility readyVisibility(GameInstance instance) => + instance.State == InstanceInstallationState.Ready ? Visibility.Visible : Visibility.Collapsed; + + internal static string LaunchErrorText(Hako.Core.Instances.GameRunState run) + { + var text = HakoStrings.Get(run.ErrorResourceKey ?? "LaunchProcessStartFailed"); + return run.ExitCode is { } code ? string.Format(text, code) : text; + } + + private async Task LoadIconAsync(LauncherServices services, GameInstance instance) + { + var image = await InstanceIconSource.CreateAsync(await InstanceIconSource.ReadAsync(services.Store, instance)); + if (_iconRevision == instance.IconRevision) + { + IconImage = image; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IconImage))); + } + } + + private void Set(string name, T current, T value, Action assign) + { + if (EqualityComparer.Default.Equals(current, value)) + { + return; + } + + assign(value); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } } diff --git a/windows/Hako/ContentResourceKeys.cs b/windows/Hako/ContentResourceKeys.cs new file mode 100644 index 0000000..641800a --- /dev/null +++ b/windows/Hako/ContentResourceKeys.cs @@ -0,0 +1,110 @@ +using Hako.Core.Instances; +using System.Collections.Generic; + +namespace Hako; + +internal static class ContentResourceKeys +{ + internal static string FormatFailure(ContentFailure failure, Func getString) + { + var template = getString(Failure(failure.Code)); + try + { + return failure.Code switch + { + ContentFailureCode.FabricApiIncompatible => string.Format(template, + failure.Arguments.GetValueOrDefault("version", ""), + failure.Arguments.GetValueOrDefault("loader", ""), + failure.Arguments.GetValueOrDefault("java", "")), + ContentFailureCode.BulkUpdateFailed => string.Format(template, + failure.Arguments.GetValueOrDefault("failureCount", "0")), + _ => template + }; + } + catch (FormatException) { return template; } + } + + internal static string FormatFailureDetails(ContentFailure failure, Func getString) + { + var message = FormatFailure(failure, getString); + return failure.Arguments.TryGetValue("diagnostic", out var diagnostic) && !string.IsNullOrWhiteSpace(diagnostic) + ? $"{message}\n{getString("ContentDiagnosticLabel")} {diagnostic}" + : message; + } + + public static string ConfirmationTitle(ContentConfirmationCode code) => code switch + { + ContentConfirmationCode.Dependencies => "ContentConfirmationDependenciesTitle", + ContentConfirmationCode.IncompatibleContent => "ContentConfirmationIncompatibleTitle", + ContentConfirmationCode.ReplaceExisting => "ContentConfirmationReplaceTitle", + ContentConfirmationCode.DeleteContent => "ContentConfirmationDeleteTitle", + ContentConfirmationCode.DatapackResourcePacks => "ContentConfirmationDatapackPacksTitle", + _ => throw new ArgumentOutOfRangeException(nameof(code)) + }; + + public static string ConfirmationMessage(ContentConfirmationCode code) => code switch + { + ContentConfirmationCode.Dependencies => "ContentConfirmationDependenciesMessage", + ContentConfirmationCode.IncompatibleContent => "ContentConfirmationIncompatibleMessage", + ContentConfirmationCode.ReplaceExisting => "ContentConfirmationReplaceMessage", + ContentConfirmationCode.DeleteContent => "ContentConfirmationDeleteMessage", + ContentConfirmationCode.DatapackResourcePacks => "ContentConfirmationDatapackPacksMessage", + _ => throw new ArgumentOutOfRangeException(nameof(code)) + }; + + public static string ConfirmationPrimary(ContentConfirmationCode code) => code switch + { + ContentConfirmationCode.Dependencies => "ContentConfirmationDependenciesPrimary", + ContentConfirmationCode.IncompatibleContent => "ContentConfirmationIncompatiblePrimary", + ContentConfirmationCode.ReplaceExisting => "ContentConfirmationReplacePrimary", + ContentConfirmationCode.DeleteContent => "ContentConfirmationDeletePrimary", + ContentConfirmationCode.DatapackResourcePacks => "ContentConfirmationDatapackPacksPrimary", + _ => throw new ArgumentOutOfRangeException(nameof(code)) + }; + + public static string ConfirmationAlternative(ContentConfirmationCode code) => code switch + { + ContentConfirmationCode.Dependencies => "ContentConfirmationDependenciesAlternative", + ContentConfirmationCode.IncompatibleContent => "ContentConfirmationIncompatibleAlternative", + ContentConfirmationCode.ReplaceExisting => "ContentConfirmationReplaceAlternative", + ContentConfirmationCode.DeleteContent => "ContentConfirmationDeleteAlternative", + ContentConfirmationCode.DatapackResourcePacks => "ContentConfirmationDatapackPacksAlternative", + _ => throw new ArgumentOutOfRangeException(nameof(code)) + }; + + public static string ConfirmationNote(ContentConfirmationNoteCode code) => code switch + { + ContentConfirmationNoteCode.DisabledWillBeEnabled => "ContentNoteDisabledWillBeEnabled", + ContentConfirmationNoteCode.Unsupported => "ContentNoteUnsupported", + ContentConfirmationNoteCode.RequiresFabric => "ContentNoteRequiresFabric", + ContentConfirmationNoteCode.NoCompatibleVersion => "ContentNoteNoCompatibleVersion", + ContentConfirmationNoteCode.ProjectLimitReached => "ContentNoteProjectLimitReached", + ContentConfirmationNoteCode.ProjectUnavailable => "ContentNoteProjectUnavailable", + ContentConfirmationNoteCode.FabricLoaderRequirement => "ContentNoteFabricLoaderRequirement", + ContentConfirmationNoteCode.JavaRequirement => "ContentNoteJavaRequirement", + ContentConfirmationNoteCode.IncompatibleWith => "ContentNoteIncompatibleWith", + _ => throw new ArgumentOutOfRangeException(nameof(code)) + }; + + public static string Failure(ContentFailureCode code) => code switch + { + ContentFailureCode.BuildUnavailable => "ContentFailureBuildUnavailable", + ContentFailureCode.BuildBusy => "ContentFailureBuildBusy", + ContentFailureCode.BuildInstalling => "ContentFailureBuildInstalling", + ContentFailureCode.ModRequiresFabric => "ContentFailureModRequiresFabric", + ContentFailureCode.InvalidChannel => "ContentFailureInvalidChannel", + ContentFailureCode.NoCompatibleVersion => "ContentFailureNoCompatibleVersion", + ContentFailureCode.RegistryCorrupt => "ContentFailureRegistryCorrupt", + ContentFailureCode.RegistryUnavailable => "ContentFailureRegistryUnavailable", + ContentFailureCode.StaleContent => "ContentFailureStaleContent", + ContentFailureCode.ConfirmationRequired => "ContentFailureConfirmationRequired", + ContentFailureCode.ConfirmationDeclined => "ContentFailureConfirmationDeclined", + ContentFailureCode.FabricApiIncompatible => "ContentFailureFabricApiIncompatible", + ContentFailureCode.ModrinthRequestFailed => "ContentFailureModrinthRequestFailed", + ContentFailureCode.ContentOperationFailed => "ContentFailureOperationFailed", + ContentFailureCode.BulkUpdateFailed => "ContentFailureBulkUpdateFailed", + ContentFailureCode.Cancelled => "ContentFailureCancelled", + ContentFailureCode.WorldUnavailable => "ContentFailureWorldUnavailable", + _ => throw new ArgumentOutOfRangeException(nameof(code)) + }; +} diff --git a/windows/Hako/DisplayPath.cs b/windows/Hako/DisplayPath.cs new file mode 100644 index 0000000..9e412b0 --- /dev/null +++ b/windows/Hako/DisplayPath.cs @@ -0,0 +1,27 @@ +namespace Hako; + +internal static class DisplayPath +{ + /// Shortens to %USERPROFILE%\… only when it lies inside the user profile. + public static string Format(string path, string userProfile) + { + var profile = userProfile.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (profile.Length == 0 || !path.StartsWith(profile, StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + if (path.Length == profile.Length) + { + return "%USERPROFILE%"; + } + + var separator = path[profile.Length]; + return separator == Path.DirectorySeparatorChar || separator == Path.AltDirectorySeparatorChar + ? "%USERPROFILE%" + path[profile.Length..] + : path; + } + + public static string Format(string path) => + Format(path, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); +} diff --git a/windows/Hako/Hako.csproj b/windows/Hako/Hako.csproj index 35454c4..ed9ddde 100644 --- a/windows/Hako/Hako.csproj +++ b/windows/Hako/Hako.csproj @@ -10,6 +10,7 @@ true false true + false 10.0.19041.57 enable true @@ -72,6 +73,10 @@ True + + $(DefineConstants);HAKO_WINDOWSAPPSDK_SELF_CONTAINED + + diff --git a/windows/Hako/InstanceContentView.xaml b/windows/Hako/InstanceContentView.xaml new file mode 100644 index 0000000..4d4d7aa --- /dev/null +++ b/windows/Hako/InstanceContentView.xaml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/windows/Hako/InstanceContentView.xaml.cs b/windows/Hako/InstanceContentView.xaml.cs new file mode 100644 index 0000000..922f2c8 --- /dev/null +++ b/windows/Hako/InstanceContentView.xaml.cs @@ -0,0 +1,713 @@ +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; +using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.Windows.Storage.Pickers; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; + +namespace Hako; + +public sealed partial class InstanceContentView : UserControl +{ + private readonly ObservableCollection _rows = []; + private LauncherServices? _services; + private GameInstance? _instance; + private InstanceContentTarget _target = InstanceContentTarget.Mods; + private ModrinthContentKind _kind => _target.Kind; + private InstanceContentState _state = new(); + private CoalescedRefresh? _stateRefresh; + private CancellationTokenSource? _readCancellation; + private long _appliedRevision = -1; + private bool _active; + private bool _catalogVisible; + private bool _importing; + private bool _pendingOpenCheck; + private string? _availabilityBlockReason; + private MenuFlyout? _itemMenu; + + public InstanceContentView() + { + InitializeComponent(); + ContentItems.ItemsSource = _rows; + CatalogPane.BackRequested += CatalogPane_BackRequested; + Loaded += (_, _) => HakoStrings.LanguageChanged += HakoStrings_LanguageChanged; + Unloaded += (_, _) => HakoStrings.LanguageChanged -= HakoStrings_LanguageChanged; + } + + internal void Configure(LauncherServices services, GameInstance instance, ModrinthContentKind kind) + => Configure(services, instance, kind switch + { + ModrinthContentKind.Mod => InstanceContentTarget.Mods, + ModrinthContentKind.ResourcePack => InstanceContentTarget.Packs, + _ => throw new ArgumentException("Datapacks require a world target.", nameof(kind)) + }); + + internal void Configure(LauncherServices services, GameInstance instance, InstanceContentTarget target, + string? targetName = null) + { + var previous = _instance; + var changed = previous?.Id != instance.Id || _target != target; + var stringsChanged = previous is null || _target != target; + var instanceStateChanged = previous is null || previous.State != instance.State + || previous.FolderName != instance.FolderName || previous.LegacyTexturepacks != instance.LegacyTexturepacks; + var contentLocationChanged = previous is not null + && (previous.FolderName != instance.FolderName || previous.LegacyTexturepacks != instance.LegacyTexturepacks); + var becameReady = previous is not null && previous.State != InstanceInstallationState.Ready + && instance.State == InstanceInstallationState.Ready; + if (changed && _active) + { + CancelRead(); + CatalogPane.SetActive(false); + } + + _services = services; + _instance = instance; + _target = target; + // Store leases change independently of the immutable GameInstance snapshot. + var blockReason = BlockReason(services, instance, target, _state); + var availabilityChanged = _availabilityBlockReason != blockReason; + _availabilityBlockReason = blockReason; + CatalogPane.Configure(services, instance, target, targetName); + if (changed) + { + _state = services.ContentController.GetState(instance.Id, target); + _appliedRevision = _state.Revision; + RefreshState(); + if (_active) + { + if (_catalogVisible) CatalogPane.SetActive(true); + else StartLocalReadAndCheck(); + } + } + + if (stringsChanged) RefreshStrings(); + else if (instanceStateChanged || availabilityChanged) RefreshState(); + + if (!changed && _active && !_catalogVisible && (contentLocationChanged || becameReady)) + StartLocalReadAndCheck(); + } + + public void SetActive(bool active) + { + if (_active == active) return; + _active = active; + if (active) + { + if (_services is { } services) + { + _stateRefresh = new CoalescedRefresh(DispatcherQueue, RefreshFromController); + services.ContentController.StateChanged += ContentController_StateChanged; + services.Store.BusyStateChanged += Store_BusyStateChanged; + if (_instance is { } instance) + { + _state = services.ContentController.GetState(instance.Id, _target); + _appliedRevision = _state.Revision; + } + } + + if (_catalogVisible) CatalogPane.SetActive(true); + else StartLocalReadAndCheck(); + } + else + { + _itemMenu?.Hide(); + if (_services is { } services) services.ContentController.StateChanged -= ContentController_StateChanged; + if (_services is { } inactiveServices) inactiveServices.Store.BusyStateChanged -= Store_BusyStateChanged; + _stateRefresh?.Stop(); + _stateRefresh = null; + CancelRead(); + CatalogPane.SetActive(false); + } + + RefreshState(); + } + + public void RefreshStrings() + { + ContentTitle.Text = HakoStrings.Get(_kind switch + { + ModrinthContentKind.Mod => "ContentSectionTitleMods", + ModrinthContentKind.Datapack => "ContentSectionTitleDatapacks", + _ => "ContentSectionTitleResourcePacks" + }); + ImportFilesButton.Content = HakoStrings.Get("ContentImportFiles"); + ImportFolderButton.Content = HakoStrings.Get("ContentImportFolder"); + ImportFolderButton.Visibility = _kind is ModrinthContentKind.ResourcePack or ModrinthContentKind.Datapack + ? Visibility.Visible : Visibility.Collapsed; + CatalogButton.Content = HakoStrings.Get("ContentOpenModrinth"); + CheckUpdatesButton.Content = HakoStrings.Get("ContentCheckUpdates"); + UpdateAllButton.Content = HakoStrings.Get("ContentUpdateAll"); + OpenFolderButton.Content = HakoStrings.Get("ContentOpenFolder"); + DropHintText.Text = HakoStrings.Get(_kind switch + { + ModrinthContentKind.Mod => "ContentDropHint", + ModrinthContentKind.Datapack => "ContentDropHintDatapacks", + _ => "ContentDropHintResourcePacks" + }); + ContentEmptyText.Text = HakoStrings.Get(_kind switch + { + ModrinthContentKind.Mod => "ContentEmptyMods", + ModrinthContentKind.Datapack => "ContentEmptyDatapacks", + _ => "ContentEmptyResourcePacks" + }); + CatalogPane.RefreshStrings(); + RefreshRows(); + RefreshState(); + } + + public void OpenCatalog() + { + _catalogVisible = true; + CancelRead(); + LocalContentPane.Visibility = Visibility.Collapsed; + CatalogPane.Visibility = Visibility.Visible; + CatalogPane.SetActive(_active); + } + + private void CatalogPane_BackRequested(object? sender, EventArgs e) + { + if (_services is null || _instance is null) return; + _catalogVisible = false; + CatalogPane.SetActive(false); + CatalogPane.Visibility = Visibility.Collapsed; + LocalContentPane.Visibility = Visibility.Visible; + RefreshState(); + } + + private void HakoStrings_LanguageChanged(object? sender, EventArgs e) => RefreshStrings(); + + private void ContentController_StateChanged(object? sender, InstanceContentStateChangedEventArgs e) + { + if (_instance?.Id == e.Key.InstanceId && e.Key.Kind == _target.Kind && e.Key.WorldId == _target.WorldId) + _stateRefresh?.Request(); + } + + private void Store_BusyStateChanged(object? sender, EventArgs e) => _stateRefresh?.Request(); + + private void RefreshFromController() + { + if (!_active || _services is not { } services || _instance is not { } instance) return; + var state = services.ContentController.GetState(instance.Id, _target); + if (state.Revision < _appliedRevision) return; + _appliedRevision = state.Revision; + _state = state; + RefreshState(); + CatalogPane.ContentStateChanged(state); + if (_pendingOpenCheck && !_catalogVisible && !state.IsBusy && !services.Store.IsContentBusy(instance.Id)) + StartLocalReadAndCheck(); + } + + private void StartLocalReadAndCheck() + { + if (!_active || _catalogVisible || _services is not { } services || _instance is not { } instance) return; + _pendingOpenCheck = true; + if (services.Store.IsContentBusy(instance.Id) + || services.ContentController.GetState(instance.Id, _target).IsBusy) return; + _pendingOpenCheck = false; + CancelRead(); + _readCancellation = new CancellationTokenSource(); + _ = ReadAndCheckAsync(services, instance.Id, _target, _readCancellation.Token); + } + + private async Task ReadAndCheckAsync(LauncherServices services, Guid instanceId, + InstanceContentTarget target, CancellationToken cancellationToken) + { + try + { + if (services.Store.IsContentBusy(instanceId) || services.ContentController.GetState(instanceId, target).IsBusy) + { + _pendingOpenCheck = true; + return; + } + + await services.ContentController.ReloadAsync(instanceId, target, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (services.Store.IsContentBusy(instanceId) || services.ContentController.GetState(instanceId, target).IsBusy) + { + _pendingOpenCheck = true; + return; + } + + await services.ContentController.CheckUpdatesAsync(instanceId, target, cancellationToken); + } + catch (OperationCanceledException) { } + catch (Exception exception) + { + System.Diagnostics.Debug.WriteLine($"Content refresh failed: {exception.GetType().Name}"); + } + finally + { + if (!cancellationToken.IsCancellationRequested) _stateRefresh?.Request(); + } + } + + private void CancelRead() + { + var cancellation = _readCancellation; + _readCancellation = null; + cancellation?.Cancel(); + cancellation?.Dispose(); + } + + private void RefreshState() + { + if (_services is not { } services || _instance is not { } instance) return; + var blocked = BlockReason(services, instance, _target, _state); + BlockReasonText.Text = blocked ?? ""; + BlockReasonText.Visibility = blocked is null ? Visibility.Collapsed : Visibility.Visible; + var canMutate = blocked is null && !_state.IsBusy && !_state.IsCheckingUpdates && !_importing; + if (!canMutate) _itemMenu?.Hide(); + ImportFilesButton.IsEnabled = canMutate; + ImportFolderButton.IsEnabled = canMutate; + CatalogButton.IsEnabled = canMutate; + OpenFolderButton.IsEnabled = instance.State == InstanceInstallationState.Ready; + CheckUpdatesButton.IsEnabled = BlockReason(services, instance, _target, _state) is null + && !_state.IsBusy && !_state.IsCheckingUpdates && !_importing; + ToolTipService.SetToolTip(CheckUpdatesButton, blocked); + ToolTipService.SetToolTip(ImportFilesButton, blocked); + ToolTipService.SetToolTip(ImportFolderButton, blocked); + ToolTipService.SetToolTip(CatalogButton, blocked); + ToolTipService.SetToolTip(UpdateAllButton, blocked); + UpdateAllButton.IsEnabled = canMutate; + UpdateAllButton.Visibility = _state.HasUpdates ? Visibility.Visible : Visibility.Collapsed; + + var loading = _state.IsLoading || _state.IsCheckingUpdates || _importing; + ContentProgress.IsActive = loading; + ContentProgressPanel.Visibility = loading ? Visibility.Visible : Visibility.Collapsed; + ContentProgressText.Text = _importing + ? HakoStrings.Get("ContentActionBusy") + : HakoStrings.Get(_state.IsCheckingUpdates ? "ContentCheckingUpdates" : "ContentLoading"); + ContentCount.Text = string.Format(HakoStrings.Get("ContentActiveCount"), + _state.Items.Count(item => item.Enabled), _state.Items.Count); + ContentEmptyText.Visibility = _state.Items.Count == 0 && !_state.IsLoading && !_state.IsBusy + ? Visibility.Visible : Visibility.Collapsed; + ContentUpdateMessageText.Visibility = _state.UpdateMessage is null ? Visibility.Collapsed : Visibility.Visible; + ContentUpdateMessageText.Text = _state.UpdateMessage is { } message ? FormatFailure(message) : ""; + ContentErrorText.Visibility = _state.Error is null ? Visibility.Collapsed : Visibility.Visible; + ContentErrorText.Text = _state.Error is { } error ? FormatFailure(error) : ""; + ContentDiagnosticText.Visibility = _state.Error?.Arguments.ContainsKey("diagnostic") == true + ? Visibility.Visible : Visibility.Collapsed; + ContentDiagnosticText.Text = _state.Error?.Arguments.TryGetValue("diagnostic", out var diagnostic) == true + ? $"{HakoStrings.Get("ContentDiagnosticLabel")} {diagnostic}" : ""; + RefreshRows(canMutate); + CatalogPane.ContentStateChanged(_state); + } + + private string? BlockReason(LauncherServices services, GameInstance instance, + InstanceContentTarget target, InstanceContentState state) + { + var kind = target.Kind; + if (kind == ModrinthContentKind.Mod && instance.ModLoader != InstanceModLoader.Fabric) + return HakoStrings.Get("ContentBlockedFabric"); + if (instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + return HakoStrings.Get("ContentBlockedInstall"); + if (instance.State != InstanceInstallationState.Ready) + return HakoStrings.Get("ContentBlockedBuild"); + if (state.IsBusy || services.Store.IsContentBusy(instance.Id)) + return HakoStrings.Get("ContentBlockedFilesBusy"); + if (services.Store.ManagementBlockedError(instance) is { } blocked) + return HakoStrings.Get(blocked.ResourceKey()); + return null; + } + + private void RefreshRows(bool? canMutateOverride = null) + { + if (_services is not { } services || _instance is not { } instance) return; + var canMutate = canMutateOverride ?? (BlockReason(services, instance, _target, _state) is null + && !_state.IsBusy && !_state.IsCheckingUpdates && !_importing); + _rows.Clear(); + foreach (var item in _state.Items) + { + _state.Updates.TryGetValue(item.LogicalName, out var update); + _rows.Add(new InstanceContentRow(item, update, canMutate, + BlockReason(services, instance, _target, _state), allowDelete: _kind != ModrinthContentKind.Datapack)); + } + } + + private async void ImportFilesButton_Click(object sender, RoutedEventArgs e) + { + if (App.MainWindowId is not { } windowId || _instance is not { } instance || !CanMutate()) return; + var target = _target; + try + { + var picker = new FileOpenPicker(windowId) + { + SuggestedStartLocation = PickerLocationId.Downloads, + FileTypeFilter = { target.Kind == ModrinthContentKind.Mod ? ".jar" : ".zip" } + }; + var files = await picker.PickMultipleFilesAsync(); + if (files is not null) await ImportPathsAsync(files.Select(file => file.Path), instance, target); + } + catch (Exception exception) + { + ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); + } + } + + private async void ImportFolderButton_Click(object sender, RoutedEventArgs e) + { + if (App.MainWindowId is not { } windowId || _instance is not { } instance + || _kind == ModrinthContentKind.Mod || !CanMutate()) return; + var target = _target; + try + { + var picker = new FolderPicker(windowId) { SuggestedStartLocation = PickerLocationId.Downloads }; + var folder = await picker.PickSingleFolderAsync(); + if (folder is not null) await ImportPathsAsync([folder.Path], instance, target); + } + catch (Exception exception) + { + ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); + } + } + + private async Task ImportPathsAsync(IEnumerable paths, GameInstance instance, InstanceContentTarget target) + { + if (_services is not { } services || !CanMutate()) return; + var kind = target.Kind; + _importing = true; + RefreshState(); + var imported = 0; + var feedback = new List(); + try + { + foreach (var path in paths) + { + var name = Path.GetFileName(Path.TrimEndingDirectorySeparator(path)); + try + { + var (isSupported, invalidKey) = await Task.Run(() => ValidateImportPath(path, kind)); + if (!isSupported) + { + feedback.Add(string.Format(HakoStrings.Get(invalidKey), name)); + continue; + } + + var result = await services.ContentController.ImportLocalAsync(instance.Id, target, path); + if (result.Succeeded) imported++; + else if (result.Failure is { } failure) + feedback.Add(string.Format(HakoStrings.Get("ContentImportFailureLine"), name, FormatFailureDetails(failure))); + else + feedback.Add(string.Format(HakoStrings.Get("ContentImportCancelled"), name)); + } + catch (Exception exception) + { + var failure = new ContentFailure(ContentFailureCode.ContentOperationFailed, + new Dictionary { ["diagnostic"] = exception.Message }); + feedback.Add(string.Format(HakoStrings.Get("ContentImportFailureLine"), name, FormatFailureDetails(failure))); + } + } + } + finally + { + _importing = false; + _stateRefresh?.Request(); + RefreshState(); + } + + if (imported > 0) feedback.Insert(0, string.Format(HakoStrings.Get("ContentImportSuccess"), imported)); + ContentActionFeedbackText.Text = string.Join(Environment.NewLine, feedback); + ContentActionFeedbackText.Visibility = feedback.Count == 0 ? Visibility.Collapsed : Visibility.Visible; + } + + private static (bool IsSupported, string FailureKey) ValidateImportPath(string path, ModrinthContentKind kind) + { + var datapack = kind == ModrinthContentKind.Datapack; + var failureKey = kind == ModrinthContentKind.Mod ? "ContentInvalidModFile" + : datapack ? "ContentInvalidDatapackFile" : "ContentInvalidPackFile"; + if (Directory.Exists(path)) + { + if (kind is not (ModrinthContentKind.ResourcePack or ModrinthContentKind.Datapack)) return (false, failureKey); + if (!File.Exists(Path.Combine(path, "pack.mcmeta"))) + { + failureKey = datapack ? "ContentInvalidDatapackFolder" : "ContentInvalidPackFolder"; + return (false, failureKey); + } + + return (true, failureKey); + } + + if (!File.Exists(path)) return (false, failureKey); + var extension = Path.GetExtension(path); + var supported = kind == ModrinthContentKind.Mod + ? extension.Equals(".jar", StringComparison.OrdinalIgnoreCase) + : extension.Equals(".zip", StringComparison.OrdinalIgnoreCase); + return (supported, failureKey); + } + + private void DropTarget_DragOver(object sender, DragEventArgs e) + { + e.AcceptedOperation = e.DataView.Contains(StandardDataFormats.StorageItems) && CanMutate() + ? DataPackageOperation.Copy : DataPackageOperation.None; + e.DragUIOverride.Caption = HakoStrings.Get(_kind switch + { + ModrinthContentKind.Mod => "ContentDropHint", + ModrinthContentKind.Datapack => "ContentDropHintDatapacks", + _ => "ContentDropHintResourcePacks" + }); + } + + private async void DropTarget_Drop(object sender, DragEventArgs e) + { + if (!CanMutate() || _instance is not { } instance) return; + var target = _target; + try + { + var items = await e.DataView.GetStorageItemsAsync(); + var paths = items.Where(item => item is StorageFile or StorageFolder).Select(item => item.Path).ToArray(); + if (paths.Length == 0) + { + ShowActionFailure(ContentFailureCode.ContentOperationFailed, HakoStrings.Get("ContentInvalidDrop")); + return; + } + + await ImportPathsAsync(paths, instance, target); + } + catch (Exception exception) + { + ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); + } + } + + private void CatalogButton_Click(object sender, RoutedEventArgs e) + { + if (CanMutate()) OpenCatalog(); + } + + private async void CheckUpdatesButton_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance || !CanMutate()) return; + CancelRead(); + _readCancellation = new CancellationTokenSource(); + try { await services.ContentController.CheckUpdatesAsync(instance.Id, _target, _readCancellation.Token); } + catch (OperationCanceledException) { } + catch (Exception exception) { ShowActionFailure(ContentFailureCode.ModrinthRequestFailed, exception.Message); } + finally { _stateRefresh?.Request(); } + } + + private async void UpdateAllButton_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance || !CanMutate()) return; + try + { + var result = await services.ContentController.UpdateAllAsync(instance.Id, _target); + var succeeded = result.Items.Count(item => item.Succeeded); + var failed = result.Items.Where(item => !item.Succeeded).ToArray(); + var lines = new List + { + string.Format(HakoStrings.Get("ContentUpdateAllSummary"), succeeded, failed.Length) + }; + lines.AddRange(failed.Select(item => string.Format(HakoStrings.Get("ContentBulkFailureLine"), + item.Filename, item.Failure is null ? HakoStrings.Get("ContentFailureOperationFailed") : FormatFailureDetails(item.Failure)))); + ContentActionFeedbackText.Text = string.Join(Environment.NewLine, lines); + ContentActionFeedbackText.Visibility = Visibility.Visible; + } + catch (Exception exception) + { + ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); + } + finally { _stateRefresh?.Request(); } + } + + private async void UpdateItem_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance + || (sender as FrameworkElement)?.Tag is not InstanceContentItem item || !CanMutate()) return; + try + { + var result = await services.ContentController.UpdateAsync(instance.Id, _target, item); + if (!result.Succeeded && result.Failure is { } failure) ShowActionFailure(failure); + } + catch (Exception exception) { ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); } + finally { _stateRefresh?.Request(); } + } + + private async void EnabledToggle_Toggled(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance || sender is not ToggleSwitch toggle + || toggle.Tag is not InstanceContentItem item || toggle.IsOn == item.Enabled) return; + if (!CanMutate()) + { + toggle.IsOn = item.Enabled; + return; + } + + try + { + var result = await services.ContentController.SetEnabledAsync(instance.Id, _target, item, toggle.IsOn); + if (!result.Succeeded && result.Failure is { } failure) ShowActionFailure(failure); + } + catch (Exception exception) { ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); } + finally { _stateRefresh?.Request(); } + } + + private async void DeleteItem_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance + || (sender as FrameworkElement)?.Tag is not InstanceContentItem item || !CanMutate()) return; + try + { + var result = await services.ContentController.DeleteAsync(instance.Id, _kind, item); + if (!result.Succeeded && result.Failure is { } failure) ShowActionFailure(failure); + } + catch (Exception exception) { ShowActionFailure(ContentFailureCode.ContentOperationFailed, exception.Message); } + finally { _stateRefresh?.Request(); } + } + + private void ItemMenu_Opened(object? sender, object e) => _itemMenu = sender as MenuFlyout; + + private async void RevealItem_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance + || (sender as FrameworkElement)?.Tag is not InstanceContentItem item) return; + try + { + var folder = await Task.Run(() => + { + var root = services.Store.GetInstanceDirectory(instance.Id); + var relative = Path.GetRelativePath(root, item.Path); + var safePath = InstanceStorage.ContainedPath(relative, root, allowBaseReparsePoint: true); + return item.IsDirectory ? safePath : Path.GetDirectoryName(safePath); + }); + await OpenLocationAsync(folder); + } + catch (Exception) { await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ContentOpenFailed")); } + } + + private async void ContentSource_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not Uri { IsAbsoluteUri: true, Scheme: "https" } uri) return; + try + { + if (!await Windows.System.Launcher.LaunchUriAsync(uri)) + await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ContentLinkFailed")); + } + catch (Exception) + { + await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ContentLinkFailed")); + } + } + + private async void OpenFolderButton_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance) return; + try + { + var folder = await Task.Run(() => + { + var root = services.Store.GetInstanceDirectory(instance.Id); + if (_target.Kind == ModrinthContentKind.Datapack) + return InstanceWorlds.DatapacksFolder(_target.WorldId!, root); + var relative = _kind == ModrinthContentKind.Mod ? "minecraft/mods" + : instance.LegacyTexturepacks ? "minecraft/texturepacks" : "minecraft/resourcepacks"; + var path = InstanceStorage.ContainedPath(relative, root, allowBaseReparsePoint: true); + Directory.CreateDirectory(path); + return path; + }); + await OpenLocationAsync(folder); + } + catch (Exception) { await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ContentOpenFailed")); } + } + + internal static async Task OpenLocationAsync(string? folderPath) + { + if (string.IsNullOrWhiteSpace(folderPath)) return; + try + { + var folder = await StorageFolder.GetFolderFromPathAsync(folderPath); + if (!await Windows.System.Launcher.LaunchFolderAsync(folder)) + await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ContentOpenFailed")); + } + catch (Exception) { await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ContentOpenFailed")); } + } + + private bool CanMutate() => _services is { } services && _instance is { } instance + && BlockReason(services, instance, _target, _state) is null + && !_state.IsBusy && !_state.IsCheckingUpdates && !_importing; + + private void ShowActionFailure(ContentFailureCode code, string? diagnostic = null) + { + var message = HakoStrings.Get(ContentResourceKeys.Failure(code)); + if (code == ContentFailureCode.ContentOperationFailed && !string.IsNullOrWhiteSpace(diagnostic)) + message += $"\n{HakoStrings.Get("ContentDiagnosticLabel")} {diagnostic}"; + ContentActionFeedbackText.Text = message; + ContentActionFeedbackText.Visibility = Visibility.Visible; + } + + private void ShowActionFailure(ContentFailure failure) + { + ContentActionFeedbackText.Text = FormatFailureDetails(failure); + ContentActionFeedbackText.Visibility = Visibility.Visible; + } + + internal static string FormatFailure(ContentFailure failure) => ContentResourceKeys.FormatFailure(failure, HakoStrings.Get); + + internal static string FormatFailureDetails(ContentFailure failure) => ContentResourceKeys.FormatFailureDetails(failure, HakoStrings.Get); +} + +public sealed class InstanceContentRow +{ + public InstanceContentRow(InstanceContentItem item, InstanceContentUpdate? update, bool canMutate, + string? blockReason, bool allowDelete = true) + { + BlockReason = blockReason; + Item = item; + Name = item.Name; + SourceText = HakoStrings.Get(item.Origin?.Source switch + { + ModSource.Modrinth => "ContentSourceModrinth", + ModSource.CurseForge => "ContentSourceCurseForge", + _ => "ContentSourceLocal" + }); + VersionText = item.Origin is { } origin + ? origin.VersionNumber ?? HakoStrings.Get("ContentVersionUnknown") + : ""; + PageUrl = item.Origin?.PageURL; + HasPageUrl = PageUrl is { IsAbsoluteUri: true, Scheme: "https" }; + Enabled = item.Enabled; + EnabledOnText = HakoStrings.Get("ContentEnabledOn"); + EnabledOffText = HakoStrings.Get("ContentEnabledOff"); + RevealText = HakoStrings.Get("ContentRevealItem"); + DeleteText = HakoStrings.Get("ContentDeleteItem"); + DeleteVisibility = allowDelete ? Visibility.Visible : Visibility.Collapsed; + CanToggle = canMutate; + UpdateVisibility = update is null ? Visibility.Collapsed : Visibility.Visible; + UpdateText = update is null ? HakoStrings.Get("ContentUpdateButton") + : string.Format(HakoStrings.Get("ContentUpdateItem"), update.Version?.VersionNumber ?? update.Api?.Version ?? ""); + CanUpdate = canMutate && update is not null; + PlaceholderGlyph = item.IsDirectory ? "\uE8B7" : "\uE8A5"; + Icon = PageUrl is { IsAbsoluteUri: true, Scheme: "https" } && item.Origin?.IconURL is { IsAbsoluteUri: true, Scheme: "https" } icon + ? new BitmapImage(icon) : null; + IconVisibility = Icon is null ? Visibility.Collapsed : Visibility.Visible; + PlaceholderVisibility = Icon is null ? Visibility.Visible : Visibility.Collapsed; + } + + public InstanceContentItem Item { get; } + public string? BlockReason { get; } + public string Name { get; } + public string SourceText { get; } + public string VersionText { get; } + public Uri? PageUrl { get; } + public bool HasPageUrl { get; } + public bool Enabled { get; } + public bool CanToggle { get; } + public string EnabledOnText { get; } + public string EnabledOffText { get; } + public string RevealText { get; } + public string DeleteText { get; } + public Visibility DeleteVisibility { get; } + public string UpdateText { get; } + public bool CanUpdate { get; } + public Visibility UpdateVisibility { get; } + public string PlaceholderGlyph { get; } + public ImageSource? Icon { get; } + public Visibility IconVisibility { get; } + public Visibility PlaceholderVisibility { get; } +} diff --git a/windows/Hako/InstanceCreationChoiceDialog.cs b/windows/Hako/InstanceCreationChoiceDialog.cs new file mode 100644 index 0000000..8b8365b --- /dev/null +++ b/windows/Hako/InstanceCreationChoiceDialog.cs @@ -0,0 +1,91 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace Hako; + +internal enum InstanceCreationChoice +{ + None, + Custom, + Modpack +} + +/// «Своя сборка» or «Готовая с Modrinth» (InstanceCreationChoiceView on macOS). +internal static class InstanceCreationChoiceDialog +{ + public static async Task ShowAsync() + { + var choice = InstanceCreationChoice.None; + var dialog = new ContentDialog + { + Title = HakoStrings.Get("CreationTitle"), + CloseButtonText = HakoStrings.Get("CancelButton"), + DefaultButton = ContentDialogButton.Close + }; + + var custom = Option("", HakoStrings.Get("CreationCustomTitle"), HakoStrings.Get("CreationCustomDetails")); + custom.Click += (_, _) => + { + choice = InstanceCreationChoice.Custom; + dialog.Hide(); + }; + var modrinth = Option("", HakoStrings.Get("CreationModrinthTitle"), HakoStrings.Get("CreationModrinthDetails")); + modrinth.Click += (_, _) => + { + choice = InstanceCreationChoice.Modpack; + dialog.Hide(); + }; + + dialog.Content = new StackPanel + { + Spacing = 12, + MinWidth = 420, + Children = + { + new TextBlock + { + Text = HakoStrings.Get("CreationChoiceSubtitle"), + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] + }, + custom, + modrinth + } + }; + await HakoDialogs.ShowAsync(dialog); + return choice; + } + + private static Button Option(string glyph, string title, string details) + { + var text = new StackPanel { Spacing = 4 }; + text.Children.Add(new TextBlock { Text = title, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + text.Children.Add(new TextBlock + { + Text = details, + TextWrapping = TextWrapping.Wrap, + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] + }); + var layout = new Grid + { + ColumnSpacing = 16, + ColumnDefinitions = { new ColumnDefinition { Width = GridLength.Auto }, new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } + }; + var icon = new FontIcon { Glyph = glyph, FontSize = 22, FontFamily = new FontFamily("Segoe Fluent Icons"), Foreground = (Brush)Application.Current.Resources["AccentTextFillColorPrimaryBrush"] }; + layout.Children.Add(icon); + Grid.SetColumn(text, 1); + layout.Children.Add(text); + var chevron = new FontIcon { Glyph = "", FontSize = 12, FontFamily = new FontFamily("Segoe Fluent Icons") }; + Grid.SetColumn(chevron, 2); + layout.Children.Add(chevron); + var button = new Button + { + Content = layout, + Padding = new Thickness(16), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(button, title); + return button; + } +} diff --git a/windows/Hako/InstanceCreationPage.xaml b/windows/Hako/InstanceCreationPage.xaml new file mode 100644 index 0000000..ec51538 --- /dev/null +++ b/windows/Hako/InstanceCreationPage.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/Hako/InstanceScreenshotsView.xaml.cs b/windows/Hako/InstanceScreenshotsView.xaml.cs new file mode 100644 index 0000000..880ab12 --- /dev/null +++ b/windows/Hako/InstanceScreenshotsView.xaml.cs @@ -0,0 +1,495 @@ +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using Hako.Core; +using Hako.Core.Instances; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; + +namespace Hako; + +public sealed partial class InstanceScreenshotsView : UserControl +{ + private readonly ObservableCollection _rows = []; + private readonly ScreenshotThumbnails _thumbnails = new(); + private readonly Dictionary _thumbnailLoads = []; + private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher; + private LauncherServices? _services; + private GameInstance? _instance; + private CoalescedRefresh? _refresh; + private CancellationTokenSource? _listCancellation; + private long _listGeneration; + private bool _active; + private bool _loading; + private bool _operationBusy; + private string? _loadError; + private string? _feedback; + + public InstanceScreenshotsView() + { + InitializeComponent(); + _dispatcher = DispatcherQueue; + Loaded += (_, _) => HakoStrings.LanguageChanged += HakoStrings_LanguageChanged; + Unloaded += (_, _) => HakoStrings.LanguageChanged -= HakoStrings_LanguageChanged; + RefreshStrings(); + } + + internal void Configure(LauncherServices services, GameInstance instance, ProfilePreviewOptions? preview) + { + _ = preview; + var changed = _instance?.Id != instance.Id; + _services = services; + _instance = instance; + if (changed) + { + CancelListLoad(); + CancelThumbnailLoads(); + _rows.Clear(); + _loadError = null; + _feedback = null; + } + + RefreshStrings(); + if (changed && _active) LoadScreenshots(); + } + + public void SetActive(bool active) + { + if (_active == active) return; + _active = active; + if (active) + { + if (_services is { } services) + { + _refresh = new CoalescedRefresh(DispatcherQueue, RefreshAvailability); + services.Store.BusyStateChanged += CoreStateChanged; + services.Store.InstancesChanged += Store_InstancesChanged; + services.Games.GameSessionEnded += Games_GameSessionEnded; + } + LoadScreenshots(); + } + else + { + if (_services is { } services) + { + services.Store.BusyStateChanged -= CoreStateChanged; + services.Store.InstancesChanged -= Store_InstancesChanged; + services.Games.GameSessionEnded -= Games_GameSessionEnded; + } + _refresh?.Stop(); + _refresh = null; + CancelListLoad(); + CancelThumbnailLoads(); + foreach (var row in _rows) row.Thumbnail = null; + } + RefreshAvailability(); + } + + public void RefreshScreenshotsIfActive() + { + if (_active) LoadScreenshots(); + } + + internal void RefreshStrings() + { + ScreenshotsTitle.Text = HakoStrings.Get("ScreenshotsTitle"); + RefreshButton.Content = HakoStrings.Get("ScreenshotsRefresh"); + OpenFolderButton.Content = HakoStrings.Get("ScreenshotsOpenFolder"); + DeleteButton.Content = HakoStrings.Get("ScreenshotsDeleteSelected"); + LoadingText.Text = HakoStrings.Get("ScreenshotsLoading"); + OperationText.Text = HakoStrings.Get("ScreenshotsOperationInProgress"); + foreach (var row in _rows) row.UpdateLocalizedText(_services?.App.Language ?? AppLanguages.Default); + RefreshSelectionText(); + RefreshAvailability(); + } + + private void HakoStrings_LanguageChanged(object? sender, EventArgs e) => RefreshStrings(); + private void CoreStateChanged(object? sender, EventArgs e) => _refresh?.Request(); + + private void Store_InstancesChanged(object? sender, EventArgs e) + { + QueueScreenshotsRefresh(); + } + + private void Games_GameSessionEnded(object? sender, GameSessionEventArgs e) + { + QueueScreenshotsRefresh(e.InstanceId); + } + + private void QueueScreenshotsRefresh(Guid? instanceID = null) + { + var generation = Interlocked.Read(ref _listGeneration); + _dispatcher.TryEnqueue(() => + { + if (!_active || generation != _listGeneration + || (instanceID is { } id && _instance?.Id != id)) return; + LoadScreenshots(); + }); + } + + private void RefreshAvailability() + { + var reason = BlockReason(); + ScreenshotsBlockReason.Text = reason ?? ""; + ScreenshotsBlockReason.Visibility = reason is null ? Visibility.Collapsed : Visibility.Visible; + LoadingPanel.Visibility = _loading ? Visibility.Visible : Visibility.Collapsed; + OperationPanel.Visibility = _operationBusy ? Visibility.Visible : Visibility.Collapsed; + if (LoadingPanel.Children.FirstOrDefault() is ProgressRing ring) ring.IsActive = _loading; + ErrorText.Text = _loadError ?? ""; + ErrorText.Visibility = _loadError is null ? Visibility.Collapsed : Visibility.Visible; + EmptyText.Text = HakoStrings.Get("ScreenshotsEmpty"); + EmptyText.Visibility = !_loading && _loadError is null && _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + FeedbackText.Text = _feedback ?? ""; + FeedbackText.Visibility = _feedback is null ? Visibility.Collapsed : Visibility.Visible; + RefreshButton.IsEnabled = _active && !_loading && !_operationBusy; + OpenFolderButton.IsEnabled = !_operationBusy && _instance?.State == InstanceInstallationState.Ready; + DeleteButton.IsEnabled = !_operationBusy && _instance?.State == InstanceInstallationState.Ready + && !IsContentBusy() && ScreenshotItems.SelectedItems.Count > 0; + ScreenshotItems.IsEnabled = !_operationBusy; + ScreenshotsCount.Text = string.Format(HakoStrings.Get("ScreenshotsCountFormat"), _rows.Count); + RefreshSelectionText(); + } + + private string? BlockReason() + { + if (_instance is not { } instance || _services is not { } services) return null; + if (_operationBusy) return HakoStrings.Get("ScreenshotsOperationBusy"); + if (instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + return HakoStrings.Get("ContentBlockedInstall"); + if (instance.State != InstanceInstallationState.Ready) + return HakoStrings.Get("ContentBlockedBuild"); + if (IsContentBusy()) return HakoStrings.Get("ContentBlockedFilesBusy"); + return null; + } + + private bool IsContentBusy() => _instance is { } instance && _services is { } services + && services.Store.IsContentBusy(instance.Id); + + private void LoadScreenshots() + { + if (!_active || _services is not { } services || _instance is not { } instance) return; + CancelListLoad(); + _listCancellation = new CancellationTokenSource(); + var token = _listCancellation.Token; + var generation = ++_listGeneration; + _loading = true; + _loadError = null; + RefreshAvailability(); + _ = LoadScreenshotsAsync(services, instance, generation, token); + } + + private async Task LoadScreenshotsAsync(LauncherServices services, GameInstance instance, long generation, + CancellationToken cancellationToken) + { + try + { + var screenshots = await services.Screenshots.ListAsync(instance.Id, cancellationToken); + if (!IsCurrentLoad(instance.Id, generation, cancellationToken)) return; + var existing = _rows.ToDictionary(row => row.Path, StringComparer.OrdinalIgnoreCase); + var next = new List(screenshots.Count); + foreach (var screenshot in screenshots) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsCurrentLoad(instance.Id, generation, cancellationToken)) return; + if (!existing.TryGetValue(screenshot.Path, out var row)) row = new ScreenshotRow(screenshot); + else + { + CancelThumbnailLoad(row); + row.Update(screenshot); + } + row.UpdateLocalizedText(services.App.Language); + next.Add(row); + } + + foreach (var removed in _rows.Where(row => next.All(item => !ReferenceEquals(item, row))).ToArray()) + { + CancelThumbnailLoad(removed); + _rows.Remove(removed); + } + for (var index = 0; index < next.Count; index++) + { + var row = next[index]; + if (index < _rows.Count && ReferenceEquals(_rows[index], row)) continue; + var oldIndex = _rows.IndexOf(row); + if (oldIndex >= 0) _rows.Move(oldIndex, index); + else _rows.Insert(index, row); + } + while (_rows.Count > next.Count) _rows.RemoveAt(_rows.Count - 1); + _feedback = null; + foreach (var row in _rows) if (row.IsVisible) StartThumbnailLoad(row); + } + catch (OperationCanceledException) { } + catch (Exception exception) + { + if (IsCurrentLoad(instance.Id, generation, cancellationToken)) + _loadError = FormatException(exception); + } + finally + { + if (generation == _listGeneration) + { + _loading = false; + RefreshAvailability(); + } + } + } + + private bool IsCurrentLoad(Guid id, long generation, CancellationToken token) => + !token.IsCancellationRequested && generation == _listGeneration && _active && _instance?.Id == id; + + private void CancelListLoad() + { + var cancellation = _listCancellation; + _listCancellation = null; + cancellation?.Cancel(); + cancellation?.Dispose(); + _listGeneration++; + _loading = false; + } + + private void StartThumbnailLoad(ScreenshotRow row) + { + if (!_active || !_rows.Contains(row) || row.Thumbnail is not null || _thumbnailLoads.ContainsKey(row)) return; + var cancellation = new CancellationTokenSource(); + _thumbnailLoads.Add(row, cancellation); + _ = LoadThumbnailAsync(row, cancellation); + } + + private async Task LoadThumbnailAsync(ScreenshotRow row, CancellationTokenSource cancellation) + { + try + { + var thumbnail = await _thumbnails.LoadAsync(row.Path, cancellation.Token); + if (cancellation.IsCancellationRequested || !_active + || !_thumbnailLoads.TryGetValue(row, out var current) || !ReferenceEquals(current, cancellation) + || !_rows.Contains(row)) return; + row.Thumbnail = thumbnail; + } + catch (OperationCanceledException) { } + catch (Exception) { if (_rows.Contains(row)) row.Thumbnail = null; } + finally + { + if (_thumbnailLoads.TryGetValue(row, out var current) && ReferenceEquals(current, cancellation)) + _thumbnailLoads.Remove(row); + cancellation.Dispose(); + } + } + + private void CancelThumbnailLoad(ScreenshotRow row) + { + if (!_thumbnailLoads.Remove(row, out var cancellation)) return; + cancellation.Cancel(); + } + + private void CancelThumbnailLoads() + { + foreach (var cancellation in _thumbnailLoads.Values) cancellation.Cancel(); + _thumbnailLoads.Clear(); + } + + private void ScreenshotTile_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { Tag: ScreenshotRow row }) return; + row.IsVisible = true; + StartThumbnailLoad(row); + } + + private void ScreenshotTile_Unloaded(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { Tag: ScreenshotRow row }) return; + row.IsVisible = false; + CancelThumbnailLoad(row); + } + + private async void ScreenshotTile_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is ScreenshotRow row) await OpenScreenshotAsync(row); + } + + private async void OpenScreenshot_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is ScreenshotRow row) await OpenScreenshotAsync(row); + } + + private async Task OpenScreenshotAsync(ScreenshotRow row) + { + try + { + var file = await StorageFile.GetFileFromPathAsync(row.Path); + if (!await Windows.System.Launcher.LaunchFileAsync(file)) + await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ScreenshotsOpenFailed")); + } + catch (Exception) { await HakoDialogs.ShowErrorAsync(HakoStrings.Get("ScreenshotsOpenFailed")); } + } + + private async void OpenScreenshotFolder_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not ScreenshotRow row) return; + await InstanceContentView.OpenLocationAsync(Path.GetDirectoryName(row.Path)); + } + + private async void OpenFolderButton_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance) return; + try + { + var folder = await services.Screenshots.GetOrCreateFolderAsync(instance.Id); + await InstanceContentView.OpenLocationAsync(folder); + } + catch (Exception exception) { await HakoDialogs.ShowErrorAsync(FormatException(exception)); } + } + + private void RefreshButton_Click(object sender, RoutedEventArgs e) + { + _feedback = null; + LoadScreenshots(); + } + + private void ScreenshotItems_SelectionChanged(object sender, SelectionChangedEventArgs e) => RefreshAvailability(); + + private void RefreshSelectionText() => ScreenshotsSelectionCount.Text = ScreenshotItems.SelectedItems.Count == 0 + ? "" : string.Format(HakoStrings.Get("ScreenshotsSelectionFormat"), ScreenshotItems.SelectedItems.Count); + + private async void DeleteButton_Click(object sender, RoutedEventArgs e) => await DeleteSelectedAsync(); + + private async void DeleteScreenshot_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not ScreenshotRow row) return; + if (!ScreenshotItems.SelectedItems.Contains(row)) ScreenshotItems.SelectedItems.Clear(); + if (!ScreenshotItems.SelectedItems.Contains(row)) ScreenshotItems.SelectedItems.Add(row); + await DeleteSelectedAsync(); + } + + private async Task DeleteSelectedAsync() + { + var selected = ScreenshotItems.SelectedItems.OfType().ToArray(); + if (selected.Length == 0 || _services is not { } services || _instance is not { } instance || _operationBusy) return; + _operationBusy = true; + _feedback = null; + RefreshAvailability(); + try + { + var answer = await HakoDialogs.ConfirmAsync( + HakoStrings.Get("ScreenshotsDeleteTitle"), + string.Format(HakoStrings.Get("ScreenshotsDeleteMessage"), selected.Length), + HakoStrings.Get("ScreenshotsDeleteConfirm"), HakoStrings.Get("CancelButton"), destructive: true); + if (answer != ContentDialogResult.Primary) return; + + var result = await services.Screenshots.TrashAsync(instance.Id, selected.Select(row => row.Screenshot)); + var recycled = result.Results.Items.Count(item => item.Status == RecycleBinItemStatus.Recycled); + var failures = result.Results.Items.Count - recycled; + _feedback = failures == 0 + ? string.Format(HakoStrings.Get("ScreenshotsDeletedFormat"), recycled) + : string.Format(HakoStrings.Get("ScreenshotsDeletePartialFormat"), recycled, failures); + ScreenshotItems.SelectedItems.Clear(); + LoadScreenshots(); + } + catch (Exception exception) { _feedback = FormatException(exception); } + finally + { + _operationBusy = false; + RefreshAvailability(); + } + } + + private async void ScreenshotTile_DragStarting(UIElement sender, DragStartingEventArgs args) + { + if ((sender as FrameworkElement)?.Tag is not ScreenshotRow row) return; + var rows = ScreenshotItems.SelectedItems.Contains(row) + ? ScreenshotItems.SelectedItems.OfType().ToArray() : [row]; + var deferral = args.GetDeferral(); + try + { + var files = new List(rows.Length); + foreach (var selected in rows) + files.Add(await StorageFile.GetFileFromPathAsync(selected.Path)); + args.Data.SetStorageItems(files, readOnly: true); + args.Data.RequestedOperation = DataPackageOperation.Copy; + args.AllowedOperations = DataPackageOperation.Copy; + } + catch (Exception) { args.Cancel = true; } + finally { deferral.Complete(); } + } + + private void ScreenshotItems_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Delete && ScreenshotItems.SelectedItems.Count > 0) + { + e.Handled = true; + _ = DeleteSelectedAsync(); + } + } + + private string FormatException(Exception exception) + { + if (exception is InstanceStorageException storage) + return $"{HakoStrings.Get(storage.Error.ResourceKey())}\n{string.Format(HakoStrings.Get("BackupDiagnosticFormat"), storage.Message)}"; + return HakoStrings.Get("ScreenshotsActionFailed") + "\n" + exception.Message; + } + +} + +public sealed class ScreenshotRow : INotifyPropertyChanged +{ + private InstanceScreenshot _screenshot; + private BitmapImage? _thumbnail; + private CultureInfo _culture = CultureInfo.CurrentCulture; + + internal ScreenshotRow(InstanceScreenshot screenshot) => _screenshot = screenshot; + public event PropertyChangedEventHandler? PropertyChanged; + internal InstanceScreenshot Screenshot => _screenshot; + internal bool IsVisible { get; set; } + internal string Path => _screenshot.Path; + public string Name => _screenshot.Name; + public string CreatedText => _screenshot.CreatedAt?.ToLocalTime().ToString("g", _culture) ?? ""; + public string ActionsText { get; private set; } = ""; + public string OpenText { get; private set; } = ""; + public string OpenFolderText { get; private set; } = ""; + public string DeleteText { get; private set; } = ""; + public BitmapImage? Thumbnail + { + get => _thumbnail; + set + { + if (ReferenceEquals(_thumbnail, value)) return; + _thumbnail = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ThumbnailVisibility)); + OnPropertyChanged(nameof(PlaceholderVisibility)); + } + } + public Visibility ThumbnailVisibility => Thumbnail is null ? Visibility.Collapsed : Visibility.Visible; + public Visibility PlaceholderVisibility => Thumbnail is null ? Visibility.Visible : Visibility.Collapsed; + + internal void Update(InstanceScreenshot screenshot) + { + Thumbnail = null; + _screenshot = screenshot; + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(CreatedText)); + } + + internal void UpdateLocalizedText(AppLanguage language) + { + _culture = CultureInfo.GetCultureInfo(language == AppLanguage.Russian ? "ru-RU" : "en-US"); + OnPropertyChanged(nameof(CreatedText)); + ActionsText = HakoStrings.Get("ScreenshotsActions"); + OpenText = HakoStrings.Get("ScreenshotsOpen"); + OpenFolderText = HakoStrings.Get("ScreenshotsOpenFolder"); + DeleteText = HakoStrings.Get("ScreenshotsDelete"); + OnPropertyChanged(nameof(ActionsText)); + OnPropertyChanged(nameof(OpenText)); + OnPropertyChanged(nameof(OpenFolderText)); + OnPropertyChanged(nameof(DeleteText)); + } + + private void OnPropertyChanged([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/windows/Hako/InstanceWorldsView.xaml b/windows/Hako/InstanceWorldsView.xaml new file mode 100644 index 0000000..2c172c3 --- /dev/null +++ b/windows/Hako/InstanceWorldsView.xaml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/Hako/InstanceWorldsView.xaml.cs b/windows/Hako/InstanceWorldsView.xaml.cs new file mode 100644 index 0000000..1d59808 --- /dev/null +++ b/windows/Hako/InstanceWorldsView.xaml.cs @@ -0,0 +1,586 @@ +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Storage; + +namespace Hako; + +public sealed partial class InstanceWorldsView : UserControl +{ + private readonly ObservableCollection _rows = []; + private LauncherServices? _services; + private GameInstance? _instance; + private ProfilePreviewOptions? _preview; + private CoalescedRefresh? _refresh; + private CoalescedRefresh? _listRefresh; + private CancellationTokenSource? _loadCancellation; + private long _loadGeneration; + private bool _active; + private bool _loading; + private bool _operationBusy; + private bool _previewCatalogOpened; + private string? _expandedWorldId; + private string? _loadError; + private string? _feedback; + + public InstanceWorldsView() + { + InitializeComponent(); + WorldItems.ItemsSource = _rows; + Loaded += (_, _) => HakoStrings.LanguageChanged += HakoStrings_LanguageChanged; + Unloaded += (_, _) => HakoStrings.LanguageChanged -= HakoStrings_LanguageChanged; + RefreshStrings(); + } + + internal void Configure(LauncherServices services, GameInstance instance, ProfilePreviewOptions? preview) + { + var changed = _instance?.Id != instance.Id; + _services = services; + _instance = instance; + _preview = preview; + if (changed) + { + CancelListLoad(); + _rows.Clear(); + _expandedWorldId = null; + _loadError = null; + _feedback = null; + _previewCatalogOpened = false; + } + + RefreshStrings(); + if (changed && _active) LoadWorlds(); + } + + public void SetActive(bool active) + { + if (_active == active) return; + _active = active; + if (active) + { + if (_services is { } services) + { + _refresh = new CoalescedRefresh(DispatcherQueue, RefreshAvailability); + _listRefresh = new CoalescedRefresh(DispatcherQueue, LoadWorlds); + services.Store.BusyStateChanged += CoreStateChanged; + services.Store.InstancesChanged += Store_InstancesChanged; + services.Installations.PropertyChanged += CorePropertyChanged; + services.Games.RunStateChanged += Games_RunStateChanged; + services.Games.GameSessionEnded += Games_GameSessionEnded; + } + + LoadWorlds(); + foreach (var row in _rows.Where(row => row.IsExpanded)) ConfigureDatapacksView(row); + } + else + { + if (_services is { } services) + { + services.Store.BusyStateChanged -= CoreStateChanged; + services.Store.InstancesChanged -= Store_InstancesChanged; + services.Installations.PropertyChanged -= CorePropertyChanged; + services.Games.RunStateChanged -= Games_RunStateChanged; + services.Games.GameSessionEnded -= Games_GameSessionEnded; + } + + _refresh?.Stop(); + _refresh = null; + _listRefresh?.Stop(); + _listRefresh = null; + CancelListLoad(); + foreach (var row in _rows) row.ContentView?.SetActive(false); + } + + RefreshAvailability(); + } + + public void RefreshWorldsIfActive() + { + if (_active) LoadWorlds(); + } + + private void HakoStrings_LanguageChanged(object? sender, EventArgs e) => RefreshStrings(); + + internal void RefreshStrings() + { + WorldsTitle.Text = HakoStrings.Get("WorldsTitle"); + RefreshButton.Content = HakoStrings.Get("WorldsRefresh"); + OpenSavesButton.Content = HakoStrings.Get("WorldsOpenSaves"); + WorldLoadingText.Text = HakoStrings.Get("WorldsLoading"); + WorldEmptyText.Text = HakoStrings.Get("WorldsEmpty"); + WorldOperationText.Text = HakoStrings.Get("WorldsOperationInProgress"); + foreach (var row in _rows) row.UpdateLocalizedText(_services?.App.Language ?? AppLanguages.Default); + RefreshAvailability(); + } + + private void CoreStateChanged(object? sender, EventArgs e) => _refresh?.Request(); + + private void Store_InstancesChanged(object? sender, EventArgs e) + { + _refresh?.Request(); + _listRefresh?.Request(); + } + + private void CorePropertyChanged(object? sender, PropertyChangedEventArgs e) => _refresh?.Request(); + + private void Games_RunStateChanged(object? sender, GameRunStateChangedEventArgs e) + { + if (_instance?.Id == e.InstanceId) _refresh?.Request(); + } + + private void Games_GameSessionEnded(object? sender, GameSessionEventArgs e) + { + if (_instance?.Id == e.InstanceId) _listRefresh?.Request(); + } + + private void RefreshAvailability() + { + var reason = BlockReason(); + WorldBlockReasonText.Text = reason ?? ""; + WorldBlockReasonText.Visibility = reason is null ? Visibility.Collapsed : Visibility.Visible; + RefreshButton.IsEnabled = _active && !_loading; + OpenSavesButton.IsEnabled = _instance?.State == InstanceInstallationState.Ready; + WorldLoadingPanel.Visibility = _loading ? Visibility.Visible : Visibility.Collapsed; + WorldErrorText.Text = _loadError ?? ""; + WorldErrorText.Visibility = _loadError is null ? Visibility.Collapsed : Visibility.Visible; + WorldEmptyText.Visibility = !_loading && _loadError is null && _rows.Count == 0 + ? Visibility.Visible : Visibility.Collapsed; + WorldOperationPanel.Visibility = _operationBusy ? Visibility.Visible : Visibility.Collapsed; + WorldActionFeedbackText.Text = _feedback ?? ""; + WorldActionFeedbackText.Visibility = _feedback is null ? Visibility.Collapsed : Visibility.Visible; + foreach (var row in _rows) + row.UpdateAvailability(reason is null, reason); + } + + private string? BlockReason() + { + if (_instance is not { } instance || _services is not { } services) return null; + if (_operationBusy) return HakoStrings.Get("WorldsOperationBusy"); + if (instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + return HakoStrings.Get("ContentBlockedInstall"); + if (instance.State != InstanceInstallationState.Ready) + return HakoStrings.Get("ContentBlockedBuild"); + if (services.Store.ManagementBlockedError(instance) is { } blocked) + return HakoStrings.Get(blocked.ResourceKey()); + return services.Store.IsContentBusy(instance.Id) ? HakoStrings.Get("ContentBlockedFilesBusy") : null; + } + + private void LoadWorlds() + { + if (!_active || _services is not { } services || _instance is not { } instance) return; + CancelListLoad(); + _loadCancellation = new CancellationTokenSource(); + var cancellationToken = _loadCancellation.Token; + var generation = ++_loadGeneration; + _loading = true; + _loadError = null; + RefreshAvailability(); + _ = LoadWorldsAsync(services, instance, generation, cancellationToken); + } + + private async Task LoadWorldsAsync(LauncherServices services, GameInstance instance, long generation, + CancellationToken cancellationToken) + { + try + { + var worlds = await services.Worlds.ListAsync(instance.Id, cancellationToken); + if (!IsCurrentLoad(instance.Id, generation, cancellationToken)) return; + await ApplyWorldsAsync(worlds, generation, cancellationToken); + } + catch (OperationCanceledException) { } + catch (Exception exception) + { + if (IsCurrentLoad(instance.Id, generation, cancellationToken)) + _loadError = FormatWorldException(exception); + } + finally + { + if (generation == _loadGeneration) + { + _loading = false; + RefreshAvailability(); + } + } + } + + private async Task ApplyWorldsAsync(IReadOnlyList worlds, long generation, + CancellationToken cancellationToken) + { + var existing = _rows.ToDictionary(row => row.WorldId, StringComparer.OrdinalIgnoreCase); + var next = new List(worlds.Count); + foreach (var world in worlds) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsCurrentLoad(_instance?.Id ?? Guid.Empty, generation, cancellationToken)) return; + var icon = await InstanceIconSource.CreateAsync(world.IconData); + if (cancellationToken.IsCancellationRequested || generation != _loadGeneration) return; + if (!existing.TryGetValue(world.Id, out var row)) row = new InstanceWorldRow(world.Id); + row.Update(world, icon, _services?.App.Language ?? AppLanguages.Default); + next.Add(row); + } + + foreach (var removed in _rows.Where(row => next.All(item => item.WorldId != row.WorldId)).ToArray()) + { + removed.ContentView?.SetActive(false); + _rows.Remove(removed); + } + + for (var index = 0; index < next.Count; index++) + { + var row = next[index]; + row.IsExpanded = row.WorldId == _expandedWorldId; + if (index < _rows.Count && ReferenceEquals(_rows[index], row)) continue; + var oldIndex = _rows.IndexOf(row); + if (oldIndex >= 0) _rows.Move(oldIndex, index); + else _rows.Insert(index, row); + } + + while (_rows.Count > next.Count) _rows.RemoveAt(_rows.Count - 1); + if (_expandedWorldId is not null && next.All(row => row.WorldId != _expandedWorldId)) _expandedWorldId = null; + if (_expandedWorldId is null && _preview?.Page is PreviewPage.Datapacks or PreviewPage.DatapackCatalog + && next.Any(row => row.WorldId == "Survival World")) + _expandedWorldId = "Survival World"; + foreach (var row in _rows) row.IsExpanded = row.WorldId == _expandedWorldId; + foreach (var row in _rows.Where(row => row.IsExpanded)) ConfigureDatapacksView(row); + RefreshAvailability(); + } + + private bool IsCurrentLoad(Guid instanceId, long generation, CancellationToken cancellationToken) => + !cancellationToken.IsCancellationRequested && generation == _loadGeneration && _active + && _instance?.Id == instanceId; + + private void CancelListLoad() + { + var cancellation = _loadCancellation; + _loadCancellation = null; + cancellation?.Cancel(); + cancellation?.Dispose(); + _loadGeneration++; + _loading = false; + } + + private void ExpandButton_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not InstanceWorldRow row) return; + var expanded = !row.IsExpanded; + foreach (var other in _rows.Where(item => item.IsExpanded)) + { + other.IsExpanded = false; + other.ContentView?.SetActive(false); + } + + _expandedWorldId = expanded ? row.WorldId : null; + row.IsExpanded = expanded; + if (expanded) ConfigureDatapacksView(row); + } + + private void DatapacksView_Loaded(object sender, RoutedEventArgs e) + { + if (sender is not InstanceContentView content || content.Tag is not InstanceWorldRow row) return; + row.ContentView = content; + if (row.IsExpanded) ConfigureDatapacksView(row); + } + + private void DatapacksView_Unloaded(object sender, RoutedEventArgs e) + { + if (sender is not InstanceContentView content || content.Tag is not InstanceWorldRow row) return; + content.SetActive(false); + if (ReferenceEquals(row.ContentView, content)) row.ContentView = null; + } + + private void ConfigureDatapacksView(InstanceWorldRow row) + { + if (!_active || !row.IsExpanded || row.ContentView is not { } content + || _services is not { } services || _instance is not { } instance) return; + content.Configure(services, instance, InstanceContentTarget.WorldDatapacks(row.WorldId), row.Name); + content.SetActive(true); + if (_preview?.Page == PreviewPage.DatapackCatalog && !_previewCatalogOpened) + { + content.OpenCatalog(); + _previewCatalogOpened = true; + } + } + + private async void OpenSavesButton_Click(object sender, RoutedEventArgs e) + { + if (_services is not { } services || _instance is not { } instance) return; + try + { + var folder = await Task.Run(() => + { + var root = services.Store.GetInstanceDirectory(instance.Id); + var saves = InstanceStorage.ContainedPath("minecraft/saves", root, allowBaseReparsePoint: true); + return Directory.Exists(saves) ? saves : null; + }); + if (folder is null) + { + _feedback = HakoStrings.Get("WorldsSavesUnavailable"); + RefreshAvailability(); + return; + } + + await InstanceContentView.OpenLocationAsync(folder); + } + catch (Exception exception) + { + _feedback = FormatWorldException(exception); + RefreshAvailability(); + } + } + + private async void DuplicateWorld_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not InstanceWorldRow row + || _services is not { } services || _instance is not { } instance || BlockReason() is not null) return; + _operationBusy = true; + _feedback = null; + RefreshAvailability(); + try + { + var answer = await HakoDialogs.ConfirmAsync( + WorldResourceKeys.FormatMessage("WorldDuplicateTitle", HakoStrings.Get, row.Name, instance.Name), + WorldResourceKeys.FormatMessage("WorldDuplicateMessage", HakoStrings.Get, row.Name, instance.Name), + HakoStrings.Get("WorldDuplicateConfirm"), HakoStrings.Get("CancelButton")); + if (answer != ContentDialogResult.Primary) return; + if (CoreBlockReason() is { } blocked) + { + _feedback = blocked; + return; + } + + var result = await services.Worlds.DuplicateAsync(instance.Id, row.WorldId); + if (!result.Succeeded) + { + _feedback = HakoStrings.Get(result.FailureReason is { } failure + ? WorldResourceKeys.Failure(failure) : "WorldActionFailed"); + return; + } + + _feedback = WorldResourceKeys.FormatMessage("WorldDuplicateSucceeded", HakoStrings.Get, + row.Name, result.WorldId!); + LoadWorlds(); + } + catch (Exception exception) + { + _feedback = FormatWorldException(exception); + } + finally + { + _operationBusy = false; + RefreshAvailability(); + } + } + + private async void TrashWorld_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not InstanceWorldRow row + || _services is not { } services || _instance is not { } instance || BlockReason() is not null) return; + _operationBusy = true; + _feedback = null; + RefreshAvailability(); + try + { + var answer = await HakoDialogs.ConfirmAsync( + WorldResourceKeys.FormatMessage("WorldTrashTitle", HakoStrings.Get, row.Name), + WorldResourceKeys.FormatMessage("WorldTrashMessage", HakoStrings.Get, row.Name, instance.Name), + HakoStrings.Get("WorldTrashConfirm"), HakoStrings.Get("CancelButton"), destructive: true); + if (answer != ContentDialogResult.Primary) return; + if (CoreBlockReason() is { } blocked) + { + _feedback = blocked; + return; + } + + var result = await services.Worlds.TrashAsync(instance.Id, row.WorldId); + if (!result.Succeeded) + { + _feedback = HakoStrings.Get(result.FailureReason is { } failure + ? WorldResourceKeys.Failure(failure) : "WorldActionFailed"); + return; + } + + if (_expandedWorldId == row.WorldId) _expandedWorldId = null; + _feedback = WorldResourceKeys.FormatMessage("WorldTrashSucceeded", HakoStrings.Get, row.Name); + LoadWorlds(); + } + catch (Exception exception) + { + _feedback = FormatWorldException(exception); + } + finally + { + _operationBusy = false; + RefreshAvailability(); + } + } + + private async void BackupWorld_Click(object sender, RoutedEventArgs e) + { + if ((sender as FrameworkElement)?.Tag is not InstanceWorldRow row + || _services is not { } services || _instance is not { } instance || BlockReason() is not null) return; + _operationBusy = true; + _feedback = null; + WorldOperationText.Text = HakoStrings.Get("WorldBackupInProgress"); + RefreshAvailability(); + try + { + var result = await services.WorldBackups.CreateAsync(instance.Id, row.WorldId); + if (!result.Succeeded || result.Path is null) + { + _feedback = BackupResourceKeys.FormatFailure(result.Failure, HakoStrings.Get); + return; + } + + var answer = await HakoDialogs.ConfirmAsync( + HakoStrings.Get("BackupSucceededTitle"), + string.Format(HakoStrings.Get("WorldBackupSucceededMessage"), Path.GetFileName(result.Path)), + HakoStrings.Get("BackupShowInExplorer"), HakoStrings.Get("CloseButton")); + if (answer == ContentDialogResult.Primary) + await InstanceContentView.OpenLocationAsync(Path.GetDirectoryName(result.Path)); + } + catch (Exception exception) + { + _feedback = HakoStrings.Get("BackupFailed") + "\n" + + string.Format(HakoStrings.Get("BackupDiagnosticFormat"), exception.Message); + } + finally + { + _operationBusy = false; + RefreshAvailability(); + } + } + + private string? CoreBlockReason() + { + if (_instance is not { } instance || _services is not { } services) return null; + if (instance.State is InstanceInstallationState.Queued or InstanceInstallationState.Installing) + return HakoStrings.Get("ContentBlockedInstall"); + if (instance.State != InstanceInstallationState.Ready) + return HakoStrings.Get("ContentBlockedBuild"); + if (services.Store.ManagementBlockedError(instance) is { } blocked) + return HakoStrings.Get(blocked.ResourceKey()); + return services.Store.IsContentBusy(instance.Id) ? HakoStrings.Get("ContentBlockedFilesBusy") : null; + } + + private string FormatWorldException(Exception exception) + { + var message = exception is InstanceWorldException world + ? HakoStrings.Get(WorldResourceKeys.Failure(world.Error)) + : HakoStrings.Get("WorldActionFailed"); + return $"{message}\n{HakoStrings.Get("WorldDiagnosticLabel")} {exception.Message}"; + } + + private void RefreshButton_Click(object sender, RoutedEventArgs e) + { + _feedback = null; + LoadWorlds(); + } + +} + +public sealed class InstanceWorldRow : INotifyPropertyChanged +{ + private bool _isExpanded; + private string? _blockReason; + + internal InstanceWorldRow(string worldId) => WorldId = worldId; + + public event PropertyChangedEventHandler? PropertyChanged; + public string WorldId { get; } + public string Name { get; private set; } = ""; + public string VersionText { get; private set; } = ""; + public string ActivityText { get; private set; } = ""; + public string MetadataErrorText { get; private set; } = ""; + public Visibility MetadataErrorVisibility { get; private set; } = Visibility.Collapsed; + public ImageSource? Icon { get; private set; } + public Visibility IconVisibility => Icon is null ? Visibility.Collapsed : Visibility.Visible; + public Visibility PlaceholderVisibility => Icon is null ? Visibility.Visible : Visibility.Collapsed; + public string PlaceholderGlyph => "\uE8B7"; + public string ExpandText { get; private set; } = ""; + public string DatapacksTitle { get; private set; } = ""; + public string ActionsText { get; private set; } = ""; + public string DuplicateText { get; private set; } = ""; + public string BackupText { get; private set; } = ""; + public string TrashText { get; private set; } = ""; + public bool CanManage { get; private set; } + public string? BlockReason => _blockReason; + public Visibility ExpandedVisibility => IsExpanded ? Visibility.Visible : Visibility.Collapsed; + public InstanceContentView? ContentView { get; set; } + + public bool IsExpanded + { + get => _isExpanded; + set + { + if (_isExpanded == value) return; + _isExpanded = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ExpandedVisibility)); + UpdateLocalizedText(AppLanguages.Default); + } + } + + internal void Update(InstanceWorld world, ImageSource? icon, AppLanguage language) + { + Icon = icon; + Name = world.Name; + var culture = CultureInfo.GetCultureInfo(language == AppLanguage.Russian ? "ru-RU" : "en-US"); + var version = world.Version ?? HakoStrings.Get("WorldVersionUnknown"); + VersionText = WorldResourceKeys.FormatMessage("WorldVersionFormat", HakoStrings.Get, version, + HakoStrings.Get(WorldResourceKeys.GameType(world.GameType))); + ActivityText = WorldResourceKeys.FormatMessage("WorldActivityFormat", HakoStrings.Get, + WorldResourceKeys.FormatLastPlayed(world.LastPlayed, culture, HakoStrings.Get), + WorldResourceKeys.FormatSize(world.Size, culture, HakoStrings.Get)); + MetadataErrorText = world.MetadataError is { } error + ? WorldResourceKeys.FormatMessage("WorldMetadataUnavailable", HakoStrings.Get, + HakoStrings.Get(WorldResourceKeys.MetadataError(error))) + : ""; + MetadataErrorVisibility = world.MetadataError is null ? Visibility.Collapsed : Visibility.Visible; + UpdateLocalizedText(language); + OnPropertyChanged(nameof(Icon)); + OnPropertyChanged(nameof(IconVisibility)); + OnPropertyChanged(nameof(PlaceholderVisibility)); + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(VersionText)); + OnPropertyChanged(nameof(ActivityText)); + OnPropertyChanged(nameof(MetadataErrorText)); + OnPropertyChanged(nameof(MetadataErrorVisibility)); + } + + internal void UpdateAvailability(bool canManage, string? reason) + { + CanManage = canManage; + _blockReason = reason; + OnPropertyChanged(nameof(CanManage)); + OnPropertyChanged(nameof(BlockReason)); + } + + internal void UpdateLocalizedText(AppLanguage language) + { + ExpandText = HakoStrings.Get(IsExpanded ? "WorldsHideDatapacks" : "WorldsShowDatapacks"); + DatapacksTitle = HakoStrings.Get("WorldsDatapacksTitle"); + ActionsText = HakoStrings.Get("WorldsActions"); + DuplicateText = HakoStrings.Get("WorldsDuplicate"); + BackupText = HakoStrings.Get("WorldsBackup"); + TrashText = HakoStrings.Get("WorldsTrash"); + OnPropertyChanged(nameof(ExpandText)); + OnPropertyChanged(nameof(DatapacksTitle)); + OnPropertyChanged(nameof(ActionsText)); + OnPropertyChanged(nameof(DuplicateText)); + OnPropertyChanged(nameof(BackupText)); + OnPropertyChanged(nameof(TrashText)); + } + + private void OnPropertyChanged([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/windows/Hako/JavaMemorySlider.cs b/windows/Hako/JavaMemorySlider.cs new file mode 100644 index 0000000..a601f4b --- /dev/null +++ b/windows/Hako/JavaMemorySlider.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using Hako.Core.Minecraft; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; + +namespace Hako; + +/// Java heap limit in 256 MiB steps with the 70% warning and the x86 limit, as JavaMemorySlider on macOS. +public sealed partial class JavaMemorySlider : UserControl +{ + private static readonly Lazy SharedPolicy = new(() => JavaMemoryPolicy.Current); + private readonly TextBlock _title = new() { FontWeight = Microsoft.UI.Text.FontWeights.Medium }; + private readonly TextBlock _value = new() { HorizontalAlignment = HorizontalAlignment.Right }; + private readonly Slider _slider = new() { StepFrequency = 256, SmallChange = 256, LargeChange = 1024, TickPlacement = TickPlacement.None }; + private readonly TextBlock _caption = new() { TextWrapping = TextWrapping.Wrap, FontSize = 12 }; + private readonly TextBlock _warning = new() { TextWrapping = TextWrapping.Wrap, FontSize = 12 }; + private GameArchitecture _architecture = GameArchitecture.X64; + private bool _updating; + + public JavaMemorySlider() + { + _caption.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]; + _warning.Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SystemFillColorCriticalBrush"]; + var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } }; + header.Children.Add(_title); + Grid.SetColumn(_value, 1); + header.Children.Add(_value); + Content = new StackPanel { Spacing = 6, Children = { header, _slider, _caption, _warning } }; + _slider.Minimum = 512; + _slider.ValueChanged += Slider_ValueChanged; + Configure(GameArchitecture.X64, Policy.InitialMiB(GameArchitecture.X64)); + } + + public event EventHandler? ValueChanged; + + public static JavaMemoryPolicy Policy => SharedPolicy.Value; + + public int Value => Policy.Normalize((int)_slider.Value, _architecture); + + public void Configure(GameArchitecture architecture, int value) + { + _architecture = architecture; + _updating = true; + _slider.Maximum = Policy.MaximumMiB(architecture); + _slider.Value = Policy.Normalize(value, architecture); + _updating = false; + UpdateText(); + } + + public void RefreshStrings() + { + _title.Text = HakoStrings.Get("JavaMemoryTitle"); + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(_slider, _title.Text); + _warning.Text = HakoStrings.Get("LaunchMemoryWarning"); + UpdateText(); + } + + private void Slider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + var normalized = Policy.Normalize((int)Math.Round(e.NewValue), _architecture); + if (Math.Abs(_slider.Value - normalized) > 0.5) + { + _slider.Value = normalized; + return; + } + + UpdateText(); + if (!_updating) + { + ValueChanged?.Invoke(this, EventArgs.Empty); + } + } + + private void UpdateText() + { + _value.Text = string.Format(CultureInfo.CurrentCulture, HakoStrings.Get("JavaMemoryValue"), Value / 1024d); + var physical = Policy.PhysicalBytes / (1024d * 1024 * 1024); + _caption.Text = string.Format(CultureInfo.CurrentCulture, HakoStrings.Get( + _architecture == GameArchitecture.X86 ? "JavaMemoryCaptionX86" : "JavaMemoryCaption"), physical); + _warning.Visibility = Policy.Warns(Value) ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/windows/Hako/LauncherServices.cs b/windows/Hako/LauncherServices.cs new file mode 100644 index 0000000..d14d269 --- /dev/null +++ b/windows/Hako/LauncherServices.cs @@ -0,0 +1,660 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Hako.Core.Playtime; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Storage.Streams; + +namespace Hako; + +/// App-lifetime services shared by the launcher pages (the Windows counterpart of HakoServices). +internal sealed record LauncherServices( + AppCoordinator App, + UserSettingsService Settings, + InstanceStore Store, + InstallationCoordinator Installations, + GameLaunchCoordinator Games, + MojangClient Mojang, + FabricClient Fabric, + ModrinthClient Modrinth, + ModpackInstaller Modpacks, + ModpackCatalog ModpackCatalog, + InstanceContent Content, + InstanceContentController ContentController, + InstanceWorlds Worlds, + InstanceScreenshots Screenshots, + InstanceBackup Backups, + WorldBackup WorldBackups, + HttpClient? PreviewModrinthHttpClient, + MinecraftSkinPreviewService SkinPreview, + ProfilePreviewOptions? PreviewOptions, + PlaytimeCoordinator Playtime); + +internal sealed record InstanceProfileContext(LauncherServices Services, Guid InstanceId); + +/// A page with unsaved input asks before the window navigates away or closes. +internal interface INavigationGuard +{ + Task ConfirmLeaveAsync(); +} + +/// +/// Collapses notifications raised on Core worker threads (often while Core holds its locks) into one +/// refresh on the UI thread. The handler only enqueues and never calls back into Core synchronously. +/// +internal sealed class CoalescedRefresh(DispatcherQueue dispatcher, Action refresh) +{ + private int _pending; + private bool _stopped; + + public void Request() + { + if (Volatile.Read(ref _stopped) || Interlocked.Exchange(ref _pending, 1) == 1) + { + return; + } + + if (!dispatcher.TryEnqueue(Run)) + { + Volatile.Write(ref _pending, 0); + } + } + + public void Stop() => Volatile.Write(ref _stopped, true); + + private void Run() + { + Volatile.Write(ref _pending, 0); + if (!Volatile.Read(ref _stopped)) + { + refresh(); + } + } +} + +/// Shows one ContentDialog at a time: WinUI allows a single open dialog per XamlRoot. +internal static class HakoDialogs +{ + private static readonly SemaphoreSlim Gate = new(1, 1); + private static readonly object WindowSync = new(); + private static CancellationTokenSource _windowLifetime = new(); + private static CancellationTokenSource _contentConfirmationLifetime = new(); + private static DispatcherQueue? _dispatcher; + private static XamlRoot? _root; + private static bool _windowClosed; + private static bool _windowClosing; + + public static XamlRoot? Root + { + get { lock (WindowSync) return _root; } + set { lock (WindowSync) _root = value; } + } + + public static void AttachWindow(DispatcherQueue dispatcher, XamlRoot root) + { + lock (WindowSync) + { + _dispatcher = dispatcher; + _root = root; + _windowClosed = false; + _windowClosing = false; + } + } + + public static void BeginWindowClose() + { + CancellationTokenSource pending; + lock (WindowSync) + { + _windowClosing = true; + pending = _contentConfirmationLifetime; + _contentConfirmationLifetime = new CancellationTokenSource(); + } + + pending.Cancel(); + } + + public static void EndWindowClose() + { + CancellationTokenSource pending; + lock (WindowSync) + { + if (_windowClosed) return; + _windowClosing = false; + pending = _contentConfirmationLifetime; + _contentConfirmationLifetime = new CancellationTokenSource(); + } + + pending.Cancel(); + } + + public static void CloseWindow() + { + CancellationTokenSource[] pending; + lock (WindowSync) + { + _windowClosed = true; + _root = null; + _dispatcher = null; + pending = [_windowLifetime, _contentConfirmationLifetime]; + _windowLifetime = new CancellationTokenSource(); + _contentConfirmationLifetime = new CancellationTokenSource(); + } + + foreach (var cancellation in pending) cancellation.Cancel(); + } + + public static async Task ShowAsync(ContentDialog dialog, Action? refresh = null) + { + EventHandler? languageChanged = null; + try + { + return await ShowAsync(() => + { + if (refresh is not null) + { + refresh(dialog); + languageChanged = (_, _) => refresh(dialog); + HakoStrings.LanguageChanged += languageChanged; + } + return dialog; + }, CancellationToken.None); + } + finally + { + if (languageChanged is not null) HakoStrings.LanguageChanged -= languageChanged; + } + } + + private static async Task ShowAsync(Func createDialog, CancellationToken cancellationToken) + { + CancellationToken windowToken; + lock (WindowSync) + { + if (_windowClosed) return ContentDialogResult.None; + windowToken = _windowLifetime.Token; + } + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, windowToken); + cancellationToken = lifetime.Token; + try + { + await Gate.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return ContentDialogResult.None; + } + + try + { + DispatcherQueue? dispatcher; + lock (WindowSync) dispatcher = _dispatcher; + if (dispatcher is null) + { + return ContentDialogResult.None; + } + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dialogSync = new object(); + ContentDialog? activeDialog = null; + void CancelOnUiThread() + { + ContentDialog? dialog; + lock (dialogSync) dialog = activeDialog; + if (dialog is null) + { + completion.TrySetResult(ContentDialogResult.None); + return; + } + + HideDialog(dialog); + } + + using var registration = cancellationToken.Register(() => + { + if (dispatcher.HasThreadAccess) CancelOnUiThread(); + else if (!dispatcher.TryEnqueue(CancelOnUiThread)) completion.TrySetResult(ContentDialogResult.None); + }); + + if (dispatcher.HasThreadAccess) + { + return await ShowOnUiThreadAsync(createDialog, cancellationToken, dialog => + { + lock (dialogSync) activeDialog = dialog; + }); + } + + if (!dispatcher.TryEnqueue(() => _ = CompleteOnUiThreadAsync(createDialog, cancellationToken, + dialog => + { + lock (dialogSync) activeDialog = dialog; + }, completion))) + { + return ContentDialogResult.None; + } + + return await completion.Task.ConfigureAwait(false); + } + finally + { + Gate.Release(); + } + } + + public static async Task ConfirmContentAsync( + ContentConfirmationRequest request, + CancellationToken cancellationToken) + { + CancellationToken windowToken; + CancellationToken contentToken; + lock (WindowSync) + { + if (_windowClosed || _windowClosing) return new ContentConfirmationResult(ContentConfirmationChoice.Cancel); + windowToken = _windowLifetime.Token; + contentToken = _contentConfirmationLifetime.Token; + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, windowToken, contentToken); + ContentConfirmationPresentation? presentation = null; + EventHandler? languageChanged = null; + try + { + var result = await ShowAsync(() => + { + presentation = new ContentConfirmationPresentation(request); + languageChanged = (_, _) => + { + DispatcherQueue? dispatcher; + lock (WindowSync) dispatcher = _dispatcher; + if (dispatcher?.HasThreadAccess == true) presentation.Refresh(); + else dispatcher?.TryEnqueue(presentation.Refresh); + }; + HakoStrings.LanguageChanged += languageChanged; + presentation.Refresh(); + return presentation.Dialog; + }, linked.Token).ConfigureAwait(false); + + return result switch + { + ContentDialogResult.Primary => new ContentConfirmationResult( + ContentConfirmationChoice.Primary, presentation?.ChosenFilename), + ContentDialogResult.Secondary => new ContentConfirmationResult(ContentConfirmationChoice.Alternative), + _ => new ContentConfirmationResult(ContentConfirmationChoice.Cancel) + }; + } + catch (OperationCanceledException) + { + return new ContentConfirmationResult(ContentConfirmationChoice.Cancel); + } + finally + { + if (languageChanged is not null) HakoStrings.LanguageChanged -= languageChanged; + } + } + + private static async Task CompleteOnUiThreadAsync(Func createDialog, + CancellationToken cancellationToken, Action setDialog, + TaskCompletionSource completion) + { + try + { + completion.TrySetResult(await ShowOnUiThreadAsync(createDialog, cancellationToken, setDialog)); + } + catch (Exception exception) + { + completion.TrySetException(exception); + } + } + + private static async Task ShowOnUiThreadAsync( + Func createDialog, CancellationToken cancellationToken, Action setDialog) + { + if (cancellationToken.IsCancellationRequested) return ContentDialogResult.None; + var root = Root; + if (root is null) return ContentDialogResult.None; + + var dialog = createDialog(); + dialog.XamlRoot = root; + dialog.RequestedTheme = (root.Content as FrameworkElement)?.ActualTheme ?? ElementTheme.Default; + setDialog(dialog); + + try + { + if (cancellationToken.IsCancellationRequested) return ContentDialogResult.None; + return await dialog.ShowAsync(); + } + catch (Exception) when (cancellationToken.IsCancellationRequested || Root is null) + { + return ContentDialogResult.None; + } + finally + { + setDialog(null); + } + } + + private static void HideDialog(ContentDialog dialog) + { + try { dialog.Hide(); } + catch (InvalidOperationException) { } + } + + private sealed class ContentConfirmationPresentation + { + private readonly ContentConfirmationRequest _request; + private readonly TextBlock _message; + private readonly List<(TextBlock Title, TextBlock? Note, ContentConfirmationProject Project)> _projects; + private readonly List<(RadioButton Button, ContentConfirmationFile File, TextBlock Note)> _files; + + public ContentConfirmationPresentation(ContentConfirmationRequest request) + { + _request = request; + _message = new TextBlock { TextWrapping = TextWrapping.Wrap }; + var rows = new StackPanel { Spacing = 10 }; + _projects = []; + _files = []; + foreach (var project in request.Projects) + { + var title = new TextBlock { FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap }; + TextBlock? note = project.NoteCode is null ? null : new TextBlock + { + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] + }; + var text = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center }; + text.Children.Add(title); + if (note is not null) text.Children.Add(note); + var row = new Grid { ColumnDefinitions = { new ColumnDefinition { Width = new GridLength(36) }, new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) } }, ColumnSpacing = 10 }; + if (project.IconUrl is { IsAbsoluteUri: true, Scheme: "https" } iconUrl) + { + row.Children.Add(new Image + { + Width = 32, + Height = 32, + Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(iconUrl), + VerticalAlignment = VerticalAlignment.Center + }); + } + else + { + row.Children.Add(new FontIcon { Glyph = "\uE71D", FontSize = 20, VerticalAlignment = VerticalAlignment.Center }); + } + Grid.SetColumn(text, 1); + row.Children.Add(text); + rows.Children.Add(row); + _projects.Add((title, note, project)); + } + + var body = new StackPanel { Spacing = 12 }; + body.Children.Add(_message); + if (request.Files is { Count: > 0 } files) + { + var fileRows = new StackPanel { Spacing = 8 }; + var groupName = "ContentFileChoice" + Guid.NewGuid().ToString("N"); + foreach (var file in files) + { + var title = new TextBlock { Text = file.Filename, TextWrapping = TextWrapping.Wrap }; + var note = new TextBlock + { + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] + }; + var text = new StackPanel { Spacing = 2, Children = { title, note } }; + var option = new RadioButton + { + Content = text, + GroupName = groupName, + IsChecked = _files.Count == 0, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + fileRows.Children.Add(option); + _files.Add((option, file, note)); + } + + body.Children.Add(new ScrollViewer + { + MaxHeight = 220, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = fileRows + }); + } + + if (rows.Children.Count > 0) + { + body.Children.Add(new ScrollViewer { MaxHeight = 300, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = rows }); + } + + Dialog = new ContentDialog + { + Content = body, + PrimaryButtonText = request.PrimaryActionAvailable ? HakoStrings.Get(ContentResourceKeys.ConfirmationPrimary(request.Code)) : "", + SecondaryButtonText = request.AlternativeActionAvailable ? HakoStrings.Get(ContentResourceKeys.ConfirmationAlternative(request.Code)) : "", + CloseButtonText = HakoStrings.Get("CancelButton"), + DefaultButton = request.Destructive ? ContentDialogButton.Close : ContentDialogButton.Primary + }; + Dialog.PrimaryButtonClick += (_, _) => ChosenFilename = SelectedFilename; + } + + public ContentDialog Dialog { get; } + public string? ChosenFilename { get; private set; } + + private string? SelectedFilename => _files.FirstOrDefault(item => item.Button.IsChecked == true).File?.Filename; + + public void Refresh() + { + Dialog.Title = HakoStrings.Get(ContentResourceKeys.ConfirmationTitle(_request.Code)); + _message.Text = FormatMessage(ContentResourceKeys.ConfirmationMessage(_request.Code), _request.Arguments, + _request.Code switch + { + ContentConfirmationCode.Dependencies => ["projectTitle", "instanceName"], + ContentConfirmationCode.IncompatibleContent => ["projectTitle", "instanceName"], + ContentConfirmationCode.ReplaceExisting => ["filename", "instanceName"], + ContentConfirmationCode.DeleteContent => ["filename", "instanceName"], + _ => [] + }); + Dialog.PrimaryButtonText = _request.PrimaryActionAvailable ? HakoStrings.Get(ContentResourceKeys.ConfirmationPrimary(_request.Code)) : ""; + Dialog.SecondaryButtonText = _request.AlternativeActionAvailable ? HakoStrings.Get(ContentResourceKeys.ConfirmationAlternative(_request.Code)) : ""; + Dialog.CloseButtonText = HakoStrings.Get("CancelButton"); + foreach (var (title, note, project) in _projects) + { + title.Text = project.Title; + if (note is not null && project.NoteCode is { } code) + { + note.Text = FormatMessage(ContentResourceKeys.ConfirmationNote(code), project.NoteArguments ?? EmptyArguments, + code switch + { + ContentConfirmationNoteCode.ProjectLimitReached => ["limit"], + ContentConfirmationNoteCode.FabricLoaderRequirement or ContentConfirmationNoteCode.JavaRequirement => ["required", "current"], + ContentConfirmationNoteCode.IncompatibleWith => ["projectTitle"], + _ => [] + }); + } + } + + foreach (var (_, file, note) in _files) + note.Text = HakoStrings.Get(file.Required ? "ContentConfirmationFileRequired" : "ContentConfirmationFileOptional"); + } + + private static readonly IReadOnlyDictionary EmptyArguments = new Dictionary(); + } + + private static string FormatMessage(string key, IReadOnlyDictionary arguments, string[] names) + { + var template = HakoStrings.Get(key); + if (names.Length == 0) return template; + try { return string.Format(template, names.Select(name => arguments.GetValueOrDefault(name, "")).ToArray()); } + catch (FormatException) { return template; } + } + + /// Asks whether to leave a page whose changed draft is invalid and will not be saved. + public static async Task ConfirmLeaveInvalidDraftAsync() => + await ConfirmAsync( + HakoStrings.Get("InvalidDraftTitle"), + HakoStrings.Get("InvalidDraftMessage"), + HakoStrings.Get("InvalidDraftLeave"), + HakoStrings.Get("InvalidDraftStay"), + destructive: true) == ContentDialogResult.Primary; + + public static Task ConfirmAsync( + string title, + string message, + string primary, + string close, + string? secondary = null, + bool destructive = false) + { + return ShowAsync(() => + { + var dialog = new ContentDialog + { + Title = title, + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + PrimaryButtonText = primary, + CloseButtonText = close, + DefaultButton = destructive ? ContentDialogButton.Close : ContentDialogButton.Primary + }; + if (secondary is not null) dialog.SecondaryButtonText = secondary; + return dialog; + }, CancellationToken.None); + } + + public static Task ShowErrorAsync(string message) => ShowAsync(() => new ContentDialog + { + Title = HakoStrings.Get("ErrorTitle"), + Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, + CloseButtonText = HakoStrings.Get("CloseButton"), + DefaultButton = ContentDialogButton.Close + }, CancellationToken.None); + + /// + /// Shared step for operations that move files to the Recycle Bin: items the bin can't take (for example on a + /// volume without one) are deleted permanently only after the user confirms. Returns the paths still left. + /// + public static async Task> ConfirmPermanentDeleteAsync( + RecycleBin recycleBin, + RecycleBinResult result, + string allowedRoot) + { + var notRecyclable = result.Items + .Where(item => item.Status == RecycleBinItemStatus.NotRecyclable) + .Select(item => item.Path) + .ToList(); + if (notRecyclable.Count == 0) + { + return []; + } + + var names = string.Join("\n", notRecyclable.Select(Path.GetFileName)); + var answer = await ConfirmAsync( + HakoStrings.Get("PermanentDeleteTitle"), + string.Format(HakoStrings.Get("PermanentDeleteMessage"), names), + HakoStrings.Get("PermanentDeleteConfirm"), + HakoStrings.Get("CancelButton"), + destructive: true); + if (answer != ContentDialogResult.Primary) + { + return notRecyclable; + } + + await Task.Run(() => recycleBin.PermanentDelete(notRecyclable, allowedRoot)); + return []; + } +} + +internal static class InstanceIconSource +{ + /// Reads icon.png off the UI thread so the image never holds the file (rename and delete stay possible). + public static Task ReadAsync(InstanceStore store, GameInstance instance) => Task.Run(() => + { + if (!string.IsNullOrEmpty(instance.IconSymbol)) + { + return null; + } + + try + { + var path = InstanceStorage.ContainedPath("icon.png", store.GetInstanceDirectory(instance.Id), allowBaseReparsePoint: true); + var info = new FileInfo(path); + return info.Exists && info.Length is > 0 and <= 4 * 1024 * 1024 ? File.ReadAllBytes(path) : null; + } + catch (Exception) + { + return null; + } + }); + + public static async Task CreateAsync(byte[]? data) + { + if (data is null) + { + return null; + } + + try + { + using var stream = new InMemoryRandomAccessStream(); + await stream.WriteAsync(data.AsBuffer()); + stream.Seek(0); + var bitmap = new BitmapImage(); + await bitmap.SetSourceAsync(stream); + return bitmap; + } + catch (Exception) + { + return null; + } + } +} + +internal static class InstanceText +{ + public static string LoaderTitle(GameInstance instance) => instance.ModLoader == InstanceModLoader.Fabric + ? $"Fabric {instance.FabricConfiguration?.LoaderVersion}" + : "Vanilla"; + + public static string Details(GameInstance instance) => + string.Format(HakoStrings.Get("InstanceDetailsFormat"), instance.VersionID, LoaderTitle(instance)); + + public static string Error(Exception exception) => HakoStrings.Get(InstallationResourceKeys.ErrorResourceKey(exception)); + + public static string InstallationError(GameInstance instance) => instance.ErrorCode is { } code + ? HakoStrings.Get(InstallationResourceKeys.ErrorResourceKey(code)) + : HakoStrings.Get("InstallationErrorUnexpected"); + + public static string Status(GameInstance instance, InstallationProgress? progress) + { + if (instance.State == InstanceInstallationState.Installing && progress is not null) + { + var stage = HakoStrings.Get(progress.Stage.ResourceKey()); + return progress.TotalBytes > 0 ? $"{stage} · {Percent(progress)}%" : stage; + } + + return HakoStrings.Get(instance.State.ResourceKey()); + } + + public static double? Fraction(InstallationProgress? progress) => progress is { TotalBytes: > 0 } value + ? Math.Clamp((double)value.CompletedBytes / value.TotalBytes, 0, 1) + : null; + + private static int Percent(InstallationProgress progress) => (int)Math.Floor((Fraction(progress) ?? 0) * 100); +} + +internal static class FieldHeader +{ + /// A header in the UI font for text boxes whose own text is monospaced. + public static TextBlock Create(string text) => new() + { + Text = text, + FontFamily = (FontFamily)Application.Current.Resources["ContentControlThemeFontFamily"] + }; +} diff --git a/windows/Hako/MainWindow.xaml b/windows/Hako/MainWindow.xaml index 92680b3..05614e1 100644 --- a/windows/Hako/MainWindow.xaml +++ b/windows/Hako/MainWindow.xaml @@ -9,7 +9,7 @@ - + @@ -26,6 +26,7 @@ IsSettingsVisible="False" PaneDisplayMode="LeftCompact" SelectionChanged="RootNavigation_SelectionChanged" + ItemInvoked="RootNavigation_ItemInvoked" Visibility="Collapsed"> @@ -33,12 +34,25 @@ + + + + + + + + - + + + + + + diff --git a/windows/Hako/MainWindow.xaml.cs b/windows/Hako/MainWindow.xaml.cs index 2f5442b..8e79093 100644 --- a/windows/Hako/MainWindow.xaml.cs +++ b/windows/Hako/MainWindow.xaml.cs @@ -1,7 +1,12 @@ +using System.Collections.Generic; +using System.Linq; using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; using System.Runtime.InteropServices; using Windows.Graphics; using WinRT.Interop; @@ -10,34 +15,48 @@ namespace Hako; public sealed partial class MainWindow : Window { + private readonly LauncherServices _services; private readonly AppCoordinator _coordinator; private readonly MinecraftSkinPreviewService _skinPreviewService; private readonly ProfilePreviewOptions? _previewOptions; + private readonly Dictionary _instanceItems = []; + private readonly Dictionary _instanceIconRevisions = []; + private readonly CoalescedRefresh _instancesRefresh; private bool _changingSelection; private bool _windowActive; private bool _windowVisible; private bool _closed; + private bool _closeConfirmed; + private bool _closeRequested; + private bool _navigating; + private Guid? _shownInstanceId; - internal MainWindow( - AppCoordinator coordinator, - MinecraftSkinPreviewService skinPreviewService, - ProfilePreviewOptions? previewOptions) + internal MainWindow(LauncherServices services) { InitializeComponent(); ExtendsContentIntoTitleBar = true; SetTitleBar(AppTitleBar); - _coordinator = coordinator; - _skinPreviewService = skinPreviewService; - _previewOptions = previewOptions; + _services = services; + _coordinator = services.App; + _skinPreviewService = services.SkinPreview; + _previewOptions = services.PreviewOptions; + _instancesRefresh = new CoalescedRefresh(DispatcherQueue, RefreshInstanceItems); SetInitialWindowSize(); _coordinator.PropertyChanged += Coordinator_PropertyChanged; + _services.Store.InstancesChanged += Store_InstancesChanged; + RootGrid.Loaded += (_, _) => + { + if (RootGrid.XamlRoot is { } root) HakoDialogs.AttachWindow(DispatcherQueue, root); + }; Activated += Window_Activated; VisibilityChanged += Window_VisibilityChanged; AppWindow.Changed += AppWindow_Changed; + AppWindow.Closing += AppWindow_Closing; Closed += Window_Closed; HakoStrings.SetLanguage(_coordinator.Language, true); UpdateNavigationLabels(); + RefreshInstanceItems(); ShowScreen(); UpdateSkinAvailability(); } @@ -79,29 +98,33 @@ private void Coordinator_PropertyChanged(object? sender, System.ComponentModel.P { HakoStrings.SetLanguage(_coordinator.Language); UpdateNavigationLabels(); + RefreshInstanceItems(); } if (e.PropertyName == nameof(AppCoordinator.Screen)) { ShowScreen(); } + else if (e.PropertyName == nameof(AppCoordinator.SelectedInstanceId) + && _coordinator.Screen == LauncherScreen.Instance) + { + ShowScreen(); + } else if (e.PropertyName == nameof(AppCoordinator.SelectedSettingsSection) && ShellFrame.Content is SettingsPage settingsPage) { settingsPage.SelectSection(_coordinator.SelectedSettingsSection); + UpdateSelection(); UpdateSkinAvailability(); } - else if (e.PropertyName == nameof(AppCoordinator.IsGuest) - && ShellFrame.Content is SettingsPage currentSettingsPage) + else if (e.PropertyName is nameof(AppCoordinator.IsGuest) or nameof(AppCoordinator.Account)) { - currentSettingsPage.RefreshProfile(); - UpdateSkinAvailability(); - } - else if (e.PropertyName == nameof(AppCoordinator.Account) - && ShellFrame.Content is SettingsPage accountSettingsPage) - { - accountSettingsPage.RefreshProfile(); - UpdateSkinAvailability(); + UpdateNavigationLabels(); + if (ShellFrame.Content is SettingsPage currentSettingsPage) + { + currentSettingsPage.RefreshProfile(); + UpdateSkinAvailability(); + } } else if (e.PropertyName == nameof(AppCoordinator.CurrentStartupNotice) && FlowFrame.Content is WelcomePage welcomePage) @@ -112,35 +135,54 @@ private void Coordinator_PropertyChanged(object? sender, System.ComponentModel.P private void ShowScreen() { - if (_coordinator.Screen is LauncherScreen.Builds or LauncherScreen.Settings) + var screen = _coordinator.Screen; + if (screen is LauncherScreen.Builds or LauncherScreen.Instance or LauncherScreen.InstanceCreation + or LauncherScreen.ModpackCatalog or LauncherScreen.ModpackCreation or LauncherScreen.Settings) { FlowFrame.Visibility = Visibility.Collapsed; RootNavigation.Visibility = Visibility.Visible; - _changingSelection = true; - RootNavigation.SelectedItem = _coordinator.Screen == LauncherScreen.Builds - ? BuildsNavigationItem - : SettingsNavigationItem; - _changingSelection = false; + UpdateSelection(); - if (_coordinator.Screen == LauncherScreen.Builds) + switch (screen) { - ShellFrame.Navigate(typeof(BuildsPage), _coordinator); + case LauncherScreen.Builds when ShellFrame.Content is not BuildsPage: + ShellFrame.Navigate(typeof(BuildsPage), _services); + break; + case LauncherScreen.Instance when _coordinator.SelectedInstanceId is { } id + && (ShellFrame.Content is not InstanceProfilePage || _shownInstanceId != id): + _shownInstanceId = id; + ShellFrame.Navigate(typeof(InstanceProfilePage), new InstanceProfileContext(_services, id)); + break; + case LauncherScreen.InstanceCreation when ShellFrame.Content is not InstanceCreationPage: + ShellFrame.Navigate(typeof(InstanceCreationPage), _services); + break; + case LauncherScreen.ModpackCatalog when ShellFrame.Content is not ModpackCatalogPage: + ShellFrame.Navigate(typeof(ModpackCatalogPage), _services); + break; + case LauncherScreen.ModpackCreation when ShellFrame.Content is not ModpackCreationPage: + ShellFrame.Navigate(typeof(ModpackCreationPage), new ModpackCreationContext(_services, _pendingModpack)); + break; + case LauncherScreen.Settings when ShellFrame.Content is not SettingsPage: + ShellFrame.Navigate( + typeof(SettingsPage), + new SettingsPageContext(_coordinator, _skinPreviewService, _previewOptions, _services.Settings, _services.Playtime)); + break; } - else + + if (screen != LauncherScreen.Instance) { - ShellFrame.Navigate( - typeof(SettingsPage), - new SettingsPageContext(_coordinator, _skinPreviewService, _previewOptions)); + _shownInstanceId = null; } UpdateSkinAvailability(); return; } + _shownInstanceId = null; RootNavigation.Visibility = Visibility.Collapsed; UpdateSkinAvailability(); FlowFrame.Visibility = Visibility.Visible; - var page = _coordinator.Screen switch + var page = screen switch { LauncherScreen.Login => typeof(LoginPage), LauncherScreen.Welcome => typeof(WelcomePage), @@ -149,9 +191,245 @@ private void ShowScreen() FlowFrame.Navigate(page, _coordinator); } + private void UpdateSelection() + { + _changingSelection = true; + RootNavigation.SelectedItem = _coordinator.Screen switch + { + LauncherScreen.Builds => BuildsNavigationItem, + LauncherScreen.Instance when _coordinator.SelectedInstanceId is { } id => _instanceItems.GetValueOrDefault(id), + LauncherScreen.InstanceCreation or LauncherScreen.ModpackCatalog or LauncherScreen.ModpackCreation => CreateNavigationItem, + LauncherScreen.Settings => _coordinator.SelectedSettingsSection == SettingsSection.Profile + ? AccountNavigationItem + : SettingsNavigationItem, + _ => null + }; + _changingSelection = false; + } + + private void Store_InstancesChanged(object? sender, EventArgs e) => _instancesRefresh.Request(); + + /// Updates rail items in place so selection and the shown page survive store notifications. + private void RefreshInstanceItems() + { + if (_closed) + { + return; + } + + var instances = _services.Store.Instances.OrderBy(instance => instance.CreatedAt).ThenBy(instance => instance.Id).ToList(); + var present = instances.Select(instance => instance.Id).ToHashSet(); + _changingSelection = true; + try + { + foreach (var removed in _instanceItems.Keys.Where(id => !present.Contains(id)).ToList()) + { + RootNavigation.MenuItems.Remove(_instanceItems[removed]); + _instanceItems.Remove(removed); + _instanceIconRevisions.Remove(removed); + } + + for (var index = 0; index < instances.Count; index++) + { + var instance = instances[index]; + if (!_instanceItems.TryGetValue(instance.Id, out var item)) + { + item = new NavigationViewItem { Tag = instance.Id }; + _instanceItems[instance.Id] = item; + } + + item.Content = instance.Name; + ToolTipService.SetToolTip(item, $"{instance.Name}\n{InstanceText.Details(instance)}"); + if (!_instanceIconRevisions.TryGetValue(instance.Id, out var revision) || revision != instance.IconRevision + || item.Icon is null) + { + _instanceIconRevisions[instance.Id] = instance.IconRevision; + item.Icon = new FontIcon { Glyph = InstanceIconGlyphs.Glyph(instance.IconSymbol), FontFamily = new FontFamily("Segoe Fluent Icons") }; + _ = LoadNavigationIconAsync(item, instance); + } + + // Builds follow the "Сборки" item and its separator. + var target = index + 2; + var current = RootNavigation.MenuItems.IndexOf(item); + if (current != target) + { + if (current >= 0) + { + RootNavigation.MenuItems.RemoveAt(current); + } + + RootNavigation.MenuItems.Insert(target, item); + } + } + + BuildsSeparator.Visibility = instances.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + CreateNavigationItem.IsEnabled = _services.Store.LoadError is null; + } + finally + { + _changingSelection = false; + } + + if (_coordinator.SelectedInstanceId is { } selected && !present.Contains(selected)) + { + _coordinator.CloseInstance(selected); + } + + UpdateSelection(); + } + + private async Task LoadNavigationIconAsync(NavigationViewItem item, GameInstance instance) + { + var data = await InstanceIconSource.ReadAsync(_services.Store, instance); + if (data is null || await InstanceIconSource.CreateAsync(data) is not { } image) + { + return; + } + + if (_instanceIconRevisions.GetValueOrDefault(instance.Id) == instance.IconRevision) + { + item.Icon = new ImageIcon { Source = image }; + } + } + + private void RootNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if (_changingSelection || args.SelectedItemContainer?.Tag is not { } tag) + { + return; + } + + Action? action = tag switch + { + "Builds" => _coordinator.OpenBuilds, + "Settings" => () => _coordinator.OpenSettings(), + "Account" => () => _coordinator.OpenSettings(SettingsSection.Profile), + Guid id => () => _coordinator.OpenInstance(id), + _ => null + }; + if (action is not null) + { + _ = NavigateAsync(action); + } + } + + private void RootNavigation_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) + { + if (_changingSelection) return; + if (args.InvokedItemContainer == CreateNavigationItem) + { + _ = NavigateAsync(null, chooseCreation: true); + } + } + + /// Asks the current page about unsaved input before leaving it; restores the rail selection when it stays. + private async Task NavigateAsync(Action? action, bool chooseCreation = false) + { + if (_navigating) + { + UpdateSelection(); + return; + } + + _navigating = true; + try + { + UpdateSelection(); + if (chooseCreation && ShellFrame.Content is InstanceCreationPage) + { + return; + } + + if (ShellFrame.Content is INavigationGuard guard && !await guard.ConfirmLeaveAsync()) + { + return; + } + + if (chooseCreation) + { + var choice = await InstanceCreationChoiceDialog.ShowAsync(); + if (choice == InstanceCreationChoice.Custom) + { + _coordinator.OpenInstanceCreation(); + } + else if (choice == InstanceCreationChoice.Modpack) + { + _coordinator.OpenModpackCatalog(); + } + + return; + } + + action?.Invoke(); + } + finally + { + _navigating = false; + UpdateSelection(); + } + } + + /// Opens the build creation flow from a page (the empty state of Сборки) with the same guard as the rail. + internal static void RequestCreation(Window? window) + { + if (window is MainWindow main) + { + _ = main.NavigateAsync(null, chooseCreation: true); + } + } + + private ModpackSelection? _pendingModpack; + + internal static void OpenModpackCreation(Window? window, ModrinthProject project, ModrinthVersion version, string? minecraftFilter) + { + if (window is MainWindow main) + { + main._pendingModpack = new ModpackSelection(project, version, minecraftFilter); + main._coordinator.OpenModpackCreation(); + } + } + + private async void AppWindow_Closing(AppWindow sender, AppWindowClosingEventArgs args) + { + if (_closeConfirmed) + { + return; + } + + args.Cancel = true; + if (_closeRequested) + { + return; + } + + _closeRequested = true; + HakoDialogs.BeginWindowClose(); + try + { + if (RootNavigation.Visibility == Visibility.Visible + && ShellFrame.Content is INavigationGuard guard + && !await guard.ConfirmLeaveAsync()) + { + return; + } + + _closeConfirmed = true; + await App.ShutdownServicesAsync(); + Close(); + } + finally + { + _closeRequested = false; + if (!_closeConfirmed) HakoDialogs.EndWindowClose(); + } + } + private void Window_Activated(object sender, WindowActivatedEventArgs e) { + var wasActive = _windowActive; _windowActive = e.WindowActivationState != WindowActivationState.Deactivated; + if (!wasActive && _windowActive && ShellFrame.Content is InstanceProfilePage profilePage) + profilePage.WindowReturned(); UpdateSkinAvailability(); } @@ -173,10 +451,14 @@ private void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs e) private void Window_Closed(object sender, WindowEventArgs e) { _closed = true; + HakoDialogs.CloseWindow(); _windowActive = false; _windowVisible = false; + _instancesRefresh.Stop(); _coordinator.PropertyChanged -= Coordinator_PropertyChanged; + _services.Store.InstancesChanged -= Store_InstancesChanged; AppWindow.Changed -= AppWindow_Changed; + AppWindow.Closing -= AppWindow_Closing; _coordinator.CancelSignIn(); UpdateSkinAvailability(); _skinPreviewService.Dispose(); @@ -198,27 +480,15 @@ private void UpdateSkinAvailability() settingsPage.SetWindowState(windowVisible, windowVisible && _windowActive); } - private void RootNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) - { - if (_changingSelection || args.SelectedItemContainer?.Tag is not string tag) - { - return; - } - - if (tag == "Builds") - { - _coordinator.OpenBuilds(); - } - else if (tag == "Settings") - { - _coordinator.OpenSettings(); - } - } - private void UpdateNavigationLabels() { BuildsNavigationItem.Content = HakoStrings.Get("BuildsTitle"); SettingsNavigationItem.Content = HakoStrings.Get("SettingsTitle"); + CreateNavigationItem.Content = HakoStrings.Get("CreateBuildButton"); + ToolTipService.SetToolTip(CreateNavigationItem, HakoStrings.Get("CreateBuildButton")); + AccountNavigationItem.Content = _coordinator.Account?.MinecraftName + ?? _coordinator.Account?.Gamertag + ?? HakoStrings.Get(_coordinator.IsGuest ? "GuestProfileTitle" : "ProfileTitle"); } [DllImport("user32.dll")] diff --git a/windows/Hako/ModpackCatalogPage.xaml b/windows/Hako/ModpackCatalogPage.xaml new file mode 100644 index 0000000..cd4e18f --- /dev/null +++ b/windows/Hako/ModpackCatalogPage.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + +