Public Access
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
# An Android client: what it would take
|
||||
|
||||
**Status: audited, scoped, not started.** No Android code exists. The four decisions that shape the work
|
||||
have been taken and are recorded in [Decisions](#decisions-taken); everything else here is the audit they
|
||||
were taken against.
|
||||
|
||||
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||
is **one remote pane** with Android's document picker for moving files in and out.
|
||||
|
||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||
|
||||
- Every project's target framework, read from `Directory.Build.props` and the `.csproj` files.
|
||||
- The target frameworks each pinned package ships, read out of the local NuGet cache — so these are the
|
||||
assemblies this solution would actually resolve, not what a package's README claims.
|
||||
- Every site in `src/` that names a Windows API, a Windows path convention, or a desktop lifetime.
|
||||
|
||||
**What was not**: nothing was compiled for Android, nothing was run on a device or emulator, and no Android
|
||||
SDK is installed here. Every statement below about *runtime* behaviour is reasoning from the code and the
|
||||
platform's documented rules, and is marked where it matters.
|
||||
|
||||
---
|
||||
|
||||
## The headline
|
||||
|
||||
The port is smaller than it looks in one dimension and much larger in another.
|
||||
|
||||
**The core is already portable.** Every project targets plain `net10.0`, with no `net10.0-windows` anywhere
|
||||
and no conditional compilation. All the Windows-specific code now sits in one project — `DodoSSH.Client.App`,
|
||||
the desktop head. The cryptography, the sync engine, the local cache, the SSH layer, the item kinds and every
|
||||
view model are platform-neutral today, and that is not luck: it is what the project structure has been
|
||||
enforcing all along.
|
||||
|
||||
(One file was out of place when this was written — `WindowsDeviceKeyStore`, in `DodoSSH.Client.Session`. It
|
||||
has since been moved, which is the only code change this audit produced; see §4.)
|
||||
|
||||
**The product is not.** DodoSSH is a two-pane file browser, a tab strip, a nav rail and a terminal, laid out
|
||||
at a minimum of 880×560, driven by hover, right-click, middle-click and drag-and-drop. A phone is about
|
||||
360dp wide and has none of those inputs. Roughly none of the *interface* ports; the question an Android
|
||||
client really asks is not "will this compile" but "what is the Android product".
|
||||
|
||||
Two platform rules make that sharper, and they are the things most likely to be underestimated:
|
||||
|
||||
- **Scoped storage.** Android has no arbitrary local filesystem for an app to browse. The left-hand pane of
|
||||
the Files screen — this machine's drives and directories — has no Android equivalent at all.
|
||||
- **Background execution.** Android stops a backgrounded process. A terminal client whose whole premise is
|
||||
that a shell survives locking the vault, and a transfer queue that runs for minutes, both assume a process
|
||||
that keeps running. On Android that needs a foreground service with a persistent notification, or the
|
||||
feature changes shape.
|
||||
|
||||
Neither is a porting problem; both were product decisions, and both have now been taken — a foreground
|
||||
service, and a single remote pane. See [Decisions](#decisions-taken).
|
||||
|
||||
---
|
||||
|
||||
## What ports as it stands
|
||||
|
||||
Verified from the resolved package assemblies.
|
||||
|
||||
| Dependency | Ships for Android | Note |
|
||||
| --- | --- | --- |
|
||||
| `libsodium` 1.0.22 | ✅ `android-arm64`, `android-arm`, `android-x64`, `android-x86` | The native half of all the cryptography |
|
||||
| `NSec.Cryptography` 26.4.0 | ⚠️ no Android-specific build | Ships `net9.0` plus iOS/tvOS/MacCatalyst. The plain `net9.0` assembly should load, since the platform-specific part is libsodium — but this is the one dependency worth proving with a build before anything else |
|
||||
| `SSH.NET` 2025.1.0 | ✅ `netstandard2.0`, `net8.0` | Sockets only; needs the `INTERNET` permission |
|
||||
| `SQLitePCLRaw.bundle_e_sqlite3` 2.1.12 | ✅ `net6.0-android31.0` | The local cache |
|
||||
| `AWSSDK.S3` 4.0 | ✅ `netstandard2.0`, `net8.0` | |
|
||||
| `CommunityToolkit.Mvvm` 8.4.2 | ✅ `netstandard2.0` | Every view model |
|
||||
| `Avalonia.Controls.WebView` 12.0.1 | ✅ `net10.0-android36.0` | The surprise — see below |
|
||||
| `Avalonia.Desktop` 12.1.1 | ❌ `net10.0` only | Replaced by `Avalonia.Android`, not ported |
|
||||
|
||||
So: **`DodoSSH.Client.Domain`, `.Storage`, `.Sync`, `.Api`, `.Auth`, `.Ssh`, `.Terminal`, `.Transfer`,
|
||||
`.ObjectStore`, `.Import` and `.Crypto` should all target `net10.0-android` unchanged.** That is the great
|
||||
majority of the code, including all of the cryptography and all of the sync protocol.
|
||||
|
||||
`DodoSSH.Client.Session` needed one file moved and no longer does — see §4. `DodoSSH.Client.App` is the
|
||||
desktop head and does not port; an Android head would be a sibling project sharing its view models.
|
||||
|
||||
---
|
||||
|
||||
## What does not port, in order of how much it costs
|
||||
|
||||
### 1. The interface — the largest item by far, and it is not a port
|
||||
|
||||
880×560 minimum, a 54-pixel nav rail, a 268-pixel host sidebar, a two-pane file browser with six columns per
|
||||
pane, a tab strip, and a layout suite (`DodoSSH.Client.App.Layout.Tests`, 64 tests) whose entire premise is
|
||||
that everything fits at that minimum.
|
||||
|
||||
None of it survives a phone. What an Android client would be is a different product with the same core:
|
||||
probably a host list, a terminal, and a single-pane file browser, with the keychain, snippets, logs and pins
|
||||
as screens rather than as a rail. The desktop screens are not a starting point for that — they are a
|
||||
different answer to a different question.
|
||||
|
||||
**This is where the real effort is**, and it is design effort before it is engineering effort. Everything
|
||||
else on this list is a week or two of work; this is the product.
|
||||
|
||||
**Decided: phone first.** See [Decisions](#phone-first) — which means a redesign rather than a reflow, and
|
||||
rules out the cheaper tablet route deliberately.
|
||||
|
||||
### 2. The Files screen's left pane has no Android equivalent
|
||||
|
||||
Scoped storage means an app sees its own directory and whatever the user hands it through the system
|
||||
picker. There is no browsable `C:\` or `/home`. So the two-pane layout — the thing the whole screen is built
|
||||
around — does not exist on Android.
|
||||
|
||||
The honest shapes are: a one-pane remote browser with **download to** and **upload from** going through the
|
||||
system document picker, or a remote-to-remote transfer tool with no local side at all. Both are fine; both
|
||||
mean the transfer queue's local half (`LocalDirectory`, the drive list, the breadcrumb trail) is desktop-only
|
||||
code.
|
||||
|
||||
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
|
||||
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
|
||||
|
||||
**Decided: one remote pane and the document picker**, and out of the first scope. See
|
||||
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
|
||||
|
||||
### 3. Background execution
|
||||
|
||||
`TerminalWorkspace` keeps shells running across a vault lock, deliberately and documented. `FileTransferQueue`
|
||||
runs one transfer at a time for as long as it takes. `VaultViewModel` runs an auto-sync pass every minute.
|
||||
All three assume a process Android will stop.
|
||||
|
||||
The options are a foreground service with a notification for as long as a session or a transfer is live
|
||||
(which is what every serious SSH client on Android does), or accepting that backgrounding the app drops the
|
||||
connection. The first is not hard; it is a decision about what the app is allowed to do to the user's
|
||||
battery and notification shade, and it wants taking deliberately.
|
||||
|
||||
**Decided: a foreground service** while a shell or a transfer is live. See
|
||||
[Decisions](#sessions-survive-backgrounding-via-a-foreground-service).
|
||||
|
||||
### 4. The device key store — the cheap one, because the seam exists
|
||||
|
||||
`WindowsDeviceKeyStore` is DPAPI over a TPM-held key. `IDeviceKeyStore` is already the interface everything
|
||||
else uses, with three methods and an `IsAvailableAsync` that exists precisely so a platform without a
|
||||
keystore can say no.
|
||||
|
||||
Android's equivalent is the Android Keystore, with StrongBox where the hardware has it, and it is a closer
|
||||
match than the Windows one: it can require biometric or device-credential authentication to release the key,
|
||||
which is what the unlock screen would want anyway. **This is a straightforward implementation of an existing
|
||||
interface**, and it is the piece of Android integration most clearly worth doing well.
|
||||
|
||||
**Done, ahead of any decision:** `WindowsDeviceKeyStore` used to sit in `DodoSSH.Client.Session`, which was
|
||||
the one thing keeping that project from being portable. It now lives in `DodoSSH.Client.App/Platform/`, and
|
||||
its factory is `DesktopDeviceKeyStores` — named for the head it belongs to. `IDeviceKeyStore` and
|
||||
`UnavailableDeviceKeyStore` stayed behind, because they are the seam rather than an implementation.
|
||||
|
||||
The move cost nothing but a namespace, which is the useful part of the finding: the session layer takes a
|
||||
store and has never known which one, so an Android implementation drops into the same hole. Verified by the
|
||||
build and the suite, with the two Windows-only tests moving to `DodoSSH.Client.App.Tests` alongside it.
|
||||
|
||||
### 5. Sign-in
|
||||
|
||||
`BrowserLauncher` uses `Process.Start(UseShellExecute: true)`; `LoopbackCallbackListener` implements RFC 8252
|
||||
§7.3 loopback redirect with a raw `TcpListener`.
|
||||
|
||||
Neither is right on Android. `Process.Start` does not exist; the platform way is an `Intent`, and the
|
||||
platform way to receive the redirect is a Custom Tab plus an app link or a custom scheme. Loopback redirect
|
||||
*might* work, and should not be used: on a shared device any other app can bind a loopback port, which is
|
||||
exactly the attack RFC 8252 §8.3 warns about and the reason app links exist.
|
||||
|
||||
So this is a second implementation of an existing shape rather than a port. The PKCE flow, the discovery, the
|
||||
key binding and the token handling above it are all unchanged.
|
||||
|
||||
### 6. `ClientPaths`
|
||||
|
||||
Branches Windows / macOS / XDG, with an explicit comment about wanting a *local, non-roaming* directory
|
||||
because two machines sharing one cache file corrupts the outbox. On Android the right answer is the app's own
|
||||
`filesDir`, which is per-app, non-roaming and not user-visible — it satisfies the requirement more cleanly
|
||||
than any desktop platform does. One more branch, or better, the value injected by the head. `ClientPaths`
|
||||
already takes an explicit directory for exactly this reason.
|
||||
|
||||
### 7. `Environment.MachineName`
|
||||
|
||||
Used as the device name on connection and activity log entries, and when registering a device. On Android it
|
||||
returns something like `localhost`, which would make every log entry from a phone indistinguishable. Needs a
|
||||
real device name from the head.
|
||||
|
||||
### 8. The Windows-only bits of the desktop head
|
||||
|
||||
Listed for completeness; none of these is ported, they are simply absent from an Android head.
|
||||
|
||||
- `NativeKeyboardFocus` — `user32.dll SetFocus`, and the whole documented asymmetry about focus not
|
||||
returning from the WebView. Android's focus model is different and this problem may simply not exist there.
|
||||
- `Program.Main` — `[STAThread]` (required by WebView2 specifically) and `StartWithClassicDesktopLifetime`.
|
||||
An Android head is an `AvaloniaMainActivity` instead.
|
||||
- Middle-click tab close, right-click, hover states, drag-and-drop between panes.
|
||||
|
||||
### 9. The terminal — better news than expected, with one unknown
|
||||
|
||||
`Avalonia.Controls.WebView` ships a `net10.0-android36.0` target, which was the single fact most likely to
|
||||
sink this. And the transport underneath is more portable than it looks: `TerminalDataPlane` serves the page
|
||||
and the binary protocol over a **loopback WebSocket**, and an Android WebView can load `http://127.0.0.1:port`
|
||||
just as WebView2 does. The xterm.js bundles are embedded resources and are platform-neutral.
|
||||
|
||||
**Unverified, and it is the thing to check first if this goes ahead:** whether Avalonia's Android WebView
|
||||
composites the same way — a native view above everything Avalonia draws. If it does, the occlusion rule in
|
||||
`docs/platform-flags.md` applies unchanged and `IsTerminalShowing` keeps doing its job. If it does not, the
|
||||
rule is unnecessary rather than wrong, which is the harmless direction.
|
||||
|
||||
The parts that are definitely different are the on-screen keyboard, and the fact that a terminal on a phone
|
||||
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
||||
accessory key row for this. That is UI work, not porting.
|
||||
|
||||
---
|
||||
|
||||
## Decisions taken
|
||||
|
||||
Four, each recorded with the reasoning that was actually weighed rather than only the outcome.
|
||||
|
||||
### Scope: the keychain and a terminal
|
||||
|
||||
Not a companion, and not the file browser. Everything that is already a list or a form — hosts, groups,
|
||||
keys, passwords, snippets, pins, logs — plus opening a shell.
|
||||
|
||||
The terminal is the expensive half and it is the half that makes it an SSH client rather than a viewer. It
|
||||
depends on the WebView spike coming back clean; if it does not, the companion subset is what is left and is
|
||||
still worth shipping, so the work is ordered to find that out early.
|
||||
|
||||
### File transfer, when it comes: one pane and the document picker
|
||||
|
||||
Out of the first scope, decided now so the seams are not built the wrong way. A single remote pane, with
|
||||
Android's document picker for moving files in and out.
|
||||
|
||||
This is the shape scoped storage allows, and the interesting part is how little it costs: `FileTransferQueue`
|
||||
and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket behind that interface beside
|
||||
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
|
||||
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
|
||||
|
||||
### Sessions survive backgrounding, via a foreground service
|
||||
|
||||
A persistent notification for as long as a shell or a transfer is live.
|
||||
|
||||
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
||||
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
||||
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
||||
notification is to look at.
|
||||
|
||||
### Phone first
|
||||
|
||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||
and much of the current layout could have been reused — and the phone is the device people have with them,
|
||||
which for an SSH client is most of the point.
|
||||
|
||||
So the interface is a redesign rather than a reflow, and that is the largest single item of work here. The
|
||||
nav rail, the 268-pixel sidebar and the two-pane browser do not survive. What does survive is everything
|
||||
behind them: every view model, every command, every piece of state.
|
||||
|
||||
---
|
||||
|
||||
## What the interface actually has to carry
|
||||
|
||||
Written for designing the phone client. It is an inventory of what exists today and what each part is
|
||||
*for* — not a layout, and not a claim that any of it should look the same.
|
||||
|
||||
**Read it as a checklist of things that need somewhere to go.** The desktop has room to put a warning, a
|
||||
confirmation and a form on screen at once; a phone does not, and the states most easily lost are the ones
|
||||
that appear rarely and matter most. Those are marked **◆**.
|
||||
|
||||
### Getting in: six states before the app is usable
|
||||
|
||||
`ShellState`, and every one of them is a screen.
|
||||
|
||||
1. **Starting** — reading the local cache to find out whether this machine is enrolled.
|
||||
2. **Needs a server** — nothing cached: name a server, sign in through the browser. The only state that
|
||||
requires a network.
|
||||
3. **Needs enrollment** — signed in, but the account has no vault key yet. Choose a passphrase.
|
||||
4. **◆ Showing the recovery code** — *the user must not be able to click past this.* It is the only moment
|
||||
the code exists; losing it along with the passphrase means the vault is unrecoverable, and there is no
|
||||
server-side reset by design. On desktop it is a whole screen with a confirmation. It needs to stay one.
|
||||
5. **Locked** — the unlock screen. Passphrase box, optional device unlock (biometric on Android), a status
|
||||
line, and the line saying this works with no network. **◆** Also carries two disclosures: how many shells
|
||||
are still connected behind the lock screen, and the paragraph explaining that *locked* describes the
|
||||
keychain and not this machine's access to the hosts. Plus **reset this machine** for a forgotten
|
||||
passphrase.
|
||||
6. **Unlocked** — everything below.
|
||||
|
||||
### Chrome that is present on every screen
|
||||
|
||||
- **Titlebar** — vault name, account name, a search affordance (Ctrl+K on desktop), and a sync dot with a
|
||||
label: synced, pending count, offline, unreachable.
|
||||
- **Status bar** — the selected terminal's live dot and address, the vault's last status sentence, the sync
|
||||
label again, and the search hint.
|
||||
- **Nav rail** — eight destinations: `HOSTS FILES KEYS PINS SNIPS LOGS TEAM PREFS`. Five characters is a
|
||||
desktop constraint, not a product one; the phone can use words.
|
||||
- **Terminal strip** — always visible, above every screen. Tabs with a close cross *inside* each tab, a `+`,
|
||||
and a sentence when there are none. This is what makes a terminal a surface the window switches to rather
|
||||
than a screen you navigate away from, and it is the single most desktop-shaped idea in the product.
|
||||
- **Quick connect** — a search palette over hosts that connects on Enter.
|
||||
|
||||
### The nine destinations
|
||||
|
||||
**1. Hosts** — the list of machines, and what is known about the selected one.
|
||||
|
||||
- *Sidebar:* filter box; group headings with a chevron and a count, **shown only when groups exist**; host
|
||||
rows carrying a connected dot, name, sync badge, address and one word for how it authenticates.
|
||||
- *Editor* (doubles as "add"): name, hostname, port, username, notes, one authentication picker covering
|
||||
typed password / key / stored credential, a group picker, a relay checkbox with the sentence explaining
|
||||
that relay puts the address on the server in plain text, and **◆ forget host key** — the only way back from
|
||||
a legitimately rebuilt server.
|
||||
- *Actions:* new / edit / delete, **replaced in place** by the delete confirmation rather than stacked under
|
||||
it.
|
||||
- *Right column:* the connect banner — a password box only for a host that asks for one, a sentence in its
|
||||
place when it does not, and CONNECT.
|
||||
- **◆ Unknown host key prompt** — fingerprint shown in full, TRUST AND CONNECT / CANCEL. Appears on first
|
||||
contact with any host.
|
||||
- **◆ Changed host key refusal** — deliberately has *no* continue button. Presenting this as dismissible is
|
||||
the one design mistake that matters here.
|
||||
- **◆ Conflict log** — what a merge overrode and what it discarded, scrollable, with DISMISS ALL. The merge
|
||||
is only allowed to pick a winner because this exists.
|
||||
- *Groups panel:* the groups as chips with host counts, a name box that both adds and renames, delete with
|
||||
its own confirmation counting the affected hosts.
|
||||
|
||||
**2. Files** — two panes and a queue.
|
||||
|
||||
- *Bar:* a HOST / BUCKET toggle, the matching picker, a password box for hosts that need one, CONNECT
|
||||
(or OPEN for a bucket), DISCONNECT, a connected chip, a status line.
|
||||
- *Panes:* breadcrumb trail, drive roots on the local side, and a listing with name, size, modified and —
|
||||
remote only — POSIX permissions. Each pane has an empty state and a drop highlight in two flavours,
|
||||
accepting and refusing.
|
||||
- *Queue:* direction arrow, name, the remote path, progress with bytes and rate, state, and RESUME / RETRY /
|
||||
stop per row.
|
||||
- **◆ The host key prompts appear here too.** File transfer is a second, separate connection that makes its
|
||||
own trust decision.
|
||||
- On Android this becomes one remote pane plus the document picker — see the decision above — but the queue,
|
||||
its states and the prompts are unchanged.
|
||||
|
||||
**3. Keychain** — everything that is not a host.
|
||||
|
||||
- *Categories:* ALL / SSH KEYS / PASSWORDS / BUCKETS, each with a count.
|
||||
- *Table:* name, type, one line of detail, sync badge. The detail is what is *known about* an item and never
|
||||
the secret.
|
||||
- *Four editors,* which are four different shapes: an SSH key (label, private key armour shown unmasked so a
|
||||
truncated paste is visible, passphrase, public key, notes); **generate a key** (algorithm choice, comment,
|
||||
and it fills the editor rather than saving); a password (label, username, password masked, notes); a bucket
|
||||
(label, bucket, access key id, secret access key masked, region, endpoint, **◆ a path-style checkbox with
|
||||
the sentence explaining why**, notes).
|
||||
- **◆ Delete confirmations that count** — "three hosts authenticate with this key and will refuse to connect".
|
||||
The number is the difference between a sentence somebody reads and one they click past.
|
||||
|
||||
**4. Pins** — the host keys this keychain has approved.
|
||||
|
||||
- Filter that matches **fingerprints as well as names**, because the workflow is "the operator published
|
||||
SHA256:… — do I have that one?".
|
||||
- Table: host, port, algorithm, **◆ fingerprint never truncated**, approved date.
|
||||
- Detail pane with the fingerprint in full again, a "no host uses this" chip, the note that the date is
|
||||
derived from the item id and means first approval rather than last use, and FORGET THIS HOST KEY.
|
||||
|
||||
**5. Snippets** — saved commands.
|
||||
|
||||
- Filter matching the command text as well as the name.
|
||||
- Rows: name, **◆ a "runs immediately" chip**, sync badge, and a one-line preview with newlines shown as `⏎`.
|
||||
- Editor: name, a multi-line command box, notes, and **◆ a "press Enter after inserting this" checkbox with
|
||||
the paragraph explaining that leaving it off is the whole safety property**.
|
||||
- Detail pane with the command in full and two buttons that **name the terminal they will type into** —
|
||||
`TYPE INTO prod-db` and, only for a snippet marked as running, `RUN IN prod-db`. Plus the "no terminal
|
||||
open" state, and the sentence saying whatever is in the terminal receives this.
|
||||
|
||||
**6. Logs** — two logs behind one screen.
|
||||
|
||||
- A CONNECTIONS / KEYCHAIN toggle and a refresh.
|
||||
- *Connections:* live dot, host, address, **◆ "still open" rather than a dash for a session in progress**,
|
||||
kind (terminal or files), started, device, and a chip for a refused host key.
|
||||
- *Keychain:* item, type, what happened, **the names of the fields that changed**, when.
|
||||
|
||||
**7. Preferences** — import `~/.ssh/config`, register or forget this device, sign out.
|
||||
|
||||
**8. Team** — nothing behind it, and the screen says so rather than being hidden from the rail.
|
||||
|
||||
**9. Import** — a preview before anything is written: tick per row, alias, resolved address, how it
|
||||
authenticates, a warnings chip, an "already in the keychain" badge, TICK ALL / NONE, and IMPORT N HOSTS.
|
||||
Nothing is stored until the button.
|
||||
|
||||
### States that cut across every screen
|
||||
|
||||
- **Empty states**, with copy written per screen — each says what the thing is *for*, not "no items".
|
||||
- **A read-only item**, written by a newer client: shown, refused for editing, with a message saying to
|
||||
update. Re-encoding would silently drop a colleague's field.
|
||||
- **Sync badges** per row: not synced yet, refused and waiting on a person, read-only.
|
||||
- **Unreadable items** — a count of things that would not decrypt, which is the signal that new key grants
|
||||
are needed after a rekey.
|
||||
- **Busy**, and **offline / unreachable**, which are different from each other and both different from
|
||||
"everything is synced".
|
||||
|
||||
### The five most likely to be lost on a phone
|
||||
|
||||
In the order I would worry about them, and all of them are full-width blocks today with nowhere obvious to
|
||||
go at 360dp:
|
||||
|
||||
1. The changed-host-key refusal, which must not become dismissible.
|
||||
2. The recovery code screen, which must not become skippable.
|
||||
3. The counted delete confirmations, which are the difference between a decision and a reflex.
|
||||
4. The unknown-host-key prompt, which is the one interruption that is genuinely load-bearing.
|
||||
5. The conflict log, which is what makes the merge honest rather than last-writer-wins.
|
||||
|
||||
---
|
||||
|
||||
## The order of work
|
||||
|
||||
1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
|
||||
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done** — `DodoSSH.Client.Session` is now free
|
||||
of Windows APIs entirely.
|
||||
3. **The spike, and it answers two questions at once.** One throwaway Android head that unlocks a vault from
|
||||
a passphrase, then puts a WebView on the screen pointing at the terminal data plane. The first half
|
||||
settles NSec-on-Android, libsodium's native resolution, SQLite and whether the sync stack runs; the second
|
||||
settles whether the terminal is possible at all, which the scope decision now depends on. Both are cheap
|
||||
and both are gates — nothing after this is worth starting until it comes back.
|
||||
4. **Android device key store**, with biometric or device-credential release. A straightforward
|
||||
implementation of `IDeviceKeyStore`, and the piece of platform integration most clearly worth doing well:
|
||||
the Android Keystore is a closer match to what the unlock screen wants than the Windows one is.
|
||||
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.
|
||||
6. **The foreground service**, before the terminal rather than after it. A session that dies on backgrounding
|
||||
would otherwise shape every decision made while building the screen, and be expensive to unpick.
|
||||
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate.
|
||||
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.
|
||||
|
||||
Steps 1 and 2 are done. Step 3 is a few days and retires nearly all the remaining technical risk. Steps 4–6
|
||||
are each perhaps a week and are ordinary work behind interfaces that already exist. Step 7 dominates
|
||||
everything else put together, and step 8 is small only because step 7 came first.
|
||||
|
||||
---
|
||||
|
||||
## Still open
|
||||
|
||||
Neither of these blocks the spike, and both want answering before there is anything to release.
|
||||
|
||||
- **Which Android versions.** Less forced than it first looked: the packages *compile against* API 36 and 31
|
||||
respectively, which constrains `targetSdk` rather than `minSdk`. The floor is therefore a real choice about
|
||||
which devices are worth supporting, and it should be made deliberately rather than inherited from whatever
|
||||
restores. Worth settling before the interface work, since it decides which platform APIs are available to
|
||||
design against.
|
||||
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
|
||||
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
|
||||
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
|
||||
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
|
||||
but it is a change to a documented security property of this product, and it should be reasoned about in
|
||||
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
|
||||
costs reach.
|
||||
|
||||
## Smaller things, decided by default
|
||||
|
||||
Recorded so they are choices rather than accidents. Any of them is cheap to revisit.
|
||||
|
||||
- **`ClientPaths`** gets the app's own `filesDir`, injected by the head rather than branched for inside the
|
||||
record — which already takes an explicit directory for exactly this reason. It satisfies the
|
||||
local-and-non-roaming requirement more cleanly than any desktop platform does.
|
||||
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
||||
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||
confirmed to exist before anything is written to solve it.
|
||||
Reference in New Issue
Block a user