using DodoSSH.Client.Session;
using Velopack;
using Velopack.Sources;
namespace DodoSSH.Client.App.Platform;
///
/// Chooses the update channel this machine can actually use.
///
///
/// Decided once, at composition, from a property of the machine — the same shape as
/// DesktopDeviceKeyStores.ForThisMachine, 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.
///
internal static class UpdateChannels
{
/// The channel for this machine, or one that reports itself unavailable.
///
///
/// Two conditions, and the second is the one that matters in development. Velopack's
/// IsInstalled is false when the process is not running from an installed layout — which is
/// every dotnet run, 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.
///
///
/// Constructing an 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.
///
///
internal static IUpdateChannel ForThisMachine()
{
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
// AppImage an addition here rather than a thing that silently already half-happened.
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();
}
}
}
///
/// The desktop update channel, backed by Velopack against the project's own forge.
///
///
///
/// 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 DodoSSH.Client.Session — and because DodoSSH.Client.Shell is
/// shared with the Android head, which must never acquire an updater.
///
///
/// One class for both desktop platforms, where the key stores are one class each. 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 UpdateManager 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 docs/adr/0013-desktop-distribution-and-updates.md.
///
///
internal sealed class VelopackUpdateChannel : IUpdateChannel
{
///
/// Where builds come from, and it is a constant on purpose.
///
///
/// This must never become a setting. ADR 0011 rule 2 says the deployment a client signs in to
///
/// 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.
///
///
/// ◆ DodoTech-Public, and the owner is part of the address rather than incidental. 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: HttpClient follows a redirect on a GET.
/// It does not follow one on a POST, so vpk upload 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.
///
///
private const string RepositoryUrl = "https://git.dodotech.cloud/DodoTech-Public/DodoSSH";
///
/// The release channel to read, and it is stated rather than left to the default.
///
///
/// A contract with scripts/release-windows.ps1, which passes the same word to vpk pack.
/// 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.
///
private const string WindowsReleaseChannel = "win";
///
/// The nightly channel, which is a different name rather than the same one on a different tag.
///
///
///
/// A contract with the desktop nightly job in .github/workflows/ci.yml, which passes
/// this word to both vpk pack and vpk upload. The name reaches the wire: Velopack
/// publishes its index as releases.win-nightly.json and looks for exactly that file, so a
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
///
///
/// Two names rather than one name on two tags, and that is the part doing the work. 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.
///
///
private const string WindowsNightlyChannel = "win-nightly";
/// The macOS release channel, and Velopack's own default there.
///
/// A contract with scripts/release-macos.sh, 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
/// win package is that it never reads that index.
///
private const string MacReleaseChannel = "osx";
/// The macOS nightly channel.
///
/// 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 ci.yml — 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.
///
private const string MacNightlyChannel = "osx-nightly";
private readonly UpdateManager manager;
///
/// The last thing a check found, kept so that a download and an apply can name it.
///
///
/// Velopack's UpdateInfo 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.
///
private UpdateInfo? found;
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
///
public bool IsSupported => true;
/// Always, on this head, on either platform.
///
/// Velopack's apply hands off to a separate updater process — Update.exe on Windows, the
/// UpdateMac 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 would have to decide. The phone's is the other answer; see
/// for what the caller does differently.
///
public bool ApplyingEndsTheProcess => true;
///
///
/// From the assembly rather than from manager.CurrentVersion, 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.
///
public string CurrentVersion => ClientVersion.Current;
///
/// The updater for this build's channel.
///
///
///
/// The prerelease flag is the half Velopack cannot work out for itself. 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 releases/latest, 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.
///
///
/// The release channel takes false, 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.
///
///
internal static UpdateManager CreateManager()
{
var nightly = DesktopChannel.IsNightly;
return new UpdateManager(
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
}
///
/// The one of the four channel names this build belongs to.
///
///
///
/// 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.
///
///
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
/// 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.
///
///
private static string ChannelFor(bool nightly)
{
if (OperatingSystem.IsMacOS())
{
return nightly ? MacNightlyChannel : MacReleaseChannel;
}
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
}
///
public async Task 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());
}
///
public Task DownloadAsync(
AvailableUpdate update,
IProgress progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(progress);
// Velopack reports progress as an Action and the rest of this codebase speaks IProgress,
// so the adaptation happens here rather than leaking the older shape into the view models.
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
}
///
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);
}
///
/// The guard exists because the seam narrows UpdateInfo down to a version string, so nothing in
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
/// returned; 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.
///
private UpdateInfo Matched(AvailableUpdate update)
{
if (found is not { } info
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"No update matching {update.Version} has been found by this channel. "
+ "Call CheckAsync and pass back what it returned.");
}
return info;
}
}