Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App Updater #546

Closed
wants to merge 16 commits into from
Closed
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
14 changes: 14 additions & 0 deletions NexusMods.App.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
61 changes: 61 additions & 0 deletions docs/design-explanations/0003-updater-design.md
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions src/NexusMods.Abstractions/CLI/AVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,26 @@ public interface AVerb<in T1, in T2, in T3> : IVerb
public Task<int> Run(T1 a, T2 b, T3 c, CancellationToken token);
}

/// <summary>
/// Abstract class for a verb that takes four arguments
/// </summary>
/// <typeparam name="T1"></typeparam>
/// <typeparam name="T2"></typeparam>
/// <typeparam name="T3"></typeparam>
/// <typeparam name="T4"></typeparam>
public interface AVerb<in T1, in T2, in T3, in T4> : IVerb
{
Delegate IVerb.Delegate => Run;

/// <summary>
/// Runs the verb
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <param name="c"></param>
/// <param name="d"></param>
/// <param name="token"></param>
/// <returns></returns>
public Task<int> Run(T1 a, T2 b, T3 c, T4 d, CancellationToken token);
}

3 changes: 3 additions & 0 deletions src/NexusMods.App.UI/Windows/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using Avalonia;
Expand All @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion src/NexusMods.App/CLI/Renderers/Spectre.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using NexusMods.Abstractions.CLI;
using NexusMods.DataModel.RateLimiting;
using Spectre.Console;
Expand All @@ -20,7 +21,9 @@ public Spectre(IEnumerable<IResource> 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<T> WithProgress<T>(CancellationToken token, Func<Task<T>> f, bool showSize = true)
Expand Down Expand Up @@ -129,6 +132,9 @@ public async Task Render<T>(T o)
case Abstractions.CLI.DataOutputs.Table t:
await RenderTable(t);
break;
case string s:
AnsiConsole.MarkupLine(s);
break;
default:
throw new NotImplementedException();
}
Expand Down
1 change: 1 addition & 0 deletions src/NexusMods.App/NexusMods.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<ProjectReference Include="..\Networking\NexusMods.Networking.NexusWebApi\NexusMods.Networking.NexusWebApi.csproj" />
<ProjectReference Include="..\NexusMods.App.UI\NexusMods.App.UI.csproj" />
<ProjectReference Include="..\NexusMods.CLI\NexusMods.CLI.csproj" />
<ProjectReference Include="..\NexusMods.Updater\NexusMods.Updater.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
13 changes: 11 additions & 2 deletions src/NexusMods.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,7 +26,7 @@ public class Program
[STAThread]
public static async Task<int> Main(string[] args)
{
var host = BuildHost();
var host = BuildHost(args.Length == 0);

_logger = host.Services.GetRequiredService<ILogger<Program>>();
TaskScheduler.UnobservedTaskException += (_, e) =>
Expand Down Expand Up @@ -59,11 +60,18 @@ public static async Task<int> Main(string[] args)

// Start listeners only available in GUI mode
host.Services.GetRequiredService<NxmRpcListener>();

if (Environment.GetCommandLineArgs().Length != 0)
{
var updater = host.Services.GetRequiredService<UpdaterService>();
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.
Expand All @@ -80,6 +88,7 @@ public static IHost BuildHost()
config = JsonSerializer.Deserialize<AppConfig>(configJson)!;
config.Sanitize();
services.AddSingleton(config);
services.AddUpdater();
services.AddApp(config).Validate();
})
.ConfigureLogging((_, builder) => AddLogging(builder, config.LoggingSettings))
Expand Down
2 changes: 1 addition & 1 deletion src/NexusMods.App/Services.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public static IServiceCollection AddRenderers(this IServiceCollection services)
services.AddScoped<IRenderer, Json>();
return services;
}

public static IServiceCollection AddListeners(this IServiceCollection services)
{
services.AddSingleton<NxmRpcListener>();
Expand Down
24 changes: 23 additions & 1 deletion src/NexusMods.Common/FakeProcessFactory.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
using System.Buffers;
using System.Diagnostics;
using System.Text;
using CliWrap;

namespace NexusMods.Common;

internal class FakeProcessFactory : IProcessFactory
public class FakeProcessFactory : IProcessFactory

Check warning on line 8 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory'
{
private readonly int _exitCode;

public Action<Command>? Callback { get; set; }

Check warning on line 12 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.Callback'
public Func<Command, Task>? AsyncCallback { get; set; }

Check warning on line 13 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.AsyncCallback'

public string? StandardOutput { get; set; }

Check warning on line 15 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.StandardOutput'
public string? StandardError { get; set; }

Check warning on line 16 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.StandardError'

public List<Command> Commands { get; } = new();

Check warning on line 18 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.Commands'

public FakeProcessFactory(int exitCode)

Check warning on line 20 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.FakeProcessFactory(int)'
{
_exitCode = exitCode;
}

public async Task<CommandResult> ExecuteAsync(Command command,

Check warning on line 25 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.ExecuteAsync(Command, CancellationToken)'
CancellationToken cancellationToken = default)
{
Callback?.Invoke(command);
Expand Down Expand Up @@ -47,6 +50,25 @@
DateTimeOffset.Now));
}

/// <inheritdoc />
public Process? ExecuteAndDetach(Command command)
{
throw new ExecuteAndDetatchException(command);
}

/// <summary>
/// Thrown when <see cref="IProcessFactory.ExecuteAndDetach"/> is called. Used to test that the
/// correct command was passed to the factory.
/// </summary>
public class ExecuteAndDetatchException : Exception
{
public Command Command { get; }

Check warning on line 65 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.ExecuteAndDetatchException.Command'
public ExecuteAndDetatchException(Command command) : base("Executed command and detached from it")

Check warning on line 66 in src/NexusMods.Common/FakeProcessFactory.cs

View workflow job for this annotation

GitHub Actions / build

Missing XML comment for publicly visible type or member 'FakeProcessFactory.ExecuteAndDetatchException.ExecuteAndDetatchException(Command)'
{
Command = command;
}
}

private static async Task WriteStringToPipe(string text, PipeTarget pipe, CancellationToken cancellationToken = default)
{
var bytes = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(text.Length));
Expand Down
11 changes: 11 additions & 0 deletions src/NexusMods.Common/IProcessFactory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using CliWrap;

namespace NexusMods.Common;
Expand All @@ -13,4 +14,14 @@ public interface IProcessFactory
/// <param name="command">The command to execute.</param>
/// <param name="cancellationToken">Allows you to cancel the task, killing the process prematurely.</param>
Task<CommandResult> ExecuteAsync(Command command, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
/// <param name="command"></param>
/// <returns></returns>
Process? ExecuteAndDetach(Command command);
}
13 changes: 13 additions & 0 deletions src/NexusMods.Common/ProcessFactory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using CliWrap;

namespace NexusMods.Common;
Expand All @@ -13,4 +14,16 @@ public async Task<CommandResult> ExecuteAsync(Command command,
{
return await command.ExecuteAsync(cancellationToken);
}

/// <inheritdoc />
public Process? ExecuteAndDetach(Command command)
{
var info = new ProcessStartInfo(command.TargetFilePath)
{
Arguments = command.Arguments,
WorkingDirectory = command.WorkingDirPath,

};
return Process.Start(info);
}
}
11 changes: 11 additions & 0 deletions src/NexusMods.Updater/Constants.cs
Original file line number Diff line number Diff line change
@@ -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");

}
16 changes: 16 additions & 0 deletions src/NexusMods.Updater/DTOs/Asset.cs
Original file line number Diff line number Diff line change
@@ -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; }
}

9 changes: 9 additions & 0 deletions src/NexusMods.Updater/DTOs/Release.cs
Original file line number Diff line number Diff line change
@@ -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<Asset>();
}
31 changes: 31 additions & 0 deletions src/NexusMods.Updater/DownloadSources/Github.cs
Original file line number Diff line number Diff line change
@@ -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<Release?> GetLatestRelease(string owner, string repo)
{
var releases = await GetReleases(owner, repo);
return releases.FirstOrDefault();
}

private async Task<Release[]> 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<Release[]>(json)!;
}
}
Loading
Loading