using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input.Platform; using Avalonia.Markup.Xaml; using Avalonia.Threading; 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) { ShowSomethingWhileComposing(desktop); } base.OnFrameworkInitializationCompleted(); } /// /// Puts a window on screen first, and composes the application behind it. /// /// /// /// The splash is the main window for a moment, which is what makes this work at all. The /// desktop lifetime shows whatever MainWindow holds once this method returns and then runs the /// message loop; a window created here and merely shown would be a window with no lifetime attached, and /// with 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 rather than called. It is synchronous and takes long enough to be /// worth a splash in the first place — which is the whole argument for this method — so running it here /// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind /// rendering, which is what makes this a painted window rather than merely a shown one. /// /// private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop) { var splash = new SplashWindow(); desktop.MainWindow = splash; // Fully qualified, and it has to be: Application inherits an instance Dispatcher property from // AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an // instance property — so the unqualified name is a compile error rather than the type. Same below. Avalonia.Threading.Dispatcher.UIThread.Post( () => ComposeBehind(desktop, splash), DispatcherPriority.Background); } /// /// /// The order at the end is not interchangeable. ShutdownMode is OnLastWindowClose, so the /// real window is shown while the splash is still open and the splash is closed afterwards; closing the /// only window there is would end the process instead of handing it over. /// /// /// A failure is caught rather than left to the dispatcher, and the splash is exactly why. Before /// this method existed a composition that threw took the process with it and left nothing behind. Now /// there is something to leave behind — so an exception escaping a posted callback would abandon a window /// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What /// arrives instead is the sentence and a way to close it. /// /// private static void ComposeBehind( IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash) { try { Compose(desktop); } catch (Exception exception) when (exception is not OutOfMemoryException) { splash.CouldNotStart(exception.Message); return; } desktop.MainWindow?.Show(); splash.Close(); } /// /// /// 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); } }; /// Reads this machine's clipboard. /// /// The other direction, looked up per call for the same reason and with the same fall-through. One /// caller: the terminal's right-click paste, which the renderer cannot serve itself — see /// TerminalClientOpcode.ClipboardPaste. /// private static Func> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) => async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard } ? await clipboard.TryGetTextAsync().ConfigureAwait(false) : null; /// 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 // Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a // Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything // else gets a store 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, PinPrompt(desktop)); var updates = UpdateChannels.ForThisMachine(); var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections, browser, updates); desktop.MainWindow = new MainWindow { DataContext = viewModel }; // The other half of Program's claim on this profile: a second launch was refused, and this is what // answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and // a window may only be touched from the thread that owns it — see SingleInstance.Listen. var others = SingleInstance.Listen( paths, () => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow))); // 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, others); } /// /// Raises the window somebody has just asked for by launching the application again. /// /// /// Un-minimised first, because activating a minimised window leaves it minimised and the launch would /// look ignored. Activate is a request rather than a guarantee — Windows refuses the foreground to /// a process that did not receive the input, and what happens then is a flashing taskbar button, which is /// still an answer. That is the platform's own rule about focus stealing and not a thing to work around. /// private static void BringToFront(Window? window) { if (window is null) { return; } if (window.WindowState is WindowState.Minimized) { window.WindowState = WindowState.Normal; } window.Show(); window.Activate(); } /// Composes the shell itself, with everything this platform answers for it. /// /// Split out of for length rather than for reuse — it has one caller, and the /// method it came out of is at the limit the analyzers set. /// private static MainWindowViewModel BuildShell( IClassicDesktopStyleApplicationLifetime desktop, ClientPaths paths, ClientCacheFactory caches, TerminalWorkspace workspace, VaultKnownHostStore knownHosts, IDeviceKeyStore deviceKeys, SshNetConnectionFactory connections, SystemBrowserLauncher browser, IUpdateChannel updates) => new( 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), readClipboard: ClipboardReader(desktop), updates: updates, openInEditor: OpenInDefaultEditorAsync, watchFile: WatchFile); /// /// Hands a local file to whatever this machine opens that kind of file with. /// /// /// /// One of the two halves of editing a remote file in place, and both are this platform's rather than /// the shell's: what opens a .conf is a registry association, a desktop entry or a Launch /// Services binding depending on which desktop this is, and watching a file is a platform object with /// its own thread. The shell decides when; see TransfersViewModel.EditRemoteAsync. /// /// /// UseShellExecute is the whole of it, and it is what makes the same three lines work on all /// three desktops: Windows resolves the association, macOS goes through open and Linux through /// xdg-open, each of which .NET reaches for when the flag is set. Without it the path is treated /// as an executable to run, which for a .conf fails and for something else would be worse. /// /// /// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open /// instance and exit at once, so its lifetime says nothing about whether the file is still being /// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING. /// /// private static Task OpenInDefaultEditorAsync(string path) { using var opened = System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); return Task.CompletedTask; } /// /// Watches one file for saves, until the handle is disposed. /// /// /// /// ◆ The directory is watched and the name filtered, which is not the same as watching the file. /// A great many editors do not write in place: they write a temporary file beside the original and /// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the /// containing directory for that name catches the rename as a Created, which is what makes this /// work with the editors people actually use. /// /// /// Every kind of change is one callback, and the shell debounces nothing. One save can raise two /// or three events; what the shell does with each is stat the host and compare, which is a round trip /// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the /// server for the sake of saving it. /// /// private static FileSystemWatcher WatchFile(string path, Action onChanged) { var watcher = new FileSystemWatcher( Path.GetDirectoryName(path)!, Path.GetFileName(path)) { NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, }; watcher.Changed += (_, _) => onChanged(); watcher.Created += (_, _) => onChanged(); watcher.Renamed += (_, _) => onChanged(); watcher.EnableRaisingEvents = true; return watcher; } /// How the Linux device key store asks for a PIN, and the only thing that ever calls it. /// /// The lifetime is captured rather than the window, so this can be composed before there is one — /// composition runs first, and DevicePinWindow.AskAsync answers null when there is nothing to /// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating /// system's and this process is not in the conversation. See ADR 0007. /// private static Func> PinPrompt( IClassicDesktopStyleApplicationLifetime desktop) => (purpose, cancellationToken) => DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken); /// /// 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, IAsyncDisposable others) { 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); // Awaited rather than dropped, so the pipe name is free before this process is. It matters for // one caller and matters a lot there: an update relaunches this application, and a successor // that found the name still taken would be a second instance refusing itself. await others.DisposeAsync().ConfigureAwait(true); caches.Dispose(); desktop.Shutdown(); }; } }