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 -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",