diff --git a/.github/scripts/prepare-release.ps1 b/.github/scripts/prepare-release.ps1
new file mode 100644
index 000000000..8d988dc38
--- /dev/null
+++ b/.github/scripts/prepare-release.ps1
@@ -0,0 +1,93 @@
+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."
+}
+
+$legacyFileNames = Get-ChildItem -LiteralPath $BuildDirectory -File |
+ Select-Object -ExpandProperty Name |
+ Sort-Object
+$legacyDirectoryNames = Get-ChildItem -LiteralPath $BuildDirectory -Directory |
+ Select-Object -ExpandProperty Name |
+ Sort-Object
+
+New-Item -ItemType Directory -Path $ReleaseDirectory | Out-Null
+
+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"
+
+$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 "distribution/README-FIRST.txt" -Destination $ReleaseDirectory
+Copy-Item -LiteralPath "LICENSE" -Destination $ReleaseDirectory
+Copy-Item -LiteralPath "NOTICE" -Destination $ReleaseDirectory
+
+$applicationsDirectory = Join-Path $ReleaseDirectory "Apps"
+Move-Item -LiteralPath $BuildDirectory -Destination $applicationsDirectory
+
+$duplicateLauncherFiles = Get-ChildItem -LiteralPath $applicationsDirectory -File | Where-Object {
+ $_.Name -like "OpenKh.Launcher.*"
+}
+
+foreach ($duplicateFile in $duplicateLauncherFiles) {
+ Remove-Item -LiteralPath $duplicateFile.FullName
+}
+
+$packagedModManager = Join-Path $applicationsDirectory "OpenKh.Tools.ModsManager.exe"
+if (-not (Test-Path -LiteralPath $packagedModManager -PathType Leaf)) {
+ throw "Required Mod Manager executable '$packagedModManager' does not exist."
+}
+
+$legacyFileManifest = Join-Path $applicationsDirectory "legacy-release-files.txt"
+$legacyDirectoryManifest = Join-Path $applicationsDirectory "legacy-release-directories.txt"
+$legacyFileNames | Set-Content -LiteralPath $legacyFileManifest -Encoding UTF8
+$legacyDirectoryNames | Set-Content -LiteralPath $legacyDirectoryManifest -Encoding UTF8
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index f0afbad26..4a2d69072 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,48 @@ 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\OpenKh.Tools.ModsManager.exe"
+ )
+ foreach ($entry in $requiredEntries) {
+ if ($archiveListing -notmatch [regex]::Escape($entry)) {
+ throw "Required update entry '$entry' is missing from openkh.zip."
+ }
+ }
+ $obsoleteEntries = @(
+ "openkh\AdvancedTools",
+ "openkh\Apps\ModManager"
+ )
+ foreach ($entry in $obsoleteEntries) {
+ if ($archiveListing -match [regex]::Escape($entry)) {
+ throw "Obsolete update entry '$entry' is present in 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..bdf9d11ad
--- /dev/null
+++ b/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs
@@ -0,0 +1,63 @@
+using OpenKh.Tools.ModsManager.Services;
+using Xunit;
+
+namespace OpenKh.Tests.ModsManager
+{
+ public class OpenkhInstallationTest
+ {
+ [Theory]
+ [InlineData(@"C:\OpenKh", @"C:\OpenKh")]
+ [InlineData(@"C:\OpenKh\Apps", @"C:\OpenKh")]
+ [InlineData(@"C:\OpenKh\apps", @"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
+ );
+ }
+
+ [Fact]
+ public void GetModManagerExecutableSupportsCurrentAndPreviousLayouts()
+ {
+ var installationDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"openkh-installation-{Guid.NewGuid():N}"
+ );
+ var rootExecutable = Path.Combine(installationDirectory, "OpenKh.Tools.ModsManager.exe");
+ var previousExecutable = Path.Combine(
+ installationDirectory,
+ "Apps",
+ "ModManager",
+ "OpenKh.Tools.ModsManager.exe"
+ );
+ var currentExecutable = Path.Combine(
+ installationDirectory,
+ "Apps",
+ "OpenKh.Tools.ModsManager.exe"
+ );
+
+ try
+ {
+ Directory.CreateDirectory(installationDirectory);
+ File.WriteAllText(rootExecutable, string.Empty);
+ Assert.Equal(rootExecutable, OpenkhInstallation.GetModManagerExecutable(installationDirectory));
+
+ Directory.CreateDirectory(Path.GetDirectoryName(previousExecutable)!);
+ File.WriteAllText(previousExecutable, string.Empty);
+ Assert.Equal(previousExecutable, OpenkhInstallation.GetModManagerExecutable(installationDirectory));
+
+ File.WriteAllText(currentExecutable, string.Empty);
+ Assert.Equal(currentExecutable, OpenkhInstallation.GetModManagerExecutable(installationDirectory));
+ }
+ finally
+ {
+ if (Directory.Exists(installationDirectory))
+ Directory.Delete(installationDirectory, recursive: 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..c5590cc1d
--- /dev/null
+++ b/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs
@@ -0,0 +1,221 @@
+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[] PreviousApplicationDirectories =
+ {
+ "AdvancedTools",
+ Path.Combine("Apps", "ModManager"),
+ };
+
+ 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", CompatibilityExecutableName);
+ if (!File.Exists(modManagerPath))
+ {
+ 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)
+ {
+ if (!IsOrganizedInstallation(installationDirectory))
+ return;
+
+ var legacyFiles = GetLegacyApplicationFiles(installationDirectory)
+ .Where(File.Exists)
+ .ToArray();
+ var legacyDirectories = GetLegacyResourceDirectories(installationDirectory)
+ .Select(directoryName => Path.Combine(installationDirectory, directoryName))
+ .Concat(PreviousApplicationDirectories.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 bool IsOrganizedInstallation(string installationDirectory) =>
+ File.Exists(Path.Combine(installationDirectory, LauncherExecutableName))
+ && File.Exists(Path.Combine(
+ installationDirectory,
+ "Apps",
+ CompatibilityExecutableName
+ ));
+
+ 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..115606248
--- /dev/null
+++ b/OpenKh.Tools.Launcher/MainWindow.xaml
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenKh.Tools.Launcher/MainWindow.xaml.cs b/OpenKh.Tools.Launcher/MainWindow.xaml.cs
new file mode 100644
index 000000000..f92b2b916
--- /dev/null
+++ b/OpenKh.Tools.Launcher/MainWindow.xaml.cs
@@ -0,0 +1,469 @@
+using OpenKh.Tools.ModsManager.Services;
+using System.Diagnostics;
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace OpenKh.Tools.Launcher;
+
+public partial class MainWindow : Window
+{
+ private const string ModManagerExecutable = "OpenKh.Tools.ModsManager.exe";
+ private const string ApplicationsDirectory = "Apps";
+ private const string FavoritesFileName = "launcher-favorites.txt";
+
+ private static readonly IReadOnlyDictionary ToolDescriptions =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["BarEditor"] = "Inspect and edit BAR archives.",
+ ["BbsEventTableEditor"] = "Edit Birth by Sleep event tables.",
+ ["BbsMapStudio"] = "Create and inspect Birth by Sleep maps.",
+ ["ImageViewer"] = "View textures and supported game images.",
+ ["IdxImg"] = "Browse and manage IDX/IMG game archives.",
+ ["Kh2BattleEditor"] = "Edit Kingdom Hearts II battle data.",
+ ["Kh2MapStudio"] = "Create and inspect Kingdom Hearts II maps.",
+ ["Kh2MdlxEditor"] = "Inspect and edit Kingdom Hearts II models.",
+ ["Kh2MsetEditor"] = "Inspect Kingdom Hearts II animation sets.",
+ ["Kh2ObjectEditor"] = "Edit Kingdom Hearts II object data.",
+ ["Kh2SystemEditor"] = "Edit Kingdom Hearts II system data.",
+ ["Kh2TextEditor"] = "Edit game messages and text resources.",
+ ["LayoutEditor"] = "Edit 2D layouts and interface assets.",
+ ["MissionEditor"] = "Edit mission data.",
+ ["ObjentryEditor"] = "Edit object entry tables.",
+ };
+
+ private readonly List _allTools = new();
+ private readonly HashSet _favoriteToolNames = new(StringComparer.OrdinalIgnoreCase);
+ private OpenkhUpdateCheckerService.CheckResult? _availableUpdate;
+ private string BaseDirectory => OpenkhInstallation.Directory;
+ private string ModManagerPath => OpenkhInstallation.GetModManagerExecutable(BaseDirectory);
+ private string ApplicationsPath => Path.Combine(BaseDirectory, ApplicationsDirectory);
+ private string FavoritesPath => Path.Combine(BaseDirectory, FavoritesFileName);
+ private string CompatibilityModManagerPath => Path.Combine(BaseDirectory, ModManagerExecutable);
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ var version = FileVersionInfo.GetVersionInfo(Environment.ProcessPath!).ProductVersion;
+ VersionText.Text = string.IsNullOrWhiteSpace(version) ? string.Empty : $"Version {version}";
+
+ var modManagerAvailable = File.Exists(ModManagerPath);
+ LaunchModManagerButton.IsEnabled = modManagerAvailable;
+ CheckForUpdatesButton.IsEnabled = true;
+ CreateShortcutButton.IsEnabled = File.Exists(CompatibilityModManagerPath);
+ ModManagerStatusText.Text = modManagerAvailable ? string.Empty : "Mod Manager was not found";
+ ModManagerStatusText.Visibility = modManagerAvailable ? Visibility.Collapsed : Visibility.Visible;
+
+ LoadFavorites();
+ LoadTools();
+ _ = RefreshUpdateAvailabilityAsync(showErrors: false, showProgress: false);
+ }
+
+ private void LoadTools()
+ {
+ _allTools.Clear();
+
+ if (Directory.Exists(ApplicationsPath))
+ {
+ _allTools.AddRange(
+ Directory.EnumerateFiles(ApplicationsPath, "OpenKh.Tools.*.exe", SearchOption.TopDirectoryOnly)
+ .Where(path => !Path.GetFileName(path).Equals(ModManagerExecutable, StringComparison.OrdinalIgnoreCase))
+ .Select(CreateToolEntry)
+ );
+ }
+
+ ToolCountText.Text = _allTools.Count == 0
+ ? "Tools are installed with the full OpenKH package"
+ : $"{_allTools.Count} tools available";
+
+ ApplyToolFilter();
+ }
+
+ private ToolEntry CreateToolEntry(string executablePath)
+ {
+ var identifier = Path.GetFileName(executablePath);
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(executablePath);
+ var shortName = fileNameWithoutExtension.StartsWith("OpenKh.Tools.", StringComparison.OrdinalIgnoreCase)
+ ? fileNameWithoutExtension["OpenKh.Tools.".Length..]
+ : fileNameWithoutExtension;
+ var displayName = HumanizeName(shortName);
+ var description = ToolDescriptions.TryGetValue(shortName, out var knownDescription)
+ ? knownDescription
+ : "Open a specialized OpenKH modding utility.";
+
+ return new ToolEntry(
+ identifier,
+ displayName,
+ description,
+ executablePath,
+ _favoriteToolNames.Contains(identifier)
+ );
+ }
+
+ private static string HumanizeName(string value)
+ {
+ var result = Regex.Replace(value, "(?<=[a-z0-9])(?=[A-Z])", " ");
+ return result
+ .Replace("Kh1", "KH1", StringComparison.OrdinalIgnoreCase)
+ .Replace("Kh2", "KH2", StringComparison.OrdinalIgnoreCase)
+ .Replace("Bbs", "BBS", StringComparison.OrdinalIgnoreCase)
+ .Replace("Idx", "IDX", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void ApplyToolFilter()
+ {
+ var query = SearchBox?.Text?.Trim() ?? string.Empty;
+ var matchingTools = string.IsNullOrWhiteSpace(query)
+ ? _allTools.AsEnumerable()
+ : _allTools
+ .Where(tool => tool.DisplayName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
+ || tool.Description.Contains(query, StringComparison.CurrentCultureIgnoreCase));
+ var filteredTools = matchingTools
+ .OrderByDescending(tool => tool.IsFavorite)
+ .ThenBy(tool => tool.DisplayName, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+
+ if (ToolsList != null)
+ ToolsList.ItemsSource = filteredTools;
+
+ if (ToolsStatusText != null)
+ {
+ ToolsStatusText.Text = !Directory.Exists(ApplicationsPath)
+ ? "The Apps folder is not available in this installation."
+ : $"Showing {filteredTools.Count} of {_allTools.Count} tools";
+ }
+ }
+
+ private void LaunchModManager_Click(object sender, RoutedEventArgs e) => Launch(ModManagerPath);
+
+ private async void CheckForUpdates_Click(object sender, RoutedEventArgs e)
+ {
+ CheckForUpdatesButton.IsEnabled = false;
+
+ try
+ {
+ var checkResult = _availableUpdate?.HasUpdate == true
+ ? _availableUpdate
+ : await RefreshUpdateAvailabilityAsync(showErrors: true, showProgress: true);
+ if (checkResult == null)
+ return;
+
+ if (!checkResult.HasUpdate)
+ {
+ var message = string.IsNullOrWhiteSpace(checkResult.CurrentVersion)
+ ? "No OpenKH update is currently available."
+ : $"The latest version '{checkResult.CurrentVersion}' is already installed.";
+ MessageBox.Show(this, message, "OpenKH Update", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var updateMessage = "A new version of OpenKH is available.\n" +
+ $"Current: {checkResult.CurrentVersion}\n" +
+ $"Latest: {checkResult.NewVersion}\n\n" +
+ "Do you want to download and install it now?";
+ if (MessageBox.Show(
+ this,
+ updateMessage,
+ "OpenKH Update",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question
+ ) != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ CheckForUpdatesButton.Content = "Downloading Update...";
+ var launcherPath = Path.Combine(OpenkhInstallation.Directory, "OpenKh.Launcher.exe");
+ await new OpenkhUpdateProceederService().UpdateAsync(
+ checkResult.DownloadZipUrl,
+ rate => Dispatcher.Invoke(() =>
+ CheckForUpdatesButton.Content = $"Downloading {rate:P0}"),
+ CancellationToken.None,
+ launcherPath
+ );
+
+ Application.Current.Shutdown();
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ this,
+ $"OpenKH could not check for or install updates.\n\n{exception.Message}",
+ "OpenKH Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+ finally
+ {
+ CheckForUpdatesButton.IsEnabled = true;
+ SetUpdateAvailability(_availableUpdate?.HasUpdate == true);
+ }
+ }
+
+ private async Task RefreshUpdateAvailabilityAsync(
+ bool showErrors,
+ bool showProgress
+ )
+ {
+ if (showProgress)
+ CheckForUpdatesButton.Content = "Checking for Updates...";
+
+ try
+ {
+ var checkResult = await new OpenkhUpdateCheckerService().CheckAsync(CancellationToken.None);
+ _availableUpdate = checkResult;
+ SetUpdateAvailability(checkResult.HasUpdate);
+ return checkResult;
+ }
+ catch (Exception exception)
+ {
+ _availableUpdate = null;
+ SetUpdateAvailability(false);
+
+ if (showErrors)
+ {
+ MessageBox.Show(
+ this,
+ $"OpenKH could not check for updates.\n\n{exception.Message}",
+ "OpenKH Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+
+ return null;
+ }
+ }
+
+ private void SetUpdateAvailability(bool updateAvailable)
+ {
+ CheckForUpdatesButton.Content = updateAvailable ? "Update Available" : "Check for Updates";
+ CheckForUpdatesButton.Foreground = new SolidColorBrush(updateAvailable
+ ? Color.FromRgb(127, 220, 173)
+ : Color.FromRgb(143, 185, 248));
+ }
+
+ private void CreateShortcut_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var shortcutPath = DesktopShortcutService.CreateModManagerShortcut(CompatibilityModManagerPath);
+ MessageBox.Show(
+ $"The OpenKH Mod Manager shortcut was created on your desktop.\n\n{shortcutPath}",
+ "Shortcut created",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information
+ );
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ $"OpenKH could not create the desktop shortcut.\n\n{exception.Message}",
+ "Unable to create shortcut",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+ }
+
+ private void ShowTools_Click(object sender, RoutedEventArgs e)
+ {
+ LoadTools();
+ HomePanel.Visibility = Visibility.Collapsed;
+ ToolsPanel.Visibility = Visibility.Visible;
+ SearchBox.Focus();
+ }
+
+ private void ShowHome_Click(object sender, RoutedEventArgs e)
+ {
+ ToolsPanel.Visibility = Visibility.Collapsed;
+ HomePanel.Visibility = Visibility.Visible;
+ }
+
+ private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyToolFilter();
+
+ private void LaunchTool_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: ToolEntry tool })
+ Launch(tool.ExecutablePath);
+ }
+
+ private void ToggleFavorite_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button { Tag: ToolEntry tool })
+ return;
+
+ var isFavorite = _favoriteToolNames.Add(tool.Identifier);
+ if (!isFavorite)
+ _favoriteToolNames.Remove(tool.Identifier);
+
+ tool.IsFavorite = isFavorite;
+
+ try
+ {
+ File.WriteAllLines(
+ FavoritesPath,
+ _favoriteToolNames.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
+ );
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
+ {
+ tool.IsFavorite = !isFavorite;
+ if (isFavorite)
+ _favoriteToolNames.Remove(tool.Identifier);
+ else
+ _favoriteToolNames.Add(tool.Identifier);
+
+ MessageBox.Show(
+ this,
+ $"OpenKH could not save your favorites.\n\n{exception.Message}",
+ "Unable to save favorites",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+
+ ApplyToolFilter();
+ if (ToolsList.Items.Count > 0)
+ {
+ ToolsList.SelectedItem = null;
+ ToolsList.ScrollIntoView(ToolsList.Items[0]);
+ }
+ }
+
+ private void ToolsList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (e.OriginalSource is DependencyObject source
+ && FindVisualAncestor