Public Access
Put a window on screen while it starts, and refuse the second one
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:
1 parent
aff776cb42
commit
b8c94ecb73
12 files changed
+1044
-3
No files matched your search
@@ -252,6 +252,34 @@ registered in the capture phase, that no native menu is drawn once it is prevent
|
||||
`ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent
|
||||
when neither is there, because there is no status line on this screen to say it on.
|
||||
|
||||
### 1.12 Something is on screen while it starts
|
||||
|
||||
Close DodoSSH and start it again. Watch the first second.
|
||||
|
||||
**Pass:** a small panel — about 420 pixels wide, no titlebar — appears saying **DodoSSH** and *Starting…*
|
||||
with a bar moving under it, and is then replaced by the main window. Measured on a debug build against a cold
|
||||
profile: the panel at 668 ms, the window at 3.1 s. A release install is quicker and the shape is the same.
|
||||
What must never come back is the old behaviour, which was nothing at all on screen for the whole of it.
|
||||
|
||||
**Pass, and worth checking separately:** the panel is **gone** once the window is up, there is never a moment
|
||||
with two windows both claiming to be DodoSSH, and clicking another application while the panel is up puts
|
||||
that application in front of it. The last one is deliberate — a splash that stays on top is a splash sitting
|
||||
over whatever you gave up and went back to.
|
||||
|
||||
**And the failure state, which is the part with something to get wrong.** It needs provoking: put a
|
||||
*directory* where `cache.db` should be — `%LOCALAPPDATA%\DodoSSH.Nightly\cache.db` for the nightly — and
|
||||
start the application.
|
||||
|
||||
**Pass:** the panel stays, the bar stops moving, and it reads *DodoSSH could not start:* followed by the
|
||||
reason and *Press Escape to close.* Escape closes it. Remove the directory afterwards.
|
||||
|
||||
**Failure means:** a panel left saying *Starting…* is the main window having failed to show with nothing
|
||||
catching it, which is the one outcome worse than the crash it replaced — `DodoSshApp.ComposeBehind` exists to
|
||||
make it impossible. Two windows at once is the show-then-close order in that method having been swapped:
|
||||
closing the only open window ends the process, so the real window is shown while the panel is still up.
|
||||
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -2890,6 +2918,43 @@ offer a nightly, ever, however many have been published since. It reads a differ
|
||||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||
rather than as a bug in the nightly.
|
||||
|
||||
### 16.11 One instance, and what a second launch does instead
|
||||
|
||||
With DodoSSH running, start it again — Start menu, desktop shortcut, or the executable directly. Do it three
|
||||
times: with the window in front, with it minimised, and with it behind another application.
|
||||
|
||||
**Pass:** no second window appears, and the one already open comes forward, un-minimised if it was
|
||||
minimised. **A flashing taskbar button rather than a raised window is also a pass**: Windows refuses the
|
||||
foreground to a process that did not receive the input, which is its own rule about focus stealing and not
|
||||
something to defeat. Nothing happening at all is the failure.
|
||||
|
||||
**And the side-by-side case, which is the whole reason the lock lives where it does.** 16.10's arrangement,
|
||||
both builds installed, both started.
|
||||
|
||||
**Pass:** both run. They are separate applications with separate profiles, and the lock is a file inside the
|
||||
profile — so neither can see the other's. Two people signed in to one machine is the same property from the
|
||||
other direction and needs no check of its own.
|
||||
|
||||
**And the update relaunch · needs a release.** Take an update (16.7) and press RESTART NOW.
|
||||
|
||||
**Pass:** the application comes back. This is the race worth naming: the updater starts the new build as the
|
||||
old one is exiting, and a lock held a moment too long would make the new process refuse itself and disappear
|
||||
— an update that looked like an uninstall. `SingleInstance.Claim` keeps trying for three seconds while the
|
||||
lock is held by something that does not answer its pipe, which is what covers it.
|
||||
|
||||
**Failure means:** a second window means the lock is not being taken. A profile directory that cannot be
|
||||
written to answers "you are the only one" on purpose — refusing to start an SSH client over a permissions
|
||||
problem would be the wrong trade — so check that the profile exists, is writable, and has an
|
||||
`instance.lock` in it while the application runs. A second launch that exits without raising the first means
|
||||
the lock worked and the doorbell did not: the pipe name is derived from the profile path, so two processes
|
||||
disagreeing about it means they disagree about which profile they are in. Two windows *after an update* is
|
||||
the opposite failure — the outgoing process never released the lock, which means something is holding it
|
||||
open past `Shutdown`.
|
||||
|
||||
`SingleInstanceTests` covers everything reachable without a second process: the lock, its release, the
|
||||
doorbell answering more than once, and two profiles not excluding each other. What is left for a person is a
|
||||
second *executable* and a real window coming forward.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Installing and updating the phone
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Compose(desktop);
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a window on screen first, and composes the application behind it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
||||
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
|
||||
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
|
||||
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
||||
/// the property over is one assignment, and Avalonia reads it lazily.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
|
||||
/// worth a splash in the first place — which is the whole argument for this method — so running it here
|
||||
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
|
||||
/// rendering, which is what makes this a painted window rather than merely a shown one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var splash = new SplashWindow();
|
||||
|
||||
desktop.MainWindow = splash;
|
||||
|
||||
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
|
||||
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
|
||||
// instance property — so the unqualified name is a compile error rather than the type. Same below.
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
() => ComposeBehind(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
|
||||
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
|
||||
/// only window there is would end the process instead of handing it over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
|
||||
/// this method existed a composition that threw took the process with it and left nothing behind. Now
|
||||
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
|
||||
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
|
||||
/// arrives instead is the sentence and a way to close it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ComposeBehind(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
SplashWindow splash)
|
||||
{
|
||||
try
|
||||
{
|
||||
Compose(desktop);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
splash.CouldNotStart(exception.Message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
desktop.MainWindow?.Show();
|
||||
splash.Close();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
||||
@@ -137,12 +203,44 @@ internal sealed partial class DodoSshApp : Application
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||
var others = SingleInstance.Listen(
|
||||
paths,
|
||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||
|
||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||
// discarding the task here is safe rather than merely convenient.
|
||||
_ = viewModel.StartAsync(CancellationToken.None);
|
||||
|
||||
WireShutdown(desktop, viewModel, workspace, caches);
|
||||
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the window somebody has just asked for by launching the application again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||
/// </remarks>
|
||||
private static void BringToFront(Window? window)
|
||||
{
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.WindowState is WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||
@@ -271,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -290,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||
// that found the name still taken would be a second instance refusing itself.
|
||||
await others.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
caches.Dispose();
|
||||
|
||||
desktop.Shutdown();
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
||||
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
||||
/// they are separate applications by design, 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 of those from somewhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
||||
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
||||
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
||||
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
||||
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
||||
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
||||
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
||||
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
||||
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||
/// what makes the exclusion invisible rather than mysterious.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SingleInstance
|
||||
{
|
||||
/// <summary>The one thing a second launch has to say.</summary>
|
||||
/// <remarks>
|
||||
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
||||
/// message is: one message exists and treating anything else as a command would be a wider door than
|
||||
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
||||
/// is checked rather than trusted.
|
||||
/// </remarks>
|
||||
private const string ComeForward = "dodossh.come-forward";
|
||||
|
||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||
/// <remarks>
|
||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
||||
/// cannot be talked to.
|
||||
/// </remarks>
|
||||
private const int AnswerTimeoutMilliseconds = 500;
|
||||
|
||||
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
||||
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
||||
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
||||
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
||||
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
||||
/// and has not reached its pipe yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
||||
/// launch is gone before any of this matters.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>How long to wait between attempts within that window.</summary>
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
||||
/// in which case that instance has already been asked to raise its window and this process should end.
|
||||
/// </returns>
|
||||
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
||||
/// <param name="patience">
|
||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||
/// </param>
|
||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
paths.EnsureCreated();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// No profile directory means no lock file, and every build before this one started anyway. What
|
||||
// follows will fail on its own terms and say something better than this could.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
|
||||
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
||||
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
||||
// makes of them.
|
||||
var attemptsLeft = (int)Math.Ceiling(
|
||||
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (TryTake(paths.InstanceLockFile) is { } taken)
|
||||
{
|
||||
return taken;
|
||||
}
|
||||
|
||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||
// round trip on a local pipe rather than the whole window below.
|
||||
if (AskTheRunningOneToComeForward(paths))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attemptsLeft-- <= 0)
|
||||
{
|
||||
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
||||
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
||||
// worth corrupting it for.
|
||||
return null;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers the launches that were refused, for as long as the returned handle is held.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
||||
/// accept completes. A caller that touches a window has to marshal.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The profile this instance claimed.</param>
|
||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(comeForward);
|
||||
|
||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
||||
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
||||
/// <returns>Whether anything was listening.</returns>
|
||||
/// <remarks>
|
||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||
/// and proves itself, and this is the part with a protocol in it.
|
||||
/// </remarks>
|
||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
PipeNameFor(paths),
|
||||
PipeDirection.Out);
|
||||
|
||||
pipe.Connect(AnswerTimeoutMilliseconds);
|
||||
|
||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
writer.WriteLine(ComeForward);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
||||
// "no".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||
/// <remarks>
|
||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
||||
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
||||
/// there and sometimes locked has one fewer state.
|
||||
/// </remarks>
|
||||
private static IDisposable? TryTake(string lockFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(
|
||||
lockFile,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.None);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Somebody else holds it. The one expected failure, and the whole point.
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// A profile this user cannot write to, which is a broken installation rather than a second
|
||||
// instance. Answered as a claim so the application starts and fails on something legible.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
||||
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
||||
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
||||
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
||||
/// </remarks>
|
||||
private static string PipeNameFor(ClientPaths paths)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
||||
|
||||
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||
}
|
||||
|
||||
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
||||
private sealed class NoClaim : IDisposable
|
||||
{
|
||||
internal static NoClaim Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing was taken.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The accept loop that answers refused launches.</summary>
|
||||
/// <remarks>
|
||||
/// One server instance at a time and a fresh stream per connection, which is the shape
|
||||
/// <see cref="NamedPipeServerStream"/> wants: a stream that has served a client cannot serve another.
|
||||
/// The loop is what makes a third and fourth launch work as well as the second.
|
||||
/// </remarks>
|
||||
private sealed class Doorbell : IAsyncDisposable
|
||||
{
|
||||
private readonly string name;
|
||||
private readonly Action comeForward;
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task? loop;
|
||||
|
||||
internal Doorbell(string name, Action comeForward)
|
||||
{
|
||||
this.name = name;
|
||||
this.comeForward = comeForward;
|
||||
}
|
||||
|
||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (loop is { } running)
|
||||
{
|
||||
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
||||
// matters for exactly one caller: an update that is about to relaunch this application and
|
||||
// would otherwise race its own successor for the name.
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
name,
|
||||
PipeDirection.In,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var reader = new StreamReader(server, Encoding.UTF8);
|
||||
|
||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||
{
|
||||
comeForward();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A client that connected and hung up, or a name the platform took away. Round again:
|
||||
// giving up here would leave every later launch with nothing to talk to, which is the
|
||||
// silent-exit behaviour this class exists to avoid.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,27 @@ internal static class Program
|
||||
|
||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||
|
||||
/*
|
||||
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||
this process rather than of the application.
|
||||
|
||||
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||
|
||||
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||
*/
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths);
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,26 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
/// </remarks>
|
||||
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
||||
|
||||
/// <summary>
|
||||
/// The file one desktop instance holds open for as long as it is running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named here rather than in the head that uses it, because every other name in this directory is named
|
||||
/// here and a filename invented somewhere else is one nobody looking at the profile can account for. The
|
||||
/// Android head has no use for it: the platform allows one process per package and needs no help.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Being in the profile directory is what scopes it correctly, and that is the whole reason it is a
|
||||
/// file rather than a named mutex.</b> This path is already per user and per channel, so a nightly and a
|
||||
/// release build do not exclude each other and two people signed into one machine each get their own
|
||||
/// instance — neither of which a global name would give without repeating the same two decisions in
|
||||
/// another form. Contents are irrelevant and it stays empty; what carries the meaning is the exclusive
|
||||
/// handle, which the operating system releases when the process ends however it ends.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||
|
||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||
|
||||
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
||||
/// </remarks>
|
||||
internal const double HostDrawerWidth = 320;
|
||||
|
||||
/// <summary>The splash window's fixed width, from <c>SplashWindow.axaml</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Its height is deliberately absent from this list, because the window has none to state: it is
|
||||
/// <c>SizeToContent="Height"</c> so that a failure message of any length is readable rather than
|
||||
/// clipped. <see cref="SplashMinimumHeight"/> is the floor under the ordinary one-line case, and
|
||||
/// <c>SplashCardTests</c> pins all three against the XAML.
|
||||
/// </remarks>
|
||||
internal const double SplashWidth = 420;
|
||||
|
||||
/// <inheritdoc cref="SplashWidth" />
|
||||
internal const double SplashMinimumHeight = 160;
|
||||
|
||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and what the second launch does instead of starting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither
|
||||
/// is worth faking — the whole claim being made is about what the operating system does with an exclusive
|
||||
/// handle and a pipe name, and a fake of either would be a test of the fake.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
|
||||
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
|
||||
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
|
||||
/// line.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SingleInstanceTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — so a unique
|
||||
/// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever
|
||||
/// DodoSSH the developer happens to have running.
|
||||
/// </remarks>
|
||||
private readonly ClientPaths paths =
|
||||
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
|
||||
|
||||
/// <summary>
|
||||
/// No patience at all, which is what every test here wants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching
|
||||
/// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision
|
||||
/// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Impatient = TimeSpan.Zero;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(paths.DataDirectory))
|
||||
{
|
||||
Directory.Delete(paths.DataDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFirstLaunchClaimsTheProfile()
|
||||
{
|
||||
using var claim = SingleInstance.Claim(paths, Impatient);
|
||||
|
||||
claim.ShouldNotBeNull();
|
||||
File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the
|
||||
/// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ASecondLaunchIsRefused()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the
|
||||
/// runtime when it is disposed and by the kernel when the process ends however it ends — which is the
|
||||
/// reason this is a file rather than a named mutex, whose abandonment is a state to be handled.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo()
|
||||
{
|
||||
var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
first.Dispose();
|
||||
|
||||
using var second = SingleInstance.Claim(paths, Impatient);
|
||||
|
||||
second.ShouldNotBeNull("the lock went with the handle");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees
|
||||
/// nothing has been told the application is broken, so the running instance is asked to come forward.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var asked = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult());
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken");
|
||||
|
||||
// Awaited rather than polled: the callback comes off a thread pool thread, and the test's own
|
||||
// cancellation is what bounds a doorbell that never rings.
|
||||
await asked.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A third and a fourth launch work as well as the second, which is what the accept loop is for: a
|
||||
/// <c>NamedPipeServerStream</c> that has served one client cannot serve another, so a doorbell that
|
||||
/// forgot to go round again would answer exactly one refused launch and then leave every later one to
|
||||
/// exit in silence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheDoorbellAnswersMoreThanOnce()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var rings = 0;
|
||||
var twice = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() =>
|
||||
{
|
||||
if (Interlocked.Increment(ref rings) == 2)
|
||||
{
|
||||
twice.TrySetResult();
|
||||
}
|
||||
});
|
||||
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
|
||||
await twice.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// What the give-up path turns on. A lock held by something that is not listening — a process still
|
||||
/// starting, or one on its way out — answers no, and that is what makes <c>Claim</c> wait and try the
|
||||
/// lock again rather than concluding anything from the silence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AskingWithNobodyListening_SaysSo()
|
||||
{
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two profiles, because a nightly and a release build are installed side by side on purpose and must
|
||||
/// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived
|
||||
/// from it, so both halves are scoped by the one decision <c>ClientPaths.ForChannel</c> already made.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TwoProfilesDoNotExcludeEachOther()
|
||||
{
|
||||
var other = new ClientPaths(paths.DataDirectory + ".Nightly");
|
||||
|
||||
try
|
||||
{
|
||||
using var release = SingleInstance.Claim(paths, Impatient);
|
||||
using var nightly = SingleInstance.Claim(other, Impatient);
|
||||
|
||||
release.ShouldNotBeNull();
|
||||
nightly.ShouldNotBeNull("a second profile is a second application");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(other.DataDirectory))
|
||||
{
|
||||
Directory.Delete(other.DataDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user