Public Access
The first pass of the update loop waited two minutes. Every pass after it came six hours apart, which is the right interval for a product that ships rarely — but the delay in front of the first one quietly excluded a whole way of using this application. A client opened to reach one host and closed again is over before the two minutes are. Used that way, it never checks at all: not once, not slowly, never. That is precisely the machine ADR 0011 names as the real cost of distributing outside a store — quietly a year behind — and the galling part is that the mechanism to fix it was switched on the whole time and simply never reached. The delay's own argument is recorded in the diff it is being removed from, and it was not a bad one: 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, at the one moment somebody is watching the window. What it weighed was the cost of checking early against the benefit of checking early. It never weighed the cost of not checking at all. ◆ THE YIELD IS WHAT KEEPS THIS OFF THE LAUNCH PATH, AND IT IS NOT DECORATION. Start() is called from MainWindowViewModel.StartAsync ahead of the migration, so an inline first pass would run 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 launch back and puts the check in a later turn, which is the same moment in every sense anybody can perceive and none of the cost. So the answer to the delay's argument is not that it was wrong; it is that a yield buys most of what two minutes bought. Task.Yield takes no token where Task.Delay did, so the loop body now observes cancellation at its head. Without that, an application closed during launch spends its last moment asking a release channel about a build it will not run. Two things deliberately not changed. The AUTOMATIC UPDATE CHECKS preference still gates the pass — "on start" means every start, not regardless of what the user asked for, and that setting is already on by default. And the data cost is unchanged rather than merely acceptable: a check is a few hundred bytes and the download only follows if something newer exists, so this moves the same traffic earlier without adding any. That matters most on the phone, where the same loop runs against AndroidUpdateChannel. TheFirstPassRunsAtStart_RatherThanOnADelay drives the real loop rather than CheckOnceAsync, which is the one thing that file otherwise avoids — and here it is the point, because the claim is about when the pass happens rather than what it does. It waits on the pass and not on a clock, so there is nothing to be flaky about: a regression that puts a delay back does not fail on a margin, it spins until the suite's own cancellation ends it. DisposingStopsTheLoop keeps its assertion and gains a note that it is now a race rather than a formality. §16.7 of the manual checks gains the sentence that reopening the application does what CHECK NOW does. It is the step somebody following that section would otherwise discover by accident. 447 App tests and 153 layout tests pass.
457 lines
18 KiB
C#
457 lines
18 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);
|
|
|
|
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;
|
|
|
|
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 && !IsBannerDismissed;
|
|
|
|
/// <summary>What the banner says.</summary>
|
|
internal string ReadyHeadline => 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));
|
|
}
|
|
|
|
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
|
|
|
|
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>
|
|
/// <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.
|
|
/// </remarks>
|
|
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
|
|
{
|
|
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.</summary>
|
|
/// <returns>The update found, or null.</returns>
|
|
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
|
|
{
|
|
State = UpdateState.Checking;
|
|
Status = string.Empty;
|
|
|
|
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
LastChecked = clock.GetUtcNow();
|
|
|
|
if (found is null)
|
|
{
|
|
State = UpdateState.Idle;
|
|
|
|
return null;
|
|
}
|
|
|
|
State = UpdateState.Downloading;
|
|
DownloadPercent = 0;
|
|
|
|
// 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;
|
|
IsBannerDismissed = false;
|
|
State = UpdateState.Ready;
|
|
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
|
|
|
|
return found;
|
|
}
|
|
|
|
/// <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 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;
|
|
}
|
|
}
|
|
|
|
[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();
|
|
}
|
|
}
|