using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Shell.ViewModels;
///
/// Where an update has got to.
///
///
/// An enum rather than a handful of booleans, for the reason ShellSurface gives: there is then no
/// way to write the state where two of these are true at once.
///
/// There is deliberately no Available member. 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.
///
///
internal enum UpdateState
{
/// This copy cannot replace itself, so none of the rest can happen.
Unsupported = 0,
/// Nothing in progress.
Idle = 1,
/// Asking the release channel.
Checking = 2,
/// Fetching a newer build.
Downloading = 3,
/// Fetched, and waiting for somebody to say when.
Ready = 4,
/// Something the user asked for did not work.
Failed = 5,
}
///
/// Looks for newer builds, fetches them, and waits to be told when.
///
///
///
/// Nothing here ever installs anything on its own. 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 MainWindowViewModel.LockAsync, 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.
///
///
/// It lives as long as the process, not as long as a vault. Unlike the screens built per unlock, and
/// unlike VaultViewModel'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.
///
///
/// It does not speak through Announce or the vault's status line. 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 of its own, which the preferences screen reads.
///
///
internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposable
{
/// How often to look, once the first pass has happened.
///
/// 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.
///
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
/// How long the look taken on the way to a restart may take before it is given up on.
///
///
/// 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.
///
///
/// 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.
///
///
private static readonly TimeSpan LastLookTimeout = TimeSpan.FromSeconds(10);
private readonly IUpdateChannel updates;
private readonly ClientSettingsStore settings;
private readonly TimeProvider clock;
///
/// 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.
///
private readonly Func liveSessionCount;
///
/// What to do when the user presses restart. See for why this is not
/// simply a call into the channel.
///
private readonly Func restart;
private readonly CancellationTokenSource lifetime = new();
private Task? loop;
private AvailableUpdate? ready;
private bool disposed;
internal UpdateViewModel(
IUpdateChannel updates,
ClientSettingsStore settings,
TimeProvider clock,
Func liveSessionCount,
Func 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;
}
/// What this build calls itself.
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;
///
/// 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.
///
[ObservableProperty]
private bool isBannerDismissed;
[ObservableProperty]
private DateTimeOffset? lastChecked;
///
/// Whether a restart is under way, including the look it takes before replacing anything.
///
///
/// It exists to keep the banner up. Everything the banner shows keys off , 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
/// , which is what it says instead.
///
[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;
/// What the banner says.
///
/// 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.
///
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.";
}
}
/// What restarting costs, in the terms this application has already taught.
///
/// 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.
///
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."),
};
}
}
/// When this run last asked, for as long as this run lasts.
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));
}
///
/// 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.
///
partial void OnDownloadPercentChanged(int value) => OnPropertyChanged(nameof(ReadyHeadline));
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
///
/// 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.
///
partial void OnIsAutomaticChanged(bool value) =>
settings.Write(settings.Read() with { AutomaticUpdateChecks = value });
/// Starts looking, on a timer.
///
/// 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 a pass at a time
/// instead of racing a timer.
///
internal void Start()
{
if (!updates.IsSupported || loop is not null)
{
return;
}
loop = RunCheckLoopAsync(lifetime.Token);
}
///
///
/// The first pass runs at launch, with no delay in front of it. 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.
///
///
/// The yield is what keeps that off the launch path. is called from
/// MainWindowViewModel.StartAsync 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.
///
///
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.
}
}
/// One pass of the loop.
///
///
/// Quiet by construction. 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 VaultViewModel.AutoSyncAsync: what
/// nobody asked for may only speak when it has something to say.
///
///
/// ◆ A pass still runs when one is already fetched, and this used to be the one state that stopped it
/// for good. The guard read Downloading or Ready, on the reasonable-looking argument that
/// there is no point fetching what has already been fetched. What it actually did was stop asking
/// — 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.
///
///
/// Not fetching what is already fetched is still the rule, and 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.
///
///
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;
}
}
///
/// 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.
///
[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;
}
}
/// Checks, and fetches whatever it finds that is not already fetched.
/// Cancellation.
///
/// A bound on the check alone, or null for none. See . 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 question, not about the download the answer may lead to.
///
/// What is waiting to be installed once this has run, or null if nothing is.
///
///
/// ◆ It answers with what is pending rather than with what the check returned, and the difference is
/// what makes a second pass safe. 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.
///
///
/// 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 running, 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.
///
///
private async Task 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);
}
/// Downloads one update and offers it.
///
/// Split from 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.
///
private async Task 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 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(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;
}
/// Asks the channel, giving up after if one was given.
///
///
/// 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.
///
///
/// Which leaves the request running, and it is the reason the channel had to learn to remember what it
/// downloaded 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 VelopackUpdateChannel.Matched.
///
///
private async Task 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.";
///
///
/// 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.
///
///
/// A delegate taken in the constructor, like TransfersViewModel's addBucket 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.
///
///
[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;
}
}
///
/// Asks the feed once more, on the way to replacing this process.
///
///
///
/// ◆ Because what was fetched can be hours old, and a restart is the one moment being one build
/// behind is expensive. The loop asks every six hours; a build published between the last pass and
/// this press would otherwise be installed on the next 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.
///
///
/// It costs one small request in the ordinary case, because the feed answers with the build already on
/// disk and 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.
///
///
/// Nothing here refuses a restart. 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: is for something a person is waiting on an
/// answer to, and this person is waiting on a restart they are about to get.
///
///
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;
///
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();
}
}