diff --git a/.github/scripts/create-release-archive.ps1 b/.github/scripts/create-release-archive.ps1 new file mode 100644 index 000000000..9ea0d6843 --- /dev/null +++ b/.github/scripts/create-release-archive.ps1 @@ -0,0 +1,63 @@ +param ( + [Parameter(Mandatory = $true)] + [string] $SourceDirectory, + + [Parameter(Mandatory = $true)] + [string] $DestinationPath +) + +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$sourcePath = (Resolve-Path -LiteralPath $SourceDirectory).Path +$sourceName = Split-Path -Leaf $sourcePath +$destinationFullPath = [System.IO.Path]::GetFullPath($DestinationPath) +$destinationDirectory = Split-Path -Parent $destinationFullPath + +if ($destinationDirectory) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null +} + +if (Test-Path -LiteralPath $destinationFullPath) { + Remove-Item -LiteralPath $destinationFullPath -Force +} + +$archive = [System.IO.Compression.ZipFile]::Open( + $destinationFullPath, + [System.IO.Compression.ZipArchiveMode]::Create) + +try { + Get-ChildItem -LiteralPath $sourcePath -Directory -Recurse -Force | ForEach-Object { + $relativePath = $_.FullName.Substring($sourcePath.Length).TrimStart('\', '/') + $entryName = "$sourceName/$($relativePath.Replace('\', '/'))/" + $null = $archive.CreateEntry($entryName) + } + + Get-ChildItem -LiteralPath $sourcePath -File -Recurse -Force | ForEach-Object { + $relativePath = $_.FullName.Substring($sourcePath.Length).TrimStart('\', '/') + $entryName = "$sourceName/$($relativePath.Replace('\', '/'))" + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, + $_.FullName, + $entryName, + [System.IO.Compression.CompressionLevel]::Optimal) | Out-Null + } +} +finally { + $archive.Dispose() +} + +$archive = [System.IO.Compression.ZipFile]::OpenRead($destinationFullPath) +try { + $invalidEntry = $archive.Entries | Where-Object { $_.FullName.Contains('\') } | Select-Object -First 1 + if ($invalidEntry) { + throw "Archive entry '$($invalidEntry.FullName)' contains a Windows path separator." + } +} +finally { + $archive.Dispose() +} + +Write-Host "Created $destinationFullPath with portable ZIP paths." diff --git a/.github/scripts/prepare-appimage.sh b/.github/scripts/prepare-appimage.sh new file mode 100644 index 000000000..d0ee05a3b --- /dev/null +++ b/.github/scripts/prepare-appimage.sh @@ -0,0 +1,107 @@ +#!/bin/sh +set -eu + +# linuxdeploy inspects every PATH entry, including inaccessible Windows paths inherited by WSL. +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +export PATH + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 RELEASE_DIRECTORY OUTPUT_APPIMAGE [VERSION]" >&2 + exit 2 +fi + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPOSITORY_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd) +RELEASE_DIRECTORY=$(CDPATH= cd -- "$1" && pwd) +OUTPUT_DIRECTORY=$(CDPATH= cd -- "$(dirname -- "$2")" && pwd) +OUTPUT_APPIMAGE="$OUTPUT_DIRECTORY/$(basename -- "$2")" +VERSION_NAME="${3:-continuous}" +WORK_DIRECTORY=$(mktemp -d) +APP_DIRECTORY="$WORK_DIRECTORY/OpenKH.AppDir" +TOOLS_DIRECTORY="$WORK_DIRECTORY/tools" +ICON_PATH="$WORK_DIRECTORY/openkh.png" + +cleanup() { + rm -rf "$WORK_DIRECTORY" +} +trap cleanup EXIT INT TERM + +require_file() { + if [ ! -f "$1" ]; then + echo "Required AppImage input is missing: $1" >&2 + exit 1 + fi +} + +require_file "$RELEASE_DIRECTORY/OpenKh.Launcher" +require_file "$RELEASE_DIRECTORY/Apps/OpenKh.Tools.ModsManager" +require_file "$REPOSITORY_ROOT/distribution/AppImage/AppRun" +require_file "$REPOSITORY_ROOT/distribution/AppImage/openkh.desktop" +require_file "$REPOSITORY_ROOT/images/openKH_Old.ico" + +mkdir -p \ + "$APP_DIRECTORY/usr/bin" \ + "$APP_DIRECTORY/usr/lib/openkh" \ + "$APP_DIRECTORY/usr/share/applications" \ + "$APP_DIRECTORY/usr/share/icons/hicolor/256x256/apps" \ + "$TOOLS_DIRECTORY" +cp -a "$RELEASE_DIRECTORY/." "$APP_DIRECTORY/usr/lib/openkh/" +chmod +x \ + "$APP_DIRECTORY/usr/lib/openkh/OpenKh.Launcher" \ + "$APP_DIRECTORY/usr/lib/openkh/Apps/OpenKh.Tools.ModsManager" +ln -s ../lib/openkh/OpenKh.Launcher "$APP_DIRECTORY/usr/bin/openkh" +ln -s ../lib/openkh/Apps/OpenKh.Tools.ModsManager "$APP_DIRECTORY/usr/bin/openkh-mod-manager" +install -m 644 \ + "$REPOSITORY_ROOT/distribution/AppImage/openkh.desktop" \ + "$APP_DIRECTORY/usr/share/applications/openkh.desktop" + +# The Windows icon already contains the approved OpenKH artwork at several sizes. +convert "${REPOSITORY_ROOT}/images/openKH_Old.ico[0]" \ + -background none \ + -gravity center \ + -resize 256x256 \ + -extent 256x256 \ + "$ICON_PATH" +install -m 644 "$ICON_PATH" "$APP_DIRECTORY/usr/share/icons/hicolor/256x256/apps/openkh.png" + +LINUXDEPLOY_VERSION="${LINUXDEPLOY_VERSION:-1-alpha-20251107-1}" +APPIMAGETOOL_VERSION="${APPIMAGETOOL_VERSION:-1.9.1}" +LINUXDEPLOY="$TOOLS_DIRECTORY/linuxdeploy-x86_64.AppImage" +APPIMAGETOOL="$TOOLS_DIRECTORY/appimagetool-x86_64.AppImage" +curl --fail --location --silent --show-error \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/${LINUXDEPLOY_VERSION}/linuxdeploy-x86_64.AppImage" \ + --output "$LINUXDEPLOY" +curl --fail --location --silent --show-error \ + "https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage" \ + --output "$APPIMAGETOOL" +chmod +x "$LINUXDEPLOY" "$APPIMAGETOOL" + +set -- \ + "$LINUXDEPLOY" \ + --appdir "$APP_DIRECTORY" \ + --desktop-file "$APP_DIRECTORY/usr/share/applications/openkh.desktop" \ + --icon-file "$APP_DIRECTORY/usr/share/icons/hicolor/256x256/apps/openkh.png" \ + --executable "$APP_DIRECTORY/usr/bin/openkh" \ + --executable "$APP_DIRECTORY/usr/bin/openkh-mod-manager" + +# Avalonia loads these libraries at runtime, so the .NET app host does not expose them to ldd. +for library_name in libfontconfig.so.1 libfreetype.so.6 libICE.so.6 libSM.so.6 libX11.so.6 libX11-xcb.so.1 libxcb.so.1; do + library_path=$(ldconfig -p | awk -v name="$library_name" '$1 == name { print $NF; exit }') + if [ -z "$library_path" ]; then + echo "Required Linux library was not found: $library_name" >&2 + exit 1 + fi + set -- "$@" --library "$library_path" +done + +APPIMAGE_EXTRACT_AND_RUN=1 "$@" +rm -f "$APP_DIRECTORY/AppRun" +install -m 755 "$REPOSITORY_ROOT/distribution/AppImage/AppRun" "$APP_DIRECTORY/AppRun" + +rm -f "$OUTPUT_APPIMAGE" +ARCH=x86_64 \ +VERSION="$VERSION_NAME" \ +APPIMAGE_EXTRACT_AND_RUN=1 \ + "$APPIMAGETOOL" "$APP_DIRECTORY" "$OUTPUT_APPIMAGE" +chmod +x "$OUTPUT_APPIMAGE" +echo "Created $OUTPUT_APPIMAGE" diff --git a/.github/scripts/prepare-linux-release.ps1 b/.github/scripts/prepare-linux-release.ps1 new file mode 100644 index 000000000..c2e7f28e4 --- /dev/null +++ b/.github/scripts/prepare-linux-release.ps1 @@ -0,0 +1,83 @@ +param ( + [string] $ReleaseDirectory = "openkh-linux-x64", + [string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$launcherProject = Join-Path $repositoryRoot "OpenKh.Tools.Launcher\OpenKh.Tools.Launcher.csproj" +$modManagerProject = Join-Path $repositoryRoot "OpenKh.Tools.ModsManager.Avalonia\OpenKh.Tools.ModsManager.csproj" +$panaceaSourceDirectory = Join-Path $repositoryRoot "OpenKh.Research.Panacea\Release" +$panaceaFileNames = @( + "OpenKH.Panacea.dll", + "avcodec-vgmstream-59.dll", + "avformat-vgmstream-59.dll", + "avutil-vgmstream-57.dll", + "bass.dll", + "bass_vgmstream.dll", + "libatrac9.dll", + "libcelt-0061.dll", + "libcelt-0110.dll", + "libg719_decode.dll", + "libmpg123-0.dll", + "libspeex-1.dll", + "libvorbis.dll", + "swresample-vgmstream-4.dll" +) + +if (Test-Path -LiteralPath $ReleaseDirectory) { + throw "Release directory '$ReleaseDirectory' already exists." +} + +$applicationsDirectory = Join-Path $ReleaseDirectory "Apps" +New-Item -ItemType Directory -Path $applicationsDirectory -Force | Out-Null + +dotnet publish ` + $launcherProject ` + --configuration $Configuration ` + --runtime linux-x64 ` + --self-contained true ` + --source "https://api.nuget.org/v3/index.json" ` + --output $ReleaseDirectory ` + /p:PublishSingleFile=true ` + /p:IncludeNativeLibrariesForSelfExtract=true ` + /p:DebugType=None ` + /p:DebugSymbols=false + +if ($LASTEXITCODE -ne 0) { + throw "Publishing the Linux launcher failed with exit code $LASTEXITCODE." +} + +dotnet publish ` + $modManagerProject ` + --configuration $Configuration ` + --runtime linux-x64 ` + --self-contained true ` + --source "https://api.nuget.org/v3/index.json" ` + --output $applicationsDirectory ` + /p:PublishSingleFile=true ` + /p:IncludeNativeLibrariesForSelfExtract=true ` + /p:DebugType=None ` + /p:DebugSymbols=false + +if ($LASTEXITCODE -ne 0) { + throw "Publishing the Linux Mod Manager failed with exit code $LASTEXITCODE." +} + +foreach ($fileName in $panaceaFileNames) { + $sourcePath = Join-Path $panaceaSourceDirectory $fileName + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "Required Panacea file '$sourcePath' does not exist." + } + Copy-Item -LiteralPath $sourcePath -Destination $applicationsDirectory +} + +Get-ChildItem -LiteralPath $ReleaseDirectory -Filter "*.pdb" -File -Recurse | + Remove-Item -Force + +Copy-Item ` + -LiteralPath (Join-Path $repositoryRoot "distribution\README-LINUX.txt") ` + -Destination (Join-Path $ReleaseDirectory "README-FIRST.txt") +Copy-Item -LiteralPath (Join-Path $repositoryRoot "distribution\install-openkh-linux.sh") -Destination $ReleaseDirectory +Copy-Item -LiteralPath (Join-Path $repositoryRoot "LICENSE") -Destination $ReleaseDirectory +Copy-Item -LiteralPath (Join-Path $repositoryRoot "NOTICE") -Destination $ReleaseDirectory diff --git a/.github/scripts/prepare-release.ps1 b/.github/scripts/prepare-release.ps1 index 8d988dc38..347eed7af 100644 --- a/.github/scripts/prepare-release.ps1 +++ b/.github/scripts/prepare-release.ps1 @@ -27,9 +27,11 @@ dotnet publish ` "OpenKh.Tools.Launcher/OpenKh.Tools.Launcher.csproj" ` --configuration $Configuration ` --runtime win-x64 ` - --self-contained false ` + --self-contained true ` + --source "https://api.nuget.org/v3/index.json" ` --output $ReleaseDirectory ` /p:PublishSingleFile=true ` + /p:IncludeNativeLibrariesForSelfExtract=true ` /p:DebugType=None ` /p:DebugSymbols=false @@ -37,6 +39,9 @@ if ($LASTEXITCODE -ne 0) { throw "Publishing OpenKH Launcher failed with exit code $LASTEXITCODE." } +Get-ChildItem -LiteralPath $ReleaseDirectory -Filter "*.pdb" -File | + Remove-Item -Force + $compatibilityExecutable = Join-Path $ReleaseDirectory "OpenKh.Tools.ModsManager.exe" Copy-Item ` -LiteralPath (Join-Path $ReleaseDirectory "OpenKh.Launcher.exe") ` diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4a2d69072..48cdbd5ff 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,9 +16,9 @@ jobs: with: submodules: recursive - name: Setup .NET - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: pre-build.ps1 @@ -33,50 +33,67 @@ jobs: - name: msbuild panacea run: | msbuild OpenKh.Research.Panacea\OpenKh.Research.Panacea.vcxproj /p:Configuration=Release /p:Platform=x64 - xcopy "OpenKh.Research.Panacea\Release\*.dll" bin\ - xcopy "OpenKh.Research.Panacea\Dependencies\*.dll" bin\ + xcopy /Y "OpenKh.Research.Panacea\Release\*.dll" bin\ + xcopy /Y "OpenKh.Research.Panacea\Dependencies\*.dll" bin\ - name: Organize release for mod users run: powershell -ExecutionPolicy Unrestricted ./.github/scripts/prepare-release.ps1 shell: pwsh + - name: Build Linux and Steam Deck release + run: powershell -ExecutionPolicy Unrestricted ./.github/scripts/prepare-linux-release.ps1 + shell: pwsh + - name: create openkh-release shell: bash env: RELEASE_TAG: "release2-${{github.run_number}}" run: | echo $RELEASE_TAG > openkh/openkh-release + echo $RELEASE_TAG > openkh-linux-x64/openkh-release - - name: zip - uses: TheDoctor0/zip-release@0.6.2 - with: - filename: openkh.zip - path: openkh + - name: Create Windows release archive + run: powershell -ExecutionPolicy Unrestricted ./.github/scripts/create-release-archive.ps1 -SourceDirectory openkh -DestinationPath openkh.zip + shell: pwsh - name: validate update archive shell: pwsh run: | - $archiveListing = (7z l openkh.zip) -join "`n" + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path "openkh.zip")) + try { + $archiveEntries = @($archive.Entries | ForEach-Object { $_.FullName }) + } + finally { + $archive.Dispose() + } $requiredEntries = @( - "openkh\OpenKh.Launcher.exe", - "openkh\OpenKh.Tools.ModsManager.exe", - "openkh\Apps\OpenKh.Tools.ModsManager.exe" + "openkh/OpenKh.Launcher.exe", + "openkh/OpenKh.Tools.ModsManager.exe", + "openkh/Apps/OpenKh.Tools.ModsManager.exe" ) foreach ($entry in $requiredEntries) { - if ($archiveListing -notmatch [regex]::Escape($entry)) { + if ($entry -notin $archiveEntries) { throw "Required update entry '$entry' is missing from openkh.zip." } } $obsoleteEntries = @( - "openkh\AdvancedTools", - "openkh\Apps\ModManager" + "openkh/AdvancedTools", + "openkh/Apps/ModManager" ) foreach ($entry in $obsoleteEntries) { - if ($archiveListing -match [regex]::Escape($entry)) { + if ($archiveEntries | Where-Object { $_.StartsWith($entry, [System.StringComparison]::OrdinalIgnoreCase) }) { throw "Obsolete update entry '$entry' is present in openkh.zip." } } + - name: Upload Linux release layout + uses: actions/upload-artifact@v4 + with: + name: openkh-linux-layout + path: openkh-linux-x64 + if-no-files-found: error + - name: "GitHub release latest" if: ${{ github.ref_name == 'master' }} uses: "marvinpinto/action-automatic-releases@latest" @@ -99,3 +116,111 @@ jobs: title: "OpenKh Build ${{github.run_number}} (${{github.ref_name}})" files: | openkh.zip + + appimage: + needs: build + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Download Linux release layout + uses: actions/download-artifact@v4 + with: + name: openkh-linux-layout + path: openkh-linux-x64 + + - name: Build portable Linux archive + shell: bash + run: | + chmod +x \ + openkh-linux-x64/OpenKh.Launcher \ + openkh-linux-x64/Apps/OpenKh.Tools.ModsManager \ + openkh-linux-x64/install-openkh-linux.sh + tar -czf openkh-linux-x64.tar.gz openkh-linux-x64 + + - name: Validate portable Linux archive + shell: bash + run: | + archive_root="$RUNNER_TEMP/openkh-tar-test" + mkdir -p "$archive_root" + tar -xzf openkh-linux-x64.tar.gz -C "$archive_root" + test -x "$archive_root/openkh-linux-x64/OpenKh.Launcher" + test -x "$archive_root/openkh-linux-x64/Apps/OpenKh.Tools.ModsManager" + test -x "$archive_root/openkh-linux-x64/install-openkh-linux.sh" + test -f "$archive_root/openkh-linux-x64/Apps/OpenKH.Panacea.dll" + + - name: Install AppImage build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes \ + imagemagick \ + libfontconfig1 \ + libfreetype6 \ + libice6 \ + libsm6 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + xvfb + + - name: Build AppImage + shell: bash + env: + RELEASE_TAG: release2-${{ github.run_number }} + run: | + sh ./.github/scripts/prepare-appimage.sh \ + openkh-linux-x64 \ + openkh-x86_64.AppImage \ + "$RELEASE_TAG" + + - name: Validate AppImage + shell: bash + run: | + ./openkh-x86_64.AppImage --appimage-version + ./openkh-x86_64.AppImage --appimage-extract >/dev/null + test -x squashfs-root/AppRun + test -x squashfs-root/usr/lib/openkh/OpenKh.Launcher + test -x squashfs-root/usr/lib/openkh/Apps/OpenKh.Tools.ModsManager + test -f squashfs-root/usr/lib/openkh/Apps/OpenKH.Panacea.dll + APPIMAGE_EXTRACT_AND_RUN=1 \ + OPENKH_DATA_ROOT="$RUNNER_TEMP/openkh-appimage-test" \ + xvfb-run -a sh -c ' + "$1" & + app_pid=$! + sleep 5 + if ! kill -0 "$app_pid" 2>/dev/null; then + wait "$app_pid" || true + echo "The AppImage launcher exited before the smoke test completed." >&2 + exit 1 + fi + kill "$app_pid" + wait "$app_pid" || true + ' sh ./openkh-x86_64.AppImage + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: openkh-appimage + path: openkh-x86_64.AppImage + if-no-files-found: error + + - name: Upload portable Linux archive + uses: actions/upload-artifact@v4 + with: + name: openkh-linux-tarball + path: openkh-linux-x64.tar.gz + if-no-files-found: error + + - name: Add AppImage to latest release + if: ${{ github.event_name == 'push' && github.ref_name == 'master' }} + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload latest openkh-x86_64.AppImage openkh-linux-x64.tar.gz --clobber + + - name: Add AppImage to numbered release + if: ${{ github.event_name == 'push' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: release2-${{ github.run_number }} + run: gh release upload "$RELEASE_TAG" openkh-x86_64.AppImage openkh-linux-x64.tar.gz --clobber diff --git a/OpenKh.Command.Bdxio.Library/OpenKh.Command.Bdxio.Library.csproj b/OpenKh.Command.Bdxio.Library/OpenKh.Command.Bdxio.Library.csproj new file mode 100644 index 000000000..f3564813f --- /dev/null +++ b/OpenKh.Command.Bdxio.Library/OpenKh.Command.Bdxio.Library.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + OpenKh.Command.Bdxio.Library + + + + + + + + + + + + + + + + + diff --git a/OpenKh.Patcher/OpenKh.Patcher.csproj b/OpenKh.Patcher/OpenKh.Patcher.csproj index 0c265a56b..d502260a9 100644 --- a/OpenKh.Patcher/OpenKh.Patcher.csproj +++ b/OpenKh.Patcher/OpenKh.Patcher.csproj @@ -10,7 +10,7 @@ - + diff --git a/OpenKh.Patcher/PatcherProcessor.cs b/OpenKh.Patcher/PatcherProcessor.cs index 60b80305b..23392e79c 100644 --- a/OpenKh.Patcher/PatcherProcessor.cs +++ b/OpenKh.Patcher/PatcherProcessor.cs @@ -8,9 +8,10 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; -using System.Linq; -using YamlDotNet.Serialization; +using System.IO; +using System.Linq; +using System.Threading; +using YamlDotNet.Serialization; namespace OpenKh.Patcher { @@ -48,9 +49,9 @@ public Context( DestinationPath = destinationPath; } - public string GetOriginalAssetPath(string path) => Path.Combine(OriginalAssetPath, path); - public string GetSourceModAssetPath(string path) => Path.Combine(SourceModAssetPath, path); - public string GetDestinationPath(string path) => Path.Combine(DestinationPath, path); + public string GetOriginalAssetPath(string path) => Path.Combine(OriginalAssetPath, NormalizeAssetPath(path)); + public string GetSourceModAssetPath(string path) => Path.Combine(SourceModAssetPath, NormalizeAssetPath(path)); + public string GetDestinationPath(string path) => Path.Combine(DestinationPath, NormalizeAssetPath(path)); public void EnsureDirectoryExists(string fileName) => Directory.CreateDirectory(Path.GetDirectoryName(fileName)); public void CopyOriginalFile(string fileName, string dstFile) { @@ -62,6 +63,10 @@ public void CopyOriginalFile(string fileName, string dstFile) File.Copy(originalFile, dstFile); } } + + private static string NormalizeAssetPath(string path) => + path.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); } public void Patch(string originalAssets, string outputDir, string modFilePath) @@ -94,9 +99,10 @@ public void Patch( IDictionary packageMap = null, string LaunchGame = null, string Language = "en", - bool Tests = false, - Dictionary collectionOptionalEnabledMods = null - ) + bool Tests = false, + Dictionary collectionOptionalEnabledMods = null, + Action progress = null + ) { if (collectionOptionalEnabledMods == null) collectionOptionalEnabledMods = new Dictionary { }; @@ -111,17 +117,23 @@ public void Patch( if (metadata.IsCollection && !metadata.CollectionGames.Contains(LaunchGame)) return; - var exclusiveLock = new object(); - metadata.Assets.AsParallel().ForAll(assetFile => - { - if (assetFile.Game != null && assetFile.Game != LaunchGame) - return; - if (assetFile.CollectionOptional == true) - if (!collectionOptionalEnabledMods.ContainsKey(assetFile.Name)) - return; - else if (!collectionOptionalEnabledMods[assetFile.Name]) - return; - var names = new List(); + var exclusiveLock = new object(); + var assets = metadata.Assets + .Where(assetFile => assetFile.Game == null || assetFile.Game == LaunchGame) + .Where(assetFile => assetFile.CollectionOptional != true || + collectionOptionalEnabledMods.TryGetValue(assetFile.Name, out var enabled) && enabled) + .ToArray(); + var progressMaximum = assets.Sum(assetFile => + 1 + (assetFile.Multi?.Count(entry => !string.IsNullOrEmpty(entry.Name)) ?? 0)); + var progressValue = 0; + + void ReportProgress() => progress?.Invoke( + Interlocked.Increment(ref progressValue), + progressMaximum); + + assets.AsParallel().ForAll(assetFile => + { + var names = new List(); names.Add(assetFile.Name); if (assetFile.Multi != null) names.AddRange(assetFile.Multi.Select(x => x.Name).Where(x => !string.IsNullOrEmpty(x))); @@ -131,8 +143,11 @@ public void Patch( if (assetFile.Platform == null) assetFile.Platform = "both"; - if (assetFile.Required && !File.Exists(context.GetOriginalAssetPath(name))) - continue; + if (assetFile.Required && !File.Exists(context.GetOriginalAssetPath(name))) + { + ReportProgress(); + continue; + } string _packageFile = null; switch (LaunchGame) @@ -177,18 +192,27 @@ public void Patch( { default: { - if (assetFile.Platform.ToLower() == "pc") - continue; - - else if (_pcFile) - continue; + if (assetFile.Platform.ToLower() == "pc") + { + ReportProgress(); + continue; + } + + else if (_pcFile) + { + ReportProgress(); + continue; + } } break; case 2: { - if (assetFile.Platform.ToLower() == "ps2") - continue; + if (assetFile.Platform.ToLower() == "ps2") + { + ReportProgress(); + continue; + } if (assetFile.Platform.ToLower() != "ps2") packageMapLocation = _packageFile + "/" + _extraPath + name; @@ -205,7 +229,7 @@ public void Patch( // Protect against multiple mods having the same file where one uses forward slash and one uses backslash lock (exclusiveLock) { - packageMap[name.Replace("\\", "/")] = packageMapLocation; + packageMap[name.Replace("\\", "/")] = packageMapLocation.Replace("\\", "/"); } } @@ -343,10 +367,11 @@ public void Patch( } } catch (IOException) { } - //This is here so the user does not have to close Mod Manager to see what the warnings were if any. Helpful especially on PC since the build window closes after build unlike emulator where it stays open during mod injection. - Log.Flush(); - } - }); + //This is here so the user does not have to close Mod Manager to see what the warnings were if any. Helpful especially on PC since the build window closes after build unlike emulator where it stays open during mod injection. + Log.Flush(); + ReportProgress(); + } + }); } catch (Exception ex) diff --git a/OpenKh.Research.Panacea/OpenKh.Research.Panacea.vcxproj b/OpenKh.Research.Panacea/OpenKh.Research.Panacea.vcxproj index 3b2c8bca0..0da79547d 100644 --- a/OpenKh.Research.Panacea/OpenKh.Research.Panacea.vcxproj +++ b/OpenKh.Research.Panacea/OpenKh.Research.Panacea.vcxproj @@ -70,7 +70,7 @@ bass_vgmstream.dll;bass.dll;%(DelayLoadDLLs) - copy Dependencies\*.dll ..\Debug\ + copy /Y "Dependencies\*.dll" "$(OutDir)" @@ -98,7 +98,7 @@ bass_vgmstream.dll;bass.dll;%(DelayLoadDLLs) - copy Dependencies\*.dll ..\Release\ + copy /Y "Dependencies\*.dll" "$(OutDir)" @@ -115,4 +115,4 @@ - \ No newline at end of file + diff --git a/OpenKh.Tests.Launcher.Avalonia/LauncherNavigationIntegrationTests.cs b/OpenKh.Tests.Launcher.Avalonia/LauncherNavigationIntegrationTests.cs new file mode 100644 index 000000000..c74a38ad7 --- /dev/null +++ b/OpenKh.Tests.Launcher.Avalonia/LauncherNavigationIntegrationTests.cs @@ -0,0 +1,224 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using OpenKh.Tools.Launcher; +using OpenKh.Tools.Launcher.Updates; +using OpenKh.Tools.ModsManager.Avalonia.Services; +using Xunit; + +[assembly: AvaloniaTestApplication(typeof(App))] + +namespace OpenKh.Tests.Launcher.Avalonia; + +public sealed class LauncherNavigationIntegrationTests +{ + private static readonly ControllerAction[] Directions = + [ + ControllerAction.NavigateUp, + ControllerAction.NavigateDown, + ControllerAction.NavigateLeft, + ControllerAction.NavigateRight + ]; + + [Fact] + public void DataDirectoryDefaultsToInstallationDirectory() + { + var installationDirectory = Path.Combine(Path.GetTempPath(), "openkh-installation"); + + var result = LauncherInstallation.DetectDataDirectory(installationDirectory, null); + + Assert.Equal(Path.GetFullPath(installationDirectory), result); + } + + [Fact] + public void DataDirectoryUsesAppImageOverride() + { + var installationDirectory = Path.Combine(Path.GetTempPath(), "openkh-installation"); + var dataDirectory = Path.Combine(Path.GetTempPath(), "openkh-data"); + + var result = LauncherInstallation.DetectDataDirectory(installationDirectory, dataDirectory); + + Assert.Equal(Path.GetFullPath(dataDirectory), result); + } + + [AvaloniaFact] + public void HomeHasAConnectedControllerNavigationGraph() + { + using var controller = new TestControllerInputService(); + var launcher = new MainWindow(controller, checkUpdatesOnOpen: false); + launcher.Show(); + RefreshLayout(launcher, 1100, 740); + + AssertConnected("Launcher home", launcher, launcher.HandleControllerAction); + launcher.Close(); + } + + [AvaloniaFact] + public void ToolsHasAConnectedControllerNavigationGraph() + { + using var controller = new TestControllerInputService(); + var launcher = new MainWindow(controller, checkUpdatesOnOpen: false); + launcher.Show(); + + var homePanel = launcher.FindControl("HomePanel")!; + var toolsPanel = launcher.FindControl("ToolsPanel")!; + var toolsList = launcher.FindControl("ToolsList")!; + homePanel.IsVisible = false; + toolsPanel.IsVisible = true; + toolsList.ItemsSource = new[] + { + new MainWindow.ToolEntry("OpenKh.Tools.First.exe", "First tool", "First test tool", "first", false), + new MainWindow.ToolEntry("OpenKh.Tools.Second.exe", "Second tool", "Second test tool", "second", true) + }; + toolsList.SelectedIndex = 0; + RefreshLayout(launcher, 1100, 740); + + Assert.Equal(2, toolsList.GetVisualDescendants().OfType().Count()); + AssertConnected("Launcher tools", launcher, launcher.HandleControllerAction); + launcher.Close(); + } + + [AvaloniaFact] + public void MessageDialogHasAConnectedControllerNavigationGraph() + { + var dialogType = typeof(MainWindow).Assembly.GetType("OpenKh.Tools.Launcher.MessageDialog", throwOnError: true)!; + var dialog = (Window)Activator.CreateInstance(dialogType, "Test", "Test message", true)!; + var handler = dialogType.GetMethod( + "HandleControllerAction", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + dialog.Show(); + RefreshLayout(dialog, 520, 260); + + AssertConnected( + "Launcher message dialog", + dialog, + action => handler.Invoke(dialog, [action])); + dialog.Close(); + } + + private static void AssertConnected(string name, Control root, Action handle) + { + var targets = GetNavigationTargets(root); + Assert.True(targets.Length > 0, $"{name} has no controller navigation targets."); + + var edges = targets.ToDictionary(target => target, _ => new HashSet()); + foreach (var source in targets) + { + Assert.True(FocusTarget(source), $"{name} could not focus {Describe(source)}."); + foreach (var direction in Directions) + { + Assert.True(FocusTarget(source), $"{name} lost {Describe(source)} before {direction}."); + handle(direction); + Dispatcher.UIThread.RunJobs(); + var focused = TopLevel.GetTopLevel(root)?.FocusManager?.GetFocusedElement() as Control; + var target = GetOutermostTarget(focused); + Assert.True(target is not null && targets.Contains(target), + $"{name} moved outside its active screen after {direction} from {Describe(source)}."); + edges[source].Add(target!); + } + } + + var visited = new HashSet { targets[0] }; + var pending = new Queue(); + pending.Enqueue(targets[0]); + while (pending.TryDequeue(out var current)) + { + foreach (var next in edges[current]) + { + if (visited.Add(next)) + pending.Enqueue(next); + } + } + + var unreachable = targets.Where(target => !visited.Contains(target)).Select(Describe).ToArray(); + Assert.True(unreachable.Length == 0, + $"{name} has unreachable controls: {string.Join(", ", unreachable)}."); + } + + private static Control[] GetNavigationTargets(Control root) => root.GetVisualDescendants() + .OfType() + .Where(IsAvailable) + .Where(IsNavigationKind) + .Where(control => !control.GetVisualAncestors() + .OfType() + .Any(ancestor => IsAvailable(ancestor) && IsNavigationKind(ancestor))) + .ToArray(); + + private static Control? GetOutermostTarget(Control? control) => control? + .GetVisualAncestors() + .Prepend(control) + .OfType() + .Where(IsAvailable) + .Where(IsNavigationKind) + .LastOrDefault(); + + private static bool IsAvailable(Control control) => + control.Focusable && + control.IsVisible && + control.IsEffectivelyEnabled && + control.GetVisualAncestors() + .OfType() + .All(ancestor => ancestor.IsVisible && ancestor.IsEffectivelyEnabled); + + private static bool IsNavigationKind(Control control) => + control is Button or TextBox or ComboBox or CheckBox or ToggleSwitch or Expander or ListBoxItem; + + private static bool FocusTarget(Control target) + { + if (target.Focus(NavigationMethod.Directional)) + return true; + + return target.GetVisualDescendants() + .OfType() + .Where(IsAvailable) + .Any(control => control.Focus(NavigationMethod.Directional)); + } + + private static string Describe(Control control) => control switch + { + Button button => $"Button:{button.Name ?? button.Content?.ToString()}", + TextBox textBox => $"TextBox:{textBox.Name ?? textBox.PlaceholderText}", + ComboBox comboBox => $"ComboBox:{comboBox.Name}", + ListBoxItem item => $"ListBoxItem:{item.DataContext}", + _ => $"{control.GetType().Name}:{control.Name}" + }; + + private static void RefreshLayout(Window window, double width, double height) + { + window.Width = width; + window.Height = height; + Dispatcher.UIThread.RunJobs(); + window.InvalidateMeasure(); + (window.Content as Control)?.InvalidateMeasure(); + window.Measure(new Size(width, height)); + window.Arrange(new Rect(0, 0, width, height)); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + } + + private sealed class TestControllerInputService : IControllerInputService + { + public event Action? ActionTriggered; + public event Action? ConnectionChanged { add { } remove { } } + public event Action? StatusChanged { add { } remove { } } + public bool IsConnected => true; + public string StatusText => "Controller connected"; + public string NavigationHelpText => "Controller navigation"; + public void Start() { } + public void Dispatch(ControllerAction action) => ActionTriggered?.Invoke(action); + public IDisposable Capture(Action handler) => new EmptyDisposable(); + public void Dispose() { } + + private sealed class EmptyDisposable : IDisposable + { + public void Dispose() { } + } + } +} diff --git a/OpenKh.Tests.Launcher.Avalonia/OpenKh.Tests.Launcher.Avalonia.csproj b/OpenKh.Tests.Launcher.Avalonia/OpenKh.Tests.Launcher.Avalonia.csproj new file mode 100644 index 000000000..05c879aa3 --- /dev/null +++ b/OpenKh.Tests.Launcher.Avalonia/OpenKh.Tests.Launcher.Avalonia.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + enable + enable + false + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/OpenKh.Tests.Launcher.Avalonia/UpdateArchiveTests.cs b/OpenKh.Tests.Launcher.Avalonia/UpdateArchiveTests.cs new file mode 100644 index 000000000..b743152c6 --- /dev/null +++ b/OpenKh.Tests.Launcher.Avalonia/UpdateArchiveTests.cs @@ -0,0 +1,50 @@ +using System.Formats.Tar; +using System.IO.Compression; +using OpenKh.Tools.Launcher.Updates; +using Xunit; + +namespace OpenKh.Tests.Launcher.Avalonia; + +public sealed class UpdateArchiveTests : IDisposable +{ + private readonly string _rootDirectory = Path.Combine( + Path.GetTempPath(), + "OpenKhUpdateArchiveTests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void ExtractArchiveReadsPortableLinuxTarball() + { + var packageDirectory = Path.Combine(_rootDirectory, "source", "openkh-linux-x64"); + var applicationDirectory = Path.Combine(packageDirectory, "Apps"); + Directory.CreateDirectory(applicationDirectory); + File.WriteAllText(Path.Combine(packageDirectory, "OpenKh.Launcher"), "launcher"); + File.WriteAllText(Path.Combine(applicationDirectory, "OpenKh.Tools.ModsManager"), "manager"); + var archivePath = Path.Combine(_rootDirectory, "openkh-linux-x64.tar.gz"); + Directory.CreateDirectory(_rootDirectory); + using (var output = File.Create(archivePath)) + using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize)) + TarFile.CreateFromDirectory(packageDirectory, gzip, includeBaseDirectory: true); + + var extractionDirectory = Path.Combine(_rootDirectory, "extracted"); + Directory.CreateDirectory(extractionDirectory); + OpenKhUpdateInstallerService.ExtractArchive(archivePath, extractionDirectory); + + Assert.Equal( + "launcher", + File.ReadAllText(Path.Combine(extractionDirectory, "openkh-linux-x64", "OpenKh.Launcher"))); + Assert.Equal( + "manager", + File.ReadAllText(Path.Combine( + extractionDirectory, + "openkh-linux-x64", + "Apps", + "OpenKh.Tools.ModsManager"))); + } + + public void Dispose() + { + if (Directory.Exists(_rootDirectory)) + Directory.Delete(_rootDirectory, true); + } +} diff --git a/OpenKh.Tests.ModsManager.Avalonia/AllWindowNavigationIntegrationTests.cs b/OpenKh.Tests.ModsManager.Avalonia/AllWindowNavigationIntegrationTests.cs new file mode 100644 index 000000000..90d7cbd2e --- /dev/null +++ b/OpenKh.Tests.ModsManager.Avalonia/AllWindowNavigationIntegrationTests.cs @@ -0,0 +1,198 @@ +using global::Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Themes.Fluent; +using Avalonia.Threading; +using Avalonia.VisualTree; +using OpenKh.Tools.ModsManager.Avalonia.Services; +using ModViews = OpenKh.Tools.ModsManager.Avalonia.Views; +using Xunit; + +namespace OpenKh.Tests.ModsManager.Avalonia; + +public sealed class AllWindowNavigationIntegrationTests +{ + private static readonly ControllerAction[] Directions = + [ + ControllerAction.NavigateUp, + ControllerAction.NavigateDown, + ControllerAction.NavigateLeft, + ControllerAction.NavigateRight + ]; + + [AvaloniaFact] + public void EveryModManagerDialogHasAConnectedControllerNavigationGraph() + { + var screens = new (string Name, Func Create, Action Handle)[] + { + ("Collection settings", () => new ModViews.CollectionSettingsWindow(), (view, action) => ((ModViews.CollectionSettingsWindow)view).HandleControllerAction(action)), + ("Confirmation", () => new ModViews.ConfirmationWindow(), (view, action) => ((ModViews.ConfirmationWindow)view).HandleControllerAction(action)), + ("Controller keyboard", () => new ModViews.ControllerKeyboardWindow(), (view, action) => ((ModViews.ControllerKeyboardWindow)view).HandleControllerAction(action)), + ("Creator", () => new ModViews.CreatorWindow(), (view, action) => ((ModViews.CreatorWindow)view).HandleControllerAction(action)), + ("Info", () => new ModViews.InfoWindow(), (view, action) => ((ModViews.InfoWindow)view).HandleControllerAction(action)), + ("Install mods", () => new ModViews.InstallModWindow(), (view, action) => ((ModViews.InstallModWindow)view).HandleControllerAction(action)), + ("Browse mods", () => new ModViews.OnlineModsWindow(), (view, action) => ((ModViews.OnlineModsWindow)view).HandleControllerAction(action)), + ("Presets", () => new ModViews.PresetsWindow(), (view, action) => ((ModViews.PresetsWindow)view).HandleControllerAction(action)), + ("Settings", () => new ModViews.SettingsWindow(), (view, action) => ((ModViews.SettingsWindow)view).HandleControllerAction(action)), + ("Setup", () => new ModViews.SetupWindow(), (view, action) => ((ModViews.SetupWindow)view).HandleControllerAction(action)), + ("Target files", () => new ModViews.TargetFilesWindow(), (view, action) => ((ModViews.TargetFilesWindow)view).HandleControllerAction(action)) + }; + + var windowSizes = new[] + { + new Size(1500, 1000), + new Size(960, 640) + }; + + foreach (var windowSize in windowSizes) + { + foreach (var screen in screens) + { + var view = screen.Create(); + var window = ShowContent(view, windowSize.Width, windowSize.Height); + AssertConnected( + $"{screen.Name} at {windowSize.Width}x{windowSize.Height}", + view, + action => screen.Handle(view, action)); + window.Close(); + } + } + } + + private static void AssertConnected(string name, Control root, Action handle) + => AssertConnectedCore(name, root, direction => handle(direction)); + + private static void AssertConnectedCore(string name, Control root, Action handle) + { + var targets = GetNavigationTargets(root); + Assert.True(targets.Length > 0, $"{name} has no controller navigation targets."); + + var edges = targets.ToDictionary(target => target, _ => new HashSet()); + foreach (var source in targets) + { + source.BringIntoView(); + Dispatcher.UIThread.RunJobs(); + Assert.True(FocusTarget(source), $"{name} could not focus {Describe(source)}."); + foreach (var direction in Directions) + { + source.BringIntoView(); + Dispatcher.UIThread.RunJobs(); + Assert.True(FocusTarget(source), $"{name} lost {Describe(source)} before {direction}."); + handle(direction); + Dispatcher.UIThread.RunJobs(); + var focused = TopLevel.GetTopLevel(root)?.FocusManager?.GetFocusedElement() as Control; + var target = GetOutermostTarget(focused); + Assert.True(target is not null && targets.Contains(target), $"{name} moved outside its active screen after {direction} from {Describe(source)}."); + edges[source].Add(target!); + } + } + + var visited = new HashSet { targets[0] }; + var pending = new Queue(); + pending.Enqueue(targets[0]); + while (pending.TryDequeue(out var current)) + { + foreach (var next in edges[current]) + { + if (visited.Add(next)) + pending.Enqueue(next); + } + } + + var unreachable = targets.Where(target => !visited.Contains(target)).Select(Describe).ToArray(); + var graph = string.Join("; ", edges.Select(edge => + $"{Describe(edge.Key)} -> {string.Join(" | ", edge.Value.Select(Describe))}")); + var bounds = string.Join("; ", targets.Select(target => + { + var transform = target.TransformToVisual(root); + var position = transform is null + ? target.Bounds + : new Rect(target.Bounds.Size).TransformToAABB(transform.Value); + return $"{Describe(target)}={position}"; + })); + Assert.True(unreachable.Length == 0, + $"{name} has unreachable controls: {string.Join(", ", unreachable)}. Graph: {graph}. Bounds: {bounds}"); + } + + private static Control[] GetNavigationTargets(Control root) => root.GetVisualDescendants() + .OfType() + .Where(IsAvailable) + .Where(IsNavigationKind) + .Where(control => !control.GetVisualAncestors() + .OfType() + .Any(ancestor => + ancestor is not Expander && + IsAvailable(ancestor) && + IsNavigationKind(ancestor))) + .ToArray(); + + private static Control? GetOutermostTarget(Control? control) => control? + .GetVisualAncestors() + .Prepend(control) + .OfType() + .Where(IsAvailable) + .Where(IsNavigationKind) + .LastOrDefault(); + + private static bool IsAvailable(Control control) => + control.Focusable && + control.IsVisible && + control.IsEffectivelyEnabled && + control.GetVisualAncestors() + .OfType() + .All(ancestor => ancestor.IsVisible && ancestor.IsEffectivelyEnabled); + + private static bool IsNavigationKind(Control control) => + control is Button or TextBox or ComboBox or CheckBox or ToggleSwitch or Expander or ListBoxItem; + + private static string Describe(Control control) => control switch + { + CheckBox checkBox => $"CheckBox:{checkBox.Name ?? checkBox.Content?.ToString()}", + Button button => $"Button:{button.Name ?? button.Content?.ToString()}", + TextBox textBox => $"TextBox:{textBox.Name ?? textBox.PlaceholderText}", + ComboBox comboBox => $"ComboBox:{comboBox.Name}", + ListBoxItem item => $"ListBoxItem:{item.DataContext}", + _ => $"{control.GetType().Name}:{control.Name}" + }; + + private static Window ShowContent(Control content, double width, double height) + { + if (Application.Current?.Styles.OfType().Any() == false) + Application.Current.Styles.Add(new FluentTheme()); + + var window = content as Window ?? new Window { Content = content }; + window.Width = width; + window.Height = height; + window.Show(); + RefreshLayout(window, width, height); + return window; + } + + private static void RefreshLayout(Window window, double width, double height) + { + Dispatcher.UIThread.RunJobs(); + window.InvalidateMeasure(); + (window.Content as Control)?.InvalidateMeasure(); + window.Measure(new Size(width, height)); + window.Arrange(new Rect(0, 0, width, height)); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + } + + private static bool FocusTarget(Control target) + { + if (target.Focus(NavigationMethod.Directional)) + return true; + + return target.GetVisualDescendants() + .OfType() + .Where(IsAvailable) + .Any(control => control.Focus(NavigationMethod.Directional)); + } + +} diff --git a/OpenKh.Tests.ModsManager.Avalonia/ControllerNavigationIntegrationTests.cs b/OpenKh.Tests.ModsManager.Avalonia/ControllerNavigationIntegrationTests.cs new file mode 100644 index 000000000..6e4f793f1 --- /dev/null +++ b/OpenKh.Tests.ModsManager.Avalonia/ControllerNavigationIntegrationTests.cs @@ -0,0 +1,775 @@ +using System.Collections.ObjectModel; +using global::Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Themes.Fluent; +using Avalonia.Threading; +using Avalonia.VisualTree; +using OpenKh.Tools.ModsManager.Avalonia.Services; +using OpenKh.Tools.ModsManager.Avalonia.ViewModels; +using OpenKh.Tools.ModsManager.Avalonia.Views; +using OpenKh.Tools.ModsManager.Core; +using System.Reflection; +using Xunit; + +namespace OpenKh.Tests.ModsManager.Avalonia; + +public class ControllerNavigationIntegrationTests +{ + [AvaloniaFact] + public void SetupMapsEveryAdvancedStorageBrowseButtonToItsField() + { + var setup = new SetupWindow(); + var resolver = typeof(SetupWindow).GetMethod( + "GetPathTextBox", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + Assert.Same( + setup.FindControl("ModStorageTextBox"), + resolver.Invoke(setup, ["ModStorageTextBox"])); + Assert.Same( + setup.FindControl("CollectionStorageTextBox"), + resolver.Invoke(setup, ["CollectionStorageTextBox"])); + Assert.Same( + setup.FindControl("BuiltModsTextBox"), + resolver.Invoke(setup, ["BuiltModsTextBox"])); + } + + [AvaloniaFact] + public void Pcsx2ExtractionConfirmationOnlyDescribesIsoExtraction() + { + var root = Path.Combine(Path.GetTempPath(), "OpenKhSetupConfirmationTests", Guid.NewGuid().ToString("N")); + try + { + var layout = InstallationLayout.Detect("ignored", ["--data-root", root]); + var configuration = new ModManagerConfigurationService(layout); + configuration.Current.GameEdition = 1; + var viewModel = new SetupWindowViewModel(configuration); + var resolver = typeof(SetupWindow).GetMethod( + "GetExtractionConfirmationDescription", + BindingFlags.Static | BindingFlags.NonPublic)!; + + var message = Assert.IsType(resolver.Invoke(null, [viewModel])); + + Assert.Contains("ISO files", message); + Assert.Contains("may be overwritten", message); + Assert.DoesNotContain("remastered", message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("disk space", message, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, true); + } + } + + [AvaloniaFact] + public void InstallDialogUsesSeparateRepositoryAndLocalFileActions() + { + var install = new InstallModWindow(); + + Assert.NotNull(install.FindControl("SourceTextBox")); + Assert.NotNull(install.FindControl("BranchTextBox")); + Assert.NotNull(install.FindControl -