Repository navigation
Expand file tree
/
Copy pathAppShell.Update.cs
More file actions
261 lines (225 loc) · 9.12 KB
/
Copy pathAppShell.Update.cs
File metadata and controls
261 lines (225 loc) · 9.12 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
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Snipyard.Services;
namespace Snipyard;
/// <summary>
/// The update notice: the corner of the shell that says a newer release exists and installs it.
///
/// The order of the steps is the whole design. The executable is fetched and verified while the
/// application is still running, so cancelling or a broken connection costs nothing; only once
/// there is a whole new executable on disk does anything close. What the user sees is the flow
/// they asked for - press the button, the application closes, the new version comes up - with no
/// window in which a failed download could leave them with nothing to start.
/// </summary>
internal partial class AppShell
{
private UpdateInfo? _update;
private CancellationTokenSource? _updateDownload;
/// <summary>
/// The percentage last painted. 80MB arrives in 64KB reads, and repainting the bar on every
/// one of them is 1,300 layout passes to show the same hundred positions.
/// </summary>
private int _updatePercent = -1;
/// <summary>
/// Asks GitHub whether there is a newer release and raises the notice if there is. Fired
/// rather than awaited: an unreachable API must not hold up the window coming up, and a
/// check that finds nothing says nothing.
/// </summary>
private void StartUpdateCheck()
{
// Whatever the last update left behind, cleared on the run after it - the old executable
// could not be deleted while it was the one running.
UpdateService.CleanupOldExe();
if (!_settings.CheckUpdateOnStartup) return;
if (!UpdateService.CanCheck) return;
_ = Task.Run(async () =>
{
var info = await UpdateService.CheckAsync().ConfigureAwait(false);
if (info is null) return;
Dispatcher.UIThread.Post(() => ShowUpdateBanner(info));
});
}
private void ShowUpdateBanner(UpdateInfo info)
{
_update = info;
UpdateTitleText.Text = Loc.Get("UpdateTitle");
UpdateVersionText.Text = string.Format(
Loc.Get("UpdateVersionFmt"), UpdateService.CurrentVersion, info.Version);
RenderUpdateNotes();
UpdateError.IsVisible = false;
ResetUpdateBanner();
UpdateBanner.IsVisible = true;
}
/// <summary>
/// Paints the release notes. GitHub writes them in Markdown, and the notice used to show
/// that source verbatim - headings as hashes, emphasis as asterisks - so they are rendered
/// here instead. The parser resolves its colours once, when the blocks are built, which is
/// why a theme switch has to come back through this.
/// </summary>
private void RenderUpdateNotes()
{
UpdateNotes.Children.Clear();
var notes = _update?.ReleaseNotes ?? "";
UpdateNotesScroll.IsVisible = notes.Length > 0;
if (notes.Length == 0) return;
foreach (var block in MarkdownParser.Parse(notes, _isDark, baseFontSize: 11))
UpdateNotes.Children.Add(block);
}
/// <summary>Downloads the new executable, then hands over to the swap.</summary>
private async void OnUpdateApply(object? sender, RoutedEventArgs e)
{
if (_update is not { } info || _updateDownload != null) return;
// The sessions are the work in progress. Ending them is the decision of the person who
// started them, so it is asked rather than assumed.
var sessions = Shells.Sum(s => s._children.Count);
if (sessions > 0)
{
var proceed = await ShowConfirmDialog(
Loc.Get("UpdateTitle"),
string.Format(Loc.Get("UpdateSessionsRunningFmt"), sessions));
if (!proceed) return;
}
// Asked before the download rather than after it: an install under Program Files should
// say so in a second, not 80MB later.
if (!UpdateService.CanWriteToInstallDir())
{
ShowUpdateFailure(Loc.Get("UpdateNoPermission"));
OpenReleasePage();
return;
}
var cts = new CancellationTokenSource();
_updateDownload = cts;
_updatePercent = -1;
BtnUpdateApply.IsEnabled = false;
UpdateError.IsVisible = false;
UpdateProgress.Value = 0;
UpdateProgress.IsVisible = true;
UpdateProgressText.Text = "";
UpdateProgressText.IsVisible = true;
LblUpdateCancel.Text = Loc.Get("UpdateAbort");
string staged;
try
{
// Constructed on the UI thread, so its callbacks arrive there too.
var progress = new Progress<(long Read, long Total)>(ReportUpdateProgress);
staged = await UpdateService.DownloadAsync(info, progress, cts.Token);
}
catch (OperationCanceledException)
{
ResetUpdateBanner();
return;
}
catch (Exception ex)
{
Debug.WriteLine($"[AppShell] Update download failed: {ex.GetType().Name}");
ResetUpdateBanner();
ShowUpdateFailure(Loc.Get("UpdateFailed"));
return;
}
finally
{
_updateDownload = null;
cts.Dispose();
}
// Only the published single-file build replaces itself. A development copy has come this
// far to prove the notice and the download work, and stops before touching the folder it
// was built into.
if (!UpdateService.CanSelfUpdate)
{
UpdateService.CleanupOldExe();
ResetUpdateBanner();
ShowUpdateFailure(Loc.Get("UpdateNoPermission"));
OpenReleasePage();
return;
}
await ApplyUpdateAndRestartAsync(staged);
}
/// <summary>
/// Winds the application down, replaces the executable, and starts the new one.
///
/// The shutdown mode is pinned first, and that is not incidental: the default ends the
/// process the moment the last window closes, which is several lines before the swap.
/// </summary>
private async Task ApplyUpdateAndRestartAsync(string staged)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return;
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
// Sends /exit to every terminal and waits for them, saves the settings, closes the windows.
await ShutdownApplicationAsync();
try
{
UpdateService.SwapAndRestart(staged);
}
catch (Exception ex)
{
Debug.WriteLine($"[AppShell] Update swap failed: {ex.GetType().Name}");
// The old executable has been put back, but the windows are gone and there is nothing
// left to show a message in. Leave the user at the release page instead.
OpenReleasePage();
}
desktop.Shutdown();
}
private void ReportUpdateProgress((long Read, long Total) p)
{
if (p.Total <= 0) return;
var percent = (int)(p.Read * 100 / p.Total);
if (percent == _updatePercent) return;
_updatePercent = percent;
UpdateProgress.Value = percent;
UpdateProgressText.Text = string.Format(
Loc.Get("UpdateDownloadingFmt"),
UpdateService.FormatSize(p.Read),
UpdateService.FormatSize(p.Total));
}
/// <summary>
/// Dismisses the notice, and stops the download if one is under way. Nothing is remembered:
/// the next start asks again, which is what makes this the light half of the choice.
/// </summary>
private void OnUpdateCancel(object? sender, RoutedEventArgs e)
{
_updateDownload?.Cancel();
UpdateBanner.IsVisible = false;
}
private void OnUpdateNotesLink(object? sender, RoutedEventArgs e) => OpenReleasePage();
private void OpenReleasePage()
{
var url = _update?.HtmlUrl ?? UpdateService.ReleasesUrl;
try
{
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
}
catch (Exception ex)
{
Debug.WriteLine($"[AppShell] Could not open the release page: {ex.GetType().Name}");
}
}
/// <summary>Back to the state the notice opens in: offer the update, no progress, no error.</summary>
private void ResetUpdateBanner()
{
UpdateProgress.IsVisible = false;
UpdateProgressText.IsVisible = false;
BtnUpdateApply.IsEnabled = true;
LblUpdateCancel.Text = Loc.Get("Cancel");
}
private void ShowUpdateFailure(string message)
{
UpdateError.Text = message;
UpdateError.IsVisible = true;
UpdateBanner.IsVisible = true;
}
private void OnCheckUpdateSettingChanged(object? sender, RoutedEventArgs e)
{
if (!_settingsInitialized || _suppressSettingsChanged) return;
_settings.CheckUpdateOnStartup = ChkCheckUpdate.IsChecked == true;
_settings.Save();
}
}