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 to wait before the first pass. /// /// A delay, where VaultViewModel's sync loop runs a pass immediately. The difference is what the /// user is waiting for: a vault edited on another machine should be current by the time they have /// finished reading the list, whereas nothing anybody does in their first two minutes depends on an /// update. Launch is already contending for the network and the CPU with a schema migration, a resumed /// sign-in and a first sync, at the one moment somebody is watching the window. /// private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2); 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; 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; /// What the banner says. internal string ReadyHeadline => 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)); } partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing)); 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); } private async Task RunCheckLoopAsync(CancellationToken cancellationToken) { try { await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true); using var timer = new PeriodicTimer(CheckInterval, clock); do { 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. /// 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; } } /// /// 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. /// The update found, or null. private async Task 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 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; IsBannerDismissed = false; State = UpdateState.Ready; Status = $"DodoSSH {found.Version} is downloaded and will run after a restart."; return found; } /// /// /// 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 not { } update) { return; } await restart(update).ConfigureAwait(true); } [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(); } }