Let the desktop client replace itself, and give the repository one version

Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
This commit is contained in:
2026-08-04 17:04:41 +02:00
parent 176df67861
commit 6728a0a597
66 changed files with 3190 additions and 44 deletions
@@ -288,6 +288,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly VaultsViewModel vaults;
/// <summary>
/// Where newer builds come from, and how far one has got.
/// </summary>
/// <remarks>
/// A process-lifetime object like <see cref="transfers"/>, and for a reason that is its own rather than
/// borrowed: this one outlives a lock because the release channel is not the vault.
/// </remarks>
private readonly UpdateViewModel updateScreen;
/// <summary>
/// The tab standing in for each connection that has been asked for and has not answered yet.
/// </summary>
@@ -347,6 +356,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// What to call this machine. Optional, and the default is right for every head that runs on a desktop
/// operating system — see the field it is kept in for the one that it is not right for.
/// </param>
/// <param name="updates">
/// Where newer builds of this client come from. Optional, and the default is a channel that reports
/// itself unavailable — which is a deliberate difference from <paramref name="deviceKeys"/>, which every
/// head passes explicitly. With an optional parameter, "the phone has no updater" is enforced by the
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
/// </param>
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -359,7 +375,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null,
string? deviceName = null)
string? deviceName = null,
IUpdateChannel? updates = null)
{
this.paths = paths;
this.caches = caches;
@@ -404,6 +421,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
settings = new ClientSettingsStore(paths);
updateScreen = CreateUpdateScreen(updates);
// Read straight away rather than at first use, so the value is right before anything can read it —
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
// as the interface correcting itself.
@@ -412,6 +431,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = TellRendererTheFontSizeAsync();
}
/// <summary>
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
/// </summary>
/// <remarks>
/// A method rather than four more lines in the constructor, because the restart delegate needs a
/// paragraph of its own and the constructor is already at the length the analyzers allow.
/// </remarks>
private UpdateViewModel CreateUpdateScreen(IUpdateChannel? updates)
{
// The channel is captured rather than reached through the view model, which keeps the restart
// delegate free of a reference to the object it is being handed to.
var channel = updates ?? new UnavailableUpdateChannel();
return new UpdateViewModel(
channel,
settings,
clock,
() => workspace.LiveSessionCount,
// Everything this application does on the way out, and only then the swap. Applying an update
// ends the process, and disposing this view model is what zeroes the identity keys, the vault
// keys and the cache key — so the other order would leave them sitting in a memory image the
// installer is about to write over, and would abandon a transfer still writing to a part file.
restart: async update =>
{
await DisposeAsync().ConfigureAwait(true);
channel.ApplyAndRestart(update);
});
}
/// <remarks>
/// <para>
/// The page starts at its own default and has no way to know what was stored, so somebody has to tell
@@ -568,6 +618,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal TransfersViewModel Transfers => transfers;
/// <summary>Where newer builds come from, which the window binds whether or not a vault is open.</summary>
/// <remarks>
/// Bound from the titlebar's banner and from the preferences screen, and it answers on a locked shell
/// too — the banner is drawn outside the unlocked half of the window on purpose, because a machine left
/// locked overnight is exactly the one that will have found an update by morning.
/// </remarks>
internal UpdateViewModel Updates => updateScreen;
/// <summary>
/// Shells that were left running when the vault was locked.
/// </summary>
@@ -1625,6 +1683,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken)
{
// Before anything that can return early, and outside the try: looking for a newer build does not
// depend on there being a profile, a server or a vault, and a machine that never gets past the setup
// screen is still one that should not be running a build with a hole in it. Start() is a no-op on a
// copy that cannot replace itself.
updateScreen.Start();
try
{
paths.EnsureCreated();
@@ -2467,6 +2531,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
// vault because the restart path disposes this whole object and then applies the update — so a
// check still running would be writing into a view model the process is about to replace.
await updateScreen.DisposeAsync().ConfigureAwait(false);
knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
@@ -0,0 +1,412 @@
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);
/// <summary>How long to wait before the first pass.</summary>
/// <remarks>
/// A delay, where <c>VaultViewModel</c>'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.
/// </remarks>
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2);
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);
}
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.
}
}
/// <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;
var progress = new Progress<int>(percent => 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;
}
await restart(update).ConfigureAwait(true);
}
[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();
}
}