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(); } }