Public Access
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
169 lines
6.9 KiB
C#
169 lines
6.9 KiB
C#
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;
|
|
|
|
/// <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>
|
|
/// <summary>
|
|
/// Puts one line of text on the system clipboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// <para>
|
|
/// A delegate rather than handing the view model an <c>IClipboard</c>, so that nothing in the view
|
|
/// models needs a visual and every test that drives them stays window-free.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static Func<string, Task> 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);
|
|
}
|
|
|
|
/// <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();
|
|
};
|
|
}
|
|
}
|