Files
DodoSSH/src/DodoSSH.Client.App/Platform/VelopackUpdateChannel.cs
T
jaap-jan 890a5f2246
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s
Give the desktop a macOS head, signed from the first release
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
2026-08-10 10:43:28 +02:00

319 lines
16 KiB
C#

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()
{
// 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();
}
}
}
/// <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;
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 manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
}
/// <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>
/// 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; 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.
/// </remarks>
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;
}
}