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;
}
///