diff --git a/ARESLauncher.Desktop/LauncherUpdateBootstrapper.cs b/ARESLauncher.Desktop/LauncherUpdateBootstrapper.cs new file mode 100644 index 0000000..4ef1af7 --- /dev/null +++ b/ARESLauncher.Desktop/LauncherUpdateBootstrapper.cs @@ -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 ParseArgs(string[] args) + { + if(args.Length < 9 || args.Length % 2 == 0) + throw new InvalidOperationException("Invalid launcher bootstrapper arguments."); + + var parsed = new Dictionary(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 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."); + } +} diff --git a/ARESLauncher.Desktop/Program.cs b/ARESLauncher.Desktop/Program.cs index 5595bd4..51840d7 100644 --- a/ARESLauncher.Desktop/Program.cs +++ b/ARESLauncher.Desktop/Program.cs @@ -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() diff --git a/ARESLauncher/Configuration/LauncherConfiguration.cs b/ARESLauncher/Configuration/LauncherConfiguration.cs index c47bcb6..a5311ae 100644 --- a/ARESLauncher/Configuration/LauncherConfiguration.cs +++ b/ARESLauncher/Configuration/LauncherConfiguration.cs @@ -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")]; diff --git a/ARESLauncher/Services/AresGithubDownloader.cs b/ARESLauncher/Services/AresGithubDownloader.cs index 98405ee..dff5fcd 100644 --- a/ARESLauncher/Services/AresGithubDownloader.cs +++ b/ARESLauncher/Services/AresGithubDownloader.cs @@ -29,6 +29,23 @@ public async Task GetAvailableVersions(LauncherSource source) return versions.ToArray(); } + public async Task Download(LauncherSource source, SemanticVersion version, string destination, string? authToken, + IProgress? 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> FetchAndNormalizeVersions(GitHubClient client, string owner, string repo) { var versions = new List(); @@ -108,6 +125,24 @@ private static async Task GetReleaseForVersion(GitHubClient client, Are throw new InvalidOperationException($"Could not locate release for version {version}."); } + private static async Task 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) @@ -132,6 +167,35 @@ private static async Task 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)) @@ -146,4 +210,4 @@ private static async Task GetReleaseForVersion(GitHubClient client, Are [GeneratedRegex(".*[vV](.*)")] private static partial Regex VersionRegex(); -} \ No newline at end of file +} diff --git a/ARESLauncher/Services/IAresDownloader.cs b/ARESLauncher/Services/IAresDownloader.cs index 1e108a0..00ec2e8 100644 --- a/ARESLauncher/Services/IAresDownloader.cs +++ b/ARESLauncher/Services/IAresDownloader.cs @@ -16,6 +16,9 @@ public interface IAresDownloader Task GetAvailableVersions(LauncherSource soruce); + Task Download(LauncherSource source, SemanticVersion version, string destination, string? authToken, + IProgress? progress = null); + /// /// /// @@ -26,4 +29,4 @@ public interface IAresDownloader /// The file path of the newly downloaded item Task Download(AresSource source, SemanticVersion version, AresComponent component, string destination, string? authToken, IProgress? progress = null); -} \ No newline at end of file +} diff --git a/ARESLauncher/Services/ILauncherUpdater.cs b/ARESLauncher/Services/ILauncherUpdater.cs index 6163336..1ad245a 100644 --- a/ARESLauncher/Services/ILauncherUpdater.cs +++ b/ARESLauncher/Services/ILauncherUpdater.cs @@ -9,4 +9,6 @@ namespace ARESLauncher.Services; public interface ILauncherUpdater { Task GetAvailableVersions(); + + Task UpdateLatest(); } diff --git a/ARESLauncher/Services/LauncherUpdater.cs b/ARESLauncher/Services/LauncherUpdater.cs index 5b16477..d72fac3 100644 --- a/ARESLauncher/Services/LauncherUpdater.cs +++ b/ARESLauncher/Services/LauncherUpdater.cs @@ -1,9 +1,12 @@ -using ARESLauncher.Services.Configuration; +using ARESLauncher.Services.Configuration; +using ARESLauncher.Tools; using Microsoft.Extensions.Logging; using NuGet.Versioning; using System; -using System.Collections.Generic; -using System.Text; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; using System.Threading.Tasks; namespace ARESLauncher.Services; @@ -23,7 +26,141 @@ public LauncherUpdater(IAresDownloader downloader, ILogger logg public Task GetAvailableVersions() { - var source = _configurationService.Current.LauncerSource; + var source = _configurationService.Current.LauncherSource; return _downloader.GetAvailableVersions(source); } + + public async Task UpdateLatest() + { + var versions = await GetAvailableVersions(); + var latest = versions.OrderDescending().FirstOrDefault(); + if(latest is null) + throw new InvalidOperationException("No launcher versions found."); + + var currentLauncherVersion = LauncherVersionHelper.GetLauncherVersion(); + if(!SemanticVersion.TryParse(currentLauncherVersion, out var currentVersion)) + throw new InvalidOperationException($"Unable to parse current launcher version: {currentLauncherVersion}"); + + if(currentVersion.IsGreatest(versions)) + { + _logger.LogInformation("Launcher is already up to date at {Version}", currentVersion.ToNormalizedString()); + return false; + } + + var source = _configurationService.Current.LauncherSource; + var tempRoot = Path.Combine(Path.GetTempPath(), "ares-launcher-selfupdate", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + + var archivePath = await _downloader.Download(source, latest, tempRoot, _configurationService.Current.GitToken); + if(!archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("Automatic launcher updates currently support only .zip release assets."); + + var stagingPath = Path.Combine(tempRoot, "staging"); + await Unpacker.Unpack(archivePath, stagingPath); + + var executablePath = Environment.ProcessPath; + if(string.IsNullOrWhiteSpace(executablePath)) + throw new InvalidOperationException("Unable to determine launcher executable path."); + + var installDirectory = Path.GetFullPath(AppContext.BaseDirectory); + EnsureInstallDirectoryIsWritable(installDirectory); + + StartUpdateWorker( + Environment.ProcessId, + stagingPath, + installDirectory, + executablePath, + tempRoot, + _logger); + + _logger.LogInformation("Launcher update to {Version} has been downloaded and staged.", latest.ToNormalizedString()); + return true; + } + + private static void EnsureInstallDirectoryIsWritable(string installDirectory) + { + var probe = Path.Combine(installDirectory, $".areslauncher-update-probe-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(probe, ""); + File.Delete(probe); + } + + private static void StartUpdateWorker(int targetProcessId, string sourceDir, string targetDir, string executablePath, string workingDir, + ILogger logger) + { + Directory.CreateDirectory(workingDir); + + CopyBootstrapperRuntime(executablePath, targetDir, workingDir); + var bootstrapperPath = Path.Combine(workingDir, Path.GetFileName(executablePath)); + EnsureExecutablePermissions(bootstrapperPath); + + var psi = new ProcessStartInfo(bootstrapperPath) + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDir + }; + + psi.ArgumentList.Add("--apply-update"); + psi.ArgumentList.Add("--target-pid"); + psi.ArgumentList.Add(targetProcessId.ToString(CultureInfo.InvariantCulture)); + psi.ArgumentList.Add("--source-dir"); + psi.ArgumentList.Add(sourceDir); + psi.ArgumentList.Add("--target-dir"); + psi.ArgumentList.Add(targetDir); + psi.ArgumentList.Add("--exe-path"); + psi.ArgumentList.Add(executablePath); + + var process = Process.Start(psi); + if(process is null) + throw new InvalidOperationException("Failed to start the launcher update bootstrapper."); + + logger.LogInformation("Launcher bootstrapper process started. Pid: {Pid}", process.Id); + } + + private static void CopyBootstrapperRuntime(string executablePath, string installDirectory, string workingDirectory) + { + var exeName = Path.GetFileName(executablePath); + var copiedFiles = 0; + + CopyIfExists(executablePath, Path.Combine(workingDirectory, exeName), ref copiedFiles); + + var baseName = Path.GetFileNameWithoutExtension(executablePath); + CopyIfExists(Path.Combine(installDirectory, $"{baseName}.dll"), Path.Combine(workingDirectory, $"{baseName}.dll"), ref copiedFiles); + CopyIfExists(Path.Combine(installDirectory, $"{baseName}.deps.json"), Path.Combine(workingDirectory, $"{baseName}.deps.json"), ref copiedFiles); + CopyIfExists(Path.Combine(installDirectory, $"{baseName}.runtimeconfig.json"), + Path.Combine(workingDirectory, $"{baseName}.runtimeconfig.json"), ref copiedFiles); + + foreach(var file in Directory.EnumerateFiles(installDirectory, "*.dll")) + { + var destination = Path.Combine(workingDirectory, Path.GetFileName(file)); + CopyIfExists(file, destination, ref copiedFiles); + } + + if(copiedFiles == 0) + throw new InvalidOperationException("Failed to stage launcher bootstrapper runtime files."); + } + + private static void CopyIfExists(string source, string destination, ref int copiedFiles) + { + if(!File.Exists(source)) + return; + + File.Copy(source, destination, true); + copiedFiles++; + } + + private static void EnsureExecutablePermissions(string executablePath) + { + if(OperatingSystem.IsWindows()) + return; + + try + { + File.SetUnixFileMode(executablePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch(Exception) + { + // Best effort; permissions can already be correct based on extraction umask. + } + } } diff --git a/ARESLauncher/ViewModels/MainViewModel.cs b/ARESLauncher/ViewModels/MainViewModel.cs index d8977bc..2cd6e72 100644 --- a/ARESLauncher/ViewModels/MainViewModel.cs +++ b/ARESLauncher/ViewModels/MainViewModel.cs @@ -1,6 +1,8 @@ using ARESLauncher.Models; using ARESLauncher.Services; using ARESLauncher.Tools; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; using NuGet.Versioning; using ReactiveUI; using ReactiveUI.SourceGenerators; @@ -76,7 +78,7 @@ public MainViewModel(ConfigurationOverviewViewModel overview, StopAresCommand = ReactiveCommand.CreateFromTask(aresStarter.Stop); UpdateAresCommand = ReactiveCommand.CreateFromTask(UpdateAres); OpenBrowserCommand = ReactiveCommand.Create(browserOpener.Open); - OpenLauncherReleasePageCommand = ReactiveCommand.Create(() => browserOpener.Open("https://github.com/AFRL-ARES/ARES-Launcher/releases")); + OpenLauncherReleasePageCommand = ReactiveCommand.CreateFromTask(CheckForUpdatedLauncher); ConflictDialog = new Interaction(); ResolveConflictsCommand = ReactiveCommand.CreateFromTask(async () => { @@ -116,8 +118,8 @@ public MainViewModel(ConfigurationOverviewViewModel overview, .WhenAnyValue(x => x.AvailableLauncherVersions, (av) => { var currentLauncherVersion = LauncherVersionHelper.GetLauncherVersion(); - var matchingSemantic = AvailableLauncherVersions?.FirstOrDefault(v => $"{v.Major}.{v.Minor}.{v.Patch}" == currentLauncherVersion); - bool launcherUpdateAvailable = av is not null && matchingSemantic?.IsGreatest(av) is false; + var hasCurrentVersion = SemanticVersion.TryParse(currentLauncherVersion, out var currentSemantic); + bool launcherUpdateAvailable = av is not null && hasCurrentVersion && currentSemantic!.IsGreatest(av) is false; return launcherUpdateAvailable; }) .ToProperty(this, ViewModels => ViewModels.LauncherUpdateAvailable); @@ -264,6 +266,9 @@ public MainViewModel(ConfigurationOverviewViewModel overview, [Reactive] public partial bool ButtonEnabled { get; private set; } + [Reactive] + public partial bool LauncherUpdateInProgress { get; private set; } + public AresState AresState => _aresState.Value; public IReactiveCommand? ButtonCommand => _buttonCommand.Value; @@ -392,11 +397,29 @@ private async Task CheckForUpdatedLauncher() { try { + Error = ""; + LauncherUpdateInProgress = true; + var updateStarted = await _launcherUpdater.UpdateLatest(); + if(!updateStarted) + { + LauncherUpdateInProgress = false; + return; + } + if(Application.Current is App app) + { + app.BeginShutdown(); + } + + if(Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + desktopLifetime.Shutdown(); + } } catch(Exception e) { + LauncherUpdateInProgress = false; Error = e.Message; } } @@ -423,4 +446,4 @@ private void OnConfigurationSaved(object? sender, EventArgs e) Overview.Refresh(); _ = CheckAresCondition(); } -} \ No newline at end of file +} diff --git a/ARESLauncher/Views/MainView.axaml b/ARESLauncher/Views/MainView.axaml index 23eb557..d4d6b8f 100644 --- a/ARESLauncher/Views/MainView.axaml +++ b/ARESLauncher/Views/MainView.axaml @@ -33,106 +33,129 @@ - - - - - + + + + + + - - + + - - - + - - + + - - - + + - - Edge and Chrome might give a "Your connection isn't private" warning. - Feel free to click "Advanced" and then proceed. + + Edge and Chrome might give a "Your connection isn't private" warning. + Feel free to click "Advanced" and then proceed. + - - - - - - - - + + + + + + + - - + - - - - - + TextWrapping="Wrap" + Margin="0,10,0,0" /> + + + + + + + + + + + + - - + + + + + + + + + + + - - - - - - - - - - \ No newline at end of file + + + +