diff --git a/.github/scripts/prepare-release.ps1 b/.github/scripts/prepare-release.ps1 new file mode 100644 index 000000000..d72de295c --- /dev/null +++ b/.github/scripts/prepare-release.ps1 @@ -0,0 +1,113 @@ +param ( + [string] $BuildDirectory = "bin", + [string] $ReleaseDirectory = "openkh", + [string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $BuildDirectory -PathType Container)) { + throw "Build directory '$BuildDirectory' does not exist." +} + +if (Test-Path -LiteralPath $ReleaseDirectory) { + throw "Release directory '$ReleaseDirectory' already exists." +} + +New-Item -ItemType Directory -Path $ReleaseDirectory | Out-Null + +$applicationsDirectory = Join-Path $ReleaseDirectory "Apps" +$modManagerDirectory = Join-Path $applicationsDirectory "ModManager" +New-Item -ItemType Directory -Path $modManagerDirectory -Force | Out-Null + +$legacyFileManifest = Join-Path $applicationsDirectory "legacy-release-files.txt" +$legacyDirectoryManifest = Join-Path $applicationsDirectory "legacy-release-directories.txt" +Get-ChildItem -LiteralPath $BuildDirectory -File | + Select-Object -ExpandProperty Name | + Sort-Object | + Set-Content -LiteralPath $legacyFileManifest -Encoding UTF8 +Get-ChildItem -LiteralPath $BuildDirectory -Directory | + Select-Object -ExpandProperty Name | + Sort-Object | + Set-Content -LiteralPath $legacyDirectoryManifest -Encoding UTF8 + +dotnet publish ` + "OpenKh.Tools.Launcher/OpenKh.Tools.Launcher.csproj" ` + --configuration $Configuration ` + --runtime win-x64 ` + --self-contained false ` + --output $ReleaseDirectory ` + /p:PublishSingleFile=true ` + /p:DebugType=None ` + /p:DebugSymbols=false + +if ($LASTEXITCODE -ne 0) { + throw "Publishing OpenKH Launcher failed with exit code $LASTEXITCODE." +} + +$compatibilityExecutable = Join-Path $ReleaseDirectory "OpenKh.Tools.ModsManager.exe" +Copy-Item ` + -LiteralPath (Join-Path $ReleaseDirectory "OpenKh.Launcher.exe") ` + -Destination $compatibilityExecutable +(Get-Item -LiteralPath $compatibilityExecutable).Attributes += "Hidden" + +dotnet publish ` + "OpenKh.Tools.ModsManager/OpenKh.Tools.ModsManager.csproj" ` + --configuration $Configuration ` + --output $modManagerDirectory ` + /p:DebugType=None ` + /p:DebugSymbols=false + +if ($LASTEXITCODE -ne 0) { + throw "Publishing Mods Manager failed with exit code $LASTEXITCODE." +} + +$referencedCommandArtifacts = Get-ChildItem -LiteralPath $modManagerDirectory -File | Where-Object { + $_.Name -like "OpenKh.Command.*" -and $_.Extension -ne ".dll" +} + +foreach ($referencedCommandArtifact in $referencedCommandArtifacts) { + Remove-Item -LiteralPath $referencedCommandArtifact.FullName +} + +$panaceaFiles = @( + "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" +) + +foreach ($fileName in $panaceaFiles) { + $sourcePath = Join-Path $BuildDirectory $fileName + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "Required Panacea file '$sourcePath' does not exist." + } + + Copy-Item -LiteralPath $sourcePath -Destination $modManagerDirectory +} + +Copy-Item -LiteralPath "distribution/README-FIRST.txt" -Destination $ReleaseDirectory +Copy-Item -LiteralPath "LICENSE" -Destination $ReleaseDirectory +Copy-Item -LiteralPath "NOTICE" -Destination $ReleaseDirectory + +$advancedToolsDirectory = Join-Path $ReleaseDirectory "AdvancedTools" +Move-Item -LiteralPath $BuildDirectory -Destination $advancedToolsDirectory + +$duplicateApplicationFiles = Get-ChildItem -LiteralPath $advancedToolsDirectory -File | Where-Object { + $_.Name -like "OpenKh.Launcher.*" -or + $_.Name -like "OpenKh.Tools.ModsManager.*" +} + +foreach ($duplicateFile in $duplicateApplicationFiles) { + Remove-Item -LiteralPath $duplicateFile.FullName +} diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f0afbad26..f2349c5a7 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -27,7 +27,7 @@ jobs: - name: build.ps1 run: powershell -ExecutionPolicy Unrestricted ./build.ps1 shell: pwsh - + - name: setup-msbuild uses: microsoft/setup-msbuild@v1.1.3 - name: msbuild panacea @@ -35,23 +35,39 @@ jobs: 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\ - + + - name: Organize release for mod users + run: powershell -ExecutionPolicy Unrestricted ./.github/scripts/prepare-release.ps1 + shell: pwsh + - name: create openkh-release shell: bash env: RELEASE_TAG: "release2-${{github.run_number}}" run: | - echo $RELEASE_TAG > bin/openkh-release - - - name: bin → openkh - run: ren bin openkh - shell: pwsh + echo $RELEASE_TAG > openkh/openkh-release + - name: zip uses: TheDoctor0/zip-release@0.6.2 with: filename: openkh.zip path: openkh + - name: validate update archive + shell: pwsh + run: | + $archiveListing = (7z l openkh.zip) -join "`n" + $requiredEntries = @( + "openkh\OpenKh.Launcher.exe", + "openkh\OpenKh.Tools.ModsManager.exe", + "openkh\Apps\ModManager\OpenKh.Tools.ModsManager.exe" + ) + foreach ($entry in $requiredEntries) { + if ($archiveListing -notmatch [regex]::Escape($entry)) { + throw "Required update entry '$entry' is missing from openkh.zip." + } + } + - name: "GitHub release latest" if: ${{ github.ref_name == 'master' }} uses: "marvinpinto/action-automatic-releases@latest" diff --git a/.gitignore b/.gitignore index 9824a46a3..1b7b4c9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # Project specific files .tests/ +/openkh/ +/openkh-*/ +/OpenKh-*-release.zip # User-specific files *.suo diff --git a/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs b/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs new file mode 100644 index 000000000..5fd143ab5 --- /dev/null +++ b/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs @@ -0,0 +1,21 @@ +using OpenKh.Tools.ModsManager.Services; +using Xunit; + +namespace OpenKh.Tests.ModsManager +{ + public class OpenkhInstallationTest + { + [Theory] + [InlineData(@"C:\OpenKh", @"C:\OpenKh")] + [InlineData(@"C:\OpenKh\Apps\ModManager", @"C:\OpenKh")] + [InlineData(@"C:\OpenKh\apps\modmanager", @"C:\OpenKh")] + public void GetDirectoryReturnsInstallationRoot(string applicationDirectory, string expectedDirectory) + { + Assert.Equal( + Path.GetFullPath(expectedDirectory), + OpenkhInstallation.GetDirectory(applicationDirectory), + ignoreCase: true + ); + } + } +} diff --git a/OpenKh.Tools.Launcher/App.xaml b/OpenKh.Tools.Launcher/App.xaml new file mode 100644 index 000000000..9e16e5150 --- /dev/null +++ b/OpenKh.Tools.Launcher/App.xaml @@ -0,0 +1,6 @@ + + + + diff --git a/OpenKh.Tools.Launcher/App.xaml.cs b/OpenKh.Tools.Launcher/App.xaml.cs new file mode 100644 index 000000000..631bf9eca --- /dev/null +++ b/OpenKh.Tools.Launcher/App.xaml.cs @@ -0,0 +1,20 @@ +using System.Windows; + +namespace OpenKh.Tools.Launcher; + +public partial class App : Application +{ + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + if (LegacyInstallationMigration.TryStartModManager()) + { + Shutdown(); + return; + } + + LegacyInstallationMigration.ScheduleCleanupIfNeeded(); + new MainWindow().Show(); + } +} diff --git a/OpenKh.Tools.Launcher/DesktopShortcutService.cs b/OpenKh.Tools.Launcher/DesktopShortcutService.cs new file mode 100644 index 000000000..89a76d3b7 --- /dev/null +++ b/OpenKh.Tools.Launcher/DesktopShortcutService.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Runtime.InteropServices; + +namespace OpenKh.Tools.Launcher; + +internal static class DesktopShortcutService +{ + public static string CreateModManagerShortcut(string targetPath, string? shortcutDirectory = null) + { + shortcutDirectory ??= Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + var shortcutPath = Path.Combine(shortcutDirectory, "OpenKH Mod Manager.lnk"); + var shellType = Type.GetTypeFromProgID("WScript.Shell") + ?? throw new InvalidOperationException("Windows Script Host is not available."); + object? shell = null; + object? shortcut = null; + + try + { + shell = Activator.CreateInstance(shellType) + ?? throw new InvalidOperationException("Windows Script Host could not be started."); + shortcut = shellType.InvokeMember( + "CreateShortcut", + System.Reflection.BindingFlags.InvokeMethod, + null, + shell, + new object[] { shortcutPath } + ) ?? throw new InvalidOperationException("The shortcut could not be created."); + + var shortcutType = shortcut.GetType(); + shortcutType.InvokeMember("TargetPath", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { targetPath }); + shortcutType.InvokeMember("WorkingDirectory", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { Path.GetDirectoryName(targetPath)! }); + shortcutType.InvokeMember("Description", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { "Open OpenKH Mod Manager" }); + shortcutType.InvokeMember("IconLocation", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { $"{targetPath},0" }); + shortcutType.InvokeMember("Save", System.Reflection.BindingFlags.InvokeMethod, null, shortcut, null); + } + finally + { + if (shortcut != null && Marshal.IsComObject(shortcut)) + Marshal.FinalReleaseComObject(shortcut); + if (shell != null && Marshal.IsComObject(shell)) + Marshal.FinalReleaseComObject(shell); + } + + return shortcutPath; + } +} diff --git a/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs b/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs new file mode 100644 index 000000000..014336362 --- /dev/null +++ b/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Windows; + +namespace OpenKh.Tools.Launcher; + +internal static class LegacyInstallationMigration +{ + private const string LauncherExecutableName = "OpenKh.Launcher.exe"; + private const string CompatibilityExecutableName = "OpenKh.Tools.ModsManager.exe"; + + private static readonly string[] FallbackLegacyResourceDirectories = + { + "cs-CZ", + "de", + "es", + "fr", + "hu", + "it", + "ja-JP", + "pt-BR", + "resources", + "ro", + "ru", + "runtimes", + "sv", + "zh-Hans", + }; + + public static bool TryStartModManager() + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(processPath) + || !Path.GetFileName(processPath).Equals(CompatibilityExecutableName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var installationDirectory = AppContext.BaseDirectory.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ); + var modManagerPath = Path.Combine( + installationDirectory, + "Apps", + "ModManager", + CompatibilityExecutableName + ); + + if (!File.Exists(modManagerPath)) + { + MessageBox.Show( + "The updated Mod Manager could not be found. Extract the latest OpenKH release again.", + "OpenKH Update", + MessageBoxButton.OK, + MessageBoxImage.Error + ); + return true; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = modManagerPath, + WorkingDirectory = Path.GetDirectoryName(modManagerPath), + UseShellExecute = true, + }; + + foreach (var argument in Environment.GetCommandLineArgs().Skip(1)) + startInfo.ArgumentList.Add(argument); + + Process.Start(startInfo); + + ScheduleCleanupIfNeeded(installationDirectory); + } + catch (Exception exception) + { + MessageBox.Show( + $"OpenKH could not complete the update.\n\n{exception.Message}", + "OpenKH Update", + MessageBoxButton.OK, + MessageBoxImage.Error + ); + } + + return true; + } + + public static void ScheduleCleanupIfNeeded() + { + var installationDirectory = AppContext.BaseDirectory.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ); + ScheduleCleanupIfNeeded(installationDirectory); + } + + private static void ScheduleCleanupIfNeeded(string installationDirectory) + { + var legacyFiles = GetLegacyApplicationFiles(installationDirectory) + .Where(File.Exists) + .ToArray(); + var legacyDirectories = GetLegacyResourceDirectories(installationDirectory) + .Select(directoryName => Path.Combine(installationDirectory, directoryName)) + .Where(Directory.Exists) + .ToArray(); + + if (legacyFiles.Length == 0 && legacyDirectories.Length == 0) + return; + + var batchPath = Path.Combine(Path.GetTempPath(), $"openkh-migrate-{Guid.NewGuid():N}.bat"); + var batch = new StringBuilder(); + + batch.AppendLine("@echo off"); + batch.AppendLine("chcp 65001 > nul"); + batch.AppendLine(":wait_for_launcher"); + batch.AppendLine($"tasklist /fi \"PID eq {Environment.ProcessId}\" 2>nul | find \"{Environment.ProcessId}\" >nul"); + batch.AppendLine("if not errorlevel 1 ("); + batch.AppendLine(" timeout /t 1 /nobreak >nul"); + batch.AppendLine(" goto wait_for_launcher"); + batch.AppendLine(")"); + + foreach (var filePath in legacyFiles) + { + var escapedPath = EscapeBatchPath(filePath); + batch.AppendLine($"attrib -h -r {escapedPath} 2>nul"); + batch.AppendLine($"del /f /q {escapedPath} 2>nul"); + } + + foreach (var directoryPath in legacyDirectories) + { + batch.AppendLine($"rmdir /s /q {EscapeBatchPath(directoryPath)} 2>nul"); + } + + batch.AppendLine("del /f /q \"%~f0\""); + File.WriteAllText(batchPath, batch.ToString(), new UTF8Encoding(false)); + + Process.Start(new ProcessStartInfo + { + FileName = batchPath, + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + }); + } + + private static IEnumerable GetLegacyApplicationFiles(string installationDirectory) + { + var manifestPath = Path.Combine(installationDirectory, "Apps", "legacy-release-files.txt"); + if (!File.Exists(manifestPath)) + { + return Directory.EnumerateFiles(installationDirectory, "*", SearchOption.TopDirectoryOnly) + .Where(IsLegacyApplicationFile); + } + + var legacyFileNames = File.ReadAllLines(manifestPath) + .Where(fileName => !string.IsNullOrWhiteSpace(fileName)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return Directory.EnumerateFiles(installationDirectory, "*", SearchOption.TopDirectoryOnly) + .Where(filePath => + legacyFileNames.Contains(Path.GetFileName(filePath)) + || Path.GetFileName(filePath).StartsWith("OpenKh.Tools.ModsManager.", StringComparison.OrdinalIgnoreCase) + ) + .Where(filePath => !Path.GetFileName(filePath).Equals(LauncherExecutableName, StringComparison.OrdinalIgnoreCase)) + .Where(filePath => !Path.GetFileName(filePath).Equals(CompatibilityExecutableName, StringComparison.OrdinalIgnoreCase)); + } + + private static IEnumerable GetLegacyResourceDirectories(string installationDirectory) + { + var manifestPath = Path.Combine(installationDirectory, "Apps", "legacy-release-directories.txt"); + return File.Exists(manifestPath) + ? File.ReadAllLines(manifestPath).Where(directoryName => !string.IsNullOrWhiteSpace(directoryName)) + : FallbackLegacyResourceDirectories; + } + + private static bool IsLegacyApplicationFile(string filePath) + { + var fileName = Path.GetFileName(filePath); + if (fileName.Equals(LauncherExecutableName, StringComparison.OrdinalIgnoreCase) + || fileName.Equals(CompatibilityExecutableName, StringComparison.OrdinalIgnoreCase)) + return false; + + if (Path.GetExtension(fileName).Equals(".dll", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!fileName.StartsWith("OpenKh.", StringComparison.OrdinalIgnoreCase)) + return false; + + return fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".config", StringComparison.OrdinalIgnoreCase); + } + + private static string EscapeBatchPath(string path) => $"\"{path.Replace("\"", "\"\"")}\""; +} diff --git a/OpenKh.Tools.Launcher/MainWindow.xaml b/OpenKh.Tools.Launcher/MainWindow.xaml new file mode 100644 index 000000000..26ed356fa --- /dev/null +++ b/OpenKh.Tools.Launcher/MainWindow.xaml @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +