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