From 18e02595c4ddbfa956cda35a2684bdca315a792a Mon Sep 17 00:00:00 2001
From: patchzyy <64382339+patchzyy@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:37:40 +0200
Subject: [PATCH] fix shortcut
---
.../WiiCompiled.Setup/InstallTransaction.cs | 27 +++++++++-
Launcher/WiiCompiled.Setup/InstallerEngine.cs | 5 +-
.../WiiCompiled.Setup/ProductRepairService.cs | 3 ++
.../WiiCompiled.Setup/RunningProductGuard.cs | 47 +++++++++++++++++
Launcher/WiiCompiled.Setup/SelfTests.cs | 30 +++++++----
.../WiiCompiled.Setup/ShellIntegration.cs | 51 +++++++++++--------
.../WiiCompiled.Setup/UninstallService.cs | 2 +-
7 files changed, 128 insertions(+), 37 deletions(-)
create mode 100644 Launcher/WiiCompiled.Setup/RunningProductGuard.cs
diff --git a/Launcher/WiiCompiled.Setup/InstallTransaction.cs b/Launcher/WiiCompiled.Setup/InstallTransaction.cs
index 230050f..4ee7d4b 100644
--- a/Launcher/WiiCompiled.Setup/InstallTransaction.cs
+++ b/Launcher/WiiCompiled.Setup/InstallTransaction.cs
@@ -304,13 +304,36 @@ internal sealed class InstallTransaction : IDisposable
private static bool ExistsAsOtherKind(string path, InstallTransactionEntryKind kind) =>
kind == InstallTransactionEntryKind.Directory ? File.Exists(path) : Directory.Exists(path);
+ // Antivirus and indexers briefly hold handles inside freshly written trees; ride those out
+ // before treating a locked path as fatal.
+ private const int MoveAttempts = 10;
+ private static readonly TimeSpan MoveRetryDelay = TimeSpan.FromMilliseconds(500);
+
private static void Move(string source, string destination, InstallTransactionEntryKind kind)
{
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
- if (kind == InstallTransactionEntryKind.Directory) Directory.Move(source, destination);
- else File.Move(source, destination);
+ for (var attempt = 1; ; attempt++)
+ {
+ try
+ {
+ if (kind == InstallTransactionEntryKind.Directory) Directory.Move(source, destination);
+ else File.Move(source, destination);
+ return;
+ }
+ catch (Exception ex) when (IsTransientLock(ex))
+ {
+ if (attempt == MoveAttempts)
+ throw new IOException($"Could not replace \"{destination}\": {ex.Message} " +
+ "Close any program using this folder and retry the update.", ex);
+ Thread.Sleep(MoveRetryDelay);
+ }
+ }
}
+ internal static bool IsTransientLock(Exception ex) =>
+ ex is IOException or UnauthorizedAccessException &&
+ (ex.HResult & 0xFFFF) is 5 or 32 or 33; // ACCESS_DENIED, SHARING_VIOLATION, LOCK_VIOLATION
+
private static void Delete(string path, InstallTransactionEntryKind kind)
{
if (kind == InstallTransactionEntryKind.Directory) FileSystemUtilities.DeleteDirectoryIfExists(path);
diff --git a/Launcher/WiiCompiled.Setup/InstallerEngine.cs b/Launcher/WiiCompiled.Setup/InstallerEngine.cs
index 3e39b0e..b38b83b 100644
--- a/Launcher/WiiCompiled.Setup/InstallerEngine.cs
+++ b/Launcher/WiiCompiled.Setup/InstallerEngine.cs
@@ -291,6 +291,7 @@ internal sealed class InstallerEngine
Path.Combine(installDirectory, InstalledLayout.InstallStateFileName)));
cancellationToken.ThrowIfCancellationRequested();
+ RunningProductGuard.EnsureProductsNotRunning(installDirectory);
_reporter.Progress(InstallStages.Publish, "Publishing the completed installation...", progressPercent);
var configPath = RuntimeConfiguration.ResolveConfigPath(installDirectory);
var configSnapshot = RuntimeConfiguration.Capture(configPath);
@@ -318,11 +319,11 @@ internal sealed class InstallerEngine
try
{
ShellIntegration.RegisterUninstaller(installDirectory, state.RetroRewindInstalled);
- ShellIntegration.RemoveAllShortcuts();
+ ShellIntegration.CreateShortcuts(installDirectory);
}
catch (Exception ex)
{
- _reporter.Diagnostic("The installation succeeded, but Windows uninstall registration failed: " +
+ _reporter.Diagnostic("The installation succeeded, but Windows shell integration failed: " +
ex.Message);
}
}
diff --git a/Launcher/WiiCompiled.Setup/ProductRepairService.cs b/Launcher/WiiCompiled.Setup/ProductRepairService.cs
index 57769d1..3d99ec8 100644
--- a/Launcher/WiiCompiled.Setup/ProductRepairService.cs
+++ b/Launcher/WiiCompiled.Setup/ProductRepairService.cs
@@ -80,6 +80,7 @@ internal sealed class ProductRepairService
if (!_installation.HasToolkit)
throw new InvalidOperationException(
"The installed recompilation toolkit is missing. Apply the current setup release before repairing products.");
+ RunningProductGuard.EnsureProductsNotRunning(_installation.Root);
var expectedDolSha256 = options.ExpectedDolSha256;
var expectedRelSha256 = options.ExpectedRelSha256;
if (string.IsNullOrWhiteSpace(expectedDolSha256) != string.IsNullOrWhiteSpace(expectedRelSha256))
@@ -450,6 +451,8 @@ internal sealed class ProductRepairService
}
cancellationToken.ThrowIfCancellationRequested();
+ // The game may have been started during a long compile; publishing renames its directory.
+ RunningProductGuard.EnsureProductsNotRunning(_installation.Root);
var configPath = RuntimeConfiguration.ResolveConfigPath(_installation.Root);
var configSnapshot = RuntimeConfiguration.Capture(configPath);
using var transaction = InstallTransaction.Begin(_installation.Root, _reporter, entries.ToArray());
diff --git a/Launcher/WiiCompiled.Setup/RunningProductGuard.cs b/Launcher/WiiCompiled.Setup/RunningProductGuard.cs
new file mode 100644
index 0000000..00b902d
--- /dev/null
+++ b/Launcher/WiiCompiled.Setup/RunningProductGuard.cs
@@ -0,0 +1,47 @@
+using System.Diagnostics;
+
+namespace WiiCompiled.Setup;
+
+///
+/// Refuses to replace installed products while one of them is running: publishing renames the
+/// product directories, which Windows rejects while a process runs from them.
+///
+internal static class RunningProductGuard
+{
+ public static void EnsureProductsNotRunning(string installDirectory)
+ {
+ var installation = new Installation(installDirectory);
+ var running = FindProcessesUnder(installation.BaseDirectory, installation.RetroDirectory);
+ if (running.Count == 0) return;
+
+ throw new InvalidOperationException(
+ $"Mario Kart Wii is still running ({string.Join(", ", running)}). " +
+ "Close the game, then retry the update.");
+ }
+
+ private static List FindProcessesUnder(params string[] roots)
+ {
+ var names = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var process in Process.GetProcesses())
+ {
+ try
+ {
+ if (process.Id == Environment.ProcessId) continue;
+ var path = process.MainModule?.FileName;
+ if (path is null) continue;
+ if (roots.Any(root => FileSystemUtilities.PathContains(root, Path.GetFullPath(path))))
+ names.Add(Path.GetFileName(path));
+ }
+ catch
+ {
+ // Inaccessible processes (elevated, exited, protected) cannot run our products' exes
+ // from a user-writable install directory in any case that matters here.
+ }
+ finally
+ {
+ process.Dispose();
+ }
+ }
+ return names.ToList();
+ }
+}
diff --git a/Launcher/WiiCompiled.Setup/SelfTests.cs b/Launcher/WiiCompiled.Setup/SelfTests.cs
index 6ddead4..00bb8af 100644
--- a/Launcher/WiiCompiled.Setup/SelfTests.cs
+++ b/Launcher/WiiCompiled.Setup/SelfTests.cs
@@ -59,6 +59,7 @@ internal static class SelfTests
Test("Portable relative path settings", TestPortableRelativePathSettings, failures);
Test("Portable move healing", TestPortableMoveHealing, failures);
Test("Install transaction rollback", TestInstallTransactionRollback, failures);
+ Test("Install move transient-lock policy", TestInstallMoveTransientLockPolicy, failures);
Test("Install scratch recovery", TestInstallScratchRecovery, failures);
Test("Shortcut cleanup is best effort", TestShortcutCleanupIsBestEffort, failures);
Test("Canonical Retro Rewind resolution", TestCanonicalRetroRewindResolution, failures);
@@ -434,30 +435,25 @@ internal static class SelfTests
private static void TestShortcutCleanupIsBestEffort()
{
var root = Path.Combine(Path.GetTempPath(), "mkwc-shortcuts-" + Guid.NewGuid().ToString("N"));
- var desktop = Path.Combine(root, "Desktop");
- var startMenu = Path.Combine(root, "Start Menu", ProductInfo.Name);
try
{
- Directory.CreateDirectory(desktop);
+ Directory.CreateDirectory(root);
// File.Delete cannot delete a directory. This deterministically forces the first cleanup
// to fail while the remaining independently guarded targets must still be attempted.
- Directory.CreateDirectory(Path.Combine(desktop, "WiiCompiled.lnk"));
- var secondShortcut = Path.Combine(desktop, "Retro Rewind.lnk");
+ var firstShortcut = Path.Combine(root, "first.lnk");
+ Directory.CreateDirectory(firstShortcut);
+ var secondShortcut = Path.Combine(root, "second.lnk");
File.WriteAllText(secondShortcut, "shortcut");
- Directory.CreateDirectory(startMenu);
- File.WriteAllText(Path.Combine(startMenu, "WiiCompiled.lnk"), "shortcut");
try
{
- ShellIntegration.RemoveAllShortcuts(desktop, startMenu);
+ ShellIntegration.RemoveShortcuts([firstShortcut, secondShortcut]);
throw new Exception("A shortcut deletion failure was not reported.");
}
catch (AggregateException) { }
if (File.Exists(secondShortcut))
throw new Exception("One failed shortcut deletion prevented the next file deletion.");
- if (Directory.Exists(startMenu))
- throw new Exception("One failed shortcut deletion prevented Start Menu cleanup.");
}
finally
{
@@ -727,6 +723,20 @@ internal static class SelfTests
}
}
+ private static void TestInstallMoveTransientLockPolicy()
+ {
+ if (!InstallTransaction.IsTransientLock(new IOException("in use", unchecked((int)0x80070020))))
+ throw new Exception("A sharing violation would not be retried.");
+ if (!InstallTransaction.IsTransientLock(new IOException("locked", unchecked((int)0x80070021))))
+ throw new Exception("A lock violation would not be retried.");
+ if (!InstallTransaction.IsTransientLock(new UnauthorizedAccessException("denied")))
+ throw new Exception("A transient access-denied would not be retried.");
+ if (InstallTransaction.IsTransientLock(new IOException("disk full", unchecked((int)0x80070070))))
+ throw new Exception("A permanent I/O failure would be retried.");
+ if (InstallTransaction.IsTransientLock(new InvalidDataException("bad")))
+ throw new Exception("A non-I/O failure would be retried.");
+ }
+
private static void TestRetroWfcTransientRetryPolicy()
{
if (!InputValidation.IsTransientRetroWfcDownloadFailure(
diff --git a/Launcher/WiiCompiled.Setup/ShellIntegration.cs b/Launcher/WiiCompiled.Setup/ShellIntegration.cs
index eeae1de..1746848 100644
--- a/Launcher/WiiCompiled.Setup/ShellIntegration.cs
+++ b/Launcher/WiiCompiled.Setup/ShellIntegration.cs
@@ -4,6 +4,8 @@ namespace WiiCompiled.Setup;
internal static class ShellIntegration
{
+ private const string ShortcutFileName = "wiicompiled (base) (beta).lnk";
+
public static void RegisterUninstaller(string installDirectory, bool retroInstalled)
{
using var key = Registry.CurrentUser.CreateSubKey(ProductInfo.UninstallKey, writable: true)
@@ -25,26 +27,43 @@ internal static class ShellIntegration
public static void UnregisterUninstaller() =>
Registry.CurrentUser.DeleteSubKeyTree(ProductInfo.UninstallKey, throwOnMissingSubKey: false);
- /// Removes shortcuts left by GUI-capable releases from before Wheel Wizard owned the UI.
- public static void RemoveAllShortcuts()
+ /// Creates the desktop and Start Menu shortcuts that launch the base game.
+ public static void CreateShortcuts(string installDirectory)
{
- var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
- var startMenuFolder = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", ProductInfo.Name);
- RemoveAllShortcuts(desktop, startMenuFolder);
+ var cli = Path.Combine(installDirectory, ProductInfo.SetupCopyName);
+ var shellType = Type.GetTypeFromProgID("WScript.Shell")
+ ?? throw new InvalidOperationException("The Windows Script Host shell is unavailable.");
+ dynamic shell = Activator.CreateInstance(shellType)!;
+ foreach (var path in ShortcutPaths())
+ {
+ dynamic shortcut = shell.CreateShortcut(path);
+ shortcut.TargetPath = cli;
+ shortcut.Arguments = "--launch-base";
+ shortcut.WorkingDirectory = installDirectory;
+ shortcut.IconLocation = cli + ",0";
+ shortcut.Description = "Play Mario Kart Wii (base game)";
+ shortcut.Save();
+ }
}
- internal static void RemoveAllShortcuts(string desktop, string startMenuFolder)
+ public static void RemoveShortcuts() => RemoveShortcuts(ShortcutPaths());
+
+ internal static void RemoveShortcuts(IEnumerable shortcutPaths)
{
var failures = new List();
- DeleteFileBestEffort(Path.Combine(desktop, "WiiCompiled.lnk"), failures);
- DeleteFileBestEffort(Path.Combine(desktop, "Retro Rewind.lnk"), failures);
- DeleteDirectoryBestEffort(startMenuFolder, failures);
+ foreach (var path in shortcutPaths)
+ DeleteFileBestEffort(path, failures);
if (failures.Count != 0)
throw new AggregateException("One or more WiiCompiled shortcuts could not be removed.", failures);
}
+ private static string[] ShortcutPaths() =>
+ [
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), ShortcutFileName),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs", ShortcutFileName),
+ ];
+
private static void DeleteFileBestEffort(string path, List failures)
{
try
@@ -57,18 +76,6 @@ internal static class ShellIntegration
}
}
- private static void DeleteDirectoryBestEffort(string path, List failures)
- {
- try
- {
- if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
- }
- catch (Exception ex)
- {
- failures.Add(new IOException($"Could not delete shortcut folder {path}: {ex.Message}", ex));
- }
- }
-
private static int EstimateSizeKb(string directory)
{
try
diff --git a/Launcher/WiiCompiled.Setup/UninstallService.cs b/Launcher/WiiCompiled.Setup/UninstallService.cs
index a76a9e3..25ac2a4 100644
--- a/Launcher/WiiCompiled.Setup/UninstallService.cs
+++ b/Launcher/WiiCompiled.Setup/UninstallService.cs
@@ -28,7 +28,7 @@ internal static class UninstallService
{
installDirectory = Path.GetFullPath(installDirectory);
Thread.Sleep(750);
- TryCleanup("remove shortcuts", quiet, ShellIntegration.RemoveAllShortcuts);
+ TryCleanup("remove shortcuts", quiet, ShellIntegration.RemoveShortcuts);
// A portable installation never registered an uninstall entry, and the single machine-wide
// key may belong to a normal installation on the same account. Removing it here would