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>
291 lines
14 KiB
C#
291 lines
14 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Input.Platform;
|
|
using Avalonia.Markup.Xaml;
|
|
|
|
using DodoSSH.Client.Android.Platform;
|
|
using DodoSSH.Client.Android.Views;
|
|
using DodoSSH.Client.Auth;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Shell.Terminal;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.Android;
|
|
|
|
/// <summary>
|
|
/// The Avalonia application, phone side.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Named <c>DodoSshApp</c> for the same reason the desktop head's is, and then for a second reason on top
|
|
/// of it: a type called <c>App</c> in a namespace ending <c>.Android</c> is what makes every
|
|
/// <c>Android.App</c> in this assembly ambiguous. See the note at the top of MainActivity.
|
|
/// </remarks>
|
|
public sealed partial class DodoSshApp : Avalonia.Application
|
|
{
|
|
/// <inheritdoc />
|
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
|
|
|
/// <inheritdoc />
|
|
public override void OnFrameworkInitializationCompleted()
|
|
{
|
|
// ISingleViewApplicationLifetime, not IClassicDesktopStyleApplicationLifetime: a phone has one
|
|
// surface and no window to own. That difference is the whole reason the two heads cannot share a
|
|
// composition root, and very nearly the only one — everything either of them composes is the same.
|
|
if (ApplicationLifetime is ISingleViewApplicationLifetime single)
|
|
{
|
|
single.MainView = Compose();
|
|
}
|
|
|
|
base.OnFrameworkInitializationCompleted();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Composed by hand rather than through a container, matching the desktop head: the graph is a handful
|
|
/// of objects deep and an indirection to read through would buy nothing at this size. Read it beside
|
|
/// <c>DodoSSH.Client.App/App.axaml.cs</c> — the shape is deliberately identical, and the four
|
|
/// differences are the four things docs/android-port.md said would differ.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing here is disposed on a lifecycle hook</b>, and that is not an oversight either. Android
|
|
/// does not promise to call anything before killing a process, so a teardown path would be a comfort
|
|
/// rather than a guarantee. What actually protects the sessions is the foreground service; what
|
|
/// protects the vault keys is that they never leave memory this process owns.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static PhoneShell Compose()
|
|
{
|
|
// Difference 1: the profile directory comes from the head. filesDir is per-app and non-roaming,
|
|
// which is what ClientPaths asks for and what no desktop platform guarantees.
|
|
var paths = PhoneEnvironment.Paths;
|
|
paths.EnsureCreated();
|
|
|
|
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
|
|
|
var knownHosts = new VaultKnownHostStore();
|
|
var connections = new SshNetConnectionFactory(knownHosts);
|
|
|
|
var workspace = new TerminalWorkspace(
|
|
new AvaloniaTerminalAssetProvider(),
|
|
connections,
|
|
TimeProvider.System);
|
|
|
|
workspace.Start();
|
|
|
|
// Before anything can queue a transfer, which is the only moment at which emptying this is
|
|
// provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
|
|
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
|
|
DocumentStaging.Sweep();
|
|
|
|
// Built before the view model, because the clipboard is reached through it — see ClipboardWriter,
|
|
// which takes the surface and looks the TopLevel up on each call rather than now.
|
|
var shell = new PhoneShell();
|
|
|
|
var viewModel = ComposeShell(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
connections,
|
|
ClipboardWriter(shell),
|
|
ClipboardReader(shell));
|
|
|
|
shell.DataContext = viewModel;
|
|
|
|
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
|
// does and why.
|
|
ComposeKeepAlive(workspace, viewModel);
|
|
|
|
return shell;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
|
|
/// a vault lock — true on a platform that stops backgrounded processes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
|
|
/// </para>
|
|
/// <para>
|
|
/// The transfer count is real now that the document picker gives this head a way to start one, and it
|
|
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
|
|
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
|
|
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
|
|
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
|
|
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
|
|
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
|
|
/// cipher, which a bucket never has — that answers whether one is open.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
|
|
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
|
|
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
|
|
{
|
|
var keepAlive = new SessionKeepAlive(
|
|
workspace,
|
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
|
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
|
|
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
|
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
|
// Without this the notification would come up when an upload started and stay up after it
|
|
// finished, which is the failure this class exists to prevent.
|
|
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
|
|
|
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
|
|
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
|
|
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
|
|
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
|
|
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
|
|
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
|
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
|
|
|
keepAlive.Refresh();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writing to this phone's clipboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>This head had none, and every control that wanted one said so out loud.</b> COPY PUBLIC KEY on
|
|
/// the keychain answered "This machine has no clipboard" on a device that plainly has one, because the
|
|
/// delegate was simply never wired here — the desktop passed one and the phone passed null. Android has
|
|
/// a clipboard and Avalonia surfaces it through the same <c>TopLevel</c> the desktop reaches, so there
|
|
/// was nothing platform-shaped about the gap.
|
|
/// </para>
|
|
/// <para>
|
|
/// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no
|
|
/// <c>TopLevel</c> yet, because this is the method building the view it will be attached to. A machine
|
|
/// that somehow has none falls through silently and the view model decides what to say, which is what
|
|
/// keeps "no clipboard here" and "copied" different answers.
|
|
/// </para>
|
|
/// <para>
|
|
/// A delegate rather than an <c>IClipboard</c>, so nothing in the view models needs a visual and every
|
|
/// test that drives them stays window-free.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static Func<string, Task> ClipboardWriter(Visual surface) =>
|
|
async text =>
|
|
{
|
|
if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard })
|
|
{
|
|
await clipboard.SetTextAsync(text).ConfigureAwait(false);
|
|
}
|
|
};
|
|
|
|
/// <summary>Reads this phone's clipboard.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
|
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
|
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <c>contextmenu</c> event is unverified — see
|
|
/// <c>docs/manual-checks.md</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static Func<Task<string?>> ClipboardReader(Visual surface) =>
|
|
async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }
|
|
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
|
: null;
|
|
|
|
/// <remarks>
|
|
/// Split from <see cref="Compose"/> 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.
|
|
/// </remarks>
|
|
private static MainWindowViewModel ComposeShell(
|
|
ClientPaths paths,
|
|
ClientCacheFactory caches,
|
|
TerminalWorkspace workspace,
|
|
VaultKnownHostStore knownHosts,
|
|
SshNetConnectionFactory connections,
|
|
Func<string, Task> copyToClipboard,
|
|
Func<Task<string?>> 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.
|
|
var deviceKeys = new AndroidDeviceKeyStore(paths);
|
|
|
|
// Difference 6, and the newest: where newer builds come from. The same shape of question
|
|
// deviceKeys answers — a property of this installation, decided once, here — and the same answer
|
|
// when this copy was not installed by anything that can replace it. See ADR 0014.
|
|
//
|
|
// Its own HttpClient rather than the sync client's: this one talks to the project's forge and that
|
|
// one talks to the deployment, and the whole point of ADR 0011 rule 2 is that those are different
|
|
// parties. Sharing a handler would be one connection pool, one set of default headers and one
|
|
// place for a future change to leak a token from the second into the first. Never disposed, for
|
|
// the reason nothing else here is: an Avalonia Application has no disposal hook, and this lives as
|
|
// long as the process.
|
|
var updates = AndroidUpdateChannels.ForThisPhone(new HttpClient());
|
|
|
|
var browser = new AndroidBrowserLauncher();
|
|
|
|
var viewModel = new MainWindowViewModel(
|
|
paths,
|
|
caches,
|
|
workspace,
|
|
knownHosts,
|
|
deviceKeys,
|
|
|
|
// Difference 4: the system browser by intent, and the response by intent too rather than on a
|
|
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
|
async (url, cancellationToken) => await ServerConnection
|
|
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
|
.ConfigureAwait(false),
|
|
|
|
TimeProvider.System,
|
|
connections,
|
|
passphraseProfile: null,
|
|
|
|
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
|
|
// makes a launch after the first one arrive online rather than merely enrolled.
|
|
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
|
|
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
|
.ConfigureAwait(false),
|
|
|
|
// Difference 5: what this phone is called. The shell's default is Environment.MachineName, which
|
|
// answers localhost here — so without this the account's device list would show one localhost
|
|
// per phone, on the very screen a lost device is revoked from, and every log entry a phone wrote
|
|
// would name the same machine. See PhoneEnvironment.DeviceName.
|
|
deviceName: PhoneEnvironment.DeviceName,
|
|
|
|
copyToClipboard: copyToClipboard,
|
|
readClipboard: readClipboard,
|
|
|
|
updates: updates);
|
|
|
|
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
|
// view model shows its own progress and handles its own failures.
|
|
_ = viewModel.StartAsync(CancellationToken.None);
|
|
|
|
return viewModel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Difference 4: where the authorization response comes back to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The only thing this head changes about signing in, and it changes it for a security reason rather
|
|
/// than a platform one. The desktop receives the response on a loopback <c>TcpListener</c>; on a phone
|
|
/// any other installed application can bind a loopback port and race for the authorization code, which
|
|
/// is the attack RFC 8252 §8.3 names. Android routes a registered redirect to this application
|
|
/// instead — see <see cref="AndroidRedirectCallback"/>, including what a private-use scheme does and
|
|
/// does not protect against.
|
|
/// </remarks>
|
|
private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
|
|
options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
|
|
}
|