Files
DodoSSH/src/DodoSSH.Client.App/App.axaml.cs
T
jaap-jan 240aadb746
ci / build and test (push) Failing after 3s
Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
Four files needed a hand, and all four were two branches adding something in
the same place rather than either changing what the other did.

The shell's constructor now takes both new parameters: main's SFTP session
factory, which it must have because it builds the transfers view model, and
this branch's optional resume handler, which stays last so every existing test
that constructs a shell without one still gets a shell that can only be online
because somebody signed in during this run. App.axaml.cs, ShellFlowTests and
QuickConnectTests pass the pair; the layout suite keeps both of its new fields.

Signing out now detaches the transfers screen exactly as locking does, and the
confirmation says that an open transfer session survives it. That is the same
policy both sides already argue for their own case: signing out destroys this
machine's copy of the vault, not work that authenticated before it.

QuickConnectTests did not compile on main — the SFTP commit added a constructor
parameter and the quick-connect suite, merged from a parallel branch just
before it, was still calling the old one. Fixed here rather than worked around,
since the merged tree has to build.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
980 tests, including the end-to-end suite against real containers.
2026-07-31 11:16:49 +02:00

142 lines
5.6 KiB
C#

using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.App.Terminal;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App;
/// <summary>
/// The Avalonia application.
/// </summary>
/// <remarks>
/// Named <c>DodoSshApp</c> rather than the conventional <c>App</c> only because the assembly's root
/// namespace already ends in <c>App</c>, and a type whose name matches its namespace forces every
/// ambiguous reference to be fully qualified.
/// </remarks>
internal sealed partial class DodoSshApp : Application
{
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Compose(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
/// indirection to read through would buy nothing at this size.
/// </para>
/// <para>
/// Everything disposable is a local captured by the closures below rather than a field, because an
/// Avalonia <c>Application</c> has no disposal hook of its own and a type that owned them would have
/// nowhere honest to release them.
/// </para>
/// </remarks>
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
var paths = ClientPaths.Default;
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
// Composed here, once, because the connection factory below needs it now and outlives every unlock;
// the vault behind it is attached and detached as one is opened and locked. See VaultKnownHostStore
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
var knownHosts = new VaultKnownHostStore();
// One factory for both kinds of connection. Shells and file transfers start with the same handshake
// and the same host key decision, and composing two would mean two snapshots of the pins.
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
var browser = new SystemBrowserLauncher();
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
var deviceKeys = DeviceKeyStores.ForThisMachine(paths);
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
TimeProvider.System,
connections,
passphraseProfile: null,
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
// makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false));
desktop.MainWindow = new MainWindow { DataContext = viewModel };
// 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);
}
/// <remarks>
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
/// or deadlocks, if any of that disposal needs the UI thread.
/// </remarks>
private static void WireShutdown(
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel viewModel,
TerminalWorkspace workspace,
ClientCacheFactory caches)
{
var shuttingDown = false;
desktop.ShutdownRequested += async (_, e) =>
{
if (shuttingDown)
{
return;
}
shuttingDown = true;
e.Cancel = true;
// The view model first: it holds the vault session, and disposing that is what zeroes the
// identity keys, the vault keys and the cache key.
await viewModel.DisposeAsync().ConfigureAwait(true);
await workspace.DisposeAsync().ConfigureAwait(true);
caches.Dispose();
desktop.Shutdown();
};
}
}