Let the desktop client replace itself, and give the repository one version

Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
This commit is contained in:
2026-08-04 17:04:41 +02:00
parent 176df67861
commit 6728a0a597
66 changed files with 3190 additions and 44 deletions
+27
View File
@@ -50,10 +50,37 @@ RUN dotnet restore src/DodoSSH.Api/DodoSSH.Api.csproj --locked-mode
COPY BannedSymbols.txt .editorconfig ./
COPY src/ src/
# The version, handed in rather than derived, because there is no repository in here to derive
# it from: MinVer reads git tags, and .dockerignore excludes .git/ deliberately — the context is
# the repository root and copying the whole history into every image build would be absurd.
#
# Without this the build still succeeds (MINVER1001 is a warning, and TreatWarningsAsErrors does
# not escalate a task warning), and that is the trap: the image would be built with the SDK's
# fallback version and GET /api/v1/meta would report 0.0.0-alpha.0 as its serverVersion, which is
# a lie told quietly. The tag is already parsed by the workflow for the image tags, so it is the
# same number, passed one step further.
#
# MinVerSkip because there is nothing here for it to do, and it should not warn about it either.
#
# ASSEMBLY_VERSION and emphatically not VERSION, which is the trap this block exists to avoid and
# which cost a build to find. An ARG is an environment variable for the rest of the stage, MSBuild
# reads environment variables as global properties, and property names are case-insensitive — so an
# `ARG VERSION` in a build stage silently sets MSBuild's `Version` for every project in it. With the
# workflow passing `main-<short sha>` on a main build, that is not a version the SDK will accept, and
# the publish dies with NETSDK1018 "Invalid NuGet version string" pointing at DodoSSH.Contracts, a
# project nobody changed. The name is the whole fix; the ARG in the final stage below is only ever a
# label and never meets MSBuild.
#
# The workflow passes this empty except on a tag build, so a main image keeps the SDK default rather
# than carrying a version that is not one.
ARG ASSEMBLY_VERSION=""
RUN dotnet publish src/DodoSSH.Api/DodoSSH.Api.csproj \
--no-restore \
--configuration Release \
--output /app \
-p:MinVerSkip=true \
${ASSEMBLY_VERSION:+-p:Version="$ASSEMBLY_VERSION"} \
-p:UseAppHost=false
# ---------------------------------------------------------------------------------------
+6
View File
@@ -44,6 +44,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"FastEndpoints.Attributes": {
"type": "Transitive",
"resolved": "8.2.0",
@@ -20,8 +20,14 @@
<TargetPlatformVersion>36</TargetPlatformVersion>
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
<!--
versionCode, and it stays a hand-bumped literal. Android requires a monotonically increasing
integer and SemVer does not give you one — deriving it from the version would work until the day
a patch number reached 10 and the arithmetic went backwards, which is the sort of failure that
surfaces as an upload Google refuses for reasons it will not explain.
-->
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<!--
False here for the same reason the desktop head sets it false: this process formats timestamps
@@ -30,6 +36,31 @@
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<!--
versionName, which is the string a person sees in Android's app info, taken from the same v* tag
as everything else so the two heads cannot claim different versions of one product.
It has to be a target rather than a property, and that is the whole reason this is nine lines
instead of one. MinVer computes the version in a target of its own, so at evaluation time $(Version)
is still the SDK's default 1.0.0 — an ApplicationDisplayVersion set in a PropertyGroup would read
as though it were derived and would ship 1.0.0 forever. Running after MinVer is what makes it true.
Major.Minor.Patch, without MinVer's prerelease or build-metadata parts: versionName is a free string
to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is not a version anybody can
read back to you over a support conversation.
-->
<Target Name="UseTheDerivedVersionForAndroid" AfterTargets="MinVer" DependsOnTargets="MinVer">
<PropertyGroup>
<!--
Guarded, because the failure without it is silent and absurd: MinVerMajor and its two siblings
are empty until MinVer has run, so an unguarded assignment yields the versionName ".." — three
characters that are a legal Android versionName and are what the phone would then show. Measured,
not imagined; invoking this target on its own produced exactly that.
-->
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != ''">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
</PropertyGroup>
</Target>
<ItemGroup>
<!-- The phone's control styles. The palette they draw from lives in Shell and is shared. -->
<AvaloniaResource Include="Theme/**" />
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"dodossh.client.auth": {
"type": "Project"
},
+27 -10
View File
@@ -72,6 +72,24 @@ internal sealed partial class DodoSshApp : Application
}
};
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
/// <remarks>
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
/// be listening before the renderer attaches, and a workspace handed out un-started is one whose first
/// connect fails for a reason nothing on screen would explain.
/// </remarks>
private static TerminalWorkspace StartedWorkspace(SshNetConnectionFactory connections)
{
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
return workspace;
}
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
var paths = ClientPaths.Default;
@@ -87,19 +105,17 @@ internal sealed partial class DodoSshApp : Application
// and the same host key decision, and composing two would mean two snapshots of the pins.
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
var workspace = StartedWorkspace(connections);
var browser = new SystemBrowserLauncher();
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
// Both chosen once, here, because each is a property of the machine rather than of any session. A
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
// (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0012.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var updates = UpdateChannels.ForThisMachine();
var viewModel = new MainWindowViewModel(
paths,
@@ -120,7 +136,8 @@ internal sealed partial class DodoSshApp : Application
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop));
copyToClipboard: ClipboardWriter(desktop),
updates: updates);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
@@ -5,6 +5,35 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!--
The project is named for its layer and the executable is named for the product. Users see the
executable — in Task Manager, in a shortcut's target, in the SmartScreen dialog an unsigned
installer raises — and DodoSSH.Client.App.exe reads as an implementation detail leaking out.
Safe to change now and not later: it becomes the main executable recorded in every Velopack
package, and changing it after a release means the updater looks for an executable that is no
longer there. Nothing else depends on the name — no avares://DodoSSH.Client.App URI exists, the
window icon is the assembly-relative /Assets/dodossh.ico, and WindowsDeviceKeyStore names its
CNG key with a literal.
-->
<AssemblyName>DodoSSH</AssemblyName>
<!--
No RuntimeIdentifiers here, and that is the considered answer rather than an omission.
Declaring win-x64 is the obvious way to let a self-contained publish restore, and it was tried:
it works, and it also writes a net10.0/win-x64 target into the lock file of every project this
one references transitively — which includes DodoSSH.Contracts and DodoSSH.Crypto, which the
server builds too. The API's Dockerfile then restores those projects with no RID and locked
mode, and fails NU1004 on a lock file that has grown a runtime identifier the server knows
nothing about. The desktop head's packaging would have broken the server's image build.
So the RID stays out of the committed state entirely, and the publish that needs one asks for
it unlocked — see scripts/release-windows.ps1 and the CI step that proves the same thing. The
locked restore that gates every ordinary build is untouched, which is the property worth
keeping. See docs/platform-flags.md.
-->
<!--
The icon on the executable itself — what Explorer, the Start menu and a pinned taskbar button
draw, all of which read it from the PE resource and never start the process. Window.Icon in
@@ -31,6 +60,33 @@
<AvaloniaResource Include="Assets/dodossh.ico" />
</ItemGroup>
<!--
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
Measured on a win-x64 publish: libSkiaSharp.pdb is 80.1 MB and libHarfBuzzSharp.pdb is 19.9 MB, so
the two of them are 100 MB of debug symbols for third-party native code nobody here will ever step
through. Dropping them takes the publish to about 127 MB, which is most of what a first install
costs somebody on a slow connection.
Our own symbols stay, and the distinction is the point rather than a compromise. All fifteen managed
PDBs together are 0.93 MB, and with them present an Exception.ToString() carries file names and line
numbers — which for a self-hosted product is the whole diagnostic channel, because the way a fault
gets reported is a user pasting a stack into an issue.
Named one by one instead of matched by a pattern. A rule like "drop every .pdb whose assembly is
native" would be shorter and would silently start dropping ours the day a managed library ships a
file this heuristic misreads, and a build that quietly stops carrying line numbers is not a thing
anybody notices until they need them.
-->
<Target Name="DropNativeSymbolsFromPublish" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<ResolvedFileToPublish
Remove="@(ResolvedFileToPublish)"
Condition="'%(Filename)%(Extension)' == 'libSkiaSharp.pdb'
or '%(Filename)%(Extension)' == 'libHarfBuzzSharp.pdb'" />
</ItemGroup>
</Target>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
@@ -38,6 +94,14 @@
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Controls.WebView" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<!--
Here and in no other project. The Android head must never reference it — that head's
distribution is settled by ADR 0011 and has no updater — and DodoSSH.Client.Shell is shared
between the two heads, so the one file that names Velopack lives in Platform/ beside
WindowsDeviceKeyStore, which is the same shape of thing: a Windows-only implementation of an
interface declared in DodoSSH.Client.Session.
-->
<PackageReference Include="Velopack" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,194 @@
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()
{
if (!OperatingSystem.IsWindows())
{
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 Windows 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 <c>WindowsDeviceKeyStore</c>
/// rather than in a project of its own because it is the same kind of thing — a Windows-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>
/// See <c>docs/adr/0012-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
/// 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.
/// </remarks>
private const string RepositoryUrl = "https://git.dodotech.cloud/DodoTech/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 ReleaseChannel = "win";
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;
/// <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;
internal static UpdateManager CreateManager() =>
new(
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
new UpdateOptions { ExplicitChannel = ReleaseChannel });
/// <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;
}
}
+71 -2
View File
@@ -1,5 +1,7 @@
using Avalonia;
using Avalonia.Media;
using DodoSSH.Client.Session;
using Velopack;
namespace DodoSSH.Client.App;
@@ -10,11 +12,78 @@ internal static class Program
/// </summary>
/// <remarks>
/// <c>STAThread</c> is required, not decorative: WebView2 checks the apartment state and refuses
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows.
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows. It applies to
/// everything below, which is why the Velopack call lives inside this method rather than in an entry
/// point of its own.
/// </remarks>
[STAThread]
public static void Main(string[] args) =>
public static void Main(string[] args)
{
// First, before Avalonia is even configured.
//
// The installer, the updater and the uninstaller all re-run this executable with arguments that
// mean "do the install bookkeeping and stop". Run() is what notices, does it, and exits — so on
// those runs nothing below happens at all, and that is the point rather than a side effect:
// DodoSshApp.Compose opens the SQLite cache and starts the terminal workspace's listening socket,
// and a silent installer run that reached either would be a background process holding the cache
// file open during the very file operations the installer is performing.
//
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
// before unlock and touching no encrypted content.
VelopackApp.Build().Run();
KeepTheWebViewProfileOutOfTheInstallDirectory();
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
/// </summary>
/// <remarks>
/// <para>
/// WebView2 defaults this to a directory next to the host executable. Under a Velopack install that is
/// <c>%LOCALAPPDATA%\DodoSSH.Desktop\current\</c>, and <c>current\</c> is <em>replaced</em> by every
/// update — so the browser profile would be destroyed on each one, and the first connect afterwards
/// would pay a cold WebView2 start: a new user data directory and a fresh process tree, which is the
/// slow path <c>TerminalWorkspaceOptions.RendererTimeout</c>'s fifteen seconds was sized for. It would
/// land at the exact moment somebody is most ready to believe the update broke the terminal.
/// </para>
/// <para>
/// The profile directory is the right home because Velopack never touches it — the pack id is
/// deliberately not <c>DodoSSH</c>, so the install root and <c>ClientPaths.DataDirectory</c> are
/// siblings rather than the same folder. See docs/adr/0012-desktop-distribution-and-updates.md.
/// </para>
/// <para>
/// An environment variable rather than the control's own options, because it is read by the WebView2
/// loader before any of this application's UI exists, and because it needs no reference to whichever
/// WebView package the terminal happens to be hosted by.
/// </para>
/// </remarks>
private static void KeepTheWebViewProfileOutOfTheInstallDirectory()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2");
try
{
Directory.CreateDirectory(folder);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", folder);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Left unset, which puts the profile back beside the executable. That is a slow first connect
// after each update, not a broken terminal, and refusing to start an SSH client over it would
// be the wrong trade.
}
}
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
/// <remarks>
+19 -2
View File
@@ -70,7 +70,7 @@
fires only for its own IsVisible.
-->
<Grid RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="Auto,*,Auto,Auto">
<views:TitleBar Grid.Row="0" />
@@ -334,7 +334,24 @@
</Panel>
<views:StatusBar Grid.Row="2" />
<!--
A fourth row, and a row rather than an overlay for the reason the occlusion rule above gives: this
appears while a terminal may be open, and anything drawn in the WebView's rectangle is sliced. Taking
height from the row above moves the native control's bounds instead of covering it, which is the one
arrangement that works — the same one TitleBar and StatusBar already rely on.
It is a separate control because nothing in this file can be measured by a test, and a strip with two
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
edge. See UpdateBanner.axaml.
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
announcing an update that does not exist.
-->
<views:UpdateBanner Grid.Row="2"
IsVisible="{Binding Updates.IsBannerShowing, FallbackValue=False}" />
<views:StatusBar Grid.Row="3" />
</Grid>
@@ -61,6 +61,79 @@
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="UPDATES" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
Not on the design at all, unlike everything else here. It arrived with packaging: an installed
client can replace itself, and the moment that is true the question of where a replacement comes
from stops being theoretical. The answer is the security content of this section rather than a
footnote to it, which is why it is printed under the version instead of hidden in a tooltip.
-->
<TextBlock Classes="mono" Text="{Binding Updates.CurrentVersion}" FontSize="12" Margin="0,8,0,0"
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Margin="0,4,0,0"
Text="Builds come from the project's own release page, and never from the server you sign in to. That is deliberate: whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Check for updates" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Asks the release page whether there is a newer build, and downloads it if there is. Nothing is ever installed while you are using it — a downloaded update waits for a restart you ask for, or for the next time you start DodoSSH." />
</StackPanel>
<Button x:Name="CheckNowButton" Grid.Column="1" Classes="ghost" Content="CHECK NOW"
Command="{Binding Updates.CheckNowCommand}"
IsEnabled="{Binding Updates.CanCheckNow}" />
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Check on its own" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
</StackPanel>
<CheckBox x:Name="AutomaticUpdatesToggle" Grid.Column="1" VerticalAlignment="Top"
IsChecked="{Binding Updates.IsAutomatic}"
IsEnabled="{Binding Updates.IsSupported}" />
</Grid>
<!-- The only other ProgressBar in the application is the transfers one; same height, same brushes. -->
<ProgressBar Height="4" Minimum="0" Maximum="100" Margin="0,12,0,0"
Value="{Binding Updates.DownloadPercent}"
Foreground="{StaticResource Accent}" Background="{StaticResource Raised}"
IsVisible="{Binding Updates.IsDownloading}" />
<!--
The restart, with the sentence the banner only has room for in a tooltip. This screen scrolls, so
this is where the warning can be as long as it needs to be — and it needs to be, because this
application has spent a lot of words teaching that locking keeps shells running.
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0" IsVisible="{Binding Updates.IsReady}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="{Binding Updates.ReadyHeadline}" Foreground="{StaticResource Text}"
FontSize="13" FontWeight="Medium" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding Updates.RestartWarning}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="RESTART NOW"
Command="{Binding Updates.RestartNowCommand}" />
</Grid>
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="{Binding Updates.Status}"
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
The HasNoDeviceKeyOption precedent, one section up: a machine that gets none of the above is told
why rather than shown three controls that cannot do anything.
-->
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="This copy of DodoSSH cannot replace itself, so none of the 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}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
@@ -185,7 +258,7 @@
<TextBlock Classes="gap"
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
<TextBlock Classes="gap"
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
Text="A beta channel — there is one release channel, and a switch offering a second would be a preference with nothing behind it." />
<TextBlock Classes="gap"
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
<TextBlock Classes="gap"
@@ -0,0 +1,82 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.UpdateBanner"
x:DataType="vm:UpdateViewModel">
<!--
The strip that says a newer build has been fetched, and asks when.
── WHERE IT LIVES, WHICH IS THE WHOLE DESIGN ──────────────────────────────────────────────────────────
A row of MainWindow's root grid, between the content and the status bar. Not an overlay, and that is not
a preference: NativeWebView hosts a real Win32 child window that composites above everything Avalonia
paints in the same rectangle, so anything drawn over the terminal is sliced at its left edge with its
buttons unreachable — a defect this window has shipped once. See the occlusion rule in MainWindow.axaml
and docs/platform-flags.md.
A sibling row is the arrangement that is already proven here twice over: TitleBar sits above the
terminal and StatusBar below it, and both draw and take clicks correctly. Taking height from the row the
WebView is in moves its bounds rather than covering it, which is what NativeControlHost re-pushes on
layout.
The cost, stated rather than discovered: the terminal gets 48 fewer pixels while this is up, so the grid
reflows and the remote is told it has fewer rows. That is the same reflow any window resize causes and
the renderer already handles it — and the alternative is the arrangement that does not work at all.
── WHY IT IS ITS OWN FILE ─────────────────────────────────────────────────────────────────────────────
Nothing inside MainWindow can be laid out by a test — WebView2's adapter refuses the headless session's
thread, see LayoutHarnessTests.WhyTheWindowItselfIsNeverShown — so markup left there is markup nobody can
measure. This control has two buttons and a version string of unknown length in one fixed-height row,
which is exactly the shape that arranges something off the right edge. UpdateBannerTests measures it.
── ONE LINE HIGH ──────────────────────────────────────────────────────────────────────────────────────
Fixed height and trimmed rather than wrapped, for the reason StatusBar gives about itself and with more
force: a message that grew this row would shrink the terminal further, and it would do it while somebody
is reading it.
-->
<Border Height="48" Background="{StaticResource AccentWash}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="14,0">
<Border Grid.Column="0" Classes="chip" BorderBrush="{StaticResource Accent}">
<TextBlock Classes="mono" Text="UPDATE" FontSize="10" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Accent}" />
</Border>
<TextBlock Grid.Column="1" Margin="12,0,16,0" VerticalAlignment="Center"
FontSize="13" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis"
Text="{Binding ReadyHeadline}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<!--
LATER is honest, which is what makes it safe to offer at all. The dismissal lasts this run, the
preferences row goes on offering the restart, and the build that has already been fetched is what
starts next time regardless — so nothing is given up by pressing it, and the tooltip says so
rather than leaving somebody to wonder whether they have just refused the update.
-->
<Button Classes="ghost" Content="LATER" Command="{Binding DismissBannerCommand}"
ToolTip.Tip="Hides this until the next launch. The update is already downloaded and will be running the next time you start DodoSSH, so nothing is lost by waiting." />
<!--
The warning is on the tooltip rather than in the strip because it is a sentence and this is one
line — and because its whole job is to be read before the button is pressed, not after. The long
form is on the preferences screen, which scrolls.
-->
<Button x:Name="RestartNowButton" Classes="accent" Content="RESTART NOW"
Command="{Binding RestartNowCommand}"
ToolTip.Tip="{Binding RestartWarning}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
internal sealed partial class UpdateBanner : UserControl
{
public UpdateBanner() => InitializeComponent();
}
+12
View File
@@ -1,5 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!--
Both attributes here are side-by-side activation fields, and this application does not use
side-by-side activation, so nothing reads either of them. They are deliberately not kept in step
with anything.
The version a person sees comes from the PE version resource, which MSBuild fills from
FileVersion, which MinVer fills from the nearest v* tag. The version an update decision is made on
is the one baked into the Velopack package. Neither passes through here, and wiring this attribute
to the real version would be machinery maintaining a value with no reader.
The name likewise stays DodoSSH.Client.App even though the assembly is now called DodoSSH.
-->
<assemblyIdentity version="1.0.0.0" name="DodoSSH.Client.App" />
<!--
+12
View File
@@ -72,6 +72,18 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Velopack": {
"type": "Direct",
"requested": "[1.2.0, )",
"resolved": "1.2.0",
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.27548.20260419",
@@ -13,6 +13,12 @@
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
}
}
}
@@ -13,6 +13,12 @@
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
}
}
}
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"dodossh.client.domain": {
"type": "Project"
}
@@ -29,6 +29,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
+59 -3
View File
@@ -19,8 +19,38 @@ namespace DodoSSH.Client.Session;
/// belongs in the cache, behind the passphrase.
/// </para>
/// </remarks>
public sealed record ClientSettings
/// <param name="TerminalFontSize">The terminal font size, in CSS pixels.</param>
/// <param name="AutomaticUpdateChecks">
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
/// </param>
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
That is not a style choice it is the only place System.Text.Json will read them from, and getting
it wrong fails silently.
What the source generator emits for a record is an ObjectWithParameterizedConstructorCreator: it
treats the init-only properties as constructor arguments and builds the object as
new ClientSettings() { TerminalFontSize = (int)args[0], AutomaticUpdateChecks = (bool)args[1] }
so a property initializer does run and is then immediately overwritten by args, which for a member
absent from the JSON is the CLR default. Measured before this was fixed: a settings file of {} read
back TerminalFontSize 0, clamped to the 8px floor rather than the 13px the renderer draws at, and
AutomaticUpdateChecks false. Reflection-based deserialisation of the same JSON answers 13 and true,
which is what makes it so easy to miss. Setting GenerationMode to Metadata does not help either; it
was tried.
Constructor parameter defaults are carried into the generated metadata, so absence resolves to the
value declared here. ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault fails without this.
The type is still used exactly as before new ClientSettings(), and `with` expressions because
every parameter is optional.
*/
/// <summary>The size a terminal draws at with nothing stored.</summary>
/// <remarks>
/// Matches the renderer's own default, and has to: the page creates panes at its own constant until
@@ -43,8 +73,34 @@ public sealed record ClientSettings
/// </remarks>
public const int MaximumTerminalFontSize = 32;
/// <summary>The terminal font size, in CSS pixels.</summary>
public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;
/*
AutomaticUpdateChecks: why it is on by default, and the three neighbours it deliberately does not
have. Prose rather than XML doc because the property is declared in the parameter list above, and a
param tag is the wrong shape for several paragraphs.
On by default. A client that quietly runs a year behind is the failure ADR 0011 names as the real
cost of distributing outside a store, and the desktop has the mechanism to avoid it so the
default should use it. It costs little to leave on, because a check downloads and never installs:
nothing happens to a running application except a line offering a restart.
Three neighbours this deliberately does not have, because each would be a mistake worth
naming rather than an omission.
No feed address. Where builds come from is a constant in the code, and ADR 0011 rule 2 is
why: an operator who could point this at themselves could pin a chosen user to a build with a known
hole. This file is plaintext and hand-editable, which is exactly what makes it the wrong home for
that value.
No last-checked timestamp. The rule above about nothing secret is not only about secrets
a record of when this machine last contacted the project's forge is a small fact about a person
that this file does not otherwise carry. It also buys little: the check runs on a timer measured
from launch, so any machine that runs at all is current within hours. What the screen shows is
remembered for the session and no longer.
No channel switch. There is one release channel. A toggle offering a second would be a
preference with nothing behind it, which is the thing the preferences screen's own opening comment
warns against.
*/
/// <summary>Brings a value inside the range this type will store.</summary>
public static int ClampTerminalFontSize(int pixels) =>
+166
View File
@@ -0,0 +1,166 @@
using System.Reflection;
namespace DodoSSH.Client.Session;
/// <summary>
/// A build newer than the one running.
/// </summary>
/// <remarks>
/// A version string and nothing else, so that whatever the update framework hands back never crosses this
/// seam. The implementation keeps its own richer answer privately and matches on the version when it is
/// handed one of these back; everything above only ever needs the number to print.
/// </remarks>
/// <param name="Version">What the newer build calls itself.</param>
public sealed record AvailableUpdate(string Version);
/// <summary>
/// Where newer builds of this client come from.
/// </summary>
/// <remarks>
/// <para>
/// <b>The release channel is the project's own, and never the deployment the client is signed in to.</b>
/// That is ADR 0011 rule 2 — see <c>docs/adr/0011-android-distribution.md</c> — and it is a security
/// property rather than a preference:
/// an operator who could answer the update check could pin a chosen user to a build with a known hole, by
/// withholding the answer, without holding any key at all. The implementation's feed address is therefore
/// a constant and not a setting, and this interface has no member that would let a caller name one.
/// </para>
/// <para>
/// <b>An interface rather than a delegate</b>, which is a deliberate departure from the shell's habit —
/// <c>SignInHandler</c>, <c>resume</c> and <c>copyToClipboard</c> are all delegates so that the view models
/// stay drivable by a test with no network. That shape fits a single call. This is four operations, an
/// availability question and an order they have to happen in, and four delegates in a constructor is worse
/// than one interface. <see cref="IDeviceKeyStore"/> is the same shape in the same assembly and settled the
/// same trade.
/// </para>
/// <para>
/// Nothing here throws for the ordinary failures. A forge that cannot be reached is a laptop on a train,
/// not a fault, and the caller's answer is to try again later.
/// </para>
/// </remarks>
public interface IUpdateChannel
{
/// <summary>Whether this copy of the client is one that can replace itself.</summary>
/// <remarks>
/// False for a build run from a source checkout, for a copy somebody unzipped by hand, and for every
/// platform this has not been built for. Asked before offering anything, because an offer that cannot
/// be carried out is worse than no offer.
/// </remarks>
bool IsSupported { get; }
/// <summary>What this build calls itself.</summary>
/// <remarks>
/// Answered even when <see cref="IsSupported"/> is false. Which version is running is a fact worth
/// printing on a build that cannot update itself — arguably more so, since somebody will have to
/// replace it by hand.
/// </remarks>
string CurrentVersion { get; }
/// <summary>Asks the release channel whether there is anything newer.</summary>
/// <returns>
/// The newer build, or <see langword="null"/> if this one is current. Null is also the honest answer
/// when the channel cannot be reached at all: the caller does the same thing either way, and an
/// unreachable forge is not a state a user can act on.
/// </returns>
Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken);
/// <summary>Fetches an update a previous <see cref="CheckAsync"/> found.</summary>
/// <param name="update">The update to fetch. Must be one this source returned.</param>
/// <param name="progress">Reports percentage complete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DownloadAsync(AvailableUpdate update, IProgress<int> progress, CancellationToken cancellationToken);
/// <summary>
/// Swaps this build for the downloaded one and starts it.
/// </summary>
/// <remarks>
/// <b>This does not return.</b> It is the reason the whole capability is behind an interface rather
/// than being three lines inline in the view model: a test cannot drive a method that ends the process,
/// and the single most important thing to be able to assert about this feature is that a downloaded
/// update is <em>never</em> applied unless somebody asked for it. A fake that records the call instead
/// of making it is what makes that assertion writable.
/// </remarks>
/// <param name="update">The update to apply. Must be one that has been downloaded.</param>
void ApplyAndRestart(AvailableUpdate update);
}
/// <summary>
/// A copy of the client that cannot replace itself.
/// </summary>
/// <remarks>
/// <para>
/// Four different situations resolve to this one object, and they are deliberately not distinguished: a
/// build started from a source checkout, a copy extracted by hand from an archive, a desktop platform this
/// has not been packaged for, and the phone — whose distribution is settled separately by ADR 0011 and has
/// no updater at all. All four mean the same thing. <em>This copy was not installed by anything that knows
/// how to replace it</em>, and saying so is different from pretending otherwise.
/// </para>
/// <para>
/// A null object rather than a nullable field on the view model, following
/// <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.
/// </para>
/// </remarks>
public sealed class UnavailableUpdateChannel : IUpdateChannel
{
/// <inheritdoc />
public bool IsSupported => false;
/// <inheritdoc />
public string CurrentVersion => ClientVersion.Current;
/// <inheritdoc />
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
Task.FromResult<AvailableUpdate?>(null);
/// <inheritdoc />
public Task DownloadAsync(
AvailableUpdate update,
IProgress<int> progress,
CancellationToken cancellationToken) =>
throw new NotSupportedException(
"This copy of DodoSSH cannot replace itself. Check IsSupported before offering an update.");
/// <inheritdoc />
public void ApplyAndRestart(AvailableUpdate update) =>
throw new NotSupportedException(
"This copy of DodoSSH cannot replace itself. Check IsSupported before offering an update.");
}
/// <summary>
/// What this build calls itself.
/// </summary>
/// <remarks>
/// <para>
/// Read from the assembly rather than from the update framework, so that it answers on a build that has no
/// updater — a source checkout, or the phone. The version a user is shown should not depend on whether the
/// thing that could replace it happens to be present.
/// </para>
/// <para>
/// The informational version is the one that carries the whole number, including any prerelease part;
/// <c>AssemblyVersion</c> is <c>major.0.0.0</c> by MinVer's design and would print <c>0.0.0</c> for every
/// 0.x build. The <c>+sha</c> build metadata is trimmed because it is for a machine, and this string is
/// read aloud in support conversations.
/// </para>
/// </remarks>
public static class ClientVersion
{
/// <summary>The running build's version, without build metadata.</summary>
public static string Current { get; } = Read();
private static string Read()
{
var informational = (Assembly.GetEntryAssembly() ?? typeof(ClientVersion).Assembly)
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (string.IsNullOrWhiteSpace(informational))
{
return "0.0.0";
}
var plus = informational.IndexOf('+', StringComparison.Ordinal);
return plus < 0 ? informational : informational[..plus];
}
}
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
@@ -47,8 +47,14 @@
The view models are internal, as they were when they lived in the desktop head, and both heads plus
the two shell suites are let in explicitly. Making them public instead would turn every rename of a
command into a compatibility question about an assembly nobody consumes.
The first entry is an assembly name and not a project name, which is why it does not read
DodoSSH.Client.App: that project sets AssemblyName to DodoSSH, so the executable is named for the
product rather than for its layer. The two suites below keep their project names because they do
not set one. Getting this wrong does not fail here — it fails as a wall of CS0122 in the head,
naming every view model and explaining none of it.
-->
<InternalsVisibleTo Include="DodoSSH.Client.App" />
<InternalsVisibleTo Include="DodoSSH" />
<InternalsVisibleTo Include="DodoSSH.Client.Android" />
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
@@ -288,6 +288,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly VaultsViewModel vaults;
/// <summary>
/// Where newer builds come from, and how far one has got.
/// </summary>
/// <remarks>
/// A process-lifetime object like <see cref="transfers"/>, and for a reason that is its own rather than
/// borrowed: this one outlives a lock because the release channel is not the vault.
/// </remarks>
private readonly UpdateViewModel updateScreen;
/// <summary>
/// The tab standing in for each connection that has been asked for and has not answered yet.
/// </summary>
@@ -347,6 +356,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// What to call this machine. Optional, and the default is right for every head that runs on a desktop
/// operating system — see the field it is kept in for the one that it is not right for.
/// </param>
/// <param name="updates">
/// Where newer builds of this client come from. Optional, and the default is a channel that reports
/// itself unavailable — which is a deliberate difference from <paramref name="deviceKeys"/>, which every
/// head passes explicitly. With an optional parameter, "the phone has no updater" is enforced by the
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
/// </param>
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -359,7 +375,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null,
string? deviceName = null)
string? deviceName = null,
IUpdateChannel? updates = null)
{
this.paths = paths;
this.caches = caches;
@@ -404,6 +421,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
settings = new ClientSettingsStore(paths);
updateScreen = CreateUpdateScreen(updates);
// Read straight away rather than at first use, so the value is right before anything can read it —
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
// as the interface correcting itself.
@@ -412,6 +431,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = TellRendererTheFontSizeAsync();
}
/// <summary>
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
/// </summary>
/// <remarks>
/// A method rather than four more lines in the constructor, because the restart delegate needs a
/// paragraph of its own and the constructor is already at the length the analyzers allow.
/// </remarks>
private UpdateViewModel CreateUpdateScreen(IUpdateChannel? updates)
{
// The channel is captured rather than reached through the view model, which keeps the restart
// delegate free of a reference to the object it is being handed to.
var channel = updates ?? new UnavailableUpdateChannel();
return new UpdateViewModel(
channel,
settings,
clock,
() => workspace.LiveSessionCount,
// Everything this application does on the way out, and only then the swap. Applying an update
// ends the process, and disposing this view model is what zeroes the identity keys, the vault
// keys and the cache key — so the other order would leave them sitting in a memory image the
// installer is about to write over, and would abandon a transfer still writing to a part file.
restart: async update =>
{
await DisposeAsync().ConfigureAwait(true);
channel.ApplyAndRestart(update);
});
}
/// <remarks>
/// <para>
/// The page starts at its own default and has no way to know what was stored, so somebody has to tell
@@ -568,6 +618,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal TransfersViewModel Transfers => transfers;
/// <summary>Where newer builds come from, which the window binds whether or not a vault is open.</summary>
/// <remarks>
/// Bound from the titlebar's banner and from the preferences screen, and it answers on a locked shell
/// too — the banner is drawn outside the unlocked half of the window on purpose, because a machine left
/// locked overnight is exactly the one that will have found an update by morning.
/// </remarks>
internal UpdateViewModel Updates => updateScreen;
/// <summary>
/// Shells that were left running when the vault was locked.
/// </summary>
@@ -1625,6 +1683,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken)
{
// Before anything that can return early, and outside the try: looking for a newer build does not
// depend on there being a profile, a server or a vault, and a machine that never gets past the setup
// screen is still one that should not be running a build with a hole in it. Start() is a no-op on a
// copy that cannot replace itself.
updateScreen.Start();
try
{
paths.EnsureCreated();
@@ -2467,6 +2531,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
// vault because the restart path disposes this whole object and then applies the update — so a
// check still running would be writing into a view model the process is about to replace.
await updateScreen.DisposeAsync().ConfigureAwait(false);
knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
@@ -0,0 +1,412 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// Where an update has got to.
/// </summary>
/// <remarks>
/// An enum rather than a handful of booleans, for the reason <c>ShellSurface</c> gives: there is then no
/// way to write the state where two of these are true at once.
/// <para>
/// <b>There is deliberately no <c>Available</c> member.</b> The policy is to check and fetch in one motion,
/// so "found but not yet fetched" is a state nobody is ever looking at — and a state with nothing that can
/// be in it would describe a different product, one that asks permission before using the network. If a
/// reason to pause between the two ever arrives — a metered connection is the obvious one — that is when
/// the member earns its place, and the shape of this enum is the record of it not having arrived yet.
/// </para>
/// </remarks>
internal enum UpdateState
{
/// <summary>This copy cannot replace itself, so none of the rest can happen.</summary>
Unsupported = 0,
/// <summary>Nothing in progress.</summary>
Idle = 1,
/// <summary>Asking the release channel.</summary>
Checking = 2,
/// <summary>Fetching a newer build.</summary>
Downloading = 3,
/// <summary>Fetched, and waiting for somebody to say when.</summary>
Ready = 4,
/// <summary>Something the user asked for did not work.</summary>
Failed = 5,
}
/// <summary>
/// Looks for newer builds, fetches them, and waits to be told when.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here ever installs anything on its own.</b> A fetched update sits until the user presses
/// restart, or until the application is next started for their own reasons. That is the whole policy, and
/// it is a policy rather than an implementation detail: this application deliberately keeps shells running
/// across a lock — see <c>MainWindowViewModel.LockAsync</c>, which argues that a lock destroying work would
/// simply stop being used — and a restart does not keep them. Something that took the decision away would
/// be ending a person's session to save them a click.
/// </para>
/// <para>
/// <b>It lives as long as the process, not as long as a vault.</b> Unlike the screens built per unlock, and
/// unlike <c>VaultViewModel</c>'s own sync loop, where builds come from has nothing to do with whether a
/// keychain is open — so this is constructed once and disposed at shutdown, and its loop keeps running
/// while the vault is locked. A laptop left locked for a week should still come back current.
/// </para>
/// <para>
/// <b>It does not speak through <c>Announce</c> or the vault's status line.</b> Those carry saves, syncs,
/// refusals and conflicts, and an update has nothing to do with any of them; the banner appearing is the
/// announcement. Hence a <see cref="Status"/> of its own, which the preferences screen reads.
/// </para>
/// </remarks>
internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposable
{
/// <summary>How often to look, once the first pass has happened.</summary>
/// <remarks>
/// Six hours. This is a request against the project's own forge for a product that ships rarely, so
/// hourly would be traffic without information; a day would mean a machine that is only ever awake in
/// the morning could sit a week behind.
/// </remarks>
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
/// <summary>How long to wait before the first pass.</summary>
/// <remarks>
/// A delay, where <c>VaultViewModel</c>'s sync loop runs a pass immediately. The difference is what the
/// user is waiting for: a vault edited on another machine should be current by the time they have
/// finished reading the list, whereas nothing anybody does in their first two minutes depends on an
/// update. Launch is already contending for the network and the CPU with a schema migration, a resumed
/// sign-in and a first sync, at the one moment somebody is watching the window.
/// </remarks>
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2);
private readonly IUpdateChannel updates;
private readonly ClientSettingsStore settings;
private readonly TimeProvider clock;
/// <remarks>
/// A function rather than the workspace itself, so this view model needs no terminal to exist and a
/// test can say "three shells are open" without opening any.
/// </remarks>
private readonly Func<int> liveSessionCount;
/// <remarks>
/// What to do when the user presses restart. See <see cref="RestartNowAsync"/> for why this is not
/// simply a call into the channel.
/// </remarks>
private readonly Func<AvailableUpdate, Task> restart;
private readonly CancellationTokenSource lifetime = new();
private Task? loop;
private AvailableUpdate? ready;
private bool disposed;
internal UpdateViewModel(
IUpdateChannel updates,
ClientSettingsStore settings,
TimeProvider clock,
Func<int> liveSessionCount,
Func<AvailableUpdate, Task> restart)
{
this.updates = updates;
this.settings = settings;
this.clock = clock;
this.liveSessionCount = liveSessionCount;
this.restart = restart;
CurrentVersion = updates.CurrentVersion;
isAutomatic = settings.Read().AutomaticUpdateChecks;
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
}
/// <summary>What this build calls itself.</summary>
internal string CurrentVersion { get; }
[ObservableProperty]
private UpdateState state;
[ObservableProperty]
private string? readyVersion;
[ObservableProperty]
private int downloadPercent;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool isAutomatic;
/// <remarks>
/// Per run, and deliberately not persisted. LATER means "not now" and must not quietly come to mean
/// "never": the preferences row goes on offering the restart, and the build that was fetched is applied
/// at the next ordinary launch whatever this says.
/// </remarks>
[ObservableProperty]
private bool isBannerDismissed;
[ObservableProperty]
private DateTimeOffset? lastChecked;
internal bool IsSupported => State is not UpdateState.Unsupported;
internal bool IsUnsupported => State is UpdateState.Unsupported;
internal bool IsChecking => State is UpdateState.Checking;
internal bool IsDownloading => State is UpdateState.Downloading;
internal bool IsReady => State is UpdateState.Ready;
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
internal bool IsBannerShowing => IsReady && !IsBannerDismissed;
/// <summary>What the banner says.</summary>
internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
? $"DodoSSH {version} is ready to install."
: "An update is ready to install.";
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
/// <remarks>
/// The contrast is the point. This application tells people in several places that locking keeps their
/// shells running — it is the reason locking is safe to use mid-job — so the one moment that stops being
/// true is a moment it owes them a sentence. The close button's tooltip already says the same thing in
/// the same words.
/// </remarks>
internal string RestartWarning
{
get
{
var open = liveSessionCount();
return open switch
{
0 => "Nothing is connected, so this closes and reopens straight away.",
1 => "Restarting closes the shell you have open. A lock keeps shells running; a restart does not.",
_ => string.Create(
CultureInfo.CurrentCulture,
$"Restarting closes the {open} shells you have open. A lock keeps shells running; a restart does not."),
};
}
}
/// <summary>When this run last asked, for as long as this run lasts.</summary>
internal string LastCheckedSummary => LastChecked is { } at
? $"Last checked {at.ToLocalTime().ToString("f", CultureInfo.CurrentCulture)}."
: "Not checked yet.";
partial void OnStateChanged(UpdateState value)
{
OnPropertyChanged(nameof(IsSupported));
OnPropertyChanged(nameof(IsUnsupported));
OnPropertyChanged(nameof(IsChecking));
OnPropertyChanged(nameof(IsDownloading));
OnPropertyChanged(nameof(IsReady));
OnPropertyChanged(nameof(CanCheckNow));
OnPropertyChanged(nameof(IsBannerShowing));
OnPropertyChanged(nameof(RestartWarning));
}
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
/// <remarks>
/// Read-modify-write against the file rather than against a field, so a setting this build does not
/// know about — written by a newer one, or by hand — survives this one storing its own.
/// </remarks>
partial void OnIsAutomaticChanged(bool value) =>
settings.Write(settings.Read() with { AutomaticUpdateChecks = value });
/// <summary>Starts looking, on a timer.</summary>
/// <remarks>
/// Called from the shell's own start rather than from the constructor, so that constructing this object
/// starts nothing — which is what lets a test drive <see cref="CheckOnceAsync"/> a pass at a time
/// instead of racing a timer.
/// </remarks>
internal void Start()
{
if (!updates.IsSupported || loop is not null)
{
return;
}
loop = RunCheckLoopAsync(lifetime.Token);
}
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
{
try
{
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
using var timer = new PeriodicTimer(CheckInterval, clock);
do
{
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
}
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
}
catch (OperationCanceledException)
{
// Shutdown.
}
}
/// <summary>One pass of the loop.</summary>
/// <remarks>
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
/// nobody asked for may only speak when it has something to say.
/// </remarks>
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
{
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
{
return;
}
try
{
await FetchAsync(cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Deliberately silent, and deliberately back to Idle rather than Failed: Failed is for
// something a person is waiting on an answer to.
State = UpdateState.Idle;
}
}
/// <remarks>
/// The answer always arrives, including "you are on the latest build", because somebody pressed a
/// button and a button that appears to do nothing is worse than one that reports no news.
/// </remarks>
[RelayCommand]
private async Task CheckNowAsync(CancellationToken cancellationToken)
{
if (!CanCheckNow)
{
return;
}
try
{
var found = await FetchAsync(cancellationToken).ConfigureAwait(true);
if (found is null)
{
Status = $"DodoSSH {CurrentVersion} is the latest build.";
}
}
catch (OperationCanceledException)
{
State = UpdateState.Idle;
Status = "Cancelled.";
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
State = UpdateState.Failed;
Status = exception.Message;
}
}
/// <summary>Checks, and fetches whatever it finds.</summary>
/// <returns>The update found, or null.</returns>
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
{
State = UpdateState.Checking;
Status = string.Empty;
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
LastChecked = clock.GetUtcNow();
if (found is null)
{
State = UpdateState.Idle;
return null;
}
State = UpdateState.Downloading;
DownloadPercent = 0;
var progress = new Progress<int>(percent => DownloadPercent = percent);
await updates.DownloadAsync(found, progress, cancellationToken).ConfigureAwait(true);
DownloadPercent = 100;
ready = found;
ReadyVersion = found.Version;
IsBannerDismissed = false;
State = UpdateState.Ready;
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
return found;
}
/// <remarks>
/// <para>
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
/// reason is ordering: applying ends the process, and the vault has to be disposed first because that
/// is what zeroes the identity keys, the vault keys and the cache key. This view model does not know
/// about any of that and should not learn.
/// </para>
/// <para>
/// A delegate taken in the constructor, like <c>TransfersViewModel</c>'s <c>addBucket</c> and the
/// shell's own sign-in handler. An event would have been the other option and is worse here: there is
/// exactly one subscriber, it is known at construction, and a second one would mean two things racing
/// to end the same process.
/// </para>
/// </remarks>
[RelayCommand]
private async Task RestartNowAsync()
{
if (ready is not { } update)
{
return;
}
await restart(update).ConfigureAwait(true);
}
[RelayCommand]
private void DismissBanner() => IsBannerDismissed = true;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await lifetime.CancelAsync().ConfigureAwait(false);
if (loop is { } running)
{
// Awaited rather than abandoned, so that a pass in flight is finished with before the
// application tears down what it is writing into.
await running.ConfigureAwait(false);
}
lifetime.Dispose();
}
}
@@ -31,6 +31,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"NSec.Cryptography": {
"type": "Direct",
"requested": "[26.4.0, )",
@@ -60,6 +60,12 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
@@ -14,6 +14,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
+6
View File
@@ -25,6 +25,12 @@
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
}
}
}
+6
View File
@@ -20,6 +20,12 @@
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"NSec.Cryptography": {
"type": "Direct",
"requested": "[26.4.0, )",
+6
View File
@@ -13,6 +13,12 @@
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
}
}
}
@@ -45,6 +45,12 @@
"Newtonsoft.Json": "13.0.3"
}
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
"resolved": "7.0.0",
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
},
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"type": "Direct",
"requested": "[10.0.3, )",