Compare commits

...
Author SHA1 Message Date
jaap-janandClaude Opus 5.5 2ab978a439 Merge claude/windows-startup-performance-a615bc: precompile the macOS and Linux publishes too
ci / desktop nightly (push) Successful in 4m35s
ci / android head (push) Successful in 4m16s
ci / build and test (push) Successful in 4m28s
ci / api image (push) Successful in 8s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:09:14 +02:00
jaap-janandClaude Opus 5.5 1426be8f63 Precompile the macOS and Linux publishes too
ReadyToRun went onto the win-x64 publish in the previous change and took the main window from 2.6 s to
about 1.4 s on its own. The cause it answers — every launch JIT-compiling Avalonia, the XAML and EF
Core's model building — is the same on every desktop, so the condition is now any RID rather than win-*.

Verified in the Alpine SDK container that reproduces CI: osx-arm64 and linux-x64 both cross-compile,
`vpk '[osx]' bundle` builds the .app with its Info.plist, and scripts/ci-linux-rpm.sh builds the RPM
with its provides and requires checks passing. Each publish lists exactly the same files with
ReadyToRun as without, because the precompiled code lives inside the managed assemblies — so the
per-dylib signing loop in release-macos.sh and the RPM's library scan have nothing new to see. macOS
runs it under the allow-jit entitlement the JIT already needed.

The cost is size, as on Windows: osx-arm64 from 141 MB to 192 MB, linux-x64 from 130 MB to 172 MB.

Not measured: the startup gain on either platform, which the csproj comment says rather than implies,
and anything about the signed and notarized macOS build, which only release-macos.sh on a Mac produces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:09:14 +02:00
jaap-janandClaude Opus 5.5 3e607f57b9 Merge claude/windows-startup-performance-a615bc: start the Windows client in under a second by precompiling the publish and taking the cache off the UI thread
ci / build and test (push) Successful in 3m52s
ci / android head (push) Successful in 4m23s
ci / api image (push) Successful in 56s
ci / deploy ssh.dodotech.cloud (push) Successful in 22s
ci / desktop nightly (push) Successful in 4m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:52:19 +02:00
jaap-janandClaude Opus 5.5 4965813b73 Start the Windows client in under a second: precompile the publish, and take the cache off the UI thread
Measured on a self-contained win-x64 publish, warm disk, median of six launches from process start: the
main window idle on its first screen went from 2.56 s to about 0.9 s, and the splash from 735 ms to
about 430 ms. Two causes, and they were most of it.

◆ THE JIT. Every launch compiled Avalonia, the XAML, EF Core's model building and our own code method by
method before anything reached the screen. The runtime pack ships precompiled; nothing else did. The
head now sets PublishReadyToRun on a win-* publish, which alone took the main window to 1.4 s. It costs
size — the publish grows from 146 MB to 175 MB, the Setup executable from 70 MB to 89 MB — and is
conditioned on the RID so build, run and the tests are untouched. Cross-compiling it on Linux works:
reproduced CI's win-x64 publish in the Alpine SDK container, and vpk pack accepts the result.

◆ THE MIGRATION RAN INLINE. EF Core's async methods over SQLite complete synchronously, so StartAsync's
awaited migration and profile read ran inside Compose's dispatcher turn: a quarter to half a second
during which the window could not be built. They now run on the thread pool, and the desktop head calls
StartAsync before building the window rather than after, so the two overlap. The Android head already
called it first and gets the thread-pool half for free.

The window can now appear a few tens of milliseconds before the profile is read, which shows the
existing "Opening the local cache…" card briefly — the state the splash was always meant to hand over
to, and one that until now was never on screen at all.

macOS and Linux would very likely gain the same from ReadyToRun; they are left out only because nothing
here has timed them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:52:07 +02:00
laurensandClaude Opus 5.5 0e6bdb75db Android: split the Spelunk sign-in and the phone's own sign-in out of ComposeShell, which MA0051 found too long
ci / build and test (push) Successful in 3m25s
ci / api image (push) Successful in 8s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m11s
ci / desktop nightly (push) Successful in 3m3s
The sign-in rework added the SpelunkAccounts argument to ComposeShell and took it to 67 lines, over
the analyzer's 60, which failed the android head job of df64012. The two delegates' construction moves
into helpers of their own, with the comments that explained them; nothing they do changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:26:14 +02:00
laurensandClaude Opus 5.5 df64012865 Merge claude/spelunk-api-key-tenant-6373cf: sign each device in to Spelunk through the browser instead of keeping Spelunk API keys in the vault
ci / android head (push) Failing after 25s
ci / build and test (push) Successful in 3m24s
ci / api image (push) Successful in 22s
ci / deploy ssh.dodotech.cloud (push) Successful in 13s
ci / desktop nightly (push) Successful in 2m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:11:02 +02:00
laurens 8543c69cca Merge remote-tracking branch 'origin/main' into claude/spelunk-api-key-tenant-6373cf 2026-10-20 17:05:56 +02:00
laurensandClaude Opus 5.5 88bd924d83 Merge claude/linux-update-check: offer an RPM only to a copy rpm owns, and never read an unknown version as older
ci / build and test (push) Successful in 3m43s
ci / api image (push) Successful in 9s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Successful in 2m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:47:01 +02:00
laurensandClaude Opus 5.5 e0509c6679 Updates: offer an RPM only to a copy rpm owns, and never read an unknown version as older
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree
at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel
then asked rpm for the installed version, got nothing because there is no rpm, read the silence as
"0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the
machine could never install, every six hours.

UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new
LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that
says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the
package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that,
UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place
of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only
where the path already matched.

CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws
instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The
comment that called the fallback a deliberate choice is replaced with the reason it was wrong.

LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the
test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a
copy rpm owns gets the channel. The newer, equal and older comparisons were already covered.
UpdateFlowTests covers the reason reaching the view model. Manual check 19.13.

App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:39:17 +02:00
12 changed files with 310 additions and 50 deletions

No files matched your search

+13
View File
@@ -3538,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2 make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
something wrote into the install directory at runtime and would survive a reinstall. something wrote into the install directory at runtime and would survive a reinstall.
### 19.13 A copy at the same path that rpm does not own is offered nothing
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
stays empty however long it runs.
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
+19 -13
View File
@@ -246,11 +246,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
knownHosts, knownHosts,
deviceKeys, deviceKeys,
// Difference 4: the system browser by intent, and the response by intent too rather than on a SignInThroughBrowser(browser),
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
.ConfigureAwait(false),
TimeProvider.System, TimeProvider.System,
connections, connections,
@@ -273,14 +269,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
updates: updates, updates: updates,
// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same spelunkAccounts: PhoneSpelunkAccounts(browser));
// registered scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as
// the account's device list does, for difference 5's reason.
spelunkAccounts: new SpelunkAccounts(
browser,
path => new AndroidRedirectCallback(path),
TimeProvider.System,
PhoneEnvironment.DeviceName));
// Started rather than awaited: framework initialisation must not block on a schema migration. The // Started rather than awaited: framework initialisation must not block on a schema migration. The
// view model shows its own progress and handles its own failures. // view model shows its own progress and handles its own failures.
@@ -289,6 +278,23 @@ public sealed partial class DodoSshApp : Avalonia.Application
return viewModel; return viewModel;
} }
/// <summary>
/// Difference 4: the system browser by intent, and the response by intent too rather than on a loopback
/// socket. See AndroidAuthorization for why the desktop's listener is not reused.
/// </summary>
private static MainWindowViewModel.SignInHandler SignInThroughBrowser(AndroidBrowserLauncher browser) =>
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
.ConfigureAwait(false);
/// <summary>
/// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same registered
/// scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as the account's
/// device list does, for difference 5's reason.
/// </summary>
private static SpelunkAccounts PhoneSpelunkAccounts(AndroidBrowserLauncher browser) =>
new(browser, path => new AndroidRedirectCallback(path), TimeProvider.System, PhoneEnvironment.DeviceName);
/// <summary> /// <summary>
/// Difference 4: where the authorization response comes back to. /// Difference 4: where the authorization response comes back to.
/// </summary> /// </summary>
+5 -5
View File
@@ -266,6 +266,11 @@ internal sealed partial class DodoSshApp : Application
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections, var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
browser, updates); browser, updates);
// Started rather than awaited, and before the window rather than after it: the migration runs on the
// thread pool, so it overlaps building the window instead of following it — see StartAsync. The view
// model shows its own progress and handles its own failures, so discarding the task here is safe.
_ = viewModel.StartAsync(CancellationToken.None);
desktop.MainWindow = new MainWindow { DataContext = viewModel }; desktop.MainWindow = new MainWindow { DataContext = viewModel };
// The other half of Program's claim on this profile: a second launch was refused, and this is what // The other half of Program's claim on this profile: a second launch was refused, and this is what
@@ -286,11 +291,6 @@ internal sealed partial class DodoSshApp : Application
viewModel.OpenLink(first); viewModel.OpenLink(first);
} }
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches, others); WireShutdown(desktop, viewModel, workspace, caches, others);
} }
@@ -89,6 +89,33 @@
that is correct for nobody. that is correct for nobody.
--> -->
<InvariantGlobalization>false</InvariantGlobalization> <InvariantGlobalization>false</InvariantGlobalization>
<!--
Precompiled, on every desktop publish, because a cold start here is mostly the JIT. The runtime
pack's own assemblies already ship precompiled; ours and every package's — Avalonia, Skia's
bindings, EF Core, SSH.NET — ship as IL, and before this every launch compiled the toolkit, the XAML
and EF's model building method by method before anything reached the screen.
Measured on a self-contained win-x64 publish, warm disk, median of six launches: the splash from
735 ms to 536 ms after the process starts, and the main window idle on its first screen from 2.6 s
to 1.4 s. The cost is size, because each method is carried as IL and as code: the publish grows from
146 MB to 175 MB and the Setup executable from 70 MB to 89 MB. That is the trade worth making for an
application opened many times a day and installed once — and it is a fifth of what dropping the
native symbols below saves, so the first install is still far smaller than it once was.
The numbers above are Windows's, and only Windows has been timed. macOS and Linux take the same
publish — osx-arm64 and linux-x64 are cross-compiled on the same Linux runner, and ReadyToRun code
lives inside the managed assemblies, so neither the per-dylib signing loop in release-macos.sh nor
the RPM's library scan sees a new file: both publishes list exactly the same files with it as
without. They grow the same way Windows's does, osx-arm64 from 141 MB to 192 MB and linux-x64 from
130 MB to 172 MB. On macOS it runs under the allow-jit entitlement the JIT already needs. Whether they gain as much is unmeasured; that they gain at all is the runtime's own
bet, since its framework assemblies ship precompiled for both.
Conditioned on the RID rather than set outright, so a plain `dotnet build`, `dotnet run` and every
test project referencing this one are untouched: ReadyToRun is a publish step and needs a RID to
target.
-->
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' != ''">true</PublishReadyToRun>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -127,17 +127,61 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
this.installedVersion = installedVersion; this.installedVersion = installedVersion;
this.handToInstaller = handToInstaller; this.handToInstaller = handToInstaller;
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal) packageName = PackageFor(channel);
? "dodossh-nightly" }
: "dodossh";
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
internal static IUpdateChannel ForInstall(string channel) =>
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
/// <summary>
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
/// reach it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The path says where this copy is and only rpm says who put it there.</b>
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
/// again every six hours.
/// </para>
/// <para>
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
/// already matched, and nothing at all anywhere else.
/// </para>
/// </remarks>
/// <param name="channel">The build's channel, which names the package.</param>
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
internal static IUpdateChannel ForInstall(
string channel,
Func<string, string?> installedVersion,
Func<IUpdateChannel> whenOwned)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
ArgumentNullException.ThrowIfNull(installedVersion);
ArgumentNullException.ThrowIfNull(whenOwned);
var package = PackageFor(channel);
return installedVersion(package) is null
? new UnavailableUpdateChannel(
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
+ "package manager that installed it.")
: whenOwned();
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// True unconditionally, because whether this copy is one that can be updated was already decided by /// True unconditionally, because whether this copy is one that can be updated was already decided by
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class /// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at /// a checkout, or installed by something other than rpm, never reaches this class at all. The same
/// composition rather than at every call site. /// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
/// every call site.
/// </remarks> /// </remarks>
public bool IsSupported => true; public bool IsSupported => true;
@@ -184,9 +228,20 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
$"The {channel} release's {manifestName} could not be read as a manifest."); $"The {channel} release's {manifestName} could not be read as a manifest.");
} }
// ◆ No answer from rpm is a failure, never a version. ForInstall only built this class because
// rpm answered, so silence now is a package removed under a running process or an rpm that
// stopped working — and any stand-in number would make every published build look newer and
// fetch a package for a machine that cannot install it, which is what this once did.
if (installedVersion(packageName) is not { } installed)
{
throw new InvalidOperationException(
$"rpm has no record of {packageName}, so there is no installed version to compare the "
+ $"{channel} release against.");
}
// The one place null is returned, and it means what null is documented to mean: this build is // The one place null is returned, and it means what null is documented to mean: this build is
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install. // current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0) if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
{ {
return null; return null;
} }
@@ -324,6 +379,12 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
? $"{RepositoryApi}/releases/tags/nightly-desktop" ? $"{RepositoryApi}/releases/tags/nightly-desktop"
: $"{RepositoryApi}/releases/latest"; : $"{RepositoryApi}/releases/latest";
/// <summary>The package a channel's builds are published as.</summary>
private static string PackageFor(string channel) =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
private static string? Asset(LinuxForgeRelease? release, string name) => private static string? Asset(LinuxForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault( release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl; asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
@@ -365,9 +426,11 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree. /// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
/// </para> /// </para>
/// <para> /// <para>
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every /// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then /// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
/// an install PackageKit declines rather than a security fix that never arrives. /// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
/// </para> /// </para>
/// </remarks> /// </remarks>
private static string? InstalledRpmVersion(string packageName) private static string? InstalledRpmVersion(string packageName)
@@ -66,21 +66,23 @@ internal static class UpdateChannels
} }
} }
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary> /// <summary>The Linux channel, if this copy is one the RPM put here.</summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process /// <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 /// lives and then asking rpm.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is /// spec puts it there — so a build running from anywhere else is a checkout somebody started, an
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be /// extracted archive, or a tree copied by hand. None of those can be updated by installing a package
/// updated by installing a package over them, and offering it would be an offer that cannot be carried /// over them, and offering it would be an offer that cannot be carried out.
/// out.
/// </para> /// </para>
/// <para> /// <para>
/// Checked by path rather than by asking rpm which package owns the executable, which would be the /// <b>The path is necessary and not sufficient.</b> It used to be the whole check, on the argument
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on /// that asking rpm cost a process launch for a distinction nothing acted on differently. Something
/// differently. The install root is a constant in two places — here and the packaging script — and /// does: a locally built pacman package that mirrors the spec puts the same tree at the same path,
/// they are named in each other's comments. /// and that copy was offered every nightly as an RPM it could never install. So a matching path is
/// then put to rpm — see <see cref="LinuxUpdateChannel.ForInstall(string)"/> — and the launch is paid
/// only where the path already matched. The install root is a constant in two places — here and the
/// packaging script — and they are named in each other's comments.
/// </para> /// </para>
/// </remarks> /// </remarks>
[SupportedOSPlatform("linux")] [SupportedOSPlatform("linux")]
@@ -91,7 +93,7 @@ internal static class UpdateChannels
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(here, root, StringComparison.Ordinal) return string.Equals(here, root, StringComparison.Ordinal)
? new LinuxUpdateChannel(DesktopChannel.Name) ? LinuxUpdateChannel.ForInstall(DesktopChannel.Name)
: new UnavailableUpdateChannel(); : new UnavailableUpdateChannel();
} }
} }
@@ -84,7 +84,10 @@
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path." Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
IsVisible="{Binding Updates.IsUnsupported}" /> IsVisible="{Binding Updates.IsUnsupportedForTheUsualReason}" />
<TextBlock Classes="settingsrowcaption"
Text="{Binding Updates.UnsupportedReason}"
IsVisible="{Binding Updates.UnsupportedReason, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -141,9 +141,32 @@ public interface IUpdateChannel
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no /// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
/// updater here" is a thing the interface can express rather than a case every call site has to remember. /// updater here" is a thing the interface can express rather than a case every call site has to remember.
/// </para> /// </para>
/// <para>
/// <b>One situation outside those four carries a <see cref="Reason"/>.</b> A Linux copy sitting exactly
/// where the RPM puts it that no RPM owns — built locally and installed by another distribution's package
/// manager. It looks installed, so the sentence about checkouts and unzipped copies would describe the
/// wrong machine, and the person reading it needs telling which updater is theirs.
/// </para>
/// </remarks> /// </remarks>
public sealed class UnavailableUpdateChannel : IUpdateChannel public sealed class UnavailableUpdateChannel : IUpdateChannel
{ {
/// <summary>Creates one for the situations that need no telling apart.</summary>
public UnavailableUpdateChannel()
{
}
/// <summary>Creates one whose situation is worth a sentence of its own.</summary>
/// <param name="reason">What to tell the user, as a sentence they can act on.</param>
public UnavailableUpdateChannel(string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
Reason = reason;
}
/// <summary>Why this copy cannot replace itself, where that is not the usual why.</summary>
public string? Reason { get; }
/// <inheritdoc /> /// <inheritdoc />
public bool IsSupported => false; public bool IsSupported => false;
@@ -3213,6 +3213,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Migrating happens before unlock and touches no encrypted content — only the shape of the tables. /// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
/// That is the point of migrating rather than recreating: a user who upgrades while offline must still /// That is the point of migrating rather than recreating: a user who upgrades while offline must still
/// be able to open their vault. /// be able to open their vault.
/// <para>
/// <b>The cache is read on the thread pool, and that is the whole of what makes this asynchronous.</b>
/// EF Core over SQLite has no real asynchronous I/O: its async methods complete synchronously, so
/// awaiting them on the UI thread ran the migration and the profile read inline, inside the caller's
/// dispatcher turn. Measured on a release build, that was a quarter to half a second of a cold start —
/// EF building its model and SQLite loading — during which the window could not be built or drawn.
/// Off the UI thread it overlaps the window's construction instead, which is why both heads call this
/// before building their view rather than after.
/// </para>
/// </remarks> /// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken) internal async Task StartAsync(CancellationToken cancellationToken)
{ {
@@ -3224,10 +3233,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
try try
{ {
paths.EnsureCreated(); // Taken here rather than inside the delegate, so that nothing on the thread pool reads this view
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true); // model's state.
var opener = Opener();
var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true); var profile = await Task.Run(
async () =>
{
paths.EnsureCreated();
await caches.MigrateAsync(cancellationToken).ConfigureAwait(false);
return await opener.ReadProfileAsync(cancellationToken).ConfigureAwait(false);
},
cancellationToken).ConfigureAwait(true);
if (profile is null) if (profile is null)
{ {
@@ -126,6 +126,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
this.restart = restart; this.restart = restart;
CurrentVersion = updates.CurrentVersion; CurrentVersion = updates.CurrentVersion;
UnsupportedReason = (updates as UnavailableUpdateChannel)?.Reason;
isAutomatic = settings.Read().AutomaticUpdateChecks; isAutomatic = settings.Read().AutomaticUpdateChecks;
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported; state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
} }
@@ -133,6 +134,16 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// <summary>What this build calls itself.</summary> /// <summary>What this build calls itself.</summary>
internal string CurrentVersion { get; } internal string CurrentVersion { get; }
/// <summary>Why this copy cannot replace itself, when the channel had something particular to say.</summary>
/// <remarks>
/// Null in the ordinary cases, where the preferences screen's own sentence about checkouts and unzipped
/// copies is the right one. See <see cref="UnavailableUpdateChannel"/> for the case where it is not.
/// </remarks>
internal string? UnsupportedReason { get; }
/// <summary>Unsupported, with nothing more particular to say than the screen's own sentence.</summary>
internal bool IsUnsupportedForTheUsualReason => IsUnsupported && UnsupportedReason is null;
[ObservableProperty] [ObservableProperty]
private UpdateState state; private UpdateState state;
@@ -248,6 +259,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
{ {
OnPropertyChanged(nameof(IsSupported)); OnPropertyChanged(nameof(IsSupported));
OnPropertyChanged(nameof(IsUnsupported)); OnPropertyChanged(nameof(IsUnsupported));
OnPropertyChanged(nameof(IsUnsupportedForTheUsualReason));
OnPropertyChanged(nameof(IsChecking)); OnPropertyChanged(nameof(IsChecking));
OnPropertyChanged(nameof(IsDownloading)); OnPropertyChanged(nameof(IsDownloading));
OnPropertyChanged(nameof(IsReady)); OnPropertyChanged(nameof(IsReady));
@@ -86,19 +86,86 @@ public sealed class LinuxUpdateChannelTests : IDisposable
} }
/// <summary> /// <summary>
/// A machine that cannot say what it has treats everything as newer. /// A machine that cannot say what it has is told so, and is offered nothing.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is /// This used to read the silence as <c>0</c> and offer everything, on the argument that the cost was
/// an install the package manager declines, and the cost of the other direction is a security fix that /// an install the package manager declined. On a machine with no rpm at all — a pacman package of the
/// never arrives. /// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing
/// could install. Unknown is not older.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer() public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
{ {
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null); var handed = new List<string>();
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.200-1",
installed: null,
handToInstaller: handed.Add);
(await channel.CheckAsync(Token)).ShouldNotBeNull(); await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
new Progress<int>(),
Token));
handed.ShouldBeEmpty();
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
}
/// <summary>
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
/// </summary>
/// <remarks>
/// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the
/// spec. No binary and no such package both arrive here as null.
/// </remarks>
[Fact]
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
{
var asked = new List<string>();
var updates = LinuxUpdateChannel.ForInstall(
"nightly",
package =>
{
asked.Add(package);
return null;
},
() => throw new InvalidOperationException("Built a channel for a copy rpm does not own."));
asked.ShouldBe(["dodossh-nightly"]);
updates.IsSupported.ShouldBeFalse();
var reason = updates.ShouldBeOfType<DodoSSH.Client.Session.UnavailableUpdateChannel>().Reason;
reason.ShouldNotBeNull();
reason.ShouldContain("outside the dodossh-nightly RPM");
reason.ShouldContain("package manager that installed it");
}
[Fact]
public void ACopyRpmOwns_GetsTheChannel()
{
var asked = new List<string>();
var owned = new FakeUpdateChannel();
var updates = LinuxUpdateChannel.ForInstall(
"release",
package =>
{
asked.Add(package);
return "0.0.1-1";
},
() => owned);
asked.ShouldBe(["dodossh"]);
updates.ShouldBeSameAs(owned);
} }
[Fact] [Fact]
@@ -80,6 +80,32 @@ public sealed class UpdateFlowTests : IDisposable
Build().CurrentVersion.ShouldBe("0.4.2"); Build().CurrentVersion.ShouldBe("0.4.2");
} }
[Fact]
public void ABuildThatCannotReplaceItself_ForAReasonOfItsOwn_SaysThatInstead()
{
var updates = new UpdateViewModel(
new UnavailableUpdateChannel("Update it with the package manager that installed it."),
new ClientSettingsStore(Paths),
TimeProvider.System,
() => 0,
restart: _ => Task.CompletedTask);
updates.IsUnsupported.ShouldBeTrue();
updates.UnsupportedReason.ShouldBe("Update it with the package manager that installed it.");
updates.IsUnsupportedForTheUsualReason.ShouldBeFalse();
}
[Fact]
public void ABuildThatCannotReplaceItself_ForTheUsualReasons_LeavesTheScreenItsOwnSentence()
{
channel.IsSupported = false;
var updates = Build();
updates.UnsupportedReason.ShouldBeNull();
updates.IsUnsupportedForTheUsualReason.ShouldBeTrue();
}
[Fact] [Fact]
public async Task ABackgroundPassThatFindsNothing_SaysNothing() public async Task ABackgroundPassThatFindsNothing_SaysNothing()
{ {