Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/UniGetUI.Avalonia/Infrastructure/AppRestartHelper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Avalonia.Controls.ApplicationLifetimes;
using UniGetUI.Avalonia.Views;
using UniGetUI.Core.Logging;
using UniGetUI.Core.Tools;

namespace UniGetUI.Avalonia.Infrastructure;
Expand All @@ -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()
Expand All @@ -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();
}
Expand Down
37 changes: 37 additions & 0 deletions src/UniGetUI.Core.Tools.Tests/PlatformFacts.cs
Original file line number Diff line number Diff line change
@@ -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";
}
}
}
155 changes: 155 additions & 0 deletions src/UniGetUI.Core.Tools.Tests/RelaunchTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
3 changes: 3 additions & 0 deletions src/UniGetUI.Core.Tools/InternalsVisibleTo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("UniGetUI.Core.Tools.Tests")]
105 changes: 96 additions & 9 deletions src/UniGetUI.Core.Tools/Tools.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -157,28 +158,114 @@
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)
/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// Builds the detached process that waits for <paramref name="currentProcessId"/> to exit and
/// then starts <paramref name="executablePath"/>: PowerShell on Windows, a /bin/sh helper
/// elsewhere (the same shape the self-updater uses), which opens the .app bundle on macOS.
/// </summary>
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
Comment on lines +226 to +230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The macOS branch has no fallback and swallows failure, which can leave no app running at all.

FindAppBundle matches any ancestor directory whose name ends in .app (case-insensitive) — it does not verify that the directory is a real bundle. If open refuses the path (a tarball extracted into a directory that merely ends in .app, a bundle LaunchServices rejects as damaged or quarantined, an unregistered ad-hoc-signed build), the helper exits non-zero, nobody reads that status, and UniGetUI has already terminated. The user accepts "restart" after a dependency install and the app is simply gone — the bug this PR set out to remove, moved to macOS.

Falling back to the direct-exec branch costs one token:

/usr/bin/open -na "$bundle" || "$exe" >/dev/null 2>&1 &

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1342c47 with the line you suggested: /usr/bin/open -na "$bundle" || "$exe" >/dev/null 2>&1 &.

The new RelaunchHelper_StartsTheExecutableWhenOpenRejectsTheBundle runs the real helper with a bundle path that does not exist (on Linux there is no /usr/bin/open at all; on macOS open refuses the path) and asserts the executable starts only after the stand-in process exits. 5 consecutive runs green on Fedora 44; removing the || "$exe" fallback fails it and the script assertion. Still not run on a Mac.

""";

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

/// <summary>
/// Returns the .app bundle that contains <paramref name="executablePath"/>, or null when the
/// executable is not inside one.
/// </summary>
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;
}

/// <summary>
Expand Down Expand Up @@ -1522,7 +1609,7 @@
}

Task reads = Task.WhenAll(stdout, stderr);
reads.ContinueWith(

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / Linux (Avalonia)

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / Linux (NativeAOT)

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / Windows (Avalonia)

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / Windows (NativeAOT)

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)

Check warning on line 1612 in src/UniGetUI.Core.Tools/Tools.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Do not create tasks without passing a TaskScheduler (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2008)
completed => _ = completed.Exception,
TaskContinuationOptions.OnlyOnFaulted
);
Expand Down
Loading