using System.Runtime.Versioning;
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()
{
// ◆ 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();
}
}
/// The Linux channel, if this copy is one a package manager put here.
///
///
/// The question Velopack answers with IsInstalled, answered by looking at where this process
/// lives. The RPM owns /opt/DodoSSH or /opt/DodoSSH.Nightly — the spec puts it there
/// and nothing else writes to /opt 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.
///
///
/// 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.
///
///
[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();
}
}
///
/// 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;
///
/// The last thing actually fetched, kept separately from the last thing found.
///
///
///
/// ◆ Because the two can disagree, and when they do it is an apply that pays for it. A check
/// overwrites , 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 found 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 resolving only against found would have thrown at it.
///
///
/// 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.
///
///
private UpdateInfo? fetched;
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 DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
}
///
/// 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 .
///
private async Task DownloadAndRememberAsync(
UpdateInfo info,
IProgress progress,
CancellationToken cancellationToken)
{
await manager
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
.ConfigureAwait(false);
fetched = info;
}
///
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, 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.
///
///
/// 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 for how the two come to disagree.
///
///
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);
}