diff --git a/README.md b/README.md index 1109369..c1a7897 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,10 @@ dotnet run --project src/DodoSSH.Client.App In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships `alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be -skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click +skipped and cannot be recovered from the server. **COPY CODE** puts it on the clipboard so it can go +straight into a password manager: it is the one secret this application deliberately offers to a clipboard, +because there is nowhere better for it to go and the alternative people actually reach for is a photograph +of the screen. You can then add a host and open a shell on it — double-click its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same command with the password box above it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`). diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 9672a93..4720de2 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -1188,7 +1188,23 @@ directly instead of `Classes="... secret"`. `PasswordChar` is what the screen dr what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one. -### 10.2 The keyboard does not cover the box being typed into +### 10.2 COPY CODE actually copies, on both heads + +Enrol a fresh profile. On the recovery-code screen, press **COPY CODE**, then paste somewhere — another +app on the phone, a text editor on the desktop. + +**Pass:** the whole code arrives, and the line under the button says to put it in a password manager now. + +**Failure means:** if the phone says *this machine has no clipboard*, the delegate is not being passed to +`MainWindowViewModel` again — the phone shipped for a while with none, so every copy on that head said +exactly that on a device that plainly has one. If nothing is said at all, the button silently no-opped, +which on this screen is worse than refusing: somebody who believes the code is on their clipboard will not +write it down, and it is shown once. + +Worth pasting somewhere you can see all of it. The code is the only thing standing between a forgotten +passphrase and an unrecoverable vault, and a truncated copy fails silently months later. + +### 10.3 The keyboard does not cover the box being typed into The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible. Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect @@ -1201,7 +1217,7 @@ password on HOSTS, and the connect password on FILES. `WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of resizing it — which, for a window whose whole content is one native view, pans by nothing useful. -### 10.3 Nothing is stranded when the keyboard closes +### 10.4 Nothing is stranded when the keyboard closes Dismiss the keyboard with back or the down-chevron from each of those screens. @@ -1211,7 +1227,7 @@ bottom and no scroll position left part way down. **Failure means:** the inset is being applied but not cleared — the closed state is not being read from the event, or the margin is only ever added to. -### 10.4 Rotating with the keyboard up +### 10.5 Rotating with the keyboard up Focus a passphrase box, then turn the phone sideways. diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index 10be61d..6f49eed 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -1,5 +1,7 @@ using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; using Avalonia.Markup.Xaml; using DodoSSH.Client.Android.Platform; @@ -79,7 +81,14 @@ public sealed partial class DodoSshApp : Avalonia.Application // RESUME has something to read — and whatever a process death interrupted. See DocumentStaging. DocumentStaging.Sweep(); - var viewModel = ComposeShell(paths, caches, workspace, knownHosts, connections); + // Built before the view model, because the clipboard is reached through it — see ClipboardWriter, + // which takes the surface and looks the TopLevel up on each call rather than now. + var shell = new PhoneShell(); + + var viewModel = ComposeShell( + paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell)); + + shell.DataContext = viewModel; // Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a // shell outlives a vault lock — true on a platform that stops backgrounded processes. @@ -104,9 +113,40 @@ public sealed partial class DodoSshApp : Avalonia.Application keepAlive.Refresh(); - return new PhoneShell { DataContext = viewModel }; + return shell; } + /// + /// Writing to this phone's clipboard. + /// + /// + /// + /// ◆ This head had none, and every control that wanted one said so out loud. COPY PUBLIC KEY on + /// the keychain answered "This machine has no clipboard" on a device that plainly has one, because the + /// delegate was simply never wired here — the desktop passed one and the phone passed null. Android has + /// a clipboard and Avalonia surfaces it through the same TopLevel the desktop reaches, so there + /// was nothing platform-shaped about the gap. + /// + /// + /// Looked up per call rather than captured, exactly as the desktop's is: at composition there is no + /// TopLevel yet, because this is the method building the view it will be attached to. A machine + /// that somehow has none falls through silently and the view model decides what to say, which is what + /// keeps "no clipboard here" and "copied" different answers. + /// + /// + /// A delegate rather than an IClipboard, so nothing in the view models needs a visual and every + /// test that drives them stays window-free. + /// + /// + private static Func ClipboardWriter(Visual surface) => + async text => + { + if (TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }) + { + await clipboard.SetTextAsync(text).ConfigureAwait(false); + } + }; + /// /// Split from only for length. The division is a real one though: above this is /// the platform graph, and below it is the shell every head shares. @@ -116,7 +156,8 @@ public sealed partial class DodoSshApp : Avalonia.Application ClientCacheFactory caches, TerminalWorkspace workspace, VaultKnownHostStore knownHosts, - SshNetConnectionFactory connections) + SshNetConnectionFactory connections, + Func copyToClipboard) { // 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. @@ -165,6 +206,8 @@ public sealed partial class DodoSshApp : Avalonia.Application // would name the same machine. See PhoneEnvironment.DeviceName. deviceName: PhoneEnvironment.DeviceName, + copyToClipboard: copyToClipboard, + updates: updates); // Started rather than awaited: framework initialisation must not block on a schema migration. The diff --git a/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml index 95af9c4..7abd847 100644 --- a/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/RecoveryCodeScreen.axaml @@ -44,6 +44,26 @@ LetterSpacing="1.5" LineHeight="26" TextWrapping="Wrap" /> + +