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(); }; } }