-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAppUpdateService.cs
More file actions
341 lines (298 loc) · 12.8 KB
/
Copy pathAppUpdateService.cs
File metadata and controls
341 lines (298 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
namespace ArIED61850Tester;
internal sealed class AppUpdateService : IDisposable
{
internal const string ManifestUrl = "https://masarray.github.io/arsas/latest.json";
internal const string InstallerFileName = "ARSAS-Windows-x64-Setup.exe";
private const string ExpectedInstallerUrl = "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe";
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(12);
private static readonly TimeSpan DeferInterval = TimeSpan.FromHours(24);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private readonly HttpClient _httpClient;
private readonly string _stateDirectory;
private readonly string _statePath;
public AppUpdateService()
{
CurrentVersion = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0);
_stateDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ARSAS",
"Updater");
_statePath = Path.Combine(_stateDirectory, "state.json");
_httpClient = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = System.Net.DecompressionMethods.All
})
{
Timeout = TimeSpan.FromSeconds(30)
};
_httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("ARSAS-Updater", CurrentVersion.ToString(3)));
_httpClient.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true
};
}
public Version CurrentVersion { get; }
public async Task<UpdateManifest?> CheckForUpdateAsync(CancellationToken cancellationToken)
{
var state = LoadState();
var now = DateTimeOffset.UtcNow;
if (state.LastSuccessfulCheckUtc is { } lastCheck && now - lastCheck < CheckInterval)
return null;
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{ManifestUrl}?current={Uri.EscapeDataString(CurrentVersion.ToString(3))}&t={now.ToUnixTimeSeconds()}");
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var manifest = await JsonSerializer.DeserializeAsync<UpdateManifest>(
stream,
JsonOptions,
cancellationToken).ConfigureAwait(false);
ValidateManifest(manifest);
state.LastSuccessfulCheckUtc = now;
SaveState(state);
var latestVersion = ParseVersion(manifest!.Version);
if (latestVersion <= CurrentVersion)
return null;
if (state.DeferredVersion.Equals(manifest.Version, StringComparison.OrdinalIgnoreCase) &&
state.DeferredUntilUtc is { } deferredUntil && deferredUntil > now)
{
return null;
}
return manifest;
}
public void Defer(UpdateManifest manifest)
{
var state = LoadState();
state.DeferredVersion = manifest.Version;
state.DeferredUntilUtc = DateTimeOffset.UtcNow.Add(DeferInterval);
SaveState(state);
}
public async Task<string> DownloadInstallerAsync(
UpdateManifest manifest,
IProgress<UpdateDownloadProgress>? progress,
CancellationToken cancellationToken)
{
ValidateManifest(manifest);
var version = ParseVersion(manifest.Version).ToString(3);
var directory = Path.Combine(_stateDirectory, "Packages", version);
Directory.CreateDirectory(directory);
var destination = Path.Combine(directory, InstallerFileName);
if (File.Exists(destination) && await VerifyInstallerAsync(destination, manifest.Installer, cancellationToken).ConfigureAwait(false))
{
progress?.Report(new UpdateDownloadProgress(manifest.Installer.SizeBytes, manifest.Installer.SizeBytes));
return destination;
}
var partial = destination + ".part";
TryDelete(partial);
try
{
using var response = await _httpClient.GetAsync(
manifest.Installer.Url,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var target = new FileStream(
partial,
FileMode.Create,
FileAccess.Write,
FileShare.None,
128 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var buffer = new byte[128 * 1024];
long total = 0;
var lastReport = Stopwatch.StartNew();
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
break;
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
total += read;
if (total > manifest.Installer.SizeBytes)
throw new InvalidDataException("Downloaded installer is larger than the verified release manifest.");
if (lastReport.ElapsedMilliseconds >= 150)
{
progress?.Report(new UpdateDownloadProgress(total, manifest.Installer.SizeBytes));
lastReport.Restart();
}
}
await target.FlushAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(new UpdateDownloadProgress(total, manifest.Installer.SizeBytes));
if (!await VerifyInstallerAsync(partial, manifest.Installer, cancellationToken).ConfigureAwait(false))
throw new InvalidDataException("Installer verification failed. The file was not opened.");
File.Move(partial, destination, overwrite: true);
return destination;
}
catch
{
TryDelete(partial);
throw;
}
}
public void LaunchInstaller(string installerPath)
{
if (!File.Exists(installerPath))
throw new FileNotFoundException("The verified installer could not be found.", installerPath);
var process = Process.Start(new ProcessStartInfo
{
FileName = installerPath,
Arguments = "/SILENT /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS /NORESTART",
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? _stateDirectory,
UseShellExecute = true
});
if (process is null)
throw new InvalidOperationException("Windows did not start the ARSAS installer.");
}
public void OpenInstallerFolder(string? installerPath)
{
var directory = installerPath is not null
? Path.GetDirectoryName(installerPath)
: Path.Combine(_stateDirectory, "Packages");
if (string.IsNullOrWhiteSpace(directory))
return;
Directory.CreateDirectory(directory);
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = installerPath is not null && File.Exists(installerPath)
? $"/select,\"{installerPath}\""
: $"\"{directory}\"",
UseShellExecute = true
});
}
private static void ValidateManifest(UpdateManifest? manifest)
{
if (manifest is null)
throw new InvalidDataException("The ARSAS update manifest is empty.");
if (!manifest.Product.Equals("ARSAS", StringComparison.Ordinal))
throw new InvalidDataException("The update manifest is for a different product.");
if (!manifest.Channel.Equals("stable", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException("Only the stable ARSAS update channel is accepted.");
_ = ParseVersion(manifest.Version);
if (!manifest.Installer.Name.Equals(InstallerFileName, StringComparison.Ordinal))
throw new InvalidDataException("The update manifest does not name the official ARSAS installer.");
if (!Uri.TryCreate(manifest.Installer.Url, UriKind.Absolute, out var installerUri) ||
installerUri.Scheme != Uri.UriSchemeHttps ||
!manifest.Installer.Url.Equals(ExpectedInstallerUrl, StringComparison.Ordinal))
{
throw new InvalidDataException("The update manifest points to an untrusted installer location.");
}
if (manifest.Installer.SizeBytes is < 1_000_000 or > 500_000_000)
throw new InvalidDataException("The installer size in the update manifest is invalid.");
if (manifest.Installer.Sha256.Length != 64 ||
manifest.Installer.Sha256.Any(character => !Uri.IsHexDigit(character)))
{
throw new InvalidDataException("The update manifest has an invalid SHA-256 value.");
}
}
private static Version ParseVersion(string value)
{
var normalized = value.Trim().TrimStart('v', 'V');
if (!Version.TryParse(normalized, out var version))
throw new InvalidDataException($"Invalid ARSAS update version '{value}'.");
return version;
}
private static async Task<bool> VerifyInstallerAsync(
string path,
UpdateInstallerManifest installer,
CancellationToken cancellationToken)
{
var file = new FileInfo(path);
if (!file.Exists || file.Length != installer.SizeBytes)
return false;
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
128 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var actual = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var expected = Convert.FromHexString(installer.Sha256);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
private UpdateState LoadState()
{
try
{
if (!File.Exists(_statePath))
return new UpdateState();
return JsonSerializer.Deserialize<UpdateState>(File.ReadAllText(_statePath), JsonOptions) ?? new UpdateState();
}
catch (Exception exception)
{
Debug.WriteLine($"Unable to read ARSAS updater state: {exception}");
return new UpdateState();
}
}
private void SaveState(UpdateState state)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var temporary = _statePath + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(state, JsonOptions));
File.Move(temporary, _statePath, overwrite: true);
}
catch (Exception exception)
{
Debug.WriteLine($"Unable to save ARSAS updater state: {exception}");
}
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// A stale partial file is harmless and will be overwritten on the next attempt.
}
}
public void Dispose() => _httpClient.Dispose();
private sealed class UpdateState
{
public DateTimeOffset? LastSuccessfulCheckUtc { get; set; }
public string DeferredVersion { get; set; } = string.Empty;
public DateTimeOffset? DeferredUntilUtc { get; set; }
}
}
internal sealed class UpdateManifest
{
public int SchemaVersion { get; set; }
public string Product { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Channel { get; set; } = string.Empty;
public DateTimeOffset? PublishedAtUtc { get; set; }
public UpdateInstallerManifest Installer { get; set; } = new();
}
internal sealed class UpdateInstallerManifest
{
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Sha256 { get; set; } = string.Empty;
public long SizeBytes { get; set; }
}
internal readonly record struct UpdateDownloadProgress(long BytesReceived, long TotalBytes)
{
public double Percentage => TotalBytes <= 0 ? 0 : Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100);
}