fix shortcut

This commit is contained in:
patchzyy
2026-08-23 19:37:40 +02:00
parent 91a0c62cdf
commit 18e02595c4
7 changed files with 128 additions and 37 deletions
@@ -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);
@@ -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);
}
}
@@ -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());
@@ -0,0 +1,47 @@
using System.Diagnostics;
namespace WiiCompiled.Setup;
/// <summary>
/// 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.
/// </summary>
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<string> FindProcessesUnder(params string[] roots)
{
var names = new SortedSet<string>(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();
}
}
+20 -10
View File
@@ -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(
+29 -22
View File
@@ -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);
/// <summary>Removes shortcuts left by GUI-capable releases from before Wheel Wizard owned the UI.</summary>
public static void RemoveAllShortcuts()
/// <summary>Creates the desktop and Start Menu shortcuts that launch the base game.</summary>
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<string> shortcutPaths)
{
var failures = new List<Exception>();
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<Exception> failures)
{
try
@@ -57,18 +76,6 @@ internal static class ShellIntegration
}
}
private static void DeleteDirectoryBestEffort(string path, List<Exception> 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
@@ -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