diff --git a/NexusMods.App.sln b/NexusMods.App.sln index d65b0b8de4..4db9b3dad6 100644 --- a/NexusMods.App.sln +++ b/NexusMods.App.sln @@ -123,6 +123,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.Downlo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions", "src\NexusMods.Abstractions\NexusMods.Abstractions.csproj", "{1E20E979-5F04-44FD-BE07-54B6686ECB13}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Updater", "src\NexusMods.Updater\NexusMods.Updater.csproj", "{39563B8C-E034-401E-AC97-47FD570759C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Updater.Tests", "tests\NexusMods.Updater.Tests\NexusMods.Updater.Tests.csproj", "{1A9F310A-BA79-44C0-95E8-A3A66BD691AC}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Generic.Tests", "tests\Games\NexusMods.Games.Generic.Tests\NexusMods.Games.Generic.Tests.csproj", "{AA95B93F-23AC-46D5-83B3-2E7AE4BD309C}" EndProject Global @@ -303,10 +307,18 @@ Global {1E20E979-5F04-44FD-BE07-54B6686ECB13}.Debug|Any CPU.Build.0 = Debug|Any CPU {1E20E979-5F04-44FD-BE07-54B6686ECB13}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E20E979-5F04-44FD-BE07-54B6686ECB13}.Release|Any CPU.Build.0 = Release|Any CPU + {39563B8C-E034-401E-AC97-47FD570759C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39563B8C-E034-401E-AC97-47FD570759C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39563B8C-E034-401E-AC97-47FD570759C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39563B8C-E034-401E-AC97-47FD570759C0}.Release|Any CPU.Build.0 = Release|Any CPU {AA95B93F-23AC-46D5-83B3-2E7AE4BD309C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AA95B93F-23AC-46D5-83B3-2E7AE4BD309C}.Debug|Any CPU.Build.0 = Debug|Any CPU {AA95B93F-23AC-46D5-83B3-2E7AE4BD309C}.Release|Any CPU.ActiveCfg = Release|Any CPU {AA95B93F-23AC-46D5-83B3-2E7AE4BD309C}.Release|Any CPU.Build.0 = Release|Any CPU + {1A9F310A-BA79-44C0-95E8-A3A66BD691AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A9F310A-BA79-44C0-95E8-A3A66BD691AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A9F310A-BA79-44C0-95E8-A3A66BD691AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A9F310A-BA79-44C0-95E8-A3A66BD691AC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -361,6 +373,8 @@ Global {09B037AB-07BB-4154-95FD-6EA2E55C4568} = {897C4198-884F-448A-B0B0-C2A6D971EAE0} {1E20E979-5F04-44FD-BE07-54B6686ECB13} = {E7BAE287-D505-4D6D-A090-665A64309B2D} {AA95B93F-23AC-46D5-83B3-2E7AE4BD309C} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D} + {39563B8C-E034-401E-AC97-47FD570759C0} = {E7BAE287-D505-4D6D-A090-665A64309B2D} + {1A9F310A-BA79-44C0-95E8-A3A66BD691AC} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501} diff --git a/docs/design-explanations/0003-updater-design.md b/docs/design-explanations/0003-updater-design.md new file mode 100644 index 0000000000..367fdbec8f --- /dev/null +++ b/docs/design-explanations/0003-updater-design.md @@ -0,0 +1,61 @@ +# Application Updater + +## Problem +The application needs a way to update itself, preferably without a lot of +user interaction + +The issue is that on Windows and perhaps other platforms an executable cannot +be overwritten while it is running. This means that the application cannot +simply download a new version and restart itself. Instead we need to have +a multi-step process. + +## Design Goals + +* Remain as simple as possible, we don't want to pull in a lot of dependencies +* The application is rather small, so we don't need to worry about any sort of delta + patching +* It would be nice to not need a separate launcher application as then we have an A->B->A issue + where we need an updater to update the updater +* It would be nice to be able to download an update while the app is running, and then let the user + know that we will update the code during next time the app launches +* We should never stop the users from using the app just to run an update +* We should recover from failed updates +* We can't update when the CLI is running, so we need to not attempt to run updates when multiple + processes are running. +* Make it as OS agnostic as possible, so we don't need to write a different updater for each OS + + + +## Implementation + +The solution to this problem is to download updates into a `_update` folder, inside the main +application folder. When the application starts it looks for this folder, and if there is a pending +update, the app relaunches itself with special flag, but runs the executable in the `_update` folder. +This command then reads the current folder and copies its contents into parent folder. This means +that all the logic is contained inside the application itself, and yet still allows for updates. + + +## Code Flow + +### On CLI start +* Look for a `__update__` folder, and see if `UPDATE_READY` exists as a file in that folder +* If it does, log a message saying there's a pending update, the contents of the `UPDATE_READY` file contains the new version + +### On normal app start +* If any other process is running with the same process name as the current process, exit the update routine +* If the `__update__` folder exists, but does not contain the `UPDATE_READY` file, then delete the folder, and continue a normal launch +* If the `__update__` folder exists, and contains a `UPDATE_READY` file, launch the app with the `copy-app-to-folder` command, + passing in the current app folder, and the current app process ID. Then exit the app. + +### During normal app operation +* Read the list of versions available from github +* If the current version is not the latest version, extract the latest version to the `__update__` folder +* Create a `UPDATE_READY` file in the `__update__` folder, and write the new version number to it +* If the current version is the latest, cache the latest version for 6 hours (so future app restarts don't ping github) +* Sleep for 6 hours + +### If the app is run with `copy-app-to-folder` +* Wait for the parent process to exit +* Copy everything in the current folder to the parent folder +* Delete the `UPDATE_READY` file +* Launch the process in the parent, and exit diff --git a/src/NexusMods.Abstractions/CLI/AVerb.cs b/src/NexusMods.Abstractions/CLI/AVerb.cs index de84fad49c..4ad420910e 100644 --- a/src/NexusMods.Abstractions/CLI/AVerb.cs +++ b/src/NexusMods.Abstractions/CLI/AVerb.cs @@ -76,3 +76,26 @@ public interface AVerb : IVerb public Task Run(T1 a, T2 b, T3 c, CancellationToken token); } +/// +/// Abstract class for a verb that takes four arguments +/// +/// +/// +/// +/// +public interface AVerb : IVerb +{ + Delegate IVerb.Delegate => Run; + + /// + /// Runs the verb + /// + /// + /// + /// + /// + /// + /// + public Task Run(T1 a, T2 b, T3 c, T4 d, CancellationToken token); +} + diff --git a/src/NexusMods.App.UI/Windows/MainWindow.axaml.cs b/src/NexusMods.App.UI/Windows/MainWindow.axaml.cs index 80dadee0b1..9af4a1a3bb 100644 --- a/src/NexusMods.App.UI/Windows/MainWindow.axaml.cs +++ b/src/NexusMods.App.UI/Windows/MainWindow.axaml.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reactive.Disposables; using System.Reactive.Linq; using Avalonia; @@ -18,6 +19,8 @@ public MainWindow() this.AttachDevTools(); #endif + Title = "Nexus Mods App - v" + Process.GetCurrentProcess().MainModule?.FileVersionInfo?.ProductVersion ?? "Unknown"; + this.WhenActivated(disposables => { this.OneWayBind(ViewModel, vm => vm.TopBar, v => v.TopBar.ViewModel) diff --git a/src/NexusMods.App/CLI/Renderers/Spectre.cs b/src/NexusMods.App/CLI/Renderers/Spectre.cs index ce2b6f6766..7186caed2e 100644 --- a/src/NexusMods.App/CLI/Renderers/Spectre.cs +++ b/src/NexusMods.App/CLI/Renderers/Spectre.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using NexusMods.Abstractions.CLI; using NexusMods.DataModel.RateLimiting; using Spectre.Console; @@ -20,7 +21,9 @@ public Spectre(IEnumerable resources) public string Name => "console"; public void RenderBanner() { - //AnsiConsole.Write(new FigletText("NexusMods.App") {Color = NexusColor}); + AnsiConsole.Write(new FigletText("NexusMods.App") {Color = NexusColor}); + var version = Process.GetCurrentProcess().MainModule?.FileVersionInfo?.ProductVersion; + AnsiConsole.Write(new Text($"v: {version}\n", new Style(NexusColor))); } public async Task WithProgress(CancellationToken token, Func> f, bool showSize = true) @@ -129,6 +132,9 @@ public async Task Render(T o) case Abstractions.CLI.DataOutputs.Table t: await RenderTable(t); break; + case string s: + AnsiConsole.MarkupLine(s); + break; default: throw new NotImplementedException(); } diff --git a/src/NexusMods.App/NexusMods.App.csproj b/src/NexusMods.App/NexusMods.App.csproj index 25116014bf..fb4555e4d3 100644 --- a/src/NexusMods.App/NexusMods.App.csproj +++ b/src/NexusMods.App/NexusMods.App.csproj @@ -20,6 +20,7 @@ + diff --git a/src/NexusMods.App/Program.cs b/src/NexusMods.App/Program.cs index d0e94b2a33..070f9b5af1 100644 --- a/src/NexusMods.App/Program.cs +++ b/src/NexusMods.App/Program.cs @@ -12,6 +12,7 @@ using NexusMods.CLI; using NexusMods.Common; using NexusMods.Paths; +using NexusMods.Updater; using NLog.Extensions.Logging; using NLog.Targets; using ReactiveUI; @@ -25,7 +26,7 @@ public class Program [STAThread] public static async Task Main(string[] args) { - var host = BuildHost(); + var host = BuildHost(args.Length == 0); _logger = host.Services.GetRequiredService>(); TaskScheduler.UnobservedTaskException += (_, e) => @@ -59,11 +60,18 @@ public static async Task Main(string[] args) // Start listeners only available in GUI mode host.Services.GetRequiredService(); + + if (Environment.GetCommandLineArgs().Length != 0) + { + var updater = host.Services.GetRequiredService(); + await updater.Startup(); + } + Startup.Main(host.Services, args); return 0; } - public static IHost BuildHost() + public static IHost BuildHost(bool isGui = true) { // I'm not 100% sure how to wire this up to cleanly pass settings // to ConfigureLogging; since the DI container isn't built until the host is. @@ -80,6 +88,7 @@ public static IHost BuildHost() config = JsonSerializer.Deserialize(configJson)!; config.Sanitize(); services.AddSingleton(config); + services.AddUpdater(); services.AddApp(config).Validate(); }) .ConfigureLogging((_, builder) => AddLogging(builder, config.LoggingSettings)) diff --git a/src/NexusMods.App/Services.cs b/src/NexusMods.App/Services.cs index 4b53bd23b3..02e412f41c 100644 --- a/src/NexusMods.App/Services.cs +++ b/src/NexusMods.App/Services.cs @@ -33,7 +33,7 @@ public static IServiceCollection AddRenderers(this IServiceCollection services) services.AddScoped(); return services; } - + public static IServiceCollection AddListeners(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/NexusMods.Common/FakeProcessFactory.cs b/src/NexusMods.Common/FakeProcessFactory.cs index 548b3c43fd..f61c9d9dc8 100644 --- a/src/NexusMods.Common/FakeProcessFactory.cs +++ b/src/NexusMods.Common/FakeProcessFactory.cs @@ -1,10 +1,11 @@ using System.Buffers; +using System.Diagnostics; using System.Text; using CliWrap; namespace NexusMods.Common; -internal class FakeProcessFactory : IProcessFactory +public class FakeProcessFactory : IProcessFactory { private readonly int _exitCode; @@ -14,6 +15,8 @@ internal class FakeProcessFactory : IProcessFactory public string? StandardOutput { get; set; } public string? StandardError { get; set; } + public List Commands { get; } = new(); + public FakeProcessFactory(int exitCode) { _exitCode = exitCode; @@ -47,6 +50,25 @@ await WriteStringToPipe( DateTimeOffset.Now)); } + /// + public Process? ExecuteAndDetach(Command command) + { + throw new ExecuteAndDetatchException(command); + } + + /// + /// Thrown when is called. Used to test that the + /// correct command was passed to the factory. + /// + public class ExecuteAndDetatchException : Exception + { + public Command Command { get; } + public ExecuteAndDetatchException(Command command) : base("Executed command and detached from it") + { + Command = command; + } + } + private static async Task WriteStringToPipe(string text, PipeTarget pipe, CancellationToken cancellationToken = default) { var bytes = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(text.Length)); diff --git a/src/NexusMods.Common/IProcessFactory.cs b/src/NexusMods.Common/IProcessFactory.cs index f8186f9f4e..7f0e2d47f9 100644 --- a/src/NexusMods.Common/IProcessFactory.cs +++ b/src/NexusMods.Common/IProcessFactory.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CliWrap; namespace NexusMods.Common; @@ -13,4 +14,14 @@ public interface IProcessFactory /// The command to execute. /// Allows you to cancel the task, killing the process prematurely. Task ExecuteAsync(Command command, CancellationToken cancellationToken = default); + + /// + /// Executes the given command that starts the process, but remains detached from it in such a way + /// that the process will continue to run even if the parent process is killed. + /// + /// Returns null if the process could not be started. + /// + /// + /// + Process? ExecuteAndDetach(Command command); } diff --git a/src/NexusMods.Common/ProcessFactory.cs b/src/NexusMods.Common/ProcessFactory.cs index 1a656b7675..1e1d673520 100644 --- a/src/NexusMods.Common/ProcessFactory.cs +++ b/src/NexusMods.Common/ProcessFactory.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CliWrap; namespace NexusMods.Common; @@ -13,4 +14,16 @@ public async Task ExecuteAsync(Command command, { return await command.ExecuteAsync(cancellationToken); } + + /// + public Process? ExecuteAndDetach(Command command) + { + var info = new ProcessStartInfo(command.TargetFilePath) + { + Arguments = command.Arguments, + WorkingDirectory = command.WorkingDirPath, + + }; + return Process.Start(info); + } } diff --git a/src/NexusMods.Updater/Constants.cs b/src/NexusMods.Updater/Constants.cs new file mode 100644 index 0000000000..bd500a0750 --- /dev/null +++ b/src/NexusMods.Updater/Constants.cs @@ -0,0 +1,11 @@ +using NexusMods.Paths; + +namespace NexusMods.Updater; + +public static class Constants +{ + public static RelativePath UpdateMarkerFile = new("UPDATE_READY"); + public static RelativePath UpdateFolder = new("__update__"); + public static RelativePath UpdateExecutable = new("NexusMods.App.exe"); + +} diff --git a/src/NexusMods.Updater/DTOs/Asset.cs b/src/NexusMods.Updater/DTOs/Asset.cs new file mode 100644 index 0000000000..a4eb52b61a --- /dev/null +++ b/src/NexusMods.Updater/DTOs/Asset.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace NexusMods.Updater.DTOs; + +public class Asset +{ + [JsonPropertyName("browser_download_url")] + public Uri BrowserDownloadUrl { get; set; } = new("https://nexusmods.com/"); + + [JsonPropertyName("name")] public string Name { get; set; } = ""; + + + [JsonPropertyName("size")] + public long Size { get; set; } +} + diff --git a/src/NexusMods.Updater/DTOs/Release.cs b/src/NexusMods.Updater/DTOs/Release.cs new file mode 100644 index 0000000000..1f4c36b24c --- /dev/null +++ b/src/NexusMods.Updater/DTOs/Release.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace NexusMods.Updater.DTOs; + +public class Release +{ + [JsonPropertyName("tag_name")] public string Tag { get; set; } = ""; + [JsonPropertyName("assets")] public Asset[] Assets { get; set; } = Array.Empty(); +} diff --git a/src/NexusMods.Updater/DownloadSources/Github.cs b/src/NexusMods.Updater/DownloadSources/Github.cs new file mode 100644 index 0000000000..8e1de0d884 --- /dev/null +++ b/src/NexusMods.Updater/DownloadSources/Github.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NexusMods.Updater.DTOs; + +namespace NexusMods.Updater.DownloadSources; + +public class Github +{ + private readonly HttpClient _client; + + public Github(HttpClient client) + { + _client = client; + } + + public async Task GetLatestRelease(string owner, string repo) + { + var releases = await GetReleases(owner, repo); + return releases.FirstOrDefault(); + } + + private async Task GetReleases(string owner, string repo) + { + var url = $"https://github.com/repos/{owner}/{repo}/releases"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("User-Agent", "NexusMods.Updater"); + var response = await _client.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json)!; + } +} diff --git a/src/NexusMods.Updater/NexusMods.Updater.csproj b/src/NexusMods.Updater/NexusMods.Updater.csproj new file mode 100644 index 0000000000..b69dfbed59 --- /dev/null +++ b/src/NexusMods.Updater/NexusMods.Updater.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/src/NexusMods.Updater/Services.cs b/src/NexusMods.Updater/Services.cs new file mode 100644 index 0000000000..af492775dd --- /dev/null +++ b/src/NexusMods.Updater/Services.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.CLI; +using NexusMods.Updater.DownloadSources; +using NexusMods.Updater.Verbs; + +namespace NexusMods.Updater; + +public static class Services +{ + public static IServiceCollection AddUpdater(this IServiceCollection services) + { + return services.AddSingleton() + .AddVerb() + .AddVerb() + .AddSingleton(); + } + +} diff --git a/src/NexusMods.Updater/UpdaterService.cs b/src/NexusMods.Updater/UpdaterService.cs new file mode 100644 index 0000000000..bd035eff56 --- /dev/null +++ b/src/NexusMods.Updater/UpdaterService.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.IO.Compression; +using CliWrap; +using Microsoft.Extensions.Logging; +using NexusMods.Common; +using NexusMods.Networking.HttpDownloader; +using NexusMods.Paths; +using NexusMods.Updater.DownloadSources; +using NexusMods.Updater.DTOs; + +namespace NexusMods.Updater; + +public class UpdaterService +{ + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private Task? _runnerTask; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly CancellationToken _token; + private readonly Github _github; + private readonly TemporaryFileManager _temporaryFileManager; + private readonly IHttpDownloader _downloader; + public readonly AbsolutePath AppFolder; + public readonly AbsolutePath UpdateFolder; + private readonly IProcessFactory _processFactory; + + public UpdaterService(ILogger logger, IFileSystem fileSystem, Github github, + TemporaryFileManager temporaryFileManager, IHttpDownloader downloader, IProcessFactory processFactory) + { + _logger = logger; + _fileSystem = fileSystem; + AppFolder = fileSystem.GetKnownPath(KnownPath.EntryDirectory); + UpdateFolder = AppFolder.Combine(Constants.UpdateFolder); + _github = github; + _cancellationTokenSource = new CancellationTokenSource(); + _token = _cancellationTokenSource.Token; + _temporaryFileManager = temporaryFileManager; + _downloader = downloader; + _processFactory = processFactory; + } + + public bool IsOnlyInstance() + { + var thisName = Process.GetCurrentProcess().ProcessName; + return Process.GetProcesses().Count(p => p.ProcessName == thisName) > 1; + } + + public async Task IsUpdateReady() + { + var updateMarkerFile = _fileSystem.GetKnownPath(KnownPath.EntryDirectory) + .Combine(Constants.UpdateFolder) + .Combine(Constants.UpdateMarkerFile); + + if (updateMarkerFile.FileExists) + return true; + + if (UpdateFolder.DirectoryExists()) + { + _logger.LogDebug($"Old update folder exists, deleting..."); + UpdateFolder.DeleteDirectory(); + } + + return false; + } + + + public async Task Startup() + { +#if !DEBUG + if (await IsUpdateReady()) + { + await RunUpdate(); + } + _runnerTask = Task.Run(async () => await RunLoop(), _token); +#endif + } + + private async Task RunUpdate() + { + var updateProgram = UpdateFolder.Combine(Constants.UpdateExecutable); + + // We're letting CLIWrap escape all the variables for us, but we want to use + // Process.Start because we don't want to wait for the process to exit. + var cmd = new Command(updateProgram.ToString()) + .WithWorkingDirectory(UpdateFolder.ToString()) + .WithArguments(new[] + { + "copy-app-to-folder", + "-f", UpdateFolder.ToString(), + "-t", AppFolder.ToString(), + "-p", Environment.ProcessId.ToString(), + "-c", AppFolder.Combine(Constants.UpdateExecutable).ToString() + }); + + var process = _processFactory.ExecuteAndDetach(cmd); + if (process == null) + { + _logger.LogError("Failed to start update process"); + return; + } + Environment.Exit(0); + } + + private async Task RunLoop() + { + while (!_cancellationTokenSource.Token.IsCancellationRequested) + { + var currentVersionString = Process.GetCurrentProcess().MainModule?.FileVersionInfo?.ProductVersion; + if (!Version.TryParse(currentVersionString, out var version)) + version = new Version(0, 0, 0, 1); + + await DownloadAndExtractUpdate(version); + await Task.Delay(TimeSpan.FromHours(6), _token); + } + } + + /// + /// Downloads and prepares for installation the latest update of the app, assuming the latest + /// version is newer than the given version. + /// + /// + public async Task DownloadAndExtractUpdate(Version version) + { + var latestRelease = await _github.GetLatestRelease("Nexus-Mods", "NexusMods.App"); + if (latestRelease != null) + { + if (Version.TryParse(latestRelease.Tag.TrimStart('v'), out var latestVersion)) + { + if (latestVersion > version) + { + _logger.LogInformation("New version available: {LatestVersion}", latestVersion); + await using var file = await DownloadRelease(latestRelease); + if (file != null) + { + await ExtractRelease(file.Value.Path, latestVersion); + } + } + } + } + } + + private async Task ExtractRelease(AbsolutePath file, Version newVersion) + { + var destination = _fileSystem.GetKnownPath(KnownPath.EntryDirectory) + .Combine(Constants.UpdateFolder); + if (destination.DirectoryExists()) + { + _logger.LogDebug($"Old update folder exists, deleting..."); + destination.DeleteDirectory(); + } + + _logger.LogInformation("Extracting new version ({Version}) of the app", newVersion); + using var archive = new ZipArchive(file.Read(), ZipArchiveMode.Read, false); + UpdateFolder.CreateDirectory(); + + foreach (var entry in archive.Entries) + { + var destinationPath = destination.Combine(entry.FullName); + destinationPath.Parent.CreateDirectory(); + if (entry.FullName.EndsWith("/")) + continue; + + await using var stream = entry.Open(); + await using var destinationStream = destinationPath.Create(); + await stream.CopyToAsync(destinationStream, _token); + } + + await destination.Combine(Constants.UpdateMarkerFile) + .WriteAllTextAsync(newVersion.ToString(), _token); + _logger.LogInformation("Update marker file created for version {Version}", newVersion); + } + + private async Task DownloadRelease(Release latestRelease) + { + var os = ""; + if (OSInformation.Shared.IsWindows) + { + os = "win"; + } + else if (OSInformation.Shared.IsLinux) + { + os = "linux"; + } + var asset = latestRelease.Assets.FirstOrDefault(a => a.Name.StartsWith("NexusMods.App-") && a.Name.EndsWith($"{os}-x64.zip")); + if (asset == null) return null; + + _logger.LogInformation("Downloading new version of the app {AssetName}", asset.Name); + var destination = _temporaryFileManager.CreateFile(); + await _downloader.DownloadAsync(new[] {new HttpRequestMessage(HttpMethod.Get, asset.BrowserDownloadUrl)} , destination.Path, null, null, _token); + + return destination; + } +} diff --git a/src/NexusMods.Updater/Verbs/CopyAppToFolder.cs b/src/NexusMods.Updater/Verbs/CopyAppToFolder.cs new file mode 100644 index 0000000000..d15f6c5c1a --- /dev/null +++ b/src/NexusMods.Updater/Verbs/CopyAppToFolder.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using CliWrap; +using NexusMods.Abstractions.CLI; +using NexusMods.Common; +using NexusMods.Paths; + +namespace NexusMods.Updater.Verbs; + +public class CopyAppToFolder : AVerb, IRenderingVerb +{ + public CopyAppToFolder(IProcessFactory processFactory) + { + _processFactory = processFactory; + } + + public IRenderer Renderer { get; set; } = null!; + + public static VerbDefinition Definition => new("copy-app-to-folder", + "Copies the app to the specified folder, used by the auto-updater.", + new OptionDefinition[] + { + new OptionDefinition("-f", "--from", "The source path to copy the app from."), + new OptionDefinition("-t", "--to", "The destination path to copy the app to."), + new OptionDefinition("-c", "--on-complete", "The file to run when the copy is complete."), + new OptionDefinition("-p", "-process-id", "The folder id to copy the app to.") + }); + + private readonly IProcessFactory _processFactory; + + + public async Task Run(AbsolutePath from, AbsolutePath to, AbsolutePath onComplete, int processId, CancellationToken token) + { + while (Process.GetProcesses().FirstOrDefault(p => p.Id == processId) != null) + { + await Renderer.Render($"Waiting for process {processId} to exit..."); + await Task.Delay(500, token); + } + + await Renderer.Render($"Copying app from {from} to {to}..."); + + var markerFile = to.Combine(Constants.UpdateMarkerFile); + + foreach (var file in from.EnumerateFiles()) + { + if (file == markerFile) + continue; + + var relativePath = file.RelativeTo(from); + var toFile = to.Combine(relativePath); + toFile.Parent.CreateDirectory(); + await Renderer.Render($"Copying {relativePath}"); + await using var fromStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + await using var toStream = toFile.Open(FileMode.Create, FileAccess.Write, FileShare.None); + await fromStream.CopyToAsync(toStream, token); + } + + markerFile.Delete(); + + var command = new Command(onComplete.ToString()) + .WithWorkingDirectory(onComplete.Parent.ToString()); + + _ = _processFactory.ExecuteAndDetach(command); + return 0; + } +} diff --git a/src/NexusMods.Updater/Verbs/ForceAppUpdate.cs b/src/NexusMods.Updater/Verbs/ForceAppUpdate.cs new file mode 100644 index 0000000000..1217a1eb67 --- /dev/null +++ b/src/NexusMods.Updater/Verbs/ForceAppUpdate.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.CLI; + +namespace NexusMods.Updater.Verbs; + +public class ForceAppUpdate : AVerb +{ + private readonly UpdaterService _updater; + private readonly ILogger _logger; + + public ForceAppUpdate(ILogger logger, UpdaterService updater) + { + _logger = logger; + _updater = updater; + } + + public static VerbDefinition Definition => new("force-app-update", + "Forces a download of the latest version of the app", Array.Empty()); + + public async Task Run(CancellationToken token) + { + _logger.LogInformation("Forcing an update..."); + await _updater.DownloadAndExtractUpdate(new Version(0, 0, 0, 1)); + _logger.LogInformation("Update complete. The next UI startup will boot into the new version"); + return 0; + } + +} diff --git a/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs b/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs index b72b09fee5..1d01f02ac6 100644 --- a/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs +++ b/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs @@ -221,7 +221,7 @@ protected async Task AnalyzeArchive(AbsolutePath path) var analyzedFile = await ArchiveAnalyzer.AnalyzeFileAsync(path); if (analyzedFile is AnalyzedArchive analyzedArchive) return analyzedArchive; - throw new ArgumentException($"File at {path} is not an archive!", nameof(path)); + throw new ArgumentException($"File at {path} is not an archive! Instead is {analyzedFile}", nameof(path)); } /// diff --git a/tests/NexusMods.Updater.Tests/CopyAppToFolderTests.cs b/tests/NexusMods.Updater.Tests/CopyAppToFolderTests.cs new file mode 100644 index 0000000000..317bdc423f --- /dev/null +++ b/tests/NexusMods.Updater.Tests/CopyAppToFolderTests.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using FluentAssertions; +using NexusMods.Abstractions.CLI; +using NexusMods.Common; +using NexusMods.Paths; +using NexusMods.Updater.Verbs; + +namespace NexusMods.Updater.Tests; + +public class CopyAppToFolderTests +{ + private readonly CopyAppToFolder _verb; + private readonly TemporaryFileManager _temporaryFileManager; + private readonly UpdaterService _updaterService; + + public CopyAppToFolderTests(TemporaryFileManager temporaryFileManager, CopyAppToFolder copyAppToFolder, UpdaterService updaterService, IRenderer renderer) + { + _verb = copyAppToFolder; + _temporaryFileManager = temporaryFileManager; + _updaterService = updaterService; + copyAppToFolder.Renderer = renderer; + } + + [Fact] + public async Task FilesAreCopied() + { + await using var targetFolder = _temporaryFileManager.CreateFolder(); + + var onContinue = targetFolder.Path.Combine("NexusMods.App.exe"); + + // ReSharper disable once AccessToDisposedClosure + Func> act = async () => await _verb.Run(_updaterService.AppFolder, targetFolder, onContinue, -1, CancellationToken.None); + + await act.Should().ThrowAsync("Process was started") + .Where(e => e.Command.TargetFilePath == onContinue.ToString()); + + targetFolder.Path.Combine("NexusMods.App.exe").FileExists.Should().BeTrue(); + + } +} diff --git a/tests/NexusMods.Updater.Tests/GithubIntegration.cs b/tests/NexusMods.Updater.Tests/GithubIntegration.cs new file mode 100644 index 0000000000..fb63477ce6 --- /dev/null +++ b/tests/NexusMods.Updater.Tests/GithubIntegration.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using NexusMods.Updater.DownloadSources; +using NexusMods.Updater.Verbs; + +namespace NexusMods.Updater.Tests; + +public class GithubIntegration +{ + private readonly Github _github; + private readonly ForceAppUpdate _forceAppUpdate; + private readonly UpdaterService _updaterService; + + public GithubIntegration(Github github, ForceAppUpdate forceAppUpdate, UpdaterService updaterService) + { + _github = github; + _forceAppUpdate = forceAppUpdate; + _updaterService = updaterService; + } + + [Fact] + public async Task GetLatestRelease() + { + var latestRelease = await _github.GetLatestRelease("Nexus-Mods", "NexusMods.App"); + latestRelease.Should().NotBeNull(); + + var version = Version.Parse(latestRelease!.Tag.TrimStart('v')); + version.Should().BeGreaterThan(new Version(0, 0)); + } + + [Fact] + public async Task CanForceDownload() + { + if (_updaterService.UpdateFolder.DirectoryExists()) + _updaterService.UpdateFolder.DeleteDirectory(); + + await _forceAppUpdate.Run(CancellationToken.None); + _updaterService.UpdateFolder.DirectoryExists().Should().BeTrue(); + + _updaterService.UpdateFolder.Combine(Constants.UpdateExecutable).FileExists.Should().BeTrue(); + var updateFile = _updaterService.UpdateFolder.Combine(Constants.UpdateMarkerFile); + updateFile.FileExists.Should().BeTrue(); + Version.Parse(await updateFile.ReadAllTextAsync()).Should().BeGreaterThan(new Version(0, 0, 0, 1)); + + _updaterService.UpdateFolder.DeleteDirectory(); + } +} diff --git a/tests/NexusMods.Updater.Tests/NexusMods.Updater.Tests.csproj b/tests/NexusMods.Updater.Tests/NexusMods.Updater.Tests.csproj new file mode 100644 index 0000000000..34213f2ff8 --- /dev/null +++ b/tests/NexusMods.Updater.Tests/NexusMods.Updater.Tests.csproj @@ -0,0 +1,30 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/NexusMods.Updater.Tests/Startup.cs b/tests/NexusMods.Updater.Tests/Startup.cs new file mode 100644 index 0000000000..a6342c41ea --- /dev/null +++ b/tests/NexusMods.Updater.Tests/Startup.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.CLI; +using NexusMods.Common; +using NexusMods.Networking.HttpDownloader; +using NexusMods.Paths; +using Xunit.DependencyInjection; +using Xunit.DependencyInjection.Logging; +using IResource = NexusMods.DataModel.RateLimiting.IResource; + +namespace NexusMods.Updater.Tests; + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton() + .AddAllSingleton(x => new FakeProcessFactory(0)) + .AddSingleton(s => new App.CLI.Renderers.Spectre(Array.Empty())) + .AddFileSystem() + .AddUpdater() + .AddSingleton(new TemporaryFileManager(FileSystem.Shared)) + .AddAdvancedHttpDownloader(); + + } + + public void Configure(ILoggerFactory loggerFactory, ITestOutputHelperAccessor accessor) => + loggerFactory.AddProvider(new XunitTestOutputLoggerProvider(accessor, delegate { return true; })); +} + diff --git a/tests/NexusMods.Updater.Tests/Usings.cs b/tests/NexusMods.Updater.Tests/Usings.cs new file mode 100644 index 0000000000..c802f4480b --- /dev/null +++ b/tests/NexusMods.Updater.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit;