diff --git a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs index f541199b4c..2361f2d698 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs @@ -1,5 +1,6 @@ using Avalonia.Controls.ApplicationLifetimes; using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Logging; using UniGetUI.Core.Tools; namespace UniGetUI.Avalonia.Infrastructure; @@ -8,6 +9,8 @@ internal static class AppRestartHelper { private const string LauncherExecutableName = "UniGetUI.exe"; + private sealed class RelaunchNotScheduledException : Exception; + public static void Restart() => _ = RestartAsync(); private static async Task RestartAsync() @@ -16,12 +19,29 @@ private static async Task RestartAsync() if (MainWindow.Instance is { } mainWindow) { - await mainWindow.RequestQuitApplicationAsync( - () => CoreTools.ScheduleRelaunchAfterExit(executablePath)); + // A throw from the callback makes the coordinator cancel the shutdown, so the window stays open. + try + { + await mainWindow.RequestQuitApplicationAsync(() => + { + if (!CoreTools.TryScheduleRelaunchAfterExit(executablePath)) + throw new RelaunchNotScheduledException(); + }); + } + catch (RelaunchNotScheduledException) + { + Logger.Warn("Restart cancelled: the relaunch helper could not be started, UniGetUI stays open"); + } + + return; + } + + if (!CoreTools.TryScheduleRelaunchAfterExit(executablePath)) + { + Logger.Warn("Restart cancelled: the relaunch helper could not be started, UniGetUI stays open"); return; } - CoreTools.ScheduleRelaunchAfterExit(executablePath); (global::Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); } diff --git a/src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs b/src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs new file mode 100644 index 0000000000..0242cb43b1 --- /dev/null +++ b/src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs @@ -0,0 +1,37 @@ +namespace UniGetUI.Core.Tools.Tests; + +// xUnit 2 has no runtime skip; setting Skip in the attribute makes a test for another platform +// report as skipped instead of passing without running. + +public sealed class WindowsFactAttribute : FactAttribute +{ + public WindowsFactAttribute() + { + if (!OperatingSystem.IsWindows()) + { + Skip = "Runs on Windows only"; + } + } +} + +public sealed class UnixFactAttribute : FactAttribute +{ + public UnixFactAttribute() + { + if (OperatingSystem.IsWindows()) + { + Skip = "Runs on Linux and macOS only"; + } + } +} + +public sealed class MacOSFactAttribute : FactAttribute +{ + public MacOSFactAttribute() + { + if (!OperatingSystem.IsMacOS()) + { + Skip = "Runs on macOS only"; + } + } +} diff --git a/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs b/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs new file mode 100644 index 0000000000..f5be4aa3df --- /dev/null +++ b/src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs @@ -0,0 +1,155 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Core.Tools.Tests; + +public class RelaunchTests +{ + [WindowsFact] + public void CreateRelaunchStartInfo_OnWindows_WaitsAndStartsThroughPowerShell() + { + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo( + 4242, + @"C:\Users\O'Brien\UniGetUI\UniGetUI.exe" + ); + + Assert.Equal("powershell.exe", startInfo.FileName); + Assert.Equal( + "-NoProfile -WindowStyle Hidden -Command \"Wait-Process -Id 4242; " + + @"Start-Process -FilePath 'C:\Users\O''Brien\UniGetUI\UniGetUI.exe'""", + startInfo.Arguments + ); + Assert.Empty(startInfo.ArgumentList); + Assert.False(startInfo.UseShellExecute); + Assert.True(startInfo.CreateNoWindow); + } + + [UnixFact] + public void CreateRelaunchStartInfo_OffWindows_WaitsForThePidInAShellHelper() + { + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo(4242, "/opt/unigetui/UniGetUI"); + + Assert.Equal("/bin/sh", startInfo.FileName); + Assert.Equal("", startInfo.Arguments); + Assert.Equal(6, startInfo.ArgumentList.Count); + Assert.Equal("-c", startInfo.ArgumentList[0]); + Assert.Contains("kill -0 \"$pid\"", startInfo.ArgumentList[1]); + Assert.Contains("\"$exe\" >/dev/null 2>&1 &", startInfo.ArgumentList[1]); + Assert.Contains("/usr/bin/open -na \"$bundle\" || \"$exe\" >/dev/null 2>&1 &", startInfo.ArgumentList[1]); + Assert.Equal("sh", startInfo.ArgumentList[2]); + Assert.Equal("4242", startInfo.ArgumentList[3]); + Assert.Equal("/opt/unigetui/UniGetUI", startInfo.ArgumentList[4]); + Assert.Equal("", startInfo.ArgumentList[5]); + Assert.False(startInfo.UseShellExecute); + } + + [MacOSFact] + public void CreateRelaunchStartInfo_OnMacOs_PassesTheBundleToTheHelper() + { + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo( + 4242, + "/Applications/UniGetUI.app/Contents/MacOS/UniGetUI" + ); + + Assert.Equal("/Applications/UniGetUI.app", startInfo.ArgumentList[5]); + } + + [Theory] + [InlineData("/Applications/UniGetUI.app/Contents/MacOS/UniGetUI", "UniGetUI.app")] + [InlineData("/Users/me/My Apps/UniGetUI.app/Contents/MacOS/UniGetUI", "UniGetUI.app")] + public void FindAppBundle_ReturnsTheBundleThatContainsTheExecutable(string executable, string bundleName) + { + string? bundle = CoreTools.FindAppBundle(executable); + + Assert.NotNull(bundle); + Assert.Equal(bundleName, Path.GetFileName(bundle)); + Assert.EndsWith(bundleName, bundle); + } + + [Theory] + [InlineData("/opt/unigetui/UniGetUI")] + [InlineData("/home/me/UniGetUI.app.bak/bin/UniGetUI")] + [InlineData("UniGetUI")] + public void FindAppBundle_ReturnsNullOutsideABundle(string executable) + { + Assert.Null(CoreTools.FindAppBundle(executable)); + } + + [Fact] + public void TryStartRelaunchHelper_ReturnsFalseWhenTheHelperCannotStart() + { + var startInfo = new ProcessStartInfo( + Path.Combine(Path.GetTempPath(), "unigetui-missing-" + Path.GetRandomFileName()) + ) + { + UseShellExecute = false, + }; + + Assert.False(CoreTools.TryStartRelaunchHelper(startInfo)); + } + + // The real helper: a sleeping child stands in for the exiting UniGetUI, and the "executable" is + // a script that leaves a marker. The marker must not appear while the child is alive. + [UnixFact] + [UnsupportedOSPlatform("windows")] + public Task RelaunchHelper_StartsTheExecutableOnlyAfterTheProcessExits() => + AssertHelperRelaunchesAfterExitAsync(bundle: null); + + // A bundle open refuses (here one that does not exist; on Linux there is no /usr/bin/open at + // all) must fall back to starting the executable instead of leaving nothing running. + [UnixFact] + [UnsupportedOSPlatform("windows")] + public Task RelaunchHelper_StartsTheExecutableWhenOpenRejectsTheBundle() => + AssertHelperRelaunchesAfterExitAsync( + bundle: Path.Combine(Path.GetTempPath(), "unigetui-missing-" + Path.GetRandomFileName(), "UniGetUI.app") + ); + + [UnsupportedOSPlatform("windows")] + private static async Task AssertHelperRelaunchesAfterExitAsync(string? bundle) + { + string directory = Path.Combine(Path.GetTempPath(), "unigetui-relaunch-" + Path.GetRandomFileName()); + Directory.CreateDirectory(directory); + string marker = Path.Combine(directory, "relaunched"); + string target = Path.Combine(directory, "target.sh"); + File.WriteAllText(target, $"#!/bin/sh\ntouch \"{marker}\"\n"); + File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + using var standIn = Process.Start(new ProcessStartInfo("sleep", "1.5") { UseShellExecute = false })!; + try + { + ProcessStartInfo startInfo = CoreTools.CreateRelaunchStartInfo(standIn.Id, target); + if (bundle is not null) + { + startInfo.ArgumentList[5] = bundle; + } + + using var helper = Process.Start(startInfo); + Assert.NotNull(helper); + + await Task.Delay(500); + Assert.False(standIn.HasExited); + Assert.False(File.Exists(marker), "the helper must not start the executable while the process is alive"); + + await standIn.WaitForExitAsync(); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!File.Exists(marker) && DateTime.UtcNow < deadline) + { + await Task.Delay(100); + } + + Assert.True(File.Exists(marker), "the helper did not start the executable after the process exited"); + await helper.WaitForExitAsync(); + Assert.Equal(0, helper.ExitCode); + } + finally + { + if (!standIn.HasExited) + { + standIn.Kill(); + } + + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/src/UniGetUI.Core.Tools/InternalsVisibleTo.cs b/src/UniGetUI.Core.Tools/InternalsVisibleTo.cs new file mode 100644 index 0000000000..55ff8a4021 --- /dev/null +++ b/src/UniGetUI.Core.Tools/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Core.Tools.Tests")] diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index 074ae393f8..5d2d540926 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Collections; using System.Collections.Concurrent; +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Net; @@ -157,28 +158,114 @@ public static string AutoTranslated(string text) public static void RelaunchProcess() { Logger.Debug("Launching process: " + CoreData.UniGetUIExecutableFile); - ScheduleRelaunchAfterExit(); + if (!TryScheduleRelaunchAfterExit()) + { + return; + } + Logger.Warn("About to kill process"); Environment.Exit(0); } - public static void ScheduleRelaunchAfterExit(string? executablePath = null) + /// + /// Starts the helper that relaunches UniGetUI once this process exits. Returns false, and + /// logs why, when the helper could not be started; the caller should then keep running. + /// + public static bool TryScheduleRelaunchAfterExit(string? executablePath = null) { executablePath ??= CoreData.UniGetUIExecutableFile; - int currentProcessId = Environment.ProcessId; - string escapedExecutablePath = executablePath.Replace("'", "''"); - string command = - $"Wait-Process -Id {currentProcessId}; Start-Process -FilePath '{escapedExecutablePath}'"; + ProcessStartInfo startInfo = CreateRelaunchStartInfo(Environment.ProcessId, executablePath); + Logger.Debug($"Scheduling a relaunch of {executablePath} through {startInfo.FileName}"); + return TryStartRelaunchHelper(startInfo); + } + + internal static bool TryStartRelaunchHelper(ProcessStartInfo startInfo) + { + try + { + using var process = Process.Start(startInfo); + return process is not null; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or PlatformNotSupportedException) + { + Logger.Error($"Could not start the relaunch helper {startInfo.FileName}:"); + Logger.Error(ex); + return false; + } + } + + /// + /// Builds the detached process that waits for to exit and + /// then starts : PowerShell on Windows, a /bin/sh helper + /// elsewhere (the same shape the self-updater uses), which opens the .app bundle on macOS. + /// + internal static ProcessStartInfo CreateRelaunchStartInfo(int currentProcessId, string executablePath) + { + if (OperatingSystem.IsWindows()) + { + string escapedExecutablePath = executablePath.Replace("'", "''"); + string command = + $"Wait-Process -Id {currentProcessId}; Start-Process -FilePath '{escapedExecutablePath}'"; - using var process = Process.Start( - new ProcessStartInfo + return new ProcessStartInfo { FileName = "powershell.exe", Arguments = $"-NoProfile -WindowStyle Hidden -Command \"{command}\"", UseShellExecute = false, CreateNoWindow = true, - } + }; + } + + // Positional arguments ($1=pid, $2=executable, $3=bundle or empty) keep the paths out of + // the script text. The wait has no cap, like Wait-Process on Windows: launching while the + // old process is alive would hand off to it through the single-instance guard and exit. + // If open rejects the bundle, the executable is started directly. + const string script = """ + pid="$1"; exe="$2"; bundle="$3" + while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done + if [ -n "$bundle" ]; then + /usr/bin/open -na "$bundle" || "$exe" >/dev/null 2>&1 & + else + "$exe" >/dev/null 2>&1 & + fi + """; + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/sh", + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(script); + startInfo.ArgumentList.Add("sh"); + startInfo.ArgumentList.Add(currentProcessId.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(executablePath); + startInfo.ArgumentList.Add( + OperatingSystem.IsMacOS() ? FindAppBundle(executablePath) ?? "" : "" ); + return startInfo; + } + + /// + /// Returns the .app bundle that contains , or null when the + /// executable is not inside one. + /// + internal static string? FindAppBundle(string executablePath) + { + for ( + string? directory = Path.GetDirectoryName(executablePath); + directory is not null; + directory = Path.GetDirectoryName(directory) + ) + { + if (Path.GetFileName(directory).EndsWith(".app", StringComparison.OrdinalIgnoreCase)) + { + return directory; + } + } + + return null; } ///