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