Public Access
449 lines
20 KiB
C#
449 lines
20 KiB
C#
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;
|
|
|
|
/// <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
|
|
{
|
|
/// <summary>
|
|
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
|
|
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
|
|
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
|
|
/// the UI thread only.
|
|
/// </remarks>
|
|
private static Action<SpelunkLink>? openLink;
|
|
|
|
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
|
|
private static SpelunkLink? waitingLink;
|
|
|
|
/// <inheritdoc />
|
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
|
|
/// <inheritdoc />
|
|
public override void OnFrameworkInitializationCompleted()
|
|
{
|
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
|
{
|
|
// Recorded, not handled: see CrashLog for why the process still goes.
|
|
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
|
|
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
|
|
|
|
ListenForOpenedLinks();
|
|
ShowSomethingWhileComposing(desktop);
|
|
}
|
|
|
|
base.OnFrameworkInitializationCompleted();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the links the platform hands a running application, which on macOS is every link.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
|
|
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
|
|
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
|
|
/// activation. The scheme is declared for it in build/macos's Info.plist template.
|
|
/// </para>
|
|
/// <para>
|
|
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
|
|
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
|
|
/// desktops that deliver links as arguments instead.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void ListenForOpenedLinks()
|
|
{
|
|
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
activatable.Activated += (_, e) =>
|
|
{
|
|
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
|
|
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
|
|
{
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
|
|
private static void Receive(SpelunkLink link)
|
|
{
|
|
if (openLink is { } open)
|
|
{
|
|
open(link);
|
|
}
|
|
else
|
|
{
|
|
waitingLink = link;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts a window on screen first, and composes the application behind it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
|
/// desktop lifetime shows whatever <c>MainWindow</c> 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 <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
|
/// the property over is one assignment, and Avalonia reads it lazily.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>And composition is posted rather than called.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
};
|
|
|
|
/// <summary>Reads this machine's clipboard.</summary>
|
|
/// <remarks>
|
|
/// 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
|
|
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
|
/// </remarks>
|
|
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
|
|
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
|
|
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
|
: null;
|
|
|
|
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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.
|
|
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
|
|
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
|
|
var others = SingleInstance.Listen(
|
|
paths,
|
|
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
|
|
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
|
|
|
|
openLink = viewModel.OpenLink;
|
|
|
|
if ((waitingLink ?? Program.LaunchLink) is { } first)
|
|
{
|
|
waitingLink = null;
|
|
viewModel.OpenLink(first);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises the window somebody has just asked for by launching the application again.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
|
/// look ignored. <c>Activate</c> 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.
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
|
/// <remarks>
|
|
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
|
|
/// method it came out of is at the limit the analyzers set.
|
|
/// </remarks>
|
|
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: LocalEditor.OpenAsync,
|
|
watchFile: WatchFile);
|
|
|
|
/// <summary>
|
|
/// Watches one file for saves, until the handle is disposed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
|
|
/// 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 <c>Created</c>, which is what makes this
|
|
/// work with the editors people actually use.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
|
|
/// <remarks>
|
|
/// The lifetime is captured rather than the window, so this can be composed before there is one —
|
|
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> 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.
|
|
/// </remarks>
|
|
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
|
|
IClassicDesktopStyleApplicationLifetime desktop) =>
|
|
(purpose, cancellationToken) =>
|
|
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
|
|
|
|
/// <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,
|
|
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();
|
|
};
|
|
}
|
|
}
|