Public Access
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.
The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.
◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.
Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 1.1
is told 1.1 by every later check, because 1.1 is still the newest thing on the
feed, so "the check found something" and "there is something new" are different
questions and only the second one is worth a download.
A build already fetched is never discarded, including when a check answers null.
Null means the feed's newest is no newer than the build that is *running*, which
says nothing about the one on disk — that one is still newer, and withdrawing an
offer somebody has already been shown would be the worse answer.
◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
CANNOT REACH.
The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.
It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.
Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.
Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.
IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.
◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.
Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.
So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.
Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.
UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
699 lines
31 KiB
C#
699 lines
31 KiB
C#
using System.Globalization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Session;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Where an update has got to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// An enum rather than a handful of booleans, for the reason <c>ShellSurface</c> gives: there is then no
|
|
/// way to write the state where two of these are true at once.
|
|
/// <para>
|
|
/// <b>There is deliberately no <c>Available</c> member.</b> The policy is to check and fetch in one motion,
|
|
/// so "found but not yet fetched" is a state nobody is ever looking at — and a state with nothing that can
|
|
/// be in it would describe a different product, one that asks permission before using the network. If a
|
|
/// reason to pause between the two ever arrives — a metered connection is the obvious one — that is when
|
|
/// the member earns its place, and the shape of this enum is the record of it not having arrived yet.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal enum UpdateState
|
|
{
|
|
/// <summary>This copy cannot replace itself, so none of the rest can happen.</summary>
|
|
Unsupported = 0,
|
|
|
|
/// <summary>Nothing in progress.</summary>
|
|
Idle = 1,
|
|
|
|
/// <summary>Asking the release channel.</summary>
|
|
Checking = 2,
|
|
|
|
/// <summary>Fetching a newer build.</summary>
|
|
Downloading = 3,
|
|
|
|
/// <summary>Fetched, and waiting for somebody to say when.</summary>
|
|
Ready = 4,
|
|
|
|
/// <summary>Something the user asked for did not work.</summary>
|
|
Failed = 5,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Looks for newer builds, fetches them, and waits to be told when.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Nothing here ever installs anything on its own.</b> A fetched update sits until the user presses
|
|
/// restart, or until the application is next started for their own reasons. That is the whole policy, and
|
|
/// it is a policy rather than an implementation detail: this application deliberately keeps shells running
|
|
/// across a lock — see <c>MainWindowViewModel.LockAsync</c>, which argues that a lock destroying work would
|
|
/// simply stop being used — and a restart does not keep them. Something that took the decision away would
|
|
/// be ending a person's session to save them a click.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It lives as long as the process, not as long as a vault.</b> Unlike the screens built per unlock, and
|
|
/// unlike <c>VaultViewModel</c>'s own sync loop, where builds come from has nothing to do with whether a
|
|
/// keychain is open — so this is constructed once and disposed at shutdown, and its loop keeps running
|
|
/// while the vault is locked. A laptop left locked for a week should still come back current.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It does not speak through <c>Announce</c> or the vault's status line.</b> Those carry saves, syncs,
|
|
/// refusals and conflicts, and an update has nothing to do with any of them; the banner appearing is the
|
|
/// announcement. Hence a <see cref="Status"/> of its own, which the preferences screen reads.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposable
|
|
{
|
|
/// <summary>How often to look, once the first pass has happened.</summary>
|
|
/// <remarks>
|
|
/// Six hours. This is a request against the project's own forge for a product that ships rarely, so
|
|
/// hourly would be traffic without information; a day would mean a machine that is only ever awake in
|
|
/// the morning could sit a week behind.
|
|
/// </remarks>
|
|
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
|
|
|
/// <summary>How long the look taken on the way to a restart may take before it is given up on.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Ten seconds. It bounds one HTTPS request for a small JSON document, which ordinarily answers in well
|
|
/// under one — and the case it exists for is not a slow answer but no answer at all: a captive portal or
|
|
/// a firewall that drops packets rather than refusing them, where the underlying handler's own timeout is
|
|
/// a hundred seconds. A refused connection fails in milliseconds and never reaches this.
|
|
/// </para>
|
|
/// <para>
|
|
/// Bounded because somebody is waiting on it with a finger on RESTART NOW, which is the whole difference
|
|
/// from the timer's own pass. Giving up is not a failure here: the build already on disk is still newer
|
|
/// than the one running, so the restart goes ahead with it.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
|
|
|
|
private readonly IUpdateChannel updates;
|
|
private readonly ClientSettingsStore settings;
|
|
private readonly TimeProvider clock;
|
|
|
|
/// <remarks>
|
|
/// A function rather than the workspace itself, so this view model needs no terminal to exist and a
|
|
/// test can say "three shells are open" without opening any.
|
|
/// </remarks>
|
|
private readonly Func<int> liveSessionCount;
|
|
|
|
/// <remarks>
|
|
/// What to do when the user presses restart. See <see cref="RestartNowAsync"/> for why this is not
|
|
/// simply a call into the channel.
|
|
/// </remarks>
|
|
private readonly Func<AvailableUpdate, Task> restart;
|
|
|
|
private readonly CancellationTokenSource lifetime = new();
|
|
|
|
private Task? loop;
|
|
private AvailableUpdate? ready;
|
|
private bool disposed;
|
|
|
|
internal UpdateViewModel(
|
|
IUpdateChannel updates,
|
|
ClientSettingsStore settings,
|
|
TimeProvider clock,
|
|
Func<int> liveSessionCount,
|
|
Func<AvailableUpdate, Task> restart)
|
|
{
|
|
this.updates = updates;
|
|
this.settings = settings;
|
|
this.clock = clock;
|
|
this.liveSessionCount = liveSessionCount;
|
|
this.restart = restart;
|
|
|
|
CurrentVersion = updates.CurrentVersion;
|
|
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
|
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
|
}
|
|
|
|
/// <summary>What this build calls itself.</summary>
|
|
internal string CurrentVersion { get; }
|
|
|
|
[ObservableProperty]
|
|
private UpdateState state;
|
|
|
|
[ObservableProperty]
|
|
private string? readyVersion;
|
|
|
|
[ObservableProperty]
|
|
private int downloadPercent;
|
|
|
|
[ObservableProperty]
|
|
private string status = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool isAutomatic;
|
|
|
|
/// <remarks>
|
|
/// Per run, and deliberately not persisted. LATER means "not now" and must not quietly come to mean
|
|
/// "never": the preferences row goes on offering the restart, and the build that was fetched is applied
|
|
/// at the next ordinary launch whatever this says.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isBannerDismissed;
|
|
|
|
[ObservableProperty]
|
|
private DateTimeOffset? lastChecked;
|
|
|
|
/// <summary>
|
|
/// Whether a restart is under way, including the look it takes before replacing anything.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// It exists to keep the banner up. Everything the banner shows keys off <see cref="IsReady"/>, and the
|
|
/// last look moves the state to Checking and possibly Downloading — so without this, pressing RESTART NOW
|
|
/// makes the strip vanish and leaves nothing on screen saying why, for however long a package takes. See
|
|
/// <see cref="ReadyHeadline"/>, which is what it says instead.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isRestarting;
|
|
|
|
internal bool IsSupported => State is not UpdateState.Unsupported;
|
|
|
|
internal bool IsUnsupported => State is UpdateState.Unsupported;
|
|
|
|
internal bool IsChecking => State is UpdateState.Checking;
|
|
|
|
internal bool IsDownloading => State is UpdateState.Downloading;
|
|
|
|
internal bool IsReady => State is UpdateState.Ready;
|
|
|
|
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
|
|
|
|
internal bool IsBannerShowing => (IsReady || IsRestarting) && !IsBannerDismissed;
|
|
|
|
/// <summary>What the banner says.</summary>
|
|
/// <remarks>
|
|
/// Three sentences rather than one, because a restart no longer necessarily starts with a build in hand:
|
|
/// it takes one last look first, and may find something newer than what was fetched six hours ago. That
|
|
/// look is short and the fetch behind it is not, so both say what they are doing — a strip that read
|
|
/// "ready to install" while it downloaded would be describing the wrong build, and a blank one would be
|
|
/// describing nothing.
|
|
/// </remarks>
|
|
internal string ReadyHeadline
|
|
{
|
|
get
|
|
{
|
|
if (IsRestarting && State is UpdateState.Checking)
|
|
{
|
|
return "Checking for a newer build before restarting…";
|
|
}
|
|
|
|
if (IsRestarting && State is UpdateState.Downloading)
|
|
{
|
|
return string.Create(
|
|
CultureInfo.CurrentCulture,
|
|
$"A newer build appeared. Fetching it before restarting — {DownloadPercent}%");
|
|
}
|
|
|
|
return ReadyVersion is { Length: > 0 } version
|
|
? $"DodoSSH {version} is ready to install."
|
|
: "An update is ready to install.";
|
|
}
|
|
}
|
|
|
|
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
|
|
/// <remarks>
|
|
/// The contrast is the point. This application tells people in several places that locking keeps their
|
|
/// shells running — it is the reason locking is safe to use mid-job — so the one moment that stops being
|
|
/// true is a moment it owes them a sentence. The close button's tooltip already says the same thing in
|
|
/// the same words.
|
|
/// </remarks>
|
|
internal string RestartWarning
|
|
{
|
|
get
|
|
{
|
|
var open = liveSessionCount();
|
|
|
|
return open switch
|
|
{
|
|
0 => "Nothing is connected, so this closes and reopens straight away.",
|
|
1 => "Restarting closes the shell you have open. A lock keeps shells running; a restart does not.",
|
|
_ => string.Create(
|
|
CultureInfo.CurrentCulture,
|
|
$"Restarting closes the {open} shells you have open. A lock keeps shells running; a restart does not."),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>When this run last asked, for as long as this run lasts.</summary>
|
|
internal string LastCheckedSummary => LastChecked is { } at
|
|
? $"Last checked {at.ToLocalTime().ToString("f", CultureInfo.CurrentCulture)}."
|
|
: "Not checked yet.";
|
|
|
|
partial void OnStateChanged(UpdateState value)
|
|
{
|
|
OnPropertyChanged(nameof(IsSupported));
|
|
OnPropertyChanged(nameof(IsUnsupported));
|
|
OnPropertyChanged(nameof(IsChecking));
|
|
OnPropertyChanged(nameof(IsDownloading));
|
|
OnPropertyChanged(nameof(IsReady));
|
|
OnPropertyChanged(nameof(CanCheckNow));
|
|
OnPropertyChanged(nameof(IsBannerShowing));
|
|
OnPropertyChanged(nameof(RestartWarning));
|
|
OnPropertyChanged(nameof(ReadyHeadline));
|
|
}
|
|
|
|
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
|
|
|
|
partial void OnIsRestartingChanged(bool value)
|
|
{
|
|
OnPropertyChanged(nameof(IsBannerShowing));
|
|
OnPropertyChanged(nameof(ReadyHeadline));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The headline carries the percentage while a restart is fetching, so it has to be re-read as the bar
|
|
/// moves. Raised unconditionally rather than only while restarting, because a condition here would be a
|
|
/// second place that has to agree with the headline's own about when the percentage is in it.
|
|
/// </remarks>
|
|
partial void OnDownloadPercentChanged(int value) => OnPropertyChanged(nameof(ReadyHeadline));
|
|
|
|
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
|
|
|
|
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
|
|
|
|
/// <remarks>
|
|
/// Read-modify-write against the file rather than against a field, so a setting this build does not
|
|
/// know about — written by a newer one, or by hand — survives this one storing its own.
|
|
/// </remarks>
|
|
partial void OnIsAutomaticChanged(bool value) =>
|
|
settings.Write(settings.Read() with { AutomaticUpdateChecks = value });
|
|
|
|
/// <summary>Starts looking, on a timer.</summary>
|
|
/// <remarks>
|
|
/// Called from the shell's own start rather than from the constructor, so that constructing this object
|
|
/// starts nothing — which is what lets a test drive <see cref="CheckOnceAsync"/> a pass at a time
|
|
/// instead of racing a timer.
|
|
/// </remarks>
|
|
internal void Start()
|
|
{
|
|
if (!updates.IsSupported || loop is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
loop = RunCheckLoopAsync(lifetime.Token);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
|
|
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
|
|
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
|
|
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
|
|
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
|
|
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
|
|
/// Every start now asks.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
|
|
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
|
|
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
|
|
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
|
|
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await Task.Yield();
|
|
|
|
using var timer = new PeriodicTimer(CheckInterval, clock);
|
|
|
|
do
|
|
{
|
|
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
|
|
// the loop is waiting to be handed back the thread has to be observed here rather than
|
|
// only at the next tick. Otherwise an application closed during launch spends its last
|
|
// moment asking a release channel about a build it is not going to run.
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Shutdown.
|
|
}
|
|
}
|
|
|
|
/// <summary>One pass of the loop.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
|
|
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
|
|
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
|
|
/// nobody asked for may only speak when it has something to say.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b>A pass still runs when one is already fetched, and this used to be the one state that stopped it
|
|
/// for good.</b> The guard read <c>Downloading or Ready</c>, on the reasonable-looking argument that
|
|
/// there is no point fetching what has already been fetched. What it actually did was stop <em>asking</em>
|
|
/// — so the moment anything was downloaded, the banner was pinned to that build for the rest of the
|
|
/// process's life, however many newer ones were published afterwards. On the nightly channel, where the
|
|
/// release is deleted and remade on every push to main, that is most of the time: the client offered the
|
|
/// build it happened to see first, installed it, restarted, and immediately offered the next one. Two
|
|
/// updates and two restarts to move one step.
|
|
/// </para>
|
|
/// <para>
|
|
/// Not fetching what is already fetched is still the rule, and <see cref="FetchAsync"/> is where it now
|
|
/// lives — by comparing versions rather than by refusing to look. Downloading is still excluded, because
|
|
/// a fetch in flight must not be restarted underneath itself; Checking is excluded for the same reason,
|
|
/// and it is a pressed CHECK NOW that a tick would otherwise land on top of.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!IsAutomatic || State is UpdateState.Checking or UpdateState.Downloading)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await FetchAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Deliberately silent, and deliberately back to Idle rather than Failed: Failed is for
|
|
// something a person is waiting on an answer to.
|
|
State = UpdateState.Idle;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The answer always arrives, including "you are on the latest build", because somebody pressed a
|
|
/// button and a button that appears to do nothing is worse than one that reports no news.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task CheckNowAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!CanCheckNow)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var found = await FetchAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
if (found is null)
|
|
{
|
|
Status = $"DodoSSH {CurrentVersion} is the latest build.";
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
State = UpdateState.Idle;
|
|
Status = "Cancelled.";
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
State = UpdateState.Failed;
|
|
Status = exception.Message;
|
|
}
|
|
}
|
|
|
|
/// <summary>Checks, and fetches whatever it finds that is not already fetched.</summary>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
/// <param name="checkWithin">
|
|
/// A bound on the check alone, or null for none. See <see cref="LastLookTimeout"/>. Deliberately not a
|
|
/// bound on the whole method: a package legitimately takes minutes, and a caller that wants a short
|
|
/// answer wants it about the <em>question</em>, not about the download the answer may lead to.
|
|
/// </param>
|
|
/// <returns>What is waiting to be installed once this has run, or null if nothing is.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>It answers with what is pending rather than with what the check returned, and the difference is
|
|
/// what makes a second pass safe.</b> A client running 1.0 that has fetched 1.1 is told 1.1 by every
|
|
/// later check, because 1.1 is still the newest thing on the feed — so "the check found something" is
|
|
/// not the same question as "there is something new". Comparing the two is what lets the loop keep
|
|
/// asking without fetching the same package every six hours, and it is what replaces the pending build
|
|
/// when the feed has moved on underneath it.
|
|
/// </para>
|
|
/// <para>
|
|
/// A build already fetched is never thrown away, including when a check answers null. Null means the
|
|
/// feed's newest is no newer than the build that is <em>running</em>, which says nothing about the one
|
|
/// on disk; that one is still newer than what is running, and withdrawing an offer somebody has already
|
|
/// been shown would be the worse answer.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<AvailableUpdate?> FetchAsync(
|
|
CancellationToken cancellationToken,
|
|
TimeSpan? checkWithin = null)
|
|
{
|
|
State = UpdateState.Checking;
|
|
Status = string.Empty;
|
|
|
|
var found = await AskAsync(checkWithin, cancellationToken).ConfigureAwait(true);
|
|
|
|
LastChecked = clock.GetUtcNow();
|
|
|
|
if (found is null)
|
|
{
|
|
// Answered with what is still pending rather than with nothing, which is what keeps a pressed
|
|
// CHECK NOW honest: "you are on the latest build" is true of the build that is running and false
|
|
// of the machine, if there is one downloaded and waiting to be it.
|
|
State = ready is null ? UpdateState.Idle : UpdateState.Ready;
|
|
|
|
if (ready is { } waiting)
|
|
{
|
|
Status = Waiting(waiting);
|
|
}
|
|
|
|
return ready;
|
|
}
|
|
|
|
if (ready is { } already
|
|
&& string.Equals(already.Version, found.Version, StringComparison.Ordinal))
|
|
{
|
|
// The ordinary answer for every pass after the first one that found something. Not fetched
|
|
// again, which is the rule the old Ready guard was reaching for and got by not asking at all.
|
|
State = UpdateState.Ready;
|
|
Status = Waiting(already);
|
|
|
|
return already;
|
|
}
|
|
|
|
return await FetchIntoReadyAsync(found, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Downloads one update and offers it.</summary>
|
|
/// <remarks>
|
|
/// Split from <see cref="FetchAsync"/> for length rather than for reuse — it has one caller — and the
|
|
/// division is a real one: above is which build to fetch, below is fetching it.
|
|
/// </remarks>
|
|
private async Task<AvailableUpdate> FetchIntoReadyAsync(
|
|
AvailableUpdate found,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var superseded = ready;
|
|
|
|
State = UpdateState.Downloading;
|
|
DownloadPercent = 0;
|
|
|
|
// Said while it downloads, and only when this displaces something already fetched. Both places that
|
|
// shows are places somebody is looking: a banner whose version changes under them, and a RESTART NOW
|
|
// that starts a download instead of a restart.
|
|
Status = superseded is { } stale
|
|
? $"DodoSSH {found.Version} appeared while {stale.Version} was waiting. Fetching it instead."
|
|
: string.Empty;
|
|
|
|
// Monotonic, and it has to be. Progress<T> delivers its callbacks by posting them to the captured
|
|
// synchronisation context rather than invoking them inline, so a report can arrive after the
|
|
// download has already returned — and an unguarded assignment then puts a stale smaller number
|
|
// back on the bar and leaves it there, because nothing reports again. Seen for real: a run of this
|
|
// finished at 50 with the state already Ready.
|
|
//
|
|
// Guarding here rather than reaching for an inline IProgress, because the posting is wanted: in the
|
|
// application these callbacks come off whichever thread the updater downloads on, and an observable
|
|
// property changed off the UI thread is a binding exception rather than a stale number.
|
|
var progress = new Progress<int>(percent =>
|
|
{
|
|
if (percent > DownloadPercent)
|
|
{
|
|
DownloadPercent = percent;
|
|
}
|
|
});
|
|
|
|
await updates.DownloadAsync(found, progress, cancellationToken).ConfigureAwait(true);
|
|
|
|
DownloadPercent = 100;
|
|
ready = found;
|
|
ReadyVersion = found.Version;
|
|
|
|
// Undismissed, because this is a different build from the one that was dismissed. LATER was an
|
|
// answer about a version, and there is a new question.
|
|
IsBannerDismissed = false;
|
|
State = UpdateState.Ready;
|
|
Status = Waiting(found);
|
|
|
|
return found;
|
|
}
|
|
|
|
/// <summary>Asks the channel, giving up after <paramref name="within"/> if one was given.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The wait is bounded rather than the request cancelled, and that is not the same thing: the desktop
|
|
/// channel's own remark records that Velopack's check takes no token, so cancelling would be observed
|
|
/// only once the call had already returned. Waiting on it with a timeout is what actually gives an
|
|
/// answer back in ten seconds.
|
|
/// </para>
|
|
/// <para>
|
|
/// Which leaves the request running, and it is the reason the channel had to learn to remember what it
|
|
/// <em>downloaded</em> rather than only what it last found: an orphaned check can land afterwards and
|
|
/// move the channel's idea of the newest build, and the restart that gave up on it is about to name the
|
|
/// older one. See <c>VelopackUpdateChannel.Matched</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<AvailableUpdate?> AskAsync(TimeSpan? within, CancellationToken cancellationToken)
|
|
{
|
|
var asking = updates.CheckAsync(cancellationToken);
|
|
|
|
return within is { } bound
|
|
? await asking.WaitAsync(bound, clock, cancellationToken).ConfigureAwait(true)
|
|
: await asking.ConfigureAwait(true);
|
|
}
|
|
|
|
private static string Waiting(AvailableUpdate update) =>
|
|
$"DodoSSH {update.Version} is downloaded and will run after a restart.";
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
|
|
/// reason is ordering: applying ends the process, and the vault has to be disposed first because that
|
|
/// is what zeroes the identity keys, the vault keys and the cache key. This view model does not know
|
|
/// about any of that and should not learn.
|
|
/// </para>
|
|
/// <para>
|
|
/// A delegate taken in the constructor, like <c>TransfersViewModel</c>'s <c>addBucket</c> and the
|
|
/// shell's own sign-in handler. An event would have been the other option and is worse here: there is
|
|
/// exactly one subscriber, it is known at construction, and a second one would mean two things racing
|
|
/// to end the same process.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task RestartNowAsync()
|
|
{
|
|
if (ready is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsRestarting = true;
|
|
|
|
try
|
|
{
|
|
await TakeOneLastLookAsync().ConfigureAwait(true);
|
|
|
|
if (ready is not { } update)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// ◆ Reported rather than allowed to escape, and the phone is why. Where applying ends the
|
|
// process a throw here is nearly unreachable; where it does not — see ApplyingEndsTheProcess —
|
|
// the whole point is that this returns and the application carries on, so an exception has
|
|
// somewhere to go: out of a command handler, past the dispatcher, and into the process. What
|
|
// that looks like to somebody holding the phone is pressing INSTALL and having the application
|
|
// close, with nothing installed and nothing said. Failed and the message is the same answer
|
|
// CheckNowAsync gives, and it is the difference between a bug that describes itself and one
|
|
// that presents as a crash.
|
|
try
|
|
{
|
|
await restart(update).ConfigureAwait(true);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
State = UpdateState.Failed;
|
|
Status = exception.Message;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Never reached on a desktop, where restart() replaces the process from inside the try. It is
|
|
// for the phone, where INSTALL is a request the platform may refuse, and for every failure
|
|
// path: a flag that keeps the banner up has to come down again.
|
|
IsRestarting = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks the feed once more, on the way to replacing this process.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>Because what was fetched can be hours old, and a restart is the one moment being one build
|
|
/// behind is expensive.</b> The loop asks every six hours; a build published between the last pass and
|
|
/// this press would otherwise be installed on the <em>next</em> restart, which is to say: update,
|
|
/// restart, be told at once that another update is waiting, update, restart again. Two of everything to
|
|
/// move one step, which is exactly what a rolling nightly channel produces.
|
|
/// </para>
|
|
/// <para>
|
|
/// It costs one small request in the ordinary case, because the feed answers with the build already on
|
|
/// disk and <see cref="FetchAsync"/> compares rather than downloads. It costs a download only when there
|
|
/// genuinely is something newer, which is the case where the alternative was two downloads anyway.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing here refuses a restart.</b> A forge that cannot be reached, a look that ran out of time, a
|
|
/// fetch of the newer build that failed halfway — all of them leave the build already on disk untouched
|
|
/// and still newer than the one running, so the restart goes ahead with it. That is why this swallows
|
|
/// rather than reporting: <see cref="UpdateState.Failed"/> is for something a person is waiting on an
|
|
/// answer to, and this person is waiting on a restart they are about to get.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task TakeOneLastLookAsync()
|
|
{
|
|
try
|
|
{
|
|
await FetchAsync(lifetime.Token, checkWithin: LastLookTimeout).ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
|
{
|
|
// Shutting down, which is the one case that must not go on to restart: whatever is fetched is
|
|
// applied at the next ordinary launch anyway, and that is the policy rather than a fallback.
|
|
ready = null;
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Including the bound running out. Back to the offer that was already on screen.
|
|
State = UpdateState.Ready;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DismissBanner() => IsBannerDismissed = true;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
disposed = true;
|
|
|
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
|
|
|
if (loop is { } running)
|
|
{
|
|
// Awaited rather than abandoned, so that a pass in flight is finished with before the
|
|
// application tears down what it is writing into.
|
|
await running.ConfigureAwait(false);
|
|
}
|
|
|
|
lifetime.Dispose();
|
|
}
|
|
}
|