Public Access
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.
The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.
◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.
Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 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" and "there is something new" are different
questions and only the second one is worth a download.
A build already fetched is never discarded, 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, and withdrawing an
offer somebody has already been shown would be the worse answer.
◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
CANNOT REACH.
The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.
It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.
Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.
Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.
IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.
◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.
Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.
So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.
Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.
UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
409 lines
20 KiB
C#
409 lines
20 KiB
C#
using System.Runtime.Versioning;
|
|
using DodoSSH.Client.Session;
|
|
using Velopack;
|
|
using Velopack.Sources;
|
|
|
|
namespace DodoSSH.Client.App.Platform;
|
|
|
|
/// <summary>
|
|
/// Chooses the update channel this machine can actually use.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Decided once, at composition, from a property of the machine — the same shape as
|
|
/// <c>DesktopDeviceKeyStores.ForThisMachine</c>, and for the same reason: whether this copy can replace
|
|
/// itself does not change while it runs, and a check repeated at each call site is a check somebody
|
|
/// eventually forgets.
|
|
/// </remarks>
|
|
internal static class UpdateChannels
|
|
{
|
|
/// <summary>The channel for this machine, or one that reports itself unavailable.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two conditions, and the second is the one that matters in development. Velopack's
|
|
/// <c>IsInstalled</c> is false when the process is not running from an installed layout — which is
|
|
/// every <c>dotnet run</c>, every build started from an IDE, and every copy somebody extracted from
|
|
/// an archive by hand. Reaching into the updater from one of those does not fail politely.
|
|
/// </para>
|
|
/// <para>
|
|
/// Constructing an <see cref="UpdateManager"/> is what answers the question, and constructing one is
|
|
/// cheap — it reads the layout on disk and talks to nothing. The network is not touched until
|
|
/// somebody asks for a check.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static IUpdateChannel ForThisMachine()
|
|
{
|
|
// ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
|
|
//
|
|
// This used to be the place that said so — a negation over the two packaged platforms, with a note
|
|
// that a future AppImage should be an addition here rather than something that had silently
|
|
// half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
|
|
// is updated by the machine's own package manager, so its channel fetches and hands off rather
|
|
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
|
|
if (OperatingSystem.IsLinux())
|
|
{
|
|
return LinuxChannel();
|
|
}
|
|
|
|
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
|
{
|
|
return new UnavailableUpdateChannel();
|
|
}
|
|
|
|
try
|
|
{
|
|
var manager = VelopackUpdateChannel.CreateManager();
|
|
|
|
return manager.IsInstalled
|
|
? new VelopackUpdateChannel(manager)
|
|
: new UnavailableUpdateChannel();
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// A machine whose install layout cannot be read is a machine with no updater, which is a
|
|
// state this application already knows how to be in. Refusing to start an SSH client over
|
|
// it would be the wrong trade by a wide margin.
|
|
return new UnavailableUpdateChannel();
|
|
}
|
|
}
|
|
|
|
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
|
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
|
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
|
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
|
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
|
/// out.
|
|
/// </para>
|
|
/// <para>
|
|
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
|
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
|
/// differently. The install root is a constant in two places — here and the packaging script — and
|
|
/// they are named in each other's comments.
|
|
/// </para>
|
|
/// </remarks>
|
|
[SupportedOSPlatform("linux")]
|
|
private static IUpdateChannel LinuxChannel()
|
|
{
|
|
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
|
|
|
|
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
|
|
|
return string.Equals(here, root, StringComparison.Ordinal)
|
|
? new LinuxUpdateChannel(DesktopChannel.Name)
|
|
: new UnavailableUpdateChannel();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The desktop update channel, backed by Velopack against the project's own forge.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
|
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
|
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
|
/// shared with the Android head, which must never acquire an updater.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
|
|
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
|
|
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
|
|
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
|
|
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|
{
|
|
/// <summary>
|
|
/// Where builds come from, and it is a constant on purpose.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>This must never become a setting.</b> ADR 0011 rule 2 says the deployment a client signs in to
|
|
/// <para>
|
|
/// is never where the client comes from, and it says the same about the update check: an operator who
|
|
/// can answer "is there a newer version" can answer "no" forever, and pin a chosen user to a build
|
|
/// with a known hole without holding any key. A configurable feed URL is exactly the knob that would
|
|
/// hand them that, whether through a settings screen or through somebody editing the plaintext
|
|
/// settings.json by hand. A constant is that rule expressed structurally rather than as a convention
|
|
/// somebody has to keep.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b><c>DodoTech-Public</c>, and the owner is part of the address rather than incidental.</b> The
|
|
/// repository was moved between organisations, and Gitea leaves a 301 behind at the old path — which is
|
|
/// why a client pointing at the old one appears to work: <c>HttpClient</c> follows a redirect on a GET.
|
|
/// It does not follow one on a POST, so <c>vpk upload</c> against the stale URL fails rather than
|
|
/// redirecting, and a redirect is a thing an operator can remove. Both heads and both release scripts
|
|
/// name the live path.
|
|
/// </para>
|
|
/// </remarks>
|
|
private const string RepositoryUrl = "https://git.dodotech.cloud/DodoTech-Public/DodoSSH";
|
|
|
|
/// <summary>
|
|
/// The release channel to read, and it is stated rather than left to the default.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A contract with <c>scripts/release-windows.ps1</c>, which passes the same word to <c>vpk pack</c>.
|
|
/// It happens to be Velopack's Windows default, so leaving it unsaid on both sides would work too —
|
|
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
|
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
|
/// </remarks>
|
|
private const string WindowsReleaseChannel = "win";
|
|
|
|
/// <summary>
|
|
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
|
|
/// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
|
|
/// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
|
|
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
|
|
/// channels are published to the same repository, so a client that read the other's index could be
|
|
/// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
|
|
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
|
/// </para>
|
|
/// </remarks>
|
|
private const string WindowsNightlyChannel = "win-nightly";
|
|
|
|
/// <summary>The macOS release channel, and Velopack's own default there.</summary>
|
|
/// <remarks>
|
|
/// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
|
|
/// PowerShell script. Stated for the same reason, which applies with more force here: the four
|
|
/// channels all publish to one repository, so the only thing keeping a Mac from being offered a
|
|
/// <c>win</c> package is that it never reads that index.
|
|
/// </remarks>
|
|
private const string MacReleaseChannel = "osx";
|
|
|
|
/// <summary>The macOS nightly channel.</summary>
|
|
/// <remarks>
|
|
/// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
|
|
/// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
|
|
/// build checking this feed finds an empty channel and reports itself up to date, which is the
|
|
/// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
|
|
/// on later is one job rather than a job plus a rename that has to reach every installed client.
|
|
/// </remarks>
|
|
private const string MacNightlyChannel = "osx-nightly";
|
|
|
|
private readonly UpdateManager manager;
|
|
|
|
/// <summary>
|
|
/// The last thing a check found, kept so that a download and an apply can name it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Velopack's <c>UpdateInfo</c> carries the delta chain and the target asset, and none of that should
|
|
/// cross the seam — the shell has no use for it and a test would have to construct it. So the record
|
|
/// handed upwards is a version string, and this is where the real answer waits to be matched back up.
|
|
/// </remarks>
|
|
private UpdateInfo? found;
|
|
|
|
/// <summary>
|
|
/// The last thing actually fetched, kept separately from the last thing found.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
|
|
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
|
|
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
|
|
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
|
|
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
|
|
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
|
|
/// </para>
|
|
/// <para>
|
|
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
|
|
/// was real, and the only thing wrong was that this class had forgotten which one it was.
|
|
/// </para>
|
|
/// </remarks>
|
|
private UpdateInfo? fetched;
|
|
|
|
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
|
|
|
/// <inheritdoc />
|
|
public bool IsSupported => true;
|
|
|
|
/// <summary>Always, on this head, on either platform.</summary>
|
|
/// <remarks>
|
|
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
|
/// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
|
|
/// relaunches it, so the process is gone by the time anything could have asked a question. The
|
|
/// mechanism differs and the answer does not, which is why this is a constant rather than another
|
|
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
|
|
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
|
/// </remarks>
|
|
public bool ApplyingEndsTheProcess => true;
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// From the assembly rather than from <c>manager.CurrentVersion</c>, so that this and the version an
|
|
/// un-updatable build reports come from one place. Two ways of answering the same question is how
|
|
/// they come to disagree.
|
|
/// </remarks>
|
|
public string CurrentVersion => ClientVersion.Current;
|
|
|
|
/// <summary>
|
|
/// The updater for this build's channel.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
|
|
/// left to the installed layout — Velopack records what a package was built with — but whether to
|
|
/// consider prereleases is a property of the feed rather than of the install, and it decides more
|
|
/// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
|
|
/// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
|
|
/// stable release would become the newest release in this repository and the phone's release channel
|
|
/// would start finding no Android manifest on it. One flag here keeps the two heads out of each
|
|
/// other's way.
|
|
/// </para>
|
|
/// <para>
|
|
/// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
|
|
/// that matters most, because that is the direction where a mistake would put an unsigned CI build on
|
|
/// a machine somebody trusts with their credentials.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static UpdateManager CreateManager()
|
|
{
|
|
var nightly = DesktopChannel.IsNightly;
|
|
|
|
return new UpdateManager(
|
|
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
|
new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one of the four channel names this build belongs to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two independent axes — which platform, and which of that platform's two channels — and they are
|
|
/// resolved in one place so that neither can be answered differently somewhere else. The platform
|
|
/// half is the running OS rather than anything recorded in the build, because a package can only
|
|
/// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
|
|
/// because a release build and a nightly are the same bytes on the same OS and only the metadata
|
|
/// tells them apart.
|
|
/// </para>
|
|
/// <para>
|
|
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
|
|
/// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
|
|
/// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
|
|
/// later has to reason about to discover it cannot happen.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static string ChannelFor(bool nightly)
|
|
{
|
|
if (OperatingSystem.IsMacOS())
|
|
{
|
|
return nightly ? MacNightlyChannel : MacReleaseChannel;
|
|
}
|
|
|
|
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
|
{
|
|
// CheckForUpdatesAsync takes no token of its own, so cancellation is observed on either side of
|
|
// it rather than during. The call is one HTTPS request against a small JSON document; the worst
|
|
// case is a lock-up already bounded by the handler's own timeout.
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var update = await manager.CheckForUpdatesAsync().ConfigureAwait(false);
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (update is null)
|
|
{
|
|
found = null;
|
|
|
|
return null;
|
|
}
|
|
|
|
found = update;
|
|
|
|
return new AvailableUpdate(update.TargetFullRelease.Version.ToString());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DownloadAsync(
|
|
AvailableUpdate update,
|
|
IProgress<int> progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(update);
|
|
ArgumentNullException.ThrowIfNull(progress);
|
|
|
|
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
|
// so the adaptation happens here rather than leaking the older shape into the view models.
|
|
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
|
|
/// the one this channel can still apply. See <see cref="fetched"/>.
|
|
/// </remarks>
|
|
private async Task DownloadAndRememberAsync(
|
|
UpdateInfo info,
|
|
IProgress<int> progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await manager
|
|
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
fetched = info;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ApplyAndRestart(AvailableUpdate update)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(update);
|
|
|
|
// Does not return: the process is replaced. Anything that needed to happen before the window
|
|
// closes has to have happened already — see the shell's restart command, which disposes first.
|
|
manager.ApplyUpdatesAndRestart(Matched(update).TargetFullRelease);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
|
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
|
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
|
/// this application rather than anything a user did, which is why it throws rather than resolving to
|
|
/// some safe-looking default.
|
|
/// </para>
|
|
/// <para>
|
|
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
|
|
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
|
|
/// </para>
|
|
/// </remarks>
|
|
private UpdateInfo Matched(AvailableUpdate update)
|
|
{
|
|
if (Names(fetched, update))
|
|
{
|
|
return fetched!;
|
|
}
|
|
|
|
if (Names(found, update))
|
|
{
|
|
return found!;
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"No update matching {update.Version} has been found or fetched by this channel. "
|
|
+ "Call CheckAsync and pass back what it returned.");
|
|
}
|
|
|
|
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
|
|
info is not null
|
|
&& string.Equals(
|
|
info.TargetFullRelease.Version.ToString(),
|
|
update.Version,
|
|
StringComparison.Ordinal);
|
|
}
|