Public Access
Selecting output and right-clicking it opened the WebView's own context menu with Copy on it, which is correct behaviour for a web page and wrong for a terminal. What a terminal's right button has meant since the Windows console got QuickEdit, and since PuTTY shipped, is this: with something selected it copies and drops the selection, with nothing selected it pastes. No menu, no second click. The gesture can only live in the page. The selection is drawn into a canvas the host never sees, and once a terminal has focus the host's window receives no input events at all — the same reason the Ctrl+plus chords are handled there. ◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY. navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and the host answers, exactly as requestFontSizeStep does. Writing does not go the other way either, although navigator.clipboard.writeText would have worked from the page on Windows: a clipboard that is the host's in one direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its COPY buttons, so each App.axaml.cs grows a ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it. Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard. ◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING. Paste carries a snippet off the sidebar, and the page deliberately *types* a single-line one rather than bracketing it, because bash marks a bracketed paste as an active region and a snippet should read as though it had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it has been pasted into — so Clipboard is always bracketed and never followed by an Enter. One opcode for both would have needed a flag meaning "which of the two kinds of paste is this", on a payload whose existing flag byte means something else. Both client frames are dispatched before the session lookup, which is what keeps the gesture working in a pane whose shell has just ended: squinting at the message a remote left behind is exactly when somebody copies the line. Dispatch was past MA0051's length once it grew a second such branch, so the frames no pump needs to see moved into Announce() and the font-size chord went with them. ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. preventDefault alone suppresses the menu and still lets xterm's own contextmenu handler run, which moves its helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it, selected. Both of those exist to serve the native menu. Taking the event in the capture phase on the pane and stopping it there is what keeps the gesture wholly the page's. rightClickSelectsWord is now false, which on macOS is a change of default rather than a restatement of it: xterm sets it true there alone, so a right-click would have selected the word under the pointer and copied that instead of pasting — wrong on one platform only, which is the worst way for it to be wrong. The renderer harness grows the pane it has been inviting since RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec, because both directions run through the page's own TextEncoder and TextDecoder and a Latin-1 stub would pass a test with an em dash in the selection. Its warning about declaring names terminal.js declares now covers the half that does not SyntaxError — a harness frame() was silently replaced by the page's frame() and failed six tests on a type error a long way from the cause. Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu event reaches a capture listener, that no native menu is drawn once it is prevented, and that xterm's real paste() brackets. 11.13 also records what is unverified — whether Android's WebView delivers a pointer's secondary click as a contextmenu event at all. ClipboardReader is wired on that head regardless, because the alternative is a head where the paste half silently does nothing and nobody finds out; that head is not compiled here either, this machine having no android workload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
14 KiB
C#
299 lines
14 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);
|
|
}
|
|
};
|
|
|
|
/// <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 };
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <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: OpenInDefaultEditorAsync,
|
|
watchFile: WatchFile);
|
|
|
|
/// <summary>
|
|
/// Hands a local file to whatever this machine opens that kind of file with.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <c>.conf</c> 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 <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>UseShellExecute</c> 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 <c>open</c> and Linux through
|
|
/// <c>xdg-open</c>, 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 <c>.conf</c> fails and for something else would be worse.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static Task OpenInDefaultEditorAsync(string path)
|
|
{
|
|
using var opened = System.Diagnostics.Process.Start(
|
|
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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();
|
|
};
|
|
}
|
|
}
|