Compare commits

...
Author SHA1 Message Date
laurensandClaude Opus 5.5 0e6bdb75db Android: split the Spelunk sign-in and the phone's own sign-in out of ComposeShell, which MA0051 found too long
ci / build and test (push) Successful in 3m25s
ci / api image (push) Successful in 8s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m11s
ci / desktop nightly (push) Successful in 3m3s
The sign-in rework added the SpelunkAccounts argument to ComposeShell and took it to 67 lines, over
the analyzer's 60, which failed the android head job of df64012. The two delegates' construction moves
into helpers of their own, with the comments that explained them; nothing they do changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:26:14 +02:00
laurensandClaude Opus 5.5 df64012865 Merge claude/spelunk-api-key-tenant-6373cf: sign each device in to Spelunk through the browser instead of keeping Spelunk API keys in the vault
ci / android head (push) Failing after 25s
ci / build and test (push) Successful in 3m24s
ci / api image (push) Successful in 22s
ci / deploy ssh.dodotech.cloud (push) Successful in 13s
ci / desktop nightly (push) Successful in 2m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:11:02 +02:00
laurens 8543c69cca Merge remote-tracking branch 'origin/main' into claude/spelunk-api-key-tenant-6373cf 2026-10-20 17:05:56 +02:00
laurensandClaude Opus 5.5 88bd924d83 Merge claude/linux-update-check: offer an RPM only to a copy rpm owns, and never read an unknown version as older
ci / build and test (push) Successful in 3m43s
ci / api image (push) Successful in 9s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Successful in 2m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:47:01 +02:00
laurensandClaude Opus 5.5 abd7e56bb5 Spelunk sign-in: end it with a refused DodoSSH resume, keep the token the check rotated, and send the token by the sign-in's own address
A DodoSSH resume the provider refuses now signs the device out of every Spelunk as well, as the check
before a Spelunk connection already did, so the README's "an account the provider no longer signs in is
signed out of DodoSSH and of every Spelunk" holds whichever finds it first. The forced refresh that check
makes is written down at once rather than at the next sync pass, so a crash in between cannot cost a
browser sign-in on the next launch. The tunnel is opened at the sign-in's own address rather than the
route's: the same string, but the token going where it came from is now said where it is sent.

Relaunches in the shell tests use the scripted Spelunk sign-in too, so none can open a browser or send a
sign-out anywhere real. Fixes a doc comment the account-refused handler had split, a README line still
pointing at the key picker, and the Android composition's comment on which redirect Spelunk allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:41:11 +02:00
laurensandClaude Opus 5.5 57d3d3c801 Spelunk sign-in: refuse a token that is not an app session's, follow no redirects, and say every way the browser fails to come back
A token Spelunk's token endpoint returns must begin with spelunkapp_, the prefix Spelunk reads a bearer
token as an app session's by; anything else is refused as an answer this app does not understand. Neither
the sign-in's client nor the tunnel's follows redirects, so the code, its verifier and the token go to the
address signed in to and nowhere else, and signing out sends the token only to an address that still
normalises to itself, within ten seconds of its own.

Opening the browser and waiting for it now always end in a SpelunkSignInException rather than whatever
the platform threw: a browser that will not open, a phone's browser closed without an answer (the
OidcException AndroidRedirectCallback.Abandon raises, which escaped the Security page and the editor
before), another sign-in taking over the phone's redirect, and the ten-minute timeout are each said. A
refused code says it in Spelunk's own words, and a rate limit says to wait.

Tests: a refused code's detail, a non-app token, a browser that will not open, an abandoned phone sign-in,
the timeout, the user cancelling, a phone redirect sent byte for byte both times, the real loopback
listener on 127.0.0.1 taking one answer and closing, and a sign-out that will not send the token to an
address it could not have come from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:41:11 +02:00
laurensandClaude Opus 5.5 e0509c6679 Updates: offer an RPM only to a copy rpm owns, and never read an unknown version as older
On a distribution without rpm, a locally built package that mirrors the spec puts the nightly tree
at /opt/DodoSSH.Nightly, and that path was the whole test for "the RPM installed this". The channel
then asked rpm for the installed version, got nothing because there is no rpm, read the silence as
"0", and offered every nightly as newer, fetching a 56 MB RPM into the profile directory that the
machine could never install, every six hours.

UpdateChannels.LinuxChannel still checks the path first, then asks rpm through the new
LinuxUpdateChannel.ForInstall. A copy rpm has no record of gets an UnavailableUpdateChannel that
says it was installed outside the dodossh-nightly (or dodossh) RPM and should be updated with the
package manager that installed it. UnavailableUpdateChannel gains an optional Reason for that,
UpdateViewModel exposes it as UnsupportedReason, and the desktop preferences page shows it in place
of the sentence about checkouts and unzipped copies. That costs one rpm launch at start, and only
where the path already matched.

CheckAsync no longer falls back to "0". If rpm stops answering after composition, the check throws
instead, so CHECK NOW reports it and the timer stays quiet, and nothing is offered or fetched. The
comment that called the fallback a deliberate choice is replaced with the reason it was wrong.

LinuxUpdateChannelTests: an unknown installed version throws and downloads nothing (replacing the
test that asserted the old behaviour), a copy rpm does not own is unavailable and says why, and a
copy rpm owns gets the channel. The newer, equal and older comparisons were already covered.
UpdateFlowTests covers the reason reaching the view model. Manual check 19.13.

App tests 744/752 with 8 skips, layout tests 202/203 with 1 skip; the full solution passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:39:17 +02:00
laurensandClaude Opus 5.5 6f6e92f315 Merge origin/main into claude/spelunk-api-key-tenant-6373cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:26:32 +02:00
laurensandClaude Opus 5.5 ef365f462e Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:26:25 +02:00
job f7354aa7e8 Merge pull request 'SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole' (#25) from claude/sftp-connection-icons-5ad752 into main
ci / build and test (push) Successful in 3m27s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Successful in 13s
ci / android head (push) Successful in 3m54s
ci / desktop nightly (push) Successful in 2m18s
Reviewed-on: #25
2026-10-19 09:15:24 +00:00
jaap-jan d71f057bde SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole
ci / build and test (pull_request) Successful in 2m40s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m43s
SSH.NET 2026.0.0 runs the SFTP handshake synchronously inside ConnectAsync, with an infinite
operation timeout and without reading the cancellation token. A host whose login script prints
anything (an echo in .bashrc) puts text where the first SFTP packet belongs; SSH.NET reads its first
four bytes as a length of about a gigabyte and waits for the rest, so the file browser said
"Connecting…" for ever. The SFTP client now uses the connect timeout as its operation timeout for
the handshake and resets it to infinite once connected, so transfers on slow links are unchanged. A
timeout after authentication becomes a TimeoutException naming the likely cause.

The SSH test server gains a second port whose sessions print a banner before sftp-server, and
SftpSessionTests proves the open fails within a 3 s connect timeout with that message. Without the
fix the test runs into its 30 s guard.

Button.paneicon is a fixed 26x26 but kept Fluent's 8,5,8,6 padding, which left a 10px-wide content
box, so every 15-16px Material icon on the SFTP panes was drawn with its sides cut off. Its padding
is now 0. Rename on the host pane used U+E9AD, an italic T (text formatting); it now uses U+E9A2,
the rename glyph the phone's files screen already uses. The host pane's "Up one directory" drew the
design's arrow_downward; both panes now use arrow_upward.

SSH tests 123/123, layout tests 203/203, app tests 744/749 with the 5 Linux TPM skips.
2026-10-16 15:09:46 +02:00
jaap-jan 36b6ded63e Merge pull request 'Connections: drop the manual box's view-model state now that both heads ask through the prompt' (#24) from claude/charming-cerf-ae57b6 into main
ci / build and test (push) Successful in 3m46s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m28s
ci / desktop nightly (push) Successful in 3m27s
Reviewed-on: #24
2026-10-16 12:32:14 +00:00
jaap-jan fa85ad1eff Connections: drop the manual box's view-model state now that both heads ask through the prompt
ci / build and test (pull_request) Successful in 2m32s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m41s
The phone's Connections screen no longer draws the user@host + password box, and the desktop never
did, so nothing bound VaultViewModel.ManualTarget, ManualPassword, ManualStatus or
ConnectManuallyCommand any more, nor MainWindowViewModel.ConnectToRecentCommand, whose address branch
wrote into that box. They are gone, and so is VaultViewModel.AskAboutHost, whose one caller was
ConnectToRecent. TryParseManualTarget, ManualEndpoint and ConnectToEndpointAsync stay: the prompt, the
desktop's Ctrl+K palette and the phone's hosts search all use them. The reasoning the removed members
carried moved onto ConnectToEndpointAsync and ChoosePromptRecentAsync.

The tests whose subject is still live now go through the prompt instead of the box: a typed address
dialled exactly as typed, port 22 by default, the refusal sentences, the host-key retry replaying the
attempt that raised it, the host-key card staying over the surface it was typed on, the session facts
for a typed password, and no forwards for a typed address. The recent-row cases now drive
ChoosePromptRecentCommand. Two cases existing prompt tests already covered were deleted. Manual check
11.8 now describes recent rows landing on the prompt.
2026-10-16 14:21:10 +02:00
jaap-jan 0cecf1afc4 Android: ask what to connect to on Connections, the same prompt the desktop's empty terminal draws
ci / build and test (push) Successful in 4m14s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 22s
ci / android head (push) Successful in 5m6s
ci / desktop nightly (push) Successful in 3m2s
The phone's Connections screen with nothing open had a box that took an address alone, which left a
saved machine several screens away. It now draws the desktop's prompt over the same view-model state:
"connect to:" takes a saved host's name or user@host[:port], and a password line follows only when
one is needed. Names, addresses and refusals are read exactly as on the desktop, and nothing typed
here is saved.

What a phone changes: the hosts matching so far are thumb-sized buttons that take a host outright,
since there is no Tab; CONNECT is always drawn, since the IME's return key may only close the
keyboard; BACK, and the system back gesture, step off the password line rather than leaving the
screen; and the password's label sits on its own line. The keyboard follows the question onto the
password line, but is not raised when the screen opens, where it would cover the recent list.

Recent rows now go onto the prompt too. Covered by a new ShellFlowTests case for a tapped match on
top of the shared prompt cases; manual check 11.14.
2026-10-16 14:07:13 +02:00
jaap-jan 089de72834 Merge pull request 'Tests: wait for a dropped session's forwards to stop, not just for the tab to go dead' (#23) from claude/elastic-mccarthy-6b4ae7 into main
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / deploy ssh.dodotech.cloud (push) Canceled after 0s
ci / build and test (push) Canceled after 4m25s
Reviewed-on: #23
2026-10-16 12:04:41 +00:00
jaap-jan f43436218a Tests: wait for a dropped session's forwards to stop, not just for the tab to go dead
ci / build and test (pull_request) Successful in 2m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m42s
WhenTheSessionEnds_ItsForwardsSayTheyWentWithIt failed once under a full run. Ended clears IsLive
and then tells each forward its session is over, in one call. The app's dispatcher never splits that,
but the harness's post runs it on the fake shell's thread, so the test's poll on !IsLive could return
between the two and find the forwards still listening.

The test now also waits until every forward says "Stopped with the session.", the last thing
SessionEnded sets. A 300 ms sleep injected between the two steps fails the old test and passes the new
one; the full App.Tests suite passed three runs in a row.
2026-10-16 13:17:01 +02:00
jaap-jan 3c8c0980c2 Terminal: ask what to connect to when Terminal is pressed with nothing open
ci / build and test (push) Successful in 4m13s
ci / android head (push) Successful in 4m24s
ci / api image (push) Successful in 13s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m6s
The terminal surface with no session used to be an empty frame, with the tab row's "+" the only
thing on it that did anything. It now holds a prompt, in the order ssh asks: "connect to:" takes a
saved host's name, matched exactly, with the hosts that match so far listed under it for Tab or a
click, or user@host[:port] for a machine that is not saved. A password line follows only when one
is needed, worded as ssh words it. Escape steps back a line; recent connections are listed below.

It is drawn by Avalonia in the WebView's rectangle, which the occlusion rule allows because the two
are exclusive: the prompt shows only while there are no tabs, the WebView only for a tab with a
session.

Saved hosts connect through a new VaultViewModel.ConnectToHostAsync, which the drawer's own connect
now goes through too, so every refusal is still made once. Each attempt now carries whether it may
act on the drawer's remember-password tick, and the prompt's may not: otherwise a tick left on in
a closed drawer would save a password typed at a prompt that never offered to keep anything.

Covered by nine ShellFlowTests cases and four ConnectPromptTests; manual check 7.10b.
2026-10-16 12:17:34 +02:00
jaap-jan 5b2e3f3651 Android: type user@host into the hosts search to open a terminal on a machine without saving it
ci / android head (push) Successful in 4m22s
ci / build and test (push) Successful in 4m39s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m20s
The phone's only unsaved way in was the box on the terminal screen, which is drawn only while nothing
is open, so a second machine could not be reached that way while a first one's session was up. The
search well at the top of HOSTS is always there and the design already calls it "Search or
connect...", so it now reads an address the way the desktop palette does: with an @ in it, a NOT
SAVED card above the list offers a password box and CONNECT.

It connects through VaultViewModel.ConnectToEndpointAsync, the same path as the manual box and the
desktop palette, so nothing is written to the keychain. The search box and the password are cleared
once it dials, the password goes whenever the address changes, and an address the manual parser
refuses gets its sentence under the box.

Covered by six new ShellFlowTests cases; manual check 8.20 covers the card on a device.
2026-10-14 17:27:57 +02:00
jaap-jan 988248fa57 Quick connect: type user@host into Ctrl+K to open a terminal on a machine without saving it
ci / build and test (push) Successful in 5m48s
ci / api image (push) Successful in 47s
ci / deploy ssh.dodotech.cloud (push) Failing after 2s
ci / desktop nightly (push) Successful in 3m26s
ci / android head (push) Successful in 10m2s
The desktop had no way to reach a machine that was not in the keychain; the phone has had a manual
box on its terminal screen all along. The palette's query now reads as an address when it has an @
in it: a "not saved" row offers to dial it, Enter asks for the password in place, and a second Enter
connects. Escape on the password step goes back to the address.

It goes through the phone's own path, now split out as VaultViewModel.ConnectToEndpointAsync, so the
tab, the host-key question and the connection-log entry are the same on both heads and nothing is
written to the keychain. Password only, as on the phone: a key is the reason to save the host.

The typed row takes the highlight unless a saved host dials exactly that address, so typing a new
address cannot land on a saved machine whose address merely starts the same way.

Covered by eleven new QuickConnectTests; manual check 7.10a covers the real dial.
2026-10-14 17:02:26 +02:00
jaap-jan 902761d37b CI: deploy ssh.dodotech.cloud after the image is pushed, and fail unless it comes back ready
ci / build and test (push) Successful in 3m38s
ci / desktop nightly (push) Successful in 1m42s
ci / api image (push) Successful in 20s
ci / deploy ssh.dodotech.cloud (push) Failing after 0s
ci / android head (push) Successful in 6m3s
The image job pushed :main and stopped there, so nothing told the Spelunk
stack a new image existed and a commit carrying a migration never reached
the server's schema.

A deploy job now calls the stack's deploy hook with SPELUNK_DEPLOY_TOKEN and
then waits up to ten minutes for /healthz/ready. The new container applies
pending migrations as it starts, and readiness stays unhealthy while any is
pending, so a migration that did not apply fails the run instead of passing
quietly.

The Dockerfile no longer says nothing migrates from inside the container,
which had stopped being true and pointed at the setting that causes this.
2026-10-07 11:15:20 +02:00
jaap-jan 509937c4b8 Sync: keep the remembered sign-in when the provider is merely down, and say why SYNC NOW found the machine offline
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m10s
ci / build and test (push) Canceled after 4m12s
2026-10-07 11:11:09 +02:00
jaap-jan 5b32c76c95 Android: the redesign — a Vaults tab with hosts, keychain and snippets, card lists, a keychain that can add, and files that act on what is chosen
ci / build and test (push) Successful in 3m49s
ci / android head (push) Successful in 3m55s
ci / api image (push) Successful in 6s
ci / desktop nightly (push) Successful in 1m22s
The bottom bar is Vaults · Connections · Settings, drawn as Material 3 pills with icons from the embedded
Material font and a count of open sessions on Connections, which replaces the strip of shells above the
bar. Vaults is the open vault: its header carries the name as a switch to the vaults screen, the sync
light and a lock icon, and a Hosts · Keychain · Snippets segmented control sits under it. The keychain
and snippets move there from Settings (IsVaultSurface lights the tab; IsMoreSurface no longer lists
them), and back from either goes to the hosts. Settings becomes an account card and grouped sections:
security, vaults and history, files, this app.

Host rows are cards with a terminal tile and the live dot on its corner. The connect sheet names the
host and its address, labels its box, focuses it as it opens, reveals the password on request, and puts
CANCEL left and CONNECT right; every two-button row on the head now confirms on the right. While a
hosts-screen sheet is up the header and bar are dimmed too and a tap on them lowers it, and the bar
stands down while the software keyboard is up.

The keychain gains a floating + and a sheet of the desktop's five adds (generate a key, paste a key, a
password, a bucket, a tag) over the editors VaultViewModel already had, drawn as a full-screen page with
labels above the boxes; a chosen item gets EDIT and COPY PUBLIC KEY beside MOVE and DELETE. Snippets get
a floating + and cards that say RUNS or INSERTS. Files trade the seven-button block for a selection bar
(save, and a ⋮ sheet with rename, move, permissions and a red delete), folder and file tiles, an upload
button, and header actions for a terminal on the same host, refresh and close; a terminal's + sheet
opens SFTP on that terminal's own host.

The theme takes the audit's visual findings: Fluent's ClipToBounds is cleared on glowing buttons so the
glow is no longer cut to a square (the desktop's Button.accent too), prose is Montserrat with mono kept
for addresses and code, disabled secondary buttons are quieter than enabled ones, Fluent's dropdown,
checkbox, switch, focus and selection brushes come from the palette, touch no longer leaves hover fills
behind, and lists, nav entries and icon buttons carry names for TalkBack.

Builds clean; checked on the emulator end to end: unlock, the three vault views, adding and deleting a
password, connecting through the new sheet, files on the terminal's host with the selection bar and
its sheet. App tests 727 of 733 pass with 5 skipped; the one failure, TheCountsAPhoneShows_
FollowEveryForwardsState, passes three runs of three alone and is untouched here. Layout tests pass.
2026-10-04 15:50:09 +02:00
jaap-jan 6806e542f9 Terminal: measure characters with Unicode 11 widths, so an emoji takes the two columns the remote counts
ci / build and test (push) Successful in 3m37s
ci / android head (push) Successful in 3m45s
ci / api image (push) Successful in 8s
ci / desktop nightly (push) Successful in 1m44s
2026-10-04 13:55:20 +02:00
jaap-jan ad1a129159 CI: publish the desktop nightly with curl, since vpk's Gitea client cannot read the upgraded forge's replies
ci / build and test (push) Successful in 3m35s
ci / android head (push) Successful in 3m43s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m25s
Moving vpk to 1.2.161 was not enough: it got further and failed the same way, one frame later.
Both versions use a generated Gitea SDK that gets null back from 'create release' on the forge's
28.0.0, although the release is created, so the step threw before uploading anything and left an
empty draft behind on every push since 2 October.

The upload is now the android job's curl pattern. The release is created as a draft prerelease,
the three packages go up, then releases.win-nightly.json, which vpk pack already wrote and which is
exactly the index vpk upload would have generated, and only then is it published. The read-back
check, the RPM and the Linux manifest after it are unchanged.
2026-10-03 19:56:53 +02:00
jaap-jan e240c0cf26 Tests: prove the device-key probe runs off the caller's thread by blocking it, not by comparing thread ids
ci / build and test (push) Successful in 3m25s
ci / android head (push) Successful in 3m55s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 23s
TheChoiceIsMadeOnce_OffTheCallersThread failed on CI with the code doing the right thing. It
compared the probe's managed thread id with the test's, but the test runs on a pool thread, and that
same thread is free to pick up the queued probe as soon as the test awaits.

The probe now waits on a release the test only gives once construction has returned. A probe run on
the constructing thread could never be released, and the test fails after ten seconds instead of
passing by luck. Twenty runs pass; an inline probe fails it.
2026-10-03 19:50:25 +02:00
jaap-jan 96dd176121 CI: move vpk and Velopack to 1.2.161, whose Gitea upload survives the forge's new release responses
ci / build and test (push) Failing after 2m54s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m41s
The desktop nightly has failed on every push to main since 2 October, at 'publish the nightly
release'. vpk 1.2.0 creates the draft release and then reads release.Assets from the forge's
answer, which the upgraded server (28.0.0) no longer fills in for a new release, so it threw a
NullReferenceException at GiteaRepository.cs:136. The step deletes the previous nightly-desktop
release before it uploads, so desktop clients have had no nightly to update from since.

1.2.161 reads the assets null-safely. The library moves with the tool, since Velopack expects the
two to match. Every vpk option the workflow and the release scripts pass is present in 1.2.161;
the only renamed ones are two MSI options nothing here uses.
2026-10-03 19:44:25 +02:00
jaap-jan 367cebae9b Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00
jaap-jan c21de4ade4 Docs: the 2026-10-02 audit reports, the full sweep and the Android design audit, with their screenshots
ci / build and test (push) Successful in 4m49s
ci / android head (push) Successful in 5m26s
ci / api image (push) Successful in 9s
ci / desktop nightly (push) Failing after 56s
REPORT.md is the full-app sweep and what became of each finding. ANDROID-DESIGN.md is a walk through
every phone screen on the emulator, from a cleared install to SFTP, with findings ranked by severity
and a screenshot for each. The home-folder listing in the sweep's transfers screenshot is blurred.
2026-10-03 11:43:03 +02:00
jaap-jan 858e36279d Sync: after a read from the beginning, remove what the server no longer has, and flag an edit that brings a forgotten item back
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 48s
The server now forgets a deletion past its retention and refuses cursors
from before it. The client then reads everything again, but everything
again says nothing about what went. So a machine away for longer than the
retention kept those items for good, and its next edit of one put it back
on the server for everyone.

A read from the beginning now marks each item it reads, keeping the mark in
the sync state because the read can span passes. When it finishes, it
removes the items of the synced types it never marked. Two kinds are kept:
items newer than the read's bound, which this machine pushed during it, and
items with a change still waiting, which are the user's work. For those,
the push finds the server without the item and sends it as a create, as
before, but now records a RecreatedAfterCollection conflict and counts it,
so the sync message says so instead of undoing someone's deletion silently.

The bound is the highest sequence the cache held when the read began, or
anything higher it has read since. Taking only what the read returned would
have left the last item of a vault in place for ever.
2026-10-02 17:36:11 +02:00
jaap-jan 55e5de0ce4 Server: a cursor carries the sync horizon it was issued under, so a read from the beginning after a collection is not refused on its own pages
A cursor behind the horizon was refused on position alone. But the read a
refused client starts from the beginning is itself behind the horizon on
every page until it passes the collected deletion, since older untouched
items come first. So its second page was refused, it started over, and it
never finished, on any vault larger than one page.

The cursor now records the horizon read before the page that produced it,
and only a cursor issued under an older horizon is refused. A cursor issued
under no horizon keeps the v1 format, so a server from before this still
reads it.
2026-10-02 17:36:03 +02:00
jaap-jan 8cb1457ca9 Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection 2026-10-02 17:18:22 +02:00
jaap-jan 656a33b4b1 Desktop: build only the host cards near the view, with a virtualising wrap panel for the board
ci / build and test (push) Successful in 3m39s
ci / android head (push) Successful in 3m59s
ci / api image (push) Successful in 5s
ci / desktop nightly (push) Failing after 37s
2026-10-02 16:20:51 +02:00
jaap-jan 41f368ae75 Merge remote-tracking branch 'origin/main' into claude/audit-followups
ci / build and test (push) Successful in 3m45s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Failing after 43s
ci / api image (push) Successful in 46s
2026-10-02 15:27:39 +02:00
jaap-jan 24c18205c3 Desktop: rebuild the hosts board once the typing in the find box pauses, not once a letter 2026-10-02 15:23:58 +02:00
jaap-jan 8759b12d47 Desktop: virtualise the transfer queue and the import lists 2026-10-02 15:14:34 +02:00
jaap-jan 7b072bcded Desktop: swap the host lists in whole instead of announcing every row to listeners that redo all their work per row 2026-10-02 15:07:08 +02:00
jaap-jan b1d4a270d4 Merge pull request 'Audit follow-ups: keyboard, wording, performance, transfer, sync, retry and relay fixes' (#22) from claude/audit-followups into main
ci / build and test (push) Failing after 3m10s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m7s
Reviewed-on: #22
2026-10-02 12:59:55 +00:00
jaap-jan a2362d1e3e Desktop: decide a titlebar double-click on the press, before the drag can swallow it
ci / build and test (pull_request) Successful in 2m48s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 13s
ci / android head (pull_request) Successful in 3m41s
2026-10-02 14:40:50 +02:00
jaap-jan 079a273d46 Desktop: let a lock pressed during an unlock win, instead of the unlock finishing behind it 2026-10-02 14:37:52 +02:00
jaap-jan 37e9d74938 Desktop: keep a vault-visibility failure from closing the app, and leave a crash log when anything else does 2026-10-02 14:28:27 +02:00
jaap-jan 170caa1391 Relay: end a user's sessions into a vault the moment they lose access to it 2026-10-02 14:26:44 +02:00
jaap-jan 965c9a7043 Server: stop a team being archived while a vault is created in it 2026-10-02 14:20:30 +02:00
jaap-jan ff8a790f6c Sync: take the server having no such item as a delete done, not a rejection nobody can clear 2026-10-02 14:18:28 +02:00
jaap-jan b2516ca7fb Server: start a retried write transaction from the database, not from the attempt that failed 2026-10-02 14:15:52 +02:00
jaap-jan 36fb0817a4 Transfers: say so when a drop or a download is refused because the screen is busy 2026-10-02 14:11:41 +02:00
jaap-jan cc3d46f78c Transfers: let cancel pressed as a transfer finishes do nothing rather than throw 2026-10-02 14:10:40 +02:00
jaap-jan 8bee476039 Transfers: stop a transfer queued as the last one finishes from waiting for ever 2026-10-02 14:09:47 +02:00
jaap-jan 441709f1e9 Desktop: stop decrypting the whole keychain on a sync pass that changed nothing 2026-10-02 14:08:08 +02:00
jaap-jan 873405e591 Sync: mirror a pulled page in one write and read the outbox once per page, not twice per change 2026-10-02 14:04:36 +02:00
jaap-jan f997e87c5f SSH: open a shell off the caller's thread, and stop waiting when the caller gives up 2026-10-02 14:02:18 +02:00
jaap-jan 365954c541 Desktop: probe for the TPM once, off the UI thread, instead of on every unlock 2026-10-02 14:00:12 +02:00
jaap-jan 3ccafdd53b Desktop: drop the VAULT columns that had nothing in them, and the snippet buttons with nothing to act on 2026-10-02 13:58:07 +02:00
jaap-jan 22b4d82ba8 Desktop: stop the status bar's session timer when the shell ends 2026-10-02 13:54:24 +02:00
jaap-jan 619f49a35a Desktop: trim a long session tab label instead of letting it push the + off the strip 2026-10-02 13:54:24 +02:00
jaap-jan a6d3924e0d Desktop: call device unlock what this platform calls it, not Windows Hello everywhere 2026-10-02 13:52:00 +02:00
jaap-jan 079914bfb4 Desktop: let the arrows choose the host the drawer acts on, and Enter connect to it 2026-10-02 13:49:36 +02:00
jaap-jan 6c09bdecb6 Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no 2026-10-02 13:45:40 +02:00
jaap-jan 372a7cbdc7 Desktop: put the keyboard in the closed vault's card on launch, and let Enter sign in and enrol 2026-10-02 13:39:12 +02:00
jaap-jan a4b81da597 Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
ci / build and test (push) Successful in 3m26s
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 17s
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
  panel kept showing the previous vault's people under the new name. The read now starts regardless;
  the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
  title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
  leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
  ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
  workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
  Keychain names trim inside their column instead of printing over TYPE, and the Security page's
  caption wraps inside its card.
2026-10-02 13:16:55 +02:00
jaap-jan 0e8fe4fc80 Give each profile its own device key, and make the Linux PIN window able to open at all
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of
  the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the
  existing name for the default profile and gives every other profile its own; sign-out deletes only
  its own key, and a profile sealed before this keeps reading the shared one.
- DevicePinWindow named two palette resources that do not exist and loaded itself with
  AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no
  PIN prompt was ever shown. A layout test now builds it against the real palette.
2026-10-02 13:16:48 +02:00
jaap-jan 2ff9dff560 Terminal: deliver large pastes whole, keep keystrokes in order, and keep one session's failure off the shared socket
- A message larger than the 64 KiB receive buffer was skipped and its tail dispatched as a frame of its
  own. Messages are now reassembled up to 16 MiB; one beyond that is dropped whole.
- Input frames are handed to the pump without waiting, and ShellStream takes no lock around its write
  buffer, so a snippet's text and its carriage return could interleave or lose bytes. Writes are
  serialised per pump in arrival order.
- A resize reaching a shell SSH.NET had already disposed threw out of the receive loop and closed the
  socket every tab shares. Each frame's failure now stays with its session.
2026-10-02 13:16:48 +02:00
jaap-jan 6846f67c20 Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's
  old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and
  parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the
  later entry carries the same payload under the right number, and a fresh device no longer downloads
  an item once per edit it ever had.
- Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out
  from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks.
- UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers
  403 account-not-active for a suspended or deprovisioned account.
- The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user
  against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The
  entity is reloaded after the update.
2026-10-02 13:16:48 +02:00
jaap-jan f332d9a56f Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start
- The outbox coalesces a new save into the row a push already read, under a new operation id, but
  Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state
  deleted the row holding the later one and the edit was gone without a word. They now take the
  PendingOperation and act only while the row still holds it; a later edit is rebased onto the version
  just accepted, so it pushes cleanly instead of conflicting with its own earlier save.
- A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that
  added a type (SpelunkKey, ObjectStore) never received the items of that type made before it.
  sync_state.pulled_types records the list a cursor was read under, and a different list starts the
  pull over once. Migration AddSyncStatePulledTypes.
2026-10-02 13:16:47 +02:00
jaap-jan 03c8e8589a Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive
  letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided
  where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the
  resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot
  be a file there is left on the host and reported.
- EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run
  it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on
  macOS, and refuses a .desktop launcher on Linux.
- A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so
  the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue
  starts those over.
- The SFTP host picker re-found its selection from the empty moment of every vault reload and landed
  on the first host once a minute; the chosen host is now remembered, and a typed password is only
  sent to the host it was typed for.
2026-10-02 13:16:30 +02:00
jaap-jan 4c7f47c026 Refuse a pinned host that answers with a key of another type, rather than asking whether to trust it
Pins are stored per algorithm, so an endpoint pinned for ssh-ed25519 that was answered with only an
ECDSA or RSA key found no pin and raised SshHostKeyUnknownException: the first-use question, where a
pin exists to produce a hard refusal. Anybody in the middle needed nothing more than a key of a
different type.

The connection factory now reads every pin for the endpoint before the handshake, offers the pinned
algorithms first so a server that still holds its pinned key negotiates it, and in the gate accepts a
key under an unpinned algorithm only when its fingerprint matches one of the endpoint's pins (one RSA
key signs as rsa-sha2-256 and rsa-sha2-512). Anything else is a mismatch. IKnownHostStore gains
FindForEndpointAsync for this.
2026-10-02 13:16:30 +02:00
jaap-jan 412c314aec Merge pull request 'Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address' (#21) from claude/spelunk-api-key-security-0dfad0 into main
ci / desktop nightly (push) Successful in 1m38s
ci / build and test (push) Successful in 4m32s
ci / android head (push) Successful in 4m54s
ci / api image (push) Successful in 24s
Reviewed-on: #21
2026-09-29 08:36:08 +00:00
jaap-jan b08f2c58e3 Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
ci / build and test (pull_request) Failing after 3m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting.

A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed.

A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for.

The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
2026-09-29 10:34:38 +02:00
jaap-jan ee423b25ce Merge claude/spelunk-sign-in: sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m36s
2026-09-29 09:16:12 +02:00
jaap-jan 38ab9b431b Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
2026-09-28 22:53:51 +02:00
jaap-jan 07bd5c9886 Merge claude/api-image-to-gitea-registry: push the API image to Gitea's own registry
ci / build and test (push) Successful in 4m2s
ci / android head (push) Successful in 5m27s
ci / api image (push) Successful in 18s
ci / desktop nightly (push) Successful in 1m50s
2026-09-28 22:07:41 +02:00
jaap-jan 31aca26e89 Push the API image to Gitea's own registry instead of registry-docker
git.dodotech.cloud/dodotech-public/dodossh-api, under the repository's owner, the way Spelunk publishes its images. REGISTRY_USERNAME and REGISTRY_PASSWORD now have to be a Gitea account and a package token for that owner.
2026-09-28 22:07:06 +02:00
jaap-jan 843713b118 Merge claude/api-image-sees-illink-pin: the API image restores with the trimmer pin
ci / build and test (push) Successful in 6m15s
ci / android head (push) Successful in 6m19s
ci / desktop nightly (push) Successful in 1m19s
ci / api image (push) Successful in 40s
2026-09-28 21:44:56 +02:00
jaap-jan 9c7be8ae9b Let the API image see the trimmer pin, so its restore stops breaking on each .NET servicing release
The API Dockerfile copied four of the five root build files before restoring and left out Directory.Build.targets, which pins Microsoft.NET.ILLink.Tasks. The image's locked-mode restore therefore asked for whatever ILLink its SDK bundled, and when sdk:10.0-noble moved to 10.0.401 (ILLink 10.0.12) it failed with NU1004 against lock files that correctly say 10.0.11 - while ci.yml, which sees the pin, stayed green.
2026-09-28 21:44:56 +02:00
jaap-jan 3d0a66f997 Merge claude/spelunk-tunnel: reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 6m46s
ci / build and test (push) Canceled after 6m48s
2026-09-28 21:38:09 +02:00
jaap-jan 689ba6c1bf Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
2026-09-28 21:38:09 +02:00
jaap-jan 6e39807cc0 Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.

Client, in Client.Session:

- SpelunkTunnel implements Spelunk's contract: POST
  {api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
  and {"port":N}, then a WebSocket to the API's own origin at the path
  Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
  upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
  is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
  agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
  could not reach the port, or is too old: "Unknown stream kind") and 4503
  (agent disconnected). Only a path is taken from the ticket response, so it
  cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
  so the connect path has one shape for both.

Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.

The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.

Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.

UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.

dodossh://spelunk/connect?api=&server=&name=&host= links:

- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
  running passes it down the doorbell as "dodossh.open-link <link>", which
  the running instance parses again, bounded, and otherwise ignores whole.
  The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
  otherwise the editor opens on a new, unsaved Spelunk host, with the key
  pre-chosen only when another host already sends it to that Spelunk. A link
  that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
  hooks and removes it on uninstall only if it still points at this
  executable; the macOS plist template declares CFBundleURLTypes and the app
  takes the URL from Avalonia's activation event; the Linux desktop entry
  gets %u and MimeType=x-scheme-handler/dodossh.
2026-09-28 21:25:04 +02:00
jaap-jan d7d002c972 Merge pull request 'Start port forwards on the phone, listed in a sheet off its terminal' (#20) from claude/dazzling-moser-641acd into main
ci / build and test (push) Successful in 3m57s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Failing after 13s
ci / android head (push) Successful in 5m32s
Reviewed-on: #20
2026-09-28 19:06:37 +00:00
jaap-jan c3dfa42108 Start port forwards on the phone, listed in a sheet off its terminal
ci / build and test (pull_request) Successful in 3m32s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 5m17s
ci / api image (pull_request) Failing after 13s
The Android head skipped a host's forwards because it had nowhere to show
what was listening. Its terminal bar now draws a ⇄ for a tab that has
forwards, carrying the listening count in green and turning the danger
colour when one failed, and it raises a sheet over the same rows the
desktop sidebar lists: notation, route, state, failure reason and a tap
to switch each off or on. MainWindowViewModel.StartForwards no longer
returns early on Android.

The sheet is shell state like the connect sheet: it collapses the
renderer while up, is lowered by leaving the terminal, and the back
gesture closes it first.

Forwards ride the shell's keep-alive rather than holding the foreground
service themselves, since a forward cannot outlive its shell. They only
change the notification, which now counts ports forwarded beside shells
connected, fed by a new ForwardsChanged event and ListeningForwardCount.

The phone still has no editor for forwards; the More screen card, README
and design-import-gaps now say they run here and are edited on the
desktop.
2026-09-28 21:06:05 +02:00
jaap-jan a6a21decbd Report a host's pinned paths among the fields an edit changed
ci / build and test (push) Successful in 4m10s
ci / android head (push) Successful in 5m32s
ci / api image (push) Failing after 18s
ci / desktop nightly (push) Successful in 2m0s
2026-09-28 20:50:23 +02:00
jaap-jan f05deb4fbc Add port forwarding: local, remote and dynamic, per terminal session
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
A host can now carry port forwards (-L, -R, -D), saved inside its
encrypted payload as RelayTicketRequest.PortForwardId already assumed,
so they sync and merge per forward like tags and pinned paths. The host
schema moves to version 8 only for hosts that carry forwards.

ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports.
Every listener binds loopback and there is no bind-address field: a
forward is something any member of a shared keychain can edit. A remote
forward's target is resolved here, IPv4 first, rather than by SSH.NET's
constructor, which takes the first DNS answer and can send ::1.

Forwards start with every terminal opened to the host and stop with its
connection; a reconnect starts them again. The session sidebar lists
them with state, failure reason and an on/off switch, the status bar
counts the listening ones, and the host editor and detail pane gain a
PORT FORWARDS section. A forward that cannot start fails alone.

The phone does not start forwards yet: it has no surface showing what
is listening, and its More screen now says so. No byte counts, since
SSH.NET keeps none.
2026-09-28 20:47:02 +02:00
jaap-jan c8848ec1e3 Send the event socket's close code instead of aborting the socket
ci / build and test (push) Successful in 4m22s
ci / android head (push) Successful in 5m16s
ci / api image (push) Failing after 52s
ci / desktop nightly (push) Successful in 4m12s
The events endpoint cancelled its pump token and only then wrote the close
frame. The token was also the one passed to socket.ReceiveAsync and
SendAsync, and cancelling a pending operation on the ManagedWebSocket that
Kestrel hands over aborts it. CloseAsync then saw an Aborted socket and
returned, so in production a client whose token expired got a TCP reset
rather than 4401 "reconnect with a fresh token", and a shutdown sent no
1001 either.

The tests missed it because TestServer's in-memory socket survives that
cancellation: the same scenario through TestServer closes with 4401.

PumpAsync now does what the relay's pump does. No WebSocket operation is
cancelled; the token stops only the outbound queue, the clock and the
access refresh. The sender is let finish its last write, the close goes
out, and the receiver ends on the client's answering close. Each wait is
bounded by the close timeout, and the socket is aborted only when a peer
stops answering.

ApiFixture gains ConnectEventsOverTcpAsync, which runs a second host on
real Kestrel over loopback, sharing the container and identity stub. The
new AnExpiringToken_ClosesTheSocketWith4401 fails with a reset before this
change and passes after it.
2026-09-28 14:53:59 +02:00
jaap-jan ab432e475e Merge pull request 'Let a host marked for the relay actually be reached through it' (#19) from claude/server-relay-94b02f into main
ci / build and test (push) Successful in 8m4s
ci / api image (push) Canceled after 0s
ci / desktop nightly (push) Canceled after 36s
ci / android head (push) Canceled after 2m21s
Reviewed-on: #19
2026-09-28 12:41:29 +00:00
jaap-jan b20f7cae04 Let a host marked for the relay actually be reached through it
ci / build and test (pull_request) Successful in 6m27s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 15m47s
ci / api image (pull_request) Failing after 28s
The relay checkbox said it was not wired up, and the plan said the server
half was built and shipped. It was not: there was ADR 0004, the contract
records, RelayOptions and a CHECK constraint, and RelayOptions said in its
own remarks that there was no endpoint and no dialler. So both halves are
built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md.

Server, in Features/Relay:

- POST /api/v1/relay/tickets makes every decision: the host, Connect on
  its vault, the relay switched on, and the stored name resolved once and
  judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8,
  multicast and the metadata endpoints are refused with no configuration
  escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the
  operator's denials, allow-list and private-network switch. What passes
  goes into a Data Protection ticket, and nothing resolves the name again.
  Every target refusal is the same 403, so the endpoint cannot be used to
  map the operator's DNS; the real reason is logged.
- GET /api/v1/relay/connect takes the ticket as a second subprotocol,
  checks its expiry against TimeProvider and spends it with a unique
  insert into relay_ticket_use before accepting the upgrade. After the
  upgrade a refusal is a close code: 4502 for a host the server could not
  reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain.
- relay_session is the audit row, written from short-lived scopes.

Client:

- RelayBridge is a loopback SOCKS5 listener that accepts once and pipes
  to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so
  the connection disposes it on every way a session ends and a failed
  connect disposes it before throwing.
- ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for
  the ticket, opens the socket, starts the bridge and turns every refusal
  into a sentence.
- VaultViewModel and TransfersViewModel reach a relayed host through it,
  never fall back to dialling it directly, and replace SSH.NET's transport
  error with the relay's own reason when it gave one.

Neither side ever cancels a WebSocket operation. Cancelling one aborts
the socket, and an aborted socket sends no close frame, which is the only
way the client learns the server could not reach the host.
AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives.

Three departures from ADR 0004, recorded in it: awaited copy loops rather
than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket=
fallback, and no device id in the ticket.

Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a
real login through the bridge, API client 30, contracts 25. Not run: the
Android build (its change is copy only) and the Alpine layout oracle.
2026-09-28 14:37:20 +02:00
jaap-jan 54bc142dce Merge pull request 'Stop an attach's own replay landing in the middle of a test's frames' (#18) from claude/fix-failed-test-3e1719 into main
ci / build and test (push) Successful in 3m23s
ci / android head (push) Successful in 3m48s
ci / api image (push) Failing after 15s
ci / desktop nightly (push) Successful in 1m29s
Reviewed-on: #18
2026-09-15 12:46:46 +00:00
345 changed files with 40197 additions and 2094 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"rollForward": false "rollForward": false
}, },
"vpk": { "vpk": {
"version": "1.2.0", "version": "1.2.161",
"commands": [ "commands": [
"vpk" "vpk"
], ],
+169 -29
View File
@@ -1221,27 +1221,55 @@ jobs:
fi fi
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
# vpk rather than curl, unlike the android job above, and the difference is what is being # curl, like the android job above, and no longer `vpk upload gitea`. vpk's Gitea client is a
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a # generated SDK that cannot read the forge's answer to "create a release" since its upgrade to
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that # 28.0.0: the release is created and the client gets null back, so both 1.2.0 and 1.2.161 threw a
# index by hand would be reimplementing the tool that is already here. # NullReferenceException before uploading anything, leaving an empty draft behind on every push.
# Nothing here writes Velopack's index by hand: `vpk pack` already wrote releases.win-nightly.json
# beside the packages, and with no earlier release downloaded into that directory it is exactly
# the index `vpk upload` would have generated from it.
# #
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses # A draft first, published only once everything is on it, which is the order vpk used too: the
# client reads the index and then fetches what it names, and a published release missing a
# package is a few seconds in which every desktop is told to download something that is not
# there. The index goes up last for the same reason.
#
# prerelease is load-bearing twice over. It keeps this out of the release channel, which refuses
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release # prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
# channel reads: a desktop nightly published as a stable release would become the newest release # channel reads: a desktop nightly published as a stable release would become the newest release
# in this repository and every phone on the release channel would start failing its update check # in this repository and every phone on the release channel would start failing its update check
# against a release carrying no android manifest. # against a release carrying no android manifest.
dotnet vpk upload gitea \ releases="$RUNNER_TEMP/nightly-releases"
--skip-updates \
--repoUrl "$FORGE/$REPO" \ created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
--token "$TOKEN" \ -d "$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Nightly desktop %s","draft":true,"prerelease":true,"body":"Built from %s by CI. Installs beside the release build, never over it."}' \
--outputDir "$RUNNER_TEMP/nightly-releases" \ "$TAG" "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
--channel win-nightly \ "$api/releases")"
--tag "$TAG" \
--releaseName "Nightly desktop $VERSION" \ draft="$(printf '%s' "$created" | release_id)"
--targetCommitish "$GITHUB_SHA" \ if [ -z "$draft" ]; then
--pre \ echo "Gitea accepted the release call and returned no id:" >&2
--publish printf '%s\n' "$created" >&2
exit 1
fi
for file in "$releases"/*-full.nupkg "$releases"/*-Portable.zip "$releases"/*-Setup.exe \
"$releases"/releases.win-nightly.json; do
if [ ! -f "$file" ]; then
echo "vpk pack did not leave $file behind:" >&2
ls -la "$releases" >&2
exit 1
fi
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$draft/assets?name=$name" >/dev/null
done
curl -fsS -X PATCH -H "$auth" -H 'Content-Type: application/json' \
-d '{"draft":false}' \
"$api/releases/$draft" >/dev/null
# Asked for back rather than assumed, and the android job's history is why: it once created a # Asked for back rather than assumed, and the android job's history is why: it once created a
# release, uploaded nothing, and reported success for every upload it never made. A nightly # release, uploaded nothing, and reported success for every upload it never made. A nightly
@@ -1260,20 +1288,16 @@ jobs:
fi fi
done done
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN. # ◆ THE RPM GOES ONTO THE RELEASE JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
# #
# One rolling release carrying both heads, because they are one nightly: the same commit, # One rolling release carrying both heads, because they are one nightly: the same commit,
# the same version, and one page a person looks at to find out what is on main. A second tag # the same version, and one page a person looks at to find out what is on main. A second tag
# would double the delete-and-recreate dance this step already does, and would let the two # would double the delete-and-recreate dance this step already does, and would let the two
# drift apart on a run where one of them failed. # drift apart on a run where one of them failed.
# #
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and # It has to happen here, after the Windows upload, and not in a step before it. This step
# recreates this release, so an RPM uploaded first would be attached to something that no # deletes and recreates the release, so an RPM uploaded first would be attached to something
# longer exists by the time the job ends — silently, because the delete succeeds. # that no longer exists by the time the job ends — silently, because the delete succeeds.
#
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
# its APK: this is one file the release simply carries. There is no index to write and no
# format that belongs to a tool.
release="$(printf '%s' "$published" | release_id)" release="$(printf '%s' "$published" | release_id)"
if [ -z "$release" ]; then if [ -z "$release" ]; then
echo "The release was published and its id could not be read back:" >&2 echo "The release was published and its id could not be read back:" >&2
@@ -1326,6 +1350,19 @@ jobs:
# inspects — it is the thing that gets deployed. # inspects — it is the thing that gets deployed.
needs: [build] needs: [build]
runs-on: [linux] runs-on: [linux]
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
# the repository - so the image is listed with the repository's packages rather than on a
# registry of its own. Lowercase, because an image reference may not contain capitals and
# the owner is DodoTech-Public.
#
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
# package read and write, allowed to publish for that owner. Not the job's built-in
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
# pushes the same way, with the same two secrets.
env:
REGISTRY: git.dodotech.cloud
IMAGE: dodotech-public/dodossh-api
steps: steps:
# Duplicated from the build job; see the comment there for why it cannot be factored # Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three. # out. Any change here has to be made in all three.
@@ -1451,9 +1488,6 @@ jobs:
# changes, and where setup-buildx-action starts earning its place. # changes, and where setup-buildx-action starts earning its place.
- name: work out the tags - name: work out the tags
id: tags id: tags
env:
REGISTRY: registry-docker.dodotech.cloud
IMAGE: dodotech/dodossh-api
run: | run: |
set -euo pipefail set -euo pipefail
repo="$REGISTRY/$IMAGE" repo="$REGISTRY/$IMAGE"
@@ -1578,7 +1612,7 @@ jobs:
echo "username: ${#REGISTRY_USERNAME} characters; password: set" echo "username: ${#REGISTRY_USERNAME} characters; password: set"
printf '%s' "$REGISTRY_PASSWORD" \ printf '%s' "$REGISTRY_PASSWORD" \
| docker login registry-docker.dodotech.cloud \ | docker login "$REGISTRY" \
--username "$REGISTRY_USERNAME" --password-stdin --username "$REGISTRY_USERNAME" --password-stdin
- name: push - name: push
@@ -1596,7 +1630,113 @@ jobs:
# does not leave it behind. # does not leave it behind.
- name: log out - name: log out
if: always() && github.event_name != 'pull_request' if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud run: docker logout "$REGISTRY"
# Rolls ssh.dodotech.cloud onto the image the job above just pushed, by calling the deploy hook of the
# Spelunk stack that runs it. Without this nothing told the server a new image existed, so a commit with
# a migration in it reached the registry and stopped there, and the schema never moved.
#
# The migration itself is applied by the new container as it starts — Database:AutoMigrate, see
# Persistence.MigrateDodoDatabaseAsync — under an advisory lock, before its port opens. So triggering
# the deploy is what applies it, and the wait below is what proves it: /healthz/ready is unhealthy for
# as long as any migration the running image knows about is pending, and an image whose migration
# failed does not start at all.
#
# main only, like the :main tag the stack follows. A failure here fails the run, unlike Spelunk's own
# trigger: a server left behind its schema is the outage this job exists to prevent, and a green build
# over one is the quiet version of it.
#
# SPELUNK_DEPLOY_TOKEN is a repository secret (Settings -> Actions -> Secrets), sent as a header so it
# stays out of every proxy and runner log a URL would be written to.
deploy:
name: deploy ssh.dodotech.cloud
needs: [image]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: [linux]
timeout-minutes: 15
# A newer push to main cancels this run like any other (see concurrency at the top). That is safe:
# once the hook has answered, the rollout and its migration happen on the server, and the newer
# run's own deploy job does the waiting instead.
env:
DEPLOY_HOOK: https://api.spelunk.dodotech.cloud/api/hooks/deploy
SERVER: https://ssh.dodotech.cloud
steps:
# See the desktop job's step of the same name.
- name: ensure curl
run: |
set -eu
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
if ! command -v curl >/dev/null 2>&1; then
echo "Installing curl"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache curl
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends curl
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y curl
else
echo "No apk, apt-get or dnf here, so curl cannot be installed from inside the job." >&2
exit 1
fi
fi
curl --version | head -1
- name: trigger the deploy
env:
SPELUNK_DEPLOY_TOKEN: ${{ secrets.SPELUNK_DEPLOY_TOKEN }}
run: |
set -eu
if [ -z "${SPELUNK_DEPLOY_TOKEN:-}" ]; then
echo "SPELUNK_DEPLOY_TOKEN is empty or unset, so the server was not told about this image." >&2
echo "Add it under Settings -> Actions -> Secrets (not Variables)." >&2
exit 1
fi
code="$(curl -sS -o "$RUNNER_TEMP/deploy-response" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $SPELUNK_DEPLOY_TOKEN" \
"$DEPLOY_HOOK" || echo 000)"
case "$code" in
200|202|204) echo "Deploy hook accepted (HTTP $code)." ;;
401|404)
echo "Deploy hook rejected the token (HTTP $code): revoked or regenerated, or the stack is gone." >&2
exit 1 ;;
409)
echo "Deploy hook refused the token (HTTP 409): it is limited to a service the stack no longer has." >&2
exit 1 ;;
*)
echo "Deploy hook answered HTTP $code:" >&2
cat "$RUNNER_TEMP/deploy-response" >&2 || true
exit 1 ;;
esac
# Ten minutes, polled every ten seconds. A rollout restarts the container, so a few failed probes
# in the middle are expected; what fails the job is never becoming ready again — a migration that
# is still pending, one that crashed the start, or a stack holding the deploy for its schedule.
- name: wait for the server to be ready
run: |
set -eu
deadline=$(( $(date +%s) + 600 ))
last=""
while [ "$(date +%s)" -lt "$deadline" ]; do
sleep 10
last="$(curl -sS -m 10 -w ' (HTTP %{http_code})' "$SERVER/healthz/ready" 2>&1 || true)"
case "$last" in
"Healthy (HTTP 200)")
echo "$SERVER is ready, with no migration pending."
curl -sS -m 10 "$SERVER/api/v1/meta" || true
echo
exit 0 ;;
esac
echo "not ready yet: $last"
done
echo "$SERVER did not become ready within ten minutes. Last answer: $last" >&2
echo "A pending migration is named in the container's log (PendingMigrations)." >&2
exit 1
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop # No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own # nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
+1 -1
View File
@@ -172,7 +172,7 @@
constant rather than a setting: an operator who could answer the update check could pin a constant rather than a setting: an operator who could answer the update check could pin a
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md. chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
--> -->
<PackageVersion Include="Velopack" Version="1.2.0" /> <PackageVersion Include="Velopack" Version="1.2.161" />
<!-- <!--
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip. Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
+123 -7
View File
@@ -44,7 +44,7 @@ Three consequences worth knowing before you read further:
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md). [the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
The desktop client's interface was built from a design covering more product than exists yet — file The desktop client's interface was built from a design covering more product than existed at the time — file
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
@@ -426,6 +426,99 @@ What is not here: transferring a directory, dragging between the panes, and rout
bastion — the last needs jump hosts the connection layer has not got. All three are in bastion — the last needs jump hosts the connection layer has not got. All three are in
[`docs/design-import-gaps.md`](docs/design-import-gaps.md). [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
### Forwarding ports
A host can carry **port forwards**, added under PORT FORWARDS in its editor, in the three kinds OpenSSH has:
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
host, and they start with every terminal opened to it, on either head. The session sidebar lists them under
FORWARDS with the state of each, and the status bar counts the ones listening; on the phone the same list is a
sheet off the terminal's bar.
Three things about them are worth expecting rather than discovering.
**Everything listens on localhost, and there is no setting for it.** A forward is a field any member of a
shared keychain can fill in, and one bound to every interface would hand the remote's network to anybody who
can reach your machine for as long as the terminal is open. A remote forward asks the remote for its loopback
too; whether the remote honours that is its own `GatewayPorts`.
**A forward lives as long as its terminal.** It runs over that tab's connection, so closing the tab, losing
the connection or reconnecting stops it — a reconnect starts the host's forwards again on the new session. A
second tab to the same host runs the same forwards, and they fail there because the ports are taken; the row
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
[The Android head](#the-android-head).
### Reaching a server through Spelunk
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
Spelunk's API address and the server's id in Spelunk. A connection then asks Spelunk for a ticket, opens a
WebSocket, and the agent connects to the SSH server on the machine's own loopback — so the server needs no
open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk are one choice, and
ticking one unticks the other.
- **Sign this device in to Spelunk once, under Settings → Security → Spelunk** (or with the button in a
Spelunk host's editor). Give Spelunk's API address and press SIGN IN: your browser opens at Spelunk, you
sign in there however you normally do — password, passkey, your company's single sign-on, second factor —
and allow *DodoSSH on "<this device>"*. No key is pasted anywhere, and DodoSSH never sees your Spelunk
password.
- **One sign-in reaches every organisation you are in** on that Spelunk, as you, with the role you have on
each server at the time: Admin opens a terminal, anything less is refused for that server alone. DodoSSH
sends no organisation; Spelunk takes it from the server.
- **It stays on this device.** Nothing about it is synced: each device signs in on its own, and the host
itself carries only the address and the server id, so the same host opens on every device signed in to its
Spelunk. The sign-in is sealed in the local cache like the remembered DodoSSH sign-in, and only ever sent to
the Spelunk it came from. The API address must be `https://`, except for a Spelunk on this machine.
- **It has no expiry, and ends with you.** Spelunk refuses it once you are removed, change your password or
second factors, or sign it out under *Your account → Signed-in apps* there; DodoSSH then forgets it and asks
you to sign in again. SIGN OUT beside it under Settings → Security ends it in both places, and so does
signing out of DodoSSH.
- **DodoSSH has to be online and signed in.** Before every connection through Spelunk it asks its identity
provider whether your DodoSSH sign-in still stands (at most once a minute). An account the provider no longer
signs in is signed out of DodoSSH and of every Spelunk on this device; a provider that cannot be reached
holds the connection until it can.
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
is for every host, and the port is what the agent connects to on the server.
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: signed out, not
allowed on that server, a server Spelunk does not know, an agent that is not connected, an agent that could
not reach the SSH port or is too old to open terminals.
**Sign in through Spelunk (no SSH key needed)**, the last box in the same card, lets Spelunk log
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
Ed25519 key pair in memory and sends the public half with the ticket request, which your sign-in authorises;
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
so a DodoSSH older than this shows it read-only.
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
link, or hands the link to the copy already running, and **connects straight away.** The link never
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
- Otherwise, when this device is signed in to that Spelunk, DodoSSH saves a new host in your personal vault —
named as the link names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through
Spelunk, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any
host you saved yourself.
- **A link from a Spelunk this device is not signed in to** opens the host editor instead, filled in the same
way with sign-in ticked and the tag on, and offers to sign in to that Spelunk. Sign in, save, and every later
link from that Spelunk connects straight away. A link can be sent by anything on this machine, so it never
starts a sign-in by itself.
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
its own. A link that arrives while the vault is locked waits for the unlock.
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
The phone reaches Spelunk hosts the same way but does not answer links yet.
### Sharing a vault ### Sharing a vault
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one, **VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
@@ -641,8 +734,9 @@ its host if it has one and back into the box if it does not.
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom **A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
sessions as pills, and a `+` on the right offering the three connections this application can make — a sessions as pills, and a `+` on the right offering the three connections this application can make — a
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
that menu first if it is open. opens them; see below. The system back gesture does what the arrow does, and lowers either sheet first if
one is open.
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
screen exactly the width it was designed against. screen exactly the width it was designed against.
@@ -710,10 +804,25 @@ What it does *not* yet do is use the extra width inside a screen: the host list
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
heading has to span — which is shared with the desktop and is its own piece of work. heading has to span — which is shared with the desktop and is its own piece of work.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than **Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
every control on that screen would have had no effect. See any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
[`docs/design-import-gaps.md`](docs/design-import-gaps.md). listing the tab's forwards with their route, their state, the reason a failed one gave, and a tap on each to
switch it off or on — the desktop sidebar's FORWARDS section, over the same rows. The v2 design's whole
FORWARDING screen is not drawn, for the reason the desktop has none: a forward belongs to a host and runs
with a session, and both already have a place. What the phone has not got is an editor for them — its host
form has no PORT FORWARDS section — so they are added on the desktop and arrive with the host; the More
screen says so in a paragraph.
Forwards ride the **keep-alive** a shell already has. A forward runs over its tab's connection, so the
foreground service that holds a shell open in the background is holding its forwards open too, and nothing
else needs to: the service is never started for a forward alone, because there is never a listening forward
without a live shell. What forwards change is the notification, which counts them beside the shells —
`1 shell connected · 2 ports forwarded` — because a phone in a pocket holding ports open is something its
owner should be able to see. Local and SOCKS forwards listen on the phone's own loopback, which every app on
the phone can reach; that is what makes one useful here — a database client on the phone reaching a server
behind the remote — and it is the same exposure the desktop's localhost has to its own processes. See
[Forwarding ports](#forwarding-ports) and [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full 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 Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
@@ -873,6 +982,13 @@ keychain plus a terminal — and the spike that gates all of it.
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which [Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
are consequences rather than choices. are consequences rather than choices.
*Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary *Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
keychain with no groups draws the flat host list it always did. Two things about them are deliberate. keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
+328
View File
@@ -0,0 +1,328 @@
# Android design audit — 2026-10-02
Run on the emulator (`dodossh_api36`, Pixel 5 profile, 1080×2340 @ 440 dpi, Android 16). It used a
standalone **Debug** APK from `main` at `858e362`, against a local API, Keycloak and a throwaway `sshd`.
The walk went from a cleared install through sign-in, enrolment, recovery code, unlock, adding a host,
connecting, host-key trust, terminal, and every Settings sub-page.
Screenshots are in [`android/`](android/), downscaled. The recovery-code screen can't be captured,
because `FLAG_SECURE` blocks it, so it was reviewed from its markup and the accessibility tree.
**How to read the evidence.** "a11y tree" means `uiautomator dump`, which reads what TalkBack and any
other accessibility service see. Times and hangs come from a Debug build on an emulator (JIT, no AOT),
so treat any performance note as "confirm on Release".
---
## What works well
- **Connection progress card** ([11](android/11-connecting.png)): five named steps, the current one lit,
with *close* and *logs* available while it's in progress.
- **Host-key sheet** ([13](android/13-host-key.png)): "UNKNOWN HOST KEY", first contact, the algorithm,
the full SHA256 fingerprint, and an instruction to compare. **TRUST AND CONNECT** and **CANCEL** are
both full width.
- **Recovery-code screen**:
- no back and no skip;
- **CONTINUE** stays disabled until the tick;
- a full-width **COPY CODE**;
- capture is blocked.
- **Terminal** ([14](android/14-terminal.png)):
- resizes above the keyboard;
- the two-row accessory bar (esc, tab, ctrl, `| - ~ /`, arrows, A−/A+) stays reachable;
- the open-sessions strip and the tab strip are clear.
- **Busy states**: enrolment disables its fields and button and explains the wait ("deliberately takes a
moment").
- **Touch targets** measure 44 dp or more almost everywhere: checkboxes, buttons, rows.
- **Back** closes sheets. The soft keyboard's ✓ submits single-field forms (unlock, connect).
- **Copy is honest and specific**, e.g. "works with no network — the keychain decrypts on this phone",
and the Vaults explanation.
---
## Critical / high
### H1 · Password fields expose their plaintext to accessibility services
- **What:** the vault passphrase field ([05](android/05-unlock-prefilled.png)) and the SSH password field
([10](android/10-connect-sheet.png)) both report `password="false"` in the a11y tree. Their `text`
attribute is the secret itself, in clear.
- **Evidence:** the a11y tree on both screens; the values matched exactly what was typed (redacted here).
- **Why it matters:** any installed app holding an accessibility service can read every passphrase and
host password typed into DodoSSH. That's a common Android malware technique. TalkBack would also read
the passphrase aloud character by character.
- **Where:** `TextBox.secret` sets `PasswordChar` and `ContentType=Password`
(`Theme/Phone.axaml:583`). That's correct for the input method, but Avalonia's Android automation peer
ignores it.
- **Fix:** give secret boxes an automation peer that reports `IsPassword` and returns no value. At
minimum, override the exposed text on `TextBox.secret`. File it upstream with Avalonia as well. Check
the desktop's UIA peer for the same leak.
### H2 · The passphrase survives enrolment and pre-fills the unlock box
- **What:** right after the recovery code, the unlock screen already holds the passphrase (16 dots;
[05](android/05-unlock-prefilled.png)) beside "Unlock with the passphrase you just chose."
- **Why:** `ConfirmRecoveryCode` clears `RecoveryCode` from memory on purpose ("keeping it in a view
model … would undo that"), but `Passphrase` is never cleared after enrolment. Combined with H1, it's
readable from the a11y tree on that screen. It also skips the one moment that proves the user
remembers the passphrase.
- **Fix:** clear `Passphrase` and its confirmation when enrolment completes, as the recovery code is
cleared.
### H3 · A hardware Enter that submits the connect sheet leaves the window "not responding"
- **What:** I typed the password and pressed Enter (an injected `KEYCODE_ENTER`, which is what a
Bluetooth or DeX keyboard sends). The connection started, but Android reported the window
unresponsive. The key-up was never acknowledged (`InputDispatcher: Waited 5009ms for
KeyEvent … ENTER … action=UP`). The ANR dialog then kept coming back
([12](android/12-anr.png)) until the process was killed. Meanwhile the main thread was idle in
`Looper.pollOnce`: it wasn't busy, the event was simply lost.
- **Not affected:** tapping **CONNECT**, and the soft keyboard's ✓ (which sends an editor action, not a
key event). Both connected cleanly.
- **Likely cause:** focus moves to the terminal's native renderer (`Platform/RendererFocus.cs`) while
the Enter key-up is still in flight.
- **Repro:** host row → type password → hardware Enter.
- **Confidence:** reproduced once, and an accessibility dump was running at the time. Confirm on a real
device with a keyboard.
### H4 · Screen-reader names are type names across the app
The a11y tree announces these:
| Control | Announced as |
|---|---|
| Bottom nav (Hosts / Connections / Settings) | `Avalonia.Controls.Panel` ×3 |
| Add-sheet rows, Settings rows, session chip | `Avalonia.Controls.StackPanel` / `Avalonia.Controls.Grid` |
| Keychain filter chips | `Avalonia.Controls.StackPanel` ×5 |
| Host list rows | `DodoSSH.Client.Shell.ViewModels.HostRowViewModel` |
| Host editor dropdowns | `AuthenticationChoice { Kind = Typed, EntityId = , … }`, `GroupChoice { … }` |
| Vault row | `VaultRowViewModel { VaultId = 01a0fe77-… }` (leaks an internal id) |
| FAB / header add | `+` (no name) |
| Decorative glyphs | `⌕ ▤ ⌗ ⚙ ⚿ ❯ ⇅ ◳ ≡ ◎ ›` exposed as text |
There are two further problems:
- **Hidden content:** the hosts list stays in the tree under the full-screen host editor, so a
screen-reader user can wander into a screen they can't see.
- **Leftover desktop controls:** the desktop scrollbar's "Line up / Page down" buttons are exposed too.
**Fix:** set `AutomationProperties.Name` on every button whose content isn't a string. Give row
templates a name, or override `ToString` on the row view models. Mark glyph `TextBlock`s
`AutomationProperties.AccessibilityView="Raw"`. Hide covered content from the tree while an editor
covers it.
### H5 · Copying the recovery code isn't marked sensitive
`CopyRecoveryCode` goes through Avalonia's plain `SetTextAsync` (`App.axaml.cs:187`). On Android 13+,
the system shows a clipboard preview of the copied text. That overlay is system UI, so this screen's
`FLAG_SECURE` doesn't cover it, and Gboard keeps the text in its clipboard history. Use a native
`ClipData` with `ClipDescription.EXTRA_IS_SENSITIVE` for any secret the app copies. (Read from code;
not observed, because the screen can't be captured.)
---
## Medium — visual system
### M1 · The accent glow is clipped to a square on every primary button and the FAB
Each gradient button sits on a slightly lighter rectangle, and its corners show past the rounded shape:
- [z-glow-button](android/z-glow-button.png): SIGN IN, CREATE THE VAULT, UNLOCK, CONNECT, SAVE, ADD
FILES;
- [z-glow-fab](android/z-glow-fab.png): the round FAB sits inside a visible square.
`AccentGlow` (`0 0 22 0`) is set on the template's `ContentPresenter`
(`Phone.axaml` `Button.primary` / `Button.fab /template/ ContentPresenter`) and gets clipped to the
button's own bounds. The desktop's `Button.accent` uses the same pattern, so check it there too. Fix:
draw the glow from an unclipped wrapper (a `Border` with margin), or drop it on the phone.
### M2 · Body copy is monospace, against the shared-typeface decision
`docs/design-import-gaps.md` ("The phone catches up") records that the phone shares the desktop's
faces. On the desktop, prose (`hint`, `heading`, the settings titles and captions) is Montserrat, and
mono is kept for identifiers (`.mono`) and labels (`.label`). On the phone, `TextBlock.body`, `.title`
and `.detail` all still set `MonoFont` (`Phone.axaml:661–680`). So every paragraph, title and caption is
JetBrains Mono, and only screen headers (Personal, Settings, New host) are Montserrat. The result is
long, wide-set paragraphs that are tiring to read ([03](android/03-enrol.png),
[16](android/16-settings.png), [19](android/19-sftp.png)).
Keep mono for labels, buttons (the recorded ALL-CAPS-mono convention), addresses and code. Move
`.body`, `.title` and `.detail` to the default sans.
### M3 · Fluent's selection fill leaks into the host list
After a connect, the tapped row stays lit in `#3d486b` ([15](android/15-selected-row.png)). That
slate-blue isn't in `Palette.axaml`, which says selection is `Active` `#1E1E2C`. The comment at
`HostsScreen.axaml:360` says the selection fill was deliberately removed ("a lit row would be a mark
with no action behind it"). But only the custom `Border.hostrow.chosen` style was changed. Fluent's
`ListBoxItem:selected /template/ ContentPresenter` background was never overridden.
### M4 · Disabled secondary buttons are heavier than enabled ones
`Button.secondary` has no `:disabled` style, so Fluent's `#3c3c43` slab shows through. SFTP's disabled
**SAVE FILE** is the most prominent button in its row ([z-sftp-actions](android/z-sftp-actions.png)).
Mirror `Button.primary:disabled`: a `Raised` fill and `TextFaint` text.
### M5 · Unstyled Fluent controls among styled ones
- **Dropdowns:** `ComboBox` keeps Fluent's ~4 px radius, bright white border and black fill beside
10 px, softly bordered fields (host editor, SFTP host picker; [08](android/08-host-editor.png)).
- **Checkboxes:** the default `CheckBox`, with its label sitting below the box's centre (relay, remember
password, press-Enter; [10](android/10-connect-sheet.png)).
- **Focused fields:** the fill switches from `Field` to pure `#000`.
- **Scrollbars:** desktop scrollbars are always visible. On Keychain, the horizontal bar is drawn over
the bottom of the filter chips ([17](android/17-keychain.png)). Android expects auto-hiding overlay
bars.
- **Stuck hover:** after a tap, Vaults' ↻ keeps a grey hover square. Touch has no hover, so
`:pointerover` styles need a touch-safe variant.
### M6 · The terminal's colour and font have drifted from the palette
`WebAssets/terminal.css` says its `#171a26` background "is `TerminalSurface` in Palette.axaml". The
palette now says `#18161F`. The terminal therefore reads navy, against the app's purple-black
([14](android/14-terminal.png)). Its font stack starts at `ui-monospace` and never names the embedded
JetBrains Mono, so on Android the WebView falls back to the system mono. That makes two different
monospace faces on one screen.
### M7 · Iconography
- **Glyphs, not icons:** the phone uses Unicode glyphs (`⚿ ❯ ⇅ ◳ ≡ ◎ ⚙ ▤ ⌗ ⌕`), even though Material
Icons is embedded and the desktop uses it. Weights and optical sizes vary (the heavy purple `❯` beside
hairline `◳`; [16](android/16-settings.png)).
- **Reused meanings:** ⚙ is both the Settings tab and the Preferences row, and ⚿ is both Keychain and
"Lock the keychain".
- **Splash:** the splash shows a flat purple circle, while the app's own icon is a rounded square with
the gradient.
---
## Medium — information architecture and flows
### M8 · "Settings" holds the product
Keychain, Snippets, SFTP, S3, Logs and Vaults all sit under the ⚙ **Settings** tab, beside
Preferences and Lock ([16](android/16-settings.png)). File transfer and snippets are working tools, not
settings. Options:
- rename the tab ("More" or "Tools") and keep Preferences as one row in it;
- or promote SFTP to the bottom nav. Its icon is already ⇅.
### M9 · Keychain can't add anything
The empty state says "Add an SSH key or a password to stop typing one" ([17](android/17-keychain.png)).
The screen has no add control: no FAB, no header +, nothing in the a11y tree. Passwords can only be
created from the host editor's "+ NEW CREDENTIAL", and no phone path adds an SSH key. If keys are
desktop-only, the empty state should say so.
### M10 · Three ways to add, two ways to edit
- **Add:** Hosts has a FAB, Snippets a header "+", Keychain nothing, and the terminal's header "+"
means "new session".
- **Edit:** the host editor is a full-screen page with ← and ✓ ([08](android/08-host-editor.png)). The
snippet editor is an inline card with **SAVE/CANCEL**, and the header "+" stays live while it's open
([18](android/18-snippet-editor.png)).
Pick one pattern for each.
### M11 · SFTP
- **Action block:** it covers more than half the screen (seven buttons, a permanent help paragraph and a
status line), so only about four file rows are visible ([19](android/19-sftp.png)).
- **No selection:** RENAME, MOVE, PERMS and DELETE are enabled with nothing selected. Tapping one only
writes "Choose something on the host to rename." into the status line at the bottom.
- **DELETE:** neutral-styled here, while delete is danger-red on hosts and the keychain.
- **Directory rows:** marked only by a tiny purple ▸, with no folder icon.
- **Password again:** it's asked for again although a shell to the same host is already open.
- **S3:** blocked while an SFTP session is open. Tapping S3 bounces to SFTP, with "An SFTP session is
open. Close it before opening a bucket." at the bottom ([20](android/20-s3-bounced.png)).
Suggestion: a contextual action bar that appears with a selection, help moved behind an ⓘ, and the
listing given the screen.
### M12 · Bottom sheets
- **Partial scrim:** the scrim covers only the content area. The header (with **LOCK**) and the bottom
nav stay bright and tappable under every sheet ([07](android/07-add-sheet.png),
[10](android/10-connect-sheet.png)).
- **Decorative handle:** it has a drag handle, but swipe-down doesn't dismiss.
- **Button order:** the primary action is on the left (CONNECT | CANCEL, SAVE | CANCEL). Android puts
the confirming action on the right.
- **No autofocus:** the connect sheet's password field isn't focused on open, so it takes an extra tap.
- **Nav above keyboard:** the bottom nav rides up above the keyboard and eats the sheet's space
([13 before trust](android/13-host-key.png)).
### M13 · Sign-in and enrolment
- **Full browser:** sign-in opens a full Chrome tab (`ChromeTabbedActivity`), not a Custom Tab, so the
user leaves the app. Chrome's own first-run prompts then land on top.
- **Unannounced second sign-in:** step 2 asks for a passphrase, and **CREATE THE VAULT** then sends the
user to the identity provider's login form a second time. That's the deliberate `prompt=login` key
binding (`OidcClient.AuthorizeKeyBindingAsync`), but no copy warns that it's coming. Say "you'll
confirm your identity once more" on the passphrase screen.
- **Blank resume:** after the browser returns, the app showed a blank `#05050a` canvas for 5–10 s with
no progress indicator. Every shell state has a screen, so this looks like surface re-creation on
resume (Debug + emulator). Confirm on Release, and keep a visible state on screen while it happens.
- **Step labels:** start at "STEP 2 OF 3"; step 1 is never labelled.
### M14 · The global status line leaks across screens
A single `StatusMessage` follows the user around:
- "Unlocked 'Personal'." in the middle of Preferences ([21](android/21-preferences.png));
- "Synchronised: 2 in, 2 out." after adding one host. `Describe` counts `Pulled`/`Pushed`, which include
the app's own log entries, while `PulledItems`/`PushedItems` already exist for this;
- "audit-box asks for a password." repeated under the sheet that asks for it.
Scope messages to the screen that raised them, or use transient snackbars.
---
## Low — input, copy, polish
- **Keyboards:**
- the server URL field gets a text keyboard with contact suggestions, not the URL keyboard
([02](android/02-server-keyboard.png));
- the port field gets the full letter keyboard ([z-port-keyboard](android/z-port-keyboard.png));
- hostname and username get autocorrect suggestions.
Set `TextInputOptions.ContentType` (`Url`, `Digits`) and turn autocorrect off for those.
- **Placeholder-as-label:** name, hostname, username and notes in the host editor, the unlock field,
and the snippet fields have no labels, so each field's meaning disappears once typed in.
- **Terminology:**
- "CREATE THE VAULT", then "Creating your keychain";
- "vault passphrase" (placeholder) next to "Enter your keychain passphrase" on the same screen;
- "ENROLLMENT" (US spelling) in a British-English app ("Synchronised", "unopenable").
- **Fingerprint wrapping:** a single "I" wraps onto its own line. Chunk it, or size it to fit
([13](android/13-host-key.png)).
- **Address format:** "10.0.2.2 · 2222" on the host-key sheet, "10.0.2.2:2222" everywhere else.
- **Dates:** "10/2/2026 11:29 PM" in Logs ([23](android/23-logs.png)), "2026-10-02 21:04" in SFTP.
- **Notification permission:** Android's prompt appears without explanation at the first connect.
Explain the keep-alive notification first.
- **Terminal width:** the default size gives about 37 columns, so `ls -la` wraps every line.
- **Empty and loading states:**
- Hosts' empty state is one grey line at the top left ([06](android/06-hosts-empty.png));
- Vaults first rendered an empty list with no loading indicator;
- the keychain shows both "0 items" and an empty-state sentence.
- **Connecting card:** **CLOSE THIS TAB** and **SHOW LOGS** are indented past the card's left edge
([11](android/11-connecting.png)).
- **Host-key context:** the sheet opened over the Quick Connect form, not over the connection progress
it belongs to.
- **Disabled fields:** on the enrolment busy state they turn a flat mid-grey
([04](android/04-enrol-busy.png)), and the keyboard stays up.
- **Fingerprint setting:** "Unlock with a fingerprint" in Preferences is drawn as a bare circle; it reads
as a radio button, not a switch.
## Not verified here
- **System font size:** whether the app honours it. No `FontScale` handling exists in the phone code,
and Avalonia scales by density, so text likely ignores the setting. I didn't change device settings
to confirm.
- **Landscape and tablet:** an `IsWide` layout exists but wasn't exercised.
- **TalkBack:** not run end to end. The H4 findings come from the tree TalkBack reads.
## Environment notes for a re-run
- **Keycloak port:** 18080 was taken by another project's container, so Keycloak ran on **18180** via a
compose override, with `Oidc__Authority=http://127.0.0.1:18180/realms/dodossh` and
`adb reverse tcp:18180`.
- **`adb root`** restarts adbd and drops `adb reverse`. Re-add both forwards afterwards, or the app
shows OFFLINE.
- **The sshd** was a throwaway `linuxserver/openssh-server` container (`dodossh-audit-sshd`, port 2222,
user `dodo`).
- **Build:** `-p:RuntimeIdentifier=android-x64` adds an `android-x64` section to every
`packages.lock.json`. Restore those files afterwards.
+147
View File
@@ -0,0 +1,147 @@
# DodoSSH full sweep — 2026-10-02
## Fix status (same day)
Every fix below has a regression test. Where marked ✔✔, the test was also checked to fail with the fix taken out.
| Item | Fix |
|---|---|
| C1 ✔✔ | `LocalDirectory.ChildPath` cleans remote names and requires the resolved parent to be the target folder. It is used by every download path and by EDIT. `LocalEditor` opens a file that would run (PATHEXT plus a known list, and macOS launchers) with the type's own `edit` verb or with Notepad / `open -t`. On Linux, opening a `.desktop` file is refused. |
| C2 ✔✔ | Pinned key types are offered first. A key under an algorithm with no pin of its own is accepted only if its fingerprint matches one of the endpoint's pins (the same RSA key under `rsa-sha2-256` vs `-512`); otherwise it is a hard mismatch. Tested against the real OpenSSH container. |
| C3 ✔✔ | Complete, Revise and Park take the `PendingOperation` and act only while the row still holds that operation id. A later edit is rebased onto the version that was just accepted, so it pushes without a conflict. |
| C4 | Pull leaves out change entries the item has moved past, so a revision is never paired with newer ciphertext. This also stops re-sending an item's payload once per historical edit. |
| C5 | Deleting a team's last vault archives the team only when the owner does it. `TeamService.ArchiveAsync` now checks ownership itself. |
| C6 | `DeviceKeyNames`: the default profile keeps `DodoSSH.DeviceKey.v1`, and every other profile gets its own name. Sign-out deletes only its own key. A profile still sealed under the shared key keeps reading it. The same applies to the macOS keychain tag. |
| H1–H3 | The data plane reassembles messages up to 16 MiB. A frame that throws stays with its own session. Input writes are serialized per pump, so a slow write is never overtaken. |
| H4 | The host and bucket the user chose are remembered across reloads. A typed password is only sent to the host it was typed for. |
| H5 | Selecting a vault reads its people even while the screen is busy. A generation counter discards answers for an older selection. |
| H6 / U9 | Locking or signing out leaves Settings, and the Members panel closes with it. Escape closes the Members panel first. |
| H7 ✔✔ | The tracked user is reloaded after the out-of-band last-seen update. |
| H8 | `sync_state.pulled_types` (with a client migration) restarts the pull once when the synced type list changes. |
| H9 | `ActiveAccountRequirement` on both policies. A suspended account gets 403 `account-not-active`. |
| H10 | `DevicePinWindow` palette keys fixed, and it now uses `InitializeComponent`. `Load` never assigned the named fields, so the window could never have opened. Covered by a new layout test. |
| H11 ✔✔ | `IRemoteFileStore.CanWriteFromAnOffset`: false for buckets, so a retried upload starts over. |
| H12 | `ImportOutcome` carries refused hosts and the reason a batch stopped early, including a busy keychain. Both import screens say so. |
| U1–U4 | The host card's sync badge moved to the address line, so names show in full. Tag chips wrap and trim. Keychain names trim inside their column, with a tooltip. The Security caption wraps. |
Second round, on branch `claude/audit-followups`:
| Item | Fix |
|---|---|
| U5 ✔✔ | The closed vault's card takes the keyboard on launch and on each new card (server address, enrolment, passphrase). Enter signs in and enrols. |
| U6 ✔✔ | The host-key, delete and sign-out questions give the keyboard to their harmless answer (CANCEL / CLOSE, never TRUST or DELETE). Escape says no to each, and closes the hosts drawer. Tab stays inside the host-key card. |
| U7 ✔✔ | Arrow-key selection on the Hosts board is written back to the vault, from the list that has the keyboard only. An arrow on the board itself enters the cards. Enter on a card connects. |
| U8 | `DeviceUnlockWording`: Windows Hello, Touch ID, a TPM-checked device PIN or a fingerprint, according to the platform. |
| U10 | Session tab labels are capped at 200 px and trimmed, with the full label in a tooltip. |
| U11 | The keychain's VAULT column is an Auto column in a shared size group, so it takes no width when no row names a vault. The Tags page's always-empty column is gone. The snippet buttons appear only with a snippet chosen. |
| U12 ✔✔ | `TerminalTabViewModel.EndedAt`: the status-bar timer stops when the shell ends. |
| P1 (part) ✔✔ | A sync pass that moved nothing no longer reloads (and decrypts) the whole keychain. It restrings the ago-texts instead. Failed vaults still reload. The Clear-plus-Add rebuild itself is unchanged. |
| P3 | `ProbedDeviceKeyStore`: the TPM probe runs once per process, on the thread pool. |
| P5 | A pulled page is mirrored in one write, and the outbox is read once per page. Changes with a local edit waiting still go one at a time, in order. |
| P6 ✔✔ | `OpenShellAsync` makes its three round trips on the thread pool, honours cancellation, and closes a shell that arrives after the caller gave up. |
| Transfers ✔✔ | Lost wake-up fixed with a `pumping` flag cleared under the same lock that finds the queue empty (a stress test failed 2 of 2 runs before the fix). Cancel as a transfer finishes no longer throws. A drop or download refused while busy says so. |
| EF retry ✔✔ | `FreshAttempts.ExecuteFreshAsync`: a retried write transaction clears the tracker and reloads what it carries in. Before this, a lost commit made a team hand-over answer 204 with nothing changed. Tested with an interceptor that fails one commit. |
| Rejected delete ✔✔ | A delete of a create that never landed is complete when the server says it has no such item. A create that did land still behaves as before: the delete gives way, and the user is told. |
| Team archive race | Archive and team-vault creation take a per-team advisory lock. Archive re-counts the team's vaults, and creation re-checks the team is live. No race test. |
| Relay revocation ✔✔ | `RelayAccessWatch` re-checks a user's open relay sessions when their access changes and ends lost ones with 4403 / `AccessRevoked`. |
| Shell | `ToggleVaultAsync` catches a failure and redraws. `CrashLog` writes uncaught exceptions to `crash.log` in the profile (the process still exits). A lock pressed during an unlock now wins (✔✔). |
| Window chrome | `CaptionPresses` decides a double-click on the press, before `BeginMoveDrag`. Only a headless test covers it; it is not verified on a real Windows window. |
| P1 (rest) ✔✔ | `ReplaceableCollection.ReplaceAll`: the host, visible-host, section and bucket lists are swapped in whole and announced once, and each transfers picker refreshes only from its own list. |
| P2 (most) | The transfer queue and import lists are virtualised (✔✔: 2,000 of 2,000 rows were built before). The find box hands its text to the vault after a 150 ms pause in typing, and the view model still filters synchronously. |
| P2 (board) ✔✔ | `VirtualizingWrapPanel` builds only the host cards near the view. Measured headless, after warm-up: first layout with 300 hosts went from about 3 s to about 0.5 s, and with 1,000 from 8.3 s to 0.2 s. Clearing a filter went from 1–8 s to about 0.1–0.2 s. Arrow keys, End, the band and selection still work. |
| P4 ✔✔ | **Server:** `TombstoneCollector` runs every 6 h. It deletes tombstones past the 90-day retention together with their change entries, and compacts each item's superseded entries down to its newest. It then raises the vault's `SyncHorizon`, and a cursor from before it is refused. A cursor now carries the horizon it was issued under. Without that, the read from the beginning that a refused client starts was refused on its own second page and looped for ever on any vault larger than a page (✔✔: found by a client test, then reproduced in an API test). **Client** ("resync and prune", as chosen): a read from the beginning marks every item it reads, keeping the mark in the sync state because the read can span passes. When it finishes, it removes unmarked items of the synced types. It keeps items with an unsent change, and items pushed during the read. Such an edit is then sent as a create, as before, but now records a `RecreatedAfterCollection` conflict and is named in the sync message. The removed items are named too. New client cache migration `AddSweepMarks`. |
Not done, and why:
- **Live-run observations** (production as the default server URL, Keycloak on host port 18080): product decisions, not bugs.
Pre-existing flakiness: on untouched HEAD, 3 of 8 runs of `ShellFlowTests` each had one test fail: `TheCountsAPhoneShows_FollowEveryForwardsState`, `ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself` and `ReconnectingAHostThatWantsAPassword_AsksForOneRatherThanDiallingWithout`. These races are in the tests and predate this work.
How it was done:
- The desktop client ran on Windows with an isolated profile against a local stack: Postgres, Keycloak and the API on :5333.
- The real MainWindow was rendered headless through every screen and settings page, 39 captures at 1081×583 and 1440×900.
- Four parallel code audits covered: the Shell view models; SSH/terminal/transfer; the server and sync; the desktop views and platform code.
- The findings marked **✔ verified** were re-read in code by me, or seen in a screenshot. The rest come from the audits; each one was traced, but I did not check them again.
The live session could not get past OIDC sign-in. The default browser is Edge, which I can't type into, so the terminal, SFTP and host-key flows were reviewed in code only.
---
## Critical — data loss, security
| # | Finding | Where |
|---|---|---|
| C1 | **Path traversal on download / open-to-edit.** Remote names go straight into `Path.Combine(LocalPath, row.Name)`. SFTP only drops `.` and `..`, so on Windows a name with backslashes (`..\..\AppData\…\Startup\x.bat`) or a rooted name escapes the folder. S3 keys `../..` do the same on every OS. EDIT then opens the file with `UseShellExecute`, so a `.bat`/`.hta` *runs*. The Android head sanitises names; the desktop doesn't. ✔ verified | `TransfersViewModel.cs:2682,2698,2780,2784,3165`; `SshNetSftpSession.cs:105`; `ObjectKeys.cs:50`; `App.axaml.cs:380` |
| C2 | **Host-key pin bypass by changing the key algorithm.** Pins are looked up per algorithm. A MITM that offers ecdsa or rsa to a host pinned for ed25519 gets the "never seen before" first-use prompt instead of the mismatch hard block. ✔ verified | `SshNetConnectionFactory.cs:261-272` |
| C3 | **The outbox loses an edit made while a push is in flight.** `CompleteAsync` deletes the row by `Sequence` only, but `QueueAsync` folds a newer edit into that same row with a new `OperationId`. When the older push is acknowledged, the newer edit is deleted silently. ✔ verified | `OutboxStore.cs:239-250` vs `:71-97`; `SyncEngine.cs:554` |
| C4 | **Pull pairs historical revision numbers with the current ciphertext.** `Describe` sends `Version: change.Revision`, but the payload comes from the item's current row. The AAD binds the version, so the payload fails to decrypt. A concurrent offline edit gets parked as "Undecryptable". ✔ verified | `SyncService.cs:565,592` |
| C5 | **A team admin can archive the team (meant to be owner-only).** If an admin deletes the last vault, `ArchiveAsync` runs with no role check and revokes every membership, the owner's included. | `VaultGrantEndpoints.cs:182,196`; `TeamService.cs:242-289` |
| C6 | **The shared TPM key name breaks other profiles.** `WindowsDeviceKeyStore` uses one CNG key name (`DodoSSH.DeviceKey.v1`) for the release and nightly profiles. Sign-out in one (`ForgetAsync`) deletes the key, and the other profile's device unlock stops working. This contradicts `ClientPaths.ForChannel`, which says the two installs are kept apart. ✔ verified | `WindowsDeviceKeyStore.cs:127,233-246` |
## High — broken behaviour
| # | Finding | Where |
|---|---|---|
| H1 | **Terminal input >64 KiB (a large paste or copy) is lost.** On a frame that doesn't fit the buffer, the loop `continue`s, and the tail is then dispatched as if it were a frame. ✔ verified | `TerminalDataPlane.cs:465-472` |
| H2 | **Keystrokes can race.** Input frames are written to `ShellStream` without awaiting and without a lock, and the base `FlushAsync` runs on the pool. A snippet RUN (text, then `\r`) over about 4 KiB can execute truncated. | `TerminalDataPlane.cs:505`; `SshNetConnectionFactory.cs:554-558` |
| H3 | **One resize on a closing shell tears down the renderer socket for every tab** (`ObjectDisposedException` escapes the receive loop). | `TerminalDataPlane.cs:516-520` |
| H4 | **The SFTP host picker resets to the first host on every sync pass**, about once a minute. Selection is lost on `Hosts.Clear()`, so CONNECT can dial host[0] with the password typed for host X. The same Clear-then-Add pattern resets the Known Hosts and Snippets selection and closes the snippet delete card. | `TransfersViewModel.cs:3905,3923-3950`; `KnownHostsViewModel.cs:264`; `SnippetsViewModel.cs:689` |
| H5 | **The Vaults Members panel can show vault A's members under vault B**, and SHARE / REVOKE then act on that mix. | `VaultsViewModel.cs:1646,1698-1765` |
| H6 | **Locking or signing out from Settings leaves a window with no title bar** (no close, minimize or drag). Unlock then returns to Settings. ✔ verified by screenshot | `MainWindowViewModel.cs:3254,3400`; see `locked-from-settings-no-titlebar.png` |
| H7 | **Enrollment returns 500 if last-seen is more than an hour old.** `ExecuteUpdate` changes `xmin` under a tracked entity, and the `SaveChanges` that follows throws a concurrency exception. | `CurrentUserContext.cs:92,120`; `EnrollmentService.cs:177` |
| H8 | **A new synced type never back-fills.** A filtered pull moves the cursor past types the client didn't request, so after an upgrade adds a type (SpelunkKey, ObjectStore), older changes of that type never arrive. | `SyncService.cs:50,69`; `SyncEngine.cs:145` |
| H9 | **Suspended users keep full access.** `UserStatus` is checked nowhere on the request path. | `CurrentUserContext.cs:126` |
| H10 | **`DevicePinWindow` references `Surface` and `MutedText`, which don't exist**, so Linux TPM-PIN register/unlock should throw on open. ✔ verified | `DevicePinWindow.axaml:7,35` |
| H11 | **A failed S3 upload commits a truncated part, and every retry then fails.** | `S3UploadStream.cs:126-146` |
| H12 | **Import screens report success when the vault skipped or failed the import** (busy, or a mid-batch exception). | `ImportViewModel.cs:374`; `HetznerImportViewModel.cs:561` |
## UI issues seen in the running app
| # | Finding | Where |
|---|---|---|
| U1 | **Host card labels are cut to 3-4 characters even at 1440px.** "bastion" shows as "bast…", and k8s-worker-a / -b both show as "k8s…", as do web-prod-01 / -02. The label is the primary identifier, and the "not synced" badge takes most of its width. ✔ `hosts-truncated-labels.png` | `HostsScreen.axaml` card template |
| U2 | **Tag chips overflow the host card edge.** The WrapPanel sits inside a horizontal StackPanel, so it never wraps. ✔ | `HostsScreen.axaml:737-774` |
| U3 | **Keychain table: long names overprint the TYPE / USED BY columns.** `TextTrimming` has no effect inside a horizontal StackPanel. The same pattern appears in Logs, SessionSidebar, Snippets, NavRail and Transfers. ✔ `keychain-overlap.png` | `KeychainScreen.axaml:294` et al. |
| U4 | **Security page: the "End-to-end encrypted" caption runs past the card edge.** ✔ `security-text-clipped.png` | `SettingsSecurityPage.axaml:49-60` |
| U5 | **Enter does nothing in the server URL box** (verified live). The same applies to the enrollment passphrase boxes. The passphrase box is not focused on launch. | `MainWindow.axaml:385` |
| U6 | **Overlay cards (host key, delete, sign-out, host editor) have no Enter/Escape and don't take focus**, and Tab walks the page behind them. | `HostKeyCard.axaml` etc. |
| U7 | **Hosts board: arrow-key selection isn't written back to the view model**, so the drawer's Connect / Delete act on the previously clicked host. There is no Enter-to-connect. | `HostsScreen.axaml:573` |
| U8 | **Windows-only wording on macOS/Linux:** "USE WINDOWS HELLO", "Waiting for Windows…". | `UnlockCard.axaml:51`; `MainWindowViewModel.GestureWait` |
| U9 | **Escape over the Members panel leaves Settings and leaves the panel open** for the next visit. | `MainWindow.axaml.cs:242` |
| U10 | **Session tab labels are unbounded.** One long label pushes "+" off the strip. | `SessionTabRow.axaml:89` |
| U11 | **A VAULT column is drawn and empty** with a single vault (Keychain, Tags). The snippets list footer shows Edit / Move / Delete with nothing selected. | screenshots |
| U12 | **The status-bar session timer keeps counting after a shell has ended.** | `MainWindowViewModel.cs:4979` |
## Performance
| # | Finding | Where |
|---|---|---|
| P1 | **Every sync pass, about once a minute, decrypts the entire keychain even when nothing changed**, then rebuilds each list with Clear + Add. That is O(n²) notification fan-out: with 300 hosts, roughly 45k collection notifications per pass. | `VaultViewModel.cs:6375,4709`; `MainWindowViewModel.cs:4549` |
| P2 | **The Hosts board is not virtualised and is rebuilt on every filter keystroke.** The transfer queue (one row per file in a folder download) and the import lists are not virtualised either. | `HostsScreen.axaml:448-576`; `TransfersScreen.axaml:1193` |
| P3 | **The TPM probe (create and delete an RSA-2048 key) runs synchronously on the UI thread** at startup and after every unlock. | `WindowsDeviceKeyStore.cs:152`; `MainWindowViewModel.cs:2594,2970` |
| P4 | **Pull re-sends the full current payload once per historical change.** There is no change-log compaction and no tombstone GC, even though ADR 0003 promises 90-day GC. | `SyncService.cs:45-56,565` |
| P5 | **The client applies each pulled change in 2 or more separate SQLite transactions.** | `SyncEngine.cs:157-169,303` |
| P6 | **`OpenShellAsync` does blocking network round trips wrapped in `Task.FromResult`** and ignores cancellation. | `SshNetConnectionFactory.cs:401-418` |
## Lower / plausible
- **Transfers:**
- A wake-up can be lost, leaving a transfer queued forever (`FileTransferQueue.cs:452-505`).
- Cancel at the moment a transfer finishes can throw `ObjectDisposedException`.
- Drops and downloads are ignored silently while busy (`TransfersViewModel.cs:2272`).
- **Sync and server:**
- The EF retry strategy re-runs a push with stale tracked state (`Persistence.cs:38`).
- Deleting a create that never landed leaves a permanent "Rejected" conflict.
- A team can be archived while a vault is being created in it.
- Relay sessions survive removal from the team, for up to 12 hours.
- **Shell:**
- Lock can race an unlock that is still loading.
- `ToggleVaultAsync` has no try/catch, and there is no global `UnhandledException` handler.
- **Window chrome:** double-click to maximise may be swallowed by `BeginMoveDrag`.
## Observations from the live run
- **The first-launch default is the production server, and sign-in completes without a credential prompt.** `DefaultServerUrl = "https://ssh.dodotech.cloud"` is pre-filled. With a live browser SSO session, one press of SIGN IN on a brand-new profile completed sign-in with no password typed, and wrote the account's wrapped key and vault list into the new profile. For a self-hosted product, an empty field with a placeholder is the safer default.
- **The local dev compose hard-codes host port 18080 for Keycloak**, which collides with other projects on this machine.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+9 -1
View File
@@ -247,6 +247,13 @@ ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly # window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
# name deliberately and not the package's binary name — the window does not know which package # name deliberately and not the package's binary name — the window does not know which package
# started it. # started it.
#
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
# it, or passes it to the instance already running. update-desktop-database in the post-install
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
# installed answers, and with both installed the desktop's own default decides.
mkdir -p %{buildroot}%{_datadir}/applications mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry] [Desktop Entry]
@@ -255,13 +262,14 @@ Version=1.0
Name=%{dodo_title} Name=%{dodo_title}
GenericName=SSH Client GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin} Exec=%{dodo_bin} %%u
Icon=%{dodo_bin} Icon=%{dodo_bin}
Terminal=false Terminal=false
StartupNotify=true StartupNotify=true
StartupWMClass=DodoSSH StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System; Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell; Keywords=SSH;SFTP;Terminal;Remote;Shell;
MimeType=x-scheme-handler/dodossh;
DESKTOP DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different # One file per size into the hicolor theme, named for the package so the two channels show different
+19
View File
@@ -119,6 +119,25 @@
<key>LSUIElement</key> <key>LSUIElement</key>
<false/> <false/>
<!--
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
for a Velopack install is on first launch from /Applications.
-->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.dodotech.dodossh.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>dodossh</string>
</array>
</dict>
</array>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© DodoTech. MIT licensed.</string> <string>© DodoTech. MIT licensed.</string>
</dict> </dict>
+9 -1
View File
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.** visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be - Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
able to read tombstones, which is the one place that legitimately bypasses the soft-delete able to read tombstones, which is the one place that legitimately bypasses the soft-delete
query filter — guarded by an explicit permission check. query filter — guarded by an explicit permission check. **The collection has since been built**
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
refused. The client then reads from the beginning, and when that read finishes it removes the
items it held that the read never mentioned. An item with an unsent local edit is kept: the
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
beginning would be refused on its own early pages, which sit behind the horizon whenever an
older item survives.
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.** - **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
Without it this ADR's central bug is invisible until production. Without it this ADR's central bug is invisible until production.
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic - Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
+19
View File
@@ -99,3 +99,22 @@ real ones.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open - **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network. proxy into the operator's network.
- **Allow-listing by hostname.** Defeated by DNS rebinding. - **Allow-listing by hostname.** Defeated by DNS rebinding.
## Implementation notes
Built in `DodoSSH.Api/Features/Relay` and, on the client, `RelayBridge` and `ServerRelay`; see
`docs/reaching-a-host-you-cannot-dial.md`, "What step 3 built". Three places differ from the text above:
- **Backpressure is an awaited copy loop, not `System.IO.Pipelines`.** Each direction reads one buffer and
writes it before reading again. The property is the same — a slow peer stops the read, and TCP throttles
the origin — and the bound is tighter: two 32 KiB buffers per session.
- **There is no `?ticket=` fallback.** Both clients can set a subprotocol, and the fallback's cost — a bearer
credential in the URL — was the reason it needed scrubbing at all. It can be added if a constrained
client ever needs it; the scrubbing requirement stands.
- **The ticket carries no device id.** The server does not see a device on an ordinary request, so there was
nothing to put there. `PortForwardId` on the request is refused: a forward's port is ciphertext, and
honouring one would mean taking the port from the client.
The loopback bridge serves the relay only. SSH.NET's `ForwardedPortDynamic` already is the jump-host path's
listener, so "one mechanism, two features" is true one level down: both features reach their target through
a loopback SOCKS5 proxy named on the connection request.
+12 -1
View File
@@ -631,7 +631,8 @@ go at 360dp:
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together, for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
because it is shared. What v2 asked for and did not get is in because it is shared. What v2 asked for and did not get is in
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is [`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
named as absent rather than drawn, because nothing in the SSH layer forwards anything. named rather than drawn as a screen: it has since been built, and this head runs a host's forwards with
every terminal and lists them in a sheet off the terminal's bar. It cannot edit them yet.
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called **The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
@@ -639,6 +640,16 @@ go at 360dp:
the membership test, the tab that lights and the back gesture's first case all read that one property. the membership test, the tab that lights and the back gesture's first case all read that one property.
The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange The desktop keeps its Keychain rail entry, so this is the second thing the two heads arrange
deliberately differently, after the hub itself. deliberately differently, after the hub itself.
**And the 2026-10-04 redesign moved it back out.** The bar is VAULTS · CONNECTIONS · SETTINGS. The first
entry is the open vault, with a HOSTS · KEYCHAIN · SNIPPETS switch under the vault's header, so the
keychain and snippets sit beside the hosts they are used with rather than behind the hub (audit M8);
`IsVaultSurface` lights it, and `IsMoreSurface` lost `Keychain` and `Snippets`. SETTINGS keeps what is
about this phone and this account: an account card, security, vaults and the log, files, preferences.
The strip of open shells above the bar went too — Connections carries the count in a badge instead. The
keychain gained an add sheet and an editor page over the editors the shared view model already had, the
files screen moved its seven buttons into a selection bar and an actions sheet, and a terminal's `+`
sheet now opens SFTP on that terminal's own host.
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the 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 same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
reflows a terminal while output is arriving. reflows a terminal while output is arriving.
+9 -1
View File
@@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup, `1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag, `8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore. `13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
`18` SpelunkSignIn. `17` is retired and never reused.
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and > **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
> `HostCredential` as syncable since the contract was frozen, but this table had no value for > `HostCredential` as syncable since the contract was frozen, but this table had no value for
@@ -318,6 +319,13 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and > `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
> `AadResourceTypeTests` pins each pairing by name for exactly that reason. > `AadResourceTypeTests` pins each pairing by name for exactly that reason.
> **Added 2026-09-29, removed 2026-10-07:** `17` SpelunkKey, a Spelunk API key and the address it
> may be sent to (`14` on the wire). Removed in beta when DodoSSH started signing in to Spelunk instead;
> both numbers stay retired.
> **Added 2026-10-07:** `18` SpelunkSignIn, a device's sign-in to one Spelunk, sealed in the local cache
> (§4.2's LocalCache AAD) and bound to its row. It is never synced and has no `SyncEntityType`.
`0` means not applicable and is legal only where the table in §4.2 implies no resource. `0` means not applicable and is legal only where the table in §4.2 implies no resource.
### 4.4 What this prevents ### 4.4 What this prevents
+17 -14
View File
@@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> >
> | v2 element | What ships instead | > | v2 element | What ships instead |
> | --- | --- | > | --- | --- |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. | > | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Built, as a sheet off the terminal rather than a screen, and running on the phone as on the desktop.** A host's forwards start with every terminal opened to it on either head — `MainWindowViewModel.StartForwards` no longer skips Android. The terminal's bar draws a `⇄` for a tab that has forwards, with the listening count in green and the glyph in the danger colour when one failed, and it raises a sheet (`IsForwardsSheetOpen`) listing `SelectedTab.Forwards` with notation, route, state word, failure reason and a tap-to-toggle per row — the desktop sidebar's FORWARDS section over the same rows. Not a screen, for the desktop's reason: a forward belongs to a host and runs with a session. **Two gaps remain.** The phone's host form has no PORT FORWARDS section, so forwards are edited on the desktop and arrive with the host; the MORE screen's paragraph, now tagged IN THE TERMINAL, says so. And byte counts are built on neither head: SSH.NET keeps none. The keep-alive notification counts listening forwards beside the shells they run over, and is never started for a forward alone, since a forward cannot outlive its shell. |
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. | > | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwarded anything at the time (forwards now have the `⇄` in the bar above; see the row before this one), so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. | > | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. | > | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. | > | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
@@ -78,7 +78,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> >
> | v2 element | What ships instead | > | v2 element | What ships instead |
> | --- | --- | > | --- | --- |
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | Nothing, as on the phone and for the same reason. Three surfaces for a feature the SSH layer does not have. | > | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | **Built, as three smaller things.** Forwards are rules on the host, edited in its drawer under PORT FORWARDS and shown read-only on its detail pane; they run per terminal, listed with a switch each under FORWARDS in the session sidebar; and the session status bar counts the listening ones as `⇄ N FORWARDS`. No screen of their own, because a forward is not a thing you visit — it belongs to a host and runs with a session, and both of those already have a place. |
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. | > | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. |
> | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. | > | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. |
> | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. | > | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. |
@@ -171,7 +171,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. | > | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. |
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. | > | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. | > | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. | > | **Port Forwarding** in the sidebar | No rail entry; see the FORWARDING row above for where forwards are instead. |
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. | > | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
> >
> ## The desktop's v5 — hosts, restyled, and two reversals > ## The desktop's v5 — hosts, restyled, and two reversals
@@ -254,7 +254,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v5 element | What ships instead | > | v5 element | What ships instead |
> | --- | --- | > | --- | --- |
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. | > | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. | > | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". | > | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. | > | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. | > | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
@@ -295,7 +295,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v5b element | What ships instead | > | v5b element | What ships instead |
> | --- | --- | > | --- | --- |
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. | > | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. | > | A **Port forward** button on the host header | **Add forward**, at the foot of the session sidebar's FORWARDS list, which opens the host's editor the way Pin folder does. The header it would have sat on has since moved into that sidebar. |
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". | > | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. | > | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. | > | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
@@ -392,9 +392,10 @@ sharing that would rest on them — or a decision recorded here not to build the
Three facts explain nearly every row below. Three facts explain nearly every row below.
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing **An SSH connection here opens a shell, and forwards beside it.** `ISshConnection` offers `OpenShellAsync`
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes and `StartForwardAsync` (`src/DodoSSH.Client.Ssh/SshConnection.cs`). There is still no ProxyJump, which is
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design. what removes every `via bastion-eu` in the design. Port forwarding arrived later than most of what this
document accounts for, and the rows about it now say where it went.
It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it: It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it:
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
@@ -461,8 +462,10 @@ configuration, a server kind that refuses every plaintext field, a codec, a merg
same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a
table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched. table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched.
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` is now `HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` and
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each `PortForward` are now reserved *against a decision* rather than pending one. Port forwards shipped inside the
host's payload, for the reason `RelayTicketRequest.PortForwardId` already assumed and the one below gives for
tags. Which hosts wear a tag is a set of ids inside each
host's payload, not a join: the one thing a join buys over that is two machines tagging the same host host's payload, not a join: the one thing a join buys over that is two machines tagging the same host
without either losing, and the client's per-key three-way merge already gives it. A second item type would without either losing, and the client's per-key three-way merge already gives it. A second item type would
be a second table and a second pass through every rule in this file, for nothing. be a second table and a second pass through every rule in this file, for nothing.
@@ -507,9 +510,9 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. | | Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. | | `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | | `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | | `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". |
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. | | Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. | | Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. | | Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. | | Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. | | IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
@@ -533,7 +536,7 @@ caption buttons and window title drawn on top of the application's own — two s
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. | | Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. | | `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. | | `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. | | `⇄ FORWARDS · 2` | client-ssh | — | **Shipped**, as `⇄ 2 FORWARDS` on the session status bar, counting only forwards actually listening. |
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. | | `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. | | Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. | | Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
+136 -15
View File
@@ -322,6 +322,30 @@ closing the only open window ends the process, so the real window is shown while
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
panel, which is what posting it at `DispatcherPriority.Background` prevents. panel, which is what posting it at `DispatcherPriority.Background` prevents.
### 1.13 An emoji takes two columns, and the line after it stays where the shell thinks it is
Open a shell on a Linux host and run:
```
printf '😀|\n12|\n日|\n'
```
**Pass:** the three bars line up in one column. The emoji, the two digits and the CJK character each take two
columns, which is what the remote counted.
Then type `echo 😀😀 hello` at the prompt without pressing Enter, press **←** five times, and type `X`.
**Pass:** the `X` appears between `he` and `llo`, which is where bash put it, and the line does not redraw over
itself.
**What the tests already own.** `RendererUnicodeTests` proves the page loads `@xterm/addon-unicode11` and
selects its table before the pane opens. What is left here is xterm itself drawing to that table.
**Failure means:** the emoji's bar sits one column left of the others, or the `X` lands a column away from the
cursor. The pane is on xterm's default Unicode 6 widths, where an emoji is one column; check that
`terminal.html` still loads `vendor/addon-unicode11.js` and that `AvaloniaTerminalAssetProvider` serves it.
A script the provider does not list is a 404 the page never reports.
--- ---
## Phase 2 — Known Hosts as its own page ## Phase 2 — Known Hosts as its own page
@@ -1364,6 +1388,39 @@ Ctrl+K, then click a result with the mouse rather than pressing Enter.
**Pass:** the palette closes and the connection starts. **Pass:** the palette closes and the connection starts.
### 7.10a Connecting to a machine that is not saved
Ctrl+K, type `user@host` for a reachable machine that is **not** on HOSTS, press Enter, type its password and
press Enter again. Do it once against a machine whose host key this vault has never seen.
**Pass:** the palette asks for the password in place, with the caret already in the box. On the second Enter
it closes, a tab named `user@host` opens, and the fingerprint card asks about the key; approving it connects.
Afterwards HOSTS has nothing new on it, and the connection log has an entry for `user@host:22`. Escape from
the password box goes back to the address with what was typed still in it.
**Failure means:** a new row on HOSTS is the palette saving what it promised not to. A tab that fails with
"authentication failed" for a password known to be right is worth comparing against the phone's CONNECT TO A
MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsync` — so a difference between
the two is in how the palette hands the password over, not in the connection. The headless half of this is
`QuickConnectTests`; what it cannot do is dial.
### 7.10b Pressing Terminal with nothing open asks what to connect to
Close every terminal tab, then press **Terminal** on the rail.
**Pass:** the terminal's frame holds a prompt — `connect to:` with the caret already on it, so typing works
without a click. Type part of a saved host's name: the matching hosts appear under the line, Tab completes to
the first, and Enter connects to it — asking `user@host:port's password:` first only if that host has no key
or saved password. Type `user@host` for a machine that is not saved instead: Enter asks for its password, and
the second Enter opens a tab for it. Escape steps back from the password to the name. Afterwards HOSTS has
nothing new on it, and no credential was saved — even with "remember" ticked in a host's drawer earlier.
**Failure means:** a prompt that needs a click before it takes typing is the terminal's WebView still holding
the native keyboard focus — see `NativeKeyboardFocus`, which `ConnectPrompt.TakeKeyboard` calls. Anything drawn
*under* a blank rectangle is the occlusion rule broken: the prompt may only show while no tab exists. A new
credential in KEYS after connecting is the prompt's attempt acting on the drawer's tick, which
`ShellFlowTests.ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn` exists to stop.
### 7.11 A host or a bucket made now can be picked on FILES now ### 7.11 A host or a bucket made now can be picked on FILES now
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
@@ -1397,7 +1454,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
## Phase 8 — Adding and removing on the phone's host list ## Phase 8 — Adding and removing on the phone's host list
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite Twenty-two checks, and the reason there are twenty-two rather than none is worth stating: **the layout suite
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's `DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
application, dispatcher and platform are one-shot process globals, so a second head cannot share the application, dispatcher and platform are one-shot process globals, so a second head cannot share the
@@ -1770,6 +1827,23 @@ follow, and try disconnecting and reconnecting instead.
--- ---
### 8.20 Connecting from the search box to a machine that is not saved
With a terminal already open on some other machine, go back to HOSTS and type `user@host` for a reachable
machine that is **not** in the list into the search box at the top. Type its password into the card that
appears, and press CONNECT.
**Pass:** the card appears only once there is an `@` in the box, names the address with `:22` on the end, and
says NOT SAVED. CONNECT opens a second tab for `user@host` — the first is still there — and the fingerprint
card asks about an unknown key over the terminal. Back on HOSTS the search box is empty, the list is the whole
list again, and there is no new host in it. A port the parser refuses (`root@box:70000`) gives a sentence in
amber under the box rather than a card.
**Failure means:** the card sitting *over* the list rather than pushing it down is the panels' arrangement
having been lost — see the remark above `IsSendingChosenHostsToAVault` in `HostsScreen.axaml`. A password that
shows in the keyboard's suggestion strip is `Classes="secret"` missing from the box. Everything behind the
card is `ShellFlowTests`' hosts-search cases; this is the rectangles, and the software keyboard.
## Phase 9 — Tag chips and the picker ## Phase 9 — Tag chips and the picker
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
@@ -2025,25 +2099,42 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
With at least one keychain host and one machine connected to by hand in the log, open Connections with With at least one keychain host and one machine connected to by hand in the log, open Connections with
nothing running. nothing running.
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready **Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and name would — its password line, or straight into a terminal for a host with a key or a saved password.
leaves the password box **empty**. Neither one dials anything on its own. Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password
line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is
given.
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should **Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives carrying a host id it should not have. If the password came back, something is storing it — it never was.
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through And if the keychain one shows the logged address rather than the host's name, the host id is not being
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press. matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`.
### 11.9 Settings holds the keychain, and back walks out of it ### 11.9 The keychain and snippets are the vault's, and back walks out of them
From HOSTS: Settings, then Keychain. From HOSTS, tap KEYCHAIN in the switch under the vault's header, then SNIPPETS.
**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing. **Pass:** the bottom bar reads Vaults · Connections · Settings, and Vaults stays lit on all three. The
The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back, vault's name, the sync light, the lock and the switch stay above each one; neither screen draws a header of
by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS. its own. Back from KEYCHAIN or SNIPPETS returns to HOSTS; a second back leaves the application.
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first **Failure means:** `IsVaultSurface` and the back gesture's first case have drifted apart — they list the same
case, and those two have to move together — the switch mirrors that property by construction. three screens and have to move together.
### 11.9b Every add is the floating button, and every confirm is on the right
On HOSTS, KEYCHAIN and SNIPPETS, and on SFTP once connected, there is a round + (an upload arrow on SFTP)
at the bottom right, glowing without a square behind it. On the keychain it raises a sheet of five things
to add; choosing ADD A PASSWORD opens a full-screen page with labels above its boxes. Tap a host that asks
for a password.
**Pass:** the password sheet opens with the keyboard already up, its box ringed in violet; CANCEL is on the
left and CONNECT on the right; the header and the bottom bar behind the sheet are dimmed, and tapping either
closes the sheet. On SFTP, tapping a file puts a bar across the top with its name, a save arrow and ⋮; the ⋮
sheet has Rename, Move, Permissions and a red Delete.
**Failure means:** a square behind a glowing button is `ClipToBounds` back on (`Phone.axaml`); a bright
header under a sheet is `PhoneShell.ShowsSheetScrim` missing that sheet.
### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard** ### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard**
@@ -2246,6 +2337,23 @@ Android is not delivering the click as a `contextmenu` event, and the gesture ne
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer `WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
has no right button to miss. has no right button to miss.
### 11.14 Connections with nothing open asks what to connect to
Close every shell, then press **Connections**. Type part of a saved host's name into the `connect to:` line.
**Pass:** the matching hosts appear under the line as buttons a thumb can hit; tapping one asks for that
host's password on a line of its own under ssh's wording — or connects at once, for a host with a key or a
saved password. Then type `user@host` for a machine that is not saved and press CONNECT: the password line
appears with the keyboard already on it, and BACK — or the system back gesture — steps off it onto the
address rather than leaving the screen. A tap on a RECENT row lands on the prompt and asks whatever it needs
to before anything is dialled. Nothing new appears on HOSTS or in the keychain afterwards.
**Failure means:** a password line that appears with the keyboard still on the first line is the posted focus
in `ConnectPrompt.axaml.cs` arriving before the box has bounds. Back that leaves Connections from the password
line is `PhoneShell.OnBackRequested`'s prompt step having been reordered below the surface check. The
decisions themselves — which name matches, what is refused, what is saved — are `ShellFlowTests`' prompt
cases, shared with the desktop; this is the rectangles and the keyboard.
--- ---
## Phase 12 — Shared vaults: the operations that span two accounts ## Phase 12 — Shared vaults: the operations that span two accounts
@@ -3430,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2 make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
something wrote into the install directory at runtime and would survive a reinstall. something wrote into the install directory at runtime and would survive a reinstall.
### 19.13 A copy at the same path that rpm does not own is offered nothing
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
stays empty however long it runs.
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
+71 -10
View File
@@ -1,26 +1,30 @@
# Reaching a host you cannot dial # Reaching a host you cannot dial
Some machines do not answer from where the user is sitting. This product has **two** answers to that, and Some machines do not answer from where the user is sitting. This product has **two** answers to that, and
neither of them works. one of them works.
- The **relay** pipes raw TCP through the deployment. The server half is built and shipped; the client - The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
half does not exist, and both heads offer a checkbox that promises it. pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
- A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds` - A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds`
stores the chain; nothing writes it and nothing reads it. stores the chain; nothing writes it and nothing reads it.
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
This document is the comparison between them, which is the thing that has to be settled before either is This document is the comparison between them, which is the thing that has to be settled before either is
built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the
jump chain — see the last section for why that was wrong. jump chain — see the last section for why that was wrong.
> **Status: step 0 done, the rest planned.** > **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
> >
> | Step | State | Notes | > | Step | State | Notes |
> | --- | --- | --- | > | --- | --- | --- |
> | 0. Stop promising the relay | **Done** | Both heads say the box is not wired up and that ticking it stores the address and changes nothing. Left tickable, so a host already carrying the flag can lose it | > | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
> | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below | > | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below |
> | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy | > | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy |
> | 3. The relay: the bridge, then the ticket | Not started | The bridge's only consumer, so it lands with the feature that uses it | > | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
> | 4. File transfer parity | Not started | The transfers screen opens its own connection. Already half-done — `OpenSftpAsync` shares `BuildConnectionInfo`, so it honours a proxy today; what is missing is anything passing it one | > | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
## ◆ The relay's checkbox is a false promise, and that is a defect ## ◆ The relay's checkbox is a false promise, and that is a defect
@@ -171,7 +175,64 @@ the sentence step 0 added comes out of both heads.
**4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that **4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
work exists for. work exists for. ✅ for the relay.
## What step 3 built
**The server**, in `DodoSSH.Api/Features/Relay`:
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
reason is logged (event 2301).
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
**The client:**
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
failed connect disposes it before throwing.
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
is; a server without the feature supplies `UnavailableServerRelay`.
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
reachability probe is skipped for a relayed host, because it would test the direct path.
### ◆ Two things that are not what ADR 0004 wrote
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
rather than ~2 MiB.
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
URL — that access logs keep. A device is never visible to the server on an ordinary request.
### ◆ A WebSocket operation is never cancelled, on either side
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
frame. The close frame is the only way the client learns that the server could not reach the host, so both
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
the socket ended.
### What a developer meets
The relay refuses loopback with no configuration escape, so the development stack's sshd on
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
network.
## Traps already known ## Traps already known
@@ -226,8 +287,8 @@ is right, sharing a lifetime policy is not.
row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only. row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only.
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the - `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
remark above it. remark above it.
- `README.md` and `docs/android-port.md` wherever the relay is described as available. - `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
- ADR 0004 gains a note that its last consequence was built, and how. - ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
## What the first draft of this document got wrong ## What the first draft of this document got wrong
@@ -0,0 +1,49 @@
using DodoSSH.Domain;
using Microsoft.AspNetCore.Authorization;
namespace DodoSSH.Api.Authorization;
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
/// <remarks>
/// <para>
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
/// that no endpoint can be reached without it.
/// </para>
/// <para>
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
/// reached by this; it ends on its own idle and lifetime limits.
/// </para>
/// </remarks>
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
: AuthorizationHandler<ActiveAccountRequirement>
{
/// <inheritdoc />
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveAccountRequirement requirement)
{
ArgumentNullException.ThrowIfNull(context);
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
// as well would turn that into a 403.
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
if (user.Status == UserStatus.Active)
{
context.Succeed(requirement);
}
}
}
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping /// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would /// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict. /// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes /// <para>
/// nothing, and marking it modified would enlist the user row in whatever the request saves next. /// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
/// against the version from before the update and failed, so enrolling more than an hour after first
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
/// </para>
/// </remarks> /// </remarks>
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken) private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
{ {
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
return; return;
} }
await database.Users var written = await database.Users
.Where(u => u.Id == user.Id .Where(u => u.Id == user.Id
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow)) && (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
.ExecuteUpdateAsync( .ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now), setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (written > 0)
{
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
}
} }
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) => private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(authorizeResult); ArgumentNullException.ThrowIfNull(authorizeResult);
var accountNotActive = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } refusal
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
if (accountNotActive)
{
await TypedResults.Problem(
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
statusCode: StatusCodes.Status403Forbidden,
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
{
["code"] = ProblemCodes.AccountNotActive,
})
.ExecuteAsync(context)
.ConfigureAwait(false);
return;
}
var enrollmentMissing = authorizeResult.Forbidden var enrollmentMissing = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } failure && authorizeResult.AuthorizationFailure is { } failure
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any(); && failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
+17 -7
View File
@@ -2,9 +2,18 @@
# #
# docker build -f src/DodoSSH.Api/Dockerfile . # docker build -f src/DodoSSH.Api/Dockerfile .
# #
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at # Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
# the root and all four are load-bearing here — central package management means a csproj # global.json all sit at the root and all five are load-bearing here — central package
# alone does not name a single version, and a build that cannot see them resolves nothing. # management means a csproj alone does not name a single version, and a build that cannot see
# them resolves nothing.
#
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
# Build # Build
@@ -26,7 +35,7 @@ WORKDIR /src
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure # dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph # has to be here — restore walks ProjectReference, and a missing csproj fails the graph
# rather than skipping a node. # rather than skipping a node.
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./ COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/ COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/ COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/ COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
@@ -130,7 +139,8 @@ EXPOSE 8080
# without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more # without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more
# than one node — DODOSSH_Sync__CursorSigningKey. # than one node — DODOSSH_Sync__CursorSigningKey.
# #
# Nothing migrates the database from in here. That is the API's own design: it fails # The process migrates the database itself as it starts, before its port opens and under an
# /healthz/ready while a migration is pending and names it, so the schema is applied by # advisory lock so two replicas cannot race (Database:AutoMigrate, on by default). Leave it on
# `dotnet ef database update` alongside the deployment rather than by a racing container. # for ssh.dodotech.cloud: the CI deploy job relies on it. Turned off, /healthz/ready fails
# while a migration is pending and names it, and something else has to apply the schema.
ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"] ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"]
@@ -211,10 +211,23 @@ internal sealed class VaultEventsEndpoint(
/// <summary>Runs the socket until something ends it, then closes it politely.</summary> /// <summary>Runs the socket until something ends it, then closes it politely.</summary>
/// <remarks> /// <remarks>
/// <para>
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the /// Three loops rather than one: reading a socket and writing to it are independent waits, and the
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/> and then <em>all</em> /// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/>, and the sender is
/// awaited before the close is written, because a close frame racing a notice frame is a protocol /// finished before the close is written, because a close frame racing a notice frame is a protocol
/// violation that presents as a client dropping its connection for no visible reason. /// violation that presents as a client dropping its connection for no visible reason.
/// </para>
/// <para>
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on
/// the socket Kestrel hands over aborts it, and an aborted socket cannot send the close frame that
/// is the only way a client learns <em>why</em> it was dropped — 4401 "reconnect with a fresh
/// token" against 1001 "the server is going away" against a failure to back off from. This was
/// once the case here, and it went unnoticed because <c>TestServer</c>'s in-memory socket survives
/// the cancellation that a real one does not. So the token stops the queue and the clock; the
/// sender is let finish its last write; the close goes out; and the receiver ends on the client's
/// answering close. Each wait is bounded, and the socket is aborted only when a peer has stopped
/// answering altogether. The relay's pump does the same, for the same reason.
/// </para>
/// </remarks> /// </remarks>
private async Task PumpAsync( private async Task PumpAsync(
WebSocket socket, WebSocket socket,
@@ -236,15 +249,21 @@ internal sealed class VaultEventsEndpoint(
VaultCount: connection.VaultCount)); VaultCount: connection.VaultCount));
var sending = SendAsync(socket, connection, pump.Token); var sending = SendAsync(socket, connection, pump.Token);
var receiving = ReceiveAsync(socket, connection, pump.Token); var receiving = ReceiveAsync(socket, connection);
var minding = MindAsync(connection, closure, pump.Token); var minding = MindAsync(connection, closure, pump.Token);
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false); await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
await pump.CancelAsync().ConfigureAwait(false); await pump.CancelAsync().ConfigureAwait(false);
// Nothing may still be mid-send when the close frame goes out. // Nothing may still be mid-send when the close frame goes out. A send that does not finish is
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false); // a client that has stopped reading, which will not read a close frame either.
if (!await SettlesAsync(sending).ConfigureAwait(false))
{
socket.Abort();
}
await SettlesAsync(minding).ConfigureAwait(false);
if (lifetime.ApplicationStopping.IsCancellationRequested) if (lifetime.ApplicationStopping.IsCancellationRequested)
{ {
@@ -256,6 +275,14 @@ internal sealed class VaultEventsEndpoint(
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason); EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
await CloseAsync(socket, closure).ConfigureAwait(false); await CloseAsync(socket, closure).ConfigureAwait(false);
// The client's answering close is what ends the receiver. Returning before it arrives would
// dispose the socket under a close frame still in flight.
if (!await SettlesAsync(receiving).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(receiving).ConfigureAwait(false);
}
} }
/// <summary> /// <summary>
@@ -287,8 +314,9 @@ internal sealed class VaultEventsEndpoint(
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent); var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
// Not cancellable, deliberately. See PumpAsync.
await socket await socket
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken) .SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
} }
@@ -297,21 +325,26 @@ internal sealed class VaultEventsEndpoint(
/// Reads what the client sends, which in this version is heartbeats and a close. /// Reads what the client sends, which in this version is heartbeats and a close.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a /// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
/// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure /// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the /// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
/// caller's access and nothing else. /// caller's access and nothing else.
/// </para>
/// <para>
/// It takes no token: it ends on the client's close, a frame it refuses, or the socket failing —
/// and when the server is the one ending the connection, the close it sends is what prompts the
/// first of those. See <see cref="PumpAsync"/>.
/// </para>
/// </remarks> /// </remarks>
private static async Task ReceiveAsync( private static async Task ReceiveAsync(WebSocket socket, VaultEventConnection connection)
WebSocket socket,
VaultEventConnection connection,
CancellationToken cancellationToken)
{ {
var buffer = new byte[MaxInboundFrameBytes]; var buffer = new byte[MaxInboundFrameBytes];
while (!cancellationToken.IsCancellationRequested) while (true)
{ {
var received = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); // Not cancellable, deliberately. See PumpAsync.
var received = await socket.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false);
if (received.MessageType == WebSocketMessageType.Close) if (received.MessageType == WebSocketMessageType.Close)
{ {
@@ -492,31 +525,39 @@ internal sealed class VaultEventsEndpoint(
} }
/// <summary> /// <summary>
/// Awaits a pump loop, treating its cancellation and its socket faults as the ordinary end. /// Waits for a pump loop to finish, for at most the close timeout.
/// </summary> /// </summary>
/// <returns>Whether it finished.</returns>
/// <remarks> /// <remarks>
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception /// Every one of these loops ends by being cancelled or by the socket going away, so an exception
/// here is the expected shape of "this connection is over" rather than a fault to propagate — and /// here is the expected shape of "this connection is over" rather than a fault to propagate — and
/// propagating it would skip the close frame the other side is waiting for. /// propagating it would skip the close frame the other side is waiting for.
/// </remarks> /// </remarks>
private static async Task Settled(Task loop) private static async Task<bool> SettlesAsync(Task loop)
{ {
try try
{ {
await loop.ConfigureAwait(false); await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
} }
catch (Exception exception) catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException) when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{ {
// Expected. return true;
} }
} }
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary> /// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
/// <remarks> /// <remarks>
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump /// Mutable and shared, and safe without a lock for one specific reason: it is written only by
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a /// <see cref="MindAsync"/>, and read only after that loop has been awaited, which is a memory
/// memory barrier. Writes race only with each other, and any of them is a true answer. /// barrier. If that loop outlived the close timeout, a late write races the read, and either
/// answer is still a true one.
/// </remarks> /// </remarks>
private sealed class Closure(int code, string reason) private sealed class Closure(int code, string reason)
{ {
@@ -0,0 +1,24 @@
using DodoSSH.Api.Features.Relay;
namespace DodoSSH.Api.Features.Events;
/// <summary>
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
/// </summary>
/// <remarks>
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
/// touch any of the places a removal happens.
/// </remarks>
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
{
/// <inheritdoc />
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
/// <inheritdoc />
public void VaultAccessChanged(Guid userId)
{
hub.VaultAccessChanged(userId);
_ = relay.AccessChangedAsync(userId);
}
}
@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Crypto; using DodoSSH.Crypto;
using DodoSSH.Domain; using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint, byte[] fingerprint,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var strategy = database.Database.CreateExecutionStrategy(); return await database.ExecuteFreshAsync([user], async () =>
return await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary> /// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
internal const int SyncProtocolVersion = 1; internal const int SyncProtocolVersion = 1;
/// <summary>Feature flag for the relay.</summary>
internal const string RelayFeature = "relay";
/// <inheritdoc /> /// <inheritdoc />
public override void Configure() public override void Configure()
{ {
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
List<string> features = ["teams"]; List<string> features = ["teams"];
if (relay.Value.Enabled) if (relay.Value.Enabled)
{ {
features.Add(RelayFeature); features.Add(RelayProtocol.Feature);
} }
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is // Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
@@ -0,0 +1,62 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
/// </summary>
/// <remarks>
/// <para>
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
/// their keys and their vault list away at once, and left any shell they had open through the relay running
/// into the team's machines until it went idle.
/// </para>
/// <para>
/// Told by the same announcement that tells their event socket to re-read its vaults — see
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
/// has already committed and answered, and a slow re-check must not hold it.
/// </para>
/// </remarks>
internal sealed class RelayAccessWatch(
RelaySessionRegistry sessions,
IServiceScopeFactory scopes,
ILogger<RelayAccessWatch> logger)
{
/// <summary>Re-checks every session the user has open, in the background.</summary>
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
internal Task AccessChangedAsync(Guid userId)
{
var open = sessions.SessionsOf(userId);
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
}
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
foreach (var lease in open)
{
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
.ConfigureAwait(false);
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
{
lease.Revoke();
}
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
RelayLog.AccessRecheckFailed(logger, userId, exception);
}
}
}
@@ -0,0 +1,192 @@
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Setup;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
/// </summary>
/// <remarks>
/// <para>
/// This runs on <em>addresses</em>, never on names. The name is resolved once, at ticket issue, and what
/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
/// one for the dial — which is the attack ADR 0004 is built around.
/// </para>
/// <para>
/// Three layers, and the order is the security property. The <see cref="Refused"/> list comes first and
/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
/// then their allow-list, then the private-network switch.
/// </para>
/// <para>
/// <b>An address that embeds another is judged as the one it embeds.</b> <c>::ffff:127.0.0.1</c> is
/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
/// on a network with a NAT64 gateway <c>64:ff9b::a9fe:a9fe</c> <em>is</em> the metadata endpoint.
/// </para>
/// </remarks>
internal sealed class RelayAddressPolicy
{
/// <summary>
/// Addresses the relay never dials, whatever it is configured to do.
/// </summary>
/// <remarks>
/// Broader than the ADR's list in two places, both deliberately. <c>240.0.0.0/4</c> — reserved, and
/// containing the limited broadcast address — has no host worth reaching. And IPv6 <c>::/96</c>, the
/// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
/// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
/// </remarks>
private static readonly IPNetwork[] Refused =
[
// IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
IPNetwork.Parse("0.0.0.0/8"),
IPNetwork.Parse("127.0.0.0/8"),
IPNetwork.Parse("169.254.0.0/16"),
IPNetwork.Parse("224.0.0.0/4"),
IPNetwork.Parse("240.0.0.0/4"),
// Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
// otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
// block cannot simply be refused.
IPNetwork.Parse("100.100.100.200/32"),
// IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
// metadata endpoint's IPv6 address.
IPNetwork.Parse("::/96"),
IPNetwork.Parse("fe80::/10"),
IPNetwork.Parse("ff00::/8"),
IPNetwork.Parse("fd00:ec2::254/128"),
];
/// <summary>
/// Ranges <see cref="RelayOptions.AllowPrivateNetworks"/> governs.
/// </summary>
/// <remarks>
/// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
/// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
/// <c>fec0::/10</c> is deprecated and was private while it lived.
/// </remarks>
private static readonly IPNetwork[] Private =
[
IPNetwork.Parse("10.0.0.0/8"),
IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.168.0.0/16"),
IPNetwork.Parse("100.64.0.0/10"),
IPNetwork.Parse("fc00::/7"),
IPNetwork.Parse("fec0::/10"),
];
/// <summary>The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.</summary>
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
/// <summary>
/// Ports the relay never dials.
/// </summary>
/// <remarks>
/// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
/// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
/// and the control that matters there is who may dial the host at all.
/// </remarks>
private static readonly int[] RefusedPorts = [25, 465, 587];
private readonly bool allowPrivate;
private readonly IPNetwork[] allowed;
private readonly IPNetwork[] denied;
public RelayAddressPolicy(RelayOptions options)
{
ArgumentNullException.ThrowIfNull(options);
allowPrivate = options.AllowPrivateNetworks;
allowed = ParseAll(options.AllowedNetworks);
denied = ParseAll(options.DeniedNetworks);
}
/// <summary>Whether a port may be dialled at all.</summary>
internal static bool PermitsPort(int port) =>
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
/// <summary>
/// Whether every entry is a network this policy can read.
/// </summary>
/// <remarks>
/// For startup validation. A typo in a deny list that was silently skipped would be a hole the
/// operator believes is closed, so an unreadable entry stops the server instead.
/// <para>
/// Host bits are refused as well, although <see cref="IPNetwork.TryParse(string?, out IPNetwork)"/>
/// would mask them off quietly. <c>10.20.5.7/16</c> could be a /16 written from memory or a /32 with
/// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
/// </para>
/// </remarks>
internal static bool AreNetworks(IEnumerable<string> entries) =>
entries.All(entry =>
IPNetwork.TryParse(entry, out var network)
&& IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
&& written.Equals(network.BaseAddress));
/// <summary>
/// The address the relay would actually be talking to, with any embedding unwrapped.
/// </summary>
internal static IPAddress Normalise(IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
if (address.IsIPv4MappedToIPv6)
{
return address.MapToIPv4();
}
if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
{
var bytes = address.GetAddressBytes();
return new IPAddress(bytes.AsSpan(12, 4));
}
// A scope id is how an address names an interface on this machine, which is not something a
// host row gets to choose. Dropped, so what is judged is what is dialled.
return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
? new IPAddress(address.GetAddressBytes())
: address;
}
/// <summary>Whether the relay may dial this address.</summary>
/// <remarks>
/// Judges the normalised address. Callers should dial <see cref="Normalise"/>'s answer too, so that
/// the address checked and the address connected to are the same object rather than two spellings
/// the operating system is trusted to agree about.
/// </remarks>
internal bool Permits(IPAddress address)
{
var target = Normalise(address);
if (Refused.Any(network => Within(network, target)))
{
return false;
}
if (denied.Any(network => Within(network, target)))
{
return false;
}
if (allowed.Length > 0)
{
return allowed.Any(network => Within(network, target));
}
return allowPrivate || !Private.Any(network => Within(network, target));
}
/// <remarks>
/// <see cref="IPNetwork.Contains"/> answers false across address families rather than throwing, so
/// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
/// alternative reads as though it compared across families.
/// </remarks>
private static bool Within(IPNetwork network, IPAddress address) =>
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
private static IPNetwork[] ParseAll(IEnumerable<string> entries) =>
[.. entries.Select(entry => IPNetwork.Parse(entry))];
}
@@ -0,0 +1,759 @@
using System.Buffers;
using System.Net;
using System.Net.WebSockets;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// The relay socket: a ticket in, raw bytes to one host and back.
/// </summary>
/// <remarks>
/// <para>
/// Anonymous at the HTTP layer, because the ticket is the authorization and it is the whole of it. A
/// ticket carries no API authority — it names one host, one set of addresses and one session — so this
/// endpoint can accept it from a client that cannot set an <c>Authorization</c> header on an upgrade,
/// which is the constraint ADR 0004 was written around. It is verified, checked for freshness and
/// redeemed before the upgrade is accepted, so a refused ticket costs a problem document and no socket.
/// </para>
/// <para>
/// <b>Nothing here is scoped to the request.</b> A session lasts as long as a shell does, which is hours,
/// and anything scoped held across it is a pooled database connection held across it. The database is
/// touched three times — redeem, open the audit row, close it — each from a scope of its own that is
/// disposed before the next byte moves. That is ADR 0004's consequence about <c>DbContext</c> lifetime.
/// </para>
/// <para>
/// Backpressure is the await. Each direction reads one buffer, writes it, and does not read again until
/// the write has completed, so a slow reader on either side stops this side reading, which fills the
/// kernel's receive window, which throttles the sender at the far end. Nothing is queued in between, and
/// a session costs its two buffers whatever the peers do.
/// </para>
/// </remarks>
internal sealed class RelayConnectEndpoint(
IServiceScopeFactory scopes,
IRelayNetwork network,
RelayTicketCodec codec,
RelaySessionRegistry sessions,
IOptions<RelayOptions> options,
TimeProvider clock,
IHostApplicationLifetime lifetime,
ILogger<RelayConnectEndpoint> logger)
: EndpointWithoutRequest
{
/// <summary>
/// One direction's buffer.
/// </summary>
/// <remarks>
/// Room for an SSH packet at the size OpenSSH sends them, so a bulk transfer moves in one read and
/// one write per packet rather than several.
/// </remarks>
private const int BufferBytes = 32 * 1024;
/// <summary>How long a close handshake, or a peer's last write, may take before the socket is dropped.</summary>
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override void Configure()
{
Get(RelayProtocol.ConnectPath);
// The ticket is the authorization. See the remarks on this class, and ADR 0004.
AllowAnonymous();
Description(b => b
.WithName("RelayConnect")
.WithSummary("Pipes raw bytes between a WebSocket and the host a relay ticket names.")
.WithTags("Relay"));
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken ct)
{
var ticket = await AdmitTicketAsync(ct).ConfigureAwait(false);
if (ticket is null)
{
return;
}
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
try
{
using var socket = await HttpContext.WebSockets
.AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = RelayProtocol.SubProtocol })
.ConfigureAwait(false);
if (lease is null)
{
RelayLog.LimitReached(logger, ticket.UserId, sessions.Count);
await CloseAsync(
socket,
new Ending(
RelayCloseReason.Unspecified,
RelayProtocol.TooManySessionsCloseCode,
"Too many relay sessions are open."))
.ConfigureAwait(false);
return;
}
if (lifetime.ApplicationStopping.IsCancellationRequested)
{
await CloseAsync(socket, ShuttingDown).ConfigureAwait(false);
return;
}
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
}
finally
{
// Inside a try that starts before the upgrade, for the reason VaultEventsEndpoint gives: an
// accept that throws would otherwise hold a place against this user's cap forever.
lease?.Dispose();
}
}
private static Ending ShuttingDown { get; } = new(
RelayCloseReason.ServerShutdown,
(int)WebSocketCloseStatus.EndpointUnavailable,
"The server is shutting down.");
/// <summary>
/// Answers everything that is not a redeemable ticket, as ordinary HTTP, before any socket exists.
/// </summary>
/// <returns>The redeemed ticket, or null when a response has been sent.</returns>
/// <remarks>
/// A ticket that is forged, expired, spent, or simply absent all answer 401
/// <c>relay-ticket-invalid</c>, with one remedy — ask for another — and no hint of which it was.
/// </remarks>
private async Task<RelayTicket?> AdmitTicketAsync(CancellationToken cancellationToken)
{
if (!options.Value.Enabled)
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.RelayUnavailable,
"This server does not relay. GET /api/v1/meta lists the features it does offer."))
.ConfigureAwait(false);
return null;
}
var offered = HttpContext.WebSockets.WebSocketRequestedProtocols;
if (!HttpContext.WebSockets.IsWebSocketRequest
|| !offered.Contains(RelayProtocol.SubProtocol, StringComparer.Ordinal))
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
"This endpoint is a WebSocket. Send an upgrade request offering the "
+ $"'{RelayProtocol.SubProtocol}' subprotocol and the ticket as a second one, "
+ $"'{RelayProtocol.TicketProtocolPrefix}<ticket>'."))
.ConfigureAwait(false);
return null;
}
var sealedTicket = offered
.FirstOrDefault(protocol => protocol.StartsWith(RelayProtocol.TicketProtocolPrefix, StringComparison.Ordinal))
?[RelayProtocol.TicketProtocolPrefix.Length..];
var ticket = sealedTicket is { Length: > 0 } ? codec.TryOpen(sealedTicket) : null;
if (ticket is null || !await RedeemAsync(ticket, cancellationToken).ConfigureAwait(false))
{
RelayLog.TicketRefused(logger, ticket?.SessionId);
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status401Unauthorized,
ProblemCodes.RelayTicketInvalid,
"This relay ticket is not valid: it is expired, already used, or was not issued here. "
+ $"Ask for another from POST {RelayProtocol.TicketsPath}."))
.ConfigureAwait(false);
return null;
}
return ticket;
}
/// <summary>
/// Spends the ticket, or answers false when it has been spent already.
/// </summary>
/// <remarks>
/// <para>
/// A unique insert, which is the only form of "once" that holds across nodes: two redemptions racing
/// on two nodes both attempt the insert and the database lets exactly one of them win.
/// </para>
/// <para>
/// Rows for tickets that can no longer verify are swept on the way. They guard nothing, and sweeping
/// here — on a path that runs once per session rather than once per request — keeps the table the
/// size of the last thirty seconds without a background job to forget about.
/// </para>
/// </remarks>
private async Task<bool> RedeemAsync(RelayTicket ticket, CancellationToken cancellationToken)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var now = clock.GetUtcNow();
await database.RelayTicketUses
.Where(use => use.ExpiresAtUtc < now)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
database.RelayTicketUses.Add(new RelayTicketUse
{
TicketId = ticket.TicketId,
ExpiresAtUtc = ticket.ExpiresAt,
UsedAtUtc = now,
});
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (DbUpdateException exception)
when (string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal))
{
return false;
}
}
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
private async Task RelayAsync(
WebSocket socket,
RelayTicket ticket,
CancellationToken revoked,
CancellationToken requestAborted)
{
var started = clock.GetUtcNow();
await OpenAuditAsync(ticket, started).ConfigureAwait(false);
var dialled = await DialAsync(ticket, requestAborted).ConfigureAwait(false);
if (dialled is not var (target, address))
{
RelayLog.TargetUnreachable(logger, ticket.SessionId, ticket.HostId);
var unreachable = new Ending(
RelayCloseReason.TargetUnreachable,
RelayProtocol.TargetUnreachableCloseCode,
"The relay could not reach the host.");
await CloseAsync(socket, unreachable).ConfigureAwait(false);
await CloseAuditAsync(ticket.SessionId, null, new Counters(clock, started), unreachable.Reason).ConfigureAwait(false);
return;
}
var counters = new Counters(clock, started);
Ending ending;
await using (target.ConfigureAwait(false))
{
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
}
RelayLog.SessionClosed(
logger,
ticket.SessionId,
ending.Reason,
counters.ClientToTarget,
counters.TargetToClient);
await CloseAuditAsync(ticket.SessionId, address, counters, ending.Reason).ConfigureAwait(false);
}
/// <summary>
/// Connects to the first of the ticket's addresses that answers, within the connect timeout overall.
/// </summary>
/// <remarks>
/// Overall rather than per address, so a name with sixteen dead addresses costs five seconds rather
/// than eighty. In the order resolved, which is the order the operating system's resolver prefers.
/// </remarks>
private async Task<(Stream Target, IPAddress Address)?> DialAsync(
RelayTicket ticket,
CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(options.Value.ConnectTimeout);
foreach (var address in ticket.TargetAddresses)
{
try
{
var target = await network
.ConnectAsync(address, ticket.TargetPort, timeout.Token)
.ConfigureAwait(false);
return (target, address);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception exception) when (exception is IOException or System.Net.Sockets.SocketException)
{
// The next address, if there is one.
}
}
return null;
}
/// <summary>
/// Runs both directions and the clock until one of them ends the session, then ends it politely.
/// </summary>
/// <remarks>
/// <para>
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on a
/// .NET WebSocket aborts it, and an aborted socket cannot send the close frame that is the only way
/// a client learns <em>why</em> its session ended — which, for an idle timeout or a drain, is the
/// difference between "reconnect" and "something is broken". So the token stops the target side and
/// the clock; the sender is let finish its last write; the close goes out; and the receiver ends on
/// the client's answering close. Each wait is bounded, and the socket is aborted only when a peer
/// has stopped answering altogether.
/// </para>
/// </remarks>
private async Task<Ending> PumpAsync(
WebSocket socket,
Stream target,
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken requestAborted)
{
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
var minding = MindAsync(counters, started, revoked, pump.Token);
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
var ending = await first.ConfigureAwait(false) ?? new Ending(
RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
await pump.CancelAsync().ConfigureAwait(false);
// The only sender must be done before the close frame is written: a WebSocket permits one send
// at a time and faults permanently on a second.
if (!await SettlesAsync(downstream).ConfigureAwait(false))
{
socket.Abort();
}
await CloseAsync(socket, ending).ConfigureAwait(false);
if (!await SettlesAsync(upstream).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(upstream).ConfigureAwait(false);
}
await SettlesAsync(minding).ConfigureAwait(false);
return ending;
}
/// <summary>Client to target: every binary message is the next bytes of the stream.</summary>
private static async Task<Ending?> ClientToTargetAsync(
WebSocket socket,
Stream target,
Counters counters,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
try
{
while (true)
{
ValueWebSocketReceiveResult received;
try
{
// Not cancellable, deliberately. See PumpAsync.
received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is WebSocketException or OperationCanceledException)
{
return ClientGone;
}
if (received.MessageType == WebSocketMessageType.Close)
{
return ClientGone;
}
// A byte pipe has no text. A client sending some has the wrong idea about this endpoint,
// and forwarding it into an SSH stream would corrupt that stream at best.
if (received.MessageType != WebSocketMessageType.Binary)
{
return new Ending(
RelayCloseReason.Error,
(int)WebSocketCloseStatus.InvalidMessageType,
"The relay carries binary messages only.");
}
try
{
await target.WriteAsync(buffer.AsMemory(0, received.Count), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (IOException)
{
return TargetGone;
}
counters.AddClientToTarget(received.Count);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>Target to client: every read becomes one binary message.</summary>
private static async Task<Ending?> TargetToClientAsync(
Stream target,
WebSocket socket,
Counters counters,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
try
{
while (true)
{
int read;
try
{
read = await target.ReadAsync(buffer.AsMemory(0, BufferBytes), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (IOException)
{
return TargetGone;
}
if (read == 0)
{
return TargetGone;
}
try
{
// Not cancellable, deliberately. See PumpAsync.
await socket
.SendAsync(
buffer.AsMemory(0, read),
WebSocketMessageType.Binary,
endOfMessage: true,
CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException)
{
return ClientGone;
}
counters.AddTargetToClient(read);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Ends the session at the idle timeout, the lifetime cap, or the end of a shutdown's drain.
/// </summary>
/// <remarks>
/// <para>
/// A shutdown does not end a session; it starts a countdown. ADR 0004 asks that a
/// <c>docker compose up -d</c> not guillotine live shells, so a session in progress when the server is
/// told to stop is given the drain timeout to finish — most will, because the user was already
/// closing it or the deployment waits — and is closed with 1001 only when that runs out, which tells
/// the client to reconnect rather than to report a fault. The host's own shutdown timeout is set
/// longer than the drain for this to be possible at all.
/// </para>
/// <para>
/// Idle means no bytes in either direction. WebSocket pings are not bytes of the stream and do not
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
/// </para>
/// </remarks>
private async Task<Ending?> MindAsync(
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken cancellationToken)
{
var settings = options.Value;
var hardDeadline = started + settings.MaxSessionDuration;
DateTimeOffset? drainDeadline = null;
while (!cancellationToken.IsCancellationRequested)
{
var now = clock.GetUtcNow();
if (drainDeadline is null && lifetime.ApplicationStopping.IsCancellationRequested)
{
drainDeadline = now + settings.DrainTimeout;
}
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
{
return ending;
}
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
// than from whenever the idle clock next came round.
using var wake = drainDeadline is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
try
{
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
}
catch (OperationCanceledException)
{
return null;
}
}
return null;
}
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
/// <remarks>
/// Revocation first, because it is the one reason here that is about the user rather than the session:
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
/// </remarks>
private static Ending? Due(
DateTimeOffset now,
bool revoked,
DateTimeOffset hardDeadline,
DateTimeOffset? drainDeadline,
DateTimeOffset idleDeadline)
{
if (revoked)
{
return AccessRevoked;
}
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
return now >= idleDeadline
? new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.")
: null;
}
private static Ending AccessRevoked { get; } = new(
RelayCloseReason.AccessRevoked,
RelayProtocol.AccessRevokedCloseCode,
"You no longer have access to this host.");
private static Ending ClientGone { get; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
private static Ending TargetGone { get; } =
new(RelayCloseReason.TargetClosed, (int)WebSocketCloseStatus.NormalClosure, "The host closed the connection.");
/// <summary>
/// Opens the audit row, before the target is dialled, so a session that never reached its host is
/// still one the operator can see.
/// </summary>
private async Task OpenAuditAsync(RelayTicket ticket, DateTimeOffset started)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
database.RelaySessions.Add(new RelaySession
{
Id = ticket.SessionId,
UserId = ticket.UserId,
HostId = ticket.HostId,
VaultId = ticket.VaultId,
TargetHost = ticket.TargetHost,
TargetPort = ticket.TargetPort,
ClientAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
StartedAtUtc = started,
});
// Not the request's token: the row has to be written even for a client that has already gone,
// or a session that was cut off would be the one with no record.
await database.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Closes the audit row. A failure is logged and swallowed: the bytes have already moved, and
/// throwing here would only turn a finished session into an unhandled exception.
/// </summary>
private async Task CloseAuditAsync(
Guid sessionId,
IPAddress? address,
Counters counters,
RelayCloseReason reason)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var ended = clock.GetUtcNow();
var dialled = address?.ToString();
await database.RelaySessions
.Where(session => session.Id == sessionId)
.ExecuteUpdateAsync(
set => set
.SetProperty(session => session.EndedAtUtc, ended)
.SetProperty(session => session.TargetAddress, dialled)
.SetProperty(session => session.BytesClientToTarget, counters.ClientToTarget)
.SetProperty(session => session.BytesTargetToClient, counters.TargetToClient)
.SetProperty(session => session.CloseReason, reason),
CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is DbUpdateException or NpgsqlException or InvalidOperationException)
{
RelayLog.AuditCloseFailed(logger, sessionId, exception);
}
}
private static async Task CloseAsync(WebSocket socket, Ending ending)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
{
return;
}
using var timeout = new CancellationTokenSource(CloseTimeout);
try
{
await socket
.CloseOutputAsync((WebSocketCloseStatus)ending.Code, ending.Description, timeout.Token)
.ConfigureAwait(false);
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// The peer is already gone. There is nothing to tell it.
}
}
/// <summary>Waits for a loop to finish, for at most the close timeout.</summary>
/// <returns>Whether it finished.</returns>
private static async Task<bool> SettlesAsync(Task loop)
{
try
{
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
{
return true;
}
}
/// <summary>How a session ended, and what the client is told.</summary>
private sealed record Ending(RelayCloseReason Reason, int Code, string Description);
/// <summary>
/// Byte counts and the time of the last byte, written by the two directions and read by the clock.
/// </summary>
private sealed class Counters(TimeProvider clock, DateTimeOffset started)
{
private long clientToTarget;
private long targetToClient;
private long lastActivityTicks = started.UtcTicks;
internal long ClientToTarget => Interlocked.Read(ref clientToTarget);
internal long TargetToClient => Interlocked.Read(ref targetToClient);
internal DateTimeOffset LastActivity => new(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
internal void AddClientToTarget(int count)
{
Interlocked.Add(ref clientToTarget, count);
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
}
internal void AddTargetToClient(int count)
{
Interlocked.Add(ref targetToClient, count);
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using DodoSSH.Domain;
namespace DodoSSH.Api.Features.Relay;
/// <summary>Source-generated log messages for the relay.</summary>
/// <remarks>
/// <para>
/// Ids, counts and reasons. Never a ticket, which is a bearer credential for thirty seconds, and never
/// the target's address: that is in the audit table, where retention is the operator's decision, and
/// copying it into a log would make it the log pipeline's decision too.
/// </para>
/// <para>
/// <see cref="TargetRejected"/> is the one place the reason for a refused target is written down. The
/// client is told nothing specific, deliberately — see <c>RelayTicketEndpoint</c> — so this line is how
/// an operator answers "why will the relay not reach this host".
/// </para>
/// </remarks>
internal static partial class RelayLog
{
[LoggerMessage(
EventId = 2301,
Level = LogLevel.Information,
Message = "Relay ticket refused for user {UserId} and host {HostId}: {Reason}.")]
internal static partial void TargetRejected(ILogger logger, Guid userId, Guid hostId, string reason);
[LoggerMessage(
EventId = 2302,
Level = LogLevel.Debug,
Message = "Relay ticket issued to user {UserId} for host {HostId}, session {SessionId}, with "
+ "{AddressCount} permitted address(es).")]
internal static partial void TicketIssued(
ILogger logger,
Guid userId,
Guid hostId,
Guid sessionId,
int addressCount);
/// <remarks>
/// The session id is known only for a ticket that verified and had already been spent, which is the
/// case worth an operator's attention: a replay, or a client retrying a redemption it thinks failed.
/// </remarks>
[LoggerMessage(
EventId = 2303,
Level = LogLevel.Information,
Message = "Relay ticket refused at the socket (session {SessionId}): forged, expired or already used.")]
internal static partial void TicketRefused(ILogger logger, Guid? sessionId);
[LoggerMessage(
EventId = 2304,
Level = LogLevel.Information,
Message = "Relay session refused for user {UserId}: a session limit is met, with {SessionCount} "
+ "open on this node.")]
internal static partial void LimitReached(ILogger logger, Guid userId, int sessionCount);
[LoggerMessage(
EventId = 2305,
Level = LogLevel.Information,
Message = "Relay session {SessionId} could not reach host {HostId} on any permitted address.")]
internal static partial void TargetUnreachable(ILogger logger, Guid sessionId, Guid hostId);
[LoggerMessage(
EventId = 2306,
Level = LogLevel.Debug,
Message = "Relay session {SessionId} opened for user {UserId} to host {HostId}; {SessionCount} "
+ "open on this node.")]
internal static partial void SessionOpened(
ILogger logger,
Guid sessionId,
Guid userId,
Guid hostId,
int sessionCount);
[LoggerMessage(
EventId = 2307,
Level = LogLevel.Debug,
Message = "Relay session {SessionId} closed ({Reason}) after {ClientToTarget} byte(s) up and "
+ "{TargetToClient} down.")]
internal static partial void SessionClosed(
ILogger logger,
Guid sessionId,
RelayCloseReason reason,
long clientToTarget,
long targetToClient);
/// <remarks>
/// A warning rather than an error: the session itself is over and was fine. What is lost is the
/// audit row's second half, which leaves it looking like a session still open.
/// </remarks>
[LoggerMessage(
EventId = 2308,
Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
/// <remarks>
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
/// existed, and each still ends at its idle timeout or its lifetime.
/// </remarks>
[LoggerMessage(
EventId = 2309,
Level = LogLevel.Warning,
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
}
@@ -0,0 +1,61 @@
using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// The two things the relay asks of the network: what a name resolves to, and a connection to an address.
/// </summary>
/// <remarks>
/// <para>
/// A seam for tests, and a narrow one on purpose. <see cref="RelayAddressPolicy"/> is not behind it:
/// whatever this answers, the policy judges, and a test that replaces this still runs every address
/// through the real rules. What a test cannot do without the seam is dial anything, because every
/// address a test machine can reach on its own — loopback — is one the policy refuses outright.
/// </para>
/// </remarks>
internal interface IRelayNetwork
{
/// <summary>Resolves a stored hostname, which may be an address literal.</summary>
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
/// <summary>Opens a TCP connection to one address.</summary>
/// <returns>A stream that owns the connection.</returns>
Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
}
/// <summary>The operating system's resolver and sockets.</summary>
internal sealed class SystemRelayNetwork : IRelayNetwork
{
/// <inheritdoc />
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken) =>
// A literal parses to itself without a lookup, which is what an address typed into a host
// editor should do — and what makes "10.0.0.5" mean the same thing on every node.
IPAddress.TryParse(host, out var literal)
? Task.FromResult<IPAddress[]>([literal])
: Dns.GetHostAddressesAsync(host, cancellationToken);
/// <inheritdoc />
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
// An interactive shell is a stream of keystrokes; Nagle would hold each one back for the
// acknowledgement of the last, which is the latency the user feels as a sticky terminal.
NoDelay = true,
};
try
{
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
}
@@ -0,0 +1,150 @@
using DodoSSH.Api.Setup;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits.
/// </summary>
/// <remarks>
/// <para>
/// Per node, and so per user <em>per node</em>: a user behind a round-robin balancer with three nodes
/// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means
/// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a
/// node — which a per-node count does exactly.
/// </para>
/// <para>
/// A singleton, because a session outlives the request that opened it. It holds counts and nothing else;
/// the sockets belong to the handlers that opened them.
/// </para>
/// </remarks>
internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
{
private readonly Lock gate = new();
private readonly Dictionary<Guid, int> perUser = [];
private readonly HashSet<Lease> open = [];
private int total;
/// <summary>How many sessions are open on this node.</summary>
internal int Count
{
get
{
lock (gate)
{
return total;
}
}
}
/// <summary>Whether a session for this user would be admitted now.</summary>
/// <remarks>
/// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status
/// before it has spent a ticket, but only <see cref="TryAdmit"/> decides, since the answer here can
/// be stale by the time the socket arrives — possibly at another node.
/// </remarks>
internal bool HasRoomFor(Guid userId)
{
var settings = options.Value;
lock (gate)
{
return total < settings.MaxConcurrentSessionsTotal
&& perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser;
}
}
/// <summary>Admits a session, or answers null when a limit is met.</summary>
/// <returns>A lease that gives the place back when disposed.</returns>
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
{
lock (gate)
{
return [.. open.Where(lease => lease.UserId == userId)];
}
}
internal Lease? TryAdmit(Guid userId, Guid vaultId)
{
var settings = options.Value;
lock (gate)
{
var held = perUser.GetValueOrDefault(userId);
if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser)
{
return null;
}
perUser[userId] = held + 1;
total++;
var lease = new Lease(this, userId, vaultId);
open.Add(lease);
return lease;
}
}
private void Release(Lease lease, Guid userId)
{
lock (gate)
{
open.Remove(lease);
total--;
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
// session open and not of every user who ever had one.
if (perUser[userId] <= 1)
{
perUser.Remove(userId);
}
else
{
perUser[userId]--;
}
}
}
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
{
private readonly CancellationTokenSource revoked = new();
private int released;
/// <summary>Whose session this is.</summary>
internal Guid UserId { get; } = userId;
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
internal Guid VaultId { get; } = vaultId;
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
internal CancellationToken Revoked => revoked.Token;
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
internal void Revoke()
{
try
{
revoked.Cancel();
}
catch (ObjectDisposedException)
{
// Already over.
}
}
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
registry.Release(this, UserId);
revoked.Dispose();
}
}
}
}
@@ -0,0 +1,171 @@
using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Everything the relay socket needs to know, decided by the ticket endpoint.
/// </summary>
/// <remarks>
/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
/// to resolve again — <see cref="TargetHost"/> is carried for the audit row only — which is what keeps a
/// DNS answer that changes between issue and connect from changing where the bytes go.
/// </remarks>
/// <param name="TicketId">The replay guard's key.</param>
/// <param name="SessionId">The audit row's id, already given to the client.</param>
/// <param name="UserId">Who asked.</param>
/// <param name="HostId">The host it is for.</param>
/// <param name="VaultId">The host's vault at issue.</param>
/// <param name="TargetHost">The hostname as stored, for the audit row.</param>
/// <param name="TargetAddresses">The permitted addresses the name resolved to, in the order to try.</param>
/// <param name="TargetPort">The port.</param>
/// <param name="ExpiresAt">When the ticket stops verifying.</param>
internal sealed record RelayTicket(
Guid TicketId,
Guid SessionId,
Guid UserId,
Guid HostId,
Guid VaultId,
string TargetHost,
IReadOnlyList<IPAddress> TargetAddresses,
int TargetPort,
DateTimeOffset ExpiresAt);
/// <summary>
/// Seals and opens relay tickets.
/// </summary>
/// <remarks>
/// <para>
/// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by
/// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely
/// signed because the ticket travels in a header every proxy on the way can read, and what it holds is
/// the resolved address of a machine on the operator's network.
/// </para>
/// <para>
/// The expiry is inside the payload and checked against <see cref="TimeProvider"/>, rather than left to
/// the framework's time-limited protector, which reads the system clock directly — the thing this
/// repository bans so that time can be faked in tests.
/// </para>
/// <para>
/// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would
/// be the larger half of the code, and a hand layout has no reflection for trimming to break.
/// </para>
/// </remarks>
internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
{
/// <summary>The purpose string, which isolates these payloads from every other protector's.</summary>
private const string Purpose = "DodoSSH.Relay.Ticket";
private const byte LayoutVersion = 1;
/// <summary>More addresses than any real name resolves to, and a bound on what a ticket can carry.</summary>
internal const int MaxAddresses = 16;
private readonly IDataProtector protector = protection.CreateProtector(Purpose);
internal string Seal(RelayTicket ticket)
{
ArgumentNullException.ThrowIfNull(ticket);
using var buffer = new MemoryStream();
using (var writer = new BinaryWriter(buffer))
{
writer.Write(LayoutVersion);
writer.Write(ticket.TicketId.ToByteArray());
writer.Write(ticket.SessionId.ToByteArray());
writer.Write(ticket.UserId.ToByteArray());
writer.Write(ticket.HostId.ToByteArray());
writer.Write(ticket.VaultId.ToByteArray());
writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds());
writer.Write(ticket.TargetPort);
writer.Write(ticket.TargetHost);
writer.Write((byte)ticket.TargetAddresses.Count);
foreach (var address in ticket.TargetAddresses)
{
var bytes = address.GetAddressBytes();
writer.Write((byte)bytes.Length);
writer.Write(bytes);
}
}
// Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name.
return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray()));
}
/// <summary>Opens a ticket, or answers null for one that is forged, damaged or expired.</summary>
/// <remarks>
/// One answer for all three, because the caller's remedy is the same — ask for another — and a
/// distinct "expired" would tell a forger which of their attempts had verified.
/// </remarks>
internal RelayTicket? TryOpen(string sealedTicket)
{
byte[] payload;
try
{
payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket));
}
catch (Exception exception) when (exception is CryptographicException or FormatException)
{
return null;
}
RelayTicket ticket;
try
{
ticket = Read(payload);
}
catch (Exception exception)
when (exception is EndOfStreamException or ArgumentException or FormatException)
{
return null;
}
return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null;
}
private static RelayTicket Read(byte[] payload)
{
using var reader = new BinaryReader(new MemoryStream(payload));
if (reader.ReadByte() != LayoutVersion)
{
throw new FormatException("Unknown ticket layout.");
}
var ticketId = new Guid(reader.ReadBytes(16));
var sessionId = new Guid(reader.ReadBytes(16));
var userId = new Guid(reader.ReadBytes(16));
var hostId = new Guid(reader.ReadBytes(16));
var vaultId = new Guid(reader.ReadBytes(16));
var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64());
var port = reader.ReadInt32();
var host = reader.ReadString();
var count = reader.ReadByte();
if (count is 0 or > MaxAddresses)
{
throw new FormatException("A ticket names between one and sixteen addresses.");
}
var addresses = new IPAddress[count];
for (var index = 0; index < count; index++)
{
var length = reader.ReadByte();
if (length is not (4 or 16))
{
throw new FormatException("An address is four or sixteen bytes.");
}
addresses[index] = new IPAddress(reader.ReadBytes(length));
}
return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt);
}
}
@@ -0,0 +1,231 @@
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using DodoSSH.Infrastructure;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Issues the ticket that lets exactly one relay socket reach exactly one host.
/// </summary>
/// <remarks>
/// <para>
/// Every decision the relay makes is made here, with the full bearer token and the full access model in
/// hand: which host, whether the caller may connect to it, which addresses its name resolves to and
/// which of those the policy permits. The socket that follows decides nothing. It checks that the ticket
/// is genuine, fresh and unused, and dials what the ticket says. See ADR 0004.
/// </para>
/// <para>
/// <b>Every refusal about the target is the same refusal.</b> A host that does not exist, one in a vault
/// the caller cannot reach, one without the relay switched on, one whose name does not resolve, and one
/// whose every address the policy refuses all answer 403 <c>relay-target-rejected</c> with the same
/// sentence. Anyone who can write a host can set its address to anything, so a distinct answer for
/// "resolves, but refused" would turn this endpoint into a scanner for the operator's internal DNS. The
/// real reason goes to the server log, where the operator can read it.
/// </para>
/// </remarks>
internal sealed class RelayTicketEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
DodoDbContext database,
IRelayNetwork network,
RelayTicketCodec codec,
RelaySessionRegistry sessions,
IOptions<RelayOptions> options,
TimeProvider clock,
ILogger<RelayTicketEndpoint> logger)
: Endpoint<RelayTicketRequest, Results<Ok<RelayTicketResponse>, ProblemHttpResult>>
{
/// <summary>What every target refusal says, whatever the reason was.</summary>
private const string RejectedDetail =
"The relay will not connect to this host. Check that the relay is switched on for it and that its "
+ "address is one this server may reach; the server's log records which.";
/// <inheritdoc />
public override void Configure()
{
Post(RelayProtocol.TicketsPath);
// Enrolled, matching sync. A relayed host is still a host whose name, username and key are
// ciphertext, so a caller with no identity key has no way to have learned which host to ask for.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("IssueRelayTicket")
.WithSummary("Issues a single-use ticket for one relay connection to one host.")
.WithTags("Relay"));
}
/// <inheritdoc />
public override async Task<Results<Ok<RelayTicketResponse>, ProblemHttpResult>> ExecuteAsync(
RelayTicketRequest req,
CancellationToken ct)
{
var settings = options.Value;
if (!settings.Enabled)
{
return Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.RelayUnavailable,
"This server does not relay. Connect to the host directly; GET /api/v1/meta lists the "
+ "features this server does offer.");
}
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var (target, refusal) = await FindTargetAsync(user.Id, req, ct).ConfigureAwait(false);
if (target is not var (hostname, port, vaultId))
{
return Rejected(user.Id, req.HostId, refusal!);
}
// Before resolving rather than after: a caller already at the cap is told so without this server
// spending a DNS query on a ticket that could not be redeemed.
if (!sessions.HasRoomFor(user.Id))
{
RelayLog.LimitReached(logger, user.Id, sessions.Count);
return Problems.Coded(
StatusCodes.Status429TooManyRequests,
ProblemCodes.RelayLimitReached,
"You have as many relay sessions open as this server allows. Close one and try again.");
}
var addresses = await ResolvePermittedAsync(hostname, ct).ConfigureAwait(false);
if (addresses is not { Length: > 0 })
{
return Rejected(user.Id, req.HostId, addresses is null
? "its name did not resolve"
: "every address its name resolved to is refused by policy");
}
var ticket = new RelayTicket(
TicketId: Guid.CreateVersion7(),
SessionId: Guid.CreateVersion7(),
UserId: user.Id,
HostId: req.HostId,
VaultId: vaultId,
TargetHost: hostname,
TargetAddresses: addresses,
TargetPort: port,
ExpiresAt: clock.GetUtcNow() + settings.TicketLifetime);
RelayLog.TicketIssued(logger, user.Id, req.HostId, ticket.SessionId, addresses.Length);
return TypedResults.Ok(new RelayTicketResponse(
Ticket: codec.Seal(ticket),
WebSocketUrl: new Uri(settings.WebSocketUrl!, UriKind.Absolute),
SubProtocol: RelayProtocol.SubProtocol,
SessionId: ticket.SessionId,
ExpiresAt: ticket.ExpiresAt));
}
/// <summary>
/// Finds the host, and decides whether this caller may be relayed to it at all.
/// </summary>
/// <returns>The stored target, or the reason there is none — for the log, never for the caller.</returns>
private async Task<((string Hostname, int Port, Guid VaultId)? Target, string? Refusal)> FindTargetAsync(
Guid userId,
RelayTicketRequest req,
CancellationToken cancellationToken)
{
if (req.PortForwardId is not null)
{
return (null, "a port forward was requested, which v1 does not relay");
}
var host = await database.Hosts
.AsNoTracking()
.SingleOrDefaultAsync(h => h.Id == req.HostId && h.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (host is null)
{
return (null, "no such host");
}
var access = await vaultAccess.ResolveAsync(userId, host.VaultId, cancellationToken).ConfigureAwait(false);
// Connect rather than Read, because Connect is the permission that says what this is. Every role
// that reads today also connects — see VaultAccessService.ForRole — but a role that ever split
// them should be refused a relay here without anybody remembering to look.
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Connect))
{
return (null, "the caller may not connect to this host's vault");
}
// The CHECK constraint makes the second half unreachable while the first is true. Tested anyway,
// because the cost of trusting it is dialling a null.
if (!host.RelayEnabled || host.Hostname is not { } hostname || host.Port is not { } port)
{
return (null, "the relay is not switched on for this host");
}
return RelayAddressPolicy.PermitsPort(port)
? ((hostname, port, host.VaultId), null)
: (null, $"port {port} is refused by policy");
}
/// <summary>
/// Resolves the stored name, and keeps the addresses the policy permits.
/// </summary>
/// <returns>The permitted addresses, possibly none; or null when the name did not resolve.</returns>
/// <remarks>
/// <para>
/// Filtered rather than refused as a whole when one answer is bad. A name that resolves to a public
/// address and a link-local one is dialled on the public address, and never on the other — the
/// ticket carries only what passed, so there is nothing else for the socket to try.
/// </para>
/// <para>
/// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
/// than a second spelling of it.
/// </para>
/// </remarks>
private async Task<IPAddress[]?> ResolvePermittedAsync(string hostname, CancellationToken cancellationToken)
{
var policy = new RelayAddressPolicy(options.Value);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(options.Value.ConnectTimeout);
IPAddress[] resolved;
try
{
resolved = await network.ResolveAsync(hostname, timeout.Token).ConfigureAwait(false);
}
catch (SocketException)
{
return null;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
return
[
.. resolved
.Where(policy.Permits)
.Select(RelayAddressPolicy.Normalise)
.Distinct()
.Take(RelayTicketCodec.MaxAddresses),
];
}
private ProblemHttpResult Rejected(Guid userId, Guid hostId, string reason)
{
RelayLog.TargetRejected(logger, userId, hostId, reason);
return Problems.Coded(StatusCodes.Status403Forbidden, ProblemCodes.RelayTargetRejected, RejectedDetail);
}
}
+12
View File
@@ -27,4 +27,16 @@ internal static partial class SyncLog
int appliedCount, int appliedCount,
int operationCount, int operationCount,
Guid vaultId); Guid vaultId);
[LoggerMessage(
EventId = 2003,
Level = LogLevel.Information,
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
internal static partial void Collected(ILogger logger, int count);
[LoggerMessage(
EventId = 2004,
Level = LogLevel.Warning,
Message = "Tombstone collection failed; it will run again at the next interval.")]
internal static partial void CollectionFailed(ILogger logger, Exception exception);
} }
+85 -20
View File
@@ -26,12 +26,59 @@ internal sealed class SyncService(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var afterSequence = 0L; var afterSequence = 0L;
var issuedUnder = 0L;
if (!string.IsNullOrEmpty(request.Cursor) if (!string.IsNullOrEmpty(request.Cursor)
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence)) && !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
{ {
throw new InvalidCursorException(); throw new InvalidCursorException();
} }
// Read before the changes, for the cursor this read hands out; see the check after it.
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
.ConfigureAwait(false);
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
//
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
// a read from the beginning that started after the collection, and its early pages are behind the
// horizon because old, untouched items are; refusing those would send that read back to the start
// for ever. See SyncCursor.
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
{
throw new InvalidCursorException();
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
// The horizon from before the read, never the one after: a collection landing between the two
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
// a restart.
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
}
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
Guid vaultId,
long afterSequence,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var limit = Math.Clamp( var limit = Math.Clamp(
request.Limit ?? options.DefaultPullLimit, request.Limit ?? options.DefaultPullLimit,
1, 1,
@@ -43,7 +90,7 @@ internal sealed class SyncService(
// One extra row, so "is there more" needs no second query. // One extra row, so "is there more" needs no second query.
var query = database.VaultChanges var query = database.VaultChanges
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence); .Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
if (types is not null) if (types is not null)
{ {
@@ -62,18 +109,7 @@ internal sealed class SyncService(
changes.RemoveAt(changes.Count - 1); changes.RemoveAt(changes.Count - 1);
} }
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false); return (changes, hasMore);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
} }
/// <summary> /// <summary>
@@ -100,9 +136,7 @@ internal sealed class SyncService(
{ {
ValidateBatchLimits(request); ValidateBatchLimits(request);
var strategy = database.Database.CreateExecutionStrategy(); return await database.ExecuteFreshAsync([vault], async () =>
return await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -536,9 +570,21 @@ internal sealed class SyncService(
/// <summary>Attaches current row state to change-log entries.</summary> /// <summary>Attaches current row state to change-log entries.</summary>
/// <remarks> /// <remarks>
/// <para>
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per /// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull /// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from. /// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
/// </para>
/// <para>
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
/// </para>
/// </remarks> /// </remarks>
private async Task<List<Contracts.SyncChange>> HydrateAsync( private async Task<List<Contracts.SyncChange>> HydrateAsync(
Vault vault, Vault vault,
@@ -571,7 +617,10 @@ internal sealed class SyncService(
} }
} }
return [.. changes.Select(change => Describe( return [.. changes
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|| row.ChangeSequence <= change.Sequence)
.Select(change => Describe(
change, change,
kinds.GetValueOrDefault(change.EntityType), kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))]; rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
@@ -609,14 +658,30 @@ internal sealed class SyncService(
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary> /// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type)); private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) => /// <remarks>
await database.VaultChanges /// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
/// cost a full resync for nothing.
/// </remarks>
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var head = await database.VaultChanges
.Where(c => c.VaultId == vaultId) .Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence) .OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence) .Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
}
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Vaults
.Where(v => v.Id == vaultId)
.Select(v => v.SyncHorizon)
.SingleAsync(cancellationToken);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) => private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new( new(
operation.OperationId, operation.OperationId,
@@ -0,0 +1,197 @@
using DodoSSH.Api.Setup;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Sync;
/// <summary>
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
/// </summary>
/// <remarks>
/// <para>
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
/// edit ever made to read the items that exist.
/// </para>
/// <para>
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
/// before both an item's old entry and its new one reads the new one, and a client between them already has
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
/// </para>
/// <para>
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
/// has; see the client's <c>SyncEngine</c>.
/// </para>
/// <para>
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
/// </para>
/// </remarks>
internal sealed class TombstoneCollector(
DodoDbContext database,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollector> logger)
{
/// <summary>Collects what is due, once.</summary>
/// <returns>How many tombstones and superseded entries went.</returns>
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
{
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
var collected = 0;
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
SyncLog.Collected(logger, collected);
return collected;
}
private async Task<int> CollectAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var due = await table(database)
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
.Select(item => new { item.VaultId, item.Id })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var collected = 0;
foreach (var vault in due.GroupBy(item => item.VaultId))
{
var ids = vault.Select(item => item.Id).ToArray();
collected += await database.ExecuteFreshAsync(
[],
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
.ConfigureAwait(false);
}
return collected;
}
private async Task<int> CollectFromVaultAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
Guid vaultId,
Guid[] ids,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
await database.Database
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
.ConfigureAwait(false);
// Read again under the lock: an item restored since it was found is no longer due.
var still = await table(database)
.Where(item => item.VaultId == vaultId
&& ids.Contains(item.Id)
&& item.DeletedAtUtc != null
&& item.DeletedAtUtc < before)
.Select(item => new { item.Id, item.ChangeSequence })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (still.Count == 0)
{
return 0;
}
var going = still.Select(item => item.Id).ToArray();
var horizon = still.Max(item => item.ChangeSequence);
await table(database)
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.VaultChanges
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.Vaults
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return still.Count;
}
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
database.Database.ExecuteSqlRawAsync(
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
+ "WHERE newer.vault_id = older.vault_id "
+ "AND newer.entity_type = older.entity_type "
+ "AND newer.entity_id = older.entity_id "
+ "AND newer.sequence > older.sequence",
cancellationToken);
}
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
internal sealed class TombstoneCollectionService(
IServiceScopeFactory scopes,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollectionService> logger) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
.RunOnceAsync(stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The next run tries again; nothing collected late is harmed by waiting.
SyncLog.CollectionFailed(logger, exception);
}
}
}
}
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
} }
// Owner, not admin. An admin the owner promoted must not be able to archive the team out // Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this // from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
// re-checks it, unlike the transfer below, so this line is the whole of the guard. // checks it again, because deleting a team's last vault archives it too.
if (!access.IsOwner) if (!access.IsOwner)
{ {
return Problems.Coded( return Problems.Coded(
@@ -0,0 +1,27 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Serialises the writes that decide what a team owns, for the life of the transaction.
/// </summary>
/// <remarks>
/// <para>
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
/// </para>
/// <para>
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
/// </para>
/// </remarks>
internal static class TeamLock
{
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
database.Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
cancellationToken);
}
+22 -16
View File
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -244,8 +245,27 @@ internal sealed class TeamService(
Team team, Team team,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(actor);
ArgumentNullException.ThrowIfNull(team); ArgumentNullException.ThrowIfNull(team);
// Both callers check this first and answer the caller properly; this is what stops a third one that
// forgets from letting an admin archive the team out from under its owner.
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
{
throw new InvalidOperationException("Only the owner of a team can archive it.");
}
var now = clock.GetUtcNow();
var archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false); var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0) if (vaultCount > 0)
@@ -256,16 +276,6 @@ internal sealed class TeamService(
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it.")); $"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
} }
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var archived = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
var memberships = await database.TeamMemberships var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null) .Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
@@ -335,9 +345,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken) var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy(); await database.ExecuteFreshAsync([incoming, outgoing], async () =>
await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -716,9 +724,7 @@ internal sealed class TeamService(
} }
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy(); var revoked = await database.ExecuteFreshAsync([membership], async () =>
var revoked = await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
{ {
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false); var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
if (orphaned is not null) // Archived only by its owner. Archiving revokes every membership, the owner's included, and an
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
// the owner, who can archive it or make a vault in it.
if (orphaned is not null
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
{ {
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false); await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
} }
@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -76,6 +77,40 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry."); "That vault id is already in use. Generate a new UUIDv7 and retry.");
} }
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
return new VaultSummary(
VaultId: request.VaultId,
Name: name,
IsPersonal: false,
TeamId: team.Id,
KeyGeneration: 1,
Permissions: 0,
WrappedVaultKey: request.WrappedVaultKey,
RekeyRequired: false);
}
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
private Task WriteVaultAsync(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
string name,
UserKey key,
CancellationToken cancellationToken) =>
database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow()); AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try try
@@ -90,17 +125,20 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry."); "That vault id is already in use. Generate a new UUIDv7 and retry.");
} }
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
});
return new VaultSummary( /// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
VaultId: request.VaultId, private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
Name: name, {
IsPersonal: false, var live = await database.Teams
TeamId: team.Id, .AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
KeyGeneration: 1, .ConfigureAwait(false);
Permissions: 0,
WrappedVaultKey: request.WrappedVaultKey, if (!live)
RekeyRequired: false); {
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
}
} }
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary> /// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
+13 -6
View File
@@ -41,17 +41,24 @@ builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>(); builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>(); builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice // A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the // whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
// write paths that announce a change need only the two methods that announce one, and should not gain // that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
// a reference to connection management to get them. // vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
builder.Services.AddSingleton<VaultEventHub>(); builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>( builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
provider => provider.GetRequiredService<VaultEventHub>());
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
builder.Services.AddScoped<TombstoneCollector>();
builder.Services.AddHostedService<TombstoneCollectionService>();
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay();
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's // Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process. // DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>(); builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
// Replaces the framework default, which is also registered as a singleton. Last registration wins. // Replaces the framework default, which is also registered as a singleton. Last registration wins.
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>(); builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
+9 -2
View File
@@ -62,7 +62,14 @@ internal static class Auth
private static void AddDodoPolicies(AuthorizationOptions options) private static void AddDodoPolicies(AuthorizationOptions options)
{ {
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser()); // Both policies also require the account to be active. A suspended or deprovisioned account still
// holds a token the identity provider will vouch for until it expires — the status is this server's
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
options.AddPolicy(
AuthenticatedPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.ActiveAccountRequirement()));
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler. // Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying // An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
@@ -72,7 +79,7 @@ internal static class Auth
EnrolledPolicy, EnrolledPolicy,
policy => policy policy => policy
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement())); .AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller. // Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy); options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
+27 -3
View File
@@ -54,6 +54,13 @@ internal static class Configuration
+ "setting exists to provide.") + "setting exists to provide.")
.ValidateOnStart(); .ValidateOnStart();
services.AddRelayOptions();
return services;
}
/// <summary>The relay's options, which carry more rules than any other section. See ADR 0004.</summary>
private static void AddRelayOptions(this IServiceCollection services) =>
services.AddOptions<RelayOptions>() services.AddOptions<RelayOptions>()
.BindConfiguration(RelayOptions.SectionName) .BindConfiguration(RelayOptions.SectionName)
.ValidateDataAnnotations() .ValidateDataAnnotations()
@@ -63,11 +70,28 @@ internal static class Configuration
.Validate( .Validate(
options => options.IdleTimeout < options.MaxSessionDuration, options => options.IdleTimeout < options.MaxSessionDuration,
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.") "Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
.Validate(
options => options.TicketLifetime > TimeSpan.Zero
&& options.TicketLifetime <= TimeSpan.FromSeconds(30),
"Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer "
+ "credential in a header; ADR 0004 bounds how long one may be worth stealing.")
.Validate(
options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero,
"Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.")
.Validate(
options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks)
&& Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks),
"Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as "
+ "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap "
+ "the operator believes is closed.")
.Validate(
options => !options.Enabled
|| (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url)
&& (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal)
|| string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))),
"Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.")
.ValidateOnStart(); .ValidateOnStart();
return services;
}
/// <summary> /// <summary>
/// Warns about configuration that is individually valid but dangerous in combination. /// Warns about configuration that is individually valid but dangerous in combination.
/// </summary> /// </summary>
+38 -7
View File
@@ -121,17 +121,44 @@ public sealed class RelayOptions
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. /// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Nothing reads this setting yet, and that is the point of saying so here.</b> The relay is /// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
/// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape /// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
/// is settled before the code is, which makes it exactly the kind of comment that gets read as a /// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
/// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004 /// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
/// §3 in full — loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses
/// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
/// flag governs RFC1918 only, never that list.
/// </para> /// </para>
/// </remarks> /// </remarks>
public bool AllowPrivateNetworks { get; set; } = true; public bool AllowPrivateNetworks { get; set; } = true;
/// <summary>
/// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
/// rules permit.
/// </summary>
/// <remarks>
/// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
/// with <see cref="AllowPrivateNetworks"/> off, because "only 10.20.0.0/16" is the precise thing an
/// operator means by setting both. It still cannot reach the non-overridable list, and
/// <see cref="DeniedNetworks"/> still wins over it.
/// </remarks>
public IList<string> AllowedNetworks { get; } = [];
/// <summary>Networks, in CIDR form, that a relay target may never fall inside.</summary>
/// <remarks>
/// Checked before <see cref="AllowedNetworks"/>, so a deny inside an allow is a hole in it — the way
/// round that lets an operator carve the database subnet out of an otherwise permitted range.
/// </remarks>
public IList<string> DeniedNetworks { get; } = [];
/// <summary>
/// A directory to keep the ticket key ring in. Unset means the platform default.
/// </summary>
/// <remarks>
/// Losing the key ring is harmless in the way losing <c>Sync:CursorSigningKey</c> is: a ticket lives
/// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
/// again. What does need it is more than one node — every node must be able to read every other's
/// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
/// </remarks>
public string? KeyRingDirectory { get; set; }
/// <summary>Maximum concurrent sessions per user.</summary> /// <summary>Maximum concurrent sessions per user.</summary>
[Range(1, 1000)] [Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10; public int MaxConcurrentSessionsPerUser { get; set; } = 10;
@@ -262,6 +289,10 @@ public sealed class SyncOptions
[Range(1, 3650)] [Range(1, 3650)]
public int TombstoneRetentionDays { get; set; } = 90; public int TombstoneRetentionDays { get; set; } = 90;
/// <summary>How often the tombstone collector runs, in minutes.</summary>
[Range(1, 7 * 24 * 60)]
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
/// <summary> /// <summary>
/// Key used to sign sync cursors, base64. /// Key used to sign sync cursors, base64.
/// </summary> /// </summary>
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta; using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Features.Sync; using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams; using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts; using DodoSSH.Contracts;
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
typeof(SyncPullEndpoint), typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint), typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint), typeof(VaultEventsEndpoint),
typeof(RelayTicketEndpoint),
typeof(RelayConnectEndpoint),
typeof(CreateTeamEndpoint), typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint), typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint), typeof(UpdateTeamEndpoint),
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
// Registered as each feature lands: // Registered as each feature lands:
// Identity — key rotation, passphrase change // Identity — key rotation, passphrase change
// Vaults — per-item ACLs // Vaults — per-item ACLs
// Relay — tickets and the WebSocket
// Audit, Admin // Audit, Admin
}); });
+72
View File
@@ -0,0 +1,72 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Setup;
/// <summary>
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
/// rather than from what the failed attempt left in the change tracker.
/// </summary>
/// <remarks>
/// <para>
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
/// its two role changes, each reporting success.
/// </para>
/// <para>
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
/// before: nothing is cleared or reloaded until something has failed.
/// </para>
/// </remarks>
internal static class FreshAttempts
{
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
/// <param name="database">The request's context.</param>
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
/// <param name="attempt">The transaction, which begins and commits its own.</param>
internal static Task<T> ExecuteFreshAsync<T>(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task<T>> attempt)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(carried);
ArgumentNullException.ThrowIfNull(attempt);
var strategy = database.Database.CreateExecutionStrategy();
var attempts = 0;
return strategy.ExecuteAsync(async () =>
{
if (attempts++ > 0)
{
database.ChangeTracker.Clear();
foreach (var entity in carried)
{
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
}
}
return await attempt().ConfigureAwait(false);
});
}
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
internal static Task ExecuteFreshAsync(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task> attempt) =>
database.ExecuteFreshAsync(
carried,
async () =>
{
await attempt().ConfigureAwait(false);
return true;
});
}
+10 -5
View File
@@ -1,5 +1,6 @@
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Npgsql; using Npgsql;
@@ -29,14 +30,18 @@ internal static class Persistence
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"ConnectionStrings:{ConnectionStringName} is not configured."); $"ConnectionStrings:{ConnectionStringName} is not configured.");
services.AddDbContext<DodoDbContext>(options => options services.AddDbContext<DodoDbContext>((provider, options) => options
.UseNpgsql(connectionString, npgsql => npgsql .UseNpgsql(connectionString, npgsql => npgsql
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName) .MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
// Retry on transient faults only. Note this is safe here because writes go // Retry on transient faults only. A write transaction replayed by the strategy runs on
// through explicitly-managed transactions rather than relying on the execution // the same context, which still holds what the failed attempt tracked — every such
// strategy to replay ambient ones. // transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
// the database instead.
.EnableRetryOnFailure(3)) .EnableRetryOnFailure(3))
.UseSnakeCaseNamingConvention()); .UseSnakeCaseNamingConvention()
// None in production. The seam a test uses to make a commit fail the way a dropped
// connection would, which is the only way to watch a retry happen.
.AddInterceptors(provider.GetServices<IInterceptor>()));
services.AddHealthChecks() services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>( .AddCheck<DatabaseHealthCheck>(
+60
View File
@@ -0,0 +1,60 @@
using DodoSSH.Api.Features.Relay;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Setup;
/// <summary>The relay's services. See ADR 0004.</summary>
internal static class RelaySetup
{
/// <summary>
/// How much longer than the drain the host waits before it stops regardless.
/// </summary>
/// <remarks>
/// Room for the close handshakes and the audit rows' final writes once the drain has run out, which
/// is the part of a shutdown an operator would otherwise lose without noticing.
/// </remarks>
private static readonly TimeSpan ShutdownMargin = TimeSpan.FromSeconds(10);
internal static IServiceCollection AddDodoRelay(this IServiceCollection services)
{
// Singletons: a session outlives the request that opened it, a protector is safe to share, and
// the network seam holds no state.
services.AddSingleton<RelayTicketCodec>();
services.AddSingleton<RelaySessionRegistry>();
services.AddSingleton<RelayAccessWatch>();
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
// The application name isolates this deployment's payloads from any other ASP.NET application
// sharing a key directory, and — unlike the default, which is derived from the content root —
// is the same on every node, which is what lets any node verify any node's ticket.
services.AddDataProtection().SetApplicationName("DodoSSH");
services.AddOptions<KeyManagementOptions>()
.Configure<IOptions<RelayOptions>, ILoggerFactory>((keys, relay, loggers) =>
{
if (relay.Value.KeyRingDirectory is { Length: > 0 } directory)
{
keys.XmlRepository = new FileSystemXmlRepository(new DirectoryInfo(directory), loggers);
}
});
// Longer than the drain, or the host would stop waiting for live sessions before the drain ADR 0004
// promises them had run. Harmless with the relay off: nothing else holds a request open that long,
// and the host stops as soon as the last request has.
services.AddOptions<HostOptions>()
.Configure<IOptions<RelayOptions>>((host, relay) =>
{
var wanted = relay.Value.DrainTimeout + ShutdownMargin;
if (wanted > host.ShutdownTimeout)
{
host.ShutdownTimeout = wanted;
}
});
return services;
}
}
+28 -7
View File
@@ -132,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
var keepAlive = new SessionKeepAlive( var keepAlive = new SessionKeepAlive(
workspace, workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers, activeTransfers: () => viewModel.Transfers.ActiveTransfers,
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession); holdsFileSession: () => viewModel.Transfers.HasLiveFileSession,
listeningForwards: () => viewModel.ListeningForwardCount);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue // The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing, and a Files session connecting or disconnecting. // announces transfers appearing and finishing, and a Files session connecting or disconnecting.
@@ -148,6 +149,11 @@ public sealed partial class DodoSshApp : Avalonia.Application
// in the first place, which was precisely the promise this whole arrangement exists to keep. // in the first place, which was precisely the promise this whole arrangement exists to keep.
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh(); viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
// A forward starting, stopping or failing changes the notification's wording and nothing else — it
// rides a shell's connection, so the shell is already what holds the service up. The user is still
// entitled to know a phone in their pocket is holding ports open.
viewModel.ForwardsChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh(); keepAlive.Refresh();
} }
@@ -240,11 +246,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
knownHosts, knownHosts,
deviceKeys, deviceKeys,
// Difference 4: the system browser by intent, and the response by intent too rather than on a SignInThroughBrowser(browser),
// 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, TimeProvider.System,
connections, connections,
@@ -265,7 +267,9 @@ public sealed partial class DodoSshApp : Avalonia.Application
copyToClipboard: copyToClipboard, copyToClipboard: copyToClipboard,
readClipboard: readClipboard, readClipboard: readClipboard,
updates: updates); updates: updates,
spelunkAccounts: PhoneSpelunkAccounts(browser));
// Started rather than awaited: framework initialisation must not block on a schema migration. The // 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. // view model shows its own progress and handles its own failures.
@@ -274,6 +278,23 @@ public sealed partial class DodoSshApp : Avalonia.Application
return viewModel; return viewModel;
} }
/// <summary>
/// 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.
/// </summary>
private static MainWindowViewModel.SignInHandler SignInThroughBrowser(AndroidBrowserLauncher browser) =>
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
.ConfigureAwait(false);
/// <summary>
/// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same registered
/// scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as the account's
/// device list does, for difference 5's reason.
/// </summary>
private static SpelunkAccounts PhoneSpelunkAccounts(AndroidBrowserLauncher browser) =>
new(browser, path => new AndroidRedirectCallback(path), TimeProvider.System, PhoneEnvironment.DeviceName);
/// <summary> /// <summary>
/// Difference 4: where the authorization response comes back to. /// Difference 4: where the authorization response comes back to.
/// </summary> /// </summary>
@@ -1,10 +1,15 @@
using global::Android.OS;
using global::Android.Views; using global::Android.Views;
using Avalonia.Threading;
using DodoSSH.Client.Shell.Keyboard;
namespace DodoSSH.Client.Android.Platform; namespace DodoSSH.Client.Android.Platform;
/// <summary> /// <summary>
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it /// Takes the keyboard away from the terminal's <c>WebView</c>, reports the taps that should bring it back,
/// back. /// and turns a long press and drag into arrow keys.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -36,6 +41,23 @@ namespace DodoSSH.Client.Android.Platform;
/// keyboard over the very output being scrolled to. /// keyboard over the very output being scrolled to.
/// </para> /// </para>
/// <para> /// <para>
/// <b>A long press and a drag is the cursor gesture, and scrolling is still everything else.</b> There is no
/// mode to switch: a finger that moves straight away is a scroll, exactly as before, and the page gets every
/// event of it. A finger held still past <see cref="CursorGestureDelay"/> becomes a joystick for the arrow
/// keys — see <see cref="ArrowGesture"/> for the speeds — and from that moment the rest of the touch is this
/// class's: the <c>WebView</c> is sent a cancel, so it lets go of whatever it thought was starting, and is
/// not shown another event until the finger lifts. A haptic tick says the gesture has begun, because nothing
/// can be drawn over the terminal to say it instead.
/// </para>
/// <para>
/// The delay is a little short of the platform's long-press timeout, and that is the point of it. Chromium
/// starts its own long press — selection, and a <c>contextmenu</c> event the page answers with a paste — at
/// the platform timeout, so taking the touch away first is what keeps a held finger from pasting the
/// clipboard into a shell. Both timers run on the main looper and this one is posted first and shorter, so
/// it wins without a race. With the gesture switched off nothing is taken, and a long press does what it
/// did before.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason /// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and /// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the /// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
@@ -52,17 +74,26 @@ internal static class RendererFocus
/// </remarks> /// </remarks>
private static WeakReference<global::Android.Webkit.WebView>? configured; private static WeakReference<global::Android.Webkit.WebView>? configured;
/// <summary>How long a finger has to stay still for the cursor gesture, as a share of the long-press timeout.</summary>
private const double CursorGestureDelay = 0.8;
/// <summary>How often a held gesture is asked whether another arrow has come due — about one frame.</summary>
private const long TickMs = 16;
/// <summary> /// <summary>
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody /// Makes the renderer unable to hold the keyboard, calls <paramref name="onTapped"/> when somebody taps
/// taps it. /// it, and <paramref name="onArrows"/> while somebody long-presses and drags it.
/// </summary> /// </summary>
/// <param name="onTapped">A tap: bring the keyboard back.</param>
/// <param name="gesturesEnabled">Asked at each press, so the setting applies from the next touch.</param>
/// <param name="onArrows">How many arrows to send, and which.</param>
/// <remarks> /// <remarks>
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the /// Idempotent, and called on every transition into the terminal rather than once at startup, because the
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is /// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
/// free to tear its renderer down and build a new one, which is a case this head already handles /// free to tear its renderer down and build a new one, which is a case this head already handles
/// elsewhere (see <c>docs/android-port.md</c>). /// elsewhere (see <c>docs/android-port.md</c>).
/// </remarks> /// </remarks>
public static void Suppress(Action onTapped) public static void Suppress(Action onTapped, Func<bool> gesturesEnabled, Action<ArrowDirection, int> onArrows)
{ {
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor) if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{ {
@@ -83,57 +114,246 @@ internal static class RendererFocus
webView.FocusableInTouchMode = false; webView.FocusableInTouchMode = false;
webView.ClearFocus(); webView.ClearFocus();
WatchForTaps(webView, onTapped); WatchTouches(webView, onTapped, gesturesEnabled, onArrows);
configured = new WeakReference<global::Android.Webkit.WebView>(webView); configured = new WeakReference<global::Android.Webkit.WebView>(webView);
} }
/// <summary> /// <summary>
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else. /// Calls <paramref name="onTapped"/> for a tap, runs the cursor gesture for a long press, and stays out of
/// the way of everything else.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A tap is a press and a release that did not travel further than the platform's own /// A tap is a press and a release that did not travel further than the platform's own
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious: /// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. /// without it, dragging the scrollback would raise the keyboard over the output being scrolled to. The
/// same slop is what cancels a pending long press, so a scroll can never turn into the gesture halfway.
/// </remarks> /// </remarks>
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped) private static void WatchTouches(
global::Android.Webkit.WebView webView,
Action onTapped,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{ {
// The fallback is the platform's own historical default, for a context that has somehow gone. var touch = new TouchState(webView, gesturesEnabled, onArrows);
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
var downX = 0f;
var downY = 0f;
var moved = false;
webView.Touch += (_, e) => webView.Touch += (_, e) =>
{ {
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
e.Handled = false;
if (e.Event is not { } motion) if (e.Event is not { } motion)
{ {
e.Handled = false;
return; return;
} }
switch (motion.Action) // Observed and passed on, except while the cursor gesture holds the finger: then the page must not
// see it scroll.
e.Handled = touch.Handle(motion, onTapped);
};
}
/// <summary>One finger's worth of touch on the terminal, from press to release.</summary>
/// <remarks>
/// <para>
/// A class rather than locals captured by the handler, because two timers — the long press and the
/// repeat tick — read and write the same state from outside the touch callback.
/// </para>
/// <para>
/// Avalonia's <see cref="DispatcherTimer"/> rather than <c>View.PostDelayed</c>. On this head the
/// dispatcher is the main looper, so the timers land on the same thread and in the same queue as the
/// touches and as Chromium's own long press; and a dispatcher timer is a plain managed object, where a
/// posted <c>Runnable</c> is a Java peer that would want disposing for the life of a view that has none.
/// </para>
/// </remarks>
private sealed class TouchState
{
private readonly global::Android.Webkit.WebView webView;
private readonly Func<bool> gesturesEnabled;
private readonly Action<ArrowDirection, int> onArrows;
private readonly ArrowGesture gesture = new();
private readonly int slop;
private readonly float density;
private readonly DispatcherTimer longPress;
private readonly DispatcherTimer tick;
private float downX;
private float downY;
private float lastX;
private float lastY;
private long downTime;
private bool moved;
private bool steering;
private int lastSpeed;
internal TouchState(
global::Android.Webkit.WebView webView,
Func<bool> gesturesEnabled,
Action<ArrowDirection, int> onArrows)
{
this.webView = webView;
this.gesturesEnabled = gesturesEnabled;
this.onArrows = onArrows;
// The fallbacks are the platform's own historical defaults, for a context that has somehow gone.
slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
density = webView.Resources?.DisplayMetrics?.Density is > 0 and var d ? d : 1;
longPress = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout * CursorGestureDelay),
};
longPress.Tick += (_, _) => BeginSteering();
tick = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(TickMs) };
tick.Tick += (_, _) => Tick();
}
/// <returns>Whether the event is consumed rather than passed on to the page.</returns>
internal bool Handle(MotionEvent motion, Action onTapped)
{
switch (motion.ActionMasked)
{ {
case MotionEventActions.Down: case MotionEventActions.Down:
downX = motion.GetX(); Press(motion);
downY = motion.GetY(); return false;
moved = false;
break; case MotionEventActions.PointerDown:
// A second finger is a pinch or a two-finger scroll, never the cursor gesture.
longPress.Stop();
moved = true;
return steering;
case MotionEventActions.Move when steering:
Steer(motion.GetX(), motion.GetY());
return true;
case MotionEventActions.Move: case MotionEventActions.Move:
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop; Travel(motion.GetX(), motion.GetY());
return false;
break; case MotionEventActions.Up or MotionEventActions.Cancel:
return Release(motion.ActionMasked is MotionEventActions.Up, onTapped);
case MotionEventActions.Up when !moved: default:
return steering;
}
}
private void Press(MotionEvent motion)
{
downX = lastX = motion.GetX();
downY = lastY = motion.GetY();
downTime = motion.DownTime;
moved = false;
steering = false;
longPress.Stop();
if (gesturesEnabled())
{
longPress.Start();
}
}
/// <summary>A finger moving before the long press: past the slop it is a scroll, and stays one.</summary>
private void Travel(float x, float y)
{
lastX = x;
lastY = y;
if (!moved && (Math.Abs(x - downX) > slop || Math.Abs(y - downY) > slop))
{
moved = true;
longPress.Stop();
}
}
/// <returns>Whether the release is consumed — it is, when it ends the cursor gesture.</returns>
private bool Release(bool lifted, Action onTapped)
{
longPress.Stop();
if (steering)
{
EndSteering();
return true;
}
if (lifted && !moved)
{
onTapped(); onTapped();
}
return false;
}
/// <summary>The finger has been held still long enough: take the touch and start steering.</summary>
private void BeginSteering()
{
longPress.Stop();
steering = true;
lastSpeed = 0;
gesture.Reset();
// The page has seen a press and nothing since; a cancel tells it that press is over, so neither
// its scroller nor its own long press goes on to do anything with it. Parents are asked not to
// intercept either — the gesture now owns the finger until it lifts.
var now = SystemClock.UptimeMillis();
using (var cancel = MotionEvent.Obtain(downTime, now, MotionEventActions.Cancel, lastX, lastY, MetaKeyStates.None))
{
if (cancel is not null)
{
webView.OnTouchEvent(cancel);
cancel.Recycle();
}
}
webView.Parent?.RequestDisallowInterceptTouchEvent(true);
webView.PerformHapticFeedback(FeedbackConstants.LongPress);
tick.Start();
}
private void Steer(float x, float y)
{
var count = gesture.Move((x - downX) / density, (y - downY) / density, SystemClock.UptimeMillis());
Emit(count);
}
/// <summary>The repeat clock: whatever has come due since the last frame.</summary>
private void Tick()
{
if (steering)
{
Emit(gesture.Advance(SystemClock.UptimeMillis()));
}
}
private void Emit(int count)
{
if (count > 0)
{
onArrows(gesture.Direction, count);
}
// A tick under the thumb each time the speed shifts, which is the only way to feel the three
// levels without looking: the terminal under the finger cannot draw anything to show them.
if (gesture.Speed > lastSpeed && lastSpeed > 0)
{
webView.PerformHapticFeedback(FeedbackConstants.ClockTick);
}
lastSpeed = gesture.Speed;
}
break; private void EndSteering()
{
steering = false;
tick.Stop();
gesture.Reset();
} }
};
} }
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent) private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
/// <param name="liveSessions">Shells with a live channel behind them.</param> /// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param> /// <param name="activeTransfers">Transfers still moving bytes.</param>
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param> /// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
/// <param name="listeningForwards">
/// Port forwards listening over those shells. Named in the summary and nothing else: a forward rides a
/// shell's connection, so it cannot be the reason the process has to stay up — see
/// <see cref="SessionKeepAlive"/>.
/// </param>
/// <remarks> /// <remarks>
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as /// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
/// always. Something live and the service not yet running starts it. Something live and the service /// always. Something live and the service not yet running starts it. Something live and the service
@@ -152,7 +157,7 @@ internal sealed class SessionForegroundService : Service
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real /// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
/// start even over a service that looks like it is running; the inline remark below is why. /// start even over a service that looks like it is running; the inline remark below is why.
/// </remarks> /// </remarks>
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession) public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{ {
var context = PhoneEnvironment.Require(); var context = PhoneEnvironment.Require();
@@ -165,7 +170,7 @@ internal sealed class SessionForegroundService : Service
// The summary says what is actually held, counted rather than generic — the same principle the // The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on. // delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession); var summary = Summarise(liveSessions, activeTransfers, holdsFileSession, listeningForwards);
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is // In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
// always allowed and is preferred even when `running` says the service is up: a disconnect followed // always allowed and is preferred even when `running` says the service is up: a disconnect followed
@@ -255,15 +260,22 @@ internal sealed class SessionForegroundService : Service
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode)); activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
} }
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession) private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{ {
var parts = new List<string>(3); var parts = new List<string>(4);
if (liveSessions > 0) if (liveSessions > 0)
{ {
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected"); parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
} }
// Beside the shells they run over, and only while one is: a count left over from a session that has
// just ended would be a receipt for ports nothing holds open any more.
if (liveSessions > 0 && listeningForwards > 0)
{
parts.Add(listeningForwards == 1 ? "1 port forwarded" : $"{listeningForwards} ports forwarded");
}
if (holdsFileSession) if (holdsFileSession)
{ {
parts.Add("Files connected"); parts.Add("Files connected");
@@ -24,12 +24,20 @@ namespace DodoSSH.Client.Android.Platform;
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that /// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
/// gap closed, read the same way the other two facts are: asked, not cached. /// gap closed, read the same way the other two facts are: asked, not cached.
/// </para> /// </para>
/// <para>
/// Listening port forwards are a fourth fact, and a different kind: they only change what the notification
/// says. A forward runs over a shell's connection and stops with it, so there is never a listening forward
/// without a live shell already holding the service up — and a count that could start the service on its own
/// would only ever be wrong, in the moment a forward's Stopped had not been heard yet. What they add is the
/// receipt: the user is entitled to know the phone is holding ports open, as well as shells.
/// </para>
/// </remarks> /// </remarks>
internal sealed class SessionKeepAlive : IDisposable internal sealed class SessionKeepAlive : IDisposable
{ {
private readonly TerminalWorkspace workspace; private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers; private readonly Func<int> activeTransfers;
private readonly Func<bool> holdsFileSession; private readonly Func<bool> holdsFileSession;
private readonly Func<int> listeningForwards;
/// <param name="workspace">The live shells.</param> /// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers"> /// <param name="activeTransfers">
@@ -40,11 +48,20 @@ internal sealed class SessionKeepAlive : IDisposable
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but- /// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>. /// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
/// </param> /// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession) /// <param name="listeningForwards">
/// How many port forwards are listening, across every tab. For the notification's wording only; see the
/// class remarks. See <c>MainWindowViewModel.ListeningForwardCount</c>.
/// </param>
public SessionKeepAlive(
TerminalWorkspace workspace,
Func<int> activeTransfers,
Func<bool> holdsFileSession,
Func<int> listeningForwards)
{ {
this.workspace = workspace; this.workspace = workspace;
this.activeTransfers = activeTransfers; this.activeTransfers = activeTransfers;
this.holdsFileSession = holdsFileSession; this.holdsFileSession = holdsFileSession;
this.listeningForwards = listeningForwards;
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the // Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
// closer's own — which is fine: starting and stopping a service is a binder call and needs no // closer's own — which is fine: starting and stopping a service is a binder call and needs no
@@ -56,14 +73,15 @@ internal sealed class SessionKeepAlive : IDisposable
/// <summary>Re-reads the counts and starts or stops the service to match.</summary> /// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks> /// <remarks>
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a /// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free: /// finishing, a Files connection opening or closing, a forward starting or stopping. Calling it when nothing changed is free:
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or, /// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one, /// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
/// and Android treats all of those as no-ops. /// and Android treats all of those as no-ops.
/// </remarks> /// </remarks>
public void Refresh() => public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession()); SessionForegroundService.Reconcile(
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
@@ -72,7 +90,7 @@ internal sealed class SessionKeepAlive : IDisposable
// The notification goes with the composition root. Leaving it up over a process that is shutting // The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of. // down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0, false); SessionForegroundService.Reconcile(0, 0, false, 0);
} }
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh(); private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
+314 -12
View File
@@ -36,6 +36,156 @@
this paragraph to learn it was deliberate. this paragraph to learn it was deliberate.
--> -->
<!--
◆ FLUENT'S OWN BRUSHES, ANSWERED ONCE HERE RATHER THAN CONTROL BY CONTROL.
The 2026-10-02 audit (M5) found the controls this file never styled still drawing Fluent's defaults
among the ones it did: a dropdown with a bright white border on a black fill beside 10-radius fields on
#101019, a checkbox in Fluent's blue, a switch drawn as a bare circle, and a focused box whose fill went
to pure black. Each of those reads its colours through a DynamicResource key, and a key defined here is
found before the theme's own, because Avalonia searches an application's styles last-first and this file
is included after FluentTheme. So one block of keys restyles every instance on the head — the ones on
screens written later included — where a selector per control would restyle only the ones somebody
remembered.
Every value is a palette brush, never a new colour. The focused border is the accent rather than a
grey: a box with the keyboard up is the one control the user is currently writing into, and the
redesign gives it the ring that says so.
-->
<Styles.Resources>
<StaticResource x:Key="TextControlBackground" ResourceKey="Field" />
<StaticResource x:Key="TextControlBackgroundPointerOver" ResourceKey="Field" />
<StaticResource x:Key="TextControlBackgroundFocused" ResourceKey="Field" />
<StaticResource x:Key="TextControlBorderBrush" ResourceKey="BorderMid" />
<StaticResource x:Key="TextControlBorderBrushPointerOver" ResourceKey="BorderMid" />
<StaticResource x:Key="TextControlBorderBrushFocused" ResourceKey="TerminalTabAccent" />
<StaticResource x:Key="TextControlForegroundFocused" ResourceKey="Text" />
<StaticResource x:Key="TextControlPlaceholderForeground" ResourceKey="TextGhost" />
<StaticResource x:Key="TextControlPlaceholderForegroundFocused" ResourceKey="TextGhost" />
<StaticResource x:Key="TextControlPlaceholderForegroundPointerOver" ResourceKey="TextGhost" />
<StaticResource x:Key="ComboBoxBackground" ResourceKey="Field" />
<StaticResource x:Key="ComboBoxBackgroundPointerOver" ResourceKey="Field" />
<StaticResource x:Key="ComboBoxBackgroundPressed" ResourceKey="Hover" />
<StaticResource x:Key="ComboBoxBackgroundUnfocused" ResourceKey="Field" />
<StaticResource x:Key="ComboBoxBackgroundDisabled" ResourceKey="Raised" />
<StaticResource x:Key="ComboBoxBorderBrush" ResourceKey="BorderMid" />
<StaticResource x:Key="ComboBoxBorderBrushPointerOver" ResourceKey="BorderMid" />
<StaticResource x:Key="ComboBoxBorderBrushPressed" ResourceKey="TerminalTabAccent" />
<StaticResource x:Key="ComboBoxBorderBrushDisabled" ResourceKey="Border" />
<StaticResource x:Key="ComboBoxBackgroundBorderBrushFocused" ResourceKey="TerminalTabAccent" />
<StaticResource x:Key="ComboBoxBackgroundBorderBrushUnfocused" ResourceKey="BorderMid" />
<StaticResource x:Key="ComboBoxForeground" ResourceKey="Text" />
<StaticResource x:Key="ComboBoxForegroundFocused" ResourceKey="Text" />
<StaticResource x:Key="ComboBoxForegroundFocusedPressed" ResourceKey="Text" />
<StaticResource x:Key="ComboBoxDropDownGlyphForeground" ResourceKey="TextFaint" />
<StaticResource x:Key="ComboBoxDropDownGlyphForegroundFocused" ResourceKey="TextFaint" />
<StaticResource x:Key="ComboBoxDropDownGlyphForegroundFocusedPressed" ResourceKey="TextFaint" />
<StaticResource x:Key="ComboBoxDropDownBackground" ResourceKey="Chip" />
<StaticResource x:Key="ComboBoxDropDownBorderBrush" ResourceKey="BorderHover" />
<StaticResource x:Key="ComboBoxItemBackgroundPointerOver" ResourceKey="Hover" />
<StaticResource x:Key="ComboBoxItemBackgroundPressed" ResourceKey="Hover" />
<StaticResource x:Key="ComboBoxItemBackgroundSelected" ResourceKey="Active" />
<StaticResource x:Key="ComboBoxItemBackgroundSelectedPointerOver" ResourceKey="Active" />
<StaticResource x:Key="ComboBoxItemBackgroundSelectedPressed" ResourceKey="Active" />
<StaticResource x:Key="ComboBoxItemForegroundSelected" ResourceKey="AccentText" />
<StaticResource x:Key="ComboBoxItemForegroundSelectedPointerOver" ResourceKey="AccentText" />
<StaticResource x:Key="ComboBoxItemForegroundSelectedPressed" ResourceKey="AccentText" />
<StaticResource x:Key="CheckBoxCheckBackgroundFillUnchecked" ResourceKey="Field" />
<StaticResource x:Key="CheckBoxCheckBackgroundFillUncheckedPointerOver" ResourceKey="Field" />
<StaticResource x:Key="CheckBoxCheckBackgroundFillUncheckedPressed" ResourceKey="Hover" />
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUnchecked" ResourceKey="TextGhost" />
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUncheckedPointerOver" ResourceKey="TextGhost" />
<StaticResource x:Key="CheckBoxCheckBackgroundStrokeUncheckedPressed" ResourceKey="TextFaint" />
<StaticResource x:Key="CheckBoxCheckBackgroundFillChecked" ResourceKey="Accent" />
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPointerOver" ResourceKey="Accent" />
<StaticResource x:Key="CheckBoxCheckBackgroundFillCheckedPressed" ResourceKey="Accent" />
<StaticResource x:Key="CheckBoxCheckGlyphForegroundChecked" ResourceKey="AccentInk" />
<StaticResource x:Key="CheckBoxCheckGlyphForegroundCheckedPointerOver" ResourceKey="AccentInk" />
<StaticResource x:Key="CheckBoxCheckGlyphForegroundCheckedPressed" ResourceKey="AccentInk" />
<StaticResource x:Key="CheckBoxForegroundUnchecked" ResourceKey="Text" />
<StaticResource x:Key="CheckBoxForegroundUncheckedPointerOver" ResourceKey="Text" />
<StaticResource x:Key="CheckBoxForegroundUncheckedPressed" ResourceKey="Text" />
<StaticResource x:Key="CheckBoxForegroundChecked" ResourceKey="Text" />
<StaticResource x:Key="CheckBoxForegroundCheckedPointerOver" ResourceKey="Text" />
<StaticResource x:Key="CheckBoxForegroundCheckedPressed" ResourceKey="Text" />
<StaticResource x:Key="ToggleSwitchFillOn" ResourceKey="Accent" />
<StaticResource x:Key="ToggleSwitchFillOnPointerOver" ResourceKey="Accent" />
<StaticResource x:Key="ToggleSwitchFillOnPressed" ResourceKey="Accent" />
<StaticResource x:Key="ToggleSwitchStrokeOn" ResourceKey="Accent" />
<StaticResource x:Key="ToggleSwitchStrokeOnPointerOver" ResourceKey="Accent" />
<StaticResource x:Key="ToggleSwitchStrokeOnPressed" ResourceKey="Accent" />
<StaticResource x:Key="ToggleSwitchKnobFillOn" ResourceKey="AccentInk" />
<StaticResource x:Key="ToggleSwitchKnobFillOnPointerOver" ResourceKey="AccentInk" />
<StaticResource x:Key="ToggleSwitchKnobFillOnPressed" ResourceKey="AccentInk" />
<StaticResource x:Key="ToggleSwitchFillOff" ResourceKey="Hover" />
<StaticResource x:Key="ToggleSwitchFillOffPointerOver" ResourceKey="Hover" />
<StaticResource x:Key="ToggleSwitchFillOffPressed" ResourceKey="Hover" />
<StaticResource x:Key="ToggleSwitchStrokeOff" ResourceKey="TextGhost" />
<StaticResource x:Key="ToggleSwitchStrokeOffPointerOver" ResourceKey="TextGhost" />
<StaticResource x:Key="ToggleSwitchStrokeOffPressed" ResourceKey="TextFaint" />
<StaticResource x:Key="ToggleSwitchKnobFillOff" ResourceKey="TextFaint" />
<StaticResource x:Key="ToggleSwitchKnobFillOffPointerOver" ResourceKey="TextFaint" />
<StaticResource x:Key="ToggleSwitchKnobFillOffPressed" ResourceKey="TextFaint" />
</Styles.Resources>
<!--
Touch has no hover, so a pointer-over fill is one that sticks after a tap until the next touch lands
somewhere else — the grey square the audit found left behind on Vaults' refresh button (M5). The
transparent shapes keep their resting fill under the pointer; pressed is the feedback a finger gets.
-->
<Style Selector="Button.icon:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.row:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.secondary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
</Style>
<!--
◆ A selected row in a list is not lit. The hosts list says why in HostsScreen.axaml — a lit row would be
a mark with no action behind it — but only its own Border.hostrow.chosen was ever changed, and Fluent's
ListBoxItem:selected went on painting #3D486B, a slate blue the palette has never held, under whichever
row had been tapped last (audit M3). Pointer-over goes with it for the reason the button keys above do.
-->
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ListBoxItem:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<!--
The dropdown, on TextBox.field's numbers so the two sit in one form as one kind of box: 44 tall, a
10 radius, Field on BorderMid. The colours come from the keys above; these are the geometry Fluent sets
as plain values.
-->
<Style Selector="ComboBox">
<Setter Property="MinHeight" Value="44" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="14,0,10,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<!--
A checkbox's caption on the box's own line. Fluent tops the content of a CheckBox taller than its box,
so in a 44-pixel row the label sat below the tick's centre (audit M5).
-->
<Style Selector="CheckBox">
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.primary"> <Style Selector="Button.primary">
<Setter Property="Height" Value="48" /> <Setter Property="Height" Value="48" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
@@ -46,6 +196,14 @@
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" /> <Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
<!--
◆ Off, because Fluent's Button theme turns it on, and the glow below is drawn outside the control by
definition. With the clip on, the blur was cut at the button's square bounds and showed as a lighter
rectangle behind the rounded fill on every primary button and the FAB (audit M1). Measured in the
desktop's layout harness: ClipToBounds reads True under the theme, and cleared, the glow renders whole.
The desktop's Button.accent carries the same setter for the same reason.
-->
<Setter Property="ClipToBounds" Value="False" />
</Style> </Style>
<!-- <!--
@@ -86,6 +244,11 @@
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" /> <Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
</Style> </Style>
<!--
The alternative to a primary button, and in a row of the two it is on the LEFT: Android puts the action
that confirms on the right, and every two-button row on this head had it the other way round until the
2026-10-04 redesign (audit M12) — CANCEL | SAVE, KEEP | DELETE, CANCEL | CONNECT.
-->
<Style Selector="Button.secondary"> <Style Selector="Button.secondary">
<Setter Property="Height" Value="48" /> <Setter Property="Height" Value="48" />
<Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="HorizontalAlignment" Value="Stretch" />
@@ -100,6 +263,22 @@
<Setter Property="FontSize" Value="11.5" /> <Setter Property="FontSize" Value="11.5" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<!--
◆ Disabled is quieter than enabled, which Fluent had the wrong way round: with no rule here its #3C3C43
disabled slab filled the outline, so SFTP's unavailable SAVE FILE was the heaviest button in its row
(audit M4). The same flat-and-unlit treatment Button.primary:disabled gets, on an outline.
-->
<Style Selector="Button.secondary /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.secondary:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Button.secondary:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Button.danger"> <Style Selector="Button.danger">
<Setter Property="Height" Value="48" /> <Setter Property="Height" Value="48" />
@@ -324,6 +503,8 @@
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="24" /> <Setter Property="FontSize" Value="24" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
<!-- Off for the glow; see the remark on Button.primary's own setter, which is the same fix. -->
<Setter Property="ClipToBounds" Value="False" />
</Style> </Style>
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on <!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. --> a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
@@ -468,9 +649,9 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" /> <Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <!-- Sans since the 2026-10-04 redesign: a category is a word, "SSH keys 3", rather than an identifier. -->
<Setter Property="FontSize" Value="10.5" /> <Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Medium" /> <Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Template"> <Setter Property="Template">
<ControlTemplate> <ControlTemplate>
<Border Name="Chip" Background="{TemplateBinding Background}" <Border Name="Chip" Background="{TemplateBinding Background}"
@@ -493,11 +674,88 @@
</Style> </Style>
<!-- <!--
No segmented control either. The design draws one over the file listing for LOCAL / REMOTE, and there is ◆ A segmented control, which this file used to argue against having.
no local side on a phone — see FilesScreen. The two-way choices that are real, the log's two logs and
the keychain's categories, are chip rows, which is the same control at the same size without a well The argument was that the one the v2 design drew — LOCAL / REMOTE over the file listing — had nothing to
around it. switch on a phone, and the choices that were real were chip rows. The 2026-10-04 redesign has a choice
that is a segmented control in every sense: the open vault's HOSTS · KEYCHAIN · SNIPPETS, three equal,
exclusive views of one thing, always all three, never scrolled. Chips would make it a filter; a well with
one lit segment says it is where you are.
The well is Panel on BorderSubtle at 14, the lit segment Active at 10 — a step inside it on the ladder,
as a button sits inside a card. Sans and 14, since these are words rather than identifiers.
--> -->
<Style Selector="Border.segmented">
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="4" />
</Style>
<Style Selector="RadioButton.segment">
<Setter Property="MinHeight" Value="40" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}" CornerRadius="{TemplateBinding CornerRadius}"
MinHeight="{TemplateBinding MinHeight}">
<ContentPresenter Content="{TemplateBinding Content}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="RadioButton.segment:checked">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="RadioButton.segment:pressed">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<!--
The eye beside a secret box: outlined like the box it sits beside, lit while it is revealing. Lit with
the accent ring the focused box wears, so "you can read this" is said in the same colour as "you are
typing here".
-->
<Style Selector="ToggleButton.reveal">
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="ToggleButton.reveal /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
</Style>
<Style Selector="ToggleButton.reveal:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<!--
One Material icon: a codepoint of the embedded font the desktop draws its icons with, standing where a
Unicode glyph stood (audit M7). Hidden from the accessibility tree — the codepoint reads as nothing, and the
control around it carries the name — and it inherits its colour, so an icon in a button is the button's.
-->
<Style Selector="TextBlock.icon">
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
</Style>
<Style Selector="TextBox.field"> <Style Selector="TextBox.field">
<Setter Property="Height" Value="44" /> <Setter Property="Height" Value="44" />
@@ -585,6 +843,30 @@
<Setter Property="TextInputOptions.ContentType" Value="Password" /> <Setter Property="TextInputOptions.ContentType" Value="Password" />
</Style> </Style>
<!--
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
on the password line and sets nothing in common with.
-->
<Style Selector="TextBox.prompt">
<Setter Property="MinHeight" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
TextBox.prompt:focus /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<!-- <!--
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot 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 — drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
@@ -657,25 +939,45 @@
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
that was always meant and was previously being had by accident. that was always meant and was previously being had by accident.
--> -->
<!--
◆ PROSE IS IN THE SANS FACE NOW, AND THAT IS THE RECORDED DECISION CATCHING UP RATHER THAN A NEW ONE.
docs/design-import-gaps.md ("The phone catches up") says the phone shares the desktop's faces, and on
the desktop a hint, a caption and a settings title are Montserrat while the mono is kept for what is
machine-shaped. These three still set MonoFont, so every paragraph on this head was JetBrains Mono —
long, wide-set lines that the 2026-10-02 audit (M2) found tiring to read on the enrolment, settings and
SFTP screens. They now take the application's default family, which is Montserrat (see
DodoSshAndroidApplication.CustomizeAppBuilder).
One size step up with the change: Montserrat at the old mono sizes set noticeably smaller on the line,
and 10-pixel captions were already at the floor. A block that shows an address, a path or a command
rather than a sentence says Classes="detail code" at its own site — see TextBlock.detail.code below — so
the split is still made where the content is known.
-->
<Style Selector="TextBlock.detail"> <Style Selector="TextBlock.detail">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" /> <Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="10" /> <Setter Property="FontSize" Value="11.5" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
</Style> </Style>
<Style Selector="TextBlock.body"> <Style Selector="TextBlock.body">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="11" /> <Setter Property="FontSize" Value="12.5" />
<Setter Property="LineHeight" Value="19" /> <Setter Property="LineHeight" Value="19" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
</Style> </Style>
<Style Selector="TextBlock.title"> <Style Selector="TextBlock.title">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource Text}" /> <Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="15" /> <Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<!--
The other half of that split: a detail line that is an address, a path, a fingerprint fragment or a
command. Combined with .detail rather than replacing it, so wrapping and colour stay the caption's.
-->
<Style Selector="TextBlock.detail.code">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
</Style>
<!-- <!--
A screen's own name, in the header. The one place this design is not monospaced: the design sets every A screen's own name, in the header. The one place this design is not monospaced: the design sets every
@@ -0,0 +1,98 @@
<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.ConnectPrompt"
x:DataType="vm:MainWindowViewModel">
<!--
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
THREE THINGS A PHONE CHANGES.
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
and the system back gesture, which PhoneShell routes to the same command.
A CONNECT button, because the software keyboard's own return key may commit or may only close the
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
for every keyboard that does send it.
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
-->
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="12" Padding="14,12">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
<!-- ============ what to connect to ============ -->
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
AutomationProperties.Name="Connect to"
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
</TextBox.KeyBindings>
</TextBox>
</Grid>
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- ============ the password, when one is needed ============ -->
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
Text="{Binding PromptPasswordLabel}" />
<!--
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
-->
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
Text="{Binding PromptPassword}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
</TextBox.KeyBindings>
</TextBox>
</StackPanel>
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
<!--
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
-->
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
Command="{Binding SubmitPromptCommand}" />
</Grid>
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,45 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
namespace DodoSSH.Client.Android.Views;
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
/// <remarks>
/// <para>
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
/// type nothing and look as though it should.
/// </para>
/// <para>
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
/// </para>
/// <para>
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
/// fields are null at run time.
/// </para>
/// </remarks>
internal sealed partial class ConnectPrompt : UserControl
{
public ConnectPrompt()
{
AvaloniaXamlLoader.Load(this);
var line = this.FindControl<StackPanel>("PasswordLine")!;
var box = this.FindControl<TextBox>("Password")!;
line.PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && line.IsVisible)
{
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
// its line becomes visible, and focusing a control with none raises no keyboard.
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
}
};
}
}
@@ -56,16 +56,19 @@
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
<!-- ============ header ============ --> <!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto" Height="56" Margin="8,0"
IsVisible="{Binding !HasRemoteBatch}">
<!-- <!--
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at ◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide. somewhere the layout has removed. See PhoneShell.IsWide.
--> -->
<Button Grid.Column="0" Classes="icon" Content="←" <Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Back"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}" IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}" Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}">
<TextBlock Classes="icon" Text="&#xE5C4;" FontSize="22" />
</Button>
<TextBlock Grid.Column="1" Classes="heading" Text="SFTP" Margin="4,0" <TextBlock Grid.Column="1" Classes="heading" Text="SFTP" Margin="4,0"
IsVisible="{Binding ShowsHostPicker}" /> IsVisible="{Binding ShowsHostPicker}" />
@@ -78,10 +81,59 @@
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" /> <TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
</Border> </Border>
<Button Grid.Column="3" Classes="icon" Content="↻" Command="{Binding RefreshRemoteCommand}" <!--
IsVisible="{Binding IsConnected}" /> ◆ The other half of this host: a terminal on the machine whose files are showing. The redesign puts
the two side by side as one session's two views; this application holds them as two connections,
so what this does is the desktop's "Open terminal" — a new shell to the same host, through the same
command the session sidebar uses. A bucket has no shell, so it is SFTP only.
-->
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="Open a terminal on this host"
IsVisible="{Binding IsHostConnected}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).OpenTerminalForFilesHostCommand}">
<TextBlock Classes="icon" Text="&#xEB8E;" FontSize="22" />
</Button>
<Button Grid.Column="4" Classes="icon" AutomationProperties.Name="Refresh"
Command="{Binding RefreshRemoteCommand}" IsVisible="{Binding IsConnected}">
<TextBlock Classes="icon" Text="&#xE627;" FontSize="22" />
</Button>
<!-- CLOSE was a button of its own in the block at the foot of the screen; it is the session's, so it
is on the session's header. -->
<Button Grid.Column="5" Classes="icon" AutomationProperties.Name="Close this session"
Command="{Binding DisconnectCommand}" IsVisible="{Binding IsConnected}">
<TextBlock Classes="icon" Text="&#xE5CD;" FontSize="22" />
</Button>
</Grid> </Grid>
<!--
◆ THE SELECTION BAR, in the header's row and in its place, as the hosts screen's action bar is.
The 2026-10-04 redesign's answer to the audit's M11, where seven buttons sat under the listing whether
or not anything was chosen, and four of them answered a tap on nothing with a line in the status. Now
the actions appear when there is something to act on: a tap on a file, or a tick. SAVE is on the bar
because it is the one somebody reaches for; the four that change a file are behind the ⋮, in a sheet
with room to say what each does. The ✕ lets go of the choice.
-->
<Border Grid.Row="0" Height="56" Background="{StaticResource Active}" Padding="4,0"
IsVisible="{Binding HasRemoteBatch}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Clear the selection"
Command="{Binding ClearRemoteBatchCommand}">
<TextBlock Classes="icon" Text="&#xE5CD;" FontSize="22" Foreground="{StaticResource Text}" />
</Button>
<TextBlock Grid.Column="1" Margin="6,0" FontSize="16" FontWeight="Bold" VerticalAlignment="Center"
Foreground="{StaticResource Text}" Text="{Binding RemoteBatchLabel}"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="icon" AutomationProperties.Name="Save to this phone"
IsEnabled="{Binding CanDownload}" Click="OnSaveFile">
<TextBlock Classes="icon" Text="&#xE2C4;" FontSize="22" Foreground="{StaticResource Text}" />
</Button>
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="More actions"
Command="{Binding OpenEntryMenuCommand}">
<TextBlock Classes="icon" Text="&#xE5D4;" FontSize="22" Foreground="{StaticResource Text}" />
</Button>
</Grid>
</Border>
<!-- ============ opening one ============ --> <!-- ============ opening one ============ -->
<!-- <!--
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
@@ -164,9 +216,9 @@
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. --> <!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" /> <SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="TRUST AND OPEN" <Button Grid.Column="2" Classes="primary" Height="44" Content="TRUST AND OPEN"
Command="{Binding TrustHostKeyCommand}" /> Command="{Binding TrustHostKeyCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding RejectHostKeyCommand}" /> Command="{Binding RejectHostKeyCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -225,12 +277,12 @@
</Grid> </Grid>
<!-- ============ where we are ============ --> <!-- ============ where we are ============ -->
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" <ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4"> IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10" <Button Classes="row" MinHeight="40" Padding="9,0" CornerRadius="10"
Command="{Binding RemoteUpCommand}"> AutomationProperties.Name="Up one folder" Command="{Binding RemoteUpCommand}">
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" /> <TextBlock Classes="icon" Text="&#xE5D8;" FontSize="18" Foreground="{StaticResource AccentText}" />
</Button> </Button>
<ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center"> <ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
@@ -241,7 +293,7 @@
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10" <Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}" Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding Path}"> CommandParameter="{Binding Path}">
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" /> <TextBlock Classes="detail code" FontSize="11" Text="{Binding Name}" />
</Button> </Button>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -265,7 +317,7 @@
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
"this host has nothing pinned". "this host has nothing pinned".
--> -->
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" <ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4"> IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}"> <ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
@@ -317,6 +369,8 @@
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" /> <Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="10" /> <Setter Property="CornerRadius" Value="10" />
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Name}" />
</Style> </Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter"> <Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" /> <Setter Property="Background" Value="{StaticResource Active}" />
@@ -339,25 +393,33 @@
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center" <CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" /> IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center" <!--
VerticalAlignment="Center" FontSize="12" Text="▸" ◆ A folder looks like a folder now. It was a 12-pixel purple ▸, and a file a dot, which on a
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" /> listing of names that are all set in the same face left a directory to be told apart by a
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center" glyph a thumb covers (audit M11). The tiles are the redesign's: the accent's wash for a place
VerticalAlignment="Center" FontSize="12" Text="·" to go, a quieter one for a thing to take.
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" /> -->
<Border Grid.Column="1" Width="38" Height="38" CornerRadius="10"
Background="{StaticResource AccentSoft}" IsVisible="{Binding IsNavigable}">
<TextBlock Classes="icon" Text="&#xE2C7;" FontSize="20" Foreground="{StaticResource AccentText}" />
</Border>
<Border Grid.Column="1" Width="38" Height="38" CornerRadius="10"
Background="{StaticResource Hover}" IsVisible="{Binding IsFile}">
<TextBlock Classes="icon" Text="&#xE873;" FontSize="20" Foreground="{StaticResource TextFaint}" />
</Border>
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Margin="12,0,0,0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}" <TextBlock Classes="mono" FontSize="13.5" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7"> <StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="detail" Text="{Binding Size}" <TextBlock Classes="detail code" Text="{Binding Size}"
IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Real POSIX bits from the server, not a plausible-looking mode. --> <!-- Real POSIX bits from the server, not a plausible-looking mode. -->
<TextBlock Classes="detail" Text="{Binding Permissions}" /> <TextBlock Classes="detail code" Text="{Binding Permissions}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}" <TextBlock Grid.Column="3" Classes="detail code" Text="{Binding Modified}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </Grid>
</Border> </Border>
@@ -410,7 +472,7 @@
<!-- ============ what to do with the chosen entry ============ --> <!-- ============ what to do with the chosen entry ============ -->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. --> <!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}" <Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10"> BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,8">
<StackPanel Spacing="9"> <StackPanel Spacing="9">
<!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. --> <!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. -->
@@ -423,9 +485,9 @@
<TextBlock Classes="body" Foreground="{StaticResource DangerText}" <TextBlock Classes="body" Foreground="{StaticResource DangerText}"
Text="{Binding PendingRemoteDeletion.Consequence}" /> Text="{Binding PendingRemoteDeletion.Consequence}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE" <Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding ConfirmDeleteRemoteCommand}" /> Command="{Binding ConfirmDeleteRemoteCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" <Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP"
Command="{Binding CancelDeleteRemoteCommand}" /> Command="{Binding CancelDeleteRemoteCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -443,7 +505,7 @@
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" <TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteMove.Question}" /> Text="{Binding PendingRemoteMove.Question}" />
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" /> <TextBlock Classes="detail code" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}" <TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
PlaceholderText="{Binding PendingRemoteMove.FieldHint}"> PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
<TextBox.KeyBindings> <TextBox.KeyBindings>
@@ -452,10 +514,10 @@
</TextBox> </TextBox>
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" /> <TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" <Button Grid.Column="2" Classes="primary" Height="44"
Content="{Binding PendingRemoteMove.ConfirmLabel}" Content="{Binding PendingRemoteMove.ConfirmLabel}"
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveRemoteCommand}" /> Command="{Binding CancelMoveRemoteCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -521,10 +583,10 @@
Text="{Binding PendingRemotePermissions.Preview}" /> Text="{Binding PendingRemotePermissions.Preview}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY" <Button Grid.Column="2" Classes="primary" Height="44" Content="APPLY"
Command="{Binding ConfirmRemotePermissionsCommand}" Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" /> IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelRemotePermissionsCommand}" /> Command="{Binding CancelRemotePermissionsCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -579,54 +641,13 @@
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" /> IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
<!-- <!--
◆ The two directions, on their own row above the pair below — because DELETE is the button on this ◆ The seven buttons that were here are gone, and every one of them went somewhere it is wanted:
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses ADD FILES is the floating button over the listing, SAVE FILE is on the selection bar, RENAME, MOVE,
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this PERMS and DELETE are in the sheet behind its ⋮, and CLOSE is on the header. The paragraph explaining
screen exists for on a phone. the first two went with them, to the sheet's own SAVE row, where it is read at the moment it is true.
What is left down here is what is genuinely about the session as a whole: the questions above, the
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the queue, and the status.
save picker names one destination. CanDownload is the desktop's own flag for the same question —
a file is selected and something is connected — and is reused rather than restated here.
--> -->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
IsEnabled="{Binding CanDownload}" />
</Grid>
<!--
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
from it. Both open a form in place of these buttons — see the card above — because a phone has
nowhere to put a text field except where something else already was.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
</Grid>
<!--
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
access policy rather than nine bits — see CanChangeRemotePermissions.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
</Grid>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
Command="{Binding DisconnectCommand}" />
<Panel Grid.Column="2" />
</Grid>
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap" <TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
Text="{Binding Status}" /> Text="{Binding Status}" />
@@ -634,6 +655,105 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- ============ add ============ -->
<!--
◆ ADD FILES as the floating button, the one shape every add on this head takes. In the listing's row
so it rides with the list rather than over the questions and the queue under it. Not while something
is chosen: the bar across the top is then about that, and an add beside it would be a second subject.
-->
<Button Grid.Row="6" Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
AutomationProperties.Name="Add files from this phone" Click="OnAddFiles">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="IsConnected" />
<Binding Path="!HasRemoteBatch" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Classes="icon" Text="&#xF09B;" FontSize="24" />
</Button>
<!-- ============ ◆ what can be done to the chosen rows ============ -->
<!--
The add sheet's shape on the hosts screen, doing the files screen's job: a scrim, a bottom panel with
its top corners rounded, dismissible because "which of these" has no wrong answer. DELETE is under a rule
and in red — it was the one neutral DELETE on this head (audit M11) — and each entry is dark rather than
gone when it cannot be done, so the four keep their places under a thumb.
Each entry also lowers the sheet as it runs: three of the four open a question in the space under the
listing, and the sheet left over it would be the one thing in the way of answering.
-->
<Panel Grid.Row="0" Grid.RowSpan="9" IsVisible="{Binding IsEntryMenuOpen}">
<Button Classes="scrim" Command="{Binding CloseEntryMenuCommand}" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="12,10,12,16">
<StackPanel>
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,12" />
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold" Margin="12,0,12,8"
Text="{Binding RemoteBatchLabel}" TextTrimming="CharacterEllipsis" />
<Button Classes="row" IsEnabled="{Binding CanDownload}" Click="OnSaveFile"
AutomationProperties.Name="Save to this phone">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE2C4;" FontSize="22" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Save to this phone" />
<TextBlock Classes="detail" Text="Asks where first. The file is made there when you choose, so a transfer that fails leaves it empty." />
</StackPanel>
</Grid>
</Button>
<Button Classes="row" Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}"
Click="OnEntryMenuChosen" AutomationProperties.Name="Rename">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE9A2;" FontSize="22" />
<TextBlock Grid.Column="2" FontSize="14.5" FontWeight="SemiBold" Text="Rename"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}"
Click="OnEntryMenuChosen" AutomationProperties.Name="Move">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE675;" FontSize="22" />
<TextBlock Grid.Column="2" FontSize="14.5" FontWeight="SemiBold" Text="Move"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}" Click="OnEntryMenuChosen"
AutomationProperties.Name="Permissions">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE32A;" FontSize="22" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Permissions" />
<TextBlock Classes="detail" Text="Read, write and execute for the owner, the group and everybody else." />
</StackPanel>
</Grid>
</Button>
<Border Height="1" Background="{StaticResource Border}" Margin="12,6" />
<Button Classes="row" Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}"
Click="OnEntryMenuChosen" AutomationProperties.Name="Delete">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE872;" FontSize="22"
Foreground="{StaticResource DangerText}" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource DangerText}"
Text="Delete" />
<TextBlock Classes="detail" Text="Asks first, and says how many. There is no undo." />
</StackPanel>
</Grid>
</Button>
</StackPanel>
</Border>
</Panel>
</Grid> </Grid>
</UserControl> </UserControl>
@@ -41,6 +41,19 @@ internal sealed partial class FilesScreen : UserControl
/// rather than of any row, and each listing replaces every row it has. See /// rather than of any row, and each listing replaces every row it has. See
/// <c>TransfersViewModel.RemoteChecksChanged</c>. /// <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks> /// </remarks>
/// <summary>An entry in the actions sheet was chosen: the sheet goes down as its command runs.</summary>
/// <remarks>
/// Three of the four open a question under the listing, and the sheet left over it would be the one thing
/// in the way of answering. The command is bound in the markup; this only lowers the sheet.
/// </remarks>
private void OnEntryMenuChosen(object? sender, RoutedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
{
transfers.IsEntryMenuOpen = false;
}
}
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
(DataContext as TransfersViewModel)?.RemoteChecksChanged(); (DataContext as TransfersViewModel)?.RemoteChecksChanged();
@@ -116,6 +129,11 @@ internal sealed partial class FilesScreen : UserControl
return; return;
} }
// From the actions sheet as well as the bar, and the sheet goes down either way: the save picker is
// about to cover the screen, and coming back to the sheet still up would be coming back to a question
// already answered.
transfers.IsEntryMenuOpen = false;
if (transfers.SelectedRemoteEntry is not { IsFile: true } row) if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
{ {
transfers.Status = "Choose a file on the host to save."; transfers.Status = "Choose a file on the host to save.";
@@ -67,21 +67,57 @@
held as ids rather than as rows, see VaultViewModel.chosenHostIds — so searching for the second half held as ids rather than as rows, see VaultViewModel.chosenHostIds — so searching for the second half
of a selection and ticking it is a thing this screen supports rather than merely survives. of a selection and ticking it is a thing this screen supports rather than merely survives.
--> -->
<Border Grid.Row="0" Margin="14,10,14,4" Background="{StaticResource Field}" <Border Grid.Row="0" Margin="16,10,16,4" Background="{StaticResource Field}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="10" Height="44"> BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Height="46">
<Grid ColumnDefinitions="Auto,*"> <Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="⌕" Foreground="{StaticResource TextFaint}" FontSize="13" <TextBlock Grid.Column="0" Classes="icon" Text="&#xE8B6;" Foreground="{StaticResource TextFaint}"
Margin="13,0,0,0" VerticalAlignment="Center" /> FontSize="20" Margin="12,0,0,0" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts" <TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
Padding="9,0" VerticalContentAlignment="Center" Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="12" /> Foreground="{StaticResource Text}" FontSize="14" />
</Grid> </Grid>
</Border> </Border>
<!-- ============ sync + conflicts ============ --> <!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7"> <StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
<!--
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
Above the list rather than over it, like the panels below: the hosts that also match what was typed
stay in view, and one of them may be the same machine with a key already bound to it.
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
what keeps the password out of the software keyboard's dictionary, not only off the screen.
-->
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<StackPanel Spacing="9">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
VerticalAlignment="Center" />
</Grid>
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Height="44" Content="CONNECT"
Command="{Binding ConnectToFilterAddressCommand}" />
</StackPanel>
</Border>
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" /> <TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
<!-- <!--
@@ -181,11 +217,11 @@
Text="{Binding ChosenBindingToBringNote}" /> Text="{Binding ChosenBindingToBringNote}" />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" <Button Grid.Column="2" Classes="primary" Height="44"
Content="{Binding ChosenHostVaultPanelVerb}" Content="{Binding ChosenHostVaultPanelVerb}"
Command="{Binding ConfirmSendChosenHostsToAVaultCommand}" Command="{Binding ConfirmSendChosenHostsToAVaultCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelSendChosenHostsToAVaultCommand}" /> Command="{Binding CancelSendChosenHostsToAVaultCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -224,9 +260,9 @@
Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." /> Text="A group lends its port, its account and its key to every host filed under it that says nothing itself, so this can change what these machines dial. Nothing else about them moves." />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="FILE" <Button Grid.Column="2" Classes="primary" Height="44" Content="FILE"
Command="{Binding ConfirmRegroupChosenHostsCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmRegroupChosenHostsCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelRegroupChosenHostsCommand}" /> Command="{Binding CancelRegroupChosenHostsCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -250,9 +286,9 @@
Text="{Binding PendingDeletion.Usage}" /> Text="{Binding PendingDeletion.Usage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE" <Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5" <Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
Command="{Binding CancelDeleteCommand}" /> Command="{Binding CancelDeleteCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -291,9 +327,9 @@
</CheckBox> </CheckBox>
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE" <Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmDeleteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5" <Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
Command="{Binding CancelDeleteCommand}" /> Command="{Binding CancelDeleteCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -338,9 +374,9 @@
Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." /> Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE" <Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveGroupCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmMoveGroupCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveGroupCommand}" /> Command="{Binding CancelMoveGroupCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -374,11 +410,21 @@
<Style Selector="ListBoxItem"> <Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" /> <Setter Property="Margin" Value="16,4" />
<Setter Property="CornerRadius" Value="12" /> <Setter Property="CornerRadius" Value="16" />
<!--
A name for TalkBack. Without it the item announced its view model's type name —
"DodoSSH.Client.Shell.ViewModels.HostRowViewModel" (audit H4). Both row kinds in this list carry a
Label, and a reflection binding is what lets one setter serve the two of them.
-->
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Label}" />
</Style> </Style>
<!-- <!--
◆ A host is a card since the 2026-10-04 redesign: Panel on a hairline at the 16 radius, where it was a
transparent row on the canvas. A list of machines is a list of things, each one a target, and a card
says where one ends and the next begins without a rule between them.
◆ The ticked row, filled. On a Border inside the item rather than on the item's own selected state, ◆ The ticked row, filled. On a Border inside the item rather than on the item's own selected state,
because "ticked" is a property of the host and the list has no idea about it — the fill and the because "ticked" is a property of the host and the list has no idea about it — the fill and the
tick at the right-hand end are the same fact drawn twice, which is deliberate: a fill alone is tick at the right-hand end are the same fact drawn twice, which is deliberate: a fill alone is
@@ -386,11 +432,14 @@
360dp row. 360dp row.
--> -->
<Style Selector="Border.hostrow"> <Style Selector="Border.hostrow">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="CornerRadius" Value="12" /> <Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="16" />
</Style> </Style>
<Style Selector="Border.hostrow.chosen"> <Style Selector="Border.hostrow.chosen">
<Setter Property="Background" Value="{StaticResource Active}" /> <Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
</Style> </Style>
</ListBox.Styles> </ListBox.Styles>
@@ -402,7 +451,7 @@
the filter, which is what a heading over a filtered list has to say. the filter, which is what a heading over a filtered list has to say.
--> -->
<DataTemplate DataType="vm:SidebarGroupHeader"> <DataTemplate DataType="vm:SidebarGroupHeader">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="4,14,0,2">
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" /> <TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
<!-- <!--
@@ -419,8 +468,9 @@
</Border> </Border>
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9" <Border Grid.Column="2" Classes="tag outline" VerticalAlignment="Center">
VerticalAlignment="Center" /> <TextBlock Text="{Binding Count}" />
</Border>
<!-- <!--
◆ The phone's only way to do anything to a group. The desktop reaches all three from a menu on ◆ The phone's only way to do anything to a group. The desktop reaches all three from a menu on
@@ -435,25 +485,40 @@
Hidden on the ungrouped heading, which has no group behind it to act on. Hidden on the ungrouped heading, which has no group behind it to act on.
--> -->
<Button Grid.Column="3" Classes="icon" FontSize="15" Content="⋯" Margin="4,0,0,0" <Button Grid.Column="3" Classes="icon" Margin="4,0,0,0"
AutomationProperties.Name="{Binding Label, StringFormat='Group {0}: edit, move or delete'}"
IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}" IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).OpenGroupSheetCommand}" Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).OpenGroupSheetCommand}"
CommandParameter="{Binding}" /> CommandParameter="{Binding}">
<TextBlock Classes="icon" Text="&#xE5D4;" FontSize="20" />
</Button>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
<!-- A host. --> <!-- A host. -->
<DataTemplate DataType="vm:HostRowViewModel"> <DataTemplate DataType="vm:HostRowViewModel">
<Border Classes="hostrow" Classes.chosen="{Binding IsChosen}"> <Border Classes="hostrow" Classes.chosen="{Binding IsChosen}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" MinHeight="56" Margin="14,12"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" MinHeight="48" Margin="12,11">
<!-- 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}" Width="8" Height="8" ◆ The redesign's tile: a terminal mark on the accent's wash, with the live dot on its corner.
VerticalAlignment="Center" /> Green still means one thing only — a terminal is open on this host right now, see the .dot
style — and riding the tile it says which machine without spending a column on it.
-->
<Panel Grid.Column="0" Width="42" Height="42" VerticalAlignment="Center">
<Border CornerRadius="12" Background="{StaticResource AccentSoft}">
<TextBlock Classes="icon" Text="&#xEB8E;" FontSize="21" Foreground="{StaticResource AccentText}" />
</Border>
<Border Width="13" Height="13" CornerRadius="7" Background="{StaticResource Panel}"
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-3,-3"
IsVisible="{Binding IsConnected}">
<Ellipse Classes="dot live" Width="9" Height="9" />
</Border>
</Panel>
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="3" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Margin="14,0,0,0" Spacing="4" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7"> <StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="14" FontWeight="SemiBold" <TextBlock Classes="mono" Text="{Binding Label}" FontSize="15" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}"> <Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" /> <TextBlock Text="{Binding VaultBadge}" />
@@ -464,10 +529,10 @@
</Border> </Border>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}" <TextBlock Classes="detail code" FontSize="11.5" Text="{Binding Address}"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" /> TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="·" /> <TextBlock Classes="detail" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" /> <TextBlock Classes="detail" Text="{Binding Authentication}" />
</StackPanel> </StackPanel>
<!-- <!--
@@ -499,8 +564,8 @@
Background="{StaticResource LiveWash}"> Background="{StaticResource LiveWash}">
<TextBlock Text="live" Foreground="{StaticResource Live}" /> <TextBlock Text="live" Foreground="{StaticResource Live}" />
</Border> </Border>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15" <TextBlock Grid.Column="2" Classes="icon" Text="&#xE5CC;" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" IsVisible="{Binding !IsConnected}" /> FontSize="22" IsVisible="{Binding !IsConnected}" />
<!-- <!--
◆ The tick, in a column of its own at the far end rather than in place of the dot or the ◆ The tick, in a column of its own at the far end rather than in place of the dot or the
@@ -509,9 +574,8 @@
choosing a host hide a fact about it. An Auto column takes no width while the mark is choosing a host hide a fact about it. An Auto column takes no width while the mark is
collapsed, so a list with nothing ticked is laid out exactly as it was. collapsed, so a list with nothing ticked is laid out exactly as it was.
--> -->
<TextBlock Grid.Column="3" Text="✓" Margin="10,0,0,0" FontSize="15" <TextBlock Grid.Column="3" Classes="icon" Text="&#xE86C;" Margin="10,0,0,0" FontSize="22"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" Foreground="{StaticResource AccentText}" IsVisible="{Binding IsChosen}" />
IsVisible="{Binding IsChosen}" />
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
@@ -604,9 +668,9 @@
Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." /> Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE" <Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
Command="{Binding SaveGroupCommand}" /> Command="{Binding SaveGroupCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelGroupEditCommand}" /> Command="{Binding CancelGroupEditCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -628,10 +692,13 @@
twelfth machine while eleven are ticked for deletion is worse, because it does something and the twelfth machine while eleven are ticked for deletion is worse, because it does something and the
something is not what the screen is about. something is not what the screen is about.
--> -->
<Button Grid.Row="2" Classes="fab" Content="+" Margin="0,0,18,18" <Button Grid.Row="2" Classes="fab" Margin="0,0,18,18"
HorizontalAlignment="Right" VerticalAlignment="Bottom" HorizontalAlignment="Right" VerticalAlignment="Bottom"
AutomationProperties.Name="Add a host or a group"
IsVisible="{Binding ShowsAddButton}" IsVisible="{Binding ShowsAddButton}"
Command="{Binding OpenAddSheetCommand}" /> Command="{Binding OpenAddSheetCommand}">
<TextBlock Classes="icon" Text="&#xE145;" FontSize="26" />
</Button>
</Grid> </Grid>
@@ -646,9 +713,10 @@
"Which kind of thing are you adding" has no wrong answer and no answer at all is one of them. "Which kind of thing are you adding" has no wrong answer and no answer at all is one of them.
And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises
it too; nothing but this screen raises this. The cost is that the scrim covers the screen and not the it too; nothing but this screen raises this. The cost used to be that the scrim covered the screen and
64px nav bar underneath, so the four destinations stay lit and tappable — which for a menu is right: not the header or the nav bar, which stayed lit and tappable round every sheet (audit M12). PhoneShell
navigating away is a perfectly good way to decide not to add anything. now dims those two rows as well while any sheet here is up, and a tap on them lowers the sheet — see
PhoneShell.ShowsSheetScrim.
--> -->
<Panel IsVisible="{Binding IsAddSheetOpen}"> <Panel IsVisible="{Binding IsAddSheetOpen}">
@@ -670,7 +738,7 @@
<Button Classes="row" Margin="0,10,0,0" Command="{Binding NewHostCommand}"> <Button Classes="row" Margin="0,10,0,0" Command="{Binding NewHostCommand}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New host" /> <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="New host" />
<TextBlock Classes="detail" FontSize="10.5" <TextBlock Classes="detail" FontSize="10.5"
Text="A machine to connect to, filed under a group if you want one." /> Text="A machine to connect to, filed under a group if you want one." />
</StackPanel> </StackPanel>
@@ -678,7 +746,7 @@
<Button Classes="row" Command="{Binding NewGroupCommand}"> <Button Classes="row" Command="{Binding NewGroupCommand}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New group" /> <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="New group" />
<TextBlock Classes="detail" FontSize="10.5" <TextBlock Classes="detail" FontSize="10.5"
Text="A heading for hosts, and the port, user and key they fall back to." /> Text="A heading for hosts, and the port, user and key they fall back to." />
</StackPanel> </StackPanel>
@@ -742,19 +810,19 @@
<Button Classes="row" IsVisible="{Binding HasOneChosenHost}" <Button Classes="row" IsVisible="{Binding HasOneChosenHost}"
Command="{Binding ConnectToChosenHostCommand}"> Command="{Binding ConnectToChosenHostCommand}">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center" <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Text="Connect" /> Text="Connect" />
</Button> </Button>
<Button Classes="row" IsVisible="{Binding HasOneChosenHost}" <Button Classes="row" IsVisible="{Binding HasOneChosenHost}"
Command="{Binding BrowseChosenHostCommand}"> Command="{Binding BrowseChosenHostCommand}">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center" <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Text="Connect via SFTP" /> Text="Connect via SFTP" />
</Button> </Button>
<Button Classes="row" Command="{Binding MoveChosenHostsToVaultCommand}"> <Button Classes="row" Command="{Binding MoveChosenHostsToVaultCommand}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Move to vault" /> <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Move to vault" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Re-encrypted with the other vault's key. Groups and tags stay behind." /> Text="Re-encrypted with the other vault's key. Groups and tags stay behind." />
</StackPanel> </StackPanel>
@@ -762,19 +830,19 @@
<Button Classes="row" Command="{Binding CopyChosenHostsToVaultCommand}"> <Button Classes="row" Command="{Binding CopyChosenHostsToVaultCommand}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Copy to vault" /> <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Copy to vault" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="A second copy over there, and these stay where they are." /> Text="A second copy over there, and these stay where they are." />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Classes="row" Command="{Binding RegroupChosenHostsCommand}"> <Button Classes="row" Command="{Binding RegroupChosenHostsCommand}">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center" <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Text="Change group" /> Text="Change group" />
</Button> </Button>
<Button Classes="row" Command="{Binding DuplicateChosenHostsCommand}"> <Button Classes="row" Command="{Binding DuplicateChosenHostsCommand}">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" VerticalAlignment="Center" <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Text="Duplicate" /> Text="Duplicate" />
</Button> </Button>
@@ -782,7 +850,7 @@
<Button Classes="row" Command="{Binding DeleteChosenHostsCommand}"> <Button Classes="row" Command="{Binding DeleteChosenHostsCommand}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" <TextBlock FontSize="14.5" FontWeight="SemiBold"
Foreground="{StaticResource Danger}" Text="Remove" /> Foreground="{StaticResource Danger}" Text="Remove" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Asks first, and says how many. There is no undo." /> Text="Asks first, and says how many. There is no undo." />
@@ -816,34 +884,73 @@
the box the next tap reads, and it would satisfy the length check that decides whether to raise this at the box the next tap reads, and it would satisfy the length check that decides whether to raise this at
all. See CancelConnectPassword. all. See CancelConnectPassword.
--> -->
<Panel IsVisible="{Binding IsAskingForConnectPassword}"> <Panel x:Name="PasswordSheet" IsVisible="{Binding IsAskingForConnectPassword}">
<Button Classes="scrim" Command="{Binding CancelConnectPasswordCommand}" /> <Button Classes="scrim" Command="{Binding CancelConnectPasswordCommand}" />
<!--
◆ The 2026-10-04 redesign's connect sheet. Three changes from the audit (M12, and the placeholder-as-
label finding), and one from the redesign itself:
· CANCEL on the left and CONNECT on the right, which is the order Android puts a confirming action
in — the sheet had them the other way round, and so did every two-button row on this head;
· the box has a label over it, so what it is for is still on screen once something is typed;
· it takes the focus as it opens — see HostsScreen.FocusThePasswordBox — so the keyboard is up
without a second tap on the one box the sheet exists for;
· the host is named at the top with its address under it, in the card shape the list draws it in,
and a ✕ beside it closes the sheet as the scrim does.
The eye beside the box reveals what was typed, for the one case a phone keyboard makes common: a
password typed blind on glass is a password typed wrong. It is TextBox.RevealPassword, so the secret
is never copied anywhere to be shown.
-->
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}" <Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0" BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="20,18,20,16"> CornerRadius="22,22,0,0" Padding="20,10,20,18">
<StackPanel Spacing="10"> <StackPanel Spacing="12">
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}" <Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,6" /> HorizontalAlignment="Center" Margin="0,0,0,4" />
<!-- <!--
It names the host, as the bar did and for the same reason: the row it was tapped on is behind a It names the host, as the bar did and for the same reason: the row it was tapped on is behind a
sheet and a scrim, and "which machine is this password for" is the one thing somebody about to sheet and a scrim, and "which machine is this password for" is the one thing somebody about to
type a secret has to be sure of. type a secret has to be sure of.
--> -->
<StackPanel Orientation="Horizontal" Spacing="8"> <Grid ColumnDefinitions="Auto,12,*,Auto">
<TextBlock Classes="label" Text="CONNECT TO" /> <Border Grid.Column="0" Width="42" Height="42" CornerRadius="12"
<TextBlock Classes="mono" FontSize="11" VerticalAlignment="Center" Background="{StaticResource AccentSoft}">
<TextBlock Classes="icon" Text="&#xEB8E;" FontSize="21" Foreground="{StaticResource AccentText}" />
</Border>
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="15" FontWeight="SemiBold"
Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" /> Text="{Binding SelectedHost.Label}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail code" Text="{Binding SelectedHost.Address}"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
<Button Grid.Column="3" Classes="icon" AutomationProperties.Name="Close"
Command="{Binding CancelConnectPasswordCommand}">
<TextBlock Classes="icon" Text="&#xE5CD;" FontSize="22" />
</Button>
</Grid>
<TextBox Classes="field secret" Text="{Binding ConnectPassword}" PlaceholderText="password"> <StackPanel Spacing="6">
<TextBlock Classes="label" Text="PASSWORD" />
<Grid ColumnDefinitions="*,6,Auto">
<TextBox x:Name="PasswordBox" Grid.Column="0" Classes="field secret" Height="48"
Text="{Binding ConnectPassword}" PlaceholderText="the account's password"
AutomationProperties.Name="Password">
<TextBox.KeyBindings> <TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" /> <KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
</TextBox.KeyBindings> </TextBox.KeyBindings>
</TextBox> </TextBox>
<ToggleButton Grid.Column="2" Classes="reveal" Width="48" Height="48"
AutomationProperties.Name="Show the password"
IsChecked="{Binding #PasswordBox.RevealPassword}">
<TextBlock Classes="icon" Text="&#xE8F4;" FontSize="21" />
</ToggleButton>
</Grid>
</StackPanel>
<!-- <!--
The tick is the phone's whole answer to storing a password, and on this head it is the only one: The tick is the phone's whole answer to storing a password, and on this head it is the only one:
@@ -853,15 +960,15 @@
quietly lives. quietly lives.
--> -->
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44"> <CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap" <TextBlock FontSize="13.5" TextWrapping="Wrap" Foreground="{StaticResource Text}"
Text="Remember this password for this host" /> Text="Remember this password for this host" />
</CheckBox> </CheckBox>
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,10,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="CONNECT" <Button Grid.Column="0" Classes="secondary" Height="48" Content="CANCEL"
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelConnectPasswordCommand}" /> Command="{Binding CancelConnectPasswordCommand}" />
<Button Grid.Column="2" Classes="primary" Height="48" Content="CONNECT"
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -912,7 +1019,7 @@
Command="{Binding EditGroupFromHeadingCommand}" Command="{Binding EditGroupFromHeadingCommand}"
CommandParameter="{Binding GroupSheet}"> CommandParameter="{Binding GroupSheet}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Edit" /> <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Edit" />
<TextBlock Classes="detail" FontSize="10.5" <TextBlock Classes="detail" FontSize="10.5"
Text="Its name, where it sits, and the port, user and key its hosts fall back to." /> Text="Its name, where it sits, and the port, user and key its hosts fall back to." />
</StackPanel> </StackPanel>
@@ -922,7 +1029,7 @@
Command="{Binding MoveGroupFromHeadingCommand}" Command="{Binding MoveGroupFromHeadingCommand}"
CommandParameter="{Binding GroupSheet}"> CommandParameter="{Binding GroupSheet}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}"
Text="Move to another vault" /> Text="Move to another vault" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Takes the groups inside it and every host filed under them, re-encrypted with the other vault's key." /> Text="Takes the groups inside it and every host filed under them, re-encrypted with the other vault's key." />
@@ -935,7 +1042,7 @@
Command="{Binding DeleteGroupFromHeadingCommand}" Command="{Binding DeleteGroupFromHeadingCommand}"
CommandParameter="{Binding GroupSheet}"> CommandParameter="{Binding GroupSheet}">
<StackPanel Spacing="3" VerticalAlignment="Center"> <StackPanel Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" <TextBlock FontSize="14.5" FontWeight="SemiBold"
Foreground="{StaticResource Danger}" Text="Delete" /> Foreground="{StaticResource Danger}" Text="Delete" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap" <TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Asks first, and asks separately whether the hosts filed under it go too." /> Text="Asks first, and asks separately whether the hosts filed under it go too." />
@@ -1002,7 +1109,8 @@
FormatString="0" ShowButtonSpinner="False" MinHeight="44" FormatString="0" ShowButtonSpinner="False" MinHeight="44"
PlaceholderText="{Binding EditorPortPlaceholder}" /> PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}" <TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" /> PlaceholderText="{Binding EditorUsernamePlaceholder}"
IsEnabled="{Binding EditorAsksForLogin}" />
</Grid> </Grid>
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" /> <TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
@@ -1016,6 +1124,7 @@
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" /> <TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}" <ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}" SelectedItem="{Binding EditorSelectedAuthentication}"
IsEnabled="{Binding EditorAsksForLogin}"
HorizontalAlignment="Stretch" MinHeight="44"> HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice"> <DataTemplate x:DataType="vm:AuthenticationChoice">
@@ -1028,6 +1137,9 @@
</DataTemplate> </DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
<!-- <!--
Making a credential without leaving the host, as on the desktop and on the same reasoning: the Making a credential without leaving the host, as on the desktop and on the same reasoning: the
@@ -1061,10 +1173,10 @@
<TextBlock Classes="detail" TextWrapping="Wrap" <TextBlock Classes="detail" TextWrapping="Wrap"
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." /> Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44" <Button Grid.Column="2" Classes="primary" Content="ADD" MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding AddEditorCredentialCommand}" /> Command="{Binding AddEditorCredentialCommand}" />
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44" <Button Grid.Column="0" Classes="secondary" Content="CANCEL" MinHeight="44"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding CancelEditorCredentialCommand}" /> Command="{Binding CancelEditorCredentialCommand}" />
</Grid> </Grid>
@@ -1199,19 +1311,50 @@
port into plaintext columns the server can read, which is the single deliberate concession in port into plaintext columns the server can read, which is the single deliberate concession in
the design — see ADR 0004 — so it says so rather than being a switch labelled "relay". the design — see ADR 0004 — so it says so rather than being a switch labelled "relay".
◆ AND IT SAYS, FIRST, THAT IT BUYS NOTHING TODAY. The relay's server half is built and this It also says the two things a user would otherwise find out by failing: the server, not this
client has no path to it — the connect path dials the address directly whether this is ticked or phone, has to be able to reach the address, and a relayed host does not fall back to dialling
not — so the box as it stood spent that concession and delivered nothing for it. Left tickable directly, so it connects only while the server is reachable. See
rather than disabled, so a host already carrying the flag can lose it. See
docs/reaching-a-host-you-cannot-dial.md. docs/reaching-a-host-you-cannot-dial.md.
--> -->
<CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44"> <CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5" <TextBlock Classes="mono" FontSize="11.5"
Text="Reach this host through the server relay (not wired up yet)" Text="Reach this host through the server relay"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</CheckBox> </CheckBox>
<TextBlock Classes="body" <TextBlock Classes="body"
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." /> Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
<!--
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
one unticks the other. No credential is chosen here: the host is reached as whoever this phone signed
in to that Spelunk as, and a button signs in when it has not. See VaultViewModel.Spelunk.cs.
-->
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5"
Text="Reach this host through Spelunk"
TextWrapping="Wrap" />
</CheckBox>
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
PlaceholderText="Spelunk server id" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="{Binding EditorSpelunkSignInNote}"
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<Button Classes="secondary" MinHeight="44" HorizontalAlignment="Stretch"
Content="SIGN IN TO SPELUNK"
Command="{Binding SignInToEditorSpelunkCommand}"
IsVisible="{Binding EditorSpelunkNeedsSignIn}" />
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5"
Text="Sign in through Spelunk (no SSH key needed)"
TextWrapping="Wrap" />
</CheckBox>
</StackPanel>
<TextBlock Classes="body"
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
<!-- <!--
This page covers the whole screen while it is open, which is why it needs a Status line of its This page covers the whole screen while it is open, which is why it needs a Status line of its
@@ -1226,9 +1369,9 @@
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE" <Button Grid.Column="2" Classes="primary" Height="44" Content="SAVE"
Command="{Binding SaveHostCommand}" /> Command="{Binding SaveHostCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelEditCommand}" /> Command="{Binding CancelEditCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Shell.ViewModels;
@@ -62,7 +63,29 @@ internal sealed partial class HostsScreen : UserControl
InputElement.SetIsHoldingEnabled(rows, true); InputElement.SetIsHoldingEnabled(rows, true);
rows.Holding += OnRowHeld; rows.Holding += OnRowHeld;
var sheet = this.FindControl<Panel>("PasswordSheet")!;
var box = this.FindControl<TextBox>("PasswordBox")!;
sheet.PropertyChanged += (_, e) =>
{
if (e.Property == IsVisibleProperty && sheet.IsVisible)
{
FocusThePasswordBox(box);
} }
};
}
/// <summary>
/// Puts the focus, and so the keyboard, in the connect sheet's box as the sheet opens.
/// </summary>
/// <remarks>
/// The sheet exists for that one box, and it used to open with nothing focused — a second tap before
/// typing could start (audit M12). Posted at <c>Loaded</c> priority because the box is not yet laid out
/// on the turn the sheet's visibility changes, and focusing a control with no bounds raises no keyboard.
/// </remarks>
private static void FocusThePasswordBox(TextBox box) =>
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
/// <summary> /// <summary>
/// Connects to the row that was tapped, or ticks it where hosts are already ticked. /// Connects to the row that was tapped, or ticks it where hosts are already ticked.
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.Android.Views" xmlns:views="using:DodoSSH.Client.Android.Views"
xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.Android.Views.KeychainScreen" x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
x:DataType="vm:VaultViewModel" x:DataType="vm:VaultViewModel"
Background="{StaticResource Canvas}"> Background="{StaticResource Canvas}">
@@ -32,38 +33,42 @@
This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not This screen moved behind SETTINGS, so it draws its own header — the one every hub screen has. It is not
a decoration: the shell's header stands down behind the hub, and without a row of its own there would be a decoration: the shell's header stands down behind the hub, and without a row of its own there would be
no back arrow and nothing on screen saying what this list is. no back arrow and nothing on screen saying what this list is.
── 2026-10-04 redesign ───────────────────────────────────────────────────────────────────────────────
And it moved back out, under VAULTS: the keychain is the open vault's contents as much as its hosts are,
so it is the middle of the vault's HOSTS · KEYCHAIN · SNIPPETS switch, and the shell's header is over it
again. The header below is now drawn only where that switch is not.
--> -->
<Panel>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<!-- ============ header ============ --> <!-- ============ header ============ -->
<!-- <!--
The same shape as the one over PREFERENCES in PhoneShell, and pointed at the same place. Back goes to ◆ Wide surfaces only, since the 2026-10-04 redesign. On a narrow one this screen is the KEYCHAIN third of
the hub rather than to HOSTS, because the hub is where this screen is reached from and back that the open vault's switch, under the vault's own header — see PhoneShell.ShowsVaultTabs — and a second row
skipped it would strand the four rows underneath it. saying "Keychain" under a lit KEYCHAIN segment would be the two rows of chrome this head avoids. Its back
arrow went with it: back from here is the system gesture, which goes to the vault's hosts.
A wide surface has no switch — the rail lists the three — so the screen's own name stays there, without
an arrow, for the reason it never had one there: the rail is how you leave.
--> -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0"> <Grid Grid.Row="0" Height="56" Margin="12,0" IsVisible="{Binding $parent[views:PhoneShell].IsWide}">
<!-- <TextBlock Classes="heading" Text="Keychain" Margin="4,0" />
◆ Gone on a wide surface, where the rail is how you leave and the hub it points at is not drawn at
all. An arrow to a screen nothing else can reach would be the one control on this header that leads
somewhere the layout has removed. See PhoneShell.IsWide.
-->
<Button Grid.Column="0" Classes="icon" Content="←"
IsVisible="{Binding !$parent[views:PhoneShell].IsWide}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Keychain" Margin="4,0" />
</Grid> </Grid>
<!-- ============ categories ============ --> <!-- ============ categories ============ -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" <!-- Hidden rather than Auto: the row still scrolls, but Fluent's desktop scrollbar was drawn across the
bottom of the chips themselves (audit M5). -->
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled"
Margin="0,12,0,4"> Margin="0,12,0,4">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0"> <StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}" <RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}"
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}"> Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="ALL" /> <TextBlock Text="All" />
<TextBlock Text="{Binding TotalItemCount}" /> <TextBlock Text="{Binding TotalItemCount}" />
</StackPanel> </StackPanel>
</RadioButton> </RadioButton>
@@ -71,7 +76,7 @@
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}" <RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}"
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}"> Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="SSH KEYS" /> <TextBlock Text="SSH keys" />
<TextBlock Text="{Binding Keys.Count}" /> <TextBlock Text="{Binding Keys.Count}" />
</StackPanel> </StackPanel>
</RadioButton> </RadioButton>
@@ -80,7 +85,7 @@
Command="{Binding ShowSectionCommand}" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}"> CommandParameter="{x:Static vm:VaultSection.Credentials}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="PASSWORDS" /> <TextBlock Text="Passwords" />
<TextBlock Text="{Binding Credentials.Count}" /> <TextBlock Text="{Binding Credentials.Count}" />
</StackPanel> </StackPanel>
</RadioButton> </RadioButton>
@@ -95,7 +100,7 @@
Command="{Binding ShowSectionCommand}" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Tags}"> CommandParameter="{x:Static vm:VaultSection.Tags}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="TAGS" /> <TextBlock Text="Tags" />
<TextBlock Text="{Binding Tags.Count}" /> <TextBlock Text="{Binding Tags.Count}" />
</StackPanel> </StackPanel>
</RadioButton> </RadioButton>
@@ -104,7 +109,7 @@
Command="{Binding ShowSectionCommand}" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}"> CommandParameter="{x:Static vm:VaultSection.Buckets}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="BUCKETS" /> <TextBlock Text="Buckets" />
<TextBlock Text="{Binding ObjectStores.Count}" /> <TextBlock Text="{Binding ObjectStores.Count}" />
</StackPanel> </StackPanel>
</RadioButton> </RadioButton>
@@ -134,35 +139,45 @@
style below, which had nothing to colour until now. DELETE is the last button that should be pressed on style below, which had nothing to colour until now. DELETE is the last button that should be pressed on
a guess about which row it means. a guess about which row it means.
--> -->
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="12,4" <!--
◆ EDIT is here now, beside MOVE and DELETE, because there is something for it to open: the editor page at
the foot of this file, which is the desktop's five editors drawn for a phone. The three sit in a row
under the item's name rather than beside it, which is what fits at 360dp; DELETE keeps the danger
outline and the far end of the row.
COPY PUBLIC KEY under them for a key that has one, because a phone is where somebody is standing at a
machine that needs it in authorized_keys.
-->
<Border Grid.Row="2" IsVisible="{Binding ShowsItemActions}" Margin="16,4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}" Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="12" Padding="14,10"> BorderThickness="1" CornerRadius="14" Padding="14,12">
<Grid ColumnDefinitions="*,10,Auto"> <StackPanel Spacing="10">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="SELECTED" /> <TextBlock Classes="mono" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" /> Text="{Binding SelectedVaultItem.Name}" TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline"> <Border Classes="tag outline">
<TextBlock Text="{Binding SelectedVaultItem.Type}" /> <TextBlock Text="{Binding SelectedVaultItem.Type}" />
</Border> </Border>
</StackPanel> </StackPanel>
</StackPanel>
<Grid ColumnDefinitions="*,8,*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditSelectedItemCommand}" />
<!-- <!--
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on MOVE only where there is somewhere to move to — the rule the host's MOVE follows on this head, for
this head, for the reason a phone has: there is no room to draw a button that answers with a the reason a phone has: there is no room to draw a button that answers with a refusal.
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
moving it back.
--> -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8"> <Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
IsVisible="{Binding CanMoveSelectedItem}" IsVisible="{Binding CanMoveSelectedItem}"
Command="{Binding MoveSelectedItemCommand}" /> Command="{Binding MoveSelectedItemCommand}" />
<Button Classes="danger" Height="44" Width="104" Content="DELETE" <Button Grid.Column="4" Classes="danger" Height="44" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" /> Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
</Grid> </Grid>
<Button Classes="secondary" Height="44" Content="COPY PUBLIC KEY"
Command="{Binding CopyPublicKeyCommand}"
IsVisible="{Binding SelectedKey.Key.PublicKey, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
</StackPanel>
</Border> </Border>
<!-- ============ ◆ the counted confirmation ============ --> <!-- ============ ◆ the counted confirmation ============ -->
@@ -183,9 +198,9 @@
IsVisible="{Binding PendingDeletion.HasUsage}" IsVisible="{Binding PendingDeletion.HasUsage}"
Text="{Binding PendingDeletion.Usage}" /> Text="{Binding PendingDeletion.Usage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="danger" Content="DELETE" <Button Grid.Column="2" Classes="danger" Content="DELETE"
Command="{Binding ConfirmDeleteCommand}" /> Command="{Binding ConfirmDeleteCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5" <Button Grid.Column="0" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
Command="{Binding CancelDeleteCommand}" /> Command="{Binding CancelDeleteCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -224,9 +239,9 @@
IsVisible="{Binding HasMovingItemUsage}" IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" /> Text="{Binding MovingItemUsage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE" <Button Grid.Column="2" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL" <Button Grid.Column="0" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveItemCommand}" /> Command="{Binding CancelMoveItemCommand}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -239,12 +254,15 @@
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20" <TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" /> VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" />
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}" <!-- The foot padding is the floating button's: the last card scrolls clear of it. -->
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}" Padding="0,0,0,88"
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}"> Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}">
<ListBox.Styles> <ListBox.Styles>
<Style Selector="ListBoxItem"> <Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" /> <Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<!-- A name for TalkBack rather than the row view model's type name (audit H4). -->
<Setter Property="AutomationProperties.Name" Value="{ReflectionBinding Name}" />
</Style> </Style>
<!-- <!--
The fill goes on the card and not on the ContentPresenter behind it, which is where HOSTS and The fill goes on the card and not on the ContentPresenter behind it, which is where HOSTS and
@@ -255,28 +273,28 @@
--> -->
<Style Selector="ListBoxItem:selected Border.card"> <Style Selector="ListBoxItem:selected Border.card">
<Setter Property="Background" Value="{StaticResource Active}" /> <Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
</Style> </Style>
</ListBox.Styles> </ListBox.Styles>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel"> <DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Border Classes="card" Margin="12,4"> <Border Classes="card" Margin="16,4" CornerRadius="16" Background="{StaticResource Panel}">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="64"> <Grid ColumnDefinitions="Auto,*,Auto" MinHeight="56">
<!-- <!--
The glyph tile. One mark for all three kinds rather than three: a key, a password and a The glyph tile. One mark for all three kinds rather than three: a key, a password and a
bucket are already named by the badge beside them, and three near-identical monochrome bucket are already named by the badge beside them, and three near-identical monochrome
glyphs at 14px would be decoration that reads as a distinction. glyphs at 14px would be decoration that reads as a distinction.
--> -->
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="10" <Border Grid.Column="0" Width="40" Height="40" CornerRadius="12"
Background="{StaticResource Active}" VerticalAlignment="Center"> Background="{StaticResource AccentSoft}" VerticalAlignment="Center">
<TextBlock Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14" <TextBlock Classes="icon" Text="{Binding IconGlyph}" FontSize="20"
FontWeight="SemiBold" HorizontalAlignment="Center" Foreground="{StaticResource AccentText}" />
VerticalAlignment="Center" />
</Border> </Border>
<StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7"> <StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}" <TextBlock Classes="mono" FontSize="14.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline"> <Border Classes="tag outline">
<TextBlock Text="{Binding Type}" /> <TextBlock Text="{Binding Type}" />
@@ -287,18 +305,310 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- What is known *about* an item, never the secret. --> <!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" /> <TextBlock Classes="detail code" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15" <TextBlock Grid.Column="2" Classes="icon" Text="&#xE5CC;" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" /> FontSize="22" />
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
<!-- ============ add ============ -->
<!--
◆ The floating +, which this screen never had: its empty state asked for an SSH key or a password over
a screen with no way to add either (audit M9). It raises the add sheet below, because there are five
kinds of thing to add here and one button.
-->
<Button Classes="fab" Margin="0,0,18,18" HorizontalAlignment="Right" VerticalAlignment="Bottom"
AutomationProperties.Name="Add to the keychain" Command="{Binding OpenKeychainAddSheetCommand}">
<TextBlock Classes="icon" Text="&#xE145;" FontSize="26" />
</Button>
</Panel> </Panel>
</Grid> </Grid>
<!-- ============ the add sheet ============ -->
<!--
The desktop's "+ New key" menu, as the hosts screen's add sheet: a scrim, a bottom panel, dismissible
because "which kind" has no wrong answer. Each row opens its editor and lowers the sheet as it does —
see KeychainScreen.OnAddChosen. Generating comes first, as it does on the desktop: on a phone it is
the way to a key that never existed as a file anywhere.
-->
<Panel IsVisible="{Binding IsKeychainAddSheetOpen}">
<Button Classes="scrim" Command="{Binding CloseKeychainAddSheetCommand}" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="12,10,12,16">
<StackPanel>
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,12" />
<TextBlock Classes="label" Text="ADD TO THE KEYCHAIN" Margin="12,0,12,6" />
<Button Classes="row" Command="{Binding NewGeneratedKeyCommand}" Click="OnAddChosen"
AutomationProperties.Name="Generate a key">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xEA0B;" FontSize="22"
Foreground="{StaticResource AccentText}" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Generate a key" />
<TextBlock Classes="detail" Text="A new key pair made here, so the private half never becomes a file." />
</StackPanel>
</Grid>
</Button>
<Button Classes="row" Command="{Binding NewKeyCommand}" Click="OnAddChosen"
AutomationProperties.Name="Add an SSH key">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE73C;" FontSize="22"
Foreground="{StaticResource AccentText}" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add an SSH key" />
<TextBlock Classes="detail" Text="Paste in a key you already have." />
</StackPanel>
</Grid>
</Button>
<Button Classes="row" Command="{Binding NewCredentialCommand}" Click="OnAddChosen"
AutomationProperties.Name="Add a password">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xF042;" FontSize="22"
Foreground="{StaticResource AccentText}" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a password" />
<TextBlock Classes="detail" Text="One account, described once, for as many hosts as use it." />
</StackPanel>
</Grid>
</Button>
<Button Classes="row" Command="{Binding NewObjectStoreCommand}" Click="OnAddChosen"
AutomationProperties.Name="Add a bucket">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE2BD;" FontSize="22"
Foreground="{StaticResource AccentText}" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a bucket" />
<TextBlock Classes="detail" Text="An S3-compatible bucket, to browse under Settings, Files." />
</StackPanel>
</Grid>
</Button>
<Button Classes="row" Command="{Binding NewTagCommand}" Click="OnAddChosen"
AutomationProperties.Name="Add a tag">
<Grid ColumnDefinitions="Auto,14,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xF05B;" FontSize="22"
Foreground="{StaticResource AccentText}" />
<StackPanel Grid.Column="2" Spacing="2">
<TextBlock FontSize="14.5" FontWeight="SemiBold" Text="Add a tag" />
<TextBlock Classes="detail" Text="A name to put on hosts, before there is anything to put it on." />
</StackPanel>
</Grid>
</Button>
</StackPanel>
</Border>
</Panel>
<!-- ============ ◆ the editor page ============ -->
<!--
The desktop's five keychain editors, as one page over the whole screen — the host editor's shape on this
head, and PhoneShell stands its chrome down for it the same way (see PhoneShell.RefreshChrome). Only one
of the five is ever open; each draws its own section and its own CANCEL and SAVE, and the ← in the
header cancels whichever it is, as the system back gesture does.
Every label is a label above its box rather than a placeholder in it, which the audit asked of the
host editor too: on a phone the box is full of what was typed long before anybody is done reading it.
-->
<Grid IsVisible="{Binding IsEditingAVaultItem}" RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
<Border Grid.Row="0" Height="56" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*" Margin="8,0">
<Button Grid.Column="0" Classes="icon" AutomationProperties.Name="Cancel" Click="OnCancelEditor">
<TextBlock Classes="icon" Text="&#xE5C4;" FontSize="22" />
</Button>
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Generate a key"
IsVisible="{Binding IsGeneratingKey}" />
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="SSH key"
IsVisible="{Binding IsEditingKey}" />
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Password"
IsVisible="{Binding IsEditingCredential}" />
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Bucket"
IsVisible="{Binding IsEditingObjectStore}" />
<TextBlock Grid.Column="1" Classes="heading" Margin="4,0" Text="Tag"
IsVisible="{Binding IsEditingTag}" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel Margin="16,14,16,24" Spacing="8">
<!--
Which vault a new item is filed into, hidden at one vault, which is where most people stay. The
desktop's own "NEW ITEMS FILE TO", and the sentence under it for the reason that one has it.
-->
<StackPanel Spacing="6" IsVisible="{Binding HasVaultChoice}" Margin="0,0,0,8">
<TextBlock Classes="label" Text="NEW ITEMS FILE TO" />
<ComboBox ItemsSource="{Binding TargetVaults}" SelectedItem="{Binding SelectedTargetVault}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="detail" Text="An item filed into a shared vault is readable by everyone holding that vault's key." />
</StackPanel>
<!-- ============ generating ============ -->
<StackPanel Spacing="8" IsVisible="{Binding IsGeneratingKey}">
<TextBlock Classes="label" Text="KIND" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="chiptoggle" Classes.worn="{Binding GeneratesEd25519}" MinHeight="44"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}">
<TextBlock Classes="mono" FontSize="12.5" Text="Ed25519" />
</Button>
<Button Classes="chiptoggle" Classes.worn="{Binding GeneratesRsa}" MinHeight="44"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}">
<TextBlock Classes="mono" FontSize="12.5" Text="RSA 4096" />
</Button>
</StackPanel>
<TextBlock Classes="detail" Text="Ed25519 is what every current OpenSSH prefers. RSA is for servers too old to accept it, and takes a few seconds to make." />
<TextBlock Classes="label" Text="NAME" Margin="0,8,0,0" />
<TextBox Classes="field" Text="{Binding GenerateComment}" />
<TextBlock Classes="detail" Text="What the key is called here, and the comment written into it, so the line on a host says where it came from." />
<TextBlock Classes="body" Margin="0,8,0,0"
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read. It lands in the key editor unsaved, to be named and kept." />
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
Command="{Binding CancelGenerateKeyCommand}" />
<Button Grid.Column="2" Classes="primary" Content="GENERATE"
Command="{Binding GenerateKeyCommand}" IsEnabled="{Binding !IsBusy}" />
</Grid>
</StackPanel>
<!-- ============ a key ============ -->
<StackPanel Spacing="8" IsVisible="{Binding IsEditingKey}">
<TextBlock Classes="label" Text="NAME" />
<TextBox Classes="field" Text="{Binding KeyEditorLabel}" />
<!--
Not a password box, for the desktop's reason: the armour has to be visible to be pasted and
checked, and the mistake this prevents is pasting the .pub file, which is refused by name.
-->
<TextBlock Classes="label" Text="PRIVATE KEY" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding KeyEditorPrivateKey}" Height="140"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" TextWrapping="NoWrap" VerticalContentAlignment="Top"
Padding="12,10" FontSize="11" />
<TextBlock Classes="label" Text="PASSPHRASE, IF THE KEY HAS ONE" Margin="0,6,0,0" />
<TextBox Classes="field secret" Text="{Binding KeyEditorPassphrase}" />
<TextBlock Classes="label" Text="PUBLIC HALF (OPTIONAL)" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding KeyEditorPublicKey}" FontSize="11" />
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding KeyEditorNotes}" Height="72" AcceptsReturn="True"
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
<TextBlock Classes="body" Margin="0,6,0,0"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read." />
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveKeyCommand}" />
</Grid>
</StackPanel>
<!-- ============ a password ============ -->
<StackPanel Spacing="8" IsVisible="{Binding IsEditingCredential}">
<TextBlock Classes="label" Text="NAME" />
<TextBox Classes="field" Text="{Binding CredentialEditorLabel}" />
<TextBlock Classes="label" Text="USERNAME" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding CredentialEditorUsername}"
PlaceholderText="blank: each host's own" />
<TextBlock Classes="label" Text="PASSWORD" Margin="0,6,0,0" />
<TextBox Classes="field secret" Text="{Binding CredentialEditorPassword}" />
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding CredentialEditorNotes}" Height="72" AcceptsReturn="True"
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
<TextBlock Classes="body" Margin="0,6,0,0"
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to it in the host's own editor, under how it signs in." />
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
Command="{Binding CancelCredentialEditCommand}" />
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
</Grid>
</StackPanel>
<!-- ============ a bucket ============ -->
<StackPanel Spacing="8" IsVisible="{Binding IsEditingObjectStore}">
<TextBlock Classes="label" Text="NAME" />
<TextBox Classes="field" Text="{Binding BucketEditorLabel}" />
<TextBlock Classes="label" Text="BUCKET" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding BucketEditorBucket}" />
<TextBlock Classes="label" Text="ACCESS KEY ID" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding BucketEditorAccessKeyId}" />
<TextBlock Classes="label" Text="SECRET ACCESS KEY" Margin="0,6,0,0" />
<TextBox Classes="field secret" Text="{Binding BucketEditorSecretAccessKey}" />
<TextBlock Classes="label" Text="REGION" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding BucketEditorRegion}" PlaceholderText="e.g. eu-west-1" />
<TextBlock Classes="label" Text="ENDPOINT" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding BucketEditorEndpoint}" PlaceholderText="blank: Amazon S3" />
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}" MinHeight="44">
<TextBlock FontSize="13.5" Foreground="{StaticResource Text}" TextWrapping="Wrap"
Text="Address the bucket as a path" />
</CheckBox>
<TextBlock Classes="detail"
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
<TextBlock Classes="label" Text="NOTES" Margin="0,6,0,0" />
<TextBox Classes="field" Text="{Binding BucketEditorNotes}" Height="72" AcceptsReturn="True"
TextWrapping="Wrap" VerticalContentAlignment="Top" Padding="12,10" />
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
<Button Grid.Column="0" Classes="secondary" Content="CANCEL"
Command="{Binding CancelObjectStoreEditCommand}" />
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
</Grid>
</StackPanel>
<!-- ============ a tag ============ -->
<StackPanel Spacing="8" IsVisible="{Binding IsEditingTag}">
<TextBlock Classes="label" Text="NAME" />
<TextBox Classes="field" Text="{Binding TagEditorLabel}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="detail"
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
<Grid ColumnDefinitions="*,10,*" Margin="0,10,0,0">
<Button Grid.Column="0" Classes="secondary" Content="CANCEL" Command="{Binding CancelTagEditCommand}" />
<Button Grid.Column="2" Classes="primary" Content="SAVE" Command="{Binding SaveTagCommand}" />
</Grid>
</StackPanel>
<TextBlock Classes="detail" Margin="0,8,0,0" Text="{Binding Status}" />
</StackPanel>
</ScrollViewer>
</Grid>
</Panel>
</UserControl> </UserControl>
@@ -1,10 +1,70 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views; namespace DodoSSH.Client.Android.Views;
/// <summary>Design 04 — everything in the vault that is not a host.</summary> /// <summary>Design 04 — everything in the vault that is not a host.</summary>
internal sealed partial class KeychainScreen : UserControl internal sealed partial class KeychainScreen : UserControl
{ {
public KeychainScreen() => AvaloniaXamlLoader.Load(this); public KeychainScreen() => AvaloniaXamlLoader.Load(this);
/// <summary>
/// Cancels whichever of the keychain's five editors is open.
/// </summary>
/// <returns>Whether one was, and so whether there was anything to cancel.</returns>
/// <remarks>
/// One place for the question the editor page's ← and the system back gesture both ask. Only one editor is
/// ever open — each one's command refuses while another is — so the order only decides which test runs
/// first. Cancelling rather than hiding, as the host editor does, because cancelling is what empties the
/// boxes, and two of them hold a private key and a password.
/// </remarks>
internal static bool TryCancelAnEditor(VaultViewModel vault)
{
if (vault.IsGeneratingKey)
{
vault.CancelGenerateKeyCommand.Execute(null);
}
else if (vault.IsEditingKey)
{
vault.CancelKeyEditCommand.Execute(null);
}
else if (vault.IsEditingCredential)
{
vault.CancelCredentialEditCommand.Execute(null);
}
else if (vault.IsEditingObjectStore)
{
vault.CancelObjectStoreEditCommand.Execute(null);
}
else if (vault.IsEditingTag)
{
vault.CancelTagEditCommand.Execute(null);
}
else
{
return false;
}
return true;
}
private void OnCancelEditor(object? sender, RoutedEventArgs e)
{
if (DataContext is VaultViewModel vault)
{
TryCancelAnEditor(vault);
}
}
/// <summary>A kind was chosen on the add sheet: the sheet goes down as its editor opens.</summary>
private void OnAddChosen(object? sender, RoutedEventArgs e)
{
if (DataContext is VaultViewModel vault)
{
vault.IsKeychainAddSheetOpen = false;
}
}
} }
@@ -85,7 +85,7 @@
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel"> <DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<StackPanel Spacing="1" Margin="0,5"> <StackPanel Spacing="1" Margin="0,5">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="detail" FontSize="10" Text="{Binding Started}" <TextBlock Classes="detail code" FontSize="10" Text="{Binding Started}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" /> Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<!-- <!--
@@ -104,7 +104,7 @@
Text="{Binding Kind}" Foreground="{StaticResource Live}" /> Text="{Binding Kind}" Foreground="{StaticResource Live}" />
</StackPanel> </StackPanel>
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}" <TextBlock Classes="detail code" FontSize="10" Text="{Binding HostLabel}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" /> TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
@@ -148,7 +148,7 @@
<DataTemplate x:DataType="vm:ActivityLogRowViewModel"> <DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<StackPanel Spacing="1" Margin="0,5"> <StackPanel Spacing="1" Margin="0,5">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="detail" FontSize="10" Text="{Binding At}" <TextBlock Classes="detail code" FontSize="10" Text="{Binding At}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" /> Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold" <TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
Text="{Binding Operation}" Foreground="{StaticResource Info}" Text="{Binding Operation}" Foreground="{StaticResource Info}"
+248 -168
View File
@@ -6,211 +6,291 @@
Background="{StaticResource Canvas}"> Background="{StaticResource Canvas}">
<!-- <!--
Design v2 — SETTINGS: the hub for everything the bottom bar has no room for. SETTINGS: the hub behind the bottom bar's third entry.
Three slots and ten destinations is the arithmetic the design solves by putting seven of them one tap It takes the shell as its data context rather than the vault, because nearly every row on it is a
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because navigation command and nothing here reads an item.
every row on it is a navigation command and nothing here reads an item.
Teams is the tenth and the design never drew it — see the row itself. That one is the design's count ── the 2026-10-04 redesign ────────────────────────────────────────────────────────────────────────────
plus one rather than a rearrangement of it. The keychain below is a rearrangement, and the only one: The hub used to hold the product: the keychain, snippets, SFTP and S3 were rows here beside Preferences
the bottom bar lost a slot to gain this row. and LOCK, and the audit's M8 said so. The keychain and snippets now live under VAULTS, in the open vault's
own switch, and what is left is arranged the way a phone's settings are — who you are, then grouped
sections of rows on cards — rather than one flat list of places.
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination The account card says who is signed in and where, which nothing on this head said before: the server's
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose address is the fact that decides whose keychain this is, and it was only ever typed once, on the first
entries are one word each is a menu you have to open to read. screen. It is not a button; there is no account page on the phone to open (signing out is on the lock
screen — see PreferencesScreen's NOT HERE).
── from MORE to SETTINGS ────────────────────────────────────────────────────────────────────────────── Rows are cards of rows rather than cards each, because a destination is a place and not a thing, and a
The hub was called More because that was the honest name for a handful of things that did not fit. With group of them reads as one section only when they share a surface. Each keeps a sentence saying what is
the keychain on it as well it is the place everything that is not connecting to a machine lives, which behind it, because a hub whose entries are one word each is a menu you have to open to read.
has a name of its own and a glyph everyone already knows.
The keychain is first, and above the rule, because it is the only row here that holds the user's own Every icon is the embedded Material font's, as the bar's are — see NavButton, which says why the
material rather than a screen about the application. Everything below it is somewhere to look at what Unicode glyphs went (audit M7).
has happened or how this phone behaves.
--> -->
<UserControl.Styles>
<!-- A section's rows. The card's own padding is the rows' business, so a row's press fill reaches its
edges; the hairlines between rows are drawn by the rows' containers. -->
<Style Selector="Border.settingsgroup">
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<Style Selector="Button.settingsrow">
<Setter Property="MinHeight" Value="60" />
<Setter Property="Padding" Value="14,8" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Border.rowrule">
<Setter Property="Height" Value="1" />
<Setter Property="Margin" Value="62,0,0,0" />
<Setter Property="Background" Value="{StaticResource BorderSubtle}" />
</Style>
<!-- The tile an icon sits on: a square of the accent's wash, so a row's mark is a colour as well as a
shape. Amber for the one row that ends something rather than going somewhere. -->
<Style Selector="Border.rowtile">
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.rowtile > TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontSize" Value="19" />
</Style>
<Style Selector="TextBlock.rowtitle">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="14.5" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.chevron">
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="FontSize" Value="20" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Height="56" Padding="16,0"> <Border Grid.Row="0" Height="60" Padding="20,0" Background="{StaticResource DeepChrome}"
<TextBlock Classes="heading" Text="Settings" /> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<TextBlock Classes="heading" FontSize="22" FontWeight="Bold" Text="Settings" />
</Border> </Border>
<ScrollViewer Grid.Row="1"> <ScrollViewer Grid.Row="1">
<StackPanel Margin="10,0,10,20" Spacing="2"> <StackPanel Margin="16,14,16,24" Spacing="8">
<!-- ============ who, and where ============ -->
<Border Classes="settingsgroup" Padding="14">
<Grid ColumnDefinitions="Auto,14,*">
<Border Grid.Column="0" Width="48" Height="48" CornerRadius="24"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding AvatarInitials}" FontSize="17" FontWeight="Bold"
Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="2" Spacing="3" VerticalAlignment="Center">
<TextBlock FontSize="16" FontWeight="Bold" Foreground="{StaticResource Text}"
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
<TextBlock FontSize="12.5" Foreground="{StaticResource TextDim}" Text="{Binding Email}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding ServerUrl}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
</Border>
<!-- ============ security ============ -->
<TextBlock Classes="label" Text="SECURITY" Margin="6,14,0,2" />
<Border Classes="settingsgroup">
<StackPanel>
<!-- <!--
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a The fingerprint is a row to Preferences rather than a switch here. Turning it on registers a key
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted store a in the phone's secure hardware and turning it off withdraws one — two operations with a prompt
keychain, and everything under the surface calls it a vault, because renaming the wire route, the and a paragraph each, which live on that screen — so this row says which state the phone is in
tables and the CLR types would put an AAD resource type inside the blast radius of a and goes there. ON and OFF are the two flags the shell already computes; a phone with no way to
find-and-replace. See docs/design-import-gaps.md. A row reading Vault would be the one place the hold a key says neither and the screen behind explains.
product surface leaked the implementation's word.
--> -->
<Button Classes="row" Command="{Binding ShowScreenCommand}" <Button Classes="row settingsrow" AutomationProperties.Name="Unlock with a fingerprint"
CommandParameter="{x:Static vm:ShellScreen.Keychain}"> Command="{Binding ShowScreenCommand}"
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Keychain" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Keys, passwords, buckets and tags — everything in the vault that is not a host." />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource Border}" Margin="16,8" />
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="❯" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Snippets" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="Saved commands, typed into an open shell." />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<!--
These two go through ShowFiles rather than ShowScreen: they are one screen over one view model,
and which kind of remote it offers is the thing being chosen. See ShowFilesCommand for why that
is set by the button rather than on arrival.
-->
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⇅" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="SFTP" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Browse a host's files." />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◳" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="S3" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Objects in an S3-compatible bucket from the keychain." />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="≡" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Logs" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What has been connected to, and what has changed." />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<!--
Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for
rather than a drawn one with nothing behind it. It is on the phone because a vault arrives
without being asked for, and somebody it arrives for is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
two heads giving one destination two marks is how a user learns the wrong one.
-->
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◎" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Vaults" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Your vaults, who is in each one, and who holds its key." />
</StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}"> CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,12,*,8,Auto,4,Auto">
<TextBlock Grid.Column="0" Text="⚙" Foreground="{StaticResource AccentText}" FontSize="14" <Border Grid.Column="0" Classes="rowtile">
Width="22" VerticalAlignment="Center" /> <TextBlock Classes="icon" Text="&#xE90D;" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center"> </Border>
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" /> <StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" <TextBlock Classes="rowtitle" Text="Unlock with a fingerprint" />
Text="Unlocking with a fingerprint, and what this phone cannot do." /> <TextBlock Classes="detail" Text="The key stays in this phone's secure hardware." />
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15" <TextBlock Grid.Column="4" Classes="label" FontSize="10.5" Text="ON"
VerticalAlignment="Center" /> Foreground="{StaticResource Live}" VerticalAlignment="Center"
IsVisible="{Binding CanForgetDevice}" />
<TextBlock Grid.Column="4" Classes="label" FontSize="10.5" Text="OFF"
VerticalAlignment="Center" IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Grid.Column="6" Classes="icon chevron" Text="&#xE5CC;" />
</Grid> </Grid>
</Button> </Button>
<Border Classes="rowrule" />
<!-- <!--
Locking, here as well as in the header. Locking, here as well as in the vault's header. The header is on the VAULTS tab and stands down
on this one, so without this row every screen under SETTINGS would be two taps from locking the
The header carries it on HOSTS and stands down behind SETTINGS, so without this row every screen keychain — the wrong direction for the one action whose whole value is being quick.
under here would be two taps from locking the keychain. That is the wrong direction for the one
action whose whole value is being quick, and it is why this is a row on the hub rather than
something you go back for.
--> -->
<Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}"> <Button Classes="row settingsrow" AutomationProperties.Name="Lock the keychain now"
<Grid ColumnDefinitions="Auto,*"> Command="{Binding LockCommand}">
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource TextDim}" FontSize="14" <Grid ColumnDefinitions="Auto,12,*">
Width="22" VerticalAlignment="Center" /> <Border Grid.Column="0" Classes="rowtile">
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center"> <TextBlock Classes="icon" Text="&#xE897;" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Lock the keychain" /> </Border>
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" <StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
Text="Shells you have open keep running." /> <TextBlock Classes="rowtitle" Text="Lock now" />
<TextBlock Classes="detail" Text="Shells you have open keep running." />
</StackPanel> </StackPanel>
</Grid> </Grid>
</Button> </Button>
</StackPanel>
</Border>
<!-- ============ vaults and history ============ -->
<TextBlock Classes="label" Text="VAULTS AND HISTORY" Margin="6,14,0,2" />
<Border Classes="settingsgroup">
<StackPanel>
<!--
Vaults, which the v2 design had no row for. It is on the phone because a vault arrives without
being asked for, and somebody it arrives for is at least as likely to be holding a phone. The
vault name in the VAULTS tab's header leads here too.
-->
<Button Classes="row settingsrow" AutomationProperties.Name="Vaults"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
<Border Grid.Column="0" Classes="rowtile">
<TextBlock Classes="icon" Text="&#xE1A1;" />
</Border>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="rowtitle" Text="Vaults and sharing" />
<TextBlock Classes="detail" Text="Your vaults, who is in each one, and who holds its key." />
</StackPanel>
<TextBlock Grid.Column="4" Classes="icon chevron" Text="&#xE5CC;" />
</Grid>
</Button>
<Border Classes="rowrule" />
<Button Classes="row settingsrow" AutomationProperties.Name="Logs"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
<Border Grid.Column="0" Classes="rowtile">
<TextBlock Classes="icon" Text="&#xEF6E;" />
</Border>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="rowtitle" Text="Activity log" />
<TextBlock Classes="detail" Text="What has been connected to, and what has changed." />
</StackPanel>
<TextBlock Grid.Column="4" Classes="icon chevron" Text="&#xE5CC;" />
</Grid>
</Button>
</StackPanel>
</Border>
<!-- ============ files ============ -->
<!--
SFTP and S3 stay reachable from here, and the terminal now reaches a host's files directly — the
FILES entry in a session's menu opens them on that session's host. A bucket has no session, so this
is still the way to one. Both go through ShowFiles rather than ShowScreen: they are one screen over
one view model, and which kind of remote it offers is the thing being chosen.
-->
<TextBlock Classes="label" Text="FILES" Margin="6,14,0,2" />
<Border Classes="settingsgroup">
<StackPanel>
<Button Classes="row settingsrow" AutomationProperties.Name="SFTP"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}">
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
<Border Grid.Column="0" Classes="rowtile">
<TextBlock Classes="icon" Text="&#xE2C7;" />
</Border>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="rowtitle" Text="SFTP" />
<TextBlock Classes="detail" Text="Browse a host's files." />
</StackPanel>
<TextBlock Grid.Column="4" Classes="icon chevron" Text="&#xE5CC;" />
</Grid>
</Button>
<Border Classes="rowrule" />
<Button Classes="row settingsrow" AutomationProperties.Name="S3"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<Grid ColumnDefinitions="Auto,12,*,8,Auto">
<Border Grid.Column="0" Classes="rowtile">
<TextBlock Classes="icon" Text="&#xE2BD;" />
</Border>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="rowtitle" Text="S3" />
<TextBlock Classes="detail" Text="Objects in an S3-compatible bucket from the keychain." />
</StackPanel>
<TextBlock Grid.Column="4" Classes="icon chevron" Text="&#xE5CC;" />
</Grid>
</Button>
</StackPanel>
</Border>
<!-- ============ this app ============ -->
<!-- Preferences with tune rather than a gear: the gear is the tab this row is under (audit M7). -->
<TextBlock Classes="label" Text="THIS APP" Margin="6,14,0,2" />
<Border Classes="settingsgroup">
<Button Classes="row settingsrow" AutomationProperties.Name="Preferences"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,12,*,8,Auto,4,Auto">
<Border Grid.Column="0" Classes="rowtile">
<TextBlock Classes="icon" Text="&#xE429;" />
</Border>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="rowtitle" Text="Preferences and updates" />
<TextBlock Classes="detail" Text="Fingerprint unlock, updates, and what this phone cannot do." />
</StackPanel>
<TextBlock Grid.Column="4" Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}"
Text="{Binding Updates.CurrentVersion}" VerticalAlignment="Center"
MaxWidth="110" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="6" Classes="icon chevron" Text="&#xE5CC;" />
</Grid>
</Button>
</Border>
<!-- <!--
◆ Port forwarding, stated rather than drawn. ◆ Port forwarding, stated rather than drawn.
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic The v2 design gives this hub an entry and a full screen behind it: local, remote and dynamic rules
rules with toggles and byte counters. Nothing in this application forwards anything — ISshConnection with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
effect and every counter a number with no source. forward belongs to a host and runs with a session, and on this head the session's forwards are a
sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch
each. See MainWindowViewModel.IsForwardsSheetOpen.
What the phone lacks is the editor: its host form has no PORT FORWARDS section yet, so forwards are
added on the desktop and arrive here with the host. The byte counters are not built anywhere;
SSH.NET keeps no count to draw.
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail: It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
a destination that is quietly absent reads as a product that never intended it, and this one is a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
docs/design-import-gaps.md.
--> -->
<Border Classes="card" Margin="6,16,6,0"> <Border Classes="card" Margin="0,14,0,0">
<StackPanel Spacing="7"> <StackPanel Spacing="7">
<StackPanel Orientation="Horizontal" Spacing="9"> <StackPanel Orientation="Horizontal" Spacing="9">
<TextBlock Text="⇄" Foreground="{StaticResource TextFaint}" FontSize="14" <TextBlock Classes="icon" Text="&#xE8D4;" Foreground="{StaticResource TextFaint}" FontSize="18" />
VerticalAlignment="Center" /> <TextBlock FontSize="14" FontWeight="SemiBold" Foreground="{StaticResource TextDim}"
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Port forwarding" VerticalAlignment="Center" />
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
<Border Classes="tag outline" VerticalAlignment="Center"> <Border Classes="tag outline" VerticalAlignment="Center">
<TextBlock Text="NOT BUILT" /> <TextBlock Text="IN THE TERMINAL" />
</Border> </Border>
</StackPanel> </StackPanel>
<TextBlock Classes="body" <TextBlock Classes="body"
Text="A connection here opens one channel — a shell — and nothing else, so there is no tunnel for a rule to run through. The wire protocol reserves a slot for forwarding rules and no client writes one. Nothing on this screen is hidden by a setting; the feature is absent." /> Text="Forwards saved on a host start with every terminal opened to it, here as on the desktop. The ⇄ button in the terminal's bar lists them, with a switch each and the reason one could not start. They are added and edited in the desktop app's host editor; this phone runs them but has no editor for them yet." />
</StackPanel> </StackPanel>
</Border> </Border>
+112 -37
View File
@@ -1,4 +1,7 @@
using System.Globalization;
using Avalonia; using Avalonia;
using Avalonia.Automation;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Layout; using Avalonia.Layout;
@@ -17,16 +20,31 @@ namespace DodoSSH.Client.Android.Views;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about /// Written in C# rather than as a XAML control because it is a button with an icon, a label and a rule about
/// the accent bar, and a templated control for that would be more machinery than the thing it draws. /// the indicator, and a templated control for that would be more machinery than the thing it draws.
/// </para> /// </para>
/// <para> /// <para>
/// <b>The accent bar is on top, not underneath.</b> The v2 design distinguishes the current destination with /// ◆ <b>The indicator is a pill behind the icon, where it was a bar along the top edge.</b> The bar was put at
/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the /// the top because the bottom of the screen is under the system's gesture bar, and an indicator drawn there is
/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar, /// one nobody sees; the pill answers the same worry from the other side, by sitting in the middle of the
/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is /// control rather than at either edge. It is the shape Android's own navigation bar has drawn since Material 3
/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are /// and the one the 2026-10-04 redesign asks for. The rule the bar existed to keep is kept: the current entry is
/// three, which is the right number for the control that says where you are. /// still told apart three ways — the filled pill, the accent colour and the heavier label — because colour
/// alone at this size is the one signal a user with a colour deficiency would be left with.
/// </para>
/// <para>
/// ◆ <b>An icon from the embedded Material font rather than a Unicode glyph.</b> The glyphs (▤ ⌗ ⚙) came from
/// whichever symbol font the phone had, at whatever weight it drew them, and ⚙ meant both this tab and the
/// Preferences row under it (audit M7). The icon font is the one the desktop's rail uses.
/// </para>
/// <para>
/// <b>A count, when there is one.</b> Connections carries the number of open sessions in a green badge, which
/// was otherwise only said by the strip of shells above the bar. Zero draws nothing: a "0" is a number about
/// nothing, and the badge being absent already says it.
/// </para>
/// <para>
/// <b>It has a name.</b> Its content is a panel, and Avalonia's automation peer reported that panel's type name
/// to TalkBack — "Avalonia.Controls.Panel", three times across the bar (audit H4). The label is the name.
/// </para> /// </para>
/// <para> /// <para>
/// Every colour here is resolved from <c>Theme/Palette.axaml</c> rather than written out. A nav bar that /// Every colour here is resolved from <c>Theme/Palette.axaml</c> rather than written out. A nav bar that
@@ -39,25 +57,28 @@ internal sealed class NavButton : Button
public static readonly StyledProperty<string> LabelProperty = public static readonly StyledProperty<string> LabelProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty); AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
/// <summary>The glyph drawn above the label.</summary> /// <summary>The icon drawn above the label: one codepoint of the embedded Material Icons font.</summary>
/// <remarks>
/// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
/// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
/// keep in step with the palette.
/// </remarks>
public static readonly StyledProperty<string> GlyphProperty = public static readonly StyledProperty<string> GlyphProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty); AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty);
public static readonly StyledProperty<bool> IsCurrentProperty = public static readonly StyledProperty<bool> IsCurrentProperty =
AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent)); AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent));
private readonly Border indicator; /// <summary>A count drawn over the icon, or zero for none.</summary>
public static readonly StyledProperty<int> CountProperty =
AvaloniaProperty.Register<NavButton, int>(nameof(Count));
private readonly Border pill;
private readonly TextBlock glyph; private readonly TextBlock glyph;
private readonly TextBlock text; private readonly TextBlock text;
private readonly Border badge;
private readonly TextBlock badgeText;
// Resolved from the palette once the control is in the tree; see Repaint. // Resolved from the palette once the control is in the tree; see Repaint.
private IBrush? currentBrush; private IBrush? currentBrush;
private IBrush? currentIconBrush;
private IBrush? idleBrush; private IBrush? idleBrush;
private IBrush? pillBrush;
public NavButton() public NavButton()
{ {
@@ -70,35 +91,71 @@ internal sealed class NavButton : Button
HorizontalContentAlignment = HorizontalAlignment.Stretch; HorizontalContentAlignment = HorizontalAlignment.Stretch;
VerticalContentAlignment = VerticalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch;
indicator = new Border
{
Height = 2,
VerticalAlignment = VerticalAlignment.Top,
Background = Brushes.Transparent,
};
glyph = new TextBlock glyph = new TextBlock
{ {
FontSize = 15, FontSize = 21,
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
AutomationProperties.SetAccessibilityView(glyph, AccessibilityView.Raw);
(badge, badgeText) = MakeBadge();
pill = new Border
{
Width = 64,
Height = 32,
CornerRadius = new CornerRadius(16),
HorizontalAlignment = HorizontalAlignment.Center,
Background = Brushes.Transparent,
Child = glyph,
}; };
text = new TextBlock text = new TextBlock
{ {
FontSize = 9, FontSize = 12,
FontWeight = FontWeight.Medium, FontWeight = FontWeight.Medium,
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,
}; };
var stack = new StackPanel Content = new StackPanel
{ {
Spacing = 3, Spacing = 4,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Children = { new Panel { Children = { pill, badge } }, text },
};
}
/// <summary>The count's badge, over the pill's top-right corner.</summary>
/// <remarks>
/// Over the corner rather than beside the label, so it reads as belonging to the icon and costs the
/// label no width.
/// </remarks>
private static (Border Badge, TextBlock Text) MakeBadge()
{
var count = new TextBlock
{
FontSize = 10.5,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
Children = { glyph, text },
}; };
Content = new Panel { Children = { indicator, stack } }; var holder = new Border
{
MinWidth = 18,
Height = 18,
CornerRadius = new CornerRadius(9),
Padding = new Thickness(5, 0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, -4, 8, 0),
IsVisible = false,
Child = count,
};
return (holder, count);
} }
public string Label public string Label
@@ -121,6 +178,13 @@ internal sealed class NavButton : Button
set => SetValue(IsCurrentProperty, value); set => SetValue(IsCurrentProperty, value);
} }
/// <inheritdoc cref="CountProperty" />
public int Count
{
get => GetValue(CountProperty);
set => SetValue(CountProperty, value);
}
/// <inheritdoc /> /// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{ {
@@ -129,6 +193,7 @@ internal sealed class NavButton : Button
if (change.Property == LabelProperty) if (change.Property == LabelProperty)
{ {
text.Text = Label; text.Text = Label;
AutomationProperties.SetName(this, Label);
} }
else if (change.Property == GlyphProperty) else if (change.Property == GlyphProperty)
{ {
@@ -138,9 +203,10 @@ internal sealed class NavButton : Button
{ {
Repaint(); Repaint();
} }
else if (change.Property == FontFamilyProperty) else if (change.Property == CountProperty)
{ {
text.FontFamily = FontFamily; badge.IsVisible = Count > 0;
badgeText.Text = Count.ToString(CultureInfo.InvariantCulture);
} }
} }
@@ -151,13 +217,17 @@ internal sealed class NavButton : Button
// Resolved here rather than in the constructor: application resources are not reachable while the // 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. // control is still being constructed, and a hard-coded stack would drift from Palette.axaml.
if (Application.Current?.FindResource("MonoFont") is FontFamily mono) if (Application.Current?.FindResource("IconFont") is FontFamily icons)
{ {
text.FontFamily = mono; glyph.FontFamily = icons;
} }
currentBrush = Application.Current?.FindResource("AccentText") as IBrush; currentBrush = Application.Current?.FindResource("Text") as IBrush;
currentIconBrush = Application.Current?.FindResource("AccentText") as IBrush;
idleBrush = Application.Current?.FindResource("TextFaint") as IBrush; idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
pillBrush = Application.Current?.FindResource("AccentSoft") as IBrush;
badge.Background = Application.Current?.FindResource("Live") as IBrush;
badgeText.Foreground = Application.Current?.FindResource("Canvas") as IBrush;
// IsCurrent is normally bound and so has usually already been set by the time the template applies, // IsCurrent is normally bound and so has usually already been set by the time the template applies,
// which is why this runs again here rather than only on the property change. // which is why this runs again here rather than only on the property change.
@@ -166,16 +236,21 @@ internal sealed class NavButton : Button
private void Repaint() private void Repaint()
{ {
// Both the label and the bar, not just the bar — and the weight as well. See the remark on the type. // The pill, the colour and the weight together. See the remark on the type.
var paint = IsCurrent ? currentBrush : idleBrush; var paint = IsCurrent ? currentBrush : idleBrush;
var iconPaint = IsCurrent ? currentIconBrush : idleBrush;
if (paint is not null) if (paint is not null)
{ {
glyph.Foreground = paint;
text.Foreground = paint; text.Foreground = paint;
} }
text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium; if (iconPaint is not null)
indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent; {
glyph.Foreground = iconPaint;
}
text.FontWeight = IsCurrent ? FontWeight.Bold : FontWeight.Medium;
pill.Background = IsCurrent && pillBrush is not null ? pillBrush : Brushes.Transparent;
} }
} }
@@ -73,17 +73,28 @@
<Setter Property="Background" Value="{StaticResource Active}" /> <Setter Property="Background" Value="{StaticResource Active}" />
</Style> </Style>
<!--
◆ Material icons rather than Unicode glyphs, as the desktop's own rail draws them. The glyphs came from
whatever symbol font the phone happened to have, at whatever weight and optical size it drew them — the
heavy ❯ beside the hairline ◳ the 2026-10-02 audit pointed at (M7) — and two of them meant two things:
⚙ was both the Settings tab and Preferences, ⚿ both the keychain and locking it. The icon font is the
one the desktop embeds, so one destination now has one mark on both heads.
-->
<Style Selector="Button.railentry TextBlock.railglyph"> <Style Selector="Button.railentry TextBlock.railglyph">
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="14" /> <Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="19" />
<!-- A ligature codepoint is noise to a screen reader; the entry's name is on the button. -->
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
<Setter Property="Width" Value="24" /> <Setter Property="Width" Value="24" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
</Style> </Style>
<!-- A destination's name is a word rather than an identifier, so it takes the sans face — see the remark
on TextBlock.body in Theme/Phone.axaml. -->
<Style Selector="Button.railentry TextBlock.raillabel"> <Style Selector="Button.railentry TextBlock.raillabel">
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontSize" Value="13.5" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" /> <Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style> </Style>
@@ -120,11 +131,11 @@
<DockPanel LastChildFill="False"> <DockPanel LastChildFill="False">
<StackPanel DockPanel.Dock="Bottom" Margin="8,0,8,10"> <StackPanel DockPanel.Dock="Bottom" Margin="8,0,8,10">
<Button Classes="railentry" Classes.active="{Binding IsPreferencesShowing}" <Button Classes="railentry" AutomationProperties.Name="Preferences" Classes.active="{Binding IsPreferencesShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}"> CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚙" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE429;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Preferences" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Preferences" />
</Grid> </Grid>
</Button> </Button>
@@ -134,11 +145,11 @@
HorizontalScrollBarVisibility="Disabled"> HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,10,8,0" Spacing="2"> <StackPanel Margin="8,10,8,0" Spacing="2">
<Button Classes="railentry" Classes.active="{Binding IsHostsShowing}" <Button Classes="railentry" AutomationProperties.Name="Hosts" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}"> CommandParameter="{x:Static vm:ShellScreen.Hosts}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="▤" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE875;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Hosts" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Hosts" />
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Hosts.Count}" /> <TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Hosts.Count}" />
</Grid> </Grid>
@@ -155,67 +166,67 @@
surface and is that count, drawn as the sessions themselves — a number beside this word would be surface and is that count, drawn as the sessions themselves — a number beside this word would be
the same fact said twice, three inches apart. the same fact said twice, three inches apart.
--> -->
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}" <Button Classes="railentry" AutomationProperties.Name="Connections" Classes.active="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}"> Command="{Binding ShowTerminalCommand}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xEB8E;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
</Grid> </Grid>
</Button> </Button>
<Border Height="1" Background="{StaticResource Border}" Margin="4,8" /> <Border Height="1" Background="{StaticResource Border}" Margin="4,8" />
<Button Classes="railentry" Classes.active="{Binding IsKeychainShowing}" <Button Classes="railentry" AutomationProperties.Name="Keychain" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}"> CommandParameter="{x:Static vm:ShellScreen.Keychain}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⚿" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE73C;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Keychain" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Keychain" />
<TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Keys.Count}" /> <TextBlock Grid.Column="2" Classes="railcount" Text="{Binding Vault.Keys.Count}" />
</Grid> </Grid>
</Button> </Button>
<Button Classes="railentry" Classes.active="{Binding IsSnippetsShowing}" <Button Classes="railentry" AutomationProperties.Name="Snippets" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"> CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="❯" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE86F;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Snippets" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Snippets" />
</Grid> </Grid>
</Button> </Button>
<Button Classes="railentry" Classes.active="{Binding IsTransfersShowing}" <Button Classes="railentry" AutomationProperties.Name="SFTP" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"> CommandParameter="{x:Static vm:RemoteKind.Host}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⇅" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE2C7;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="SFTP" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="SFTP" />
</Grid> </Grid>
</Button> </Button>
<Button Classes="railentry" Classes.active="{Binding IsBucketsShowing}" <Button Classes="railentry" AutomationProperties.Name="S3" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"> CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="◳" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE2BD;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="S3" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="S3" />
</Grid> </Grid>
</Button> </Button>
<Button Classes="railentry" Classes.active="{Binding IsLogsShowing}" <Button Classes="railentry" AutomationProperties.Name="Logs" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"> CommandParameter="{x:Static vm:ShellScreen.Logs}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="≡" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xEF6E;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Logs" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Logs" />
</Grid> </Grid>
</Button> </Button>
<Button Classes="railentry" Classes.active="{Binding IsVaultsShowing}" <Button Classes="railentry" AutomationProperties.Name="Vaults" Classes.active="{Binding IsVaultsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}"> CommandParameter="{x:Static vm:ShellScreen.Vaults}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="◎" /> <TextBlock Grid.Column="0" Classes="railglyph" Text="&#xE1A1;" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Vaults" /> <TextBlock Grid.Column="1" Classes="raillabel" Text="Vaults" />
</Grid> </Grid>
</Button> </Button>
+122 -100
View File
@@ -24,10 +24,11 @@
are the pair a session moves between; on the desktop the terminal is not a rail entry at all. are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
── a terminal gets the screen ───────────────────────────────────────────────────────────────────────── ── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the The rows around the screen stand down while a shell is showing: the header and the bottom bar. Both go
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control through flags the control computes, and the bar's is the one that differs — it stays up on Connections
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which with nothing running, which is the terminal surface drawing a page rather than a shell. See
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome. PhoneShell.RefreshChrome. (A third row, the strip of open shells, was removed by the 2026-10-04 redesign;
the count on Connections took its place — see the remark where it was.)
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not. 360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
@@ -78,7 +79,7 @@
<views:PhoneRail Grid.Column="0" <views:PhoneRail Grid.Column="0"
IsVisible="{Binding $parent[views:PhoneShell].ShowsRail}" /> IsVisible="{Binding $parent[views:PhoneShell].ShowsRail}" />
<Grid Grid.Column="1" RowDefinitions="Auto,*,Auto,Auto"> <Grid Grid.Column="1" RowDefinitions="Auto,*,Auto">
<!-- <!--
The header: which vault, and whether it is synced. The header: which vault, and whether it is synced.
@@ -106,34 +107,53 @@
header in HostsScreen, which the header stands down for, both move with it for the same reason; header in HostsScreen, which the header stands down for, both move with it for the same reason;
see the remark on each. see the remark on each.
--> -->
<StackPanel>
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56"> BorderThickness="0,0,0,1" Padding="10,0,6,0" Height="60">
<Grid ColumnDefinitions="Auto,*,Auto,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!-- <!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a ◆ The vault's name is a button now, and it opens the vaults.
fact about the vault, and the design gives it the accent as a solid tile — which is also what
the launcher icon draws, so the two agree.
-->
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
Background="{StaticResource Accent}" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16" The 2026-10-04 redesign draws the name with a chevron, as the switch it is on every phone app
that holds more than one of something. What it leads to is VAULTS, which is where the vaults
this phone shows are chosen — so the chevron is a promise the screen behind it keeps, rather
than a dropdown this head has not got.
The mark stays filled, and takes the gradient Button.primary has: it is the one thing on this
header that is not a fact about the vault, and the launcher icon draws it the same way.
-->
<Button Grid.Column="0" Classes="row" MinHeight="48" Padding="4,0,8,0" CornerRadius="12"
HorizontalAlignment="Left"
AutomationProperties.Name="{Binding Vault.VaultName, StringFormat='Vault {0}. Choose vaults'}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}">
<StackPanel Orientation="Horizontal" Spacing="10">
<Border Width="32" Height="32" CornerRadius="10" Background="{StaticResource AccentGradient}"
VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}"
FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw" />
</Border>
<TextBlock Classes="heading" FontSize="18" FontWeight="Bold" MaxWidth="170"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" /> Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="icon" Text="&#xE5CF;" FontSize="20" Foreground="{StaticResource TextFaint}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- <!--
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
a permanently green "Synced" here, which is the one claim on that mock-up this application a permanently green "Synced" here, which is the one claim on that mock-up this application
will not make. will not make.
--> -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center"
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6" Margin="0,0,6,0">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="8" Height="8"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" /> <TextBlock FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource TextDim}"
Text="{Binding SyncLabel}" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<!-- <!--
@@ -145,16 +165,49 @@
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one. what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
What is left in this row is the vault's name, the sync light and LOCK: three facts about the What is left in this row is the vault's name, the sync light and the lock: three facts about
keychain, and none of them about a row. the keychain, and none of them about a row. The lock is an icon since the redesign, with its
name on it for a screen reader rather than the word LOCK in nine-pixel capitals.
--> -->
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}" <Button Grid.Column="3" Classes="icon" Command="{Binding LockCommand}"
ToolTip.Tip="Lock the keychain"> AutomationProperties.Name="Lock the keychain" ToolTip.Tip="Lock the keychain">
<TextBlock Text="LOCK" Classes="label" FontSize="8.5" <TextBlock Classes="icon" Text="&#xE897;" FontSize="21" />
Foreground="{StaticResource TextDim}" />
</Button> </Button>
</Grid> </Grid>
</Border> </Border>
<!--
◆ HOSTS · KEYCHAIN · SNIPPETS: the vault's three kinds of content, as one switch.
The redesign's answer to the audit's M8, that SETTINGS held the product. The keychain and the
snippets were rows on the hub, a screen away from the hosts they are used with; they are the open
vault's contents as much as the hosts are, so they now sit beside them under the VAULTS tab, and the
hub keeps what is about this phone rather than about a vault.
Narrow surfaces only. A wide one has the rail, which lists all three already — see
PhoneShell.ShowsVaultTabs. The chips are RadioButtons for the reason the keychain's own are: the
three are exclusive and a screen reader should say so, and Mode=OneWay so the switch moves when the
screen does and never ahead of it.
-->
<Border IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultTabs}" Padding="16,12,16,4">
<Border Classes="segmented">
<Grid ColumnDefinitions="*,*,*">
<RadioButton Grid.Column="0" Classes="segment" GroupName="vaulttab" Content="Hosts"
IsChecked="{Binding IsHostsShowing, Mode=OneWay}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
<RadioButton Grid.Column="1" Classes="segment" GroupName="vaulttab" Content="Keychain"
IsChecked="{Binding IsKeychainShowing, Mode=OneWay}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}" />
<RadioButton Grid.Column="2" Classes="segment" GroupName="vaulttab" Content="Snippets"
IsChecked="{Binding IsSnippetsShowing, Mode=OneWay}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}" />
</Grid>
</Border>
</Border>
</StackPanel>
</Panel> </Panel>
<!-- ============ ◆ the contextual action bar ============ --> <!-- ============ ◆ the contextual action bar ============ -->
@@ -277,67 +330,14 @@
</Panel> </Panel>
<!-- <!--
The shells strip. The design puts it above the nav on every screen, and that is the single most ◆ THE SHELLS STRIP IS GONE, AND THE BADGE ON CONNECTIONS IS WHAT REPLACED IT.
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.
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a It sat above the bar on every page — the single most desktop-shaped idea in the product, kept so that
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row an open shell was never invisible from the host list. The 2026-10-04 redesign keeps that promise with
saying what the row already looks like. less: Connections carries the number of open sessions in a green badge, and one tap on it is the
sessions themselves, in the terminal's own bar. That is 46 pixels of every page given back to the list,
On every screen except the one it names. The terminal draws these same sessions in its own bar, and and one row of the same pills where there were two on the way into a shell.
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
whether there are any tabs and this one is about which surface is up.
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
terminal has, on a screen where the form is the whole point of being there. See
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
--> -->
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
<!-- DeepChrome rather than Sidebar, since v5, joining the header and the bar above and below it:
the strip is chrome the same way they are — a frame around the screen rather than a pane of
it — and Sidebar is what a card sits on, which this row is not. -->
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green only while there is a shell behind the tab. It used to be lit unconditionally,
which was true when a tab could not exist without a session; one can now —
connecting opens the tab first — and a dot that was green before anything had
answered would be the one thing on this strip claiming something untrue.
Amber while it is being made, which is the other half of that correction. Not being
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
tab whose shell has exited — the two states on this strip with the least in common,
one worth waiting for and one over. See Phone.axaml.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Panel>
<!-- ============ navigation ============ --> <!-- ============ navigation ============ -->
<!-- <!--
@@ -353,20 +353,26 @@
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag ◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar. the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
── three rather than four ───────────────────────────────────────────────────────────────────────── ── VAULTS · CONNECTIONS · SETTINGS ────────────────────────────────────────────────────────────────
The keychain moved behind SETTINGS. A bottom bar is for the places a session moves between, and keys, Three entries still, and the first is the open vault rather than its host list: hosts, the keychain
credentials and tags are not that — they are managed occasionally and then left alone, which is the and snippets are a switch at the top of it. The keychain had moved behind SETTINGS on the argument that
shape of everything else already behind the hub. What is left is the two halves of using this a bottom bar is for the places a session moves between; the redesign keeps the bar at three and puts the
application, and the drawer holding the rest. vault's own contents back one tap from anywhere, which is what the audit's M8 asked for. 72 tall, for
the pill and a 12-pixel label under it — see NavButton.
--> -->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this <!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
bar is the foot of the same frame the header is the top of. --> bar is the foot of the same frame the header is the top of. -->
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}" <Border Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64"> BorderThickness="0,1,0,0" Height="72">
<Grid ColumnDefinitions="*,*,*"> <Grid ColumnDefinitions="*,*,*">
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}" <!--
IsVaultSurface rather than IsHostsShowing, for the reason SETTINGS reads IsMoreSurface: the tab
stands for three screens, and it stays lit on whichever of them is up. A tap goes to the hosts,
which is where the application opens and what the vault's switch starts on.
-->
<views:NavButton Grid.Column="0" Label="Vaults" Glyph="&#xE1A1;" IsCurrent="{Binding IsVaultSurface}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" /> CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
@@ -386,9 +392,11 @@
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
bar can now sit under and not point at is the entry looking broken instead. bar can now sit under and not point at is the entry looking broken instead.
◆ The count is the open tabs, and it is what the shells strip used to say. See the remark above.
--> -->
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗" <views:NavButton Grid.Column="1" Label="Connections" Glyph="&#xEB8E;"
IsCurrent="{Binding IsTerminalSurface}" IsCurrent="{Binding IsTerminalSurface}" Count="{Binding Tabs.Count}"
Command="{Binding ShowTerminalCommand}" /> Command="{Binding ShowTerminalCommand}" />
<!-- <!--
@@ -396,18 +404,32 @@
and a bar that went dark the moment you opened one of them would only ever light two of its and a bar that went dark the moment you opened one of them would only ever light two of its
three entries. three entries.
"Settings" and a gear, where it was "More" and a hamburger. The hub held a handful of things "Settings" and a gear, where it was "More" and a hamburger. With the vault's contents moved under
that did not fit and could not be called anything better than More; with the keychain, teams and VAULTS, what is behind it is about this phone and this account — security, the terminal's
preferences all on it, it is the place everything that is not connecting to something lives — behaviour, which vaults are shown, the logs — which is what a gear has meant on a phone since
which is what a gear has meant on a phone since before this application existed. before this application existed.
--> -->
<views:NavButton Grid.Column="2" Label="Settings" Glyph="⚙" IsCurrent="{Binding IsMoreSurface}" <views:NavButton Grid.Column="2" Label="Settings" Glyph="&#xE8B8;" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
</Grid> </Grid>
</Border> </Border>
<!--
◆ THE REST OF THE SCRIM, over the two rows of chrome a sheet on the hosts screen cannot reach.
The hosts screen's sheets are drawn inside the screen's own row, so their scrim dimmed the list and
left the header — with the lock on it — and the bottom bar bright and live above and below every
sheet (audit M12). These two finish the job in the same colour, declared last so they draw over
the header and the bar, and a tap on either does what a tap on the sheet's own scrim does: lowers
the sheet. See PhoneShell.ShowsSheetScrim and OnChromeScrimTapped.
-->
<Button Grid.Row="0" Classes="scrim" IsVisible="{Binding $parent[views:PhoneShell].ShowsSheetScrim}"
Click="OnChromeScrimTapped" AutomationProperties.Name="Close the sheet" />
<Button Grid.Row="2" Classes="scrim" IsVisible="{Binding $parent[views:PhoneShell].ShowsSheetScrim}"
Click="OnChromeScrimTapped" AutomationProperties.Name="Close the sheet" />
</Grid> </Grid>
</Grid> </Grid>
@@ -43,6 +43,13 @@ internal sealed partial class PhoneShell : UserControl
/// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary> /// <summary>The software keyboard, while this control is attached. Null on a platform without one.</summary>
private IInputPane? keyboard; private IInputPane? keyboard;
/// <summary>
/// Whether the software keyboard is up, read from the pane's state rather than from the inset: before
/// Android 15 the window itself shrinks for the keyboard and the inset reads zero. See
/// <see cref="ApplyKeyboardInset(double)"/>.
/// </summary>
private bool keyboardOpen;
/// <summary> /// <summary>
/// Whether the lock screen currently showing is the one the application launched into. /// Whether the lock screen currently showing is the one the application launched into.
/// </summary> /// </summary>
@@ -89,6 +96,7 @@ internal sealed partial class PhoneShell : UserControl
if (shell is not null) if (shell is not null)
{ {
shell.PropertyChanged -= OnShellChanged; shell.PropertyChanged -= OnShellChanged;
shell.Transfers.PropertyChanged -= OnVaultChanged;
} }
shell = DataContext as MainWindowViewModel; shell = DataContext as MainWindowViewModel;
@@ -96,6 +104,11 @@ internal sealed partial class PhoneShell : UserControl
if (shell is not null) if (shell is not null)
{ {
shell.PropertyChanged += OnShellChanged; shell.PropertyChanged += OnShellChanged;
// The files screen's actions sheet is the one sheet outside the hosts screen that the chrome
// scrim covers for, so its openness is heard here too. Transfers is built once with the shell
// and never replaced, unlike the vault.
shell.Transfers.PropertyChanged += OnVaultChanged;
ApplyScreenshotPolicy(shell.State); ApplyScreenshotPolicy(shell.State);
thisLockIsTheLaunch = true; thisLockIsTheLaunch = true;
@@ -170,9 +183,13 @@ internal sealed partial class PhoneShell : UserControl
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty = public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar)); AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary> /// <summary>Whether the HOSTS · KEYCHAIN · SNIPPETS switch is drawn under the vault's header.</summary>
public static readonly StyledProperty<bool> ShowsShellStripProperty = /// <remarks>
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip)); /// Narrow surfaces only: a wide one has the rail, which lists the three already, and a switch repeating
/// three of the rail's entries an inch to the right of them would be the same choice offered twice.
/// </remarks>
public static readonly StyledProperty<bool> ShowsVaultTabsProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultTabs));
/// <inheritdoc cref="IsWideProperty" /> /// <inheritdoc cref="IsWideProperty" />
public bool IsWide public bool IsWide
@@ -209,11 +226,22 @@ internal sealed partial class PhoneShell : UserControl
private set => SetValue(ShowsHostSelectionBarProperty, value); private set => SetValue(ShowsHostSelectionBarProperty, value);
} }
/// <inheritdoc cref="ShowsShellStripProperty" /> /// <summary>Whether the header and the bottom bar are dimmed under a sheet the hosts screen raised.</summary>
public bool ShowsShellStrip public static readonly StyledProperty<bool> ShowsSheetScrimProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsSheetScrim));
/// <inheritdoc cref="ShowsSheetScrimProperty" />
public bool ShowsSheetScrim
{ {
get => GetValue(ShowsShellStripProperty); get => GetValue(ShowsSheetScrimProperty);
private set => SetValue(ShowsShellStripProperty, value); private set => SetValue(ShowsSheetScrimProperty, value);
}
/// <inheritdoc cref="ShowsVaultTabsProperty" />
public bool ShowsVaultTabs
{
get => GetValue(ShowsVaultTabsProperty);
private set => SetValue(ShowsVaultTabsProperty, value);
} }
/// <summary> /// <summary>
@@ -243,6 +271,13 @@ internal sealed partial class PhoneShell : UserControl
/// Losing them on the keychain would be losing the only LOCK button on the surface. /// Losing them on the keychain would be losing the only LOCK button on the surface.
/// </para> /// </para>
/// <para> /// <para>
/// ◆ <b>Narrow, it is the VAULTS tab's header now, on all three of its screens.</b> The keychain and
/// snippets moved from behind SETTINGS to the vault's own switch (see
/// <see cref="MainWindowViewModel.IsVaultSurface"/>), so the header and the switch under it are drawn
/// over hosts, keychain and snippets alike, and those two screens' own headers stand down on a narrow
/// surface to make room.
/// </para>
/// <para>
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two /// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
/// more flags and two more inputs, both of them the vault's rather than the shell's — see /// more flags and two more inputs, both of them the vault's rather than the shell's — see
/// <see cref="vault"/>. While hosts are ticked the header stands down and /// <see cref="vault"/>. While hosts are ticked the header stands down and
@@ -272,9 +307,11 @@ internal sealed partial class PhoneShell : UserControl
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false }; var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header, // The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which // not the vault's switch, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to. // is the one control it needs and the one the system gesture already maps to.
var editing = vault?.IsEditing == true; // The keychain's editor page counts too: it is the host editor's shape and wants the same screen.
var editing = vault?.IsEditing == true
|| (vault?.IsEditingAVaultItem == true && shell?.IsKeychainShowing == true);
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by // Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that // navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
@@ -293,10 +330,17 @@ internal sealed partial class PhoneShell : UserControl
IsWide = wide; IsWide = wide;
ShowsRail = wide && (pages || connectPage) && !editing; ShowsRail = wide && (pages || connectPage) && !editing;
ShowsBottomBar = !wide && (pages || connectPage) && !editing; // ◆ And not while the software keyboard is up. The body shrinks to stay clear of the keyboard, so the
ShowsShellStrip = pages && !editing; // bar rode up on top of it and took 72 pixels from whatever was being typed into — the connect sheet,
// most often (audit M12). Every Android app with a bottom bar puts it away for the keyboard.
ShowsBottomBar = !wide && (pages || connectPage) && !editing && !keyboardOpen;
ShowsHostSelectionBar = pages && choosing && !editing; ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true); ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsVaultSurface == true);
ShowsVaultTabs = ShowsVaultHeader && !wide;
ShowsSheetScrim = pages
&& ((vault is not null && HasASheetUp(vault))
|| (shell is { Transfers.IsEntryMenuOpen: true }
&& (shell.IsTransfersShowing || shell.IsBucketsShowing)));
} }
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -452,10 +496,18 @@ internal sealed partial class PhoneShell : UserControl
DispatcherPriority.Background); DispatcherPriority.Background);
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e) private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0); {
keyboardOpen = e.NewState is InputPaneState.Open;
ApplyKeyboardInset(keyboardOpen ? e.EndRect.Height : 0);
RefreshChrome();
}
private void ApplyKeyboardInset(IInputPane pane) private void ApplyKeyboardInset(IInputPane pane)
=> ApplyKeyboardInset(pane.State is InputPaneState.Open ? pane.OccludedRect.Height : 0); {
keyboardOpen = pane.State is InputPaneState.Open;
ApplyKeyboardInset(keyboardOpen ? pane.OccludedRect.Height : 0);
RefreshChrome();
}
/// <summary> /// <summary>
/// Holds the phone's whole interface clear of the software keyboard. /// Holds the phone's whole interface clear of the software keyboard.
@@ -568,7 +620,7 @@ internal sealed partial class PhoneShell : UserControl
/// application opens on, which is what every other Android app does. /// application opens on, which is what every other Android app does.
/// </para> /// </para>
/// <para> /// <para>
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's first case /// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's second case
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself, /// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself,
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user /// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet /// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
@@ -579,7 +631,7 @@ internal sealed partial class PhoneShell : UserControl
/// <para> /// <para>
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now /// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way /// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the /// off it other than the scrim and CANCEL. The forwards sheet, raised from the same bar, shares the guard. It is checked before the terminal is dismissed for the
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a /// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
/// menu would take two, neither of them the one being looked at. /// menu would take two, neither of them the one being looked at.
/// </para> /// </para>
@@ -598,13 +650,20 @@ internal sealed partial class PhoneShell : UserControl
return; return;
} }
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone // The connect menu or the forwards sheet, which are raised from the terminal's own bar and are the
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the // topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer,
// terminal because a gesture that dismissed the surface underneath a menu would close two things at // and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet
// once — and the one the user was looking at would not be either of them. // would close two things at once — and the one the user was looking at would not be either of them.
if (current.IsConnectSheetOpen) if (TryCloseATerminalSheet(current))
{ {
current.CloseConnectSheetCommand.Execute(null); e.Handled = true;
return;
}
// The files screen's actions sheet, raised from its selection bar.
if (current.Transfers.IsEntryMenuOpen)
{
current.Transfers.IsEntryMenuOpen = false;
e.Handled = true; e.Handled = true;
return; return;
} }
@@ -615,6 +674,16 @@ internal sealed partial class PhoneShell : UserControl
return; return;
} }
// The Connections prompt's password line, which back steps off onto the name it asked about — the
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
// surface would throw the name away along with the question.
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
{
current.CancelPromptCommand.Execute(null);
e.Handled = true;
return;
}
if (!current.IsShowingPages) if (!current.IsShowingPages)
{ {
current.ShowScreenCommand.Execute(current.Screen); current.ShowScreenCommand.Execute(current.Screen);
@@ -622,28 +691,106 @@ internal sealed partial class PhoneShell : UserControl
return; return;
} }
if (TryGoUp(current))
{
e.Handled = true;
}
}
/// <summary>Moves one level up the screens, for the back gesture.</summary>
/// <returns>Whether it moved, and so whether back has been spent.</returns>
/// <remarks>See <see cref="OnBackRequested"/>, whose last step this is.</remarks>
private bool TryGoUp(MainWindowViewModel current)
{
switch (current.Screen) switch (current.Screen)
{ {
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers // ◆ The vault's other two screens go back to its hosts rather than to the hub, since the redesign
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults // moved them under VAULTS: the switch at the top of the vault is where they were reached from, and
or ShellScreen.Keychain: // HOSTS is where that switch starts.
case ShellScreen.Keychain or ShellScreen.Snippets:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
return true;
case ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults:
// ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and // ◆ The hub, or Hosts where there is no hub. A wide surface draws the rail instead and
// never draws SETTINGS, so backing out to it would land on a screen with no way off it but // never draws SETTINGS, so backing out to it would land on a screen with no way off it but
// a second back — and the arrows in these screens' own headers are hidden there for the // a second back — and the arrows in these screens' own headers are hidden there for the
// same reason. Home is where back goes when the thing you came from is not on the surface. // same reason. Home is where back goes when the thing you came from is not on the surface.
current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More); current.ShowScreenCommand.Execute(IsWide ? ShellScreen.Hosts : ShellScreen.More);
e.Handled = true; return true;
break;
case ShellScreen.More: case ShellScreen.More:
current.ShowScreenCommand.Execute(ShellScreen.Hosts); current.ShowScreenCommand.Execute(ShellScreen.Hosts);
e.Handled = true; return true;
break;
default: default:
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app. // Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
break; return false;
}
}
/// <summary>
/// Lowers whatever is up over the terminal: the customise sheet, the quick-access keyboard, the connect
/// menu or the forwards sheet.
/// </summary>
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
/// <remarks>
/// The three sheets are raised from the terminal's bar or its keys and the scrim under each covers both,
/// so at most one is ever open. The quick-access keyboard comes second because it is the one that can be
/// up with nothing over it — opening the customise sheet lowers it, so the two never compete either.
/// </remarks>
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
// The customise sheet, raised from the quick-access keyboard. DONE is the only other way off it, and
// every change on it has already been saved, so back loses nothing.
if (current.Keyboard.IsCustomizing)
{
current.Keyboard.CloseCustomizeCommand.Execute(null);
return true;
}
// The quick-access keyboard stands where the software keyboard stands, and back puts the software
// keyboard away without leaving the terminal — so it does the same for this. Only on the terminal
// surface: the flag outlives a trip to another screen, where there is no panel to lower.
if (current.Keyboard.IsQuickAccessOpen && current.IsTerminalShowing)
{
current.Keyboard.CloseQuickAccess();
return true;
}
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
return true;
}
if (current.IsForwardsSheetOpen)
{
current.CloseForwardsSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary>Whether one of the hosts screen's four sheets is up.</summary>
/// <remarks>The same four <see cref="TryLowerASheet"/> lowers, in the same terms.</remarks>
private static bool HasASheetUp(VaultViewModel vault) =>
vault.IsHostActionSheetOpen || vault.IsAskingForConnectPassword || vault.IsAddSheetOpen
|| vault.GroupSheet is not null || vault.IsKeychainAddSheetOpen;
/// <summary>A tap on the scrim over the header or the bar: lowers the sheet, as its own scrim does.</summary>
private void OnChromeScrimTapped(object? sender, RoutedEventArgs e)
{
if (shell is { Transfers.IsEntryMenuOpen: true })
{
shell.Transfers.IsEntryMenuOpen = false;
}
else if (shell?.Vault is { } open)
{
TryLowerASheet(open);
} }
} }
@@ -712,6 +859,12 @@ internal sealed partial class PhoneShell : UserControl
return true; return true;
} }
if (vault.IsKeychainAddSheetOpen)
{
vault.CloseKeychainAddSheetCommand.Execute(null);
return true;
}
if (vault.GroupSheet is not null) if (vault.GroupSheet is not null)
{ {
vault.CloseGroupSheetCommand.Execute(null); vault.CloseGroupSheetCommand.Execute(null);
@@ -749,6 +902,11 @@ internal sealed partial class PhoneShell : UserControl
return true; return true;
} }
if (KeychainScreen.TryCancelAnEditor(vault))
{
return true;
}
if (vault.IsEditingGroup) if (vault.IsEditingGroup)
{ {
vault.CancelGroupEditCommand.Execute(null); vault.CancelGroupEditCommand.Execute(null);
@@ -42,7 +42,7 @@
<!-- The lock screen draws the same ring beside its own fingerprint button. --> <!-- The lock screen draws the same ring beside its own fingerprint button. -->
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5" <Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Unlock with a fingerprint" /> <TextBlock FontSize="14.5" FontWeight="SemiBold" Foreground="{StaticResource Text}" Text="Unlock with a fingerprint" />
</StackPanel> </StackPanel>
<TextBlock Classes="body" <TextBlock Classes="body"
@@ -0,0 +1,325 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:kb="using:DodoSSH.Client.Shell.Keyboard"
xmlns:views="using:DodoSSH.Client.Android.Views"
x:Class="DodoSSH.Client.Android.Views.QuickAccessKeyboard"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource DeepChrome}">
<!--
The quick-access keyboard: every key, snippet and command from history, and the terminal's appearance,
one tap from the prompt.
One control in two places. On a phone it takes the software keyboard's place under the terminal, at the
keyboard's own height so the bar above it does not move; on a wide screen it is a panel beside the
terminal that stays up while the software keyboard comes and goes. Same tabs, same rows, same view model
either way — see TerminalScreen for which one is drawn when.
It reopens to the tab last used, which is a setting rather than a field on this control: the tab is the
one somebody reaches for on this device, and the next launch should know it too. See
TerminalKeyboardViewModel.SelectedTab.
-->
<UserControl.Styles>
<!--
◆ NOTHING HERE TAKES FOCUS, FOR THE REASON THE BAR'S KEYS DO NOT.
This panel is an extension of the keyboard, not a place for the keyboard to go. On a wide screen the
software keyboard can be up at the same time, and an ordinary button would take Avalonia's focus off
TerminalInputSurface on tap — tearing down the input connection and dropping the keyboard every time a
key in this panel was used. On a phone the keyboard is down while this is up, but the focus it would take
is the focus that brings the keyboard straight back when the terminal is tapped. One rule for the whole
control, so a button added to it later cannot forget.
-->
<Style Selector="Button">
<Setter Property="Focusable" Value="False" />
</Style>
<!-- The tab strip's buttons: an icon square, lit when it is the tab showing. -->
<Style Selector="Button.qtab">
<Setter Property="Width" Value="48" />
<Setter Property="Height" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.qtab:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Button.qtab.selected">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.qtab.selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentSoft}" />
</Style>
<!-- A group's caption over its keys. -->
<Style Selector="TextBlock.groupcaption">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="4,10,0,4" />
</Style>
<!-- Keys in a panel are a little shorter than the bar's: there are many more of them, all reachable. -->
<Style Selector="Button.hotkey.panelkey">
<Setter Property="Height" Value="38" />
<Setter Property="MinHeight" Value="38" />
<Setter Property="Margin" Value="3" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<!-- ============ the tabs ============ -->
<!--
Icons rather than words, as the keys tab's own grid is: four words at 360dp leave no room for the
keyboard button at the end, and each glyph is the one every phone uses for the thing — a grid of keys,
braces for snippets, a clock for history, a palette for appearance. Each carries its name for
accessibility, which is where a word belongs when there is no room to print one.
-->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="8,6,8,2">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4">
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsKeysTab}" Content="&#xE5C3;"
AutomationProperties.Name="Keys"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Keys}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsSnippetsTab}" Content="&#xE86F;"
AutomationProperties.Name="Snippets"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Snippets}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsHistoryTab}" Content="&#xE889;"
AutomationProperties.Name="History"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.History}" />
<Button Classes="qtab" Classes.selected="{Binding Keyboard.IsAppearanceTab}" Content="&#xE40A;"
AutomationProperties.Name="Appearance"
Command="{Binding Keyboard.SelectTabCommand}"
CommandParameter="{x:Static vm:QuickAccessTab.Appearance}" />
</StackPanel>
<!--
Back to the phone's own keyboard. The one control here that is not a tab, so it sits apart behind a
hairline: it does not change what this panel shows, it puts the panel away.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
<Border Width="1" Height="22" Background="{StaticResource Border}" VerticalAlignment="Center" />
<Button Classes="qtab" Content="&#xE312;" AutomationProperties.Name="Show the keyboard"
Click="OnKeyboardRequested" />
</StackPanel>
</Grid>
<Panel Grid.Row="1">
<!-- ============ KEYS ============ -->
<ScrollViewer IsVisible="{Binding Keyboard.IsKeysTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12">
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center"
Command="{Binding Keyboard.OpenCustomizeCommand}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE429;"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="10" Text="CUSTOMIZE" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The bar's own keys, first, when the bar is not drawn — which is a wide screen with this panel
open beside the terminal. A key somebody put on their bar is one they want within reach, and
hiding the bar must not hide it.
-->
<StackPanel IsVisible="{Binding $parent[views:QuickAccessKeyboard].ShowsBarGroup}">
<TextBlock Classes="groupcaption" Text="YOUR BAR" />
<ItemsControl ItemsSource="{Binding Keyboard.BarRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyRowViewModel">
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<ItemsControl ItemsSource="{Binding Keyboard.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyGroupViewModel">
<StackPanel>
<TextBlock Classes="groupcaption" Text="{Binding Title}" />
<!--
Four across, which at 360dp is an 84dp key — wide enough for "shift+tab" and "ctrl+B %"
in the mono face without shrinking it, and still four keys a thumb can tell apart.
-->
<ItemsControl ItemsSource="{Binding Keys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="4" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:KeyButtonViewModel">
<Button Classes="hotkey panelkey" Classes.latched="{Binding IsLatched}"
Content="{Binding Label}" AutomationProperties.Name="{Binding Hint}"
Command="{Binding PressCommand}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<!-- ============ SNIPPETS ============ -->
<!--
The keychain's snippets, through the shell's own InsertSnippet — the command the desktop sidebar's
rows run, which carries the snippets screen's whole safety story: typed rather than pasted for one
line, and no Enter unless the snippet was saved as one that runs. A second insert path here would be
a second place that story could go stale. The ▶ says which ones run.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsSnippetsTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12" Spacing="2">
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible, FallbackValue={x:Null}}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="row" MinHeight="48" Padding="12,6"
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail code" FontSize="10.5" Text="{Binding Preview}"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="label" FontSize="9" Text="▶ RUNS" Margin="10,0,0,0"
Foreground="{StaticResource WarnText}" VerticalAlignment="Center"
IsVisible="{Binding RunsOnInsert}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
Text="Tap a snippet to type it at the prompt. Snippets live in the keychain, where they are added and edited." />
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
Command="{Binding ShowScreenCommand}" CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<TextBlock Classes="label" FontSize="9" Text="MANAGE SNIPPETS" />
</Button>
</StackPanel>
</ScrollViewer>
<!-- ============ HISTORY ============ -->
<!--
What was typed on this phone and echoed back by the remote — never a line typed at a password prompt,
which echoes nothing. Kept in memory only and emptied by LOCK; the sentence under the list says so,
because a list of commands is exactly the kind of thing somebody should know the lifetime of. See
TypedCommandHistory for the whole of what is and is not kept.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsHistoryTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="8,4,8,12" Spacing="2">
<ItemsControl ItemsSource="{Binding Keyboard.History}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="kb:HistoryEntry">
<Button Classes="row" MinHeight="44" Padding="12,6"
Command="{Binding $parent[views:QuickAccessKeyboard].((vm:MainWindowViewModel)DataContext).Keyboard.InsertHistoryCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding Command}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="detail code" FontSize="10" Text="{Binding Host}"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
IsVisible="{Binding !Keyboard.HasHistory}"
Text="Commands you type here appear in this list. Only what the remote echoes back is kept, so nothing typed at a password prompt ever is." />
<TextBlock Classes="body" Margin="4,8,4,0" TextWrapping="Wrap"
Text="Tap a command to type it at the prompt without running it. The list is kept in memory only, and locking the keychain clears it." />
<Button Classes="row" MinHeight="40" HorizontalContentAlignment="Center" Margin="0,6,0,0"
IsVisible="{Binding Keyboard.HasHistory}"
Command="{Binding Keyboard.ClearHistoryCommand}">
<TextBlock Classes="label" FontSize="9" Text="CLEAR HISTORY" />
</Button>
</StackPanel>
</ScrollViewer>
<!-- ============ APPEARANCE ============ -->
<!--
The text size, which used to ride at the end of the arrow row as A− and A+. A phone cannot press
Ctrl+plus, so these are still buttons; they moved here because the bar is now somebody's own and two
fixed cells at its end were cells they could not have. Disabled at the ends rather than clamping
silently, for the reason they always were: a button that accepts taps and does nothing reads as the
terminal having stopped responding.
And the cursor gesture's switch, for somebody whose long press means something else to them.
-->
<ScrollViewer IsVisible="{Binding Keyboard.IsAppearanceTab}" HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="12,6,12,12" Spacing="8">
<TextBlock Classes="groupcaption" Text="TEXT SIZE" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0" Classes="hotkey" Width="56" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}">
<TextBlock Classes="mono" FontSize="13" Text="A−" />
</Button>
<TextBlock Grid.Column="1" Classes="mono" FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"
Text="{Binding TerminalFontSize, StringFormat='{}{0} px'}" />
<Button Grid.Column="2" Classes="hotkey" Width="56" Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}">
<TextBlock Classes="mono" FontSize="15" Text="A+" />
</Button>
<Button Grid.Column="3" Classes="row" MinHeight="40" Padding="12,0" Margin="8,0,0,0"
Command="{Binding ResetTerminalFontCommand}">
<TextBlock Classes="label" FontSize="9" Text="RESET" />
</Button>
</Grid>
<TextBlock Classes="groupcaption" Text="CURSOR GESTURE" Margin="0,10,0,0" />
<Button Classes="row" MinHeight="52" Padding="12,8" Command="{Binding Keyboard.ToggleCursorGesturesCommand}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Classes="mono" FontSize="12.5" FontWeight="SemiBold" Text="Long-press and drag" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="Moves the cursor or walks through history. Drag further to go faster." />
</StackPanel>
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="ON" Margin="10,0,0,0"
Foreground="{StaticResource Live}" VerticalAlignment="Center"
IsVisible="{Binding Keyboard.CursorGestures}" />
<TextBlock Grid.Column="1" Classes="label" FontSize="9.5" Text="OFF" Margin="10,0,0,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
IsVisible="{Binding !Keyboard.CursorGestures}" />
</Grid>
</Button>
</StackPanel>
</ScrollViewer>
</Panel>
</Grid>
</UserControl>
Loaded 100 of 345 files, more files were not shown because too many files have changed in this diff. Show more