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