diff --git a/README.md b/README.md
index c15195f..798ab6c 100644
--- a/README.md
+++ b/README.md
@@ -260,10 +260,16 @@ vault is visible to everybody in that team and moving it back means deleting and
### The Android head
-`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one. It is
-**partly built**: what exists is the unlock screen, the platform integration under it, and a shell that
-names each state it has not built yet rather than showing an empty screen. What it is for, what was decided
-and what is left are in [`docs/android-port.md`](docs/android-port.md).
+`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
+keychain and a terminal, which is the scope [`docs/android-port.md`](docs/android-port.md) decided on and
+the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read the keychain; the two
+host-key decisions and the counted delete confirmations are there too, and none of them were softened to
+fit 360dp.
+
+What it does **not** have is file transfer — deliberately, since scoped storage means there is no local
+pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs,
+teams), whose view models are already shared and which are additive rather than structural. Importing an
+`~/.ssh/config` has no meaning on a phone at all.
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
@@ -284,13 +290,18 @@ API 36 is not a preference. `Avalonia.Controls.WebView` ships only a `net10.0-an
anything lower cannot resolve it and the head loses its terminal. The floor is API 28, which is where
`BiometricPrompt` and StrongBox-backed keys exist without an AndroidX shim.
-Three things about it are worth expecting rather than discovering.
+Four things about it are worth expecting rather than discovering.
-**Signing in is not built yet, and that is the one real gap.** The desktop client receives the authorization
-response on a loopback `TcpListener`; reusing that on a phone would be a security regression rather than a
-shortcut, because on a shared device any other app can bind a loopback port — the attack RFC 8252 §8.3 names.
-It needs an app link instead. A phone enrolled from the desktop client unlocks here perfectly well, because
-**unlocking needs no network at all**, so this blocks first-run rather than the product.
+**Signing in does not use the desktop's loopback redirect, and must not.** On a shared device any other
+application can bind a loopback port and race for the authorization code — the attack RFC 8252 §8.3 names.
+The phone registers a redirect with the system instead and is handed the response as an intent. Everything
+above that — PKCE, the state check, discovery, the token exchange, the key binding — is the same code the
+desktop runs, because the only thing that varies is where the response arrives.
+
+The redirect is a private-use scheme (`dev.dodotech.dodossh:`) rather than an Android App Link, and the
+limit is worth knowing: another app can declare the same scheme, and Android will offer a chooser rather
+than refuse. PKCE is what makes an intercepted code useless. An App Link closes it properly and costs an
+`assetlinks.json` on your own server's domain.
**A fingerprint releases the device key, and re-enrolling a fingerprint destroys it.** The key is generated
with `setInvalidatedByBiometricEnrollment`, which is what stops somebody who can add their own fingerprint to
@@ -301,9 +312,15 @@ passphrase again and re-registering — which the unlock screen treats as ordina
client's promise that locking the vault does not close your shells is only true here behind a foreground
service. The notification is the price of that promise; it goes when the last shell does.
+**The recovery code screen blocks screenshots.** `FLAG_SECURE` is raised for that one state and lowered
+again afterwards, so the screen's own claim is true and a shell is still screenshotable. It stops the
+accident worth stopping — the only copy of an unrecoverable code landing in a cloud photo library, or in the
+recent-apps thumbnail — and stops nothing determined, since a second phone photographs a screen perfectly
+well.
+
**Nothing has been run on a device.** It compiles, links, packages, and carries the right native libraries
-for arm64 — that is verified. Everything about its runtime behaviour is not, and `docs/android-port.md` says
-which claims those are.
+for arm64 — that is verified, and CI verifies it on every change. Everything about its runtime behaviour is
+not, and `docs/android-port.md` says which claims those are.
### End-to-end verification
diff --git a/docs/android-port.md b/docs/android-port.md
index 2e976bc..62bdfa3 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -1,7 +1,9 @@
# An Android client: what it would take
-**Status: started. Steps 3, 4 and 6 are done; the interface is next.** `src/DodoSSH.Client.Android` is a
-real head that builds and packages. The four decisions that shape the work are recorded in
+**Status: the decided scope is built. Steps 3–8 are done.** `src/DodoSSH.Client.Android` is a phone-first
+head that builds and packages: the keychain and a terminal, which is exactly the scope
+[decided](#scope-the-keychain-and-a-terminal), plus the sign-in and enrollment states needed to reach them
+from a phone that has never been used before. The four decisions that shape the work are recorded in
[Decisions](#decisions-taken); everything below them is the audit they were taken against, and it has held
up — with three corrections and one addition, all marked **✅ settled** or **⚠️ corrected** where they
belong.
@@ -437,30 +439,57 @@ go at 360dp:
`setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an
unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key,
which the load path treats as ordinary rather than exceptional.
-5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
- on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
+5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be worth more than the
+ implementation. `IAuthorizationCallback` now sits between `OidcClient` and the loopback listener, so the
+ two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery,
+ the token exchange and the key binding are one implementation, which is the point. A second OIDC client
+ would have been a second place for a security bug to live.
- **Not started, and it is now the gap that matters most.** The head composes a sign-in delegate that
- throws, and `NeedsServer` draws a screen saying so in the user's words. A phone enrolled from the desktop
- client unlocks here perfectly well, because unlocking needs no network — so this blocks first-run, not
- the product. It is the next piece of work.
+ The system browser by `ACTION_VIEW` rather than a Custom Tab: a Custom Tab is the nicer surface and
+ shares the same cookie jar, but it needs `androidx.browser` and this needs nothing. Worth revisiting when
+ something else pulls AndroidX in.
+
+ **A private-use scheme, not an App Link**, and the difference is worth writing down rather than
+ discovering. The redirect is `dev.dodotech.dodossh:/callback` — the reversed package name, which RFC 8252
+ §7.1 recommends. Another application *can* also declare that scheme, and Android will show a chooser
+ rather than refuse; what stops that being a compromise is PKCE, since the code is useless without a
+ verifier that never leaves the process. An App Link would close it properly, at the cost of an
+ `assetlinks.json` on the DodoSSH server's own domain — a server change, and the natural upgrade.
6. ~~**The foreground service.**~~ **Done** — `SessionForegroundService`, with `SessionKeepAlive` starting and
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
dishonesty the unlock screen's count exists to prevent.
-7. **The interface**, phone-first. The actual project, and the one that dominates the estimate. **Started:**
- the shell switches on `ShellState` and the unlock screen is built from the design — including both of its
- ◆ disclosures, the shell count and the paragraph saying *locked* describes the keychain and not this
- phone's access to the hosts. Every other state draws a `PendingScreen` naming what is missing, in the
- desktop head's `NotBuiltScreen` tradition and for the same reason.
-8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
- resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
- Tab or arrows, and every Android SSH client ships one for exactly this reason.
+7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
+ plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
+ choosing a passphrase).
-Steps 1–4 and 6 are done. Step 5 is ordinary work behind seams that already exist, and is what first-run
-waits on. Step 7 still dominates everything else put together — the unlock screen is one of nine
-destinations and the least structural of them — and step 8 is small only because step 7 comes first.
+ All five of the ◆ states this document worried about are built and none of them softened:
+
+ - the changed-key refusal has no continue button, and is a full-screen panel rather than a bottom sheet
+ precisely because a sheet is swipe-to-dismiss by convention;
+ - the recovery code cannot be skipped, and `FLAG_SECURE` is raised for that state alone so the screen's
+ claim about screenshots is true rather than decorative;
+ - the delete confirmations keep their counts and replace the row in place;
+ - the unknown-key prompt shows the fingerprint in full, wrapping rather than clipping;
+ - the conflict log is a banner on the host list rather than a screen nobody opens.
+
+ The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
+ import has no meaning under scoped storage, and the other four are list screens whose view models already
+ exist — they are additive rather than structural.
+8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
+ same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
+ reflows a terminal while output is arriving.
+
+ The accessory key row needed one addition to the shared layer: `TerminalWorkspace.SendInputAsync`, since
+ ordinary typing goes from the renderer straight down the socket and there was no way in for input that
+ has no key to produce it. Ctrl latches rather than being held — one thumb cannot chord — and the latch is
+ drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
+ believing they typed an `l`.
+
+All eight are done for the decided scope. What is left is not on this list: the four destinations the
+phone does not draw, file transfer (deliberately deferred, and see the decision above for the shape it
+takes), the App Link upgrade, and — the one that is not optional — running any of it on a device.
---
diff --git a/src/DodoSSH.Client.Android/App.axaml b/src/DodoSSH.Client.Android/App.axaml
index a0d22f3..41c13de 100644
--- a/src/DodoSSH.Client.Android/App.axaml
+++ b/src/DodoSSH.Client.Android/App.axaml
@@ -21,6 +21,7 @@
+
diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs
index 16bca11..6f5bc1a 100644
--- a/src/DodoSSH.Client.Android/App.axaml.cs
+++ b/src/DodoSSH.Client.Android/App.axaml.cs
@@ -4,6 +4,7 @@ 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;
@@ -86,49 +87,69 @@ public sealed partial class DodoSshApp : Avalonia.Application
// its own, which is the half that would otherwise leave a notification up over nothing.
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
+ keepAlive.Refresh();
+
+ return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
+ }
+
+ ///
+ /// 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.
+ ///
+ private static MainWindowViewModel ComposeShell(
+ ClientPaths paths,
+ ClientCacheFactory caches,
+ TerminalWorkspace workspace,
+ VaultKnownHostStore knownHosts,
+ SshNetConnectionFactory connections)
+ {
// 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);
+ var browser = new AndroidBrowserLauncher();
+
var viewModel = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
- SignInIsNotBuiltHere,
+
+ // 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);
+ 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));
// 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);
- keepAlive.Refresh();
-
- return new PhoneShell { DataContext = viewModel };
+ return viewModel;
}
///
- /// Difference 4, and the one that is a refusal rather than an implementation.
+ /// Difference 4: where the authorization response comes back to.
///
///
- ///
- /// Signing in needs a redirect this head has not got. The desktop client receives the authorization
- /// response on a loopback TcpListener (RFC 8252 §7.3), and reusing that here would be a
- /// security regression rather than a shortcut: on a shared device any other application can bind a
- /// loopback port and race for the response, which is the attack §8.3 names and the reason app links
- /// exist. Process.Start does not exist on this platform either.
- ///
- ///
- /// So this throws rather than half-working, and the shell never reaches it: NeedsServer draws a
- /// screen that says the same thing in the user's words. A phone enrolled from the desktop client
- /// unlocks here perfectly well, because unlocking needs no network at all.
- ///
+ /// 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 TcpListener; 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 , including what a private-use scheme does and
+ /// does not protect against.
///
- private static Task SignInIsNotBuiltHere(Uri serverUrl, CancellationToken cancellationToken) =>
- throw new NotSupportedException(
- "Signing in is not built on the Android head yet: it needs an app-link redirect rather than "
- + "the desktop client's loopback listener. See docs/android-port.md §5.");
+ private static OidcClientOptions ConfigureAndroidOidc(OidcClientOptions options) =>
+ options with { CallbackFactory = path => new AndroidRedirectCallback(path) };
}
diff --git a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
index 597c308..7dc1237 100644
--- a/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
+++ b/src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
@@ -30,6 +30,11 @@
false
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs b/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs
index 456174f..6b8498b 100644
--- a/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs
+++ b/src/DodoSSH.Client.Android/DodoSshAndroidApplication.cs
@@ -1,11 +1,9 @@
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
-using global::Android.App;
-using global::Android.Runtime;
-
using Avalonia;
using Avalonia.Android;
-
using DodoSSH.Client.Android.Platform;
+using global::Android.App;
+using global::Android.Runtime;
namespace DodoSSH.Client.Android;
diff --git a/src/DodoSSH.Client.Android/MainActivity.cs b/src/DodoSSH.Client.Android/MainActivity.cs
index 0f36537..87e9f0d 100644
--- a/src/DodoSSH.Client.Android/MainActivity.cs
+++ b/src/DodoSSH.Client.Android/MainActivity.cs
@@ -3,10 +3,11 @@
// binds to `DodoSSH.Client.Android.App` — this head's own Avalonia application type — rather than to the
// platform. The desktop head hit the same class of collision and answered it by renaming its type; here
// the collision is in the namespace itself, so the qualification is the honest fix.
-using global::Android.App;
-using global::Android.Content.PM;
-
using Avalonia.Android;
+using DodoSSH.Client.Android.Platform;
+using global::Android.App;
+using global::Android.Content;
+using global::Android.Content.PM;
namespace DodoSSH.Client.Android;
@@ -45,6 +46,57 @@ namespace DodoSSH.Client.Android;
| ConfigChanges.SmallestScreenSize
| ConfigChanges.KeyboardHidden
| ConfigChanges.UiMode)]
+// The intent filter is what makes the sign-in redirect reach this application rather than a loopback
+// socket. The scheme is the reversed package name — RFC 8252 §7.1 — and it must match the constant on
+// AndroidRedirectCallback exactly; they are two declarations of one fact, so the constant is referenced
+// here rather than retyped and a rename cannot break only one of them.
+[IntentFilter(
+ [Intent.ActionView],
+ Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable],
+ DataScheme = AndroidRedirectCallback.Scheme)]
public sealed class MainActivity : AvaloniaMainActivity
{
+ ///
+ ///
+ /// OnNewIntent rather than OnCreate, and that is what SingleTask above buys: the
+ /// activity is already running with a sign-in waiting inside it, so the redirect has to be delivered
+ /// into that instance. Any other launch mode would start a second copy of the activity — and with it a
+ /// second Avalonia application over a live one — leaving the original waiting for a response that had
+ /// already been consumed.
+ ///
+ ///
+ protected override void OnResume()
+ {
+ base.OnResume();
+
+ PhoneEnvironment.CurrentActivity = this;
+ }
+
+ ///
+ ///
+ /// Cleared only if it is still this activity. Android may resume the next one before pausing this one,
+ /// and clearing unconditionally would drop a reference the newcomer had just set.
+ ///
+ protected override void OnPause()
+ {
+ base.OnPause();
+
+ if (ReferenceEquals(PhoneEnvironment.CurrentActivity, this))
+ {
+ PhoneEnvironment.CurrentActivity = null;
+ }
+ }
+
+ ///
+ protected override void OnNewIntent(Intent? intent)
+ {
+ base.OnNewIntent(intent);
+
+ if (intent?.Data is { } data
+ && string.Equals(data.Scheme, AndroidRedirectCallback.Scheme, StringComparison.Ordinal)
+ && Uri.TryCreate(data.ToString(), UriKind.Absolute, out var redirect))
+ {
+ AndroidRedirectCallback.Complete(redirect);
+ }
+ }
}
diff --git a/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs b/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs
new file mode 100644
index 0000000..378363f
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs
@@ -0,0 +1,150 @@
+using DodoSSH.Client.Auth;
+using global::Android.Content;
+
+namespace DodoSSH.Client.Android.Platform;
+
+///
+/// Opens the authorization page in the system browser.
+///
+///
+///
+/// Process.Start does not exist on this platform, so the desktop head's launcher cannot be reused —
+/// but the rule it enforces is the same one and is enforced here too: the system browser, never an
+/// embedded WebView. RFC 8252 §8.12 — an embedded user-agent can read the user's credentials as they are
+/// typed, hides the real address bar, and cannot reuse an existing single-sign-on session.
+///
+///
+/// An ACTION_VIEW intent rather than a Custom Tab. A Custom Tab is the nicer surface and shares the
+/// browser's cookie jar just as this does, but it needs the androidx.browser package; this needs
+/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in.
+///
+///
+internal sealed class AndroidBrowserLauncher : IBrowserLauncher
+{
+ ///
+ public Task OpenAsync(Uri url, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(url);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Asserted rather than assumed, exactly as the desktop launcher does: an ACTION_VIEW on some other
+ // scheme is a request to open whatever app claims it, and this only ever opens an authorize URL.
+ if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
+ && !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url));
+ }
+
+ var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(url.AbsoluteUri));
+
+ // NewTask because the launch comes from an application context rather than an activity one.
+ intent.AddFlags(ActivityFlags.NewTask);
+
+ PhoneEnvironment.Require().StartActivity(intent);
+
+ return Task.CompletedTask;
+ }
+}
+
+///
+/// Receives the authorization response as an intent rather than on a socket.
+///
+///
+///
+/// Why not the loopback listener. RFC 8252 §7.3's loopback redirect assumes a machine where the
+/// application's own port is the application's own. On a phone it is not: any installed application can
+/// bind a loopback port and, on a race, take the authorization code. §8.3 names this, and the platform's
+/// answer is to have the system route the redirect to the registered application instead.
+///
+///
+/// A private-use scheme, and its limit is worth stating. The redirect is
+/// dev.dodotech.dodossh:/oauth — the reversed package name, which RFC 8252 §7.1 recommends because
+/// it is a namespace the application demonstrably controls. It is *not* an Android App Link, and the
+/// difference is real: another application can also declare this scheme, and Android will offer the user a
+/// chooser rather than refusing. What stops that being a compromise is PKCE — the code is useless without
+/// the verifier, which never leaves this process — plus the state check. An App Link would close the gap
+/// properly, at the cost of hosting an assetlinks.json on the DodoSSH server's own domain; that is the
+/// upgrade, and it is a server change rather than a client one.
+///
+///
+/// The completion HTML is ignored. There is no tab of this application's own to write it into: the system
+/// hands the intent over and closes the browser itself.
+///
+///
+internal sealed class AndroidRedirectCallback : IAuthorizationCallback
+{
+ /// The scheme, which must match the intent filter on MainActivity exactly.
+ internal const string Scheme = "dev.dodotech.dodossh";
+
+ private readonly TaskCompletionSource completion =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private static AndroidRedirectCallback? waiting;
+
+ ///
+ /// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and
+ /// this one cannot silently differ.
+ ///
+ public AndroidRedirectCallback(string path)
+ {
+ RedirectUri = new Uri($"{Scheme}:{(path.StartsWith('/') ? path : "/" + path)}");
+
+ // Exactly one sign-in can be outstanding, because exactly one activity receives the intent. A
+ // second overlapping attempt would leave the first waiting forever on a response the second
+ // consumed, so the earlier one is failed rather than orphaned.
+ Interlocked.Exchange(ref waiting, this)?.completion.TrySetCanceled();
+ }
+
+ ///
+ public Uri RedirectUri { get; }
+
+ /// Hands a received redirect to whichever sign-in is waiting for it.
+ ///
+ /// Called from MainActivity.OnNewIntent. Returns quietly when nothing is waiting: a redirect
+ /// can arrive after the app was killed and relaunched, and that is a stale response rather than an
+ /// error worth showing anybody.
+ ///
+ internal static void Complete(Uri redirect)
+ {
+ var target = Volatile.Read(ref waiting);
+
+ if (target is null)
+ {
+ return;
+ }
+
+ var parameters = new Dictionary(StringComparer.Ordinal);
+
+ // The query, and the fragment if the provider used one. Response parameters arrive in the query
+ // for a code flow; reading both costs nothing and means a provider that answers errors in the
+ // fragment does not look like a silent hang.
+ foreach (var part in new[] { redirect.Query, redirect.Fragment })
+ {
+ foreach (var pair in part.TrimStart('?', '#').Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var split = pair.Split('=', 2);
+
+ parameters[Uri.UnescapeDataString(split[0])] =
+ split.Length == 2 ? Uri.UnescapeDataString(split[1]) : string.Empty;
+ }
+ }
+
+ target.completion.TrySetResult(new CallbackResult(parameters));
+ }
+
+ ///
+ public Task WaitForCallbackAsync(
+ string completionHtml,
+ CancellationToken cancellationToken) =>
+ completion.Task.WaitAsync(cancellationToken);
+
+ ///
+ public void Dispose()
+ {
+ // Only if it is still this one. A later sign-in has already replaced it, and clearing the slot
+ // then would silently break the attempt that is actually in flight.
+ Interlocked.CompareExchange(ref waiting, null, this);
+
+ completion.TrySetCanceled();
+ }
+}
diff --git a/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs b/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs
index 579f3a0..cac8b5e 100644
--- a/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs
+++ b/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs
@@ -1,11 +1,10 @@
+using DodoSSH.Client.Session;
using global::Android.App;
using global::Android.Security.Keystore;
using global::Java.Security;
using global::Javax.Crypto;
using global::Javax.Crypto.Spec;
-using DodoSSH.Client.Session;
-
namespace DodoSSH.Client.Android.Platform;
///
diff --git a/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs b/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs
index 656d3ac..9b4936f 100644
--- a/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs
+++ b/src/DodoSSH.Client.Android/Platform/PhoneEnvironment.cs
@@ -1,9 +1,8 @@
+using DodoSSH.Client.Session;
using global::Android.Content;
using global::Android.OS;
using global::Android.Provider;
-using DodoSSH.Client.Session;
-
namespace DodoSSH.Client.Android.Platform;
///
@@ -78,6 +77,17 @@ internal static class PhoneEnvironment
}
}
+ ///
+ /// The activity currently on screen, or null while the app is backgrounded.
+ ///
+ ///
+ /// Set and cleared by MainActivity. Separate from because the two have
+ /// genuinely different lifetimes and different uses: the application context is what outlives
+ /// everything and is right for starting services, and this is what a window flag or a system dialogue
+ /// needs — neither substitutes for the other.
+ ///
+ public static global::Android.App.Activity? CurrentActivity { get; set; }
+
/// The application context, once has run.
public static Context Require() =>
context ?? throw new InvalidOperationException(
diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml
new file mode 100644
index 0000000..8cbb31b
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml b/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml
new file mode 100644
index 0000000..3c90415
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml.cs
new file mode 100644
index 0000000..e302e5a
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/EnrollScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// ShellState.NeedsEnrollment — choose the passphrase that encrypts the vault.
+internal sealed partial class EnrollScreen : UserControl
+{
+ public EnrollScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml b/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml
new file mode 100644
index 0000000..992a98e
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml.cs b/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml.cs
new file mode 100644
index 0000000..bd2a7aa
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/HostKeySheet.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Designs 05 and 06 — the two host-key decisions, one of which has no way forward.
+internal sealed partial class HostKeySheet : UserControl
+{
+ public HostKeySheet() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
new file mode 100644
index 0000000..e31e692
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
new file mode 100644
index 0000000..6718a1d
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design 02 — the host list, and the connect bar that replaces the desktop's right column.
+internal sealed partial class HostsScreen : UserControl
+{
+ public HostsScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
new file mode 100644
index 0000000..96b29d8
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs
new file mode 100644
index 0000000..d939fe5
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design 04 — everything in the vault that is not a host.
+internal sealed partial class KeychainScreen : UserControl
+{
+ public KeychainScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/NavButton.cs b/src/DodoSSH.Client.Android/Views/NavButton.cs
new file mode 100644
index 0000000..d6a936d
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/NavButton.cs
@@ -0,0 +1,119 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
+// implicit usings, so the bare name is ambiguous — the same collision the namespace itself causes
+// for Android.App. See the note at the top of MainActivity.
+using Application = Avalonia.Application;
+using Button = Avalonia.Controls.Button;
+
+namespace DodoSSH.Client.Android.Views;
+
+///
+/// One destination in the bottom bar.
+///
+///
+///
+/// Written in C# rather than as a XAML control because it is a button with a label and a rule about the
+/// accent bar, and a templated control for that would be more machinery than the thing it draws.
+///
+///
+/// The accent bar is on top, not underneath. The design puts it there in the nav rail and it is
+/// worth keeping on a phone for a reason the desktop does not have: the bottom of a phone screen is under
+/// the system's own gesture bar, and an indicator drawn there is one the user cannot see.
+///
+///
+internal sealed class NavButton : Button
+{
+ public static readonly StyledProperty LabelProperty =
+ AvaloniaProperty.Register(nameof(Label), string.Empty);
+
+ public static readonly StyledProperty IsCurrentProperty =
+ AvaloniaProperty.Register(nameof(IsCurrent));
+
+ private readonly Border indicator;
+ private readonly TextBlock text;
+
+ public NavButton()
+ {
+ Background = Brushes.Transparent;
+ BorderThickness = new Thickness(0);
+ CornerRadius = new CornerRadius(0);
+ Padding = new Thickness(0);
+ HorizontalAlignment = HorizontalAlignment.Stretch;
+ VerticalAlignment = VerticalAlignment.Stretch;
+ HorizontalContentAlignment = HorizontalAlignment.Stretch;
+ VerticalContentAlignment = VerticalAlignment.Stretch;
+
+ indicator = new Border
+ {
+ Height = 2,
+ VerticalAlignment = VerticalAlignment.Top,
+ Background = Brushes.Transparent,
+ };
+
+ text = new TextBlock
+ {
+ FontSize = 9,
+ FontWeight = FontWeight.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = new SolidColorBrush(Color.Parse("#566059")),
+ };
+
+ Content = new Panel { Children = { indicator, text } };
+ }
+
+ public string Label
+ {
+ get => GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ /// Whether this is the destination currently showing.
+ public bool IsCurrent
+ {
+ get => GetValue(IsCurrentProperty);
+ set => SetValue(IsCurrentProperty, value);
+ }
+
+ ///
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == LabelProperty)
+ {
+ text.Text = Label;
+ }
+ else if (change.Property == IsCurrentProperty)
+ {
+ // Both the label and the bar, not just the bar. Colour alone at 9pt on a dark background is a
+ // weak signal, and it is the only one a user with a colour deficiency would have.
+ text.Foreground = new SolidColorBrush(Color.Parse(IsCurrent ? "#3CE88F" : "#566059"));
+ indicator.Background = IsCurrent
+ ? new SolidColorBrush(Color.Parse("#3CE88F"))
+ : Brushes.Transparent;
+ }
+ else if (change.Property == FontFamilyProperty)
+ {
+ text.FontFamily = FontFamily;
+ }
+ }
+
+ ///
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ // Resolved here rather than in the constructor: application resources are not reachable while the
+ // control is still being constructed, and a hard-coded stack would drift from Palette.axaml.
+ if (Application.Current?.FindResource("MonoFont") is FontFamily mono)
+ {
+ text.FontFamily = mono;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index c16c1fb..9a645f7 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -7,51 +7,153 @@
Background="{StaticResource Canvas}">
-
-
-
+
-
+
+
+
+
-
+
+
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
index 93dfc3b..3c4a1b7 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs
@@ -1,10 +1,82 @@
+using global::Android.Views;
+
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
+using DodoSSH.Client.Android.Platform;
+using DodoSSH.Client.Shell.ViewModels;
+
namespace DodoSSH.Client.Android.Views;
/// The phone's single view. The desktop head's MainWindow, without the window.
internal sealed partial class PhoneShell : UserControl
{
- public PhoneShell() => AvaloniaXamlLoader.Load(this);
+ private MainWindowViewModel? shell;
+
+ public PhoneShell()
+ {
+ AvaloniaXamlLoader.Load(this);
+
+ DataContextChanged += (_, _) =>
+ {
+ if (shell is not null)
+ {
+ shell.PropertyChanged -= OnShellChanged;
+ }
+
+ shell = DataContext as MainWindowViewModel;
+
+ if (shell is not null)
+ {
+ shell.PropertyChanged += OnShellChanged;
+ ApplyScreenshotPolicy(shell.State);
+ }
+ };
+ }
+
+ private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
+ {
+ if (shell is not null && e.PropertyName is nameof(MainWindowViewModel.State))
+ {
+ ApplyScreenshotPolicy(shell.State);
+ }
+ }
+
+ ///
+ /// Blocks screenshots and screen recording while the recovery code is on screen.
+ ///
+ ///
+ ///
+ /// The recovery screen says screenshots are blocked, and this is what makes that true rather than a
+ /// claim. FLAG_SECURE is a window flag — no control can set it — so it lives here, on the one
+ /// object that has the activity.
+ ///
+ ///
+ /// It is worth being clear about what this buys. It stops the obvious accident — a screenshot
+ /// of the only copy of an unrecoverable code landing in a cloud photo library — and it excludes the
+ /// screen from the recent-apps thumbnail, which is the part users never think about. It stops nothing
+ /// determined: a second phone photographs the screen perfectly well. The code is meant to be written
+ /// down, and this only pushes people away from the one place it must not be written down to.
+ ///
+ ///
+ /// Lowered again afterwards rather than left on. Leaving it set would make the terminal unscreenshotable
+ /// too, and a screenshot of a shell is a thing people legitimately want.
+ ///
+ ///
+ private static void ApplyScreenshotPolicy(ShellState state)
+ {
+ if (PhoneEnvironment.CurrentActivity?.Window is not { } window)
+ {
+ return;
+ }
+
+ if (state is ShellState.ShowingRecoveryCode)
+ {
+ window.SetFlags(WindowManagerFlags.Secure, WindowManagerFlags.Secure);
+ }
+ else
+ {
+ window.ClearFlags(WindowManagerFlags.Secure);
+ }
+ }
}
diff --git a/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml
new file mode 100644
index 0000000..95af9c4
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml.cs
new file mode 100644
index 0000000..901769e
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design 07 — the recovery code, which cannot be skipped or navigated past.
+internal sealed partial class RecoveryCodeScreen : UserControl
+{
+ public RecoveryCodeScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/ServerScreen.axaml b/src/DodoSSH.Client.Android/Views/ServerScreen.axaml
new file mode 100644
index 0000000..a9ee69d
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/ServerScreen.axaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/ServerScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/ServerScreen.axaml.cs
new file mode 100644
index 0000000..965a6bb
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/ServerScreen.axaml.cs
@@ -0,0 +1,10 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// ShellState.NeedsServer — name a server and sign in. The only state that needs a network.
+internal sealed partial class ServerScreen : UserControl
+{
+ public ServerScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
new file mode 100644
index 0000000..a6f55ba
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
new file mode 100644
index 0000000..9af3e31
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml.cs
@@ -0,0 +1,180 @@
+using System.Text;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+
+using DodoSSH.Client.Shell.ViewModels;
+
+// Avalonia's Button, not Android.Widget's. .NET for Android puts Android.Widget in this project's
+// implicit usings, so the bare name is ambiguous — the same collision the namespace itself causes
+// for Android.App. See the note at the top of MainActivity.
+using Application = Avalonia.Application;
+using Button = Avalonia.Controls.Button;
+
+namespace DodoSSH.Client.Android.Views;
+
+/// Design 03 — the terminal, and the accessory key row under it.
+internal sealed partial class TerminalScreen : UserControl
+{
+ ///
+ /// The keys a software keyboard does not have.
+ ///
+ ///
+ ///
+ /// Chosen by what a shell session actually needs rather than by what a keyboard has: Esc leaves vi's
+ /// insert mode, Tab completes a path, Ctrl makes ^C reachable, the arrows reach history, and the
+ /// pipe and hyphen are two characters that are three taps deep on every Android keyboard and appear in
+ /// almost every command worth typing on a phone.
+ ///
+ ///
+ /// The order matches the design's row. It is scrollable rather than compressed, because shrinking ten
+ /// keys to fit 360dp produces ten targets too small to hit.
+ ///
+ ///
+ private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
+ [
+ ("esc", [0x1B], false),
+ ("tab", [0x09], false),
+ ("ctrl", [], true),
+ ("|", [(byte)'|'], false),
+ ("-", [(byte)'-'], false),
+ ("~", [(byte)'~'], false),
+ ("/", [(byte)'/'], false),
+
+ // The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
+ // the difference between CSI A and SS3 A is the difference between working in bash and not.
+ ("↑", [0x1B, (byte)'[', (byte)'A'], false),
+ ("↓", [0x1B, (byte)'[', (byte)'B'], false),
+ ("←", [0x1B, (byte)'[', (byte)'D'], false),
+ ("→", [0x1B, (byte)'[', (byte)'C'], false),
+ ];
+
+ ///
+ /// Whether the next ordinary key should be sent as a control character.
+ ///
+ ///
+ /// Latching rather than held. Holding a modifier while typing needs two hands and a keyboard that
+ /// reports chords, and this row has neither — so Ctrl is pressed, then C, and releases itself. Every
+ /// Android SSH client does this and users expect it.
+ ///
+ private bool controlLatched;
+
+ private Button? controlKey;
+
+ public TerminalScreen()
+ {
+ AvaloniaXamlLoader.Load(this);
+
+ BuildAccessoryRow();
+
+ // The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
+ DataContextChanged += (_, _) =>
+ {
+ if (DataContext is MainWindowViewModel shell)
+ {
+ this.FindControl("Renderer")!.Source = shell.TerminalPageUrl;
+ }
+ };
+ }
+
+ private void BuildAccessoryRow()
+ {
+ var row = this.FindControl("AccessoryKeys")!;
+
+ foreach (var (label, bytes, latches) in Keys)
+ {
+ var key = new Button
+ {
+ Content = new TextBlock
+ {
+ Text = label,
+ FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
+ FontSize = 11,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ },
+
+ // 44 wide as well as tall. The design draws them flexed across the width, which at 360dp
+ // with ten keys is 32 pixels each — under every thumb-target guideline there is.
+ MinWidth = 44,
+ Height = 34,
+ Padding = new Thickness(10, 0),
+ CornerRadius = new CornerRadius(5),
+ Background = new SolidColorBrush(Color.Parse("#161B19")),
+ BorderBrush = new SolidColorBrush(Color.Parse("#232927")),
+ BorderThickness = new Thickness(1),
+ Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ };
+
+ if (latches)
+ {
+ controlKey = key;
+ key.Click += (_, _) => ToggleControl();
+ }
+ else
+ {
+ key.Click += (_, _) => SendAsync(bytes);
+ }
+
+ row.Children.Add(key);
+ }
+ }
+
+ private void ToggleControl()
+ {
+ controlLatched = !controlLatched;
+
+ if (controlKey is null)
+ {
+ return;
+ }
+
+ // Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
+ // ^L to a database prompt believing they typed an l.
+ controlKey.Background = new SolidColorBrush(
+ Color.Parse(controlLatched ? "#243A2F" : "#161B19"));
+
+ controlKey.Foreground = new SolidColorBrush(
+ Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB"));
+ }
+
+ ///
+ /// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
+ /// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
+ /// different sequence entirely and silently mangling one into a control byte would be worse than
+ /// ignoring the latch.
+ ///
+ private void SendAsync(byte[] bytes)
+ {
+ if (DataContext is not MainWindowViewModel { SelectedTab: { } tab } shell)
+ {
+ return;
+ }
+
+ var payload = bytes;
+
+ if (controlLatched && bytes.Length == 1)
+ {
+ var c = bytes[0];
+
+ if (c is >= (byte)'a' and <= (byte)'z' or >= (byte)'A' and <= (byte)'Z')
+ {
+ payload = [(byte)(c & 0x1F)];
+ }
+
+ ToggleControl();
+ }
+
+ // Discarded rather than awaited: this is a keystroke, the workspace ignores a session that has
+ // gone, and a button handler that awaited would serialise the row behind a slow link.
+ _ = shell.SendTerminalInputAsync(tab.SessionId, payload).AsTask();
+ }
+
+ /// Sends a literal string, for the keys that carry text rather than a control code.
+ /// Kept because the snippet feature will type into a terminal exactly this way.
+ internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
+}
diff --git a/src/DodoSSH.Client.Android/packages.lock.json b/src/DodoSSH.Client.Android/packages.lock.json
index 1d3b72a..d835f3e 100644
--- a/src/DodoSSH.Client.Android/packages.lock.json
+++ b/src/DodoSSH.Client.Android/packages.lock.json
@@ -1170,34 +1170,6 @@
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
}
- },
- "net10.0-android36.0/android-x64": {
- "HarfBuzzSharp.NativeAssets.Android": {
- "type": "Transitive",
- "resolved": "8.3.1.3",
- "contentHash": "Yte9/yYql8ngAjo7YgHlXSinLJcJXIRBM9gegVXpJ2SVYT1i2O/wMA+H3jmYiYiTQxHpHKi4exZUcMzry171MA=="
- },
- "HarfBuzzSharp.NativeAssets.Linux": {
- "type": "Transitive",
- "resolved": "8.3.1.3",
- "contentHash": "RI6A1LgmooU30+4QIyFt5rmBCzP0VzTR+587IJSGvYIsHHWlahFufihYxtraLfsIhW7I8dn6+xX+DZGygOPKWQ=="
- },
- "SkiaSharp.NativeAssets.Android": {
- "type": "Transitive",
- "resolved": "3.119.4",
- "contentHash": "qfjNh5hZBZxpOIM1aeDByj2qNbcK2JZG5Y7YyGSeliaYnf1N/hVfsswIPUa+qzcMqS9Q0VCGk85zQLvwVXtrvQ=="
- },
- "SkiaSharp.NativeAssets.Linux": {
- "type": "Transitive",
- "resolved": "3.119.4",
- "contentHash": "UAyVzbqNfZsZbKbzj68zXLyUyF/SbTKmzTfOO6qDu++dtIUMMTzPBe8oOuzU/DiewpfKoUUlOSsJmqWc6blxBw=="
- },
- "libsodium": {
- "type": "CentralTransitive",
- "requested": "[1.0.22, )",
- "resolved": "1.0.22",
- "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
- }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Api/packages.lock.json b/src/DodoSSH.Client.Api/packages.lock.json
index 8d54521..b82257b 100644
--- a/src/DodoSSH.Client.Api/packages.lock.json
+++ b/src/DodoSSH.Client.Api/packages.lock.json
@@ -41,6 +41,14 @@
"libsodium": "[1.0.22, 1.0.23)"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs b/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs
index 4536dc7..eb8b1ac 100644
--- a/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs
+++ b/src/DodoSSH.Client.Auth/LoopbackCallbackListener.cs
@@ -43,7 +43,46 @@ public sealed record CallbackResult(IReadOnlyDictionary Paramete
/// but the name resolves through the hosts file and DNS, and the address does not.
///
///
-public sealed class LoopbackCallbackListener : IDisposable
+///
+/// Where the authorization response comes back to.
+///
+///
+///
+/// An interface because the answer is a platform decision. A desktop uses a loopback redirect (RFC 8252
+/// §7.3) because it has a real HTTP stack and no way to register a URI scheme; a phone must not, because
+/// on a shared device any other application can bind a loopback port and race for the response — the
+/// attack §8.3 names. Android registers a redirect with the system instead and is handed the response as
+/// an intent.
+///
+///
+/// What does not vary is everything above it. PKCE, the state check, discovery, the token exchange
+/// and the key binding are the same on both, which is the whole point of putting the seam here rather than
+/// giving the phone its own flow — a second implementation of an OIDC client is a second place for a
+/// security bug to live.
+///
+///
+public interface IAuthorizationCallback : IDisposable
+{
+ ///
+ /// Where the provider should send the response.
+ ///
+ ///
+ /// Read before the browser opens and sent again with the token request, because the authorization
+ /// server has to see the same value twice. A listener that chose its port lazily would hand out one
+ /// URI and listen on another.
+ ///
+ Uri RedirectUri { get; }
+
+ /// Waits for the response.
+ ///
+ /// What to show in the browser once the response arrives. Ignored by an implementation that has no
+ /// browser tab of its own to write into — the system closes the tab when the app takes the intent.
+ ///
+ /// Abandons the wait.
+ Task WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken);
+}
+
+public sealed class LoopbackCallbackListener : IAuthorizationCallback
{
/// Longest request line and header block accepted, as a denial-of-service bound.
private const int MaximumRequestBytes = 16 * 1024;
diff --git a/src/DodoSSH.Client.Auth/OidcClient.cs b/src/DodoSSH.Client.Auth/OidcClient.cs
index a77334b..227653e 100644
--- a/src/DodoSSH.Client.Auth/OidcClient.cs
+++ b/src/DodoSSH.Client.Auth/OidcClient.cs
@@ -206,7 +206,10 @@ public sealed class OidcClient(
var pkce = PkcePair.Create();
var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
- using var listener = new LoopbackCallbackListener(options.RedirectPath);
+ // Loopback on a desktop, a registered redirect on a phone. See OidcClientOptions.CallbackFactory —
+ // everything below this line is identical either way, which is the reason the seam is here and not
+ // one layer up.
+ using var listener = options.CallbackFactory(options.RedirectPath);
var redirectUri = listener.RedirectUri;
var authorizeUri = BuildAuthorizeUri(
diff --git a/src/DodoSSH.Client.Auth/OidcClientOptions.cs b/src/DodoSSH.Client.Auth/OidcClientOptions.cs
index e0a911f..8532787 100644
--- a/src/DodoSSH.Client.Auth/OidcClientOptions.cs
+++ b/src/DodoSSH.Client.Auth/OidcClientOptions.cs
@@ -8,7 +8,7 @@ namespace DodoSSH.Client.Auth;
/// onboarding story: the user types one server URL and the client discovers the authority, the client
/// id and the scopes from it.
///
-public sealed class OidcClientOptions
+public sealed record OidcClientOptions
{
/// The provider's issuer URL.
public required Uri Authority { get; init; }
@@ -28,6 +28,23 @@ public sealed class OidcClientOptions
/// Path the loopback listener answers the redirect on.
public string RedirectPath { get; init; } = "/callback";
+ ///
+ /// How the authorization response is received. Loopback unless a head substitutes one.
+ ///
+ ///
+ ///
+ /// A factory rather than an instance, because a callback owns a socket or a system registration and
+ /// must not outlive one sign-in. It takes the redirect path so the default keeps behaving exactly as
+ /// it did when it was constructed inline.
+ ///
+ ///
+ /// The Android head replaces this: a loopback redirect on a shared device is the attack RFC 8252 §8.3
+ /// names, since any other application can bind the port. See .
+ ///
+ ///
+ public Func CallbackFactory { get; init; } =
+ path => new LoopbackCallbackListener(path);
+
/// Whether provider metadata must be served over HTTPS. Only false for local development.
public bool RequireHttpsMetadata { get; init; } = true;
diff --git a/src/DodoSSH.Client.Auth/packages.lock.json b/src/DodoSSH.Client.Auth/packages.lock.json
index f70ba7f..6bc156e 100644
--- a/src/DodoSSH.Client.Auth/packages.lock.json
+++ b/src/DodoSSH.Client.Auth/packages.lock.json
@@ -14,6 +14,7 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
}
- }
+ },
+ "net10.0/android-arm64": {}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Domain/packages.lock.json b/src/DodoSSH.Client.Domain/packages.lock.json
index f70ba7f..6bc156e 100644
--- a/src/DodoSSH.Client.Domain/packages.lock.json
+++ b/src/DodoSSH.Client.Domain/packages.lock.json
@@ -14,6 +14,7 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
}
- }
+ },
+ "net10.0/android-arm64": {}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Import/packages.lock.json b/src/DodoSSH.Client.Import/packages.lock.json
index a11804f..05c5d79 100644
--- a/src/DodoSSH.Client.Import/packages.lock.json
+++ b/src/DodoSSH.Client.Import/packages.lock.json
@@ -17,6 +17,7 @@
"dodossh.client.domain": {
"type": "Project"
}
- }
+ },
+ "net10.0/android-arm64": {}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.ObjectStore/packages.lock.json b/src/DodoSSH.Client.ObjectStore/packages.lock.json
index 99de2a2..08c7f37 100644
--- a/src/DodoSSH.Client.ObjectStore/packages.lock.json
+++ b/src/DodoSSH.Client.ObjectStore/packages.lock.json
@@ -83,6 +83,14 @@
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs
index 43f784b..9b7370b 100644
--- a/src/DodoSSH.Client.Session/ServerConnection.cs
+++ b/src/DodoSSH.Client.Session/ServerConnection.cs
@@ -270,7 +270,8 @@ public sealed class ServerConnection : IVaultServer
Uri serverUrl,
IBrowserLauncher browser,
TimeProvider clock,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ Func? configureOidc = null)
{
ArgumentNullException.ThrowIfNull(serverUrl);
ArgumentNullException.ThrowIfNull(browser);
@@ -287,7 +288,17 @@ public sealed class ServerConnection : IVaultServer
var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);
- var oidc = new OidcClient(transport, browser, clock, BuildOidcOptions(configuration));
+ // The head gets a say in how the authorization response comes back, and in nothing else.
+ // Everything that decides whether the flow is *safe* — PKCE, the state check, the discovery
+ // document, the token exchange — is built from the server's own configuration above and is not
+ // reachable from here. See OidcClientOptions.CallbackFactory.
+ var oidcOptions = BuildOidcOptions(configuration);
+
+ var oidc = new OidcClient(
+ transport,
+ browser,
+ clock,
+ configureOidc is null ? oidcOptions : configureOidc(oidcOptions));
var tokenSet = await oidc.SignInAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/DodoSSH.Client.Session/packages.lock.json b/src/DodoSSH.Client.Session/packages.lock.json
index 0b12ecc..61c6203 100644
--- a/src/DodoSSH.Client.Session/packages.lock.json
+++ b/src/DodoSSH.Client.Session/packages.lock.json
@@ -291,6 +291,20 @@
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index bb791f3..fa14d8b 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -411,6 +411,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Where the embedded browser should navigate.
internal Uri TerminalPageUrl => workspace.PageUrl;
+ ///
+ /// Types into a terminal on behalf of something that is not the keyboard.
+ ///
+ ///
+ ///
+ /// Exposed on the shell rather than reached through the workspace directly, because the workspace is a
+ /// composition-root object and a view has no business holding one — the same reason tabs go through
+ /// here rather than through TerminalWorkspace.CloseSessionAsync.
+ ///
+ ///
+ /// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or
+ /// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this
+ /// way — it goes from the renderer straight down the socket.
+ ///
+ ///
+ internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory data) =>
+ workspace.SendInputAsync(sessionId, data, CancellationToken.None);
+
///
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
///
@@ -620,6 +638,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Surface = ShellSurface.Page;
}
+ /// Switches to the terminal surface.
+ ///
+ ///
+ /// The other half of , and it exists because the phone's bottom bar
+ /// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
+ /// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
+ /// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
+ /// button, and a button needs a command.
+ ///
+ ///
+ /// Not gated on there being a tab, for the same reason is not: closing
+ /// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
+ /// screen carries an empty state anyway, which is worth being able to reach deliberately.
+ ///
+ ///
+ [RelayCommand]
+ private void ShowTerminal() => Surface = ShellSurface.Terminal;
+
// ---- Open terminals ----
///
diff --git a/src/DodoSSH.Client.Shell/packages.lock.json b/src/DodoSSH.Client.Shell/packages.lock.json
index c3f199a..ad198a7 100644
--- a/src/DodoSSH.Client.Shell/packages.lock.json
+++ b/src/DodoSSH.Client.Shell/packages.lock.json
@@ -371,6 +371,20 @@
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Ssh/packages.lock.json b/src/DodoSSH.Client.Ssh/packages.lock.json
index fdf5787..e5c55f8 100644
--- a/src/DodoSSH.Client.Ssh/packages.lock.json
+++ b/src/DodoSSH.Client.Ssh/packages.lock.json
@@ -58,6 +58,14 @@
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Storage/packages.lock.json b/src/DodoSSH.Client.Storage/packages.lock.json
index 0b7ddce..cfebbd2 100644
--- a/src/DodoSSH.Client.Storage/packages.lock.json
+++ b/src/DodoSSH.Client.Storage/packages.lock.json
@@ -395,6 +395,20 @@
"SQLitePCLRaw.core": "2.1.12"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Sync/packages.lock.json b/src/DodoSSH.Client.Sync/packages.lock.json
index 76eefc2..8971da3 100644
--- a/src/DodoSSH.Client.Sync/packages.lock.json
+++ b/src/DodoSSH.Client.Sync/packages.lock.json
@@ -252,6 +252,20 @@
"SQLitePCLRaw.core": "2.1.12"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
index c62c42a..7e26335 100644
--- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
@@ -335,6 +335,45 @@ public sealed class TerminalWorkspace : IAsyncDisposable
return true;
}
+ ///
+ /// Sends keystrokes to one terminal as though they had been typed into it.
+ ///
+ ///
+ ///
+ /// Ordinary typing does not come through here — it goes from the renderer's own keyboard handling
+ /// straight down the socket, which is one hop shorter and is what keeps a fast cat responsive.
+ /// This is for input that has no key on the keyboard to produce it.
+ ///
+ ///
+ /// Which on a phone is most of the useful input. A software keyboard has no Ctrl, Esc, Tab or
+ /// arrows, so the Android head draws an accessory row and sends the bytes itself; the desktop head
+ /// will want the same seam the day it grows a snippet that types into a terminal. Bytes rather than a
+ /// key name deliberately: what a terminal wants is a control sequence, and translating one at this
+ /// layer would mean owning a keymap that the renderer already owns.
+ ///
+ ///
+ /// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
+ /// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
+ ///
+ ///
+ /// The terminal to type into.
+ /// Raw bytes, already encoded as the remote expects them.
+ /// Cancellation token.
+ public ValueTask SendInputAsync(
+ uint sessionId,
+ ReadOnlyMemory data,
+ CancellationToken cancellationToken)
+ {
+ TerminalSessionPump? pump;
+
+ lock (sessionGate)
+ {
+ pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
+ }
+
+ return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
+ }
+
///
/// Closes one terminal.
///
diff --git a/src/DodoSSH.Client.Terminal/packages.lock.json b/src/DodoSSH.Client.Terminal/packages.lock.json
index 6e0493f..e1712a8 100644
--- a/src/DodoSSH.Client.Terminal/packages.lock.json
+++ b/src/DodoSSH.Client.Terminal/packages.lock.json
@@ -65,6 +65,14 @@
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Transfer/packages.lock.json b/src/DodoSSH.Client.Transfer/packages.lock.json
index 6e0493f..e1712a8 100644
--- a/src/DodoSSH.Client.Transfer/packages.lock.json
+++ b/src/DodoSSH.Client.Transfer/packages.lock.json
@@ -65,6 +65,14 @@
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Contracts/packages.lock.json b/src/DodoSSH.Contracts/packages.lock.json
index a5a3876..395937b 100644
--- a/src/DodoSSH.Contracts/packages.lock.json
+++ b/src/DodoSSH.Contracts/packages.lock.json
@@ -26,6 +26,7 @@
"resolved": "10.0.10",
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
}
- }
+ },
+ "net10.0/android-arm64": {}
}
}
\ No newline at end of file
diff --git a/src/DodoSSH.Crypto/packages.lock.json b/src/DodoSSH.Crypto/packages.lock.json
index 52bb4f7..2f41cfd 100644
--- a/src/DodoSSH.Crypto/packages.lock.json
+++ b/src/DodoSSH.Crypto/packages.lock.json
@@ -35,6 +35,14 @@
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
}
+ },
+ "net10.0/android-arm64": {
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ }
}
}
}
\ No newline at end of file
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index b41c273..d8593f6 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -562,6 +562,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Screen.ShouldBe(ShellScreen.Preferences);
}
+ ///
+ ///
+ /// The phone's bottom bar names the terminal beside the pages, so the surface needs a command of its
+ /// own — the desktop only ever reaches it implicitly, by opening a session or clicking a tab.
+ ///
+ ///
+ /// Tested here rather than in the Android head because it is shared state-machine behaviour, and
+ /// because nothing in this repository can run a test on a phone.
+ ///
+ ///
+ [Fact]
+ public async Task ShowingTheTerminal_SwitchesSurfaceWithoutChangingTheScreen()
+ {
+ var vault = await ReadyToConnectAsync();
+
+ await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
+ await vault.ConnectCommand.ExecuteAsync(null);
+
+ shell.ShowScreenCommand.Execute(ShellScreen.Vault);
+ shell.IsTerminalShowing.ShouldBeFalse();
+
+ shell.ShowTerminalCommand.Execute(null);
+
+ shell.IsTerminalShowing.ShouldBeTrue();
+ shell.IsShowingPages.ShouldBeFalse();
+
+ // The page underneath is remembered, not reset. Going to the terminal and back is navigation, and
+ // navigation that forgets where you were is how a four-button bar becomes annoying.
+ shell.Screen.ShouldBe(ShellScreen.Vault);
+ }
+
///
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
/// transition that moves the surface back on its own.
diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
index a62f3d4..c15d086 100644
--- a/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
+++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalWorkspaceTests.cs
@@ -268,6 +268,56 @@ public sealed class TerminalWorkspaceTests
// ---- Helpers ----
+ ///
+ ///
+ /// Input that did not come from the keyboard. The Android head's accessory key row is what needs this —
+ /// a software keyboard has no Ctrl, Esc, Tab or arrows — and what it sends has to arrive at the remote
+ /// byte for byte, because an escape sequence that loses a byte is not a degraded arrow key, it is a
+ /// stray character in somebody's shell.
+ ///
+ ///
+ /// Ordinary typing does not come this way and is not what is being tested: that goes from the renderer
+ /// down the socket, which the pump's own tests cover.
+ ///
+ ///
+ [Fact]
+ public async Task SendingInput_ReachesTheRemoteUnchanged()
+ {
+ var connections = new FakeConnectionFactory();
+
+ await using var workspace = CreateWorkspace(connections);
+
+ var sessionId = await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ // The up-arrow, as a PTY expects it. Three bytes, and all three matter.
+ byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
+
+ await workspace.SendInputAsync(sessionId, upArrow, TestContext.Current.CancellationToken);
+
+ await WaitUntilAsync(() =>
+ connections.Connections.SingleOrDefault()?.Shell?.Written.SequenceEqual(upArrow) == true);
+ }
+
+ ///
+ /// A tab can close while a key is still in flight, which on a phone is one mis-tap rather than a rare
+ /// race — the close cross sits inside the tab and the accessory row is directly under it. Throwing
+ /// would turn that into a crash on a keystroke that no longer matters.
+ ///
+ [Fact]
+ public async Task SendingInputToASessionThatIsGone_IsIgnored()
+ {
+ await using var workspace = CreateWorkspace(new FakeConnectionFactory());
+
+ var sessionId = await workspace.OpenSessionAsync(
+ Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
+
+ await workspace.CloseSessionAsync(sessionId);
+
+ await Should.NotThrowAsync(async () =>
+ await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
+ }
+
///
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
/// take a fake anyway, because reaching a real host from here would make this a network test.