Put a window on screen while it starts, and refuse the second one
ci / build and test (push) Failing after 3m1s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m4s

Two things a desktop application is expected to do and this one did not.

◆ THREE SECONDS OF NOTHING.

Measured on a debug build against a cold profile: the main window arrives 3.1
seconds after the executable does. Nothing was on screen for any of it — the icon
had been double-clicked and the desktop looked unchanged.

The main window cannot come sooner. It needs a view model, and the view model needs
composition to have opened the SQLite cache, bound the terminal's loopback socket,
asked the platform about a TPM and read the install layout off disk. Its own
IsStarting card already narrates what happens after it appears; this is the part
before, which no arrangement of that window can cover.

So SplashWindow, and it is measurably the point: 668 ms to the splash, 3.1 s to the
window that replaces it.

Two mechanics are load-bearing rather than incidental.

The splash *is* desktop.MainWindow for a moment. The lifetime shows whatever that
property holds once OnFrameworkInitializationCompleted returns; a window merely
Show()n there would have no lifetime attached, and under
ShutdownMode.OnLastWindowClose the first close of anything would end the process.
Handing the property over is one assignment and Avalonia reads it lazily.

And composition is posted at DispatcherPriority.Background rather than called. It
is synchronous and slow — which is the entire argument for a splash — so running it
inline would block the very dispatcher turn that has to paint the thing.

The window is SizeToContent="Height", which is about the failure and not the
splash: CouldNotStart carries an exception's own message, and inside a fixed height
a long one would wrap and be clipped at exactly the moment that text is all the
window is for. A composition that throws is caught rather than left to the
dispatcher, for the same reason — before this there was nothing on screen to
abandon, and now there is, so an escaping exception would leave a window saying
"Starting…" over a process that had stopped. Escape closes it, and only then.

◆ AND ONE INSTANCE.

Claimed in Main before Avalonia is configured, so a second launch starts nothing at
all: no toolkit, no window, no splash flashing up before it thinks better of it.

Exclusion is an exclusive handle on instance.lock inside the profile directory, and
that location settles two questions at once. A nightly and a release build do not
exclude each other — separate profiles, ADR 0013 — and two people signed in to one
machine each get their own instance. A global mutex would have had to re-derive
both from somewhere.

A file handle rather than a named mutex because the kernel releases it however the
process ends. There is no such thing as a stale lock here, which is a state a
named mutex makes both platforms model differently and neither model simply.

The second launch is not silent. Somebody who double-clicks an icon and sees
nothing has been told the application is broken, so it rings a named pipe — named
from a digest of the profile path, so it is scoped exactly as the lock is — and the
running instance un-minimises and activates. Activate is a request and not a
guarantee: Windows refuses the foreground to a process that did not receive the
input, and the flashing taskbar button that results is still an answer.

Three seconds of retrying when the lock is held by something that does not answer
its pipe, and that window has one job. Velopack's apply ends this process and its
updater starts the new build; the two are ordered, but a lock held a moment too
long would make the successor refuse itself and vanish — an update that looked like
an uninstall. The doorbell is disposed before Shutdown for the same reason, so the
pipe name is free before the process is.

◆ VERIFIED BY RUNNING IT, WHICH IS THE ONLY WAY EITHER OF THESE COULD BE.

Two launches of the built executable: the first stayed up with instance.lock in the
profile, the second exited. Window sizes over time are where the two timings above
come from — 420x160 then 1196x776.

SingleInstanceTests covers what a single process can reach: the lock, its release
by disposal, the doorbell answering more than once, and two profiles not excluding
each other. SplashCardTests measures the one thing the splash can get wrong, which
is a failure message long enough to need the height the window is willing to give
it. Checks 1.12 and 16.11 are what needs a second executable and a real screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5 committed 2026-08-26 14:49:15 +02:00
1 parent aff776cb42
commit b8c94ecb73
12 files changed
+1044 -3

No files matched your search

@@ -0,0 +1,49 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.App.Views.SplashCard">
<!--
What is on screen while the application composes itself.
── WHY THIS IS ITS OWN CONTROL ────────────────────────────────────────────────────────────────────────
The same reason UpdateBanner is: a Window cannot be laid out by the headless suite when it contains a
WebView, and while this one does not, a UserControl is what LayoutHarness can host and measure. See
SplashCardTests. It also keeps the window itself down to a frame and a size.
── AND WHY THERE IS NO VIEW MODEL ─────────────────────────────────────────────────────────────────────
There is nothing to observe. Composition is one synchronous block — see DodoSshApp.ComposeBehind — so
this control has exactly two states, both of them set by the one caller: starting, and could not. Two
methods on the code-behind is the whole of it, and a view model would be an observable object with a
lifetime and a test of its own standing in for two assignments.
-->
<Border Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}"
BorderThickness="1">
<StackPanel Margin="28" Spacing="12" VerticalAlignment="Center">
<TextBlock Classes="heading" Text="DodoSSH" />
<TextBlock x:Name="Line" Classes="hint" Text="Starting…" />
<!--
Indeterminate, because there is nothing honest to measure. Composition opens a SQLite file, binds a
loopback socket, asks the platform about a TPM and reads an install layout off disk; none of them
reports progress and inventing a bar that crawled to 90% and waited would be a worse lie than a bar
that only says "still going".
Four pixels, matching the update banner's own and the transfers screen's, so the one moving thing in
this application looks like itself wherever it appears.
-->
<ProgressBar x:Name="Bar" Height="4" Margin="0,4,0,0"
IsIndeterminate="True"
Foreground="{StaticResource Accent}"
Background="{StaticResource Chip}" />
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,32 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>What is on screen while the application composes itself.</summary>
/// <remarks>
/// The two methods below are the whole interface, and both are called by
/// <c>DodoSshApp.ComposeBehind</c> and by nothing else. See the control's own markup for why there is no
/// view model behind it.
/// </remarks>
internal sealed partial class SplashCard : UserControl
{
public SplashCard() => InitializeComponent();
/// <summary>Says what is happening, while it still is.</summary>
internal void Say(string what) => Line.Text = what;
/// <summary>
/// Says why nothing is going to happen, and stops pretending otherwise.
/// </summary>
/// <remarks>
/// The bar stopping is half the message. A composition that threw leaves this window as the only one
/// there is — there is no main window to hand over to — so an indeterminate bar would go on saying
/// "still going" over a process that has stopped, which is the one thing a splash must never do.
/// </remarks>
internal void CouldNotStart(string why)
{
Line.Text = why;
Bar.IsIndeterminate = false;
Bar.Value = 0;
}
}
@@ -0,0 +1,47 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SplashWindow"
Title="DodoSSH"
Icon="/Assets/dodossh.ico"
Width="420"
MinHeight="160"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{StaticResource Chrome}"
SystemDecorations="None">
<!--
── WHY THERE IS A SPLASH AT ALL ───────────────────────────────────────────────────────────────────────
The main window cannot be shown until it has a view model, and the view model cannot exist until
composition has opened the SQLite cache, bound the terminal's loopback socket, asked the platform about
a TPM and read the install layout off disk. Until this window existed, all of that happened with nothing
whatever on screen: the icon had been double-clicked and the desktop looked unchanged.
The main window's own IsStarting card already narrates what happens *after* it appears — see
MainWindow.axaml — and this is the part before that, which no arrangement of the main window can cover.
── NOT TOPMOST, AND NO TASKBAR SUPPRESSION ────────────────────────────────────────────────────────────
Both are the usual splash-screen habits and both are wrong here. Topmost means a start that stalls sits
over whatever the user gave up and went back to; hiding the taskbar entry means the one place they would
look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like
one.
SystemDecorations="None" rather than BorderOnly, which the main window uses: this cannot be resized, has
no titlebar of its own to drag by, and lives for about a second. What replaces the frame is the card's
own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind.
── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ─────────────────────────────────
A fixed height is right for the one line this normally shows and wrong for the one thing that can replace
it: CouldNotStart carries an exception's own message, whose length nothing here decides. Inside a fixed
height a long one would wrap and be clipped, at exactly the moment the text is the only thing this window
is for. So the height comes from the content, and MinHeight keeps the ordinary one line from looking
cramped in a box sized for it alone.
-->
<views:SplashCard x:Name="Card" />
</Window>
@@ -0,0 +1,56 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>The window that is up while the application composes itself.</summary>
/// <remarks>
/// A frame around <see cref="SplashCard"/> and nothing else. Its whole lifetime is one call to
/// <c>DodoSshApp.ComposeBehind</c>: shown by the desktop lifetime as the main window, replaced by the real
/// one, closed. The exception is a composition that fails, which is the only case this window outlives.
/// </remarks>
internal sealed partial class SplashWindow : Window
{
/// <summary>
/// Whether Escape may close this.
/// </summary>
/// <remarks>
/// False while composition is running, and that is not caution for its own sake: this window has no
/// close button, so Escape is the only key it could offer — and closing it mid-composition would leave a
/// process that has opened the cache and bound a socket with no window and no way to reach either. Once
/// there is nothing left to wait for, it is the only way out and has to work.
/// </remarks>
private bool escapable;
public SplashWindow()
{
InitializeComponent();
KeyDown += (_, e) =>
{
if (escapable && e.Key == Key.Escape)
{
Close();
}
};
}
/// <summary>Says what is happening, while it still is.</summary>
internal void Say(string what) => Card.Say(what);
/// <summary>
/// Says why nothing is going to happen, and becomes the last window there is.
/// </summary>
/// <remarks>
/// The way out is named in the message rather than drawn as a button. A failure here is a broken
/// installation — a cache that cannot be opened, a port that cannot be bound — so what this window is
/// for at that point is carrying one sentence to whoever has to act on it, and a control that has to be
/// laid out and measured to say "close me" earns less than the words do.
/// </remarks>
internal void CouldNotStart(string why)
{
escapable = true;
Card.CouldNotStart($"DodoSSH could not start: {why} Press Escape to close.");
}
}