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>
923 lines
44 KiB
C#
923 lines
44 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using DodoSSH.Client.Ssh;
|
|
|
|
namespace DodoSSH.Client.Terminal;
|
|
|
|
/// <summary>Tuning for the workspace.</summary>
|
|
public sealed class TerminalWorkspaceOptions
|
|
{
|
|
/// <summary>
|
|
/// How long <see cref="TerminalWorkspace.WaitForRendererAsync"/> waits for the renderer's socket
|
|
/// before giving up.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The value has to separate two cases. Attaching is normally near-instant: WebView2 starts with the
|
|
/// window and the page has usually attached while the user was still typing a passphrase. But a first
|
|
/// run on a cold profile creates a user-data directory and starts a process tree of some thirty-five
|
|
/// processes, and on a slow or loaded machine that is seconds rather than milliseconds. A renderer
|
|
/// that will never attach — no Evergreen runtime, an install blocked by policy, an AppContainer that
|
|
/// cannot reach loopback — will not attach however long the wait is.
|
|
/// </para>
|
|
/// <para>
|
|
/// So being generous costs only how long a genuinely broken WebView2 takes to say so, while being
|
|
/// tight costs telling someone their runtime is broken when it was merely slow. Fifteen seconds is
|
|
/// well clear of any cold start observed here and is still an answer rather than a hang.
|
|
/// </para>
|
|
/// </remarks>
|
|
public TimeSpan RendererTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
|
}
|
|
|
|
/// <summary>A session whose shell has ended.</summary>
|
|
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
|
|
/// <param name="outcome">How it ended, and the sentence that says so.</param>
|
|
/// <remarks>
|
|
/// ◆ <b>The outcome is here because a subscriber cannot work it out afterwards.</b> By the time this event
|
|
/// is raised the session is over: there is no transport left to interrogate, and the difference between a
|
|
/// shell that exited and a connection that broke is exactly the difference between a tab that is finished
|
|
/// and a tab worth offering to dial again. It used to carry the id alone, so the only place that difference
|
|
/// existed was a line of grey text inside the WebView.
|
|
/// </remarks>
|
|
public sealed class TerminalSessionEndedEventArgs(uint sessionId, TerminalSessionOutcome outcome) : EventArgs
|
|
{
|
|
/// <summary>The session that ended.</summary>
|
|
public uint SessionId { get; } = sessionId;
|
|
|
|
/// <inheritdoc cref="TerminalSessionOutcome" />
|
|
public TerminalSessionOutcome Outcome { get; } = outcome;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The negotiated cipher and host-key algorithm for one live session.
|
|
/// </summary>
|
|
/// <param name="Cipher">The server-to-client encryption algorithm; see <see cref="ISshConnection.Cipher"/>.</param>
|
|
/// <param name="HostKeyAlgorithm">The host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
|
/// <remarks>
|
|
/// Two facts rather than the whole <see cref="ISshConnection"/>, because that is all a caller outside this
|
|
/// assembly has any business reading off a session it does not own — everything else on the connection
|
|
/// (disposal, the shell) belongs to the workspace alone. See <see cref="TerminalWorkspace.GetSessionFacts"/>.
|
|
/// </remarks>
|
|
public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm);
|
|
|
|
/// <summary>The renderer asking for a different font size.</summary>
|
|
/// <param name="step">
|
|
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
|
|
/// size because the page does not hold the current one — the host does, and clamping a step is what stops
|
|
/// two chords in flight from disagreeing about where they started.
|
|
/// </param>
|
|
public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
|
|
{
|
|
/// <summary>The requested move, or zero for "back to the default".</summary>
|
|
public int Step { get; } = step;
|
|
}
|
|
|
|
/// <summary>The renderer handing over text for the system clipboard.</summary>
|
|
/// <param name="text">
|
|
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
|
|
/// letting a stray gesture wipe the machine's clipboard.
|
|
/// </param>
|
|
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
|
|
{
|
|
/// <summary>What to put on the clipboard.</summary>
|
|
public string Text { get; } = text;
|
|
}
|
|
|
|
/// <summary>The renderer asking for the system clipboard's text.</summary>
|
|
/// <param name="sessionId">
|
|
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
|
|
/// the host to infer it from whichever tab it believes is selected.
|
|
/// </param>
|
|
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
|
|
{
|
|
/// <summary>The terminal to paste into.</summary>
|
|
public uint SessionId { get; } = sessionId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owns the loopback data plane and every live terminal session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One data plane and one renderer page for the whole application, with a session id per terminal.
|
|
/// Not one WebView per tab: each WebView2 is a separate browser process, so twenty tabs would mean
|
|
/// twenty renderer processes and several hundred megabytes for a working set a user would call
|
|
/// ordinary. Splits and tabs are layout inside the single page.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A session's lifetime is the application's, not the vault's.</b> This object is composed once at
|
|
/// startup and outlives every lock, deliberately: locking the vault zeroes keys, and a shell needs no
|
|
/// vault key to keep running, so a job started before the lock keeps running through it. That is a
|
|
/// policy rather than an oversight — <c>MainWindowViewModel.LockAsync</c> says why, and the shell shows
|
|
/// <see cref="LiveSessionCount"/> on the unlock screen so it is not a hidden state.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class TerminalWorkspace : IAsyncDisposable
|
|
{
|
|
private readonly TerminalDataPlane dataPlane;
|
|
private readonly ISshConnectionFactory connections;
|
|
private readonly TimeProvider clock;
|
|
private readonly TerminalWorkspaceOptions options;
|
|
private readonly Dictionary<uint, LiveSession> sessions = [];
|
|
|
|
/// <summary>
|
|
/// Guards <see cref="sessions"/> and <see cref="nextSessionId"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This dictionary is genuinely touched from more than one thread, which the tab strip made true rather
|
|
/// than merely arguable: sessions are opened and closed from the UI thread, and
|
|
/// <see cref="RunSessionAsync"/>'s finally now asks whether the session is still known — on whichever
|
|
/// thread-pool thread the pump happened to unwind on. An unsynchronised <see cref="Dictionary{TKey,
|
|
/// TValue}"/> read concurrent with a write does not merely return a stale answer; it can throw or spin.
|
|
/// The data plane guards its own registration table the same way and for the same reason.
|
|
/// </remarks>
|
|
private readonly Lock sessionGate = new();
|
|
private readonly CancellationTokenSource lifetime = new();
|
|
|
|
/// <summary>
|
|
/// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
|
|
/// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
|
|
/// payload for a real open) tells the two apart without a second opcode.
|
|
/// </summary>
|
|
private static readonly byte[] ReplayMarker = [1];
|
|
|
|
/// <summary>What <see cref="CloseSessionAsync"/> announces, whatever the pump made of the same end.</summary>
|
|
private static readonly TerminalSessionOutcome ClosedByThisClient =
|
|
new(SshSessionEnd.ClosedLocally, "The session was closed.");
|
|
|
|
private uint nextSessionId = 1;
|
|
private Task? server;
|
|
private int disposed;
|
|
|
|
/// <param name="assets">Where the renderer's files come from.</param>
|
|
/// <param name="connections">How SSH connections are made.</param>
|
|
/// <param name="clock">Time source, so the pumps' flush interval is testable.</param>
|
|
/// <param name="options">Tuning, or null for the defaults.</param>
|
|
public TerminalWorkspace(
|
|
ITerminalAssetProvider assets,
|
|
ISshConnectionFactory connections,
|
|
TimeProvider clock,
|
|
TerminalWorkspaceOptions? options = null)
|
|
{
|
|
this.connections = connections;
|
|
this.clock = clock;
|
|
this.options = options ?? new TerminalWorkspaceOptions();
|
|
|
|
dataPlane = new TerminalDataPlane(assets);
|
|
|
|
// Forwarded rather than re-raised with the workspace as the sender, so a handler can tell where it
|
|
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
|
|
// what remembers it between launches.
|
|
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
|
|
|
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
|
|
// is reached is a property of the platform the shell is running on, and this object has never seen a
|
|
// visual.
|
|
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
|
|
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
|
|
|
|
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
|
|
// handshake handling, and has no business making that wait on however long a replay takes. See
|
|
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
|
|
// is harmless.
|
|
dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Where connections are recorded, or null to record none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Settable rather than a constructor parameter, because the two objects have different lifetimes and
|
|
/// the workspace's is the longer one: it is composed at startup and outlives every lock, while anything
|
|
/// that can write to a vault exists only while one is open. Locking sets this back to null, and the
|
|
/// sessions that were already running go on running with nothing recording them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Which means an entry can be missed — a shell open across a lock and closed after it. The recorder
|
|
/// holds its store for close-out past <c>Close()</c> precisely so that the common case does not, and the
|
|
/// residue is stated here rather than papered over.
|
|
/// </para>
|
|
/// </remarks>
|
|
public IConnectionLogSink? ConnectionLog { get; set; }
|
|
|
|
/// <summary>Where the WebView should navigate.</summary>
|
|
public Uri PageUrl => dataPlane.PageUrl;
|
|
|
|
/// <summary>
|
|
/// How many terminals still have a live shell behind them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
|
|
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
|
|
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
|
|
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
|
|
/// and it has already sent <c>SessionClosed</c> to the renderer.
|
|
/// </para>
|
|
/// <para>
|
|
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
|
/// claims a shell is still running when it is not would be the same class of dishonesty the number
|
|
/// is there to prevent.
|
|
/// </para>
|
|
/// </remarks>
|
|
public int LiveSessionCount
|
|
{
|
|
get
|
|
{
|
|
lock (sessionGate)
|
|
{
|
|
return sessions.Values.Count(session => !session.Run.IsCompleted);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether one session's shell is still running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same question <see cref="LiveSessionCount"/> answers in aggregate, and answered the same way: a
|
|
/// completed <c>Run</c> is what "the shell is gone" looks like, because nothing removes the entry when
|
|
/// the remote closes the channel on its own. A session id this workspace never issued is not live.
|
|
/// </remarks>
|
|
public bool IsSessionLive(uint sessionId)
|
|
{
|
|
lock (sessionGate)
|
|
{
|
|
return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The negotiated cipher and host-key algorithm for one live session, or null when the id names no
|
|
/// session this workspace still has open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Additive, and deliberately narrow: the caller this exists for — the shell's own connect path, which
|
|
/// wants these two facts for its status bar — has no other business with a session it does not own, and a
|
|
/// method that handed back the <see cref="ISshConnection"/> itself would have handed over the shell,
|
|
/// disposal and all, to code that already goes through <see cref="CloseSessionAsync"/> for that.
|
|
/// </para>
|
|
/// <para>
|
|
/// "No session this workspace still has open" covers two different absences the same way
|
|
/// <see cref="IsSessionLive"/> already does: an id this workspace never issued, and one whose shell has
|
|
/// already ended but whose entry has not been removed yet. Both are "nothing to report" to a caller
|
|
/// asking what a session's transport looks like right now.
|
|
/// </para>
|
|
/// </remarks>
|
|
public SessionFacts? GetSessionFacts(uint sessionId)
|
|
{
|
|
lock (sessionGate)
|
|
{
|
|
if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new SessionFacts(session.Connection.Cipher, session.Connection.HostKey.Algorithm);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A live session's flow-control window, or null when the id names no session this workspace still has
|
|
/// open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
|
|
/// reason to see a pump's credit window rather than what the transport does with it, but
|
|
/// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
|
|
/// that is easy to get backwards, and worth asserting directly rather than only through its side
|
|
/// effects. Internal rather than public, reachable from the test assembly through the
|
|
/// <c>InternalsVisibleTo</c> this project already declares for it.
|
|
/// </remarks>
|
|
internal CreditWindow? CreditsFor(uint sessionId)
|
|
{
|
|
lock (sessionGate)
|
|
{
|
|
return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raised with the session id once a session is over — its shell having ended on its own, or a
|
|
/// deliberate close having fully drained.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A tab has to be able to stop claiming it is connected, and polling would be the alternative: a timer
|
|
/// asking <see cref="IsSessionLive"/> often enough to look immediate, for a thing that happens a handful
|
|
/// of times a day. The renderer already learns about this instantly — the pump sends
|
|
/// <c>SessionClosed</c> and the page writes the reason into the pane — so this is the same fact reaching
|
|
/// the half of the interface Avalonia draws.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
|
|
/// It used to fire from inside the run's own finally block, where the task is by definition not yet
|
|
/// complete — so a handler reading <see cref="LiveSessionCount"/> still counted the session that had
|
|
/// just ended, which is how the phone's foreground notification went on saying "1 shell connected"
|
|
/// over nothing. See <see cref="AnnounceEndedAsync"/>. Raised on a thread-pool continuation, or on the
|
|
/// closer's own thread; a handler that touches an observable collection has to marshal either way.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
|
|
/// reasoning — the caller asked, so telling it is an echo — assumed every subscriber was the caller.
|
|
/// The phone's keep-alive is not: it hears this event to reconcile a notification with reality, and a
|
|
/// close that announced nothing left that notification claiming a shell that was gone. Every subscriber
|
|
/// treats the event as "reconcile" rather than "act" — a tab is marked dead if it is still there and
|
|
/// skipped if it is not — so a second announcement for a session that already announced its own end
|
|
/// (closing the tab of a shell that exited earlier) is deliberate and harmless. Shutdown is the one
|
|
/// close that stays silent: <see cref="DisposeAsync"/> is tearing the subscribers down with the
|
|
/// sessions, and news nobody is left to hear is not news.
|
|
/// </para>
|
|
/// </remarks>
|
|
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
|
|
|
/// <summary>Raised when the renderer's own keyboard asks for a different font size.</summary>
|
|
/// <remarks>
|
|
/// The chords can only be heard by the page — once a terminal has focus the host's window sees no key
|
|
/// events at all — so this is how Ctrl+plus reaches the thing that owns the setting. Raised on the
|
|
/// socket's receive loop; marshal before touching a view model.
|
|
/// </remarks>
|
|
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
|
|
|
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
|
|
/// <remarks>
|
|
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
|
|
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
|
|
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
|
|
/// receive loop; marshal before touching one.
|
|
/// </remarks>
|
|
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
|
|
|
|
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
|
|
/// <remarks>
|
|
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
|
|
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
|
|
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
|
|
/// which is a UI-thread object on more than one platform.
|
|
/// </remarks>
|
|
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
|
|
|
|
/// <summary>
|
|
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
|
|
/// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
|
|
/// has no business owning: the font size and which tab is selected are both remembered by the shell, not
|
|
/// by a terminal, so this is the seam the shell uses to re-push them. See
|
|
/// <c>MainWindowViewModel</c>'s subscription for the other half.
|
|
/// </para>
|
|
/// <para>
|
|
/// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
|
|
/// triggers it — a handler that touches a view model has to marshal.
|
|
/// </para>
|
|
/// </remarks>
|
|
public event EventHandler? RendererReattached;
|
|
|
|
/// <summary>Starts the loopback listener.</summary>
|
|
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
|
|
|
|
/// <summary>
|
|
/// Tells every pane what size to draw at.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Sent to the page rather than applied per session, and unconditionally rather than only when a
|
|
/// session is live: the page keeps the size for panes opened later, so this is also how the first
|
|
/// terminal of a launch comes up at the size the user last chose.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every live pane refits as a result and reports its new geometry, so the remotes are told they have
|
|
/// fewer columns. That round trip is the feature rather than a side effect — see
|
|
/// <see cref="TerminalServerOpcode.FontSize"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="pixels">The size in CSS pixels. Clamped by the caller; sent as one byte.</param>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) =>
|
|
dataPlane.SendAsync(
|
|
TerminalFrame.Create(
|
|
(byte)TerminalServerOpcode.FontSize,
|
|
sessionId: 0,
|
|
TerminalFrame.CreateFontSizePayload(pixels)),
|
|
cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Hands the system clipboard's text to one pane, to be pasted into it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
|
|
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
|
|
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
|
|
/// </para>
|
|
/// <para>
|
|
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
|
|
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
|
|
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
|
|
/// that a caller which grows a message one day has something to hang it on.
|
|
/// </para>
|
|
/// <para>
|
|
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
|
|
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
|
|
/// to paste" is a fact about the machine rather than about this terminal.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="sessionId">The terminal to paste into.</param>
|
|
/// <param name="text">The clipboard's text. Sent verbatim.</param>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
|
|
public async Task<bool> SendClipboardAsync(
|
|
uint sessionId,
|
|
string text,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(text);
|
|
|
|
if (!IsSessionLive(sessionId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
await dataPlane
|
|
.SendAsync(
|
|
TerminalFrame.Create(
|
|
(byte)TerminalServerOpcode.Clipboard,
|
|
sessionId,
|
|
Encoding.UTF8.GetBytes(text)),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits until the renderer page has attached its socket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A session opened before the renderer attaches would have its <c>SessionOpened</c> frame dropped —
|
|
/// the transport discards frames when nothing is connected — leaving output arriving for a terminal
|
|
/// that was never created. The gate is the invariant and stays.
|
|
/// </para>
|
|
/// <para>
|
|
/// Bounded, because whether the renderer attaches at all depends on a WebView2 runtime this process
|
|
/// does not install. An unbounded wait turned a missing runtime into a Connect that never returned,
|
|
/// with the caller's busy state never cleared and nothing on screen to explain it. Callers are
|
|
/// expected to translate the timeout into something that names the runtime, because
|
|
/// <see cref="TimeoutException"/>'s own message names nothing.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="cancellationToken">Abandons the wait.</param>
|
|
/// <exception cref="TimeoutException">
|
|
/// No renderer attached within <see cref="TerminalWorkspaceOptions.RendererTimeout"/>.
|
|
/// </exception>
|
|
public Task WaitForRendererAsync(CancellationToken cancellationToken) =>
|
|
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
|
|
|
|
/// <summary>Connects to a host and starts a terminal for it.</summary>
|
|
/// <param name="request">What to connect to, as whom, and with what.</param>
|
|
/// <param name="size">The pseudo-terminal's initial size.</param>
|
|
/// <param name="progress">
|
|
/// Told each phase as it begins, or null to report nothing. Reported from the handshake's own thread;
|
|
/// see <see cref="SshConnectionPhase"/>. Optional because a session opened by anything other than the
|
|
/// connecting card has nobody watching a step list for it, which is every caller but one.
|
|
/// </param>
|
|
/// <param name="cancellationToken">Abandons the attempt.</param>
|
|
/// <returns>The session id, which identifies this terminal in the renderer.</returns>
|
|
/// <remarks>
|
|
/// <see cref="SshConnectionPhase.OpeningShell"/> is reported here rather than by the factory because
|
|
/// this is where it happens: the factory's work ends with an authenticated connection, and asking for a
|
|
/// pseudo-terminal on it is a separate round trip this method makes.
|
|
/// </remarks>
|
|
public async Task<uint> OpenSessionAsync(
|
|
SshConnectionRequest request,
|
|
TerminalSize size,
|
|
IProgress<SshConnectionPhase>? progress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var connection = await connections
|
|
.ConnectAsync(request, progress, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
progress?.Report(SshConnectionPhase.OpeningShell);
|
|
|
|
ISshShellSession shell;
|
|
try
|
|
{
|
|
shell = await connection.OpenShellAsync(size, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
await connection.DisposeAsync().ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
|
|
uint sessionId;
|
|
TerminalSessionPump pump;
|
|
|
|
lock (sessionGate)
|
|
{
|
|
sessionId = nextSessionId++;
|
|
pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock);
|
|
}
|
|
|
|
dataPlane.Register(sessionId, pump);
|
|
|
|
// After the connection succeeded and before the run begins. Ordered that way for two reasons: a
|
|
// host-key refusal throws out of ConnectAsync above and must never be recorded as a session that
|
|
// started, and a session whose shell ends immediately must already have a ticket open for the
|
|
// finally below to close.
|
|
ConnectionLog?.Opened(sessionId, Describe(request), clock.GetUtcNow());
|
|
|
|
// Registered before running, so an acknowledgement that arrives with the very first output
|
|
// frame has somewhere to go.
|
|
var run = RunSessionAsync(sessionId, pump);
|
|
|
|
lock (sessionGate)
|
|
{
|
|
sessions[sessionId] = new LiveSession(connection, pump, run);
|
|
}
|
|
|
|
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
|
|
// so the containment check inside it can never run against a dictionary the session had not reached.
|
|
_ = AnnounceEndedAsync(sessionId, pump, run);
|
|
|
|
return sessionId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows one terminal's pane and hides the others.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The page keeps a pane per session and displays one of them, so switching tabs is this frame and
|
|
/// nothing else — no terminal is destroyed, no scrollback is lost, and the shell behind a hidden pane
|
|
/// goes on running and goes on being read. That is the whole reason tabs cost so little here: the
|
|
/// expensive object is the WebView, and there is one of those however many tabs are open.
|
|
/// </para>
|
|
/// <para>
|
|
/// Sent for a session id this workspace does not know as readily as for one it does. The page ignores
|
|
/// a pane it has never created, and refusing here would mean holding a second copy of the tab strip's
|
|
/// idea of what exists — which is the sort of duplicated truth that ends up disagreeing.
|
|
/// </para>
|
|
/// </remarks>
|
|
public ValueTask ActivateSessionAsync(uint sessionId, CancellationToken cancellationToken) =>
|
|
dataPlane.SendAsync(
|
|
TerminalFrame.Create((byte)TerminalServerOpcode.SessionActivated, sessionId, []),
|
|
cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Inserts text into one terminal, as if it had been pasted there.
|
|
/// </summary>
|
|
/// <param name="sessionId">The terminal to insert into.</param>
|
|
/// <param name="text">What to insert. Sent verbatim.</param>
|
|
/// <param name="execute">Whether to press Enter afterwards.</param>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
/// <returns>
|
|
/// <see langword="false"/> when that session's shell is not running, which is an ordinary answer rather
|
|
/// than an error: a tab whose remote hung up an hour ago is still on screen and still selectable, and
|
|
/// somebody clicking a snippet at it has made a mistake worth a sentence, not an exception.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Refused for a dead session rather than sent and dropped.</b> The transport discards frames for a
|
|
/// pane the page no longer has, so sending regardless would look exactly like success — and the one thing
|
|
/// somebody inserting a command needs to know is whether it arrived.
|
|
/// </para>
|
|
/// <para>
|
|
/// The liveness check and the send are deliberately not atomic. A shell that ends between the two is a
|
|
/// race no lock can close — the remote could hang up while the frame is in the socket — so the check is
|
|
/// there to catch the ordinary case honestly, not to make a guarantee it cannot keep.
|
|
/// </para>
|
|
/// </remarks>
|
|
public async Task<bool> PasteAsync(
|
|
uint sessionId,
|
|
string text,
|
|
bool execute,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(text);
|
|
|
|
if (!IsSessionLive(sessionId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var utf8 = Encoding.UTF8.GetBytes(text);
|
|
var payload = new byte[1 + utf8.Length];
|
|
|
|
payload[0] = execute ? (byte)1 : (byte)0;
|
|
utf8.CopyTo(payload, 1);
|
|
|
|
await dataPlane
|
|
.SendAsync(
|
|
TerminalFrame.Create((byte)TerminalServerOpcode.Paste, sessionId, payload),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends keystrokes to one terminal as though they had been typed into it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Ordinary typing does not come through here — it goes from the renderer's own keyboard handling
|
|
/// straight down the socket, which is one hop shorter and is what keeps a fast <c>cat</c> responsive.
|
|
/// This is for input that has no key on the keyboard to produce it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Which on a phone is most of the useful input.</b> A software keyboard has no Ctrl, Esc, Tab or
|
|
/// arrows, so the Android head draws an accessory row and sends the bytes itself; the desktop head
|
|
/// will want the same seam the day it grows a snippet that types into a terminal. Bytes rather than a
|
|
/// key name deliberately: what a terminal wants is a control sequence, and translating one at this
|
|
/// layer would mean owning a keymap that the renderer already owns.
|
|
/// </para>
|
|
/// <para>
|
|
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
|
|
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="sessionId">The terminal to type into.</param>
|
|
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public ValueTask SendInputAsync(
|
|
uint sessionId,
|
|
ReadOnlyMemory<byte> data,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
TerminalSessionPump? pump;
|
|
|
|
lock (sessionGate)
|
|
{
|
|
pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
|
|
}
|
|
|
|
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes one terminal.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Closing one terminal without taking the process down is what a tab close needs, and what it is now
|
|
/// reached for. It is also what <see cref="DisposeAsync"/> uses on the way out, which was its only
|
|
/// caller while the interface had no tabs.
|
|
/// </remarks>
|
|
public async Task CloseSessionAsync(uint sessionId)
|
|
{
|
|
LiveSession? session;
|
|
|
|
lock (sessionGate)
|
|
{
|
|
if (!sessions.Remove(sessionId, out session))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
dataPlane.Unregister(sessionId);
|
|
|
|
// Before the teardown, while the socket is still up. The page holds an xterm instance, its whole
|
|
// scrollback and a WebGL context per pane and never reclaimed one on its own; a frame dropped here
|
|
// is a leak nothing else would notice, because it is inside the WebView.
|
|
await dataPlane
|
|
.SendAsync(
|
|
TerminalFrame.Create((byte)TerminalServerOpcode.SessionRemoved, sessionId, []),
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
|
|
// The connection goes even if the pump's disposal throws. The entry is already out of the dictionary,
|
|
// so nothing will come back for it — not DisposeAsync's loop, which snapshots the keys — and an
|
|
// undisposed ISshConnection is a live authenticated channel to a remote host that this process has
|
|
// forgotten about. Which of the two is more important is not close.
|
|
try
|
|
{
|
|
await session.Pump.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
// Draining, not doing work. This await exists so the pump's loops have finished before the caller
|
|
// moves on; the session is already dead either way, and a fault here is nothing the caller can act
|
|
// on — the pump's last act is a best-effort SessionClosed frame on a socket that may have gone with
|
|
// the window. Letting that escape would fault a tab-close command over a session that closed fine.
|
|
try
|
|
{
|
|
await session.Run.ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Expected on the ordinary path: disposing the pump cancels its run.
|
|
}
|
|
|
|
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
|
|
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
|
|
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
|
|
//
|
|
// Reported as this client's own doing whatever the pump made of it. A shell that had already ended on
|
|
// its own before the tab was closed still carries that older reason, and re-announcing it here as
|
|
// though it had just happened is how closing a tab comes to look like a dropped connection.
|
|
if (Volatile.Read(ref disposed) == 0)
|
|
{
|
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, ClosedByThisClient));
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref disposed, 1) == 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
uint[] open;
|
|
|
|
lock (sessionGate)
|
|
{
|
|
open = [.. sessions.Keys];
|
|
}
|
|
|
|
foreach (var sessionId in open)
|
|
{
|
|
await CloseSessionAsync(sessionId).ConfigureAwait(false);
|
|
}
|
|
|
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
|
await dataPlane.DisposeAsync().ConfigureAwait(false);
|
|
|
|
if (server is not null)
|
|
{
|
|
try
|
|
{
|
|
await server.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected: the accept loop is stopped by cancelling it.
|
|
}
|
|
}
|
|
|
|
lifetime.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
|
|
/// own.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
|
|
/// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
|
|
/// handling rather than leaving an unobserved exception for nobody to see.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
|
|
/// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
|
|
/// the acknowledgement that would return that credit died with it, and without this reset the session
|
|
/// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
|
|
/// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
|
|
/// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
|
|
/// reloaded page does not.
|
|
/// </para>
|
|
/// <para>
|
|
/// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
|
|
/// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
|
|
/// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
|
|
/// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
|
|
/// </para>
|
|
/// <para>
|
|
/// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
|
|
/// already had. That is harmless: every frame in both directions names its session, delivery order
|
|
/// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
|
|
/// created yet is simply dropped, the same as any frame for a session it does not know — see
|
|
/// <c>terminal.js</c>'s <c>handleFrame</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task ReplayAfterAttachAsync()
|
|
{
|
|
KeyValuePair<uint, LiveSession>[] live;
|
|
|
|
lock (sessionGate)
|
|
{
|
|
live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var (sessionId, session) in live)
|
|
{
|
|
session.Pump.Credits.Reset();
|
|
|
|
await dataPlane
|
|
.SendAsync(
|
|
TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
RendererReattached?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
|
|
// leaves nothing worse than the problem this method exists to fix, and there is no caller on
|
|
// this thread left to hand a failure to.
|
|
}
|
|
}
|
|
|
|
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
|
|
{
|
|
try
|
|
{
|
|
await pump.RunAsync(lifetime.Token).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
dataPlane.Unregister(sessionId);
|
|
|
|
// Unconditional, and this one hook covers all three ways a session ends: the user closing the
|
|
// tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
|
|
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
|
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
|
// the application is closing.
|
|
//
|
|
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
|
// AnnounceEndedAsync for what was wrong with that.
|
|
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
|
}
|
|
}
|
|
|
|
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
|
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
|
/// <param name="pump">
|
|
/// The pump, for its <see cref="TerminalSessionPump.Outcome"/> — which is only settled once
|
|
/// <paramref name="run"/> has completed, and is therefore read after the await below rather than captured
|
|
/// alongside it.
|
|
/// </param>
|
|
/// <param name="run">The session's run task.</param>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
|
/// what a handler sees. Inside that finally the run task is not yet complete — a finally is part of the
|
|
/// task — so <see cref="LiveSessionCount"/>, which counts incomplete runs, still included the session
|
|
/// that had just ended. The phone's keep-alive answers this event by reading exactly that count, and
|
|
/// reconciled its foreground notification to "1 shell connected" over a shell that was gone, with
|
|
/// nothing left to fire afterwards and correct it. By the time an await on the run resumes, the task is
|
|
/// complete and the count is honest.
|
|
/// </para>
|
|
/// <para>
|
|
/// The containment check keeps the deliberate paths out of this route: <see cref="CloseSessionAsync"/>
|
|
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task AnnounceEndedAsync(uint sessionId, TerminalSessionPump pump, Task run)
|
|
{
|
|
try
|
|
{
|
|
await run.ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path.
|
|
// This continuation cares only that the run is over, however it got there.
|
|
}
|
|
|
|
bool announce;
|
|
|
|
lock (sessionGate)
|
|
{
|
|
announce = sessions.ContainsKey(sessionId);
|
|
}
|
|
|
|
if (announce)
|
|
{
|
|
// Read after the await, which is the only point at which the pump has settled it — see
|
|
// TerminalSessionPump.Outcome.
|
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
|
}
|
|
}
|
|
|
|
/// <summary>The address as dialled, for the log.</summary>
|
|
/// <remarks>
|
|
/// The username is in here because it is part of the address that was dialled, and an address without one
|
|
/// does not identify the connection — two people reaching one machine as different accounts is the
|
|
/// ordinary case. This is <em>not</em> the same as recording which account authenticated: nothing here
|
|
/// reads the credential, and the payload has no field for one.
|
|
/// </remarks>
|
|
private static string Describe(SshConnectionRequest request) =>
|
|
string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{request.Username}@{request.Host}:{request.Port}");
|
|
|
|
private sealed record LiveSession(ISshConnection Connection, TerminalSessionPump Pump, Task Run);
|
|
}
|