Public Access
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control, symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a customise sheet that saves as it goes. A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS, SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail). Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance; scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press, so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns the gesture off. The text-size buttons moved from the bar to APPEARANCE. On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with the bar's own keys repeated at the top of KEYS while the bar stands down. The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the emulator's install is locked behind a passphrase that was not recorded.
158 lines
5.7 KiB
C#
158 lines
5.7 KiB
C#
namespace DodoSSH.Client.Terminal;
|
|
|
|
/// <summary>What one session's output has most recently looked like.</summary>
|
|
/// <param name="Tail">The last bytes the remote wrote, oldest first.</param>
|
|
/// <param name="AlternateScreen">
|
|
/// Whether the remote has switched to the alternate screen — vi, less, htop — and not yet switched back.
|
|
/// </param>
|
|
public sealed record RecentOutput(byte[] Tail, bool AlternateScreen);
|
|
|
|
/// <summary>
|
|
/// The last kilobyte a session's remote wrote, and whether a full-screen program is running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>This exists so that the phone's command history only keeps what the remote echoed.</b> A terminal is
|
|
/// one input stream, and the client cannot tell a shell prompt from a <c>sudo</c> password prompt by what is
|
|
/// typed into it — only by whether the characters came back. A password typed with echo off leaves the
|
|
/// prompt line exactly as it was, and that is the test the history applies before it keeps anything. See
|
|
/// <c>TypedCommandHistory</c> in the shell.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Kept beside the pump's read loop, and costing that loop almost nothing.</b> Each read copies at most
|
|
/// <see cref="Capacity"/> bytes into a ring and runs one vectorised search over the read for the
|
|
/// alternate-screen switches, with no allocation. Locked rather than lock-free
|
|
/// because it is written from the read loop's thread and read from the UI thread on Enter, and an Enter is
|
|
/// rare enough that a lock is the honest price.
|
|
/// </para>
|
|
/// <para>
|
|
/// Nothing here outlives the session or leaves this process. It is the same bytes the renderer is shown,
|
|
/// held for as long as it takes for the next kilobyte to push them out.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class OutputTail
|
|
{
|
|
/// <summary>How much output is kept.</summary>
|
|
/// <remarks>
|
|
/// A prompt and a long command line with room to spare. A line longer than this is not recorded at all,
|
|
/// which is the safe direction to fail in.
|
|
/// </remarks>
|
|
public const int Capacity = 1024;
|
|
|
|
/// <summary>The longest switch sequence, less one: how far a match can straddle two reads.</summary>
|
|
private const int Overlap = 7;
|
|
|
|
private readonly Lock gate = new();
|
|
private readonly byte[] ring = new byte[Capacity];
|
|
private int start;
|
|
private int count;
|
|
private bool alternateScreen;
|
|
|
|
/// <summary>Takes a read's worth of output.</summary>
|
|
public void Append(ReadOnlySpan<byte> output)
|
|
{
|
|
if (output.IsEmpty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (gate)
|
|
{
|
|
// The seam first — the end of what is already held and the start of the new bytes — so a switch
|
|
// split across two reads is still seen whole; then the whole read, in which a later switch wins.
|
|
// All of it rather than its tail: a program that switches screens and then paints sixty
|
|
// kilobytes in the same read is the ordinary case, not the odd one.
|
|
var carried = Math.Min(count, Overlap);
|
|
Span<byte> seam = stackalloc byte[carried + Math.Min(output.Length, Overlap)];
|
|
CopyLast(seam[..carried]);
|
|
output[..(seam.Length - carried)].CopyTo(seam[carried..]);
|
|
|
|
alternateScreen = ScanForScreenSwitch(output, ScanForScreenSwitch(seam, alternateScreen));
|
|
|
|
foreach (var b in output.Length > Capacity ? output[^Capacity..] : output)
|
|
{
|
|
ring[(start + count) % Capacity] = b;
|
|
|
|
if (count < Capacity)
|
|
{
|
|
count++;
|
|
}
|
|
else
|
|
{
|
|
start = (start + 1) % Capacity;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>A copy of what is held, oldest byte first.</summary>
|
|
public RecentOutput Snapshot()
|
|
{
|
|
lock (gate)
|
|
{
|
|
var copy = new byte[count];
|
|
CopyLast(copy);
|
|
|
|
return new RecentOutput(copy, alternateScreen);
|
|
}
|
|
}
|
|
|
|
/// <summary>Copies the last <c>destination.Length</c> held bytes. Called under the lock.</summary>
|
|
private void CopyLast(Span<byte> destination)
|
|
{
|
|
var offset = count - destination.Length;
|
|
|
|
for (var i = 0; i < destination.Length; i++)
|
|
{
|
|
destination[i] = ring[(start + offset + i) % Capacity];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the screen is alternate after this output, given whether it was before.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The three DEC private modes programs use for it — 1049, which every modern one sends, and the older
|
|
/// 47 and 1047 — set with <c>h</c> and reset with <c>l</c>. The last switch in the window wins.
|
|
/// </remarks>
|
|
private static bool ScanForScreenSwitch(ReadOnlySpan<byte> window, bool current)
|
|
{
|
|
ReadOnlySpan<byte> prefix = [0x1B, (byte)'[', (byte)'?'];
|
|
|
|
var state = current;
|
|
var at = 0;
|
|
|
|
while (at < window.Length)
|
|
{
|
|
var found = window[at..].IndexOf(prefix);
|
|
|
|
if (found < 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var digits = at + found + prefix.Length;
|
|
var end = digits;
|
|
|
|
while (end < window.Length && window[end] is >= (byte)'0' and <= (byte)'9')
|
|
{
|
|
end++;
|
|
}
|
|
|
|
if (end < window.Length && window[end] is (byte)'h' or (byte)'l')
|
|
{
|
|
var mode = window[digits..end];
|
|
|
|
if (mode.SequenceEqual("1049"u8) || mode.SequenceEqual("1047"u8) || mode.SequenceEqual("47"u8))
|
|
{
|
|
state = window[end] == (byte)'h';
|
|
}
|
|
}
|
|
|
|
at = digits;
|
|
}
|
|
|
|
return state;
|
|
}
|
|
}
|