diff --git a/docs/manual-checks.md b/docs/manual-checks.md index ca62099..6886b5f 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -197,6 +197,56 @@ That is what the "is this still the page's socket" guard in `connect()` exists t input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can currently see — see the note at the end of 11.12a. +### 1.11 The right button copies and pastes, and never opens a menu + +Open a shell and run something that prints a few lines — `ls -la /etc`. Drag a selection across part of the +output with the left button, then **right-click inside the selection**. + +**Pass:** the highlight disappears at once, **no menu appears anywhere**, and the text is on the clipboard — +paste it into Notepad, or into the connect box, to be sure. The pane still takes typing straight afterwards. + +Now **right-click again with nothing selected.** + +**Pass:** the clipboard's text appears at the prompt as one insertion and **Enter is not pressed** — it sits +there waiting. On bash it will be highlighted in reverse video until the next keystroke, which is right: that +is bash marking a bracketed paste, and it is how you can see the markers arrived. + +**And the multi-line case, which is the one worth the most care.** Copy three separate lines out of a text +editor — `cd /tmp`, `pwd`, `ls` — and right-click in the terminal. + +**Pass:** all three lines land at the prompt as one thing being edited and **nothing runs** until Enter is +pressed. Three commands executing the moment you right-click is the failure this is looking for. + +**On a Mac, one more:** right-click over a word with nothing selected and confirm it pastes rather than +selecting the word under the pointer. xterm.js defaults `rightClickSelectsWord` to true on macOS alone; +`terminal.js` turns it off, and this is the one platform where forgetting to would show. + +**What the tests already own.** `RendererClipboardTests` runs `terminal.js` itself and proves which frame a +right-click sends, which session it is addressed to, that the selection is dropped after a copy and that the +answer comes back through `paste()` rather than `input()`. `ShellFlowTests` proves the round trip through the +host's own clipboard. What is left here is the WebView: that a real `contextmenu` event reaches a listener +registered in the capture phase, that no native menu is drawn once it is prevented, and that xterm's real +`paste()` brackets the text — none of which a Jint engine can answer. + +**Failure means:** + +- **A context menu appears.** The event is not being prevented, or not being seen. `handleRightClick` takes + it in the capture phase on the pane; a listener registered without the third argument to + `addEventListener` would still prevent the menu, so a menu means the listener is not attached at all. +- **A permission prompt appears** — *"…wants to see text and images copied to the clipboard"*. Somebody has + routed the read through `navigator.clipboard.readText()`, which is exactly what the `ClipboardPaste` + opcode exists to avoid. The clipboard is the host's. +- **A drag started exactly where you last right-clicked selects nothing.** xterm's own `contextmenu` + handler ran: it parks its invisible helper textarea under the pointer, 20 pixels square at z-index 1000, + to serve the native menu. The page stops the event as well as preventing it, and `stopPropagation` is the + half that was left out. Subtle enough to be worth looking for on purpose rather than waiting to notice. +- **Three commands run on a multi-line paste.** The text went through `input()` rather than `paste()`, so + bracketed paste never happened and the shell read each newline as "run this". That is the whole reason the + `Clipboard` opcode is separate from `Paste`; see `TerminalFrame.cs`. +- **Nothing happens at all, in either direction.** The head is not passing a clipboard. Both pass one — see + `ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent + when neither is there, because there is no status line on this screen to say it on. + --- ## Phase 2 — Known Hosts as its own page @@ -1993,6 +2043,26 @@ without either end noticing, which a suspended renderer can leave behind — sho connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a liveness probe rather than a faster retry. +### 11.13 The right button on a phone with a pointer · **needs a mouse, a trackpad or a dock** + +The same page as 1.11 runs on this head, so the gesture is here too — but only for a device that has a +secondary click at all. Attach a mouse or use a keyboard case's trackpad, or dock the phone, open a shell, +select some output and right-click it; then right-click again with nothing selected. + +**Pass:** exactly as 1.11 — the selection drops with no menu, and the second click pastes. + +**Unverified, and that is why this check exists.** Nothing here has been run against Android's WebView with +a pointer attached, so whether it delivers a secondary click as a DOM `contextmenu` event at all is an open +question. `ClipboardReader` is wired on this head regardless, because the alternative is a head where the +paste half silently does nothing and nobody finds out. + +**Failure means:** if the copy works and the paste does not, the clipboard read is being refused — Android +returns nothing from the clipboard to an app that is not the foreground one, and a WebView taking the +secondary click may be leaving Avalonia's window in a state where `TopLevel` has none. If neither works, +Android is not delivering the click as a `contextmenu` event, and the gesture needs a native listener on the +`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer +has no right button to miss. + --- ## Phase 12 — Shared vaults: the operations that span two accounts diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index 91f4ce3..ad53d6b 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -86,7 +86,13 @@ public sealed partial class DodoSshApp : Avalonia.Application var shell = new PhoneShell(); var viewModel = ComposeShell( - paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell)); + paths, + caches, + workspace, + knownHosts, + connections, + ClipboardWriter(shell), + ClipboardReader(shell)); shell.DataContext = viewModel; @@ -176,6 +182,26 @@ public sealed partial class DodoSshApp : Avalonia.Application } }; + /// Reads this phone's clipboard. + /// + /// + /// The other direction, looked up per call for the same reason and with the same fall-through. One + /// caller: the terminal's right-click paste, which the renderer cannot serve itself — see + /// TerminalClientOpcode.ClipboardPaste. + /// + /// + /// Wired on this head although a phone has no right button, and not for symmetry: this head runs the same + /// renderer page, and a device with a mouse, a trackpad case or a desktop dock has a secondary click. + /// Whether Android's WebView delivers one as a DOM contextmenu event is unverified — see + /// docs/manual-checks.md 11.13 — and leaving this out in the meantime would make the paste half + /// the one thing that worked on one head and silently did nothing on the other. + /// + /// + private static Func> ClipboardReader(Visual surface) => + async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard } + ? await clipboard.TryGetTextAsync().ConfigureAwait(false) + : null; + /// /// Split from only for length. The division is a real one though: above this is /// the platform graph, and below it is the shell every head shares. @@ -186,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application TerminalWorkspace workspace, VaultKnownHostStore knownHosts, SshNetConnectionFactory connections, - Func copyToClipboard) + Func copyToClipboard, + Func> readClipboard) { // Difference 3: the Android keystore, with a fingerprint or the device credential releasing the // key. A straight implementation of the interface the session layer has always taken. @@ -236,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application deviceName: PhoneEnvironment.DeviceName, copyToClipboard: copyToClipboard, + readClipboard: readClipboard, updates: updates); diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs index 090b3c4..69e3e85 100644 --- a/src/DodoSSH.Client.App/App.axaml.cs +++ b/src/DodoSSH.Client.App/App.axaml.cs @@ -72,6 +72,17 @@ internal sealed partial class DodoSshApp : Application } }; + /// Reads this machine's clipboard. + /// + /// The other direction, looked up per call for the same reason and with the same fall-through. One + /// caller: the terminal's right-click paste, which the renderer cannot serve itself — see + /// TerminalClientOpcode.ClipboardPaste. + /// + private static Func> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) => + async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard } + ? await clipboard.TryGetTextAsync().ConfigureAwait(false) + : null; + /// The terminal workspace, with its loopback listener already up. /// /// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to @@ -169,6 +180,7 @@ internal sealed partial class DodoSshApp : Application .ConfigureAwait(false), copyToClipboard: ClipboardWriter(desktop), + readClipboard: ClipboardReader(desktop), updates: updates, openInEditor: OpenInDefaultEditorAsync, watchFile: WatchFile); diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index 9ef7a75..f1b7610 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -328,6 +328,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// private readonly Func? copyToClipboard; + /// + /// The other direction, and it exists for one caller: a right-click in a terminal with nothing selected. + /// The renderer cannot read a clipboard — says why — + /// so the gesture crosses the socket and is answered here. Null on a head that wires none, which is now + /// no head at all: it stays nullable because this class has to be constructible without a visual, which + /// is what every test that drives it does. + /// + private readonly Func>? readClipboard; + /// private readonly Action post; @@ -493,6 +502,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles /// the Android head's distribution separately, so it must never acquire one by accident. /// + /// + /// Reads this machine's clipboard, or null on a head that cannot. Answers the terminal's right-click + /// paste and nothing else — see the field it is kept in. + /// /// /// Opens a local path with whatever this machine opens that kind of file with, or null on a head that /// has no such notion. Forwarded straight to , which is the only thing @@ -527,6 +540,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp Argon2Profile? passphraseProfile = null, ResumeHandler? resume = null, Func? copyToClipboard = null, + Func>? readClipboard = null, string? deviceName = null, IUpdateChannel? updates = null, Action? post = null, @@ -545,6 +559,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp this.clock = clock; this.passphraseProfile = passphraseProfile; this.copyToClipboard = copyToClipboard; + this.readClipboard = readClipboard; // Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a // blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail @@ -575,6 +590,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; this.workspace.FontSizeStepRequested += OnFontSizeStepRequested; + this.workspace.ClipboardCopyRequested += OnClipboardCopyRequested; + this.workspace.ClipboardPasteRequested += OnClipboardPasteRequested; this.workspace.RendererReattached += OnRendererReattached; settings = new ClientSettingsStore(paths); @@ -726,6 +743,106 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) => Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step)); + /// + /// A right-click over a selection: the page has sent the text, and this puts it on the clipboard. + /// + /// + /// + /// Marshalled for the reason is — this arrives on the data plane's + /// receive loop — plus one of its own, which is the stronger: an Avalonia clipboard is reached through a + /// TopLevel, and a TopLevel is only safe to touch from the thread that owns the window. + /// + /// + /// Through rather than Dispatcher.UIThread directly, unlike the handlers + /// beside it: those predate the delegate and are the ones this shell's suite records as out of its + /// reach. There is no reason to add another. + /// + /// + private void OnClipboardCopyRequested(object? sender, TerminalClipboardCopyEventArgs e) => + post(() => _ = CopyFromTerminalAsync(e.Text)); + + /// + /// A right-click with nothing selected: reads the clipboard and hands it to the pane that asked. + /// + /// Marshalled for the reasons gives. + private void OnClipboardPasteRequested(object? sender, TerminalClipboardPasteEventArgs e) => + post(() => _ = PasteIntoTerminalAsync(e.SessionId)); + + /// + /// + /// Silence where there is no clipboard, and that is a different answer from the keychain's COPY buttons + /// — which say so, because a button that appears to do nothing is worse than a refusal. There is nowhere + /// to say it from here: the shell's status line is drawn on the enroll, sign-in and unlock cards, none + /// of which is on screen while somebody is right-clicking a terminal. Inventing a channel for a branch + /// no shipped head reaches would be the wrong trade, and both heads wire a clipboard. + /// + /// + /// A throw is swallowed rather than left to become an unobserved task exception. A clipboard is a shared + /// machine resource — on Windows another process holding it open is an ordinary, transient failure — and + /// a copy that did not happen is worth exactly one more right-click. + /// + /// + private async Task CopyFromTerminalAsync(string text) + { + if (copyToClipboard is null) + { + return; + } + + try + { + await copyToClipboard(text).ConfigureAwait(true); + } + catch (Exception) + { + // Deliberately silent; see the remark for both halves of why. Nothing is left half done — the + // clipboard either took the text or did not. + } + } + + /// + /// + /// An empty clipboard ends here rather than at the renderer. Sending it would bracket nothing and hand + /// the remote an empty paste, which is a keystroke nobody made; "there was nothing to paste" is a fact + /// about the machine rather than about this terminal, and the honest answer to it is that the + /// right-click did nothing — which is what every other terminal does with an empty clipboard too. + /// + /// + /// The answer for a session whose shell has ended is discarded for the reason + /// gives for refusing it: nothing visible would + /// happen either way, and there is nowhere on this screen to say so. + /// + /// + /// A throw is swallowed, as in and for the same reason. + /// + /// + private async Task PasteIntoTerminalAsync(uint sessionId) + { + if (readClipboard is null) + { + return; + } + + try + { + var text = await readClipboard().ConfigureAwait(true); + + if (string.IsNullOrEmpty(text)) + { + return; + } + + await workspace + .SendClipboardAsync(sessionId, text, CancellationToken.None) + .ConfigureAwait(true); + } + catch (Exception) + { + // Deliberately silent; see the remark. The read and the send are both inside it, because + // whichever of the two failed leaves the same nothing on screen. + } + } + /// /// /// Marshalled for the same reason as : this arrives on the data @@ -3203,6 +3320,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp workspace.SessionEnded -= OnWorkspaceSessionEnded; workspace.FontSizeStepRequested -= OnFontSizeStepRequested; + workspace.ClipboardCopyRequested -= OnClipboardCopyRequested; + workspace.ClipboardPasteRequested -= OnClipboardPasteRequested; workspace.RendererReattached -= OnRendererReattached; transfers.PropertyChanged -= OnTransfersPropertyChanged; diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js index 20b3e85..50c91b7 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js +++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js @@ -33,11 +33,14 @@ const SERVER_SESSION_ACTIVATED = 4; const SERVER_SESSION_REMOVED = 5; const SERVER_PASTE = 6; const SERVER_FONT_SIZE = 7; +const SERVER_CLIPBOARD = 8; const CLIENT_INPUT = 1; const CLIENT_ACKNOWLEDGE = 2; const CLIENT_RESIZE = 3; const CLIENT_FONT_SIZE_STEP = 4; +const CLIENT_CLIPBOARD_COPY = 5; +const CLIENT_CLIPBOARD_PASTE = 6; const HEADER_LENGTH = 5; const SCROLLBACK_LINES = 5000; @@ -279,6 +282,66 @@ function handleKey(event) { return true; } +/* + ── THE RIGHT-CLICK IS A COPY OR A PASTE, AND NEVER A MENU ─────────────────────────────────────────── + + What a terminal's right button has meant since the Windows console got QuickEdit and since PuTTY + shipped: with something selected it copies and drops the selection, with nothing selected it pastes. + No menu, no second click. This page used to answer with the WebView's own context menu — xterm stuffs + its helper textarea with the selection on contextmenu precisely so that the browser's Copy entry + works — which is correct behaviour for a web page and wrong for a terminal. + + ◆ 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 the font-size chords do — see requestFontSizeStep. Writing would have + worked from here on Windows, and does not go that way either: 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. + + ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. + + preventDefault alone would suppress the menu and still let xterm's own contextmenu handler run, which + moves the 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; with no menu they are an invisible textarea + floating over the grid and a DOM selection nothing will clear. Taking the event in the capture phase + and stopping it there is what keeps the gesture wholly ours. + + rightClickSelectsWord is off for the same reason from the other side — see createSession. +*/ +function handleRightClick(event, sessionId) { + event.preventDefault(); + event.stopPropagation(); + + const session = sessions.get(sessionId); + + if (!session) { + return; + } + + const selection = session.term.getSelection(); + + if (selection.length > 0) { + send(CLIENT_CLIPBOARD_COPY, sessionId, new TextEncoder().encode(selection)); + + /* + And the selection goes, which is the console's behaviour and is load-bearing rather than cosmetic. + It is the only acknowledgement there is that the copy happened — nothing else on screen moves — and + it is what makes the *next* right-click a paste. Left standing, a selection somebody had forgotten + about would turn every later right-click into a copy of it, and the paste would look broken. + */ + session.term.clearSelection(); + } else { + send(CLIENT_CLIPBOARD_PASTE, sessionId); + } + + // Right-clicking a pane is a way of arriving at it. The caret follows, for the reason the paste frame's + // own focus() call gives. + session.term.focus(); +} + /** * Builds a pane for a session, or returns the one already there. * @@ -302,6 +365,19 @@ function createSession(sessionId) { convertEol: false, cursorBlink: true, fontSize, + + /* + Off, and on macOS that is a change from the default rather than a restatement of it: xterm defaults + this to true there, so a right-click would select the word under the pointer. With the gesture + meaning copy-or-paste, a right-click that quietly makes a selection is a right-click that copies a + word instead of pasting — and only on one platform, which is the worst way for it to be wrong. + + Belt and braces today, because handleRightClick takes the event in the capture phase and xterm never + sees it. It is here for the upgrade that moves xterm's handler to mousedown, which is where it + already is on the Firefox path. + */ + rightClickSelectsWord: false, + scrollback: SCROLLBACK_LINES, // Matches terminal.css, so the canvas and the page agree on the background. Both are the v2 // design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css. @@ -346,6 +422,10 @@ function createSession(sessionId) { term.attachCustomKeyEventHandler(handleKey); + // On the pane rather than on xterm's own element, and in the capture phase, which is what puts this + // ahead of xterm's handler on the element inside it. See handleRightClick. + pane.addEventListener('contextmenu', (event) => handleRightClick(event, sessionId), true); + term.onData((data) => { send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data)); }); @@ -562,6 +642,35 @@ function handleFrame(buffer) { break; } + case SERVER_CLIPBOARD: { + const session = sessions.get(sessionId); + + if (!session || payload.length < 1) { + break; + } + + /* + paste() unconditionally, and that is the whole difference from SERVER_PASTE above. This text came + off the system clipboard, which is the case bracketed paste is *for*: readline uses the markers to + decide it has been pasted into, and a shell that gets a multi-line command inside them treats every + newline as text rather than as "run this". The reasoning that makes a single-line *snippet* worth + typing instead — bash marking a bracketed paste as an active region, so the command sits at the + prompt looking selected — is the reasoning for a snippet reading as though it had been typed. A + clipboard paste is not pretending to be typing, and every other terminal highlights it too. + + No execute flag, either: a paste inserts and stops. Pressing Enter for somebody is what the + sidebar's RUN does, and it is a thing they asked for by name. + */ + session.term.paste(new TextDecoder().decode(payload)); + + // The caret goes where the text landed, for the reason SERVER_PASTE's own focus() call gives — half + // of it, anyway. Here the other half is already right: the gesture that asked for this was a click + // on the pane, so the host's window never took Win32 focus away. + session.term.focus(); + + break; + } + case SERVER_FONT_SIZE: { if (payload.length < 1) { break; @@ -808,4 +917,15 @@ document.addEventListener('visibilitychange', () => { window.addEventListener('focus', reconnectIfDown); window.addEventListener('online', reconnectIfDown); +/* + And nowhere on this page has a context menu, which the panes' own handler nearly finishes on its own: they + are positioned at inset 0, so every right-click over a terminal lands on one and is stopped there before it + reaches here. + + What is left is the status banner, which is drawn over the bottom of the pane while it has something to say + — so a right-click on "Reconnecting the terminal view…" would have been the one place in a terminal window + that still opened a browser menu. Prevented and not stopped: there is nothing below this to stop it from. +*/ +document.addEventListener('contextmenu', (event) => event.preventDefault()); + connect(); diff --git a/src/DodoSSH.Client.Shell/WebAssets/vendor/README.md b/src/DodoSSH.Client.Shell/WebAssets/vendor/README.md index 06a65bd..24fa42f 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/vendor/README.md +++ b/src/DodoSSH.Client.Shell/WebAssets/vendor/README.md @@ -24,5 +24,11 @@ These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon. 1. Download the four files at the new versions and update the table above. 2. Confirm the UMD global names are unchanged. -3. Run the terminal suite, then start the app and open a session: nothing here is covered by the - .NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page. +3. Confirm the right-click still belongs to `terminal.js`. Two things about xterm are load-bearing for it + and neither is a global: `rightClickSelectsWord` has to still be an option — the page sets it false, and + an option xterm no longer knows is silently ignored — and xterm has to still register its own right-click + handling on `contextmenu`, which `handleRightClick` gets ahead of in the capture phase. A build that moved + that to `mousedown`, as it already is on the Firefox path, would put xterm back in the gesture. +4. Run the terminal suite, then start the app and open a session: `RendererClipboardTests` runs the page's + own logic against a stubbed terminal, so what is left for a person is everything about the real one — + `docs/manual-checks.md` 1.11 is that list. diff --git a/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs index 6e88232..5bb88ef 100644 --- a/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs +++ b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs @@ -105,6 +105,23 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable /// public event EventHandler? FontSizeStepRequested; + /// Raised when the page has text for the system clipboard. + /// + /// A right-click over a selection. Raised on the socket's receive loop, like + /// and with the same obligation on a handler that touches a view + /// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to + /// be marshalled even where nothing is bound to. + /// + public event EventHandler? ClipboardCopyRequested; + + /// Raised when the page wants the system clipboard's text pasted into one of its panes. + /// + /// A right-click with nothing selected. Raised on the socket's receive loop; see + /// . The answer goes back as + /// , which is 's to send. + /// + public event EventHandler? ClipboardPasteRequested; + /// /// Raised after a socket attaches — the first one, and every later takeover. /// @@ -463,16 +480,8 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable return; } - // Answered before the session lookup, because it is the one frame that is not about a session. The - // page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just - // ended is still a request to make the text bigger. - if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep) + if (Announce(opcode, sessionId, payload)) { - if (TerminalFrame.TryReadFontSizeStep(payload, out var step)) - { - FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step)); - } - return; } @@ -518,6 +527,59 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable } } + /// + /// Raises the frames no pump needs to see, and says whether this was one of them. + /// + /// + /// + /// Answered before the session lookup, because none of these three touches a pump. A text-size chord is + /// about the person reading rather than about one terminal, and the page sends whichever id it had to + /// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back + /// rather than by writing to a pty, and does its own + /// liveness check — which is the one that can honestly refuse. + /// + /// + /// Which is also what keeps all three working in a pane whose shell has just ended, and that is when + /// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes + /// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab + /// the one place the gesture silently did nothing. + /// + /// + private bool Announce(byte opcode, uint sessionId, ReadOnlySpan payload) + { + switch ((TerminalClientOpcode)opcode) + { + case TerminalClientOpcode.FontSizeStep: + if (TerminalFrame.TryReadFontSizeStep(payload, out var step)) + { + FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step)); + } + + return true; + + case TerminalClientOpcode.ClipboardCopy: + // An empty copy is dropped rather than forwarded. The clipboard is shared with everything + // else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever + // was on it — and the page does not send one, which makes this a guard against a frame + // arriving from something other than the page we shipped. + if (payload.Length > 0) + { + ClipboardCopyRequested?.Invoke( + this, + new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload))); + } + + return true; + + case TerminalClientOpcode.ClipboardPaste: + ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId)); + return true; + + default: + return false; + } + } + /// /// Computes the Sec-WebSocket-Accept value RFC 6455 §4.2.2 requires. /// diff --git a/src/DodoSSH.Client.Terminal/TerminalFrame.cs b/src/DodoSSH.Client.Terminal/TerminalFrame.cs index 5eda2cf..5d318a0 100644 --- a/src/DodoSSH.Client.Terminal/TerminalFrame.cs +++ b/src/DodoSSH.Client.Terminal/TerminalFrame.cs @@ -95,6 +95,31 @@ public enum TerminalServerOpcode : byte /// /// FontSize = 7, + + /// + /// The system clipboard's text, for this session's pane to paste. Payload is UTF-8. + /// + /// + /// + /// Sent in answer to , which is the only thing that + /// asks for it: a right-click over a pane with nothing selected. The host reads the clipboard because + /// the page cannot — see that opcode for why not. + /// + /// + /// Not , and the difference is the bracketing. That opcode 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 this one is always bracketed. One opcode for both would need a flag + /// meaning "which of the two kinds of paste is this", which is the same distinction spelled less + /// clearly, on a payload that already has a flag byte meaning something else. + /// + /// + /// No flag byte, therefore, and no Enter: a clipboard paste inserts and stops. Pressing Enter for + /// somebody is what the sidebar's RUN does, and it is a thing they asked for by name. + /// + /// + Clipboard = 8, } /// Frames the renderer sends to the host. @@ -131,6 +156,52 @@ public enum TerminalClientOpcode : byte /// /// FontSizeStep = 4, + + /// + /// Text to put on the system clipboard. Payload is UTF-8. + /// + /// + /// + /// A right-click over a pane with something selected. What gets copied is the selection, and only the + /// page knows what that is — xterm draws it into a canvas and the host never sees a glyph of it — so it + /// is the text that travels here rather than a request for it. + /// + /// + /// Through the host rather than through the page's own navigator.clipboard. Writing the + /// clipboard from the page would work on Windows, where the renderer is Blink and + /// clipboard-write is granted to a focused document without asking. It is the reading + /// that cannot be done that way — see — and 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; this is that one. + /// + /// + /// The session id is the pane the selection came from. Nothing routes on it — a clipboard is not a + /// property of one terminal — and it is carried because every frame carries one. + /// + /// + ClipboardCopy = 5, + + /// + /// The page asking for the clipboard's text, to paste into this session. No payload. + /// + /// + /// + /// A right-click over a pane with nothing selected. The answer arrives as + /// , addressed to the session id this frame carried, so the + /// text lands in the pane the pointer was over rather than in whichever one the host believes is + /// selected. They agree in every arrangement that exists today, because only the active pane is + /// visible — but the page is the one that knows, so the page is the one that says. + /// + /// + /// A request rather than the page reading the clipboard itself, and this is the half with no + /// alternative. navigator.clipboard.readText() is gated behind the clipboard-read + /// permission in every Chromium, and an embedded WebView answers a permission request it has no handler + /// for by putting a prompt on the screen. Trading a context menu for a permission dialog is not the + /// change this was. + /// + /// + ClipboardPaste = 6, } /// diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs index 06e68ca..72962d6 100644 --- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs +++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs @@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs public int Step { get; } = step; } +/// The renderer handing over text for the system clipboard. +/// +/// 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. +/// +public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs +{ + /// What to put on the clipboard. + public string Text { get; } = text; +} + +/// The renderer asking for the system clipboard's text. +/// +/// 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. +/// +public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs +{ + /// The terminal to paste into. + public uint SessionId { get; } = sessionId; +} + /// /// Owns the loopback data plane and every live terminal session. /// @@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable // 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 @@ -313,6 +341,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable /// public event EventHandler? FontSizeStepRequested; + /// Raised when a right-click over a selection asks for it to be copied. + /// + /// The gesture can only be heard by the page, for the reason gives, + /// and the selection can only be read 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. + /// + public event EventHandler? ClipboardCopyRequested; + + /// Raised when a right-click with nothing selected asks for the clipboard to be pasted. + /// + /// Answered with , 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. + /// + public event EventHandler? ClipboardPasteRequested; + /// /// Raised once a (re)attached renderer has been sent everything this workspace owns for it. /// @@ -359,6 +405,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable TerminalFrame.CreateFontSizePayload(pixels)), cancellationToken); + /// + /// Hands the system clipboard's text to one pane, to be pasted into it. + /// + /// + /// + /// The answer to , and the only thing that sends this frame. Always + /// bracketed and never followed by an Enter — see for why a + /// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode. + /// + /// + /// Refused for a dead session, exactly as 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. + /// + /// + /// 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. + /// + /// + /// The terminal to paste into. + /// The clipboard's text. Sent verbatim. + /// Cancellation. + /// when that session's shell is not running. + public async Task 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; + } + /// /// Waits until the renderer page has attached its socket. /// diff --git a/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs b/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs index 0f2218e..6c868ca 100644 --- a/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs +++ b/tests/DodoSSH.Client.App.Tests/FakeRenderer.cs @@ -71,6 +71,63 @@ internal sealed partial class FakeRenderer : IAsyncDisposable return renderer; } + /// Sends a client frame, as the page would. + /// + /// Raw rather than through a helper per opcode: the two this exists for carry a session id and either + /// UTF-8 or nothing at all, and a method each would be three lines of ceremony around + /// . + /// + internal async Task SendAsync( + TerminalClientOpcode opcode, + uint sessionId, + ReadOnlyMemory payload, + CancellationToken cancellationToken) => + await socket + .SendAsync( + TerminalFrame.Create((byte)opcode, sessionId, payload.Span), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken) + .ConfigureAwait(false); + + /// Reads frames until one carries that opcode, and answers with it. + /// + /// + /// Skipping rather than expecting the next frame to be the interesting one, because a live session's + /// socket is not quiet: the shell's opening frame, its activation and whatever the remote said are all + /// on the way, and which of them have arrived by the time a test looks is a scheduling accident. + /// + /// + /// Bounded only by the caller's token, which in this suite is the test's own — so a frame that never + /// comes fails the test on its timeout rather than hanging the run. + /// + /// + internal async Task WaitForAsync( + TerminalServerOpcode opcode, + CancellationToken cancellationToken) + { + var buffer = new byte[64 * 1024]; + + while (true) + { + var result = await socket + .ReceiveAsync(buffer.AsMemory(), cancellationToken) + .ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + throw new InvalidOperationException( + $"The host closed the socket before sending a {opcode} frame."); + } + + if (TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var read, out _, out var payload) + && read == (byte)opcode) + { + return payload.ToArray(); + } + } + } + /// public async ValueTask DisposeAsync() { diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index f311b37..057d859 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -33,6 +33,15 @@ public sealed class ShellFlowTests : IAsyncLifetime /// Everything this shell has copied, newest last. private readonly List clipboard = []; + /// + /// What the machine's clipboard holds, as far as this shell can read it. + /// + /// + /// Null rather than empty by default, which is what a clipboard holding something that is not text + /// answers. Set by the one test that needs the shell to read one — the terminal's right-click paste. + /// + private string? machineClipboard; + /// /// Far below the shipped profile, for the same reason as everywhere else: these tests are about the /// state machine, not about how expensive the passphrase is to attack. @@ -164,6 +173,10 @@ public sealed class ShellFlowTests : IAsyncLifetime return Task.CompletedTask; }, + // And the other direction, which only the terminal's right-click paste uses. A closure over the + // field rather than a value captured now, because a test sets it after the shell is built. + readClipboard: () => Task.FromResult(machineClipboard), + // Inline, because this suite has no window and therefore no dispatcher to drain — the same // answer TransferQueueingTests reached, and for the reason its own remark gives: reaching // Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched @@ -294,6 +307,90 @@ public sealed class ShellFlowTests : IAsyncLifetime } } + // ---- The terminal's right-click, which is a copy or a paste ---- + + /// + /// + /// The gesture is heard by the page and nowhere else — the selection is drawn into a canvas, and a + /// focused terminal leaves the host's window no input events at all — so the shell's part is to be the + /// clipboard. This is that wiring end to end: a real frame on the real socket, through the workspace, to + /// the delegate a head supplies. + /// + /// + /// Sent with no session open at all, deliberately. A copy is not about a terminal, and the frame is + /// dispatched before the session lookup precisely so that copying the last thing a dead tab said still + /// works — which is when somebody is most likely to want it. + /// + /// + [Fact] + public async Task ARightClickCopy_ReachesTheMachinesClipboard() + { + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + + await renderer.SendAsync( + TerminalClientOpcode.ClipboardCopy, + sessionId: 0, + System.Text.Encoding.UTF8.GetBytes("error: nothing was written"), + Token); + + await EventuallyAsync( + () => clipboard.Contains("error: nothing was written", StringComparer.Ordinal), + "the copy frame should have reached the clipboard delegate"); + } + + /// + /// The other direction, and the half the page cannot do for itself: reading a clipboard from a WebView + /// needs the clipboard-read permission, which an embedded WebView answers with a prompt. So the + /// page asks, the shell reads, and the answer goes back down the same socket addressed to the pane that + /// asked — which is what this follows all the way round. + /// + [Fact] + public async Task ARightClickPaste_SendsTheMachinesClipboardBackToThePaneThatAsked() + { + var vault = await ReadyToConnectAsync(); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId; + + machineClipboard = "sudo systemctl restart sshd"; + + await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token); + + var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token); + + System.Text.Encoding.UTF8.GetString(payload).ShouldBe("sudo systemctl restart sshd"); + } + + /// + /// An empty clipboard is answered with nothing rather than with an empty paste, which the page would + /// bracket and hand the remote as a keystroke nobody made. Proven by the frame that does come + /// after it: the receive loop is sequential, so a clipboard frame for the second request arriving means + /// the first was seen and passed over. + /// + [Fact] + public async Task ARightClickPaste_SendsNothingWhenTheClipboardHasNoText() + { + var vault = await ReadyToConnectAsync(); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + var sessionId = shell.SelectedTab.ShouldNotBeNull().SessionId; + + machineClipboard = null; + await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token); + + machineClipboard = "uptime"; + await renderer.SendAsync(TerminalClientOpcode.ClipboardPaste, sessionId, default, Token); + + var payload = await renderer.WaitForAsync(TerminalServerOpcode.Clipboard, Token); + + System.Text.Encoding.UTF8.GetString(payload) + .ShouldBe("uptime", "the empty clipboard sent no frame of its own"); + } + [Fact] public async Task AFreshMachine_AsksForAServer() { diff --git a/tests/DodoSSH.Client.Terminal.Tests/Renderer/renderer-harness.js b/tests/DodoSSH.Client.Terminal.Tests/Renderer/renderer-harness.js index 878be07..10a21bf 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/Renderer/renderer-harness.js +++ b/tests/DodoSSH.Client.Terminal.Tests/Renderer/renderer-harness.js @@ -5,17 +5,36 @@ It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page - reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection - path builds a pane — a test that opens a session will have to add them, and should, rather than this - file guessing now at what such a test would want. + reads at load. + + ◆ AND, SINCE RendererClipboardTests, A PANE. + + Which the reconnection tests never needed and this file used to say so, inviting whoever first opened a + session to add the Terminal, the fit addon and the WebGL addon rather than guessing at them in advance. + They are below, and they are stubs with a memory: a fake terminal records what was pasted into it, what + was typed into it and what it was asked to focus, because those three are the whole of what the page does + to a pane and therefore the whole of what a test can ask about. The selection is settable from a test, + since a selection is made with a mouse in a canvas and there is no canvas here. + + What a fake terminal is NOT is xterm. It cannot say whether paste() brackets its argument or whether + getSelection() trims a row's right padding — that is xterm's behaviour, exercised by xterm's own suite and + by docs/manual-checks.md 1.11 here. What these tests can prove is which of the two the page calls, with + what, and addressed to which session, and that is where every mistake in this area has actually been. ◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES. - Both files are evaluated as scripts into the same global scope, and the page's own `const root` against - a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are + Both files are evaluated as scripts into the same global scope, and a collision goes one of two ways. + Against one of the page's `const`s — its own `root`, say — it is a SyntaxError before a line of either + runs, which is the good outcome: it is impossible to miss. That is why the page's two elements are `pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them anyway. + Against one of the page's *functions* there is no error at all. The page is evaluated second, so its + declaration silently replaces this file's and every test calling that name gets the page's function + instead — which is how `frame()` here, meant to read a sent frame back, quietly returned the ArrayBuffer + that terminal.js's own frame() builds instead, and failed six tests on a type error a long way from the + cause. Hence `sentFrames`/`sentFrame` below: prefixed on purpose, not for tidiness. + ◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT. close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed @@ -30,6 +49,15 @@ var clock = { now: 0, next: 1, timers: {} }; /** Every socket the page has opened, in order, live or dead. */ var sockets = []; +/** Every frame the page has sent, in order, as arrays of byte values. */ +var outbound = []; + +/** Every fake terminal the page has opened on a pane, in the order it opened them. */ +var terminals = []; + +/** What the last right-click did about the browser's own menu. See rightClick(). */ +var gesture = { prevented: 0, stopped: 0 }; + function listeners(target) { target.handlers = {}; @@ -41,11 +69,11 @@ function listeners(target) { target.handlers[name].push(handler); }; - target.fire = function (name) { + target.fire = function (name, event) { var handlers = target.handlers[name] || []; for (var i = 0; i < handlers.length; i++) { - handlers[i]({}); + handlers[i](event || {}); } }; @@ -67,7 +95,22 @@ FakeSocket.OPEN = 1; FakeSocket.CLOSING = 2; FakeSocket.CLOSED = 3; -FakeSocket.prototype.send = function () {}; +/** + * Records rather than discards, because half of what the page does on a right-click is a frame. + * + * Kept as raw bytes and decoded on the way out — see sentFrame() — so that a payload the page wrote as + * UTF-8 is read back as UTF-8 rather than as whatever a stringification would have made of it. + */ +FakeSocket.prototype.send = function (buffer) { + var bytes = new Uint8Array(buffer); + var copy = []; + + for (var i = 0; i < bytes.length; i++) { + copy.push(bytes[i]); + } + + outbound.push(copy); +}; /** What the page calls. See the note above on why a connecting socket goes quietly. */ FakeSocket.prototype.close = function () { @@ -93,9 +136,31 @@ var pageRoot = listeners({ var document = listeners({ hidden: false, getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; }, - createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; }, + createElement: function () { return newElement(); }, }); +/** + * A pane, as far as the page can tell. + * + * With real dimensions, and not because anything would fail without them — which is the point. The page's + * guard is `clientWidth < MINIMUM_FITTABLE_PIXELS`, and an undefined width compares false against every + * number, so a fake with no dimensions slips past a rule it ought to have been caught by and gets fitted + * anyway. A fake that passes for the wrong reason stops matching the page the day that guard is written + * differently. + * + * Listeners, because the right-click handler is registered on this and there is nowhere else to fire it + * from. + */ +function newElement() { + return listeners({ + dataset: {}, + style: {}, + clientWidth: 800, + clientHeight: 600, + remove: function () {}, + }); +} + var window = listeners({}); // The page warns through this on paths no reconnection test reaches. Present so that a test which does @@ -118,6 +183,142 @@ function ResizeObserver() { this.observe = function () {}; } +// ── The pane's three globals, which the page reaches by the names the UMD builds attach ────────────── + +/** + * A terminal that remembers what was done to it. + * + * `selection` is a plain field rather than anything computed, and settable from a test through select(): + * a selection is made by dragging a mouse across a canvas, which is the one part of this there is no + * standing in for. What the tests are about is what the page does with whatever is selected. + */ +function FakeTerminal(options) { + this.options = options || {}; + this.cols = 80; + this.rows = 24; + this.selection = ''; + this.pastes = []; + this.inputs = []; + this.writes = []; + this.focuses = 0; + this.disposed = false; + this.pane = null; + + terminals.push(this); +} + +FakeTerminal.prototype.loadAddon = function () {}; +FakeTerminal.prototype.open = function (pane) { this.pane = pane; }; +FakeTerminal.prototype.attachCustomKeyEventHandler = function (handler) { this.keys = handler; }; +FakeTerminal.prototype.onData = function (handler) { this.data = handler; }; +FakeTerminal.prototype.onResize = function (handler) { this.resized = handler; }; +FakeTerminal.prototype.focus = function () { this.focuses++; }; +FakeTerminal.prototype.paste = function (text) { this.pastes.push(text); }; +FakeTerminal.prototype.input = function (text) { this.inputs.push(text); }; +FakeTerminal.prototype.getSelection = function () { return this.selection; }; +FakeTerminal.prototype.clearSelection = function () { this.selection = ''; }; +FakeTerminal.prototype.dispose = function () { this.disposed = true; }; + +/** + * The write callback is the acknowledgement, so it is called rather than kept. + * + * Synchronously, which a real xterm does not promise — it renders first. The page's contract is that the + * acknowledgement is sent from this callback and not before, and calling it straight away is what lets a + * test assert that without needing a frame clock. + */ +FakeTerminal.prototype.write = function (data, done) { + this.writes.push(data); + + if (done) { + done(); + } +}; + +var Terminal = FakeTerminal; + +var FitAddon = { + FitAddon: function () { + this.fit = function () {}; + }, +}; + +var WebglAddon = { + WebglAddon: function () { + this.onContextLoss = function () {}; + this.dispose = function () {}; + }, +}; + +// ── UTF-8, because the page encodes and decodes every payload through these ────────────────────────── +// +// Written out rather than stubbed as one byte per character. The page sends a selection as UTF-8 and reads +// a clipboard as UTF-8, so a harness that treated the two as Latin-1 would pass a test with an em dash in +// the selection and ship a page that mangled it. + +function TextEncoder() { + this.encode = function (text) { + var bytes = []; + + for (var i = 0; i < text.length; i++) { + var point = text.charCodeAt(i); + + // A surrogate pair is one code point in two units. Combined here, because encoding each half on its + // own is exactly the mojibake this exists to catch. + if (point >= 0xd800 && point <= 0xdbff && i + 1 < text.length) { + var low = text.charCodeAt(i + 1); + + if (low >= 0xdc00 && low <= 0xdfff) { + point = 0x10000 + ((point - 0xd800) << 10) + (low - 0xdc00); + i++; + } + } + + if (point < 0x80) { + bytes.push(point); + } else if (point < 0x800) { + bytes.push(0xc0 | (point >> 6), 0x80 | (point & 0x3f)); + } else if (point < 0x10000) { + bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 0x3f), 0x80 | (point & 0x3f)); + } else { + bytes.push( + 0xf0 | (point >> 18), + 0x80 | ((point >> 12) & 0x3f), + 0x80 | ((point >> 6) & 0x3f), + 0x80 | (point & 0x3f)); + } + } + + return new Uint8Array(bytes); + }; +} + +function TextDecoder() { + this.decode = function (bytes) { + var text = ''; + + for (var i = 0; i < bytes.length;) { + var first = bytes[i]; + var length = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4; + var point = length === 1 ? first : first & (0x7f >> length); + + for (var n = 1; n < length; n++) { + point = (point << 6) | (bytes[i + n] & 0x3f); + } + + i += length; + + if (point > 0xffff) { + point -= 0x10000; + text += String.fromCharCode(0xd800 + (point >> 10), 0xdc00 + (point & 0x3ff)); + } else { + text += String.fromCharCode(point); + } + } + + return text; + }; +} + // ── What the tests drive the page with ────────────────────────────────────────────────────────────── /** @@ -165,6 +366,134 @@ function banner() { return pageBanner.textContent; } +/** How many frames the page has sent since it loaded. */ +function sentFrames() { + return outbound.length; +} + +/** + * One frame the page sent, as "opcode/session/payload", the payload decoded as UTF-8. + * + * One string rather than three values because it crosses into C# as one, and a test asserting on + * "5/7/whoami" reads as the frame it is. Resize frames land here too and read as rubbish; nothing asserts + * on those, and rendering them some other way would mean a second shape for a reader to learn. + */ +function sentFrame(index) { + var bytes = outbound[index]; + var session = ((bytes[1] << 24) | (bytes[2] << 16) | (bytes[3] << 8) | bytes[4]) >>> 0; + + return bytes[0] + '/' + session + '/' + new TextDecoder().decode(bytes.slice(5)); +} + +/** Hands the page a frame, on whichever socket is its current one. */ +function deliverFrame(opcode, sessionId, payload) { + var bytes = new Uint8Array(5 + payload.length); + var header = new DataView(bytes.buffer); + + header.setUint8(0, opcode); + header.setUint32(1, sessionId); + bytes.set(payload, 5); + + sockets[sockets.length - 1].fire('message', { data: bytes.buffer }); +} + +/** SessionOpened, which is what makes the page build a pane. */ +function openSession(sessionId) { + deliverFrame(2, sessionId, []); +} + +/** Clipboard: the host's answer to a right-click paste. */ +function deliverClipboard(sessionId, text) { + deliverFrame(8, sessionId, new TextEncoder().encode(text)); +} + +/** Paste: a snippet off the sidebar, which is deliberately not the same frame. */ +function deliverSnippet(sessionId, text, execute) { + var body = new TextEncoder().encode(text); + var payload = new Uint8Array(1 + body.length); + + payload[0] = execute ? 1 : 0; + payload.set(body, 1); + + deliverFrame(6, sessionId, payload); +} + +/** The pane behind one session, found the way the page labelled it. */ +function terminalFor(sessionId) { + for (var i = 0; i < terminals.length; i++) { + if (terminals[i].pane && terminals[i].pane.dataset.sessionId === String(sessionId)) { + return terminals[i]; + } + } + + throw new Error('No pane for session ' + sessionId + '.'); +} + +/** Drags a selection across that pane, which is the one thing a fake terminal cannot do for itself. */ +function select(sessionId, text) { + terminalFor(sessionId).selection = text; +} + +/** What is selected in that pane now — how a test sees that a copy dropped it. */ +function selection(sessionId) { + return terminalFor(sessionId).selection; +} + +/** + * The right button, over that pane. + * + * The event carries only the two methods the page calls on it, and counts the calls: suppressing the + * browser's own menu is half of what this gesture has to do, and a handler that copied correctly and left + * the menu up would be a regression no assertion about frames could see. + */ +function rightClick(sessionId) { + gesture = { prevented: 0, stopped: 0 }; + + terminalFor(sessionId).pane.fire('contextmenu', { + preventDefault: function () { gesture.prevented++; }, + stopPropagation: function () { gesture.stopped++; }, + }); +} + +/** + * The right button somewhere that is not a pane — the status banner, in practice. + * + * Fired on the document rather than on an element, which is where the page's fallback rule lives: a pane + * stops the event before it gets here, so this is the path only a click that missed every pane takes. + */ +function rightClickOffAPane() { + gesture = { prevented: 0, stopped: 0 }; + + document.fire('contextmenu', { + preventDefault: function () { gesture.prevented++; }, + stopPropagation: function () { gesture.stopped++; }, + }); +} + +/** Whether the last right-click took the event away from the browser, and from xterm. */ +function prevented() { + return gesture.prevented; +} + +function stopped() { + return gesture.stopped; +} + +/** Everything paste() was given for that pane, joined — the bracketed route. */ +function pasted(sessionId) { + return terminalFor(sessionId).pastes.join('|'); +} + +/** Everything input() was given for that pane, joined — the typed route. */ +function typed(sessionId) { + return terminalFor(sessionId).inputs.join('|'); +} + +/** How many times that pane has been asked for the caret. */ +function focuses(sessionId) { + return terminalFor(sessionId).focuses; +} + /** Whether that attempt has been closed, by the page or by the far end. */ function isClosed(index) { return sockets[index].readyState === FakeSocket.CLOSED; diff --git a/tests/DodoSSH.Client.Terminal.Tests/RendererClipboardTests.cs b/tests/DodoSSH.Client.Terminal.Tests/RendererClipboardTests.cs new file mode 100644 index 0000000..f8c715a --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/RendererClipboardTests.cs @@ -0,0 +1,276 @@ +namespace DodoSSH.Client.Terminal.Tests; + +/// +/// The renderer page's right button: a copy, a paste, and never a menu. +/// +/// +/// +/// The gesture every terminal has had since the Windows console got QuickEdit — select and right-click to +/// copy, right-click with nothing selected to paste. It lives in the page because that is the only place it +/// can: 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. +/// +/// +/// What is asserted here is which frame the page sends, addressed to which session, and which of xterm's two +/// insertion routes it takes on the way back. What is deliberately not asserted is anything about +/// xterm itself — whether paste() really brackets, whether a selection really loses its right +/// padding — because the fake terminal in the harness is a stub with a memory and not an emulator. That +/// boundary is where docs/manual-checks.md 1.11 picks up. +/// +/// +public sealed class RendererClipboardTests +{ + /// The session every single-pane test here opens. + private const uint SessionId = 7; + + [Fact] + public void ARightClickOverASelection_CopiesIt() + { + var page = Attached(); + + page.Do("select(7, 'error: nothing was written')"); + page.Do("rightClick(7)"); + + page.Sent.ShouldContain("5/7/error: nothing was written"); + } + + /// + /// The console's behaviour, and load-bearing rather than cosmetic. Dropping the selection is the only + /// acknowledgement of the copy there is — nothing else on screen moves — and it is what makes the next + /// right-click a paste rather than a second copy of something forgotten about. + /// + [Fact] + public void ARightClickOverASelection_DropsTheSelectionAfterCopyingIt() + { + var page = Attached(); + + page.Do("select(7, 'systemctl status sshd')"); + page.Do("rightClick(7)"); + + page.Selection(SessionId).ShouldBeEmpty(); + } + + [Fact] + public void ARightClickWithNothingSelected_AsksTheHostForTheClipboard() + { + var page = Attached(); + + page.Do("rightClick(7)"); + + // No payload: the page has nothing to send, which is the whole reason it has to ask. + page.Sent.ShouldContain("6/7/"); + } + + /// + /// The composite property, and the one a user would describe: copy, then paste. It passes only if the + /// copy dropped the selection — a page that left it standing sends a second copy frame here and never a + /// paste request, which is the failure that reads as "paste is broken". + /// + [Fact] + public void ASecondRightClick_PastesBecauseTheFirstOneCopied() + { + var page = Attached(); + + page.Do("select(7, 'df -h')"); + page.Do("rightClick(7)"); + page.Do("rightClick(7)"); + + page.Sent.ShouldContain("5/7/df -h"); + page.Sent.ShouldContain("6/7/"); + } + + /// + /// + /// preventDefault is the WebView's own context menu, which is what this whole change is about. + /// + /// + /// stopPropagation is xterm's, and it is the half that is easy to leave out. xterm registers its + /// own contextmenu handler on the element inside the pane, which parks a 20-pixel textarea under + /// the pointer at z-index 1000 and leaves the selection sitting in it, selected — both of which exist to + /// serve the native menu and are litter without one. The page takes the event in the capture phase to + /// get ahead of it; a listener that forgot the third argument to addEventListener would still + /// suppress the menu and would fail here. + /// + /// + [Fact] + public void ARightClick_TakesTheEventFromTheBrowserAndFromXterm() + { + var page = Attached(); + + page.Do("rightClick(7)"); + + page.Prevented.ShouldBe(1); + page.Stopped.ShouldBe(1); + } + + /// + /// The panes cover the page — they are positioned at inset 0 — so the only right-click that misses one is + /// on the status banner, which is drawn over the bottom of the pane while it has something to say. That + /// used to be the one place in a terminal window that still opened a browser menu. + /// + [Fact] + public void ARightClickThatMissesEveryPane_StillOpensNoMenu() + { + var page = Attached(); + + page.Do("rightClickOffAPane()"); + + page.Prevented.ShouldBe(1); + + // And nothing is stopped, because there is nothing below the document to stop it from — nor any + // frame sent, because a click that is on no pane is a click about no session. + page.Stopped.ShouldBe(0); + } + + /// + /// A right-click over a pane with a selection sends one frame and not two: the branch is exclusive, and a + /// page that asked for the clipboard as well would paste over the selection it had just copied. + /// + [Fact] + public void ARightClickOverASelection_DoesNotAlsoAskForTheClipboard() + { + var page = Attached(); + + page.Do("select(7, 'uname -a')"); + page.Do("rightClick(7)"); + + page.Sent.ShouldNotContain("6/7/"); + } + + /// + /// Two panes, because the session id on a clipboard frame is the whole of what puts the text where the + /// pointer was. They agree with the host's own idea of the active tab in every arrangement that exists + /// today; the page is what knows, so this is what proves the page says. + /// + [Fact] + public void ARightClick_IsAddressedToThePaneItHappenedIn() + { + var page = Attached(); + + page.Do("openSession(9)"); + page.Do("select(7, 'the first pane'); select(9, 'the second pane')"); + + page.Do("rightClick(9)"); + + page.Sent.ShouldContain("5/9/the second pane"); + page.Sent.ShouldNotContain("5/7/the first pane"); + } + + /// + /// The other half of the round trip. paste() rather than input(), because this text came + /// off the system clipboard and that is the case bracketed paste exists for: a shell that receives a + /// multi-line command inside the markers treats every newline as text rather than as "run this". + /// + [Fact] + public void TheClipboardComingBack_IsPastedRatherThanTyped() + { + var page = Attached(); + + page.Do("deliverClipboard(7, 'sudo systemctl restart sshd')"); + + page.Pasted(SessionId).ShouldBe("sudo systemctl restart sshd"); + page.Typed(SessionId).ShouldBeEmpty(); + } + + /// + /// No Enter, ever. A clipboard paste inserts and stops; pressing Enter for somebody is what the sidebar's + /// RUN does, and that is a thing they asked for by name. + /// + [Fact] + public void TheClipboardComingBack_DoesNotPressEnter() + { + var page = Attached(); + + page.Do("deliverClipboard(7, 'rm -rf /var/tmp/build')"); + + page.Typed(SessionId).ShouldBeEmpty(); + } + + /// The caret follows the paste, so the next keystroke lands where the text did. + [Fact] + public void TheClipboardComingBack_LeavesTheCaretInThatPane() + { + var page = Attached(); + + var before = page.Focuses(SessionId); + page.Do("deliverClipboard(7, 'whoami')"); + + page.Focuses(SessionId).ShouldBe(before + 1); + } + + /// + /// Multi-byte, because both directions run through the page's own TextEncoder and + /// TextDecoder and a character that straddles nothing is not a test of either. The harness's codec + /// is a real UTF-8 one for this reason; see its remark. + /// + [Fact] + public void TheTextSurvivesTheRoundTrip_WhenItIsNotAscii() + { + var page = Attached(); + + page.Do("select(7, 'échec — 90° · λ')"); + page.Do("rightClick(7)"); + page.Do("deliverClipboard(7, 'échec — 90° · λ')"); + + page.Sent.ShouldContain("5/7/échec — 90° · λ"); + page.Pasted(SessionId).ShouldBe("échec — 90° · λ"); + } + + /// + /// The distinction the new opcode exists to keep, asserted from the other side: a snippet off the sidebar + /// is still typed when it is a single line, because bash marks a bracketed paste as an active + /// region and a snippet should read as though it had been typed. Sharing one opcode with the clipboard + /// would have meant choosing one of these two behaviours for both. + /// + [Fact] + public void ASingleLineSnippet_IsStillTypedRatherThanPasted() + { + var page = Attached(); + + page.Do("deliverSnippet(7, 'tail -f /var/log/syslog', false)"); + + page.Typed(SessionId).ShouldBe("tail -f /var/log/syslog"); + page.Pasted(SessionId).ShouldBeEmpty(); + } + + /// + [Fact] + public void AMultiLineSnippet_IsStillPastedRatherThanTyped() + { + var page = Attached(); + + page.Do("deliverSnippet(7, 'cd /srv\\nmake\\nmake install', false)"); + + page.Pasted(SessionId).ShouldBe("cd /srv\nmake\nmake install"); + page.Typed(SessionId).ShouldBeEmpty(); + } + + /// + /// And the Enter goes through the typed route even for a multi-line snippet, deliberately outside the + /// bracketing: a \r inside the wrapper arrives at the shell as a literal carriage return and + /// nothing runs. + /// + [Fact] + public void ASnippetWithRun_PressesEnterOutsideTheBrackets() + { + var page = Attached(); + + page.Do("deliverSnippet(7, 'cd /srv\\nmake', true)"); + + page.Pasted(SessionId).ShouldBe("cd /srv\nmake"); + page.Typed(SessionId).ShouldBe("\r"); + } + + /// A page with an open socket and one pane, which is where every test here starts. + private static RendererPage Attached() + { + var page = RendererPage.Load(); + + // The socket has to be open before anything is sent: send() drops a frame when it is not, which + // would make every assertion here pass or fail on the transport rather than on the gesture. + page.Do("accept(0)"); + page.Do("openSession(7)"); + + return page; + } +} diff --git a/tests/DodoSSH.Client.Terminal.Tests/RendererPage.cs b/tests/DodoSSH.Client.Terminal.Tests/RendererPage.cs index dacd105..52b4a09 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/RendererPage.cs +++ b/tests/DodoSSH.Client.Terminal.Tests/RendererPage.cs @@ -44,6 +44,60 @@ internal sealed class RendererPage /// What the status element says — the banner a user would be looking at. public string Banner => engine.Evaluate("banner()").AsString(); + /// + /// Every frame the page has sent, each as opcode/session/payload. + /// + /// + /// Materialised on each read rather than cached, because the point of it is what the page did in answer + /// to the last . A session's own resize frames are in here too and read as rubbish; see + /// the harness's sentFrame() for why they are not rendered some other way. + /// + public IReadOnlyList Sent + { + get + { + var count = (int)engine.Evaluate("sentFrames()").AsNumber(); + var frames = new List(count); + + for (var index = 0; index < count; index++) + { + frames.Add( + engine + .Evaluate(string.Create(CultureInfo.InvariantCulture, $"sentFrame({index})")) + .AsString()); + } + + return frames; + } + } + + /// How many times the last right-click called preventDefault, and stopPropagation. + /// + /// Both, because they answer different questions: the first is whether the WebView's own context menu + /// was suppressed, and the second is whether xterm's own right-click handling was — which is what keeps + /// a 20-pixel textarea from being parked under the pointer with the selection sitting in it. + /// + public int Prevented => (int)engine.Evaluate("prevented()").AsNumber(); + + /// + public int Stopped => (int)engine.Evaluate("stopped()").AsNumber(); + + /// What is selected in that session's pane. + public string Selection(uint sessionId) => + engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"selection({sessionId})")).AsString(); + + /// Everything term.paste was given for that session — the bracketed route. + public string Pasted(uint sessionId) => + engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"pasted({sessionId})")).AsString(); + + /// Everything term.input was given for that session — the typed route. + public string Typed(uint sessionId) => + engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"typed({sessionId})")).AsString(); + + /// How many times that session's pane has been asked for the caret. + public int Focuses(uint sessionId) => + (int)engine.Evaluate(string.Create(CultureInfo.InvariantCulture, $"focuses({sessionId})")).AsNumber(); + /// /// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView /// navigated to it: one socket opened and still connecting. diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs index a9b40fa..46cfe09 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs @@ -356,6 +356,82 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable steps[0].ShouldBe(-1); } + /// + /// Dispatched before the session lookup for the reason the text-size chord is, and one of its own: a + /// clipboard is not a property of any terminal. The absence of a registered session is the point of the + /// test — copying the last thing a remote said before it hung up is an ordinary thing to want, and a + /// dead tab must not be the one place the gesture silently does nothing. + /// + [Fact] + public async Task ACopy_IsHeardWithNoSessionRegistered() + { + Start(); + + using var socket = await ConnectAsync(); + + var copied = new List(); + plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text); + + await SendAsync( + socket, + (byte)TerminalClientOpcode.ClipboardCopy, + Encoding.UTF8.GetBytes("error: nothing was written")); + + await WaitUntilAsync(() => copied.Count > 0); + + copied[0].ShouldBe("error: nothing was written"); + } + + /// + /// An empty copy is dropped rather than forwarded. The clipboard is shared with everything else on the + /// machine, so "copy nothing" would be a gesture that quietly destroys whatever was on it — and the page + /// does not send one, which makes this a guard against a frame from something that is not the page. + /// + [Fact] + public async Task AnEmptyCopy_IsDropped() + { + Start(); + + using var socket = await ConnectAsync(); + + var copies = 0; + plane.ClipboardCopyRequested += (_, _) => Interlocked.Increment(ref copies); + + await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, []); + + // Followed by one that must be heard, so the assertion below is about ordering rather than about + // having waited long enough: the receive loop is sequential, so a copy arriving after this proves + // the empty one was seen and passed over. + var copied = new List(); + plane.ClipboardCopyRequested += (_, e) => copied.Add(e.Text); + + await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardCopy, "uptime"u8.ToArray()); + await WaitUntilAsync(() => copied.Count > 0); + + copies.ShouldBe(1, "only the second frame carried anything"); + } + + /// + /// The session id is the whole payload of a paste request — it says which pane the pointer was over, and + /// therefore where the answer has to land. Heard with nothing registered for the reason a copy is. + /// + [Fact] + public async Task APasteRequest_CarriesTheSessionItWasMadeIn() + { + Start(); + + using var socket = await ConnectAsync(); + + var asked = new List(); + plane.ClipboardPasteRequested += (_, e) => asked.Add(e.SessionId); + + await SendAsync(socket, (byte)TerminalClientOpcode.ClipboardPaste, [], sessionId: 4242); + + await WaitUntilAsync(() => asked.Count > 0); + + asked[0].ShouldBe(4242u); + } + [Fact] public async Task AFrameForAnUnregisteredSession_IsIgnored() { diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs index 8d0c214..0d069af 100644 --- a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs @@ -268,6 +268,69 @@ public sealed class TerminalWorkspaceTests .ShouldBeFalse("the tab it names is gone"); } + /// + /// + /// The right-click paste's answer, and it is a separate frame from the snippet one above rather than the + /// same frame with a flag: the page brackets this unconditionally and types a single-line snippet, which + /// is a difference of behaviour and not of degree. See . + /// + /// + /// The payload is the clipboard's text and nothing else — no leading flag byte, because there is no + /// Enter to ask for. A frame that grew one would put a stray character at the front of every paste, and + /// this is what would catch it. + /// + /// + [Fact] + public async Task TheClipboard_ReachesThePaneThatAskedForIt() + { + var connections = new FakeConnectionFactory(blockShellReads: true); + + await using var workspace = CreateWorkspace(connections); + workspace.Start(); + + using var renderer = await ConnectRendererAsync(workspace); + + var sessionId = await workspace.OpenSessionAsync( + Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); + + // The session's opening frame, read and discarded so the clipboard frame below is the next one. + await ReceiveFrameAsync(renderer); + + (await workspace.SendClipboardAsync( + sessionId, "sudo systemctl restart sshd", TestContext.Current.CancellationToken)) + .ShouldBeTrue("the session is live"); + + var frame = await ReceiveFrameAsync(renderer); + + frame.Opcode.ShouldBe((byte)TerminalServerOpcode.Clipboard); + frame.SessionId.ShouldBe(sessionId); + Encoding.UTF8.GetString(frame.Payload).ShouldBe("sudo systemctl restart sshd"); + } + + /// + /// Refused rather than sent and dropped, for the reason PasteAsync is — with the difference that + /// nothing on screen would have shown it either way, so this answer exists to be honest rather than to + /// be reported. See . + /// + [Fact] + public async Task SendingTheClipboardToADeadSession_SaysSoRatherThanDroppingIt() + { + var connections = new FakeConnectionFactory(); + + await using var workspace = CreateWorkspace(connections); + + var sessionId = await workspace.OpenSessionAsync( + Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); + + (await workspace.SendClipboardAsync(9999, "uptime", TestContext.Current.CancellationToken)) + .ShouldBeFalse("this workspace never issued that id"); + + await workspace.CloseSessionAsync(sessionId); + + (await workspace.SendClipboardAsync(sessionId, "uptime", TestContext.Current.CancellationToken)) + .ShouldBeFalse("the tab it names is gone"); + } + /// /// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground /// notification can come down — the moment a shell exits rather than at the next thing that happens to