From 7eed2d934d485378eb5bc6a21d616961b9d1899e Mon Sep 17 00:00:00 2001 From: Li_is Date: Thu, 22 Oct 2026 03:51:25 +0400 Subject: [PATCH 01/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B1=D0=BE=D1=80=D0=BE=D0=BA=20Windows:=20.hak?= =?UTF-8?q?o,=20instances.json,=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BE=D1=80=D0=BD=D1=8F,=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D1=8B=20=D0=B8=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Obsidian/HakoVault/Architecture.md | 34 +- Obsidian/HakoVault/Data/Persistence.md | 80 +- windows/Hako.Core/AppCoordinator.cs | 32 +- windows/Hako.Core/GameLaunchDefaults.cs | 84 ++ windows/Hako.Core/Hako.Core.csproj | 1 + windows/Hako.Core/HakoPaths.cs | 148 +++ windows/Hako.Core/HakoRootLock.cs | 100 ++ windows/Hako.Core/Instances/GameInstance.cs | 128 ++ .../Instances/InstanceJsonConverters.cs | 19 + .../Hako.Core/Instances/InstanceStorage.cs | 242 ++++ windows/Hako.Core/Instances/InstanceStore.cs | 1108 +++++++++++++++++ windows/Hako.Core/JsonContexts.cs | 2 + windows/Hako.Core/Properties/AssemblyInfo.cs | 3 + windows/Hako.Core/RecycleBin.cs | 411 ++++++ windows/Hako.Core/UserSettings.cs | 10 +- windows/Hako.Core/UserSettingsService.cs | 212 ++++ windows/Hako.Tests/AppCoordinatorTests.cs | 47 + windows/Hako.Tests/InstanceStoreTests.cs | 877 +++++++++++++ windows/Hako.Tests/RecycleBinTests.cs | 324 +++++ .../Hako.Tests/UserSettingsServiceTests.cs | 247 ++++ windows/Hako/App.xaml.cs | 120 +- windows/Hako/Program.cs | 79 +- windows/Hako/Strings/en-US/Resources.resw | 5 + windows/Hako/Strings/ru-RU/Resources.resw | 5 + windows/Hako/app.manifest | 3 +- 25 files changed, 4269 insertions(+), 52 deletions(-) create mode 100644 windows/Hako.Core/GameLaunchDefaults.cs create mode 100644 windows/Hako.Core/HakoPaths.cs create mode 100644 windows/Hako.Core/HakoRootLock.cs create mode 100644 windows/Hako.Core/Instances/GameInstance.cs create mode 100644 windows/Hako.Core/Instances/InstanceJsonConverters.cs create mode 100644 windows/Hako.Core/Instances/InstanceStorage.cs create mode 100644 windows/Hako.Core/Instances/InstanceStore.cs create mode 100644 windows/Hako.Core/Properties/AssemblyInfo.cs create mode 100644 windows/Hako.Core/RecycleBin.cs create mode 100644 windows/Hako.Core/UserSettingsService.cs create mode 100644 windows/Hako.Tests/InstanceStoreTests.cs create mode 100644 windows/Hako.Tests/RecycleBinTests.cs create mode 100644 windows/Hako.Tests/UserSettingsServiceTests.cs diff --git a/Obsidian/HakoVault/Architecture.md b/Obsidian/HakoVault/Architecture.md index 0e25359..9e89cd1 100644 --- a/Obsidian/HakoVault/Architecture.md +++ b/Obsidian/HakoVault/Architecture.md @@ -270,30 +270,44 @@ Windows — 10 1809 (`TargetPlatformMinVersion` 10.0.17763.0); `app.manifest` о `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`. + `AuthContracts`, `AccountSessionStore`, `UserSettingsService`, `HakoPaths`, `HakoRootLock`, `Instances/` + (`GameInstance`, `InstanceStorage`, `InstanceStore`), source-generated COM-корзина `RecycleBin`, `JsonContexts`. - `windows/Hako.Tests/` — `AppCoordinatorTests`, `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`. До `AppInstance` он разбирает `--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`, `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` сначала выполняет `InstanceStore.LoadAsync()` и только затем показывает `MainWindow`. + `await InitializeAsync()` выбирает `Welcome` или `Builds`; ошибка реестра остаётся в `InstanceStore.LoadError` + для UI. В preview-режиме `HakoPaths` указывает на уникальный временный корень, создаются fake in-memory store + и auth-сервис, который запрещает sign-in; production-каталог данных не читается, временный корень удаляется при выходе. + Режим `--profile-preview guest` после гостевого перехода открывает «Настройки → Профиль»; обычный гостевой вход по-прежнему начинает на странице сборок. @@ -324,7 +338,7 @@ dotnet test windows/Hako.Tests/Hako.Tests.csproj -c Debug ### Ограничения Windows-клиента -- Не реализованы: сборки (вкладка «Сборки» показывает пустое состояние), запуск Minecraft, моды и ресурспаки, +- Не реализованы: UI и установка сборок (вкладка «Сборки» показывает пустое состояние), запуск Minecraft, моды и ресурспаки, каталоги Modrinth и CurseForge, самообновление. - Обновление токенов не реализовано: `MicrosoftRefreshToken` сохраняется, refresh-grant в коде нет. - `MinecraftAccessToken` сохраняется и нигде не читается. Игровой сессии на Windows пока нет. diff --git a/Obsidian/HakoVault/Data/Persistence.md b/Obsidian/HakoVault/Data/Persistence.md index c25bacd..888eb78 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,16 @@ 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/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/RedactionTests.cs` | — | `ToString()` записей `MicrosoftToken` и `AccountSession` без токенов | ### Аккаунт: `session.dat` @@ -278,16 +286,70 @@ 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, совместимость текстурпаков и архитектура. +- `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`; состояния очереди/установки также блокируют эти операции. +- `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; при выходе удаляется только каталог, эксклюзивно + созданный этим процессом. ### Каталог данных diff --git a/windows/Hako.Core/AppCoordinator.cs b/windows/Hako.Core/AppCoordinator.cs index ab50c7b..e151963 100644 --- a/windows/Hako.Core/AppCoordinator.cs +++ b/windows/Hako.Core/AppCoordinator.cs @@ -38,11 +38,10 @@ 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 LauncherScreen _screen = LauncherScreen.Loading; private AppLanguage _language = AppLanguages.Default; private AppLanguage _savedLanguage = AppLanguages.Default; @@ -60,8 +59,13 @@ public sealed class AppCoordinator : INotifyPropertyChanged private long _languageSelection; public AppCoordinator(IUserSettingsStore settingsStore, IAccountStore accountStore, IMicrosoftAuthService authService) + : this(new UserSettingsService(settingsStore), accountStore, authService) { - _settingsStore = settingsStore; + } + + public AppCoordinator(UserSettingsService settingsService, IAccountStore accountStore, IMicrosoftAuthService authService) + { + _settingsService = settingsService; _accountStore = accountStore; _authService = authService; } @@ -127,7 +131,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) UserSettings? settings = null; try { - settings = await _settingsStore.LoadAsync(cancellationToken); + settings = await _settingsService.LoadAsync(cancellationToken); } catch (OperationCanceledException) { @@ -166,10 +170,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 +185,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) diff --git a/windows/Hako.Core/GameLaunchDefaults.cs b/windows/Hako.Core/GameLaunchDefaults.cs new file mode 100644 index 0000000..3776659 --- /dev/null +++ b/windows/Hako.Core/GameLaunchDefaults.cs @@ -0,0 +1,84 @@ +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)); + } + + 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/GameInstance.cs b/windows/Hako.Core/Instances/GameInstance.cs new file mode 100644 index 0000000..6bf2bf0 --- /dev/null +++ b/windows/Hako.Core/Instances/GameInstance.cs @@ -0,0 +1,128 @@ +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; } + [JsonRequired] + public string JavaExecutable { get; init; } = "java/bin/java.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? 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/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/InstanceStorage.cs b/windows/Hako.Core/Instances/InstanceStorage.cs new file mode 100644 index 0000000..82d219e --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceStorage.cs @@ -0,0 +1,242 @@ +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, + 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 MoveDirectory(string from, string to) => System.IO.Directory.Move(from, to); + + internal static bool IsSinglePathSegment(string value) => !string.IsNullOrWhiteSpace(value) + && value is not "." and not ".." + && !value.Contains('\\') && !value.Contains('/') && !value.Contains(':') && !value.Contains('\0') + && !Path.IsPathRooted(value); + + 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..ee46e3a --- /dev/null +++ b/windows/Hako.Core/Instances/InstanceStore.cs @@ -0,0 +1,1108 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using System.Text.Json; +using Hako.Core; + +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 +{ + 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 bool _isLoaded; + private readonly HashSet _launchBusy = []; + private readonly HashSet _contentBusy = []; + + 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 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; + + public bool IsLaunchBusy(Guid id) + { + _gate.Wait(); + try + { + return _launchBusy.Contains(id); + } + finally + { + _gate.Release(); + } + } + + public bool IsContentBusy(Guid id) + { + _gate.Wait(); + try + { + return _contentBusy.Contains(id); + } + finally + { + _gate.Release(); + } + } + + public bool TryBeginLaunch(Guid id, out IDisposable? lease, out InstanceStorageError? reason) + { + bool changed; + _gate.Wait(); + try + { + var instance = Find(GetRegistrySnapshot(), id); + reason = GetManagementBlock(instance); + if (reason is not null) + { + lease = null; + return false; + } + + changed = _launchBusy.Add(id); + lease = new BusyLease(() => EndBusy(id, _launchBusy)); + } + 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 + { + var instance = Find(GetRegistrySnapshot(), id); + reason = GetManagementBlock(instance); + if (reason is not null) + { + lease = null; + return false; + } + + changed = _contentBusy.Add(id); + lease = new BusyLease(() => EndBusy(id, _contentBusy)); + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + + reason = null; + return true; + } + + private void EndBusy(Guid id, HashSet busy) + { + _gate.Wait(); + bool changed; + try + { + changed = busy.Remove(id); + } + 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) + { + _loadError = exception; + _isLoaded = true; + } + } + + NotifyInstancesChanged(); + } + finally + { + _gate.Release(); + } + + if (cleanupStaging) + { + ScheduleDeleteStagingCleanup(() => + { + _beforeDeleteStagingCleanup?.Invoke(); + CleanupDeleteStaging(); + }); + } + } + + private void ScheduleDeleteStagingCleanup(Action cleanup) + { + lock (_deleteStagingTasksSync) + { + 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) + { + ArgumentNullException.ThrowIfNull(draft); + _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 = draft.JavaMajorVersion, + LegacyTexturepacks = legacyTexturepacks || draft.LegacyTexturepacks, + ModLoader = draft.ModLoader, + FabricConfiguration = draft.ModLoader == InstanceModLoader.Fabric ? draft.FabricConfiguration : null, + 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."); + } + + 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(); + } + } + + 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); + 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) + { + _gate.Wait(); + try + { + return GetManagementBlock(instance) is { } error ? ManagementMessage(error) : null; + } + finally + { + _gate.Release(); + } + } + + 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 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 (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 (_launchBusy.Contains(instance.Id)) + { + return InstanceStorageError.BuildRunning; + } + + if (_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/JsonContexts.cs b/windows/Hako.Core/JsonContexts.cs index f1b156c..dac70c8 100644 --- a/windows/Hako.Core/JsonContexts.cs +++ b/windows/Hako.Core/JsonContexts.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Hako.Core.Instances; namespace Hako.Core; @@ -7,6 +8,7 @@ namespace Hako.Core; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(UserSettings))] +[JsonSerializable(typeof(InstanceRegistryFile))] [JsonSerializable(typeof(AccountSession))] [JsonSerializable(typeof(DeviceCodeResponse))] [JsonSerializable(typeof(MicrosoftTokenResponse))] diff --git a/windows/Hako.Core/Properties/AssemblyInfo.cs b/windows/Hako.Core/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c5cf5a8 --- /dev/null +++ b/windows/Hako.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Hako.Tests")] 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..3c6c09e 100644 --- a/windows/Hako.Tests/AppCoordinatorTests.cs +++ b/windows/Hako.Tests/AppCoordinatorTests.cs @@ -243,4 +243,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/InstanceStoreTests.cs b/windows/Hako.Tests/InstanceStoreTests.cs new file mode 100644 index 0000000..a9499ae --- /dev/null +++ b/windows/Hako.Tests/InstanceStoreTests.cs @@ -0,0 +1,877 @@ +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() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + + 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() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + Directory.CreateDirectory(Path.Combine(temporary.Paths.GameRoot, "Loose_Files")); + var store = await LoadStoreAsync(temporary.Paths); + + Assert.ThrowsExactly(() => store.ValidateName("loose files")); + } + + [TestMethod] + public async Task RegistryIsCamelCaseAndContainsFullProfileState() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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, "\"state\": \"queued\""); + StringAssert.Contains(json, "\"pauseRequested\": false"); + StringAssert.Contains(json, "\"fabricConfiguration\":"); + Assert.AreEqual(instance, store.List().Single()); + } + + [TestMethod] + public async Task FailedAtomicWriteLeavesPreviousRegistryAndRemovesTemporaryFile() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => 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() + { + using var temporary = new TemporaryDirectory(); + Directory.CreateDirectory(temporary.Paths.GameRoot); + var registryPath = Path.Combine(temporary.Paths.GameRoot, "instances.json"); + var store = await LoadStoreAsync(temporary.Paths); + 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() + { + 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 = new InstanceStore(temporary.Paths); + + 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() + { + 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 = new InstanceStore(temporary.Paths); + 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() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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(created, store.List().Single()); + Assert.AreEqual(InstanceRegistryError.Corrupt, store.LoadError?.Error); + } + + [TestMethod] + public async Task CreateFailureRemovesOnlyItsNewFolder() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths, () => 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + var instance = initial.Create(new InstanceDraft { Name = "First Build" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var failing = await LoadStoreAsync(temporary.Paths, () => 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => + { + 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + var instance = initial.Create(new InstanceDraft { Name = "Delete Me" }, "1.20.4"); + await SetStateAsync(temporary.Paths, initial, InstanceInstallationState.Ready); + var failing = await LoadStoreAsync(temporary.Paths, () => 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 DeleteCleanupRemovesNestedJunctionWithoutTouchingItsTarget() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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); + var store = new InstanceStore(temporary.Paths, 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)); + } + finally + { + continueCleanup.TrySetResult(); + } + + await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.IsFalse(Directory.Exists(staging)); + } + + [TestMethod] + public async Task LoadCleansOnlyExactLegacyAndNewDeleteStagingNames() + { + 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 = new InstanceStore(temporary.Paths); + 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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 = new InstanceStore(temporary.Paths); + 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() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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)); + Assert.AreEqual(InstanceStorageError.BuildInstalling, queuedRename.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, queuedUpdate.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)); + Assert.AreEqual(InstanceStorageError.BuildInstalling, installingRename.Error); + Assert.AreEqual(InstanceStorageError.BuildInstalling, installingUpdate.Error); + } + + [TestMethod] + public async Task TryBeginLaunchAndDeleteAreSerializedByTheStoreGate() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => + { + 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() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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 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() + { + 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 = await LoadStoreAsync(paths); + Assert.AreEqual(0, store.List().Count); + } + finally + { + Directory.Delete(Path.Combine(target, "nested-link")); + Directory.Delete(rootJunction); + } + } + + [TestMethod] + public async Task KilledLockHolderReleasesGameRootForTheNextProcess() + { + 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 var acquired = HakoRootLock.Acquire(temporary.Paths); + } + 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => 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() + { + using var temporary = new TemporaryDirectory(); + var initial = await LoadStoreAsync(temporary.Paths); + 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); + 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.Paths, () => 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() + { + using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary.Paths); + 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 + { + 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 void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + + private sealed class SyntheticIOException : IOException + { + public SyntheticIOException(int error) + { + HResult = unchecked((int)0x80070000) | error; + } + } + + private static async Task LoadStoreAsync( + HakoPaths paths, + Action? beforeRegistryCommit = null, + Action? beforeDeleteStagingCleanup = null) + { + var store = new InstanceStore(paths, 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/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/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/App.xaml.cs b/windows/Hako/App.xaml.cs index ad153cc..1657edd 100644 --- a/windows/Hako/App.xaml.cs +++ b/windows/Hako/App.xaml.cs @@ -1,46 +1,108 @@ using Hako.Core; +using Hako.Core.Instances; 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 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(); 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()); } else { - var dataDirectory = AppDataDirectory.GetLocalPath(); + var settingsService = new UserSettingsService(new JsonUserSettingsStore(LaunchPaths!.DataRoot)); + UserSettingsService = settingsService; _coordinator = new AppCoordinator( - new JsonUserSettingsStore(dataDirectory), - new AccountSessionStore(dataDirectory), + settingsService, + new AccountSessionStore(LaunchPaths!.DataRoot), new MicrosoftDeviceAuth(new HttpClient(), TimeProvider.System)); } + } 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(); + + _window = new MainWindow(_coordinator!, _skinPreviewService!, _previewOptions); _window.Activate(); - await _coordinator.InitializeAsync(); + await _coordinator!.InitializeAsync(); if (_previewOptions is not null) { if (_previewOptions.Mode == ProfilePreviewMode.Guest) @@ -70,4 +132,46 @@ 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) + { + } + + 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/Program.cs b/windows/Hako/Program.cs index ae9dc10..83602cd 100644 --- a/windows/Hako/Program.cs +++ b/windows/Hako/Program.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Hako.Core; using System.Diagnostics; using System.Runtime.InteropServices; using WinRT; @@ -14,13 +15,42 @@ private static void Main(string[] args) { ComWrappersSupport.InitializeComWrappers(); + HakoPathOverrides overrides; + try + { + overrides = HakoPaths.ParseOverrides(args); + App.LaunchOptions = ProfilePreviewOptions.Parse(args); + if (overrides.Isolated && App.LaunchOptions is not null) + { + throw new ArgumentException("--profile-preview cannot be combined with isolated storage arguments."); + } + + App.LaunchPaths = App.LaunchOptions is not null + ? CreatePreviewPaths() + : overrides.Isolated + ? HakoPaths.CreateIsolated(overrides.GameRoot!, overrides.DataRoot!) + : null; + } + catch (ArgumentException) + { + ShowStartupError("StartupInvalidArguments"); + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.ComponentModel.Win32Exception) + { + ShowStartupError("StartupStorageUnavailable"); + return; + } + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); - App.LaunchOptions = ProfilePreviewOptions.Parse(args); - var instanceKey = App.LaunchOptions is null ? "Hako.Main" : "Hako.ProfilePreview"; + var instanceKey = App.LaunchOptions is not null + ? "Hako.ProfilePreview" + : App.LaunchPaths?.GetInstanceKey() ?? "Hako.Main"; var mainInstance = AppInstance.FindOrRegisterForKey(instanceKey); if (!mainInstance.IsCurrent) { RedirectActivationTo(mainInstance, activationArgs); + App.CleanupPreviewRoot(); return; } @@ -33,6 +63,44 @@ private static void Main(string[] args) }); } + private static HakoPaths CreatePreviewPaths() + { + var parent = Path.Combine(Path.GetTempPath(), "Hako", "ProfilePreview"); + Directory.CreateDirectory(parent); + while (true) + { + var root = Path.Combine(parent, Guid.NewGuid().ToString("N")); + if (CreateDirectoryExclusive(root)) + { + App.PreviewRoot = root; + AppDomain.CurrentDomain.ProcessExit += (_, _) => App.CleanupPreviewRoot(); + return HakoPaths.CreatePreview(root); + } + } + } + + private static bool CreateDirectoryExclusive(string path) + { + if (CreateDirectoryW(path, IntPtr.Zero)) + { + return true; + } + + var error = Marshal.GetLastWin32Error(); + if (error == 183) + { + return false; + } + + throw new System.ComponentModel.Win32Exception(error); + } + + private static void ShowStartupError(string resourceKey) + { + HakoStrings.SetLanguage(AppLanguages.Default, true); + MessageBox(IntPtr.Zero, HakoStrings.Get(resourceKey), "Hako", 0x10); + } + // Redirect runs on a worker thread. The STA thread waits in CoWaitForMultipleObjects so it keeps pumping // messages; a blocking Wait() here can deadlock while the target instance handles the activation. private static void RedirectActivationTo(AppInstance instance, AppActivationArguments args) @@ -71,9 +139,16 @@ private static void RedirectActivationTo(AppInstance instance, AppActivationArgu [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int MessageBox(IntPtr window, string text, string caption, uint type); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern IntPtr CreateEvent(IntPtr attributes, bool manualReset, bool initialState, string? name); + [DllImport("kernel32.dll", EntryPoint = "CreateDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateDirectoryW(string path, IntPtr securityAttributes); + [DllImport("kernel32.dll")] private static extern bool SetEvent(IntPtr handle); diff --git a/windows/Hako/Strings/en-US/Resources.resw b/windows/Hako/Strings/en-US/Resources.resw index b418235..e46363c 100644 --- a/windows/Hako/Strings/en-US/Resources.resw +++ b/windows/Hako/Strings/en-US/Resources.resw @@ -64,4 +64,9 @@ Hako Error Close The selected language could not be saved. + Hako is already open and using these folders. Close the other instance and try again. + Hako was started with invalid command-line arguments. Check the options and try again. + Hako couldn't access its data folders. Check permissions and try again. + The instance registry is damaged or uses an unsupported version. The file was preserved, and instance changes are disabled. + The instance registry could not be read. Instance changes are disabled. diff --git a/windows/Hako/Strings/ru-RU/Resources.resw b/windows/Hako/Strings/ru-RU/Resources.resw index 570528b..cf2c4bc 100644 --- a/windows/Hako/Strings/ru-RU/Resources.resw +++ b/windows/Hako/Strings/ru-RU/Resources.resw @@ -64,4 +64,9 @@ Ошибка Закрыть Не удалось сохранить выбранный язык. + Hako уже открыт и использует эти папки. Закройте другой экземпляр и попробуйте снова. + Некорректные параметры запуска Hako. Проверьте аргументы и попробуйте снова. + Hako не удалось получить доступ к папкам данных. Проверьте разрешения и попробуйте снова. + Реестр сборок повреждён или имеет неподдерживаемую версию. Файл сохранён, изменения сборок отключены. + Не удалось прочитать реестр сборок. Изменения сборок отключены. diff --git a/windows/Hako/app.manifest b/windows/Hako/app.manifest index 5234b3d..7b3b788 100644 --- a/windows/Hako/app.manifest +++ b/windows/Hako/app.manifest @@ -13,7 +13,8 @@ + true PerMonitorV2 - \ No newline at end of file + From 64bdcb86dfcc98dd29bd5b9de74d32b97e38ce28 Mon Sep 17 00:00:00 2001 From: Li_is Date: Thu, 22 Oct 2026 04:44:47 +0400 Subject: [PATCH 02/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D1=83=20Minecraft=20=D0=B8=20Java=20=D0=B2=20Windows-=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B5:=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=20Mojang,=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=8B=20=D1=81=20x64=20=D0=BD=D0=B0=20ARM64,=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Obsidian/HakoVault/Minecraft/Installation.md | 53 + windows/Hako.Core/Instances/GameInstance.cs | 2 +- .../Hako.Core/Minecraft/HostArchitecture.cs | 124 ++ .../Hako.Core/Minecraft/MinecraftInstaller.cs | 926 ++++++++++ windows/Hako.Core/Minecraft/MojangClient.cs | 579 ++++++ windows/Hako.Core/Minecraft/MojangModels.cs | 418 +++++ windows/Hako.Tests/Hako.Tests.csproj | 4 + windows/Hako.Tests/HostArchitectureTests.cs | 67 + .../Hako.Tests/MinecraftInstallationTests.cs | 1568 +++++++++++++++++ 9 files changed, 3740 insertions(+), 1 deletion(-) create mode 100644 windows/Hako.Core/Minecraft/HostArchitecture.cs create mode 100644 windows/Hako.Core/Minecraft/MinecraftInstaller.cs create mode 100644 windows/Hako.Core/Minecraft/MojangClient.cs create mode 100644 windows/Hako.Core/Minecraft/MojangModels.cs create mode 100644 windows/Hako.Tests/HostArchitectureTests.cs create mode 100644 windows/Hako.Tests/MinecraftInstallationTests.cs diff --git a/Obsidian/HakoVault/Minecraft/Installation.md b/Obsidian/HakoVault/Minecraft/Installation.md index 42f7623..8ed9db6 100644 --- a/Obsidian/HakoVault/Minecraft/Installation.md +++ b/Obsidian/HakoVault/Minecraft/Installation.md @@ -48,6 +48,59 @@ 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 без частично опубликованных файлов. + +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. diff --git a/windows/Hako.Core/Instances/GameInstance.cs b/windows/Hako.Core/Instances/GameInstance.cs index 6bf2bf0..e967ee8 100644 --- a/windows/Hako.Core/Instances/GameInstance.cs +++ b/windows/Hako.Core/Instances/GameInstance.cs @@ -89,7 +89,7 @@ public sealed record GameInstance public bool PauseRequested { get; init; } public string? ErrorMessage { get; init; } [JsonRequired] - public string JavaExecutable { get; init; } = "java/bin/java.exe"; + public string JavaExecutable { get; init; } = "bin/javaw.exe"; [JsonRequired] public int JavaMajorVersion { get; init; } [JsonRequired] 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/MinecraftInstaller.cs b/windows/Hako.Core/Minecraft/MinecraftInstaller.cs new file mode 100644 index 0000000..395bddf --- /dev/null +++ b/windows/Hako.Core/Minecraft/MinecraftInstaller.cs @@ -0,0 +1,926 @@ +using System.IO.Compression; +using System.Runtime.ExceptionServices; +using System.Security.Cryptography; +using Hako.Core.Instances; + +namespace Hako.Core.Minecraft; + +public enum InstallationProgressStage +{ + PreparingFiles, + Downloading, + PreparingLibraries, + PreparingAssets, + Complete +} + +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); + +public sealed class MinecraftInstaller(MojangClient client) +{ + 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 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, [], progress, cancellationToken), cancellationToken); + + public Task InstallAsync( + PreparedInstallation prepared, + string instanceRoot, + IReadOnlyCollection additionalLibraries, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + Task.Run(() => InstallCoreAsync(prepared, instanceRoot, additionalLibraries, progress, cancellationToken), cancellationToken); + + private async Task InstallCoreAsync( + PreparedInstallation prepared, + string instanceRoot, + IReadOnlyCollection additionalLibraries, + 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); + + var manifest = prepared.Manifest; + 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"); + foreach (var library in prepared.Libraries.Concat(additionalLibraries)) + { + 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); + 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); + 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); + } + + 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.Invalid, $"The downloaded file is larger than declared: {job.RelativePath}."); + } + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + 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.Invalid, $"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.Invalid, $"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.Invalid, "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.Invalid, "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"); + + private static void CleanStaleTemporaryFiles(string root) + { + // 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" + }) + { + CleanStaleTemporaryEntries(root, Path.Combine(root, relativeDirectory), recurse: true); + } + + CleanStaleTemporaryEntries(root, Path.Combine(root, "minecraft"), recurse: false); + } + + private static void CleanStaleTemporaryEntries(string root, string directory, bool recurse) + { + var pending = new Stack(); + pending.Push(directory); + while (pending.TryPop(out var currentDirectory)) + { + try + { + var safeDirectory = InstanceStorage.ContainedPath(Path.GetRelativePath(root, currentDirectory), root); + if (!Directory.Exists(safeDirectory)) + { + continue; + } + + foreach (var entry in Directory.EnumerateFileSystemEntries(safeDirectory)) + { + 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/MojangClient.cs b/windows/Hako.Core/Minecraft/MojangClient.cs new file mode 100644 index 0000000..81700fa --- /dev/null +++ b/windows/Hako.Core/Minecraft/MojangClient.cs @@ -0,0 +1,579 @@ +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 Invalid("A Mojang response is larger than its declared size."); + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + 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(stream, chunk.AsMemory(0, readLength), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (buffer.Length + read > MaximumMetadataBytes + || expectedSize is { } expectedSizeLimit && buffer.Length + read > expectedSizeLimit) + { + throw Invalid(expectedSize is null + ? "Mojang metadata exceeds the supported size limit." + : "A Mojang response is larger than its declared size."); + } + + 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(); + } + } + + 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 Invalid("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..c01b3df --- /dev/null +++ b/windows/Hako.Core/Minecraft/MojangModels.cs @@ -0,0 +1,418 @@ +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 +} + +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.Tests/Hako.Tests.csproj b/windows/Hako.Tests/Hako.Tests.csproj index 3a77727..73b5644 100644 --- a/windows/Hako.Tests/Hako.Tests.csproj +++ b/windows/Hako.Tests/Hako.Tests.csproj @@ -32,6 +32,10 @@ + + + + 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/MinecraftInstallationTests.cs b/windows/Hako.Tests/MinecraftInstallationTests.cs new file mode 100644 index 0000000..c8182db --- /dev/null +++ b/windows/Hako.Tests/MinecraftInstallationTests.cs @@ -0,0 +1,1568 @@ +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.Invalid, 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 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) + { + 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), + 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) : 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 (!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 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); + } + } + } +} From b18bc8a876b7da7e9e453b0d3e7303665fa134ed Mon Sep 17 00:00:00 2001 From: Li_is Date: Thu, 22 Oct 2026 08:22:03 +0400 Subject: [PATCH 03/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BE=D0=BA=20=D0=B2=20Windows-=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=D0=B5:=20=D0=BF=D0=B0=D1=83=D0=B7?= =?UTF-8?q?=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Obsidian/HakoVault/Minecraft/Installation.md | 55 + windows/Hako.Core/Instances/GameInstance.cs | 1 + .../Instances/InstallationCoordinator.cs | 733 ++++++++++ .../Hako.Core/Instances/InstanceStorage.cs | 2 + windows/Hako.Core/Instances/InstanceStore.cs | 266 +++- .../Hako.Core/Minecraft/MinecraftInstaller.cs | 52 +- windows/Hako.Core/Minecraft/MojangClient.cs | 35 +- windows/Hako.Core/Minecraft/MojangModels.cs | 3 +- .../InstallationCoordinatorTests.cs | 1258 +++++++++++++++++ windows/Hako.Tests/InstanceStoreTests.cs | 225 ++- .../Hako.Tests/LocalizationResourcesTests.cs | 30 +- .../Hako.Tests/MinecraftInstallationTests.cs | 78 +- windows/Hako/Strings/en-US/Resources.resw | 33 + windows/Hako/Strings/ru-RU/Resources.resw | 33 + 14 files changed, 2710 insertions(+), 94 deletions(-) create mode 100644 windows/Hako.Core/Instances/InstallationCoordinator.cs create mode 100644 windows/Hako.Tests/InstallationCoordinatorTests.cs diff --git a/Obsidian/HakoVault/Minecraft/Installation.md b/Obsidian/HakoVault/Minecraft/Installation.md index 8ed9db6..a2cdde0 100644 --- a/Obsidian/HakoVault/Minecraft/Installation.md +++ b/Obsidian/HakoVault/Minecraft/Installation.md @@ -93,6 +93,61 @@ runtime manifest raw-файлов. Link-записи отклоняются на завершения; если переименование 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`. diff --git a/windows/Hako.Core/Instances/GameInstance.cs b/windows/Hako.Core/Instances/GameInstance.cs index e967ee8..6e34946 100644 --- a/windows/Hako.Core/Instances/GameInstance.cs +++ b/windows/Hako.Core/Instances/GameInstance.cs @@ -88,6 +88,7 @@ public sealed record GameInstance [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] diff --git a/windows/Hako.Core/Instances/InstallationCoordinator.cs b/windows/Hako.Core/Instances/InstallationCoordinator.cs new file mode 100644 index 0000000..48a441d --- /dev/null +++ b/windows/Hako.Core/Instances/InstallationCoordinator.cs @@ -0,0 +1,733 @@ +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> _install; + 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) + : 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)) + { + } + + internal InstallationCoordinator( + InstanceStore store, + Func> prepare, + Func?, CancellationToken, Task> install, + TimeProvider? timeProvider = null) + { + _store = store; + _prepare = prepare; + _install = install; + _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) => Task.Run(() => + { + lock (_sync) + { + EnsureStarted(); + _pendingCreates++; + } + + var pending = true; + try + { + var instance = _store.Create(draft, prepared, legacyTexturepacks); + 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 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 _install(prepared, _store.GetInstanceDirectory(id), 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), + 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", + "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 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.Complete => "InstallationStageComplete", + InstallationProgressStage.Pausing => "InstallationStagePausing", + _ => throw new ArgumentOutOfRangeException(nameof(stage), stage, null) + }; +} diff --git a/windows/Hako.Core/Instances/InstanceStorage.cs b/windows/Hako.Core/Instances/InstanceStorage.cs index 82d219e..acddab1 100644 --- a/windows/Hako.Core/Instances/InstanceStorage.cs +++ b/windows/Hako.Core/Instances/InstanceStorage.cs @@ -16,6 +16,8 @@ public enum InstanceStorageError BuildInstalling, BuildRunning, BuildFilesBusy, + InstanceRegistryNotLoaded, + InstanceRegistryUnavailable, InvalidOfflineUsername, MissingFabricLoaderVersion } diff --git a/windows/Hako.Core/Instances/InstanceStore.cs b/windows/Hako.Core/Instances/InstanceStore.cs index ee46e3a..40afc1e 100644 --- a/windows/Hako.Core/Instances/InstanceStore.cs +++ b/windows/Hako.Core/Instances/InstanceStore.cs @@ -4,6 +4,7 @@ using System.Runtime.ExceptionServices; using System.Text.Json; using Hako.Core; +using Hako.Core.Minecraft; namespace Hako.Core.Instances; @@ -35,7 +36,7 @@ public InstanceRegistryException(InstanceRegistryError error, string message, Ex public sealed record InstanceRegistryFile(int FormatVersion, List Instances); -public sealed class InstanceStore : INotifyPropertyChanged +public sealed class InstanceStore : INotifyPropertyChanged, IAsyncDisposable { private static readonly HashSet ReservedNames = CreateReservedNames(); private readonly InstanceStorage _storage; @@ -48,6 +49,7 @@ public sealed class InstanceStore : INotifyPropertyChanged private IReadOnlyList _instances = Array.AsReadOnly(Array.Empty()); private InstanceRegistryException? _loadError; private Task? _deleteStagingCleanupTask; + private Task? _disposeTask; private bool _isLoaded; private readonly HashSet _launchBusy = []; private readonly HashSet _contentBusy = []; @@ -66,6 +68,15 @@ internal InstanceStore(HakoPaths paths, Action? beforeRegistryCommit, Action? be 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; @@ -105,6 +116,19 @@ public bool IsLoaded public IReadOnlyList List() => Instances; + public string GetInstanceDirectory(Guid id) + { + _gate.Wait(); + try + { + return _storage.Directory(Find(GetRegistrySnapshot(), id).FolderName); + } + finally + { + _gate.Release(); + } + } + public bool IsLaunchBusy(Guid id) { _gate.Wait(); @@ -137,7 +161,13 @@ public bool TryBeginLaunch(Guid id, out IDisposable? lease, out InstanceStorageE _gate.Wait(); try { - var instance = Find(GetRegistrySnapshot(), id); + if (!TryGetRegistrySnapshot(out var registry, out reason)) + { + lease = null; + return false; + } + + var instance = Find(registry, id); reason = GetManagementBlock(instance); if (reason is not null) { @@ -168,7 +198,13 @@ public bool TryBeginContent(Guid id, out IDisposable? lease, out InstanceStorage _gate.Wait(); try { - var instance = Find(GetRegistrySnapshot(), id); + if (!TryGetRegistrySnapshot(out var registry, out reason)) + { + lease = null; + return false; + } + + var instance = Find(registry, id); reason = GetManagementBlock(instance); if (reason is not null) { @@ -193,6 +229,57 @@ public bool TryBeginContent(Guid id, out IDisposable? lease, out InstanceStorage 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 = _contentBusy.Add(id); + lease = new BusyLease(() => EndBusy(id, _contentBusy)); + reason = null; + } + finally + { + _gate.Release(); + } + + if (changed) + { + NotifyBusyStateChanged(); + } + + return true; + } + private void EndBusy(Guid id, HashSet busy) { _gate.Wait(); @@ -237,6 +324,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) { lock (_snapshotSync) { + _instances = Array.AsReadOnly(Array.Empty()); _loadError = exception; _isLoaded = true; } @@ -263,6 +351,11 @@ private void ScheduleDeleteStagingCleanup(Action cleanup) { lock (_deleteStagingTasksSync) { + if (_disposeTask is not null) + { + return; + } + var previous = _deleteStagingCleanupTask ?? Task.CompletedTask; var cleanupTask = Task.Run(async () => { @@ -302,7 +395,8 @@ public GameInstance Create( string versionID, string metadataURL = "", string metadataSHA1 = "", - bool legacyTexturepacks = false) + bool legacyTexturepacks = false, + int javaMajorVersion = 0) { ArgumentNullException.ThrowIfNull(draft); _gate.Wait(); @@ -338,7 +432,7 @@ public GameInstance Create( OfflineMode = draft.OfflineMode, OfflineUsername = draft.OfflineUsername, Parameters = NormalizeParameters(draft.Parameters), - JavaMajorVersion = draft.JavaMajorVersion, + JavaMajorVersion = javaMajorVersion > 0 ? javaMajorVersion : draft.JavaMajorVersion, LegacyTexturepacks = legacyTexturepacks || draft.LegacyTexturepacks, ModLoader = draft.ModLoader, FabricConfiguration = draft.ModLoader == InstanceModLoader.Fabric ? draft.FabricConfiguration : null, @@ -367,6 +461,139 @@ public GameInstance Create( } } + /// Creates a queued build from a version that was already verified by MojangClient.PrepareAsync. + public GameInstance Create(InstanceDraft draft, PreparedInstallation prepared, bool legacyTexturepacks = false) + { + 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); + } + + 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 + }; + 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(); @@ -396,6 +623,7 @@ public void UpdateSettings(Guid id, InstanceDraft draft) { var registry = GetRegistrySnapshot(); var current = Find(registry, id); + ThrowIfManagementBlocked(current); ValidateOffline(draft); var updated = ApplyDraft(current, draft, includeName: false); UpdateCore(registry, current, updated, draft.IconData); @@ -576,6 +804,30 @@ private InstanceRegistryFile GetRegistrySnapshot() } } + 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; @@ -874,6 +1126,10 @@ private void TryDeleteStaging(string path) { 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}"); diff --git a/windows/Hako.Core/Minecraft/MinecraftInstaller.cs b/windows/Hako.Core/Minecraft/MinecraftInstaller.cs index 395bddf..351f005 100644 --- a/windows/Hako.Core/Minecraft/MinecraftInstaller.cs +++ b/windows/Hako.Core/Minecraft/MinecraftInstaller.cs @@ -7,11 +7,13 @@ namespace Hako.Core.Minecraft; public enum InstallationProgressStage { + PreparingMetadata, PreparingFiles, Downloading, PreparingLibraries, PreparingAssets, - Complete + Complete, + Pausing } public sealed record InstallationProgress( @@ -77,7 +79,7 @@ private async Task InstallCoreAsync( var root = Path.GetFullPath(instanceRoot); Directory.CreateDirectory(root); _ = InstanceStorage.ContainedPath("installation-check", root); - CleanStaleTemporaryFiles(root); + CleanStaleTemporaryFiles(root, cancellationToken); var manifest = prepared.Manifest; var versionDirectory = $"minecraft/versions/{manifest.Id}"; @@ -336,10 +338,28 @@ private async Task DownloadAsync( if (job.Download.Size is { } expectedContentLength && response.Content.Headers.ContentLength is { } contentLength && contentLength > expectedContentLength) { - throw new MojangException(MojangError.Invalid, $"The downloaded file is larger than declared: {job.RelativePath}."); + throw new MojangException(MojangError.Integrity, $"The downloaded file is larger than declared: {job.RelativePath}."); } - await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + 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; @@ -367,7 +387,7 @@ private async Task DownloadAsync( if (job.Download.Size is { } maximumSize && read > maximumSize - size) { - throw new MojangException(MojangError.Invalid, $"The downloaded file is larger than declared: {job.RelativePath}."); + throw new MojangException(MojangError.Integrity, $"The downloaded file is larger than declared: {job.RelativePath}."); } size += read; @@ -384,7 +404,7 @@ private async Task DownloadAsync( if (!string.Equals(actualHash, job.Download.Sha1, StringComparison.OrdinalIgnoreCase) || job.Download.Size is { } expectedSize && size != expectedSize) { - throw new MojangException(MojangError.Invalid, $"The downloaded file failed its SHA-1 or size check: {job.RelativePath}."); + throw new MojangException(MojangError.Integrity, $"The downloaded file failed its SHA-1 or size check: {job.RelativePath}."); } File.Move(temporary, destination, overwrite: true); @@ -475,7 +495,7 @@ private static async Task CopyAtomicallyAsync( CreateDirectory(Path.GetDirectoryName(destinationRelativePath)!, root); if (!await IsValidFileAsync(source, download, cancellationToken).ConfigureAwait(false)) { - throw new MojangException(MojangError.Invalid, "An asset object changed before it could be copied."); + throw new MojangException(MojangError.Integrity, "An asset object changed before it could be copied."); } if (await IsValidFileAsync(destination, download, cancellationToken).ConfigureAwait(false)) @@ -497,7 +517,7 @@ private static async Task CopyAtomicallyAsync( cancellationToken.ThrowIfCancellationRequested(); if (!await IsValidFileAsync(temporary, download, cancellationToken).ConfigureAwait(false)) { - throw new MojangException(MojangError.Invalid, "An asset copy failed its SHA-1 or size check."); + throw new MojangException(MojangError.Integrity, "An asset copy failed its SHA-1 or size check."); } File.Move(temporary, destination, overwrite: true); @@ -754,7 +774,7 @@ private static bool IsExcluded(string path, string exclude) private static string TemporaryPathFor(string destination) => Path.Combine(Path.GetDirectoryName(destination)!, $".hako-{Guid.NewGuid():N}.tmp"); - private static void CleanStaleTemporaryFiles(string root) + 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[] @@ -767,18 +787,25 @@ private static void CleanStaleTemporaryFiles(string root) "minecraft/resources" }) { - CleanStaleTemporaryEntries(root, Path.Combine(root, relativeDirectory), recurse: true); + cancellationToken.ThrowIfCancellationRequested(); + CleanStaleTemporaryEntries(root, Path.Combine(root, relativeDirectory), recurse: true, cancellationToken); } - CleanStaleTemporaryEntries(root, Path.Combine(root, "minecraft"), recurse: false); + cancellationToken.ThrowIfCancellationRequested(); + CleanStaleTemporaryEntries(root, Path.Combine(root, "minecraft"), recurse: false, cancellationToken); } - private static void CleanStaleTemporaryEntries(string root, string directory, bool recurse) + 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); @@ -789,6 +816,7 @@ private static void CleanStaleTemporaryEntries(string root, string directory, bo foreach (var entry in Directory.EnumerateFileSystemEntries(safeDirectory)) { + cancellationToken.ThrowIfCancellationRequested(); try { var safeEntry = InstanceStorage.ContainedPath(Path.GetRelativePath(root, entry), root); diff --git a/windows/Hako.Core/Minecraft/MojangClient.cs b/windows/Hako.Core/Minecraft/MojangClient.cs index 81700fa..c16c737 100644 --- a/windows/Hako.Core/Minecraft/MojangClient.cs +++ b/windows/Hako.Core/Minecraft/MojangClient.cs @@ -126,10 +126,28 @@ internal async Task GetBytesAsync(string url, CancellationToken cancella if (expectedSize is { } expectedContentLength && response.Content.Headers.ContentLength is { } contentLength && contentLength > expectedContentLength) { - throw Invalid("A Mojang response is larger than its declared size."); + throw new MojangException(MojangError.Integrity, "A Mojang response is larger than its declared size."); } - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + 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) @@ -137,7 +155,7 @@ internal async Task GetBytesAsync(string url, CancellationToken cancella 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(stream, chunk.AsMemory(0, readLength), cancellationToken).ConfigureAwait(false); + var read = await ReadWithIdleTimeoutAsync(responseStream, chunk.AsMemory(0, readLength), cancellationToken).ConfigureAwait(false); if (read == 0) { break; @@ -146,9 +164,12 @@ internal async Task GetBytesAsync(string url, CancellationToken cancella if (buffer.Length + read > MaximumMetadataBytes || expectedSize is { } expectedSizeLimit && buffer.Length + read > expectedSizeLimit) { - throw Invalid(expectedSize is null - ? "Mojang metadata exceeds the supported size limit." - : "A Mojang response is larger than its declared size."); + 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); @@ -206,7 +227,7 @@ internal static void Verify(ReadOnlySpan bytes, MojangDownload download) if (!string.Equals(hash, download.Sha1, StringComparison.OrdinalIgnoreCase) || download.Size is { } expectedSize && bytes.Length != expectedSize) { - throw Invalid("A downloaded Mojang file failed its SHA-1 or size check."); + throw new MojangException(MojangError.Integrity, "A downloaded Mojang file failed its SHA-1 or size check."); } } diff --git a/windows/Hako.Core/Minecraft/MojangModels.cs b/windows/Hako.Core/Minecraft/MojangModels.cs index c01b3df..f6b5309 100644 --- a/windows/Hako.Core/Minecraft/MojangModels.cs +++ b/windows/Hako.Core/Minecraft/MojangModels.cs @@ -388,7 +388,8 @@ public enum MojangError { Unsupported, Invalid, - Http + Http, + Integrity } public sealed class MojangException(MojangError error, string message, int? statusCode = null, Exception? innerException = null) 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/InstanceStoreTests.cs b/windows/Hako.Tests/InstanceStoreTests.cs index a9499ae..53d74fa 100644 --- a/windows/Hako.Tests/InstanceStoreTests.cs +++ b/windows/Hako.Tests/InstanceStoreTests.cs @@ -14,8 +14,8 @@ public sealed class InstanceStoreTests [TestMethod] public async Task NamesAreTrimmedAndRestrictedToOneToSixtyAsciiLettersDigitsAndSpaces() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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!", "Сборка" }) @@ -27,8 +27,8 @@ public async Task NamesAreTrimmedAndRestrictedToOneToSixtyAsciiLettersDigitsAndS [TestMethod] public async Task ReservedAndCaseInsensitiveNameCollisionsAreRejected() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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)); @@ -42,10 +42,10 @@ public async Task ReservedAndCaseInsensitiveNameCollisionsAreRejected() [TestMethod] public async Task CaseInsensitiveFilesystemCollisionIsRejected() { - using var temporary = new TemporaryDirectory(); + 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.Paths); + var store = await LoadStoreAsync(temporary); Assert.ThrowsExactly(() => store.ValidateName("loose files")); } @@ -53,8 +53,8 @@ public async Task CaseInsensitiveFilesystemCollisionIsRejected() [TestMethod] public async Task RegistryIsCamelCaseAndContainsFullProfileState() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + await using var temporary = new TemporaryDirectory(); + var store = await LoadStoreAsync(temporary); var instance = store.Create(new InstanceDraft { Name = "Fabric Build", @@ -70,6 +70,7 @@ public async Task RegistryIsCamelCaseAndContainsFullProfileState() 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\":"); @@ -79,13 +80,13 @@ public async Task RegistryIsCamelCaseAndContainsFullProfileState() [TestMethod] public async Task FailedAtomicWriteLeavesPreviousRegistryAndRemovesTemporaryFile() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => throw new IOException("Injected before atomic replace.")); + var failing = await LoadStoreAsync(temporary, () => throw new IOException("Injected before atomic replace.")); Assert.ThrowsExactly(() => failing.Rename(instance.Id, "Changed")); @@ -98,10 +99,10 @@ public async Task FailedAtomicWriteLeavesPreviousRegistryAndRemovesTemporaryFile [TestMethod] public async Task CorruptAndUnknownVersionRegistriesAreNeverOverwritten() { - using var temporary = new TemporaryDirectory(); + 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.Paths); + 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\":[]}" }) @@ -136,7 +137,7 @@ public async Task CorruptAndUnknownVersionRegistriesAreNeverOverwritten() [TestMethod] public async Task InvalidRegistryEntriesAreReportedAsCorrupt() { - using var temporary = new TemporaryDirectory(); + 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" }; @@ -158,7 +159,7 @@ public async Task InvalidRegistryEntriesAreReportedAsCorrupt() { var original = JsonSerializer.SerializeToUtf8Bytes(invalid, HakoJsonContext.Default.InstanceRegistryFile); await File.WriteAllBytesAsync(registryPath, original); - var store = new InstanceStore(temporary.Paths); + var store = temporary.CreateStore(); await store.LoadAsync(); @@ -171,7 +172,7 @@ public async Task InvalidRegistryEntriesAreReportedAsCorrupt() [TestMethod] public async Task RegistrySymlinkIsReportedAsLoadErrorInsteadOfThrowingFromConstruction() { - using var temporary = new TemporaryDirectory(); + 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\":[]}"); @@ -185,7 +186,7 @@ public async Task RegistrySymlinkIsReportedAsLoadErrorInsteadOfThrowingFromConst Assert.Inconclusive("This environment does not allow creating a file symlink: " + exception.Message); } - var store = new InstanceStore(temporary.Paths); + var store = temporary.CreateStore(); await store.LoadAsync(); Assert.IsNotNull(store.LoadError); @@ -197,8 +198,8 @@ public async Task RegistrySymlinkIsReportedAsLoadErrorInsteadOfThrowingFromConst [TestMethod] public async Task InstanceListsUseTheLoadedSnapshotAndEventFailuresDoNotFailCommittedOperations() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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."); @@ -211,15 +212,41 @@ public async Task InstanceListsUseTheLoadedSnapshotAndEventFailuresDoNotFailComm await store.LoadAsync(); - Assert.AreEqual(created, store.List().Single()); + 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() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths, () => throw new IOException("Injected registry failure.")); + 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")); @@ -231,11 +258,11 @@ public async Task CreateFailureRemovesOnlyItsNewFolder() [TestMethod] public async Task RenameFailureRollsBackAndCaseOnlyRenameUsesTemporaryFolder() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => throw new IOException("Injected registry failure.")); + 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"))); @@ -250,12 +277,12 @@ public async Task RenameFailureRollsBackAndCaseOnlyRenameUsesTemporaryFolder() [TestMethod] public async Task RenameRollbackFailureKeepsTheOriginalOperationFailureAsPrimary() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => + var failing = await LoadStoreAsync(temporary, () => { File.WriteAllText(Path.Combine(temporary.Paths.GameRoot, instance.FolderName), "blocks rollback"); throw primaryFailure; @@ -269,11 +296,11 @@ public async Task RenameRollbackFailureKeepsTheOriginalOperationFailureAsPrimary [TestMethod] public async Task DeleteFailureRestoresFolderAndMissingFolderDoesNotBlockRemoval() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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.Paths, () => throw new IOException("Injected registry failure.")); + 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"))); @@ -287,11 +314,34 @@ public async Task DeleteFailureRestoresFolderAndMissingFolderDoesNotBlockRemoval .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() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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"); @@ -325,15 +375,16 @@ public async Task DeleteCleanupRemovesNestedJunctionWithoutTouchingItsTarget() [TestMethod] public async Task LoadPublishesSnapshotBeforeBackgroundStagingCleanupCompletes() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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); - var store = new InstanceStore(temporary.Paths, null, () => + Task? disposeTask = null; + var store = temporary.CreateStore(null, () => { cleanupStarted.TrySetResult(); continueCleanup.Task.GetAwaiter().GetResult(); @@ -346,20 +397,22 @@ public async Task LoadPublishesSnapshotBeforeBackgroundStagingCleanupCompletes() 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 store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); + await disposeTask!.WaitAsync(TimeSpan.FromSeconds(10)); Assert.IsFalse(Directory.Exists(staging)); } [TestMethod] public async Task LoadCleansOnlyExactLegacyAndNewDeleteStagingNames() { - using var temporary = new TemporaryDirectory(); + 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")); @@ -371,7 +424,7 @@ public async Task LoadCleansOnlyExactLegacyAndNewDeleteStagingNames() Directory.CreateDirectory(legacyD); Directory.CreateDirectory(newFormat); - var store = new InstanceStore(temporary.Paths); + var store = temporary.CreateStore(); await store.LoadAsync(); await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); @@ -384,8 +437,8 @@ public async Task LoadCleansOnlyExactLegacyAndNewDeleteStagingNames() [TestMethod] public async Task LoadReconcilesInterruptedDeleteAndKeepsBuildWhenItsFolderWasRestored() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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"); @@ -395,7 +448,7 @@ public async Task LoadReconcilesInterruptedDeleteAndKeepsBuildWhenItsFolderWasRe Directory.CreateDirectory(restoredStaging); await File.WriteAllTextAsync(Path.Combine(restoredStaging, "leftover.txt"), "leftover"); - var store = new InstanceStore(temporary.Paths); + var store = temporary.CreateStore(); await store.LoadAsync(); await store.DeleteStagingCleanupTask!.WaitAsync(TimeSpan.FromSeconds(10)); @@ -409,28 +462,36 @@ public async Task LoadReconcilesInterruptedDeleteAndKeepsBuildWhenItsFolderWasRe [TestMethod] public async Task RenameAndUpdateAreBlockedWhileQueuedOrInstalling() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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 TryBeginLaunchAndDeleteAreSerializedByTheStoreGate() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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); @@ -444,7 +505,7 @@ public async Task TryBeginLaunchAndDeleteAreSerializedByTheStoreGate() var deleteEnteredCommit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var continueDelete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var deleting = await LoadStoreAsync(temporary.Paths, () => + var deleting = await LoadStoreAsync(temporary, () => { deleteEnteredCommit.TrySetResult(); continueDelete.Task.GetAwaiter().GetResult(); @@ -476,8 +537,8 @@ public async Task TryBeginLaunchAndDeleteAreSerializedByTheStoreGate() [TestMethod] public async Task BusyFlagsRemainSafeUnderConcurrentReadsAndWrites() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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; @@ -610,7 +671,7 @@ public void RootLockWrapsOnlySharingAndLockViolationsAsAlreadyOpen() [TestMethod] public async Task RootJunctionIsAcceptedButNestedJunctionIsRejected() { - using var temporary = new TemporaryDirectory(); + 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"); @@ -626,7 +687,8 @@ public async Task RootJunctionIsAcceptedButNestedJunctionIsRejected() using var rootLock = HakoRootLock.Acquire(paths); Assert.AreEqual(Path.Combine(rootJunction, "Build"), storage.Directory("Build")); Assert.ThrowsExactly(() => storage.Directory("nested-link")); - var store = await LoadStoreAsync(paths); + var store = temporary.Track(new InstanceStore(paths)); + await store.LoadAsync(); Assert.AreEqual(0, store.List().Count); } finally @@ -639,7 +701,7 @@ public async Task RootJunctionIsAcceptedButNestedJunctionIsRejected() [TestMethod] public async Task KilledLockHolderReleasesGameRootForTheNextProcess() { - using var temporary = new TemporaryDirectory(); + 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"); @@ -697,8 +759,8 @@ public void OfflineUsernameUsesThreeToSixteenAsciiLettersDigitsAndUnderscores() [TestMethod] public async Task UpdateIsTransactionalAndManagementBlockReasonsTrackBusyFlags() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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); @@ -710,7 +772,7 @@ public async Task UpdateIsTransactionalAndManagementBlockReasonsTrackBusyFlags() ArgumentSource = LaunchArgumentSource.Custom, Parameters = new InstanceParameters { JavaArguments = "-Dchanged=yes", MaximumMemoryMiB = 1024 } }; - var failing = await LoadStoreAsync(temporary.Paths, () => throw new IOException("Injected registry failure.")); + 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"))); @@ -744,11 +806,12 @@ public async Task UpdateIsTransactionalAndManagementBlockReasonsTrackBusyFlags() [TestMethod] public async Task UpdateSettingsChangesOnlyProfileSettingsAndRollsBackIconOnFailure() { - using var temporary = new TemporaryDirectory(); - var initial = await LoadStoreAsync(temporary.Paths); + 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", @@ -757,7 +820,7 @@ public async Task UpdateSettingsChangesOnlyProfileSettingsAndRollsBackIconOnFail ArgumentSource = LaunchArgumentSource.Global, Parameters = new InstanceParameters { MinecraftArguments = "--new", MaximumMemoryMiB = 1024 } }; - var failing = await LoadStoreAsync(temporary.Paths, () => throw new IOException("Injected registry failure.")); + 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"))); @@ -773,8 +836,8 @@ public async Task UpdateSettingsChangesOnlyProfileSettingsAndRollsBackIconOnFail [TestMethod] public async Task RenamePreservesCustomIconFile() { - using var temporary = new TemporaryDirectory(); - var store = await LoadStoreAsync(temporary.Paths); + 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"); @@ -807,8 +870,10 @@ public void IsolatedArgumentsRequireTwoAbsoluteRootsAndUseTheirOwnInstanceKey() Assert.AreEqual(Path.Combine(data, "Cache"), paths.CacheRoot); } - private sealed class TemporaryDirectory : IDisposable + 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")); @@ -819,8 +884,40 @@ public TemporaryDirectory() public string Path { get; } public HakoPaths Paths { get; } - public void Dispose() + 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); @@ -837,11 +934,11 @@ public SyntheticIOException(int error) } private static async Task LoadStoreAsync( - HakoPaths paths, + TemporaryDirectory temporary, Action? beforeRegistryCommit = null, Action? beforeDeleteStagingCleanup = null) { - var store = new InstanceStore(paths, beforeRegistryCommit, beforeDeleteStagingCleanup); + var store = temporary.CreateStore(beforeRegistryCommit, beforeDeleteStagingCleanup); await store.LoadAsync(); return store; } diff --git a/windows/Hako.Tests/LocalizationResourcesTests.cs b/windows/Hako.Tests/LocalizationResourcesTests.cs index e23dd1d..103f344 100644 --- a/windows/Hako.Tests/LocalizationResourcesTests.cs +++ b/windows/Hako.Tests/LocalizationResourcesTests.cs @@ -1,4 +1,6 @@ using Hako.Core; +using Hako.Core.Instances; +using Hako.Core.Minecraft; using System.Xml.Linq; namespace Hako.Tests; @@ -23,7 +25,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 +36,30 @@ 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")); + } + private static List ReadResourceNames(string path) => XDocument.Load(path) .Root! .Elements("data") diff --git a/windows/Hako.Tests/MinecraftInstallationTests.cs b/windows/Hako.Tests/MinecraftInstallationTests.cs index c8182db..4bd0bae 100644 --- a/windows/Hako.Tests/MinecraftInstallationTests.cs +++ b/windows/Hako.Tests/MinecraftInstallationTests.cs @@ -377,7 +377,7 @@ public async Task InstallerRejectsBadSha1OrSizeWithoutPublishingPartialFiles(str var exception = await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(fixture.Prepared, temporary.Path)); - Assert.AreEqual(MojangError.Invalid, exception.Error); + 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()); } @@ -528,6 +528,49 @@ public async Task MojangBodyReadIOExceptionIsAHttpFailure() 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() { @@ -977,7 +1020,8 @@ private static InstallFixture CreateInstallFixture( IReadOnlyCollection? libraries = null, byte[]? nativeArchiveBytes = null, IReadOnlyDictionary? downloadPayloadOverrides = null, - CancellationTokenSource? cancelOnFailFast = null) + CancellationTokenSource? cancelOnFailFast = null, + string? streamOpenFailureUrl = null) { var javaBytes = Encoding.UTF8.GetBytes("javaw content"); var clientBytes = Encoding.UTF8.GetBytes("client content"); @@ -1084,7 +1128,7 @@ private static InstallFixture CreateInstallFixture( return new InstallFixture( prepared, - new PayloadHandler(payloads, blockDownloads, failingUrl, blockedUrl, gateDownloads, cancelOnFailFast), + new PayloadHandler(payloads, blockDownloads, failingUrl, blockedUrl, gateDownloads, cancelOnFailFast, streamOpenFailureUrl), clientBytes); } @@ -1137,7 +1181,8 @@ private sealed class PayloadHandler( string? failingUrl = null, string? blockedUrl = null, bool gateDownloads = false, - CancellationTokenSource? cancelOnFailFast = null) : HttpMessageHandler + CancellationTokenSource? cancelOnFailFast = null, + string? streamOpenFailureUrl = null) : HttpMessageHandler { private readonly ConcurrentDictionary _requests = new(StringComparer.Ordinal); private readonly ConcurrentBag _userAgents = []; @@ -1170,6 +1215,11 @@ protected override async Task SendAsync(HttpRequestMessage }; } + 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); @@ -1331,6 +1381,26 @@ protected override Task SendAsync(HttpRequestMessage reques }); } + 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; diff --git a/windows/Hako/Strings/en-US/Resources.resw b/windows/Hako/Strings/en-US/Resources.resw index e46363c..2e4939c 100644 --- a/windows/Hako/Strings/en-US/Resources.resw +++ b/windows/Hako/Strings/en-US/Resources.resw @@ -69,4 +69,37 @@ Hako Hako couldn't access its data folders. Check permissions and try again. The instance registry is damaged or uses an unsupported version. The file was preserved, and instance changes are disabled. The instance registry could not be read. Instance changes are disabled. + Queued + Installing + Paused + Ready + Installation failed + Checking Minecraft metadata… + Preparing files… + Downloading Minecraft files… + Preparing libraries… + Preparing assets… + Installation complete + Stopping downloads… + Couldn't get data from Minecraft servers. Check your connection and try again. + This Minecraft version isn't supported on this device. + A downloaded Minecraft file failed its integrity check. Try again. + A Minecraft server returned invalid data. + Could not save the installation state. + The build file path is invalid. + The installation path escapes the build folder. + Installation stopped because the path contains a link to another folder. + The build name is invalid. + This build name is reserved by the system. + A build with this name already exists. + The build folder already exists. + The build is already installing. + The build is currently running. + The build files are busy with another operation. + The build list hasn't loaded yet. + The build list is temporarily unavailable. + The offline player name is invalid. + Choose a Fabric Loader version. + The installation was cancelled. + An unexpected error occurred during installation. diff --git a/windows/Hako/Strings/ru-RU/Resources.resw b/windows/Hako/Strings/ru-RU/Resources.resw index cf2c4bc..bccca5c 100644 --- a/windows/Hako/Strings/ru-RU/Resources.resw +++ b/windows/Hako/Strings/ru-RU/Resources.resw @@ -69,4 +69,37 @@ Hako не удалось получить доступ к папкам данных. Проверьте разрешения и попробуйте снова. Реестр сборок повреждён или имеет неподдерживаемую версию. Файл сохранён, изменения сборок отключены. Не удалось прочитать реестр сборок. Изменения сборок отключены. + В очереди + Устанавливается + Приостановлена + Готова + Ошибка установки + Проверяем описание Minecraft… + Подготавливаем файлы… + Загружаем файлы Minecraft… + Подготавливаем библиотеки… + Подготавливаем ресурсы… + Установка завершена + Останавливаем загрузку… + Не удалось получить данные с серверов Minecraft. Проверьте подключение и попробуйте снова. + Эта версия Minecraft не поддерживается на устройстве. + Загруженный файл Minecraft не прошёл проверку целостности. Попробуйте снова. + Сервер Minecraft прислал некорректные данные. + Не удалось сохранить состояние установки. + Путь к файлам сборки некорректен. + Путь установки выходит за пределы папки сборки. + Установка остановлена: в пути обнаружена ссылка на другую папку. + Имя сборки некорректно. + Это имя сборки зарезервировано системой. + Сборка с таким именем уже существует. + Папка сборки уже существует. + Сборка уже устанавливается. + Сборка сейчас запущена. + Файлы сборки заняты другой операцией. + Список сборок ещё не загружен. + Список сборок временно недоступен. + Имя игрока для автономного режима некорректно. + Выберите версию Fabric Loader. + Установка отменена. + Во время установки произошла непредвиденная ошибка. From be4def9bea7fbea72c8520d2bff228a0ae8ef2d7 Mon Sep 17 00:00:00 2001 From: Li_is Date: Thu, 22 Oct 2026 13:06:49 +0400 Subject: [PATCH 04/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D1=8B=20=D0=B2=20Windows-=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B5:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20Minecraft,=20=D0=B0=D1=80=D0=B3=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20Java=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81?= =?UTF-8?q?=D1=81=20=D0=B8=D0=B3=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Obsidian/HakoVault/Auth/MicrosoftAuth.md | 19 + Obsidian/HakoVault/Minecraft/Launching.md | 51 ++ windows/Hako.Core/AppCoordinator.cs | 95 +++- windows/Hako.Core/AuthContracts.cs | 23 + .../Instances/GameLaunchCoordinator.cs | 494 ++++++++++++++++++ windows/Hako.Core/JsonContexts.cs | 2 + windows/Hako.Core/MicrosoftDeviceAuth.cs | 97 ++++ .../Hako.Core/Minecraft/GameProcessRunner.cs | 441 ++++++++++++++++ .../Hako.Core/Minecraft/JavaAnsiEncoding.cs | 146 ++++++ .../Minecraft/JavaLaunchValidation.cs | 315 +++++++++++ .../Hako.Core/Minecraft/LaunchArguments.cs | 242 +++++++++ .../Minecraft/MinecraftLaunchPlan.cs | 293 +++++++++++ windows/Hako.Core/Minecraft/Win32Process.cs | 311 +++++++++++ .../Hako.Core/MinecraftSessionCoordinator.cs | 347 ++++++++++++ .../Hako.Tests/GameLaunchCoordinatorTests.cs | 288 ++++++++++ windows/Hako.Tests/GameProcessRunnerTests.cs | 128 +++++ windows/Hako.Tests/JavaArgumentFileTests.cs | 231 ++++++++ .../Hako.Tests/JavaLaunchValidationTests.cs | 132 +++++ windows/Hako.Tests/LaunchArgumentsTests.cs | 54 ++ .../Hako.Tests/MicrosoftDeviceAuthTests.cs | 129 +++++ .../Hako.Tests/MinecraftLaunchPlanTests.cs | 171 ++++++ .../MinecraftSessionCoordinatorTests.cs | 145 +++++ windows/Hako.Tests/TestSupport.cs | 77 +++ windows/Hako/App.xaml.cs | 3 + windows/Hako/ProfilePreviewEnvironment.cs | 1 + windows/Hako/Program.cs | 83 +++ windows/Hako/Strings/en-US/Resources.resw | 23 + windows/Hako/Strings/ru-RU/Resources.resw | 23 + 28 files changed, 4356 insertions(+), 8 deletions(-) create mode 100644 windows/Hako.Core/Instances/GameLaunchCoordinator.cs create mode 100644 windows/Hako.Core/Minecraft/GameProcessRunner.cs create mode 100644 windows/Hako.Core/Minecraft/JavaAnsiEncoding.cs create mode 100644 windows/Hako.Core/Minecraft/JavaLaunchValidation.cs create mode 100644 windows/Hako.Core/Minecraft/LaunchArguments.cs create mode 100644 windows/Hako.Core/Minecraft/MinecraftLaunchPlan.cs create mode 100644 windows/Hako.Core/Minecraft/Win32Process.cs create mode 100644 windows/Hako.Core/MinecraftSessionCoordinator.cs create mode 100644 windows/Hako.Tests/GameLaunchCoordinatorTests.cs create mode 100644 windows/Hako.Tests/GameProcessRunnerTests.cs create mode 100644 windows/Hako.Tests/JavaArgumentFileTests.cs create mode 100644 windows/Hako.Tests/JavaLaunchValidationTests.cs create mode 100644 windows/Hako.Tests/LaunchArgumentsTests.cs create mode 100644 windows/Hako.Tests/MinecraftLaunchPlanTests.cs create mode 100644 windows/Hako.Tests/MinecraftSessionCoordinatorTests.cs diff --git a/Obsidian/HakoVault/Auth/MicrosoftAuth.md b/Obsidian/HakoVault/Auth/MicrosoftAuth.md index 33c39c7..923a56b 100644 --- a/Obsidian/HakoVault/Auth/MicrosoftAuth.md +++ b/Obsidian/HakoVault/Auth/MicrosoftAuth.md @@ -172,3 +172,22 @@ email и, если доступен, профиль Minecraft. Без Minecraft внутри этого условия. Устаревший вход не заменит аккаунт, а завершившийся вход не восстановит аккаунт после выхода. - Выход: `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/Minecraft/Launching.md b/Obsidian/HakoVault/Minecraft/Launching.md index da54e87..4c2776b 100644 --- a/Obsidian/HakoVault/Minecraft/Launching.md +++ b/Obsidian/HakoVault/Minecraft/Launching.md @@ -136,3 +136,54 @@ 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`, события сессий | + +- **Аргументы.** Правила 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`, код выхода) — точка + расширения учёта времени, срабатывает и для восстановленных игр. События приходят с фоновых потоков. +- **Помощник времени.** `Hako.exe --playtime-helper [pid]` распознаётся в `Program.Main` до WinUI; пока это + прототип: берёт `pid`, `startSeconds`/`startMicroseconds` из журнала (корень или `process`), ждёт процесс только при + совпадении PID и времени создания (`GameProcessRunner.TryWaitForRecordedProcess`) и пишет `.finished`. diff --git a/windows/Hako.Core/AppCoordinator.cs b/windows/Hako.Core/AppCoordinator.cs index e151963..cffad44 100644 --- a/windows/Hako.Core/AppCoordinator.cs +++ b/windows/Hako.Core/AppCoordinator.cs @@ -56,22 +56,37 @@ 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) - : this(new UserSettingsService(settingsStore), accountStore, authService) + public AppCoordinator( + IUserSettingsStore settingsStore, + IAccountStore accountStore, + IMicrosoftAuthService authService, + TimeProvider? timeProvider = null) + : this(new UserSettingsService(settingsStore), accountStore, authService, timeProvider) { } - public AppCoordinator(UserSettingsService settingsService, IAccountStore accountStore, IMicrosoftAuthService authService) + public AppCoordinator( + UserSettingsService settingsService, + IAccountStore accountStore, + IMicrosoftAuthService authService, + TimeProvider? timeProvider = null) { _settingsService = settingsService; _accountStore = accountStore; _authService = authService; + 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; @@ -123,7 +138,6 @@ public StartupNotice? CurrentStartupNotice public AccountSession? Account { get => _account; - private set => SetProperty(ref _account, value); } public async Task InitializeAsync(CancellationToken cancellationToken = default) @@ -147,7 +161,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; } @@ -157,7 +171,7 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default) } catch { - Account = null; + SetAccount(null, invalidateSessions: true); IsGuest = false; CurrentStartupNotice = StartupNotice.AccountUnavailable; Screen = LauncherScreen.Welcome; @@ -251,6 +265,7 @@ public async Task LogoutAsync(CancellationToken cancellationToken = default) lock (_commitLock) { InvalidateAttemptLocked(); + InvalidateAccountOperationsLocked(); generation = _attemptGeneration; } @@ -264,7 +279,7 @@ public async Task LogoutAsync(CancellationToken cancellationToken = default) } action(); - Account = null; + SetAccount(null, invalidateSessions: false); IsGuest = false; CurrentDeviceCode = null; LoginError = null; @@ -286,6 +301,7 @@ private Task StartAttempt() long generation; lock (_commitLock) { + InvalidateAccountOperationsLocked(); InvalidateAttemptLocked(); generation = _attemptGeneration; attempt = new CancellationTokenSource(); @@ -330,7 +346,7 @@ await _accountStore.SaveAsync(account, replaceFile => } replaceFile(); - Account = account; + SetAccount(account, invalidateSessions: true); IsGuest = false; CurrentDeviceCode = null; LoginError = null; @@ -409,6 +425,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/Instances/GameLaunchCoordinator.cs b/windows/Hako.Core/Instances/GameLaunchCoordinator.cs new file mode 100644 index 0000000..8d38597 --- /dev/null +++ b/windows/Hako.Core/Instances/GameLaunchCoordinator.cs @@ -0,0 +1,494 @@ +using System.ComponentModel; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text.Json; +using Hako.Core.Minecraft; + +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 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) + : this(store, settings, sessions, runner, javaValidation, prepare, null, timeProvider) + { + } + + internal GameLaunchCoordinator( + InstanceStore store, + UserSettingsService settings, + MinecraftSessionCoordinator sessions, + GameProcessRunner runner, + JavaLaunchValidation? javaValidation, + Func>? prepare, + Func>? start, + TimeProvider? timeProvider) + { + _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; + } + + 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; + 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; + if (instance.OfflineMode) + { + identity = MinecraftLaunchIdentity.Offline(instance.OfflineUsername); + } + else + { + session = await _sessions.ConnectForLaunchAsync(cancellationToken).ConfigureAwait(false); + identity = OnlineIdentity(session); + } + + 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); + } + } + + var process = await _start(plan, instanceId, root, Guid.NewGuid(), cancellationToken).ConfigureAwait(false); + lock (_sync) + { + _launchLeases[instanceId] = lease; + leaseTransferred = true; + } + + SetState(instanceId, new GameRunState(GameRunPhase.Running, LogPath: process.LogPath)); + _ = ObserveCompletionAsync(instanceId, process, lease); + } + catch (OperationCanceledException) + { + ClearState(instanceId); + throw; + } + catch (Exception exception) + { + 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; + try + { + var 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; + } + + 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(); + 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); + return MinecraftLaunchPlan.Build(manifest, request.InstanceRoot, request.Executable, request.Identity, + request.ArgumentSource, request.Parameters, request.Architecture, assetIndex, + javaMajorVersion: major); + } + + 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/JsonContexts.cs b/windows/Hako.Core/JsonContexts.cs index dac70c8..01dda80 100644 --- a/windows/Hako.Core/JsonContexts.cs +++ b/windows/Hako.Core/JsonContexts.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Hako.Core.Instances; +using Hako.Core.Minecraft; namespace Hako.Core; @@ -10,6 +11,7 @@ namespace Hako.Core; [JsonSerializable(typeof(UserSettings))] [JsonSerializable(typeof(InstanceRegistryFile))] [JsonSerializable(typeof(AccountSession))] +[JsonSerializable(typeof(GameProcessRecord))] [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/GameProcessRunner.cs b/windows/Hako.Core/Minecraft/GameProcessRunner.cs new file mode 100644 index 0000000..d92c587 --- /dev/null +++ b/windows/Hako.Core/Minecraft/GameProcessRunner.cs @@ -0,0 +1,441 @@ +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 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); + } + + 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(); + 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) + { + var current = ReadRecord(instanceRoot); + if (current == record) + { + 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/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..68eceb2 --- /dev/null +++ b/windows/Hako.Core/Minecraft/LaunchArguments.cs @@ -0,0 +1,242 @@ +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 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/MinecraftLaunchPlan.cs b/windows/Hako.Core/Minecraft/MinecraftLaunchPlan.cs new file mode 100644 index 0000000..a7686b8 --- /dev/null +++ b/windows/Hako.Core/Minecraft/MinecraftLaunchPlan.cs @@ -0,0 +1,293 @@ +using System.Security.Cryptography; +using System.Text; +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, + 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; } + + 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); + resolvedLibraries = resolvedLibraries.Concat(profile?.Libraries ?? []).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"] = 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, + 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.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/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/MinecraftSessionCoordinator.cs b/windows/Hako.Core/MinecraftSessionCoordinator.cs new file mode 100644 index 0000000..5f872b6 --- /dev/null +++ b/windows/Hako.Core/MinecraftSessionCoordinator.cs @@ -0,0 +1,347 @@ +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 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.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/GameProcessRunnerTests.cs b/windows/Hako.Tests/GameProcessRunnerTests.cs new file mode 100644 index 0000000..651790a --- /dev/null +++ b/windows/Hako.Tests/GameProcessRunnerTests.cs @@ -0,0 +1,128 @@ +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 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/JavaArgumentFileTests.cs b/windows/Hako.Tests/JavaArgumentFileTests.cs new file mode 100644 index 0000000..020f25e --- /dev/null +++ b/windows/Hako.Tests/JavaArgumentFileTests.cs @@ -0,0 +1,231 @@ +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 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/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/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/TestSupport.cs b/windows/Hako.Tests/TestSupport.cs index f4758c4..1ac8c32 100644 --- a/windows/Hako.Tests/TestSupport.cs +++ b/windows/Hako.Tests/TestSupport.cs @@ -290,6 +290,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 +347,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 +375,29 @@ 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 +{ + 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 void Dispose() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/windows/Hako/App.xaml.cs b/windows/Hako/App.xaml.cs index 1657edd..e9d21c4 100644 --- a/windows/Hako/App.xaml.cs +++ b/windows/Hako/App.xaml.cs @@ -152,6 +152,9 @@ internal static void CleanupPreviewRoot() catch (UnauthorizedAccessException) { } + catch (ArgumentException) + { + } PreviewRoot = null; } diff --git a/windows/Hako/ProfilePreviewEnvironment.cs b/windows/Hako/ProfilePreviewEnvironment.cs index 37f9de7..e483fb2 100644 --- a/windows/Hako/ProfilePreviewEnvironment.cs +++ b/windows/Hako/ProfilePreviewEnvironment.cs @@ -144,6 +144,7 @@ internal sealed class PreviewAuthService : IMicrosoftAuthService public Task RequestDeviceCodeAsync(CancellationToken cancellationToken) => Unsupported(); public Task WaitForTokenAsync(DeviceCode deviceCode, CancellationToken cancellationToken) => Unsupported(); public Task SignInAsync(MicrosoftToken token, CancellationToken cancellationToken) => Unsupported(); + public Task ConnectMinecraftAsync(string refreshToken, Func persistRefreshToken, CancellationToken cancellationToken) => Unsupported(); private static Task Unsupported() => Task.FromException(new NotSupportedException("Sign-in is disabled in profile preview mode.")); } diff --git a/windows/Hako/Program.cs b/windows/Hako/Program.cs index 83602cd..4cde1cc 100644 --- a/windows/Hako/Program.cs +++ b/windows/Hako/Program.cs @@ -2,8 +2,11 @@ using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; using Hako.Core; +using Hako.Core.Minecraft; using System.Diagnostics; +using System.Globalization; using System.Runtime.InteropServices; +using System.Text.Json; using WinRT; namespace Hako; @@ -13,6 +16,11 @@ internal static class Program [STAThread] private static void Main(string[] args) { + if (TryRunPlaytimeHelper(args)) + { + return; + } + ComWrappersSupport.InitializeComWrappers(); HakoPathOverrides overrides; @@ -63,6 +71,81 @@ private static void Main(string[] args) }); } + private static bool TryRunPlaytimeHelper(string[] args) + { + if (args.Length == 0 || args[0] != "--playtime-helper") + { + return false; + } + + Environment.ExitCode = RunPlaytimeHelper(args[1..]); + return true; + } + + private static int RunPlaytimeHelper(string[] arguments) + { + if (arguments.Length is < 1 or > 2 || !Path.IsPathFullyQualified(arguments[0])) + { + return 2; + } + + string journalPath; + try { journalPath = Path.GetFullPath(arguments[0]); } + catch (ArgumentException) { return 2; } + int processId; + ulong startSeconds; + ulong startMicroseconds; + try + { + using var journal = JsonDocument.Parse(File.ReadAllBytes(journalPath)); + var root = journal.RootElement; + var identity = root.TryGetProperty("pid", out _) ? root + : root.TryGetProperty("process", out var process) ? process + : default; + if (identity.ValueKind != JsonValueKind.Object + || !identity.TryGetProperty("pid", out var pid) || !pid.TryGetInt32(out processId) + || !identity.TryGetProperty("startSeconds", out var seconds) || !seconds.TryGetUInt64(out startSeconds) + || !identity.TryGetProperty("startMicroseconds", out var microseconds) || !microseconds.TryGetUInt64(out startMicroseconds)) + { + return 2; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + return 2; + } + + if (processId <= 0 || processId == Environment.ProcessId + || (arguments.Length == 2 && (!int.TryParse(arguments[1], out var argumentProcessId) || argumentProcessId != processId))) + { + return 2; + } + + // The game may have exited, or its PID been reused, between the journal write and helper startup: + // only the process with the journaled creation time is awaited. + _ = GameProcessRunner.TryWaitForRecordedProcess( + new GameProcessRecord(Guid.Empty, processId, startSeconds, startMicroseconds), out var exitCode); + + var marker = journalPath + ".finished"; + var temporaryMarker = marker + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var exitCodeText = exitCode?.ToString(CultureInfo.InvariantCulture) ?? "null"; + var finishedAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + var contents = $"{{\"pid\":{processId},\"exitCode\":{exitCodeText},\"finishedAt\":\"{finishedAt}\"}}"; + File.WriteAllText(temporaryMarker, contents); + File.Move(temporaryMarker, marker, overwrite: true); + return 0; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + try { File.Delete(temporaryMarker); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + return 3; + } + } + private static HakoPaths CreatePreviewPaths() { var parent = Path.Combine(Path.GetTempPath(), "Hako", "ProfilePreview"); diff --git a/windows/Hako/Strings/en-US/Resources.resw b/windows/Hako/Strings/en-US/Resources.resw index 2e4939c..c1d1d74 100644 --- a/windows/Hako/Strings/en-US/Resources.resw +++ b/windows/Hako/Strings/en-US/Resources.resw @@ -102,4 +102,27 @@ Hako Choose a Fabric Loader version. The installation was cancelled. An unexpected error occurred during installation. + The Microsoft session expired or was revoked. Sign in to the account again. + This account doesn't have a Minecraft: Java Edition license. + No Minecraft: Java Edition profile was found for this account. + The Minecraft version metadata is damaged or doesn't match this build. + Check the offline-mode player name. + This launch argument is controlled by the launcher settings. + This build isn't ready to launch yet. + This build is already running or preparing to launch. + The selected Java executable could not be found. + The selected Java version isn't suitable for this build. + Java's architecture doesn't match the architecture pinned to this build. + Checking Java exceeded the time limit. + The selected Java executable couldn't be checked. + The version metadata failed its integrity check. + The Minecraft asset index is damaged or failed its integrity check. + The Minecraft client file is missing or failed its integrity check. Reinstall this build. + The Minecraft session changed while preparing. Try launching again. + Java couldn't be started. See the launch log for details. + The launch command is too long for this Java version. Remove some arguments. + Launch paths or arguments contain characters that Java can't read in the system code page. Move the build to a folder with Latin characters. + The running game state couldn't be restored. + Minecraft exited with code {0}. See the launch log for details. + The selected memory limit exceeds 70% of the computer's physical memory. diff --git a/windows/Hako/Strings/ru-RU/Resources.resw b/windows/Hako/Strings/ru-RU/Resources.resw index bccca5c..189c52c 100644 --- a/windows/Hako/Strings/ru-RU/Resources.resw +++ b/windows/Hako/Strings/ru-RU/Resources.resw @@ -102,4 +102,27 @@ Выберите версию Fabric Loader. Установка отменена. Во время установки произошла непредвиденная ошибка. + Сессия Microsoft истекла или была отозвана. Войдите в аккаунт снова. + У этого аккаунта нет лицензии Minecraft: Java Edition. + Для этого аккаунта не найден профиль Minecraft: Java Edition. + Описание версии Minecraft повреждено или не соответствует сборке. + Проверьте ник для автономного режима. + Этот параметр запуска задаётся настройками лаунчера. + Сборка ещё не готова к запуску. + Эта сборка уже запущена или подготавливается. + Не найден выбранный исполняемый файл Java. + Версия выбранной Java не подходит для этой сборки. + Архитектура Java не совпадает с закреплённой архитектурой сборки. + Проверка Java превысила время ожидания. + Не удалось проверить выбранный исполняемый файл Java. + Файл описания версии не прошёл проверку целостности. + Индекс ресурсов Minecraft повреждён или не прошёл проверку. + Файл игры Minecraft отсутствует или не прошёл проверку. Повторите установку сборки. + Сессия Minecraft изменилась во время подготовки. Повторите запуск. + Не удалось запустить Java. Подробности доступны в журнале запуска. + Команда запуска слишком длинная для этой версии Java. Удалите лишние аргументы. + Пути или аргументы запуска содержат символы, которые Java не может прочитать в системной кодировке. Перенесите сборку в папку с латинскими символами. + Не удалось восстановить состояние запущенной игры. + Minecraft завершился с кодом {0}. Подробности — в журнале запуска. + Выбранный лимит памяти превышает 70% физической памяти компьютера. From f0ee9960058fe8dbc7d6b91d3456f109b99076c3 Mon Sep 17 00:00:00 2001 From: Li_is Date: Thu, 22 Oct 2026 13:47:14 +0400 Subject: [PATCH 05/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B2=20Windows-=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создание Vanilla-сборок, фоновая очередь установки, профиль сборки и запуск онлайн/offline - Настройки игры (Java, память, окно, offline-mode), иконки сборок, локализация - Тесты для defaults, иконок, store, координатора и ресурсов --- Obsidian/HakoVault/Architecture.md | 17 +- Obsidian/HakoVault/UI/Launcher.md | 83 +++ Obsidian/HakoVault/UI/Localization.md | 9 +- Obsidian/HakoVault/UI/Screens.md | 32 +- windows/Hako.Core/AppCoordinator.cs | 39 +- windows/Hako.Core/GameLaunchDefaults.cs | 6 + .../Instances/InstallationCoordinator.cs | 20 +- windows/Hako.Core/Instances/InstanceStore.cs | 21 +- .../Hako.Core/Minecraft/GameProcessRunner.cs | 13 + .../Hako.Core/Minecraft/LaunchArguments.cs | 2 + windows/Hako.Core/Minecraft/MojangClient.cs | 24 + windows/Hako.Tests/AppCoordinatorTests.cs | 24 + windows/Hako.Tests/GameLaunchDefaultsTests.cs | 23 + windows/Hako.Tests/Hako.Tests.csproj | 1 + windows/Hako.Tests/InstanceIconGlyphsTests.cs | 42 ++ windows/Hako.Tests/InstanceStoreTests.cs | 24 + windows/Hako.Tests/JavaArgumentFileTests.cs | 23 + .../Hako.Tests/LocalizationResourcesTests.cs | 45 ++ windows/Hako/App.xaml.cs | 85 ++- windows/Hako/BuildsPage.xaml | 47 +- windows/Hako/BuildsPage.xaml.cs | 208 +++++++ windows/Hako/InstanceCreationChoiceDialog.cs | 87 +++ windows/Hako/InstanceCreationPage.xaml | 79 +++ windows/Hako/InstanceCreationPage.xaml.cs | 366 +++++++++++ windows/Hako/InstanceIconGlyphs.cs | 36 ++ windows/Hako/InstanceIconImage.cs | 77 +++ windows/Hako/InstanceIconPicker.cs | 147 +++++ windows/Hako/InstanceIconView.cs | 79 +++ windows/Hako/InstanceParametersEditor.xaml | 35 ++ windows/Hako/InstanceParametersEditor.xaml.cs | 246 ++++++++ windows/Hako/InstancePlayLogic.cs | 98 +++ windows/Hako/InstanceProfilePage.xaml | 104 ++++ windows/Hako/InstanceProfilePage.xaml.cs | 586 ++++++++++++++++++ windows/Hako/JavaMemorySlider.cs | 83 +++ windows/Hako/LauncherServices.cs | 247 ++++++++ windows/Hako/MainWindow.xaml | 18 +- windows/Hako/MainWindow.xaml.cs | 324 ++++++++-- windows/Hako/PreviewBuildsFixture.cs | 74 +++ windows/Hako/ProfilePreviewEnvironment.cs | 31 +- windows/Hako/SettingsPage.xaml | 48 ++ windows/Hako/SettingsPage.xaml.cs | 169 ++++- windows/Hako/Strings/en-US/Resources.resw | 113 ++++ windows/Hako/Strings/ru-RU/Resources.resw | 113 ++++ 43 files changed, 3865 insertions(+), 83 deletions(-) create mode 100644 windows/Hako.Tests/GameLaunchDefaultsTests.cs create mode 100644 windows/Hako.Tests/InstanceIconGlyphsTests.cs create mode 100644 windows/Hako/InstanceCreationChoiceDialog.cs create mode 100644 windows/Hako/InstanceCreationPage.xaml create mode 100644 windows/Hako/InstanceCreationPage.xaml.cs create mode 100644 windows/Hako/InstanceIconGlyphs.cs create mode 100644 windows/Hako/InstanceIconImage.cs create mode 100644 windows/Hako/InstanceIconPicker.cs create mode 100644 windows/Hako/InstanceIconView.cs create mode 100644 windows/Hako/InstanceParametersEditor.xaml create mode 100644 windows/Hako/InstanceParametersEditor.xaml.cs create mode 100644 windows/Hako/InstancePlayLogic.cs create mode 100644 windows/Hako/InstanceProfilePage.xaml create mode 100644 windows/Hako/InstanceProfilePage.xaml.cs create mode 100644 windows/Hako/JavaMemorySlider.cs create mode 100644 windows/Hako/LauncherServices.cs create mode 100644 windows/Hako/PreviewBuildsFixture.cs diff --git a/Obsidian/HakoVault/Architecture.md b/Obsidian/HakoVault/Architecture.md index 9e89cd1..f5e0736 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/` | Эта база знаний | @@ -239,8 +239,11 @@ Windows собирается на `windows-2025` с .NET 8/MSBuild для x64 и ## Windows-клиент Клиент для Windows в каталоге `windows/` на первом этапе паритета с macOS: приветствие, вход в Microsoft -по коду, главная с разделами «Сборки» и «Настройки», язык, профиль с 3D-превью скина и выход. Сборки, -запуск игры, моды, ресурспаки и самообновление пока не реализованы. +по коду, главная с рейлом сборок и «Настройками» (разделы «Основные», «Игра», «Профиль»), язык, профиль +с 3D-превью скина и выход. Vanilla-сборки создаются, ставятся в фоновую очередь (пауза, продолжение, повтор), +настраиваются, удаляются и запускаются онлайн или в offline-mode — [[UI/Launcher#Windows-клиент]]. +Fabric, моды, ресурспаки, миры и самообновление пока не реализованы. +Сервисы сборок живут всё время работы приложения и создаются в `App.OnLaunched` (`LauncherServices`). `MainWindow` использует Mica под WinUI `TitleBar`; начальная клиентская область 1280×720 эффективных пикселей задаётся с масштабированием по DPI окна. Экраны — [[UI/Screens#Windows-клиент]], вход — [[Auth/MicrosoftAuth#Windows-клиент]], @@ -338,10 +341,10 @@ dotnet test windows/Hako.Tests/Hako.Tests.csproj -c Debug ### Ограничения Windows-клиента -- Не реализованы: UI и установка сборок (вкладка «Сборки» показывает пустое состояние), запуск Minecraft, моды и ресурспаки, - каталоги Modrinth и CurseForge, самообновление. -- Обновление токенов не реализовано: `MicrosoftRefreshToken` сохраняется, refresh-grant в коде нет. -- `MinecraftAccessToken` сохраняется и нигде не читается. Игровой сессии на Windows пока нет. +- Не реализованы: Fabric, моды и ресурспаки, миры, скриншоты, модпаки, дублирование и резервные копии сборок, + учёт времени игры, каталоги Modrinth и CurseForge, самообновление. +- Онлайн-запуск обновляет Minecraft-сессию через `MinecraftSessionCoordinator.ConnectForLaunchAsync` (refresh-grant + с сохранением нового refresh token); без Minecraft-профиля доступен только offline-mode сборки. ### Решения и основания diff --git a/Obsidian/HakoVault/UI/Launcher.md b/Obsidian/HakoVault/UI/Launcher.md index ca3245a..1a7736c 100644 --- a/Obsidian/HakoVault/UI/Launcher.md +++ b/Obsidian/HakoVault/UI/Launcher.md @@ -361,3 +361,86 @@ Java проверяется только при подготовке запус заменяет имя JAR и происхождение без оставления старой версии; ошибки откатывают файловую публикацию. Локальные файлы, распознанные по хешу как проекты Modrinth, тоже получают обновления; пометка «Обновляется вручную» скрыта, пока обновление доступно. + +## Windows-клиент + +Срез этапа «сборки и запуск»: Vanilla-сборки создаются, ставятся в фоновую очередь и запускаются из UI. +Fabric, моды, ресурспаки, миры, скриншоты, модпаки, дублирование, резервные копии и время игры на Windows +пока не показываются. Навигация и диалоги — [[UI/Screens#Windows-клиент]]. + +### Сервисы и потоки + +`App.OnLaunched` (`windows/Hako/App.xaml.cs`) создаёт app-lifetime `InstanceStore`, `MojangClient`, +`InstallationCoordinator`, `GameLaunchCoordinator` (с `AppCoordinator.MinecraftSessions` и `GameProcessRunner`) +и передаёт их страницам записью `LauncherServices`. После `AppCoordinator.InitializeAsync` запускаются +`InstallationCoordinator.StartAsync` и `GameLaunchCoordinator.RestoreRunningGamesAsync`; при ошибке реестра +(`InstanceStore.LoadError`) очередь не стартует, «Сборки» показывают ошибку, «+» и сохранение формы отключены, +вход и настройки работают. + +События Core (`InstancesChanged`, `BusyStateChanged`, `PropertyChanged` очереди и сессий, `RunStateChanged`) +приходят с рабочих потоков и иногда под блокировками Core. Обработчики страниц только ставят одно +обновление в `DispatcherQueue` (`CoalescedRefresh`) и не вызывают Core синхронно. Изменения хранилища +(`UpdateSettings`, `Rename`, `Delete`) и запуск (`InstancePlayLogic.Launch`) выполняются через `Task.Run`. + +### Сборки и рейл + +`BuildsPage` — `ItemsRepeater` с `UniformGridLayout` (минимум 320 DIP). `BuildCardModel` обновляет +только изменившиеся свойства: иконка, имя, «Minecraft · Loader», у неготовой сборки — состояние или стадия +с процентом и полосой прогресса, у ошибки — текст по `ErrorCode`; у `ready` — кнопка «Играть» и состояние +запуска. Нажатие на карточку открывает профиль. «В игре» появится вместе с учётом времени игры. + +Иконки: в `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) +и раскрываемые параметры `InstanceParametersEditor`. Начальные окно и полноэкранный режим берутся из глобальных +настроек. Сохранение — `InstallationCoordinator.CreateAndEnqueueAsync(draft, prepared)` и переход к «Сборкам». +Отмена, Escape и уход по рейлу подтверждаются диалогом. + +`InstanceParametersEditor`: две взаимоисключающие галочки «глобальные / Mojang» (обе сняты — свои аргументы), +аргументы Java и Minecraft (для глобальных и Mojang показываются итоговые шаблоны +`MinecraftLaunchPlan.ArgumentTemplates`, когда известно описание версии), `JavaMemorySlider` +(шаг 256 МиБ, предупреждение выше 70% памяти, предел 1,5 ГиБ для x86), окно (черновики ширины и высоты, +ошибка не меняет сохранённый размер), offline-mode с проверкой ника `OfflineUsername.IsValid`. + +### Профиль сборки + +`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`. + +Раздел «Настройки»: черновик имени с «Сохранить имя» и подтверждением переименования папки +(`InstanceStore.Rename`); иконка и параметры сохраняются автоматически (`UpdateSettings` с задержкой 500 мс, +по одному сохранению, побеждает последний черновик). Во время установки и операций с файлами настройки +заблокированы; при работающей игре они меняются и применяются при следующем запуске +(`InstanceStore.UpdateSettings` не блокируется `BuildRunning`, переименование и удаление — блокируются). +Несохранённое имя при уходе со страницы или закрытии окна вызывает выбор «Переименовать / Продолжить +без переименования / Отмена». + +### Безвозвратное удаление + +`HakoDialogs.ConfirmPermanentDeleteAsync` — общий шаг для будущих операций корзины: элементы, которые +`RecycleBin` вернул как `NotRecyclable`, удаляются `RecycleBin.PermanentDelete` только после подтверждения. + +### Предпросмотр + +`--profile-preview builds [--preview-page builds|create|profile|settings-game]` создаёт во временном корне +сборки во всех состояниях (`PreviewBuildsFixture`): готовую offline, устанавливаемую с прогрессом, в очереди, +на паузе и с ошибкой. Каталог Mojang — заготовленный ответ, остальные запросы отклоняются, установка только +сообщает прогресс; сеть и реальный `%USERPROFILE%\.hako` не используются. diff --git a/Obsidian/HakoVault/UI/Localization.md b/Obsidian/HakoVault/UI/Localization.md index 3a4cb8d..71e8576 100644 --- a/Obsidian/HakoVault/UI/Localization.md +++ b/Obsidian/HakoVault/UI/Localization.md @@ -94,6 +94,7 @@ 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.Core/Instances/InstallationCoordinator.cs` | `InstallationResourceKeys` | Ключи состояний и стадий установки, кодов ошибок установки, `InstanceStorageError.ResourceKey()`, `MojangError.ResourceKey()` и `ErrorResourceKey(Exception)` для ошибок UI | | `windows/Hako.Tests/LocalizationResourcesTests.cs` | — | Совпадение ключей ru/en и покрытие ключей ошибок и уведомлений | ### Публичные контракты @@ -113,7 +114,7 @@ Parent: [[Index]] 1. `AppCoordinator.SetLanguageAsync` меняет `Language` и сохраняет `settings.json`; сохранения сериализованы и пишут текущий язык, поэтому побеждает последний выбор. 2. `MainWindow.Coordinator_PropertyChanged` при изменении `Language` вызывает `HakoStrings.SetLanguage`. 3. `HakoStrings` поднимает `LanguageChanged`. Страницы, которые сейчас открыты (`WelcomePage`, `LoginPage`, - `SettingsPage`, `BuildsPage`), подписываются на событие в `OnNavigatedTo` и перечитывают строки; + `SettingsPage`, `BuildsPage`, `InstanceCreationPage`, `InstanceProfilePage`), подписываются на событие в `OnNavigatedTo` и перечитывают строки; в `OnNavigatedFrom` подписка снимается. Язык при старте: `AppCoordinator.InitializeAsync` берёт `AppLanguages.Parse(settings?.Language)`. @@ -126,6 +127,10 @@ 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` не используется. - Названия языков («Русский», «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..8dae216 100644 --- a/Obsidian/HakoVault/UI/Screens.md +++ b/Obsidian/HakoVault/UI/Screens.md @@ -88,8 +88,11 @@ 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` | Создание Vanilla-сборки | +| `windows/Hako/InstanceProfilePage.xaml(.cs)` | `InstanceProfilePage` | Профиль сборки: заголовок, «Играть», установка, «⋯», настройки | +| `windows/Hako/LauncherServices.cs` | `LauncherServices`, `INavigationGuard`, `CoalescedRefresh`, `HakoDialogs` | Сервисы app-lifetime для страниц, защита ухода со страницы, маршалинг событий Core, единый показ `ContentDialog` | +| `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 +104,26 @@ 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` | +| `InstanceCreation` | `InstanceCreationPage` | Форма «Своя сборка» | +| `Settings` | `SettingsPage` | Слева `ListView` из трёх разделов (`SettingsSection.General`, `Game`, `Profile`), справа панель выбранного | ### Навигация -- Для `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` отдельно передаёт ей активность экрана и окна. +- Переход по рейлу и закрытие окна сначала спрашивают текущую страницу через `INavigationGuard.ConfirmLeaveAsync`: + форма создания подтверждает отмену, профиль сборки дописывает настройки и спрашивает о несохранённом имени. + Закрытие перехватывается в `AppWindow.Closing`, затем `App.ShutdownServicesAsync` освобождает + `InstallationCoordinator` (выход не ставит установку на паузу), `InstanceStore` и `MojangClient`. +- Все `ContentDialog` показываются через `HakoDialogs.ShowAsync`: WinUI допускает один открытый диалог на `XamlRoot`. - Закрытие окна вызывает `CancelSignIn()` и завершает preview service; текущая страница сначала отменяет загрузку и останавливает frame callback, затем освобождает renderer. Деактивация или сворачивание окна, переход на другой корневой экран и выбор «Основные» выключают skin control без сброса камеры. @@ -117,6 +131,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 cffad44..5d8c144 100644 --- a/windows/Hako.Core/AppCoordinator.cs +++ b/windows/Hako.Core/AppCoordinator.cs @@ -11,13 +11,16 @@ public enum LauncherScreen Welcome, Login, Builds, + Instance, + InstanceCreation, Settings } public enum SettingsSection { General, - Profile + Profile, + Game } public enum StartupNotice @@ -47,6 +50,7 @@ public sealed class AppCoordinator : INotifyPropertyChanged 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; @@ -248,6 +252,39 @@ 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; + } + + /// 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; diff --git a/windows/Hako.Core/GameLaunchDefaults.cs b/windows/Hako.Core/GameLaunchDefaults.cs index 3776659..c1c2c54 100644 --- a/windows/Hako.Core/GameLaunchDefaults.cs +++ b/windows/Hako.Core/GameLaunchDefaults.cs @@ -29,6 +29,12 @@ public static GameLaunchDefaults From(UserSettings? settings) 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) diff --git a/windows/Hako.Core/Instances/InstallationCoordinator.cs b/windows/Hako.Core/Instances/InstallationCoordinator.cs index 48a441d..5c3a42f 100644 --- a/windows/Hako.Core/Instances/InstallationCoordinator.cs +++ b/windows/Hako.Core/Instances/InstallationCoordinator.cs @@ -43,7 +43,8 @@ public InstallationCoordinator(InstanceStore store, MojangClient client, Minecra { } - internal InstallationCoordinator( + /// Uses custom preparation and installation steps (tests and the offline UI preview). + public InstallationCoordinator( InstanceStore store, Func> prepare, Func?, CancellationToken, Task> install, @@ -709,6 +710,23 @@ public static class InstallationResourceKeys _ => "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, + 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", diff --git a/windows/Hako.Core/Instances/InstanceStore.cs b/windows/Hako.Core/Instances/InstanceStore.cs index 40afc1e..ae02abd 100644 --- a/windows/Hako.Core/Instances/InstanceStore.cs +++ b/windows/Hako.Core/Instances/InstanceStore.cs @@ -623,7 +623,12 @@ public void UpdateSettings(Guid id, InstanceDraft draft) { var registry = GetRegistrySnapshot(); var current = Find(registry, id); - ThrowIfManagementBlocked(current); + // 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); @@ -674,6 +679,20 @@ public void Update(Guid id, InstanceDraft draft) } } + /// The reason rename and delete are blocked, for localized UI text. + public InstanceStorageError? ManagementBlockedError(GameInstance instance) + { + _gate.Wait(); + try + { + return GetManagementBlock(instance); + } + finally + { + _gate.Release(); + } + } + public void Delete(Guid id) { _gate.Wait(); diff --git a/windows/Hako.Core/Minecraft/GameProcessRunner.cs b/windows/Hako.Core/Minecraft/GameProcessRunner.cs index d92c587..d5c6a7f 100644 --- a/windows/Hako.Core/Minecraft/GameProcessRunner.cs +++ b/windows/Hako.Core/Minecraft/GameProcessRunner.cs @@ -117,6 +117,18 @@ internal static (string Executable, IReadOnlyList Arguments) PrepareArgu 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); @@ -130,6 +142,7 @@ private static GameProcessLaunch Start( Action? onExit) { cancellationToken.ThrowIfCancellationRequested(); + EnsureLaunchDirectories(instanceRoot); var (executable, javaArguments) = PrepareArguments(plan, codePage); string? argumentFile = null; if (plan.JavaMajorVersion >= 9) diff --git a/windows/Hako.Core/Minecraft/LaunchArguments.cs b/windows/Hako.Core/Minecraft/LaunchArguments.cs index 68eceb2..2f4f894 100644 --- a/windows/Hako.Core/Minecraft/LaunchArguments.cs +++ b/windows/Hako.Core/Minecraft/LaunchArguments.cs @@ -184,6 +184,8 @@ public sealed partial class JavaMemoryPolicy public static JavaMemoryPolicy Current => new(ReadPhysicalMemoryBytes()); + public ulong PhysicalBytes => _physicalBytes; + public int MaximumMiB(GameArchitecture architecture) { var physicalMiB = _physicalBytes / (ulong)MiB; diff --git a/windows/Hako.Core/Minecraft/MojangClient.cs b/windows/Hako.Core/Minecraft/MojangClient.cs index c16c737..2735e22 100644 --- a/windows/Hako.Core/Minecraft/MojangClient.cs +++ b/windows/Hako.Core/Minecraft/MojangClient.cs @@ -220,6 +220,30 @@ public void 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); diff --git a/windows/Hako.Tests/AppCoordinatorTests.cs b/windows/Hako.Tests/AppCoordinatorTests.cs index 3c6c09e..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() { 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/Hako.Tests.csproj b/windows/Hako.Tests/Hako.Tests.csproj index 73b5644..c164ab2 100644 --- a/windows/Hako.Tests/Hako.Tests.csproj +++ b/windows/Hako.Tests/Hako.Tests.csproj @@ -26,6 +26,7 @@ + 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/InstanceStoreTests.cs b/windows/Hako.Tests/InstanceStoreTests.cs index 53d74fa..aaec6cc 100644 --- a/windows/Hako.Tests/InstanceStoreTests.cs +++ b/windows/Hako.Tests/InstanceStoreTests.cs @@ -487,6 +487,30 @@ public async Task RenameAndUpdateAreBlockedWhileQueuedOrInstalling() 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() { diff --git a/windows/Hako.Tests/JavaArgumentFileTests.cs b/windows/Hako.Tests/JavaArgumentFileTests.cs index 020f25e..efac413 100644 --- a/windows/Hako.Tests/JavaArgumentFileTests.cs +++ b/windows/Hako.Tests/JavaArgumentFileTests.cs @@ -136,6 +136,29 @@ public void Java8CommandLineMustBeRepresentableInAnsiCodePage() 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() { diff --git a/windows/Hako.Tests/LocalizationResourcesTests.cs b/windows/Hako.Tests/LocalizationResourcesTests.cs index 103f344..03a1602 100644 --- a/windows/Hako.Tests/LocalizationResourcesTests.cs +++ b/windows/Hako.Tests/LocalizationResourcesTests.cs @@ -60,6 +60,51 @@ public void EveryInstallationErrorCodeHasRussianAndEnglishResource() 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); + } + } + private static List ReadResourceNames(string path) => XDocument.Load(path) .Root! .Elements("data") diff --git a/windows/Hako/App.xaml.cs b/windows/Hako/App.xaml.cs index e9d21c4..94fb5ba 100644 --- a/windows/Hako/App.xaml.cs +++ b/windows/Hako/App.xaml.cs @@ -1,5 +1,7 @@ using Hako.Core; using Hako.Core.Instances; +using Hako.Core.Minecraft; +using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -10,6 +12,7 @@ public partial class App : Application { private static Window? _window; private static HakoRootLock? _previewRootLock; + private static LauncherServices? _services; private readonly AppCoordinator? _coordinator; private readonly MinecraftSkinPreviewService? _skinPreviewService; private readonly ProfilePreviewOptions? _previewOptions; @@ -99,24 +102,96 @@ protected override async void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventA InstanceStore = new InstanceStore(LaunchPaths!); await InstanceStore.LoadAsync(); - _window = new MainWindow(_coordinator!, _skinPreviewService!, _previewOptions); + var previewBuilds = _previewOptions?.Mode == ProfilePreviewMode.Builds; + var mojang = previewBuilds ? PreviewBuildsFixture.CreateMojangClient() : new MojangClient(); + var installations = previewBuilds + ? PreviewBuildsFixture.CreateInstallations(InstanceStore) + : new InstallationCoordinator(InstanceStore, mojang, new MinecraftInstaller(mojang)); + var previewInstance = previewBuilds && InstanceStore.LoadError is null ? PreviewBuildsFixture.Seed(InstanceStore) : (Guid?)null; + var games = new GameLaunchCoordinator(InstanceStore, UserSettingsService!, _coordinator!.MinecraftSessions, new GameProcessRunner()); + _services = new LauncherServices(_coordinator, UserSettingsService!, InstanceStore, installations, games, mojang, + _skinPreviewService!, _previewOptions); + + _window = new MainWindow(_services); _window.Activate(); - await _coordinator!.InitializeAsync(); + await _coordinator.InitializeAsync(); + if (InstanceStore.LoadError is null) + { + _ = StartBuildServicesAsync(installations, games); + } + if (_previewOptions is not null) { 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.Profile when previewInstance is { } id: + _coordinator.OpenInstance(id); + break; + case PreviewPage.SettingsGame: + _coordinator.OpenSettings(SettingsSection.Game); + 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.Installations.DisposeAsync(); } catch (Exception) { } + try { await services.Store.DisposeAsync(); } catch (Exception) { } + services.Mojang.Dispose(); + } + internal static void ActivateMainWindow() { if (_window is null) diff --git a/windows/Hako/BuildsPage.xaml b/windows/Hako/BuildsPage.xaml index 12a2343..73eb4ba 100644 --- a/windows/Hako/BuildsPage.xaml +++ b/windows/Hako/BuildsPage.xaml @@ -1,13 +1,54 @@ - + 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..4112933 100644 --- a/windows/Hako/BuildsPage.xaml.cs +++ b/windows/Hako/BuildsPage.xaml.cs @@ -1,28 +1,61 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using Hako.Core.Instances; +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; 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; + } + 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 +63,180 @@ private void RefreshStrings() BuildsTitle.Text = HakoStrings.Get("BuildsTitle"); BuildsEmptyTitle.Text = HakoStrings.Get("BuildsEmptyTitle"); BuildsEmptyBody.Text = HakoStrings.Get("BuildsEmptyBody"); + CreateButton.Content = HakoStrings.Get("CreateBuildButton"); + 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); + + 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); + } + } +} + +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; } + + 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(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 ? 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); + } + + 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/InstanceCreationChoiceDialog.cs b/windows/Hako/InstanceCreationChoiceDialog.cs new file mode 100644 index 0000000..3a367c3 --- /dev/null +++ b/windows/Hako/InstanceCreationChoiceDialog.cs @@ -0,0 +1,87 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace Hako; + +internal enum InstanceCreationChoice +{ + None, + Custom +} + +/// «Своя сборка» or «Готовая с Modrinth» (InstanceCreationChoiceView on macOS). Modrinth modpacks arrive later. +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("CreationModrinthUnavailable")); + modrinth.IsEnabled = false; + ToolTipService.SetToolTip(modrinth, HakoStrings.Get("CreationModrinthUnavailable")); + + 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..b558c7a --- /dev/null +++ b/windows/Hako/InstanceCreationPage.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/Hako/BuildsPage.xaml.cs b/windows/Hako/BuildsPage.xaml.cs index 03c183f..4112933 100644 --- a/windows/Hako/BuildsPage.xaml.cs +++ b/windows/Hako/BuildsPage.xaml.cs @@ -1,28 +1,61 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using Hako.Core.Instances; +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; 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; + } + 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 +63,180 @@ private void RefreshStrings() BuildsTitle.Text = HakoStrings.Get("BuildsTitle"); BuildsEmptyTitle.Text = HakoStrings.Get("BuildsEmptyTitle"); BuildsEmptyBody.Text = HakoStrings.Get("BuildsEmptyBody"); + CreateButton.Content = HakoStrings.Get("CreateBuildButton"); + 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); + + 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); + } + } +} + +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; } + + 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(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 ? 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); + } + + 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/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/InstanceCreationChoiceDialog.cs b/windows/Hako/InstanceCreationChoiceDialog.cs new file mode 100644 index 0000000..3a367c3 --- /dev/null +++ b/windows/Hako/InstanceCreationChoiceDialog.cs @@ -0,0 +1,87 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace Hako; + +internal enum InstanceCreationChoice +{ + None, + Custom +} + +/// «Своя сборка» or «Готовая с Modrinth» (InstanceCreationChoiceView on macOS). Modrinth modpacks arrive later. +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("CreationModrinthUnavailable")); + modrinth.IsEnabled = false; + ToolTipService.SetToolTip(modrinth, HakoStrings.Get("CreationModrinthUnavailable")); + + 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..b558c7a --- /dev/null +++ b/windows/Hako/InstanceCreationPage.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +