Public Access
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>
129 lines
5.2 KiB
C#
129 lines
5.2 KiB
C#
using Avalonia.Controls;
|
|
using DodoSSH.Client.App.Views;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// The splash, and the one thing about it that can go wrong.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Three stacked elements in a fixed-width window is not a layout with much to get wrong — except in one
|
|
/// state. <c>SplashWindow.CouldNotStart</c> carries an exception's own message, whose length nothing in this
|
|
/// application decides, and that message is the whole reason that window is still on screen. Inside a fixed
|
|
/// height it would wrap and be clipped at exactly the wrong moment.
|
|
/// </para>
|
|
/// <para>
|
|
/// So what is measured here is that the message grows the card, and that the window is declared in a way
|
|
/// that lets the growth through. <c>LayoutHarness.Unreachable</c> is no help: it reports interactive controls
|
|
/// arranged where they cannot be used, and the splash has none — which is why these two assert on measured
|
|
/// heights instead.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class SplashCardTests
|
|
{
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <summary>A message about as long as a real one gets: a path, a reason and a suggestion.</summary>
|
|
private const string LongFailure =
|
|
"The process cannot access the file 'C:\\Users\\somebody\\AppData\\Local\\DodoSSH\\cache.db' "
|
|
+ "because it is being used by another process, and this build cannot open a second copy of it.";
|
|
|
|
/// <remarks>
|
|
/// Pins the two constants and the mechanism against the XAML, the way
|
|
/// <c>LayoutHarnessTests.TheHarnessMeasuresTheSizeTheWindowDeclares</c> pins the main window's minimum.
|
|
/// <c>SizeToContent</c> is the one that matters: a window that lost it would clip the message and this
|
|
/// suite's other test would go on passing, because the card would still be measuring the taller size it
|
|
/// was never given.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSplashIsDeclaredTheWayTheHarnessMeasuresIt()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var window = new SplashWindow();
|
|
|
|
try
|
|
{
|
|
window.Width.ShouldBe(LayoutHarness.SplashWidth);
|
|
window.MinHeight.ShouldBe(LayoutHarness.SplashMinimumHeight);
|
|
window.SizeToContent.ShouldBe(
|
|
SizeToContent.Height,
|
|
"a fixed height would clip a long failure message");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The ordinary state has to fit the floor, or the window opens taller than its own minimum for the one
|
|
/// line it normally shows — which would make <see cref="LayoutHarness.SplashMinimumHeight"/> a number
|
|
/// that never applies to anything.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheStartingMessageFitsTheHeightTheSplashOpensAt()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() => MessageHeight(card => { }).ShouldBeLessThanOrEqualTo(
|
|
LayoutHarness.SplashMinimumHeight,
|
|
"the splash opens at its minimum and the one line it starts with has to fit"),
|
|
Token);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// ◆ The test with something behind it. A message that wraps to three lines has to make the card ask for
|
|
/// more room; if it did not, the text would be sitting under the bottom edge of the window and the only
|
|
/// thing the splash exists to carry at that point would be the part nobody could read.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ALongFailureMessageAsksForMoreRoomRatherThanBeingClipped()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var starting = MessageHeight(card => { });
|
|
var failed = MessageHeight(card => card.CouldNotStart(LongFailure));
|
|
|
|
failed.ShouldBeGreaterThan(
|
|
starting,
|
|
"the message wraps, so the card has to want the height the window then gives it");
|
|
},
|
|
Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// What one card wants to be, at the splash's width, after the caller has had its say.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Hosted in a window before being measured, because the heading and the hint take their size and their
|
|
/// wrapping from <c>App.axaml</c> — a card measured detached would be measuring unstyled text. Measured
|
|
/// again afterwards against an unbounded height, which is what the real window's <c>SizeToContent</c>
|
|
/// gives it and what the host window's own fixed height would otherwise clamp away.
|
|
/// </remarks>
|
|
private static double MessageHeight(Action<SplashCard> say)
|
|
{
|
|
var card = new SplashCard();
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
card, LayoutHarness.SplashWidth, LayoutHarness.SplashMinimumHeight);
|
|
|
|
try
|
|
{
|
|
say(card);
|
|
|
|
card.Measure(new Avalonia.Size(LayoutHarness.SplashWidth, double.PositiveInfinity));
|
|
|
|
return card.DesiredSize.Height;
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
}
|