Public Access
Give the phone the rest of its screens, and a way in
All seven screens of the design, plus the two it does not draw because it starts at an enrolled phone: naming a server, and choosing a passphrase. The five states docs/android-port.md worried about losing at 360dp are all here and none of them softened. The changed-key refusal is a full-screen panel rather than a bottom sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards, so the sentence about screenshots is true rather than decorative. The delete confirmations keep their counts and replace the row in place. Signing in works, and the seam it needed is 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 stay one implementation — a second OIDC client would be a second place for a security bug to live. The phone registers a private-use scheme with the system rather than binding a loopback port, which on a shared device any other app can do first. The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from the renderer straight down the socket, and there was no way in for the keys a software keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is drawn — a modifier that is on and does not look on is how somebody sends ^L to a database prompt believing they typed an l. 597 client tests green, including two new ones for the input path and one for the terminal surface command. Nothing has run on a device.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+48
-19
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://DodoSSH.Client.Android/Theme/Phone.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Difference 4, and the one that is a refusal rather than an implementation.
|
||||
/// Difference 4: where the authorization response comes back to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Signing in needs a redirect this head has not got. The desktop client receives the authorization
|
||||
/// response on a loopback <c>TcpListener</c> (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. <c>Process.Start</c> does not exist on this platform either.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So this throws rather than half-working, and the shell never reaches it: <c>NeedsServer</c> 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.
|
||||
/// </para>
|
||||
/// 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 Task<IVaultServer> 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) };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The phone's control styles. The palette they draw from lives in Shell and is shared. -->
|
||||
<AvaloniaResource Include="Theme/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Android" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>OnNewIntent</c> rather than <c>OnCreate</c>, and that is what <c>SingleTask</c> 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.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
protected override void OnResume()
|
||||
{
|
||||
base.OnResume();
|
||||
|
||||
PhoneEnvironment.CurrentActivity = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
protected override void OnPause()
|
||||
{
|
||||
base.OnPause();
|
||||
|
||||
if (ReferenceEquals(PhoneEnvironment.CurrentActivity, this))
|
||||
{
|
||||
PhoneEnvironment.CurrentActivity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using DodoSSH.Client.Auth;
|
||||
using global::Android.Content;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the authorization page in the system browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>Process.Start</c> 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 <em>system</em> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An <c>ACTION_VIEW</c> 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 <c>androidx.browser</c> package; this needs
|
||||
/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AndroidBrowserLauncher : IBrowserLauncher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives the authorization response as an intent rather than on a socket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why not the loopback listener.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A private-use scheme, and its limit is worth stating.</b> The redirect is
|
||||
/// <c>dev.dodotech.dodossh:/oauth</c> — 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AndroidRedirectCallback : IAuthorizationCallback
|
||||
{
|
||||
/// <summary>The scheme, which must match the intent filter on MainActivity exactly.</summary>
|
||||
internal const string Scheme = "dev.dodotech.dodossh";
|
||||
|
||||
private readonly TaskCompletionSource<CallbackResult> completion =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private static AndroidRedirectCallback? waiting;
|
||||
|
||||
/// <param name="path">
|
||||
/// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and
|
||||
/// this one cannot silently differ.
|
||||
/// </param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Uri RedirectUri { get; }
|
||||
|
||||
/// <summary>Hands a received redirect to whichever sign-in is waiting for it.</summary>
|
||||
/// <remarks>
|
||||
/// Called from <c>MainActivity.OnNewIntent</c>. 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.
|
||||
/// </remarks>
|
||||
internal static void Complete(Uri redirect)
|
||||
{
|
||||
var target = Volatile.Read(ref waiting);
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new Dictionary<string, string>(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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CallbackResult> WaitForCallbackAsync(
|
||||
string completionHtml,
|
||||
CancellationToken cancellationToken) =>
|
||||
completion.Task.WaitAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
@@ -78,6 +77,17 @@ internal static class PhoneEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The activity currently on screen, or null while the app is backgrounded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set and cleared by <c>MainActivity</c>. Separate from <see cref="Require"/> 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.
|
||||
/// </remarks>
|
||||
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
||||
|
||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||
public static Context Require() =>
|
||||
context ?? throw new InvalidOperationException(
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
The phone's own control styles. The desktop head has an equivalent block in its App.axaml and the two
|
||||
deliberately do not share one: the palette is shared because a colour must not drift, but a control's
|
||||
size is a property of the input device. Every touch target here is at least 44 effective pixels tall,
|
||||
which is the number the desktop's 28-pixel rows would fail.
|
||||
|
||||
Classes rather than implicit styles for the buttons, because the shell needs three that differ in
|
||||
meaning rather than in degree: the accent one is what the screen wants you to do, the outlined one is
|
||||
the alternative, and the red one is the one that costs something.
|
||||
-->
|
||||
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Opacity" Value="0.82" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
|
||||
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
|
||||
reads the screen as broken rather than as waiting for them.
|
||||
-->
|
||||
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#1A1F1D" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.secondary">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
||||
<Style Selector="Button.row">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="16,6" />
|
||||
</Style>
|
||||
<Style Selector="Button.row:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A category chip. A RadioButton rather than a ToggleButton because the four are exclusive and a screen
|
||||
reader should say so; the bullet its default template draws is removed, since the chip's own fill is
|
||||
what carries the state.
|
||||
-->
|
||||
<Style Selector="RadioButton.chip">
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Padding" Value="12,6" />
|
||||
<Setter Property="CornerRadius" Value="17" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="Chip" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
MinHeight="{TemplateBinding MinHeight}">
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="RadioButton.chip:checked">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox.field">
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="Padding" Value="14,0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||
nothing pings anything, and a dot meaning "up" would be a claim the application never checks.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot">
|
||||
<Setter Property="Width" Value="7" />
|
||||
<Setter Property="Height" Value="7" />
|
||||
<Setter Property="Fill" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
<Style Selector="Ellipse.dot.live">
|
||||
<Setter Property="Fill" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||
<Style Selector="TextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.detail">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="LineHeight" Value="19" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.title">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A fingerprint, and it is a style rather than four repeated setters because the rule it encodes is a
|
||||
product rule: never truncated, and always breakable across lines rather than clipped. Four screens show
|
||||
one, and the day one of them clips is the day somebody approves the wrong key.
|
||||
-->
|
||||
<Style Selector="TextBlock.fingerprint">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
@@ -0,0 +1,46 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.EnrollScreen"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
ShellState.NeedsEnrollment — signed in, but the account has no vault key yet. Step 2 of the three the
|
||||
recovery screen counts.
|
||||
|
||||
The warning is above the boxes rather than below them, and that is the whole design of this screen: it
|
||||
is the only moment where telling someone the passphrase cannot be recovered can still change what they
|
||||
type. Underneath the button it would be a receipt.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="24,40,24,32" Spacing="0">
|
||||
|
||||
<TextBlock Classes="label" Text="ENROLLMENT · STEP 2 OF 3" Foreground="{StaticResource TextFaint}" />
|
||||
<TextBlock Classes="title" Text="CHOOSE A PASSPHRASE" FontSize="17" Margin="0,10,0,0" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,10,0,0"
|
||||
Text="This passphrase encrypts your vault on this phone and on the server. Nothing can recover it — not the operator, not a reset link. A recovery code follows, and losing both makes the vault unopenable." />
|
||||
|
||||
<TextBlock Classes="label" Text="PASSPHRASE" Margin="0,20,0,0" />
|
||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding Passphrase}" PasswordChar="•"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<TextBlock Classes="label" Text="CONFIRM" Margin="0,14,0,0" />
|
||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding ConfirmPassphrase}" PasswordChar="•"
|
||||
IsEnabled="{Binding !IsBusy}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<Button Classes="primary" Content="CREATE THE VAULT" Margin="0,18,0,0"
|
||||
Command="{Binding EnrollCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,14,0,0" TextAlignment="Center" Text="{Binding StatusMessage}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>ShellState.NeedsEnrollment — choose the passphrase that encrypts the vault.</summary>
|
||||
internal sealed partial class EnrollScreen : UserControl
|
||||
{
|
||||
public EnrollScreen() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.HostKeySheet"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
Designs 05 and 06 in one control, because they are one decision point with two answers and the shell
|
||||
should not be able to show both. Which one draws is HasPendingHostKey against HasHostKeyMismatch, and
|
||||
the view models make those mutually exclusive.
|
||||
|
||||
◆ Both are load-bearing, and the difference between them is the most important thing on either screen:
|
||||
|
||||
05 offers TRUST AND CONNECT, because first contact is a decision a person is entitled to make.
|
||||
06 offers no way forward at all. There is no continue button, no "connect anyway", and no gesture that
|
||||
dismisses it into a connection — the only exits are BACK and the host's own editor. That is deliberate
|
||||
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
|
||||
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
|
||||
sheet for that reason, it is a full-screen panel.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
|
||||
<!-- ============ 05 UNKNOWN HOST KEY ============ -->
|
||||
<Panel IsVisible="{Binding HasPendingHostKey}">
|
||||
|
||||
<!-- Scrim. Non-interactive by itself: tapping outside must not answer the question. -->
|
||||
<Border Background="#9E040505" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||
<ScrollViewer MaxHeight="620">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,16" />
|
||||
|
||||
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,6,0,0">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding PendingHostKey.Algorithm}" />
|
||||
|
||||
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="12,11">
|
||||
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="detail" Margin="0,8,0,0" TextWrapping="Wrap"
|
||||
Text="Compare with the fingerprint the operator published before trusting." />
|
||||
|
||||
<Button Classes="primary" Content="TRUST AND CONNECT" Margin="0,16,0,0"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
|
||||
<Button Classes="secondary" Content="CANCEL" Margin="0,8,0,0"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ 06 CHANGED HOST KEY ============ -->
|
||||
<!--
|
||||
Opaque and full-screen, not a sheet over the previous screen. There is nothing behind this worth
|
||||
seeing and nothing behind it worth tapping.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasHostKeyMismatch}" Background="{StaticResource Canvas}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="22,40,22,24" Spacing="0">
|
||||
|
||||
<Border Width="44" Height="44" BorderBrush="{StaticResource Danger}" BorderThickness="1.5"
|
||||
CornerRadius="6" HorizontalAlignment="Left">
|
||||
<TextBlock Text="!" Foreground="{StaticResource Danger}" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="20" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="title" Text="HOST KEY CHANGED" FontSize="17"
|
||||
Foreground="{StaticResource Danger}" Margin="0,16,0,0" />
|
||||
|
||||
<!--
|
||||
The whole explanation comes from the view model, which already composes it for the desktop
|
||||
head — including which host, which dates, and both fingerprints. Rewriting it here would be a
|
||||
second copy of the most safety-critical sentence in the product.
|
||||
-->
|
||||
<SelectableTextBlock Classes="fingerprint" Margin="0,12,0,0" FontSize="11"
|
||||
Foreground="{StaticResource Text}"
|
||||
Text="{Binding HostKeyMismatch}" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,16,0,0" Foreground="{StaticResource TextFaint}"
|
||||
Text="If this server was rebuilt on purpose, open the host's editor and use FORGET HOST KEY, then connect again. There is no way to continue from this screen — that is deliberate." />
|
||||
|
||||
<!--
|
||||
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
||||
where the accent button connects.
|
||||
-->
|
||||
<Button Classes="secondary" Content="BACK TO HOSTS" Margin="0,24,0,0"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Designs 05 and 06 — the two host-key decisions, one of which has no way forward.</summary>
|
||||
internal sealed partial class HostKeySheet : UserControl
|
||||
{
|
||||
public HostKeySheet() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.HostsScreen"
|
||||
x:DataType="vm:VaultViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Design 02 — HOSTS, the phone's home.
|
||||
|
||||
The desktop's sidebar and its right-hand connect column cannot both be on screen at 360dp, so they are
|
||||
the same column here: the list fills the screen, and choosing a host raises the connect bar over the
|
||||
bottom of it. That is the one structural change from the design, and it is why the bar names the host
|
||||
it will connect to — on the desktop the selection is visible beside the button, and here it is not.
|
||||
|
||||
Groups are rows in the same list rather than a nesting container, which is what SidebarRows already
|
||||
hands both heads. The desktop shows group headings only when groups exist and so does this, for free.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ filter ============ -->
|
||||
<!--
|
||||
The design's "quick connect · search hosts" bar. On the desktop this is Ctrl+K over a palette; on a
|
||||
phone the list is already the palette, so it filters in place rather than opening a second surface.
|
||||
-->
|
||||
<Border Grid.Row="0" Margin="16,12,16,4" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="6">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text=">" Classes="detail" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="search hosts"
|
||||
Height="44" Padding="9,0" VerticalContentAlignment="Center"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="11.5" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ sync + conflicts ============ -->
|
||||
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="6">
|
||||
|
||||
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
◆ The conflict log. The merge is only allowed to pick a winner because this exists, so it is a
|
||||
banner the user has to dismiss rather than a screen they might never open.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasConflicts}" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
|
||||
<StackPanel Spacing="8">
|
||||
<ItemsControl ItemsSource="{Binding Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource WarnText}" TextWrapping="Wrap"
|
||||
Text="{Binding Summary}" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="secondary" Height="40" Content="DISMISS ALL" FontSize="10"
|
||||
Command="{Binding AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding HasUnreadableItems}" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource DangerText}" TextWrapping="Wrap"
|
||||
Text="{Binding UnreadableSummary}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ the list ============ -->
|
||||
<ListBox Grid.Row="2" Margin="0,8,0,0"
|
||||
ItemsSource="{Binding SidebarRows}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
|
||||
<ListBox.DataTemplates>
|
||||
|
||||
<!--
|
||||
A group heading. SidebarGroupHeader, not HostGroupRowViewModel — the latter is a row in the
|
||||
groups editor, and the two are deliberately different shapes: this one carries the count *after*
|
||||
the filter, which is what a heading over a filtered list has to say.
|
||||
-->
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="16,12,16,6">
|
||||
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}" Margin="7,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- A host. -->
|
||||
<DataTemplate DataType="vm:HostRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="54" Margin="16,6">
|
||||
|
||||
<!-- Green means a terminal is open on this host right now. See the .dot style. -->
|
||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border IsVisible="{Binding HasVaultBadge}" BorderBrush="{StaticResource BorderFaint}"
|
||||
BorderThickness="1" CornerRadius="3" Padding="4,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="detail" FontSize="8.5" Text="{Binding VaultBadge}" />
|
||||
</Border>
|
||||
<Border IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="3"
|
||||
Padding="4,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
|
||||
Text="{Binding Badge}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="detail" Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" Text="·" />
|
||||
<TextBlock Classes="detail" Text="{Binding Authentication}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<!-- ============ connect ============ -->
|
||||
<!--
|
||||
Raised over the list when a host is chosen, because the desktop's right-hand column has nowhere to go
|
||||
at this width. It names the host: the selection that is obvious on a wide window is not obvious here.
|
||||
-->
|
||||
<Border Grid.Row="3" IsVisible="{Binding SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Padding="16,12">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="CONNECT TO" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedHost.Label}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
|
||||
is an invitation to type a secret nothing will use.
|
||||
-->
|
||||
<TextBox Classes="field" Height="44" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding ConnectPassword}" PasswordChar="•" PlaceholderText="password">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}"
|
||||
Text="{Binding SelectedHostAuthenticationNote}" />
|
||||
|
||||
<Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
public HostsScreen() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
|
||||
x:DataType="vm:VaultViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Design 04 — KEYCHAIN: everything in the vault that is not a host.
|
||||
|
||||
Two things from the design are structural rather than decorative and both are kept.
|
||||
|
||||
The category chips carry counts, because "SSH KEYS 8" answers a question the word alone does not.
|
||||
And ◆ the delete confirmation replaces the row in place rather than stacking a dialogue over it — the
|
||||
design is explicit about that, and on a phone it matters more than on the desktop: a modal over a list
|
||||
hides which row it is talking about, which is exactly the information the confirmation exists to give.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<!-- ============ categories ============ -->
|
||||
<ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
Margin="0,12,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
|
||||
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="ALL" />
|
||||
<TextBlock Text="{Binding TotalItemCount}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="SSH KEYS" />
|
||||
<TextBlock Text="{Binding Keys.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsCredentials, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Credentials}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="PASSWORDS" />
|
||||
<TextBlock Text="{Binding Credentials.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsBuckets, Mode=OneWay}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Buckets}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="BUCKETS" />
|
||||
<TextBlock Text="{Binding ObjectStores.Count}" />
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ ◆ the counted confirmation ============ -->
|
||||
<!--
|
||||
In place, above the list it refers to. The text comes from the view model, which counts what breaks —
|
||||
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole
|
||||
difference between a sentence somebody reads and one they click past.
|
||||
-->
|
||||
<Border Grid.Row="1" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14,12">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding PendingDeletion.Question}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||
Text="{Binding PendingDeletion.Consequence}" />
|
||||
<!-- The line worth reading twice: what depends on the item, counted. Absent when nothing does. -->
|
||||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||
IsVisible="{Binding PendingDeletion.HasUsage}"
|
||||
Text="{Binding PendingDeletion.Usage}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||||
Command="{Binding CancelDeleteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the items ============ -->
|
||||
<Panel Grid.Row="2">
|
||||
|
||||
<!-- Written per section by the view model, and it says what the section is for. -->
|
||||
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
|
||||
VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" MinHeight="56" Margin="16,6">
|
||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="Medium" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="3"
|
||||
Padding="5,1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="detail" FontSize="8" Text="{Binding Type}" />
|
||||
</Border>
|
||||
<Border IsVisible="{Binding HasBadge}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" CornerRadius="3" Padding="5,1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
|
||||
Text="{Binding Badge}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- What is known *about* an item, never the secret. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Design 04 — everything in the vault that is not a host.</summary>
|
||||
internal sealed partial class KeychainScreen : UserControl
|
||||
{
|
||||
public KeychainScreen() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// One destination in the bottom bar.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The accent bar is on top, not underneath.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class NavButton : Button
|
||||
{
|
||||
public static readonly StyledProperty<string> LabelProperty =
|
||||
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
|
||||
|
||||
public static readonly StyledProperty<bool> IsCurrentProperty =
|
||||
AvaloniaProperty.Register<NavButton, bool>(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);
|
||||
}
|
||||
|
||||
/// <summary>Whether this is the destination currently showing.</summary>
|
||||
public bool IsCurrent
|
||||
{
|
||||
get => GetValue(IsCurrentProperty);
|
||||
set => SetValue(IsCurrentProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,51 +7,153 @@
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
The phone's single view, and the counterpart of the desktop head's MainWindow — except that this one
|
||||
has no window, no nav rail, no titlebar and no status bar. It switches on ShellState and nothing else.
|
||||
The phone's single view, and the counterpart of the desktop head's MainWindow — except that this one has
|
||||
no window, no nav rail, no titlebar and no status bar.
|
||||
|
||||
The states are the same six the desktop has, and they are the same six for a good reason: they are the
|
||||
shell's state machine, which both heads share. What differs is only what each one draws.
|
||||
It switches on ShellState, which is the same six states the desktop has because both heads share one
|
||||
state machine. What differs is only what each one draws.
|
||||
|
||||
Panels rather than a template selector, matching the desktop head: each screen's visibility is one
|
||||
binding, and the whole tree is laid out once. There is no WebView occlusion problem to design around
|
||||
here, which is the one structural simplification the phone gets for free — see docs/android-port.md §9
|
||||
for what is still unverified about that on this platform.
|
||||
The nav rail's eight destinations become four in a bottom bar. That is not a reflow: the plan says five
|
||||
characters was a desktop constraint and the phone can use words, and it also says a phone cannot carry
|
||||
eight. What is here is what the decided scope needs — the keychain and a terminal — and the destinations
|
||||
behind the other four are reachable from those two rather than being hidden.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
|
||||
<views:LockedScreen IsVisible="{Binding IsLocked}" DataContext="{Binding}" />
|
||||
|
||||
<!--
|
||||
The states this head has not built yet, named rather than hidden. The convention is the desktop
|
||||
head's NotBuiltScreen and the reason is in README: nothing is rendered with invented data to fill a
|
||||
screen, and a state that silently showed nothing would be indistinguishable from one that had
|
||||
quietly broken.
|
||||
|
||||
Sign-in is the substantial one, and it is not merely unwritten — it needs a different redirect. See
|
||||
docs/android-port.md §5: the loopback listener the desktop uses is the attack RFC 8252 §8.3 names on
|
||||
a shared device, so this head needs an app link before it can honestly offer the flow at all.
|
||||
-->
|
||||
<!-- ============ getting in ============ -->
|
||||
<views:PendingScreen IsVisible="{Binding IsStarting}"
|
||||
Heading="OPENING THE KEYCHAIN"
|
||||
Detail="Reading this phone's local cache to find out whether it is enrolled." />
|
||||
|
||||
<views:PendingScreen IsVisible="{Binding IsNeedingServer}"
|
||||
Heading="SIGN-IN IS NOT BUILT HERE YET"
|
||||
Detail="This phone has no profile, and signing in needs a redirect this head does not have. The desktop client's loopback listener is deliberately not reused: on a shared device any other app can bind a loopback port, which is the attack RFC 8252 §8.3 names. An app link is the next piece of work. Enroll on the desktop client and this phone will unlock against the same vault." />
|
||||
<views:ServerScreen IsVisible="{Binding IsNeedingServer}" />
|
||||
<views:EnrollScreen IsVisible="{Binding IsNeedingEnrollment}" />
|
||||
<views:RecoveryCodeScreen IsVisible="{Binding IsShowingRecoveryCode}" />
|
||||
<views:LockedScreen IsVisible="{Binding IsLocked}" />
|
||||
|
||||
<views:PendingScreen IsVisible="{Binding IsNeedingEnrollment}"
|
||||
Heading="ENROLLMENT IS NOT BUILT HERE YET"
|
||||
Detail="This account has no vault key. Choosing a passphrase — and writing down the recovery code that follows it — happens on the desktop client for now." />
|
||||
<!-- ============ unlocked ============ -->
|
||||
<Grid IsVisible="{Binding IsUnlocked}" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<views:PendingScreen IsVisible="{Binding IsShowingRecoveryCode}"
|
||||
Heading="RECOVERY CODE"
|
||||
Detail="This state is reachable only after enrollment, which this head does not do yet. It is the one screen a user must never be able to click past, so it is left unbuilt rather than approximated." />
|
||||
<!-- The header: which vault, and whether it is synced. -->
|
||||
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="16,0" Height="52">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Width="20" Height="20" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="1" VerticalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource Accent}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="9" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<views:PendingScreen IsVisible="{Binding IsUnlocked}"
|
||||
Heading="UNLOCKED"
|
||||
Detail="The vault is open. The host list, the keychain and the terminal are the next tranche of screens; the view models behind all three are already here and already driven by the desktop head." />
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" Spacing="1">
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="5" Height="5"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="8" Text="{Binding SyncLabel}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Classes="row" Width="44" MinHeight="44" Padding="0"
|
||||
HorizontalContentAlignment="Center" Command="{Binding LockCommand}">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5" Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||
<Panel Grid.Row="1">
|
||||
<!--
|
||||
Wrapped rather than bound directly, and the desktop head's MainWindow carries the same note for
|
||||
the same reason: this element's visibility is the shell's business and its data context is the
|
||||
vault, and putting both on one element resolves IsVisible against the vault — where
|
||||
IsHostsShowing does not exist. It is a compile error here rather than a silent blank screen only
|
||||
because compiled bindings are on.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsHostsShowing}">
|
||||
<views:HostsScreen DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
<Panel IsVisible="{Binding IsVaultShowing}">
|
||||
<views:KeychainScreen DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
<views:TerminalScreen IsVisible="{Binding IsTerminalShowing}" />
|
||||
|
||||
<views:PendingScreen IsVisible="{Binding IsPreferencesShowing}"
|
||||
Heading="PREFERENCES"
|
||||
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
|
||||
makes its own trust decision on the desktop, and when the phone grows that screen this must already
|
||||
be somewhere both can reach rather than nested inside one of them.
|
||||
-->
|
||||
<Panel Grid.Row="1">
|
||||
<views:HostKeySheet DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
The shells strip. The design puts it above the nav on every screen, and that is the single most
|
||||
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
|
||||
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
|
||||
host list, which is where you are when you open the next one.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="12,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="label" FontSize="8" Text="SHELLS" VerticalAlignment="Center" />
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Button Classes="row" MinHeight="32" Padding="10,6" CornerRadius="5"
|
||||
Background="{StaticResource Field}"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Ellipse Classes="dot live" Width="5" Height="5" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="10" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- ============ navigation ============ -->
|
||||
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="58">
|
||||
<Grid ColumnDefinitions="*,*,*,*">
|
||||
|
||||
<views:NavButton Grid.Column="0" Label="HOSTS" IsCurrent="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
|
||||
|
||||
<views:NavButton Grid.Column="1" Label="KEYCHAIN" IsCurrent="{Binding IsVaultShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
|
||||
|
||||
<!--
|
||||
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
|
||||
through ShowScreen. Its own command is on the shell.
|
||||
-->
|
||||
<views:NavButton Grid.Column="2" Label="TERMINAL" IsCurrent="{Binding IsTerminalShowing}"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<views:NavButton Grid.Column="3" Label="MORE" IsCurrent="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The phone's single view. The desktop head's MainWindow, without the window.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks screenshots and screen recording while the recovery code is on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The recovery screen says screenshots are blocked, and this is what makes that true rather than a
|
||||
/// claim. <c>FLAG_SECURE</c> is a window flag — no control can set it — so it lives here, on the one
|
||||
/// object that has the activity.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It is worth being clear about what this buys.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.RecoveryCodeScreen"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Design 07 — RECOVERY CODE, and the ◆ the plan worries about most after the changed-key refusal.
|
||||
|
||||
Three properties make this screen what it is, and all three are structural rather than cosmetic:
|
||||
|
||||
there is no back and no skip — this control draws no navigation at all, and the shell state it belongs
|
||||
to has no other exit; CONTINUE is disabled until the checkbox is ticked, bound to
|
||||
RecoveryCodeWrittenDown rather than to anything this view keeps; and the code is selectable so it can be
|
||||
copied into a password manager, which is what people actually do with it and what refusing would only
|
||||
push into a screenshot.
|
||||
|
||||
The design says screenshots are blocked here. That is FLAG_SECURE on the activity, which is a window
|
||||
flag rather than anything a control can set — see PhoneShell's code-behind, which raises it for exactly
|
||||
this state and lowers it afterwards.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="24,40,24,32" Spacing="0">
|
||||
|
||||
<TextBlock Classes="label" Text="ENROLLMENT · STEP 3 OF 3" Foreground="{StaticResource TextFaint}" />
|
||||
|
||||
<TextBlock Classes="title" Text="YOUR RECOVERY CODE" FontSize="17" Margin="0,10,0,0" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,10,0,0"
|
||||
Text="This is the only moment this code exists. It is not stored on the server — by design, there is no reset. Losing it together with your passphrase makes the vault unrecoverable." />
|
||||
|
||||
<!--
|
||||
SelectableTextBox rather than a grid of chunks. The design draws eight two-word cells, which is a
|
||||
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
|
||||
screen, and that is a worse home for it than their clipboard.
|
||||
-->
|
||||
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14,13">
|
||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Foreground="{StaticResource Accent}"
|
||||
LetterSpacing="1.5" LineHeight="26" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}" Margin="0,20,0,0" MinHeight="44">
|
||||
<TextBlock Classes="body" Foreground="{StaticResource Text}"
|
||||
Text="I have written this code down. I understand it will never be shown again." />
|
||||
</CheckBox>
|
||||
|
||||
<Button Classes="primary" Content="CONTINUE" Margin="0,16,0,0"
|
||||
Command="{Binding ConfirmRecoveryCodeCommand}"
|
||||
IsEnabled="{Binding RecoveryCodeWrittenDown}" />
|
||||
|
||||
<TextBlock Classes="detail" Margin="0,10,0,0" TextAlignment="Center" TextWrapping="Wrap"
|
||||
Text="no skip · no back · screenshots are blocked on this screen" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>Design 07 — the recovery code, which cannot be skipped or navigated past.</summary>
|
||||
internal sealed partial class RecoveryCodeScreen : UserControl
|
||||
{
|
||||
public RecoveryCodeScreen() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.Android.Views.ServerScreen"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Not in the design, which starts at an enrolled phone — but it is ShellState.NeedsServer and the only
|
||||
state that requires a network, so a head without it cannot be used on a phone that has never signed in.
|
||||
|
||||
One field and one button. Everything about the identity provider comes from the server's own
|
||||
/.well-known/dodossh-configuration, so there is no authority, client id or scope to type — which is the
|
||||
whole reason this screen is one box on a phone rather than a form.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="24,0" VerticalAlignment="Center" Spacing="0">
|
||||
|
||||
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
|
||||
<Border Width="44" Height="44" BorderBrush="{StaticResource Accent}" BorderThickness="1"
|
||||
HorizontalAlignment="Center">
|
||||
<TextBlock Text=">_" Foreground="{StaticResource Accent}" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="16" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Classes="title" Text="DodoSSH" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="label" Text="SERVER" />
|
||||
|
||||
<TextBox Classes="field" Margin="0,6,0,0" Text="{Binding ServerUrl}"
|
||||
PlaceholderText="https://dodossh.example.com"
|
||||
IsEnabled="{Binding !IsBusy}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<Button Classes="primary" Content="SIGN IN" Margin="0,10,0,0"
|
||||
Command="{Binding SignInCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<!--
|
||||
Said before the browser opens rather than after. RFC 8252 §8.12 requires the system browser and not
|
||||
an embedded one, which means the user is about to be handed to another application — and an SSH
|
||||
client that bounces you out to Chrome with no warning looks like it has crashed.
|
||||
-->
|
||||
<TextBlock Classes="detail" Margin="0,14,0,0" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Text="opens your browser to sign in · DodoSSH never sees your password" />
|
||||
|
||||
<TextBlock Classes="body" Margin="0,16,0,0" TextAlignment="Center" Text="{Binding StatusMessage}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace DodoSSH.Client.Android.Views;
|
||||
|
||||
/// <summary>ShellState.NeedsServer — name a server and sign in. The only state that needs a network.</summary>
|
||||
internal sealed partial class ServerScreen : UserControl
|
||||
{
|
||||
public ServerScreen() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
x:Class="DodoSSH.Client.Android.Views.TerminalScreen"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Design 03 — TERMINAL.
|
||||
|
||||
One WebView for every session, exactly as on the desktop and for the same reason: each session is a pane
|
||||
inside the one page, so switching tabs is a single frame rather than a second browser. On a phone the
|
||||
argument is stronger — a WebView per tab would be several hundred megabytes on a device that will
|
||||
reclaim them.
|
||||
|
||||
The tab strip is horizontal-scrolling rather than wrapping. Wrapping would reflow the terminal every
|
||||
time a tab opened, which is the one thing a terminal must not do while output is arriving.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ tabs ============ -->
|
||||
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal" Height="44">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Grid ColumnDefinitions="Auto,Auto">
|
||||
<Button Grid.Column="0" Classes="row" MinHeight="44" Padding="12,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot live" Width="5" Height="5" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
The close cross is inside the tab, which the plan calls out: a strip-level close would
|
||||
act on whichever tab happened to be selected, and on a phone that is a mis-tap away from
|
||||
killing the wrong shell.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="row" MinHeight="44" Width="34" Padding="0"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the connection line ============ -->
|
||||
<Border Grid.Row="1" Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1" Padding="14,5"
|
||||
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="detail" FontSize="9.5" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SelectedTab.Address}" />
|
||||
</Border>
|
||||
|
||||
<!-- ============ the renderer ============ -->
|
||||
<Panel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
The empty state, and it says what the surface is for rather than that it is empty. A phone opens
|
||||
here on a cold start, so this is the first thing a new user reads.
|
||||
-->
|
||||
<StackPanel IsVisible="{Binding !HasTabs}" VerticalAlignment="Center" Margin="24" Spacing="10">
|
||||
<TextBlock Classes="title" FontSize="13" Text="NO SHELL OPEN" />
|
||||
<TextBlock Classes="body"
|
||||
Text="Choose a host and press CONNECT. A shell opened here keeps running while the app is in the background, and keeps running after the keychain is locked — a notification says so for as long as one is alive." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Collapsed rather than merely covered when there are no tabs. On Windows this control is a native
|
||||
child window that composites above everything Avalonia draws, which is why the desktop head hides
|
||||
it explicitly; whether Android's WebView does the same is recorded as unverified in
|
||||
docs/android-port.md. Hiding it either way costs nothing and is correct under both answers.
|
||||
-->
|
||||
<NativeWebView x:Name="Renderer" IsVisible="{Binding HasTabs, FallbackValue=False}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ accessory keys ============ -->
|
||||
<!--
|
||||
The row every Android SSH client ships, and the reason is not preference: a software keyboard has no
|
||||
Ctrl, no Esc, no Tab and no arrows, and without them a phone cannot interrupt a process, complete a
|
||||
path, or reach the previous command. Ctrl and Alt latch — pressed once they apply to the next key and
|
||||
then release, because holding a modifier while typing is not possible one-thumbed.
|
||||
-->
|
||||
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" IsVisible="{Binding HasTabs}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Height="44" Spacing="4"
|
||||
Margin="6,5" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Design 03 — the terminal, and the accessory key row under it.</summary>
|
||||
internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// The keys a software keyboard does not have.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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>^C</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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the next ordinary key should be sent as a control character.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<NativeWebView>("Renderer")!.Source = shell.TerminalPageUrl;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildAccessoryRow()
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("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"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Sends a literal string, for the keys that carry text rather than a control code.</summary>
|
||||
/// <remarks>Kept because the snippet feature will type into a terminal exactly this way.</remarks>
|
||||
internal void Send(string text) => SendAsync(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,46 @@ public sealed record CallbackResult(IReadOnlyDictionary<string, string> Paramete
|
||||
/// but the name resolves through the hosts file and DNS, and the address does not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LoopbackCallbackListener : IDisposable
|
||||
/// <summary>
|
||||
/// Where the authorization response comes back to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What does not vary is everything above it.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IAuthorizationCallback : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Where the provider should send the response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
Uri RedirectUri { get; }
|
||||
|
||||
/// <summary>Waits for the response.</summary>
|
||||
/// <param name="completionHtml">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Abandons the wait.</param>
|
||||
Task<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class LoopbackCallbackListener : IAuthorizationCallback
|
||||
{
|
||||
/// <summary>Longest request line and header block accepted, as a denial-of-service bound.</summary>
|
||||
private const int MaximumRequestBytes = 16 * 1024;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
public sealed class OidcClientOptions
|
||||
public sealed record OidcClientOptions
|
||||
{
|
||||
/// <summary>The provider's issuer URL.</summary>
|
||||
public required Uri Authority { get; init; }
|
||||
@@ -28,6 +28,23 @@ public sealed class OidcClientOptions
|
||||
/// <summary>Path the loopback listener answers the redirect on.</summary>
|
||||
public string RedirectPath { get; init; } = "/callback";
|
||||
|
||||
/// <summary>
|
||||
/// How the authorization response is received. Loopback unless a head substitutes one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <see cref="IAuthorizationCallback"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Func<string, IAuthorizationCallback> CallbackFactory { get; init; } =
|
||||
path => new LoopbackCallbackListener(path);
|
||||
|
||||
/// <summary>Whether provider metadata must be served over HTTPS. Only false for local development.</summary>
|
||||
public bool RequireHttpsMetadata { get; init; } = true;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/android-arm64": {}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/android-arm64": {}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/android-arm64": {}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +270,8 @@ public sealed class ServerConnection : IVaultServer
|
||||
Uri serverUrl,
|
||||
IBrowserLauncher browser,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
Func<OidcClientOptions, OidcClientOptions>? 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);
|
||||
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,6 +411,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>Where the embedded browser should navigate.</summary>
|
||||
internal Uri TerminalPageUrl => workspace.PageUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Types into a terminal on behalf of something that is not the keyboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>TerminalWorkspace.CloseSessionAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory<byte> data) =>
|
||||
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
|
||||
/// </summary>
|
||||
@@ -620,6 +638,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
/// <summary>Switches to the terminal surface.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other half of <see cref="ShowScreenCommand"/>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowTerminal() => Surface = ShellSurface.Terminal;
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,6 +335,45 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends keystrokes to one terminal as though they had been typed into it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ordinary typing does not come through here — it goes from the renderer's own keyboard handling
|
||||
/// straight down the socket, which is one hop shorter and is what keeps a fast <c>cat</c> responsive.
|
||||
/// This is for input that has no key on the keyboard to produce it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Which on a phone is most of the useful input.</b> A software keyboard has no Ctrl, Esc, Tab or
|
||||
/// arrows, so the Android head draws an accessory row and sends the bytes itself; the desktop head
|
||||
/// will want the same seam the day it grows a snippet that types into a terminal. Bytes rather than a
|
||||
/// key name deliberately: what a terminal wants is a control sequence, and translating one at this
|
||||
/// layer would mean owning a keymap that the renderer already owns.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
|
||||
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">The terminal to type into.</param>
|
||||
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public ValueTask SendInputAsync(
|
||||
uint sessionId,
|
||||
ReadOnlyMemory<byte> data,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TerminalSessionPump? pump;
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
|
||||
}
|
||||
|
||||
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes one terminal.
|
||||
/// </summary>
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/android-arm64": {}
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -562,6 +562,37 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
|
||||
@@ -268,6 +268,56 @@ public sealed class TerminalWorkspaceTests
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <param name="connections">
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user