Files
DodoSSH/src/DodoSSH.Client.Session/ClientPaths.cs
T
laurensandClaude Opus 5 b8c94ecb73
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
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>
2026-08-26 14:49:15 +02:00

173 lines
8.8 KiB
C#

namespace DodoSSH.Client.Session;
/// <summary>
/// Where this machine keeps its profile.
/// </summary>
/// <remarks>
/// <para>
/// A record with an explicit directory rather than a static lookup, so a test — or a portable install —
/// can point it somewhere else without an environment variable.
/// </para>
/// <para>
/// <b>The choice of directory matters more than it looks.</b> The cache is a SQLite file written by one
/// process, and the whole design assumes each machine has its own: the outbox holds changes this machine
/// has made and not yet pushed, and two machines sharing one file through a cloud sync client corrupts
/// it. So this deliberately picks a <em>local</em>, non-roaming location on every platform. On Windows
/// that means <c>%LOCALAPPDATA%</c> and never <c>%APPDATA%</c>, which roams in a domain environment and
/// would do exactly the wrong thing.
/// </para>
/// </remarks>
/// <param name="DataDirectory">The profile directory. Created on demand.</param>
public sealed record ClientPaths(string DataDirectory)
{
private const string WindowsFolderName = "DodoSSH";
private const string UnixFolderName = "dodossh";
/// <summary>
/// The channel whose profile is kept apart, named here because this type is the one that acts on it.
/// </summary>
/// <remarks>
/// The string itself is the desktop head's <c>DesktopChannel.Nightly</c>. It is repeated rather than
/// referenced because the dependency runs the wrong way — this project is shared with the Android
/// head, which must never acquire a desktop updater — and because a value that reaches an assembly as
/// build metadata is a string by the time anybody here sees it.
/// </remarks>
private const string NightlyChannelName = "nightly";
/// <summary>
/// What the nightly's directory is called: the release one, with this on the end.
/// </summary>
/// <remarks>
/// A sibling rather than a subdirectory of the release profile, so that neither install's uninstaller
/// or reset can reach the other's, and so a person looking in <c>%LOCALAPPDATA%</c> sees two things
/// with two names rather than one thing with a surprise inside it.
/// </remarks>
private const string NightlySuffix = ".Nightly";
/// <summary>The conventional location for this platform.</summary>
public static ClientPaths Default { get; } = new(ResolveDataDirectory(suffix: null));
/// <summary>
/// Where a build on the given channel keeps its profile.
/// </summary>
/// <remarks>
/// <para>
/// <b>A nightly may not share a profile with the release build, and the reason is the cache rather
/// than the secrets.</b> The schema is migrated on every launch, before unlock; a nightly carrying a
/// migration the release build has not shipped yet would upgrade a database the release build then
/// opens. Both are installed at once by design — that is the whole point of a channel that installs
/// beside rather than over — so this is an ordinary Tuesday rather than a corner case. Two of them
/// running at the same time on one SQLite file and one outbox is the second reason and would be
/// enough on its own.
/// </para>
/// <para>
/// It costs a nightly its sign-in and its known hosts, which is the honest trade: a nightly is a
/// second installation of the application, and treating it as one is what stops it damaging the first.
/// The device key is per install too, so the deployment sees a new device — which is exactly what
/// happened, and what the trust model expects to be told about.
/// </para>
/// <para>
/// Only the nightly channel is answered specially. Anything else, including the release channel and
/// anything unrecognised, gets <see cref="Default"/> — the directory every existing install already
/// uses, which must not move for any reason.
/// </para>
/// </remarks>
/// <param name="channel">The build channel, as the head's own metadata reports it.</param>
public static ClientPaths ForChannel(string? channel) =>
string.Equals(channel, NightlyChannelName, StringComparison.Ordinal)
? new ClientPaths(ResolveDataDirectory(NightlySuffix))
: Default;
/// <summary>The encrypted local cache.</summary>
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
/// <summary>
/// This machine's preferences, in plaintext.
/// </summary>
/// <remarks>
/// Beside the cache rather than inside it, because everything in it is needed <em>before</em> a vault
/// is unlocked — a terminal draws at a chosen size on a launch that never reaches a passphrase. That
/// is also the reason nothing secret may go in here; see <see cref="ClientSettings"/>.
/// </remarks>
public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
/// <summary>
/// This machine's device key, encrypted to a key it cannot export.
/// </summary>
/// <remarks>
/// Local and non-roaming for a stronger reason than the cache is: the file is decryptable only by a
/// key held in this machine's TPM, so a copy of it on another machine is bytes nothing can open. It
/// following a user to a second computer would be useless rather than dangerous — but a roaming
/// profile that overwrote one machine's blob with another's would break both.
/// </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
/// during startup diagnostics and by tests that have no business creating directories.
/// </remarks>
public void EnsureCreated() => Directory.CreateDirectory(DataDirectory);
/// <remarks>
/// The platform branches are explicit rather than delegating to
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> everywhere. That enumeration does
/// the right thing on Windows, but on macOS the runtime maps it to <c>~/.local/share</c> rather than
/// to <c>~/Library/Application Support</c>, and relying on framework behaviour that differs per
/// platform for a path users will look at is how a file ends up somewhere nobody expects.
/// <para>
/// <c>XDG_DATA_HOME</c> is honoured explicitly for the same reason: it is the spec, and reading it
/// here is one line versus depending on whether the runtime happens to.
/// </para>
/// </remarks>
private static string ResolveDataDirectory(string? suffix)
{
// Appended to the folder name rather than added as a path segment, on every platform, so the two
// profiles are siblings everywhere. The Unix name is lower-cased with the rest of its folder.
var windows = WindowsFolderName + suffix;
var unix = UnixFolderName + suffix?.ToLowerInvariant();
if (OperatingSystem.IsWindows())
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
windows);
}
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (OperatingSystem.IsMacOS())
{
return Path.Combine(home, "Library", "Application Support", windows);
}
var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
var root = string.IsNullOrWhiteSpace(xdgDataHome)
? Path.Combine(home, ".local", "share")
: xdgDataHome;
return Path.Combine(root, unix);
}
}