Skip to content
Merged
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
151 changes: 151 additions & 0 deletions ARESLauncher.Desktop/LauncherUpdateBootstrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Threading;

namespace ARESLauncher.Desktop;

internal static class LauncherUpdateBootstrapper
{
private const string BootstrapperMode = "--apply-update";

public static bool TryRun(string[] args)
{
if(args.Length == 0 || !string.Equals(args[0], BootstrapperMode, StringComparison.OrdinalIgnoreCase))
return false;

try
{
Run(args);
}
catch
{
// A bootstrapper failure should not launch the full UI process.
}

return true;
}

private static void Run(string[] args)
{
var parsedArgs = ParseArgs(args);
var targetProcessId = int.Parse(GetRequiredArg(parsedArgs, "--target-pid"), CultureInfo.InvariantCulture);
var sourceDir = GetRequiredArg(parsedArgs, "--source-dir");
var targetDir = GetRequiredArg(parsedArgs, "--target-dir");
var executablePath = GetRequiredArg(parsedArgs, "--exe-path");

WaitForProcessExit(targetProcessId);
Thread.Sleep(300);

CopyDirectory(sourceDir, targetDir);
EnsureExecutablePermissions(executablePath);
Relaunch(executablePath, targetDir);
}

private static Dictionary<string, string> ParseArgs(string[] args)
{
if(args.Length < 9 || args.Length % 2 == 0)
throw new InvalidOperationException("Invalid launcher bootstrapper arguments.");

var parsed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for(var i = 1; i < args.Length; i += 2)
{
var key = args[i];
var value = args[i + 1];
parsed[key] = value;
}

return parsed;
}

private static string GetRequiredArg(IReadOnlyDictionary<string, string> args, string key)
{
return args.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidOperationException($"Missing required launcher bootstrapper arg: {key}");
}

private static void WaitForProcessExit(int processId)
{
while(IsProcessRunning(processId))
{
Thread.Sleep(300);
}
}

private static bool IsProcessRunning(int processId)
{
try
{
var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch(ArgumentException)
{
return false;
}
}

private static void CopyDirectory(string sourceDir, string targetDir)
{
if(!Directory.Exists(sourceDir))
throw new DirectoryNotFoundException($"Staging directory not found: {sourceDir}");

Directory.CreateDirectory(targetDir);

foreach(var directory in Directory.EnumerateDirectories(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, directory);
var destinationDirectory = Path.Combine(targetDir, relativePath);
Directory.CreateDirectory(destinationDirectory);
}

foreach(var file in Directory.EnumerateFiles(sourceDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDir, file);
var destinationFile = Path.Combine(targetDir, relativePath);
var destinationDirectory = Path.GetDirectoryName(destinationFile);
if(!string.IsNullOrEmpty(destinationDirectory))
{
Directory.CreateDirectory(destinationDirectory);
}

File.Copy(file, destinationFile, true);
}
}

private static void EnsureExecutablePermissions(string executablePath)
{
if(OperatingSystem.IsWindows())
return;

try
{
File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
catch
{
// Best effort only.
}
}

private static void Relaunch(string executablePath, string targetDir)
{
var workingDirectory = Path.GetDirectoryName(executablePath);
if(string.IsNullOrWhiteSpace(workingDirectory))
workingDirectory = targetDir;

var psi = new ProcessStartInfo(executablePath)
{
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory
};

var process = Process.Start(psi);
if(process is null)
throw new InvalidOperationException("Failed to relaunch launcher after update.");
}
}
12 changes: 10 additions & 2 deletions ARESLauncher.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
if(LauncherUpdateBootstrapper.TryRun(args))
return;

StartDesktop(args);
}

private static void StartDesktop(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);

// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
Expand Down
3 changes: 2 additions & 1 deletion ARESLauncher/Configuration/LauncherConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public class LauncherConfiguration

public AresSource CurrentAresRepo { get; set; } = new("AFRL-ARES", "ARES");

public LauncherSource LauncerSource { get; set; } = new("AFRL-ARES", "ARES-Launcher");
[JsonIgnore]
public LauncherSource LauncherSource { get; set; } = new("AFRL-ARES", "ARES-Launcher");

public AresSource[] AvailableAresRepos { get; set; } = [new("AFRL-ARES", "ARES")];

Expand Down
66 changes: 65 additions & 1 deletion ARESLauncher/Services/AresGithubDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ public async Task<SemanticVersion[]> GetAvailableVersions(LauncherSource source)
return versions.ToArray();
}

public async Task<string> Download(LauncherSource source, SemanticVersion version, string destination, string? authToken,
IProgress<double>? progress = null)
{
var client = CreateClient(authToken);
var release = await GetReleaseForVersion(client, source, version);
var asset = SelectAssetForLauncher(release) ??
throw new InvalidOperationException(
$"No launcher asset found in release {release.TagName} for {OsBundleNameGetter.GetName()}.");

var downloadUri = new Uri(asset.Url);
var downloadResult = await Downloader.Download(downloadUri, destination, authToken, progress);

return !downloadResult.Success
? throw new InvalidOperationException($"Failed to download launcher {version}: {downloadResult.Error}")
: downloadResult.ResultingFilePath!;
}

private async Task<List<SemanticVersion>> FetchAndNormalizeVersions(GitHubClient client, string owner, string repo)
{
var versions = new List<SemanticVersion>();
Expand Down Expand Up @@ -108,6 +125,24 @@ private static async Task<Release> GetReleaseForVersion(GitHubClient client, Are
throw new InvalidOperationException($"Could not locate release for version {version}.");
}

private static async Task<Release> GetReleaseForVersion(GitHubClient client, LauncherSource source,
SemanticVersion version)
{
var releases = await client.Repository.Release.GetAll(source.Owner, source.Repo, _fetchOptions);
foreach(var release in releases)
{
var tag = release.TagName;
var versionString = TagToVersion(tag);
var isVersion = SemanticVersion.TryParse(versionString ?? "", out var parsedVersion);
if(isVersion && version.Equals(parsedVersion))
{
return release;
}
}

throw new InvalidOperationException($"Could not locate launcher release for version {version}.");
}

private static ReleaseAsset? SelectAssetForComponent(Release release, AresComponent component)
{
if(release.Assets is null || release.Assets.Count == 0)
Expand All @@ -132,6 +167,35 @@ private static async Task<Release> GetReleaseForVersion(GitHubClient client, Are
return release.Assets.Count == 1 ? release.Assets[0] : null;
}

private static ReleaseAsset? SelectAssetForLauncher(Release release)
{
if(release.Assets is null || release.Assets.Count == 0)
return null;

var os = OsBundleNameGetter.GetName();
var candidateAssets = release.Assets
.Where(a => a.Name?.Contains(os, StringComparison.OrdinalIgnoreCase) == true)
.ToArray();

if(candidateAssets.Length == 0)
candidateAssets = release.Assets.ToArray();

var preferred = candidateAssets.FirstOrDefault(a =>
a.Name?.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) == true &&
!a.Name!.Contains("offline", StringComparison.OrdinalIgnoreCase));

if(preferred is not null)
return preferred;

preferred = candidateAssets.FirstOrDefault(a =>
a.Name?.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) == true);

if(preferred is not null)
return preferred;

return candidateAssets.Length == 1 ? candidateAssets[0] : null;
}

private static string? TagToVersion(string? tag)
{
if(string.IsNullOrWhiteSpace(tag))
Expand All @@ -146,4 +210,4 @@ private static async Task<Release> GetReleaseForVersion(GitHubClient client, Are

[GeneratedRegex(".*[vV](.*)")]
private static partial Regex VersionRegex();
}
}
5 changes: 4 additions & 1 deletion ARESLauncher/Services/IAresDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public interface IAresDownloader

Task<SemanticVersion[]> GetAvailableVersions(LauncherSource soruce);

Task<string> Download(LauncherSource source, SemanticVersion version, string destination, string? authToken,
IProgress<double>? progress = null);

/// <summary>
/// </summary>
/// <param name="source"></param>
Expand All @@ -26,4 +29,4 @@ public interface IAresDownloader
/// <returns>The file path of the newly downloaded item</returns>
Task<string> Download(AresSource source, SemanticVersion version, AresComponent component, string destination, string? authToken,
IProgress<double>? progress = null);
}
}
2 changes: 2 additions & 0 deletions ARESLauncher/Services/ILauncherUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ namespace ARESLauncher.Services;
public interface ILauncherUpdater
{
Task<SemanticVersion[]> GetAvailableVersions();

Task<bool> UpdateLatest();
}
Loading