using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App;
///
/// The Avalonia application.
///
///
/// Named DodoSshApp rather than the conventional App only because the assembly's root
/// namespace already ends in App, and a type whose name matches its namespace forces every
/// ambiguous reference to be fully qualified.
///
internal sealed partial class DodoSshApp : Application
{
///
public override void Initialize() => AvaloniaXamlLoader.Load(this);
///
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Compose(desktop);
}
base.OnFrameworkInitializationCompleted();
}
///
///
/// 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.
///
///
/// Everything disposable is a local captured by the closures below rather than a field, because an
/// Avalonia Application has no disposal hook of its own and a type that owned them would have
/// nowhere honest to release them.
///
///
///
/// Puts one line of text on the system clipboard.
///
///
/// The clipboard is reached through the window, and at composition time there is no window yet — hence
/// a closure that looks it up on each call rather than a reference captured now. A machine with no
/// clipboard falls through silently here; the view model is the one that decides what to say, and it
/// distinguishes "no clipboard on this machine" from "copied" because they are different answers.
///
/// A delegate rather than handing the view model an IClipboard, so that nothing in the view
/// models needs a visual and every test that drives them stays window-free.
///
///
private static Func ClipboardWriter(IClassicDesktopStyleApplicationLifetime desktop) =>
async text =>
{
if (TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard })
{
await clipboard.SetTextAsync(text).ConfigureAwait(false);
}
};
/// The terminal workspace, with its loopback listener already up.
///
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
/// be listening before the renderer attaches, and a workspace handed out un-started is one whose first
/// connect fails for a reason nothing on screen would explain.
///
private static TerminalWorkspace StartedWorkspace(SshNetConnectionFactory connections)
{
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
return workspace;
}
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
// worth spending a directory on.
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
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 = StartedWorkspace(connections);
var browser = new SystemBrowserLauncher();
// Both chosen once, here, because each is a property of the machine rather than 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
// (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var updates = UpdateChannels.ForThisMachine();
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),
copyToClipboard: ClipboardWriter(desktop),
updates: updates);
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);
}
///
/// 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.
///
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();
};
}
}