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); } }; 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 = DesktopDeviceKeyStores.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), copyToClipboard: ClipboardWriter(desktop)); 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(); }; } }